diff --git a/backend/api.yaml b/backend/api.yaml index 0456733a..8b3f9ef2 100644 --- a/backend/api.yaml +++ b/backend/api.yaml @@ -817,6 +817,241 @@ paths: example: Not logged in. '403': description: User is not an organisation admin. + content: + application/json: + schema: + properties: + error: + type: string + example: Unauthorized + + /campaign/{id}/role: + post: + operationId: createRole + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + description: Creates a new role in a campaign. + tags: + - Campaign + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + example: Chief Mouser + description: + type: string + required: False + example: Larry the cat is dead, now we need someone else to handle the rat issues at 10th Downing st. + min_available: + type: int32 + example: 1 + max_available: + type: int32 + example: 3 + finalised: + type: boolean + description: Whether this role has been finalised (e.g. max avaliable number) + example: False + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully created organisation. + '403': + description: User is not a Campaign Admin. + content: + application/json: + schema: + properties: + error: + type: string + example: Unauthorized + + /campaign/{id}/roles: + get: + operationId: getRolesByCampaignId + parameters: + - name: id + in: path + description: Campaign ID + required: true + schema: + type: integer + format: int64 + description: Returns info about all roles in a campaign + tags: + - Campaign + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + campaigns: + type: array + items: + type: object + properties: + name: + type: string + example: Chief Mouser + description: + type: string + example: Larry the cat gone missing! now we need someone else to handle the rat issues at 10th Downing st. + min_available: + type: int32 + example: 1 + max_available: + type: int32 + example: 3 + finalised: + type: boolean + description: Whether this role has been finalised (e.g. max avaliable number) + example: False + /role/{id}: + get: + operationId: getRoleById + parameters: + - name: id + in: path + description: Role ID + required: true + schema: + type: integer + format: int32 + description: Returns info about specified role. + tags: + - Role + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + name: + type: string + example: Chief Mouser + description: + type: string + example: Larry the cat gone missing! now we need someone else to handle the rat issues at 10th Downing st. + min_available: + type: int32 + example: 1 + max_available: + type: int32 + example: 3 + finalised: + type: boolean + description: Whether this role has been finalised (e.g. max avaliable number) + example: False + '401': + description: Not logged in. + content: + application/json: + schema: + properties: + error: + type: string + example: Not logged in. + + put: + operationId: updateRoleById + parameters: + - name: id + in: path + description: Role ID + required: true + schema: + type: integer + format: int32 + description: Update a role given the role id. + tags: + - Role + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + example: Chief Whip + description: + type: string + required: False + example: Put a bit of stick about! + min_available: + type: int32 + example: 1 + max_available: + type: int32 + example: 3 + finalised: + type: boolean + description: Whether this role has been finalised (e.g. max avaliable number) + example: true + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully update organisation. + '403': + description: User is not a Campaign Admin. + content: + application/json: + schema: + properties: + error: + type: string + example: Unauthorized + + delete: + operationId: deleteRoleById + parameters: + - name: id + in: path + description: Role ID + required: true + schema: + type: integer + format: int32 + description: Deletes specified role. + tags: + - Role + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + message: + type: string + example: Successfully deleted role. + '403': + description: User is not an admin of role's Campaign. content: application/json: schema: diff --git a/backend/migrations/20240406024211_create_organisations.sql b/backend/migrations/20240406024211_create_organisations.sql index 8c2326fb..24f2c18d 100644 --- a/backend/migrations/20240406024211_create_organisations.sql +++ b/backend/migrations/20240406024211_create_organisations.sql @@ -9,7 +9,7 @@ CREATE TABLE organisations ( CREATE TYPE organisation_role AS ENUM ('User', 'Admin'); CREATE TABLE organisation_members ( - id SERIAL PRIMARY KEY, + id BIGSERIAL PRIMARY KEY, organisation_id BIGINT NOT NULL, user_id BIGINT NOT NULL, role organisation_role DEFAULT 'User' NOT NULL, diff --git a/backend/migrations/20240406025537_create_campaigns.sql b/backend/migrations/20240406025537_create_campaigns.sql index a23d0026..ebc1311c 100644 --- a/backend/migrations/20240406025537_create_campaigns.sql +++ b/backend/migrations/20240406025537_create_campaigns.sql @@ -16,13 +16,13 @@ CREATE TABLE campaigns ( ); CREATE TABLE campaign_roles ( - id SERIAL PRIMARY KEY, + id BIGINT PRIMARY KEY, campaign_id BIGINT NOT NULL, name TEXT NOT NULL, description TEXT, - min_available INTEGER, - max_available INTEGER, - finalised BOOLEAN, + min_available INTEGER NOT NULL, + max_available INTEGER NOT NULL, + finalised BOOLEAN NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_campaign_roles_campaign diff --git a/backend/migrations/20240406031400_create_questions.sql b/backend/migrations/20240406031400_create_questions.sql index 050d07fe..8e7136af 100644 --- a/backend/migrations/20240406031400_create_questions.sql +++ b/backend/migrations/20240406031400_create_questions.sql @@ -17,7 +17,7 @@ CREATE TABLE questions ( ); CREATE TABLE multi_option_question_options ( - id SERIAL PRIMARY KEY, + id BIGSERIAL PRIMARY KEY, text TEXT NOT NULL, question_id INTEGER NOT NULL, CONSTRAINT FK_multi_option_question_options_questions diff --git a/backend/migrations/20240406031915_create_applications.sql b/backend/migrations/20240406031915_create_applications.sql index 767abb92..b62d6c1a 100644 --- a/backend/migrations/20240406031915_create_applications.sql +++ b/backend/migrations/20240406031915_create_applications.sql @@ -21,7 +21,7 @@ CREATE TABLE applications ( ); CREATE TABLE application_roles ( - id SERIAL PRIMARY KEY, + id BIGSERIAL PRIMARY KEY, application_id INTEGER NOT NULL, campaign_role_id INTEGER NOT NULL, CONSTRAINT FK_application_roles_applications @@ -40,7 +40,7 @@ CREATE INDEX IDX_application_roles_applications on application_roles (applicatio CREATE INDEX IDX_application_roles_campaign_roles on application_roles (campaign_role_id); CREATE TABLE answers ( - id SERIAL PRIMARY KEY, + id BIGSERIAL PRIMARY KEY, application_id BIGINT NOT NULL, question_id BIGINT NOT NULL, CONSTRAINT FK_answers_applications @@ -59,7 +59,7 @@ CREATE INDEX IDX_answers_applications on answers (application_id); CREATE INDEX IDX_answers_questions on answers (question_id); CREATE TABLE short_answer_answers ( - id SERIAL PRIMARY KEY, + id BIGSERIAL PRIMARY KEY, text TEXT NOT NULL, answer_id INTEGER NOT NULL, CONSTRAINT FK_short_answer_answers_answers @@ -72,7 +72,7 @@ CREATE TABLE short_answer_answers ( CREATE INDEX IDX_short_answer_answers_answers on short_answer_answers (answer_id); CREATE TABLE multi_option_answer_options ( - id SERIAL PRIMARY KEY, + id BIGSERIAL PRIMARY KEY, option_id BIGINT NOT NULL, answer_id INTEGER NOT NULL, CONSTRAINT FK_multi_option_answer_options_question_options @@ -91,7 +91,7 @@ CREATE INDEX IDX_multi_option_answer_options_question_options on multi_option_an CREATE INDEX IDX_multi_option_answer_options_answers on multi_option_answer_options (answer_id); CREATE TABLE application_ratings ( - id SERIAL PRIMARY KEY, + id BIGSERIAL PRIMARY KEY, application_id BIGINT NOT NULL, rater_id BIGINT NOT NULL, rating INTEGER NOT NULL, diff --git a/backend/server/src/handler/campaign.rs b/backend/server/src/handler/campaign.rs index a9a51776..48e15286 100644 --- a/backend/server/src/handler/campaign.rs +++ b/backend/server/src/handler/campaign.rs @@ -4,6 +4,7 @@ use crate::models::auth::AuthUser; use crate::models::auth::CampaignAdmin; use crate::models::campaign::Campaign; use crate::models::error::ChaosError; +use crate::models::role::{Role, RoleUpdate}; use axum::extract::{Json, Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; @@ -54,4 +55,24 @@ impl CampaignHandler { Campaign::delete(id, &state.db).await?; Ok((StatusCode::OK, "Successfully deleted campaign")) } + + pub async fn create_role( + State(state): State, + Path(id): Path, + _admin: CampaignAdmin, + Json(data): Json, + ) -> Result { + Role::create(id, data, &state.db, state.snowflake_generator).await?; + Ok((StatusCode::OK, "Successfully created role")) + } + + pub async fn get_roles( + State(state): State, + Path(id): Path, + _user: AuthUser, + ) -> Result { + let roles = Role::get_all_in_campaign(id, &state.db).await?; + + Ok((StatusCode::OK, Json(roles))) + } } diff --git a/backend/server/src/handler/mod.rs b/backend/server/src/handler/mod.rs index 43f0276e..732f52db 100644 --- a/backend/server/src/handler/mod.rs +++ b/backend/server/src/handler/mod.rs @@ -1,3 +1,4 @@ pub mod auth; pub mod campaign; pub mod organisation; +pub mod role; diff --git a/backend/server/src/handler/role.rs b/backend/server/src/handler/role.rs new file mode 100644 index 00000000..ff569cc6 --- /dev/null +++ b/backend/server/src/handler/role.rs @@ -0,0 +1,39 @@ +use crate::models::app::AppState; +use crate::models::auth::{AuthUser, RoleAdmin}; +use crate::models::error::ChaosError; +use crate::models::role::{Role, RoleUpdate}; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +pub struct RoleHandler; + +impl RoleHandler { + pub async fn get( + State(state): State, + Path(id): Path, + _user: AuthUser, + ) -> Result { + let role = Role::get(id, &state.db).await?; + Ok((StatusCode::OK, Json(role))) + } + + pub async fn delete( + State(state): State, + Path(id): Path, + _admin: RoleAdmin, + ) -> Result { + Role::delete(id, &state.db).await?; + Ok((StatusCode::OK, "Successfully deleted role")) + } + + pub async fn update( + State(state): State, + Path(id): Path, + _admin: RoleAdmin, + Json(data): Json, + ) -> Result { + Role::update(id, data, &state.db).await?; + Ok((StatusCode::OK, "Successfully updated role")) + } +} diff --git a/backend/server/src/main.rs b/backend/server/src/main.rs index faa04639..7b27e12b 100644 --- a/backend/server/src/main.rs +++ b/backend/server/src/main.rs @@ -5,6 +5,7 @@ use crate::models::storage::Storage; use anyhow::Result; use axum::routing::{get, patch, post}; use axum::Router; +use handler::role::RoleHandler; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use models::app::AppState; use snowflake::SnowflakeIdGenerator; @@ -90,6 +91,20 @@ async fn main() -> Result<()> { .put(OrganisationHandler::update_admins) .delete(OrganisationHandler::remove_admin), ) + .route( + "/api/v1/campaign/:id/role", + post(CampaignHandler::create_role), + ) + .route( + "/api/v1/campaign/:id/roles", + get(CampaignHandler::get_roles), + ) + .route( + "/api/v1/role/:id", + get(RoleHandler::get) + .put(RoleHandler::update) + .delete(RoleHandler::delete), + ) .route( "/api/v1/campaign/:id", get(CampaignHandler::get) diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index 4d89dedd..99ef8c85 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -4,6 +4,7 @@ use crate::service::auth::is_super_user; use crate::service::campaign::user_is_campaign_admin; use crate::service::jwt::decode_auth_token; use crate::service::organisation::user_is_organisation_admin; +use crate::service::role::user_is_role_admin; use axum::extract::{FromRef, FromRequestParts, Path}; use axum::http::request::Parts; use axum::response::{IntoResponse, Redirect, Response}; @@ -185,3 +186,43 @@ where Ok(CampaignAdmin { user_id }) } } + +pub struct RoleAdmin { + pub user_id: i64, +} + +#[async_trait] +impl FromRequestParts for RoleAdmin +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ChaosError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let decoding_key = &app_state.decoding_key; + let jwt_validator = &app_state.jwt_validator; + let TypedHeader(cookies) = parts + .extract::>() + .await + .map_err(|_| ChaosError::NotLoggedIn)?; + + let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; + + let claims = + decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; + + let pool = &app_state.db; + let user_id = claims.sub; + + let Path(campaign_id) = parts + .extract::>() + .await + .map_err(|_| ChaosError::BadRequest)?; + + user_is_role_admin(user_id, campaign_id, pool).await?; + + Ok(RoleAdmin { user_id }) + } +} diff --git a/backend/server/src/models/mod.rs b/backend/server/src/models/mod.rs index e5a5edb3..76102f22 100644 --- a/backend/server/src/models/mod.rs +++ b/backend/server/src/models/mod.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod campaign; pub mod error; pub mod organisation; +pub mod role; pub mod storage; pub mod transaction; pub mod user; diff --git a/backend/server/src/models/role.rs b/backend/server/src/models/role.rs new file mode 100644 index 00000000..1d5386c7 --- /dev/null +++ b/backend/server/src/models/role.rs @@ -0,0 +1,140 @@ +use crate::models::error::ChaosError; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{FromRow, Pool, Postgres}; + +#[derive(Deserialize, Serialize, Clone, FromRow, Debug)] +pub struct Role { + pub id: i32, + pub campaign_id: i64, + pub name: Option, + pub description: String, + pub min_available: i32, + pub max_avaliable: i32, + pub finalised: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Deserialize, Serialize)] +pub struct RoleUpdate { + pub name: String, + pub description: Option, + pub min_available: i32, + pub max_avaliable: i32, + pub finalised: bool, +} + +#[derive(Deserialize, Serialize)] +pub struct RoleDetails { + pub name: String, + pub description: Option, + pub min_available: i32, + pub max_available: i32, + pub finalised: bool, +} + +impl Role { + pub async fn create( + campaign_id: i64, + role_data: RoleUpdate, + pool: &Pool, + mut snowflake_generator: SnowflakeIdGenerator, + ) -> Result<(), ChaosError> { + let id = snowflake_generator.generate(); + + sqlx::query!( + " + INSERT INTO campaign_roles (id, campaign_id, name, description, min_available, max_available, finalised) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ", + id, + campaign_id, + role_data.name, + role_data.description, + role_data.min_available, + role_data.max_avaliable, + role_data.finalised + ) + .fetch_one(pool) + .await?; + + Ok(()) + } + + pub async fn get(id: i64, pool: &Pool) -> Result { + let role = sqlx::query_as!( + RoleDetails, + " + SELECT name, description, min_available, max_available, finalised + FROM campaign_roles + WHERE id = $1 + ", + id + ) + .fetch_one(pool) + .await?; + + Ok(role) + } + + pub async fn delete(id: i64, pool: &Pool) -> Result<(), ChaosError> { + sqlx::query!( + " + DELETE FROM campaign_roles WHERE id = $1 + ", + id + ) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn update( + id: i64, + role_data: RoleUpdate, + pool: &Pool, + ) -> Result<(), ChaosError> { + sqlx::query!( + " + UPDATE campaign_roles + SET (name, description, min_available, max_available, finalised) = ($2, $3, $4, $5, $6) + WHERE id = $1; + ", + id, + role_data.name, + role_data.description, + role_data.min_available, + role_data.max_avaliable, + role_data.finalised + ) + .fetch_one(pool) + .await?; + + Ok(()) + } + + /* + Given a campaign id, return all existing roles in this campaign + */ + pub async fn get_all_in_campaign( + campaign_id: i64, + pool: &Pool, + ) -> Result, ChaosError> { + let roles = sqlx::query_as!( + RoleDetails, + " + SELECT name, description, min_available, max_available, finalised + FROM campaign_roles + WHERE campaign_id = $1 + ", + campaign_id + ) + .fetch_all(pool) + .await?; + + Ok(roles) + } +} diff --git a/backend/server/src/service/mod.rs b/backend/server/src/service/mod.rs index 20f4c993..b98f4e2a 100644 --- a/backend/server/src/service/mod.rs +++ b/backend/server/src/service/mod.rs @@ -3,3 +3,4 @@ pub mod campaign; pub mod jwt; pub mod oauth2; pub mod organisation; +pub mod role; diff --git a/backend/server/src/service/role.rs b/backend/server/src/service/role.rs new file mode 100644 index 00000000..52329869 --- /dev/null +++ b/backend/server/src/service/role.rs @@ -0,0 +1,34 @@ +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres}; + +pub async fn user_is_role_admin( + user_id: i64, + role_id: i64, + pool: &Pool, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM ( + SELECT c.organisation_id FROM campaign_roles r + JOIN campaigns c on r.campaign_id = c.id + WHERE r.id = $1 + ) cr + JOIN organisation_members m on cr.organisation_id = m.organisation_id + WHERE m.user_id = $2 AND m.role = 'Admin' + ) + ", + role_id, + user_id + ) + .fetch_one(pool) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_admin { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +}