diff --git a/backend/server/src/handler/mod.rs b/backend/server/src/handler/mod.rs index 05f8e0fa..ebe08c27 100644 --- a/backend/server/src/handler/mod.rs +++ b/backend/server/src/handler/mod.rs @@ -5,3 +5,4 @@ pub mod organisation; pub mod rating; pub mod role; pub mod application; +pub mod question; diff --git a/backend/server/src/handler/question.rs b/backend/server/src/handler/question.rs new file mode 100644 index 00000000..a2796da7 --- /dev/null +++ b/backend/server/src/handler/question.rs @@ -0,0 +1,94 @@ +use axum::extract::{Json, Path, State}; +use axum::response::IntoResponse; +use axum::http::StatusCode; +use serde_json::json; +use crate::models::app::AppState; +use crate::models::auth::{AuthUser, CampaignAdmin, QuestionAdmin}; +use crate::models::error::ChaosError; +use crate::models::question::{NewQuestion, Question}; +use crate::models::transaction::DBTransaction; + +pub struct QuestionHandler; + +impl QuestionHandler { + pub async fn create( + State(state): State, + Path(campaign_id): Path, + _admin: CampaignAdmin, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + let id = Question::create( + campaign_id, + data.title, + data.description, + data.common, + data.roles, + data.required, + data.question_data, + state.snowflake_generator, + &mut transaction.tx, + ).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(json!({"id": id})))) + } + + pub async fn get_all_by_campaign_and_role( + Path((campaign_id, role_id)): Path<(i64, i64)>, + _user: AuthUser, + mut transaction: DBTransaction<'_>, + ) -> Result { + let questions = Question::get_all_by_campaign_and_role( + campaign_id, role_id, &mut transaction.tx + ).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(questions))) + } + + pub async fn get_all_common_by_campaign( + Path(campaign_id): Path, + _user: AuthUser, + mut transaction: DBTransaction<'_>, + ) -> Result { + let questions = Question::get_all_common_by_campaign( + campaign_id, &mut transaction.tx, + ).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(questions))) + } + + pub async fn update( + State(state): State, + Path(question_id): Path, + _admin: QuestionAdmin, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + Question::update( + question_id, data.title, data.description, data.common, + data.roles, data.required, data.question_data, + &mut transaction.tx, state.snowflake_generator, + ).await?; + + Ok((StatusCode::OK, "Successfully updated question")) + } + + pub async fn delete( + Path(question_id): Path, + _admin: QuestionAdmin, + mut transaction: DBTransaction<'_>, + ) -> Result { + Question::delete( + question_id, + &mut transaction.tx, + ).await?; + + Ok((StatusCode::OK, "Successfully deleted question")) + } +} \ No newline at end of file diff --git a/backend/server/src/main.rs b/backend/server/src/main.rs index 33e97ccf..156e8f1a 100644 --- a/backend/server/src/main.rs +++ b/backend/server/src/main.rs @@ -5,7 +5,7 @@ use crate::handler::organisation::OrganisationHandler; use crate::handler::application::ApplicationHandler; use crate::models::storage::Storage; use anyhow::Result; -use axum::routing::{get, patch, post}; +use axum::routing::{get, patch, post, put}; use axum::Router; use handler::rating::RatingHandler; use handler::role::RoleHandler; @@ -14,6 +14,7 @@ use models::app::AppState; use snowflake::SnowflakeIdGenerator; use sqlx::postgres::PgPoolOptions; use std::env; +use crate::handler::question::QuestionHandler; mod handler; mod models; @@ -116,7 +117,7 @@ async fn main() -> Result<()> { ) .route( "/api/v1/:application_id/rating", - post(RatingHandler::create_rating), + post(RatingHandler::create), ) .route( "/api/v1/:application_id/ratings", @@ -126,6 +127,7 @@ async fn main() -> Result<()> { "/api/v1/campaign/:campaign_id/role", post(CampaignHandler::create_role), ) + .route("/api/v1/campaign/:campaign_id/role/:role_id/questions", get(QuestionHandler::get_all_by_campaign_and_role)) .route( "/api/v1/campaign/:campaign_id/roles", get(CampaignHandler::get_roles), @@ -151,6 +153,9 @@ async fn main() -> Result<()> { .delete(CampaignHandler::delete), ) .route("/api/v1/campaign", get(CampaignHandler::get_all)) + .route("/api/v1/campaign/:campaign_id/question", post(QuestionHandler::create)) + .route("/api/v1/campaign/:campaign_id/question/:id", put(QuestionHandler::update).delete(QuestionHandler::delete)) + .route("/api/v1/campaign/:campaign_id/questions/common", get(QuestionHandler::get_all_common_by_campaign)) .route( "/api/v1/campaign/:campaign_id/banner", patch(CampaignHandler::update_banner), diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index ced87703..61e49f13 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -17,6 +17,7 @@ use axum::response::{IntoResponse, Redirect, Response}; use axum::{async_trait, RequestPartsExt}; use axum_extra::{headers::Cookie, TypedHeader}; use serde::{Deserialize, Serialize}; +use crate::service::question::user_is_question_admin; // tells the web framework how to take the url query params they will have #[derive(Deserialize, Serialize)] @@ -455,3 +456,45 @@ where Ok(RatingCreator { user_id }) } } + +pub struct QuestionAdmin { + pub user_id: i64, +} + +#[async_trait] +impl FromRequestParts for QuestionAdmin +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 question_id = *parts + .extract::>>() + .await + .map_err(|_| ChaosError::BadRequest)? + .get("question_id") + .ok_or(ChaosError::BadRequest)?; + + user_is_question_admin(user_id, question_id, pool).await?; + + Ok(QuestionAdmin { user_id }) + } +} \ No newline at end of file diff --git a/backend/server/src/models/question.rs b/backend/server/src/models/question.rs index b36b9ae2..49bbe8ff 100644 --- a/backend/server/src/models/question.rs +++ b/backend/server/src/models/question.rs @@ -56,14 +56,14 @@ pub struct Question { #[derive(Deserialize)] pub struct NewQuestion { - title: String, - description: Option, - common: bool, - roles: Option>, - required: bool, + pub title: String, + pub description: Option, + pub common: bool, + pub roles: Option>, + pub required: bool, #[serde(flatten)] - question_data: QuestionData, + pub question_data: QuestionData, } #[derive(Deserialize, Serialize, sqlx::FromRow)] @@ -519,84 +519,6 @@ impl QuestionData { } } - pub async fn get_from_db(self, question_id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result { - match self { - Self::ShortAnswer => Ok(Self::ShortAnswer), - Self::MultiChoice(_) => { - let data_vec = sqlx::query_as!( - MultiOptionQuestionOption, - " - SELECT id, display_order, text FROM multi_option_question_options - WHERE question_id = $1 - ", - question_id - ) - .fetch_all(transaction.deref_mut()) - .await?; - - let data = MultiOptionData { - options: data_vec - }; - - Ok(Self::MultiChoice(data)) - }, - Self::MultiSelect(_) => { - let data_vec = sqlx::query_as!( - MultiOptionQuestionOption, - " - SELECT id, display_order, text FROM multi_option_question_options - WHERE question_id = $1 - ", - question_id - ) - .fetch_all(transaction.deref_mut()) - .await?; - - let data = MultiOptionData { - options: data_vec - }; - - Ok(Self::MultiSelect(data)) - } - Self::DropDown(_) => { - let data_vec = sqlx::query_as!( - MultiOptionQuestionOption, - " - SELECT id, display_order, text FROM multi_option_question_options - WHERE question_id = $1 - ", - question_id - ) - .fetch_all(transaction.deref_mut()) - .await?; - - let data = MultiOptionData { - options: data_vec - }; - - Ok(Self::DropDown(data)) - } - Self::Ranking(_) => { - let data_vec = sqlx::query_as!( - MultiOptionQuestionOption, - " - SELECT id, display_order, text FROM multi_option_question_options - WHERE question_id = $1 - ", - question_id - ) - .fetch_all(transaction.deref_mut()) - .await?; - - let data = MultiOptionData { - options: data_vec - }; - - Ok(Self::Ranking(data)) - } - } - } - pub async fn delete_from_db(self, question_id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result<(), ChaosError> { match self { Self::ShortAnswer => Ok(()), diff --git a/backend/server/src/service/mod.rs b/backend/server/src/service/mod.rs index cb8f72a4..1c4e50fc 100644 --- a/backend/server/src/service/mod.rs +++ b/backend/server/src/service/mod.rs @@ -6,3 +6,4 @@ pub mod organisation; pub mod rating; pub mod role; pub mod application; +pub mod question; diff --git a/backend/server/src/service/question.rs b/backend/server/src/service/question.rs new file mode 100644 index 00000000..6de469b2 --- /dev/null +++ b/backend/server/src/service/question.rs @@ -0,0 +1,32 @@ +use sqlx::{Pool, Postgres}; +use crate::models::error::ChaosError; + +pub async fn user_is_question_admin( + user_id: i64, + question_id: i64, + pool: &Pool, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 + FROM questions q + JOIN campaigns c on q.campaign_id = c.id + JOIN organisation_members om on c.organisation_id = om.organisation_id + WHERE q.id = $1 AND om.user_id = $2 AND om.role = 'Admin' + ) + ", + question_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(()) +} \ No newline at end of file