diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8788bb70..c2916e28 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -30,7 +30,11 @@ jobs: echo "GOOGLE_CLIENT_ID=test" >> backend/.env echo "GOOGLE_CLIENT_SECRET=test" >> backend/.env echo "GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback" >> backend/.env - echo "ROCKET_DATABASES='{}'" >> backend/.env + echo "S3_BUCKET_NAME=chaos-storage" >> backend/.env + echo "S3_ACCESS_KEY=test_access_key" >> backend/.env + echo "S3_SECRET_KEY=test_secret_key" >> backend/.env + echo "S3_ENDPOINT=https://chaos-storage.s3.ap-southeast-1.amazonaws.com" >> backend/.env + echo "S3_REGION_NAME=ap-southeast-1" >> backend/.env # selecting a toolchain either by action or manual `rustup` calls should happen # before the plugin, as it uses the current rustc version as its cache key - uses: actions-rs/toolchain@v1 diff --git a/backend/migrations/20240406023149_create_users.sql b/backend/migrations/20240406023149_create_users.sql index b820e2d2..94fa0440 100644 --- a/backend/migrations/20240406023149_create_users.sql +++ b/backend/migrations/20240406023149_create_users.sql @@ -14,4 +14,4 @@ CREATE TABLE users ( updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); -CREATE UNIQUE INDEX IDX_users_email_lower on users ((lower(email))); \ No newline at end of file +CREATE UNIQUE INDEX IDX_users_email_lower on users((lower(email))); \ No newline at end of file diff --git a/backend/migrations/20240406024211_create_organisations.sql b/backend/migrations/20240406024211_create_organisations.sql index 008777c1..ec67f190 100644 --- a/backend/migrations/20240406024211_create_organisations.sql +++ b/backend/migrations/20240406024211_create_organisations.sql @@ -21,4 +21,4 @@ CREATE TABLE organisation_members ( ); -CREATE INDEX IDX_organisation_admins_organisation on organisation_members (organisation_id); +CREATE INDEX IDX_organisation_admins_organisation on organisation_members(organisation_id); diff --git a/backend/migrations/20240406025537_create_campaigns.sql b/backend/migrations/20240406025537_create_campaigns.sql index 757a3e63..c300ee80 100644 --- a/backend/migrations/20240406025537_create_campaigns.sql +++ b/backend/migrations/20240406025537_create_campaigns.sql @@ -32,4 +32,4 @@ CREATE TABLE campaign_roles ( ON UPDATE CASCADE ); -CREATE INDEX IDX_campaign_roles_campaign on campaign_roles (campaign_id); \ No newline at end of file +CREATE INDEX IDX_campaign_roles_campaign on campaign_roles(campaign_id); \ No newline at end of file diff --git a/backend/migrations/20240406031400_create_questions.sql b/backend/migrations/20240406031400_create_questions.sql index 24f01d97..ec44dea0 100644 --- a/backend/migrations/20240406031400_create_questions.sql +++ b/backend/migrations/20240406031400_create_questions.sql @@ -5,7 +5,7 @@ CREATE TABLE questions ( title TEXT NOT NULL, description TEXT, common BOOLEAN NOT NULL, - required BOOLEAN, + required BOOLEAN NOT NULL, question_type question_type NOT NULL, campaign_id BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -27,6 +27,30 @@ CREATE TABLE multi_option_question_options ( REFERENCES questions(id) ON DELETE CASCADE ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED, + UNIQUE (question_id, display_order) ); -CREATE INDEX IDX_multi_option_question_options_questions on multi_option_question_options (question_id); \ No newline at end of file +CREATE INDEX IDX_multi_option_question_options_questions on multi_option_question_options(question_id); + +CREATE TABLE question_roles ( + id BIGSERIAL PRIMARY KEY, + question_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + CONSTRAINT FK_question_roles_questions + FOREIGN KEY(question_id) + REFERENCES questions(id) + ON DELETE CASCADE + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED, + CONSTRAINT FK_question_roles_roles + FOREIGN KEY(role_id) + REFERENCES campaign_roles(id) + ON DELETE CASCADE + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED, + UNIQUE (question_id, role_id) +); + +CREATE INDEX IDX_question_roles_questions on question_roles(question_id); +CREATE INDEX IDX_question_roles_roles on question_roles(role_id); \ No newline at end of file diff --git a/backend/migrations/20240406031915_create_applications.sql b/backend/migrations/20240406031915_create_applications.sql index 1adf0d9c..01ee6439 100644 --- a/backend/migrations/20240406031915_create_applications.sql +++ b/backend/migrations/20240406031915_create_applications.sql @@ -53,6 +53,7 @@ CREATE TABLE answers ( REFERENCES questions(id) ON DELETE CASCADE ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED ); CREATE INDEX IDX_answers_applications on answers (application_id); @@ -79,7 +80,8 @@ CREATE TABLE multi_option_answer_options ( FOREIGN KEY(option_id) REFERENCES multi_option_question_options(id) ON DELETE CASCADE - ON UPDATE CASCADE, + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED, CONSTRAINT FK_multi_option_answer_options_answers FOREIGN KEY(answer_id) REFERENCES answers(id) @@ -96,7 +98,8 @@ CREATE TABLE ranking_answer_rankings ( FOREIGN KEY(option_id) REFERENCES multi_option_question_options(id) ON DELETE CASCADE - ON UPDATE CASCADE, + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED, CONSTRAINT FK_ranking_answer_rankings_answers FOREIGN KEY(answer_id) REFERENCES answers(id) @@ -104,8 +107,8 @@ CREATE TABLE ranking_answer_rankings ( ON UPDATE CASCADE ); -CREATE INDEX IDX_multi_option_answer_options_question_options on multi_option_answer_options (option_id); -CREATE INDEX IDX_multi_option_answer_options_answers on multi_option_answer_options (answer_id); +CREATE INDEX IDX_multi_option_answer_options_question_options on multi_option_answer_options(option_id); +CREATE INDEX IDX_multi_option_answer_options_answers on multi_option_answer_options(answer_id); CREATE TABLE application_ratings ( id BIGINT PRIMARY KEY, @@ -127,5 +130,5 @@ CREATE TABLE application_ratings ( ON UPDATE CASCADE ); -CREATE INDEX IDX_application_ratings_applications on application_ratings (application_id); -CREATE INDEX IDX_application_ratings_users on application_ratings (rater_id); \ No newline at end of file +CREATE INDEX IDX_application_ratings_applications on application_ratings(application_id); +CREATE INDEX IDX_application_ratings_users on application_ratings(rater_id); \ No newline at end of file diff --git a/backend/server/src/handler/answer.rs b/backend/server/src/handler/answer.rs new file mode 100644 index 00000000..34f3e0ea --- /dev/null +++ b/backend/server/src/handler/answer.rs @@ -0,0 +1,87 @@ +use crate::models::answer::{Answer, NewAnswer}; +use crate::models::app::AppState; +use crate::models::auth::{AnswerOwner, ApplicationOwner, AuthUser}; +use crate::models::error::ChaosError; +use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde_json::json; + +pub struct AnswerHandler; + +impl AnswerHandler { + pub async fn create( + State(state): State, + Path(path): Path, + user: AuthUser, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + let id = Answer::create( + user.user_id, + data.application_id, + data.question_id, + data.answer_data, + state.snowflake_generator, + &mut transaction.tx, + ) + .await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(json!({"id": id})))) + } + + pub async fn get_all_common_by_application( + Path(application_id): Path, + _owner: ApplicationOwner, + mut transaction: DBTransaction<'_>, + ) -> Result { + let answers = + Answer::get_all_common_by_application(application_id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(answers))) + } + + pub async fn get_all_by_application_and_role( + Path((application_id, role_id)): Path<(i64, i64)>, + _owner: ApplicationOwner, + mut transaction: DBTransaction<'_>, + ) -> Result { + let answers = + Answer::get_all_by_application_and_role(application_id, role_id, &mut transaction.tx) + .await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(answers))) + } + + pub async fn update( + Path(answer_id): Path, + _owner: AnswerOwner, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + Answer::update(answer_id, data.answer_data, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully updated answer")) + } + + pub async fn delete( + Path(answer_id): Path, + _owner: AnswerOwner, + mut transaction: DBTransaction<'_>, + ) -> Result { + Answer::delete(answer_id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully deleted answer")) + } +} diff --git a/backend/server/src/handler/application.rs b/backend/server/src/handler/application.rs index 15d75e41..8f6c2ecf 100644 --- a/backend/server/src/handler/application.rs +++ b/backend/server/src/handler/application.rs @@ -1,6 +1,6 @@ use crate::models::app::AppState; use crate::models::application::{Application, ApplicationStatus}; -use crate::models::auth::{AuthUser, ApplicationAdmin}; +use crate::models::auth::{ApplicationAdmin, AuthUser}; use crate::models::error::ChaosError; use crate::models::transaction::DBTransaction; use axum::extract::{Json, Path, State}; @@ -48,4 +48,4 @@ impl ApplicationHandler { transaction.tx.commit().await?; Ok((StatusCode::OK, Json(applications))) } -} \ No newline at end of file +} diff --git a/backend/server/src/handler/campaign.rs b/backend/server/src/handler/campaign.rs index f5a827d3..ed5b9e24 100644 --- a/backend/server/src/handler/campaign.rs +++ b/backend/server/src/handler/campaign.rs @@ -86,7 +86,14 @@ impl CampaignHandler { mut transaction: DBTransaction<'_>, Json(data): Json, ) -> Result { - Application::create(id, user.user_id, data, state.snowflake_generator, &mut transaction.tx).await?; + Application::create( + id, + user.user_id, + data, + state.snowflake_generator, + &mut transaction.tx, + ) + .await?; transaction.tx.commit().await?; Ok((StatusCode::OK, "Successfully created application")) } diff --git a/backend/server/src/handler/mod.rs b/backend/server/src/handler/mod.rs index c577af3e..73a94627 100644 --- a/backend/server/src/handler/mod.rs +++ b/backend/server/src/handler/mod.rs @@ -1,7 +1,9 @@ +pub mod answer; +pub mod application; pub mod auth; -pub mod user; pub mod campaign; pub mod organisation; -pub mod ratings; +pub mod question; +pub mod rating; pub mod role; -pub mod application; +pub mod user; diff --git a/backend/server/src/handler/organisation.rs b/backend/server/src/handler/organisation.rs index 78ec8bc6..f98b27b2 100644 --- a/backend/server/src/handler/organisation.rs +++ b/backend/server/src/handler/organisation.rs @@ -91,13 +91,14 @@ impl OrganisationHandler { } pub async fn remove_admin( - State(state): State, + mut transaction: DBTransaction<'_>, Path(id): Path, _super_user: SuperUser, Json(request_body): Json, ) -> Result { - Organisation::remove_admin(id, request_body.user_id, &state.db).await?; + Organisation::remove_admin(id, request_body.user_id, &mut transaction.tx).await?; + transaction.tx.commit().await?; Ok(( StatusCode::OK, "Successfully removed member from organisation", @@ -105,13 +106,14 @@ impl OrganisationHandler { } pub async fn remove_member( - State(state): State, + mut transaction: DBTransaction<'_>, Path(id): Path, _admin: OrganisationAdmin, Json(request_body): Json, ) -> Result { - Organisation::remove_member(id, request_body.user_id, &state.db).await?; + Organisation::remove_member(id, request_body.user_id, &mut transaction.tx).await?; + transaction.tx.commit().await?; Ok(( StatusCode::OK, "Successfully removed member from organisation", diff --git a/backend/server/src/handler/question.rs b/backend/server/src/handler/question.rs new file mode 100644 index 00000000..c425c96b --- /dev/null +++ b/backend/server/src/handler/question.rs @@ -0,0 +1,102 @@ +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; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde_json::json; + +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?; + + transaction.tx.commit().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?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully deleted question")) + } +} diff --git a/backend/server/src/handler/ratings.rs b/backend/server/src/handler/rating.rs similarity index 88% rename from backend/server/src/handler/ratings.rs rename to backend/server/src/handler/rating.rs index d8d6071f..2ec36960 100644 --- a/backend/server/src/handler/ratings.rs +++ b/backend/server/src/handler/rating.rs @@ -1,29 +1,29 @@ use crate::models::app::AppState; use crate::models::auth::{ ApplicationCreatorGivenApplicationId, ApplicationReviewerGivenApplicationId, - ApplicationReviewerGivenRatingId, RatingCreator, SuperUser, + ApplicationReviewerGivenRatingId, RatingCreator, }; use crate::models::error::ChaosError; -use crate::models::ratings::{NewRating, Rating}; +use crate::models::rating::{NewRating, Rating}; use crate::models::transaction::DBTransaction; use axum::extract::{Json, Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; -pub struct RatingsHandler; +pub struct RatingHandler; -impl RatingsHandler { - // TODO: are all the user permissions as required? Who should be able to do what with ratings? - pub async fn create_rating( +impl RatingHandler { + pub async fn create( State(state): State, Path(application_id): Path, - _admin: ApplicationCreatorGivenApplicationId, + admin: ApplicationReviewerGivenApplicationId, mut transaction: DBTransaction<'_>, Json(new_rating): Json, ) -> Result { Rating::create( new_rating, application_id, + admin.user_id, state.snowflake_generator, &mut transaction.tx, ) diff --git a/backend/server/src/handler/role.rs b/backend/server/src/handler/role.rs index c1e9d2da..5bcb2b1f 100644 --- a/backend/server/src/handler/role.rs +++ b/backend/server/src/handler/role.rs @@ -3,8 +3,8 @@ use crate::models::application::Application; use crate::models::auth::{AuthUser, RoleAdmin}; use crate::models::error::ChaosError; use crate::models::role::{Role, RoleUpdate}; -use axum::extract::{Json, Path, State}; use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; diff --git a/backend/server/src/handler/user.rs b/backend/server/src/handler/user.rs index 4909ba91..be5445e1 100644 --- a/backend/server/src/handler/user.rs +++ b/backend/server/src/handler/user.rs @@ -1,10 +1,10 @@ use crate::models::app::AppState; use crate::models::auth::AuthUser; use crate::models::error::ChaosError; +use crate::models::user::{User, UserDegree, UserGender, UserName, UserPronouns, UserZid}; use axum::extract::{Json, State}; use axum::http::StatusCode; use axum::response::IntoResponse; -use crate::models::user::{User, UserDegree, UserGender, UserName, UserPronouns, UserZid}; pub struct UserHandler; @@ -16,14 +16,14 @@ impl UserHandler { let user = User::get(user.user_id, &state.db).await?; Ok((StatusCode::OK, Json(user))) } - + pub async fn update_name( State(state): State, user: AuthUser, Json(request_body): Json, ) -> Result { User::update_name(user.user_id, request_body.name, &state.db).await?; - + Ok((StatusCode::OK, "Updated username")) } @@ -46,17 +46,17 @@ impl UserHandler { Ok((StatusCode::OK, "Updated gender")) } - + pub async fn update_zid( State(state): State, user: AuthUser, Json(request_body): Json, ) -> Result { User::update_zid(user.user_id, request_body.zid, &state.db).await?; - + Ok((StatusCode::OK, "Updated zid")) } - + pub async fn update_degree( State(state): State, user: AuthUser, @@ -66,12 +66,10 @@ impl UserHandler { user.user_id, request_body.degree_name, request_body.degree_starting_year, - &state.db + &state.db, ) .await?; - + Ok((StatusCode::OK, "Updated user degree")) } } - - diff --git a/backend/server/src/main.rs b/backend/server/src/main.rs index 1acdfa51..a464dd2a 100644 --- a/backend/server/src/main.rs +++ b/backend/server/src/main.rs @@ -1,168 +1,15 @@ -use crate::handler::auth::google_callback; -use handler::user::UserHandler; -use crate::handler::campaign::CampaignHandler; -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::Router; -use handler::ratings::RatingsHandler; -use handler::role::RoleHandler; -use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use models::app::AppState; -use snowflake::SnowflakeIdGenerator; -use sqlx::postgres::PgPoolOptions; -use std::env; +use crate::models::app::app; +use crate::models::error::ChaosError; mod handler; mod models; mod service; #[tokio::main] -async fn main() -> Result<()> { +async fn main() -> Result<(), ChaosError> { dotenvy::dotenv()?; - // Initialise DB connection - let db_url = env::var("DATABASE_URL") - .expect("Error getting DATABASE_URL") - .to_string(); - let pool = PgPoolOptions::new() - .max_connections(5) - .connect(db_url.as_str()) - .await - .expect("Cannot connect to database"); - - // Initialise JWT settings - let jwt_secret = env::var("JWT_SECRET") - .expect("Error getting JWT_SECRET") - .to_string(); - // let jwt_secret = "I want to cry"; - let encoding_key = EncodingKey::from_secret(jwt_secret.as_bytes()); - let decoding_key = DecodingKey::from_secret(jwt_secret.as_bytes()); - let jwt_header = Header::new(Algorithm::HS512); - let mut jwt_validator = Validation::new(Algorithm::HS512); - jwt_validator.set_issuer(&["Chaos"]); - jwt_validator.set_audience(&["chaos.devsoc.app"]); - - // Initialise reqwest client - let ctx = reqwest::Client::new(); - - // Initialise Snowflake Generator - let snowflake_generator = SnowflakeIdGenerator::new(1, 1); - - // Initialise S3 bucket - let storage_bucket = Storage::init_bucket(); - - // Add all data to AppState - let state = AppState { - db: pool, - ctx, - encoding_key, - decoding_key, - jwt_header, - jwt_validator, - snowflake_generator, - storage_bucket, - }; - - let app = Router::new() - .route("/", get(|| async { "Hello, World!" })) - .route("/api/auth/callback/google", get(google_callback)) - .route("/api/v1/user", get(UserHandler::get)) - .route("/api/v1/user/name", patch(UserHandler::update_name)) - .route("/api/v1/user/pronouns", patch(UserHandler::update_pronouns)) - .route("/api/v1/user/gender", patch(UserHandler::update_gender)) - .route("/api/v1/user/zid", patch(UserHandler::update_zid)) - .route("/api/v1/user/degree", patch(UserHandler::update_degree)) - .route( - "/api/v1/user/applications", - get(ApplicationHandler::get_from_curr_user), - ) - .route("/api/v1/organisation", post(OrganisationHandler::create)) - .route( - "/api/v1/organisation/:organisation_id", - get(OrganisationHandler::get).delete(OrganisationHandler::delete), - ) - .route( - "/api/v1/organisation/:organisation_id/campaign", - post(OrganisationHandler::create_campaign), - ) - .route( - "/api/v1/organisation/:organisation_id/campaigns", - get(OrganisationHandler::get_campaigns), - ) - .route( - "/api/v1/organisation/:organisation_id/logo", - patch(OrganisationHandler::update_logo), - ) - .route( - "/api/v1/organisation/:organisation_id/member", - get(OrganisationHandler::get_members) - .put(OrganisationHandler::update_members) - .delete(OrganisationHandler::remove_member), - ) - .route( - "/api/v1/organisation/:organisation_id/admin", - get(OrganisationHandler::get_admins) - .put(OrganisationHandler::update_admins) - .delete(OrganisationHandler::remove_admin), - ) - .route( - "/api/v1/ratings/:rating_id", - get(RatingsHandler::get) - .delete(RatingsHandler::delete) - .put(RatingsHandler::update), - ) - .route( - "/api/v1/:application_id/rating", - post(RatingsHandler::create_rating), - ) - .route( - "/api/v1/:application_id/ratings", - get(RatingsHandler::get_ratings_for_application), - ) - .route( - "/api/v1/campaign/:campaign_id/role", - post(CampaignHandler::create_role), - ) - .route( - "/api/v1/campaign/:campaign_id/roles", - get(CampaignHandler::get_roles), - ) - .route( - "/api/v1/campaign/:campaign_id/applications", - get(CampaignHandler::get_applications), - ) - .route( - "/api/v1/role/:role_id", - get(RoleHandler::get) - .put(RoleHandler::update) - .delete(RoleHandler::delete), - ) - .route( - "/api/v1/role/:role_id/applications", - get(RoleHandler::get_applications) - ) - .route( - "/api/v1/campaign/:campaign_id", - get(CampaignHandler::get) - .put(CampaignHandler::update) - .delete(CampaignHandler::delete), - ) - .route("/api/v1/campaign", get(CampaignHandler::get_all)) - .route( - "/api/v1/campaign/:campaign_id/banner", - patch(CampaignHandler::update_banner), - ) - .route("api/v1/campaign/:campaign_id/application", - post(CampaignHandler::create_application) - ) - .route("api/v1/application/:application_id", get(ApplicationHandler::get)) - .route("api/v1/application/:application_id/status", patch(ApplicationHandler::set_status)) - .route("api/v1/application/:application_id/private", patch(ApplicationHandler::set_private_status)) - .with_state(state); - + let app = app().await?; let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); axum::serve(listener, app).await.unwrap(); diff --git a/backend/server/src/models/answer.rs b/backend/server/src/models/answer.rs index b892fc18..670a7ba8 100644 --- a/backend/server/src/models/answer.rs +++ b/backend/server/src/models/answer.rs @@ -1,7 +1,12 @@ use crate::models::error::ChaosError; +use crate::models::question::{ + MultiOptionData, MultiOptionQuestionOption, QuestionData, QuestionType, QuestionTypeParent, +}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::{Pool, Postgres, QueryBuilder, Row}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{Pool, Postgres, Transaction}; +use std::ops::DerefMut; /// The `Answer` type that will be sent in API responses. /// @@ -11,7 +16,6 @@ use sqlx::{Pool, Postgres, QueryBuilder, Row}; /// ```json /// { /// "id": 7233828375289773948, -/// "application_id": 7233828375289125398, /// "question_id": 7233828375289139200, /// "answer_type": "MultiChoice", /// "data": 7233828393325384908, @@ -22,7 +26,6 @@ use sqlx::{Pool, Postgres, QueryBuilder, Row}; #[derive(Deserialize, Serialize)] pub struct Answer { id: i64, - application_id: i64, question_id: i64, #[serde(flatten)] @@ -32,66 +35,420 @@ pub struct Answer { updated_at: DateTime, } +#[derive(Deserialize)] +pub struct NewAnswer { + pub application_id: i64, + pub question_id: i64, + + #[serde(flatten)] + pub answer_data: AnswerData, +} + +#[derive(Deserialize, sqlx::FromRow)] +pub struct AnswerRawData { + id: i64, + question_id: i64, + question_type: QuestionType, + short_answer_answer: Option, + multi_option_answers: Option>, + ranking_answers: Option>, + created_at: DateTime, + updated_at: DateTime, +} + +#[derive(Deserialize)] +pub struct AnswerTypeApplicationId { + question_type: QuestionType, + application_id: i64, +} + +impl Answer { + pub async fn create( + user_id: i64, + application_id: i64, + question_id: i64, + answer_data: AnswerData, + mut snowflake_generator: SnowflakeIdGenerator, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + answer_data.validate()?; + + let id = snowflake_generator.generate(); + + sqlx::query!( + " + INSERT INTO answers (id, application_id, question_id) + VALUES ($1, $2, $3) + ", + id, + application_id, + question_id + ) + .execute(transaction.deref_mut()) + .await?; + + answer_data.insert_into_db(id, transaction).await?; + + Ok(id) + } + + pub async fn get( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let answer_raw_data: AnswerRawData = sqlx::query_as( + " + SELECT + a.id, + a.question_id, + q.question_type AS \"question_type: QuestionType\", + a.created_at, + a.updated_at, + saa.text AS short_answer_answer, + array_agg( + moao.option_id + ) multi_option_answers, + array_agg( + rar.option_id ORDER BY rar.rank + ) ranking_answers + FROM + answers a + JOIN questions q ON a.question_id = q.id + LEFT JOIN + multi_option_answer_options moao ON moao.answer_id = a.id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown') + + LEFT JOIN + short_answer_answers saa ON saa.answer_id = a.id + AND q.question_type = 'ShortAnswer' + + LEFT JOIN + ranking_answer_rankings rar ON rar.answer_id = a.id + AND q.question_type = 'Ranking' + WHERE q.id = $1 + GROUP BY + a.id + ", + ) + .bind(id) + .fetch_one(transaction.deref_mut()) + .await?; + + let answer_data = AnswerData::from_answer_raw_data( + answer_raw_data.question_type, + answer_raw_data.short_answer_answer, + answer_raw_data.multi_option_answers, + answer_raw_data.ranking_answers, + ); + + Ok(Answer { + id, + question_id: answer_raw_data.question_id, + answer_data, + created_at: answer_raw_data.created_at, + updated_at: answer_raw_data.updated_at, + }) + } + + pub async fn get_all_common_by_application( + application_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let answer_raw_data: Vec = sqlx::query_as( + " + SELECT + a.id, + a.question_id, + q.question_type AS \"question_type: QuestionType\", + a.created_at, + a.updated_at, + saa.text AS short_answer_answer, + array_agg( + moao.option_id + ) multi_option_answers, + array_agg( + rar.option_id ORDER BY rar.rank + ) ranking_answers + FROM + answers a + JOIN questions q ON a.question_id = q.id + LEFT JOIN + multi_option_answer_options moao ON moao.answer_id = a.id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown') + + LEFT JOIN + short_answer_answers saa ON saa.answer_id = a.id + AND q.question_type = 'ShortAnswer' + + LEFT JOIN + ranking_answer_rankings rar ON rar.answer_id = a.id + AND q.question_type = 'Ranking' + WHERE a.application_id = $1 AND q.common = true + GROUP BY + a.id + ", + ) + .bind(application_id) + .fetch_all(transaction.deref_mut()) + .await?; + + let answers = answer_raw_data + .into_iter() + .map(|answer_raw_data| { + let answer_data = AnswerData::from_answer_raw_data( + answer_raw_data.question_type, + answer_raw_data.short_answer_answer, + answer_raw_data.multi_option_answers, + answer_raw_data.ranking_answers, + ); + + Answer { + id: answer_raw_data.id, + question_id: answer_raw_data.question_id, + answer_data, + created_at: answer_raw_data.created_at, + updated_at: answer_raw_data.updated_at, + } + }) + .collect(); + + Ok(answers) + } + + pub async fn get_all_by_application_and_role( + application_id: i64, + role_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let answer_raw_data: Vec = sqlx::query_as( + " + SELECT + a.id, + a.question_id, + q.question_type AS \"question_type: QuestionType\", + a.created_at, + a.updated_at, + saa.text AS short_answer_answer, + array_agg( + moao.option_id + ) multi_option_answers, + array_agg( + rar.option_id ORDER BY rar.rank + ) ranking_answers + FROM + answers a + JOIN questions q ON a.question_id = q.id + JOIN question_roles qr ON q.id = qr.question_id + LEFT JOIN + multi_option_answer_options moao ON moao.answer_id = a.id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown') + + LEFT JOIN + short_answer_answers saa ON saa.answer_id = a.id + AND q.question_type = 'ShortAnswer' + + LEFT JOIN + ranking_answer_rankings rar ON rar.answer_id = a.id + AND q.question_type = 'Ranking' + WHERE a.application_id = $1 AND qr.role_id = $2 AND q.common = true + GROUP BY + a.id + ", + ) + .bind(application_id) + .bind(role_id) + .fetch_all(transaction.deref_mut()) + .await?; + + let answers = answer_raw_data + .into_iter() + .map(|answer_raw_data| { + let answer_data = AnswerData::from_answer_raw_data( + answer_raw_data.question_type, + answer_raw_data.short_answer_answer, + answer_raw_data.multi_option_answers, + answer_raw_data.ranking_answers, + ); + + Answer { + id: answer_raw_data.id, + question_id: answer_raw_data.question_id, + answer_data, + created_at: answer_raw_data.created_at, + updated_at: answer_raw_data.updated_at, + } + }) + .collect(); + + Ok(answers) + } + + pub async fn update( + id: i64, + answer_data: AnswerData, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + answer_data.validate()?; + + let answer = sqlx::query_as!( + AnswerTypeApplicationId, + " + SELECT a.application_id, q.question_type AS \"question_type: QuestionType\" + FROM answers a + JOIN questions q ON a.question_id = q.id + WHERE a.id = $1 + ", + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + let old_data = AnswerData::from_question_type(&answer.question_type); + old_data.delete_from_db(id, transaction).await?; + + answer_data.insert_into_db(id, transaction).await?; + + sqlx::query!( + "UPDATE applications SET updated_at = $1 WHERE id = $2", + Utc::now(), + answer.application_id + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + + pub async fn delete( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + sqlx::query!("DELETE FROM answers WHERE id = $1 RETURNING id", id) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } +} + #[derive(Deserialize, Serialize)] -#[serde( tag = "answer_type", content = "data")] +#[serde(tag = "answer_type", content = "data")] pub enum AnswerData { ShortAnswer(String), MultiChoice(i64), MultiSelect(Vec), DropDown(i64), - Ranking(Vec) + Ranking(Vec), } impl AnswerData { - pub async fn validate(self) -> Result<(), ChaosError> { + fn from_question_type(question_type: &QuestionType) -> Self { + match question_type { + QuestionType::ShortAnswer => AnswerData::ShortAnswer("".to_string()), + QuestionType::MultiChoice => AnswerData::MultiChoice(0), + QuestionType::MultiSelect => AnswerData::MultiSelect(Vec::::new()), + QuestionType::DropDown => AnswerData::DropDown(0), + QuestionType::Ranking => AnswerData::Ranking(Vec::::new()), + } + } + + fn from_answer_raw_data( + question_type: QuestionType, + short_answer_answer: Option, + multi_option_answers: Option>, + ranking_answers: Option>, + ) -> Self { + return match question_type { + QuestionType::ShortAnswer => { + let answer = + short_answer_answer.expect("Data should exist for ShortAnswer variant"); + AnswerData::ShortAnswer(answer) + } + QuestionType::MultiChoice | QuestionType::MultiSelect | QuestionType::DropDown => { + let options = + multi_option_answers.expect("Data should exist for MultiOptionData variants"); + + match question_type { + QuestionType::MultiChoice => AnswerData::MultiChoice(options[0]), + QuestionType::MultiSelect => AnswerData::MultiSelect(options), + QuestionType::DropDown => AnswerData::DropDown(options[0]), + _ => AnswerData::ShortAnswer("".to_string()), // Should never be reached, hence return ShortAnswer + } + } + QuestionType::Ranking => { + let options = ranking_answers.expect("Data should exist for Ranking variant"); + AnswerData::Ranking(options) + } + _ => { + AnswerData::ShortAnswer("".to_string()) // Should never be reached, hence return ShortAnswer + } + }; + } + + pub fn validate(&self) -> Result<(), ChaosError> { match self { - Self::ShortAnswer(text) => if text.len() == 0 { return Err(ChaosError::BadRequest) }, - Self::MultiSelect(data) => if data.len() == 0 { return Err(ChaosError::BadRequest) }, - _ => {}, + Self::ShortAnswer(text) => { + if text.len() == 0 { + return Err(ChaosError::BadRequest); + } + } + Self::MultiSelect(data) | Self::Ranking(data) => { + if data.len() == 0 { + return Err(ChaosError::BadRequest); + } + } + _ => {} } Ok(()) } - pub async fn insert_into_db(self, answer_id: i64, pool: &Pool) -> Result<(), ChaosError> { + pub async fn insert_into_db( + self, + answer_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { match self { Self::ShortAnswer(text) => { - let result = sqlx::query!( + sqlx::query!( "INSERT INTO short_answer_answers (text, answer_id) VALUES ($1, $2)", text, answer_id ) - .execute(pool) + .execute(transaction.deref_mut()) .await?; Ok(()) - }, - Self::MultiChoice(option_id) - | Self::DropDown(option_id) => { - let result = sqlx::query!( + } + Self::MultiChoice(option_id) | Self::DropDown(option_id) => { + sqlx::query!( "INSERT INTO multi_option_answer_options (option_id, answer_id) VALUES ($1, $2)", option_id, answer_id ) - .execute(pool) + .execute(transaction.deref_mut()) .await?; Ok(()) - }, + } Self::MultiSelect(option_ids) => { - let mut query_builder = sqlx::QueryBuilder::new("INSERT INTO multi_option_answer_options (option_id, answer_id)"); + let mut query_builder = sqlx::QueryBuilder::new( + "INSERT INTO multi_option_answer_options (option_id, answer_id)", + ); query_builder.push_values(option_ids, |mut b, option_id| { b.push_bind(option_id).push_bind(answer_id); }); let query = query_builder.build(); - let result = query.execute(pool).await?; + query.execute(transaction.deref_mut()).await?; Ok(()) - }, + } Self::Ranking(option_ids) => { - let mut query_builder = sqlx::QueryBuilder::new("INSERT INTO ranking_answer_rankings (option_id, rank, answer_id)"); + let mut query_builder = sqlx::QueryBuilder::new( + "INSERT INTO ranking_answer_rankings (option_id, rank, answer_id)", + ); let mut rank = 1; query_builder.push_values(option_ids, |mut b, option_id| { @@ -100,10 +457,45 @@ impl AnswerData { }); let query = query_builder.build(); - let result = query.execute(pool).await?; + query.execute(transaction.deref_mut()).await?; Ok(()) } } } + + pub async fn delete_from_db( + self, + answer_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + match self { + Self::ShortAnswer(_) => { + sqlx::query!( + "DELETE FROM short_answer_answers WHERE answer_id = $1", + answer_id + ) + .execute(transaction.deref_mut()) + .await?; + } + Self::MultiChoice(_) | Self::MultiSelect(_) | Self::DropDown(_) => { + sqlx::query!( + "DELETE FROM multi_option_answer_options WHERE answer_id = $1", + answer_id + ) + .execute(transaction.deref_mut()) + .await?; + } + Self::Ranking(_) => { + sqlx::query!( + "DELETE FROM ranking_answer_rankings WHERE answer_id = $1", + answer_id + ) + .execute(transaction.deref_mut()) + .await?; + } + } + + Ok(()) + } } diff --git a/backend/server/src/models/app.rs b/backend/server/src/models/app.rs index 19ecfbff..ed41b78f 100644 --- a/backend/server/src/models/app.rs +++ b/backend/server/src/models/app.rs @@ -1,8 +1,23 @@ -use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; +use crate::handler::answer::AnswerHandler; +use crate::handler::application::ApplicationHandler; +use crate::handler::auth::google_callback; +use crate::handler::campaign::CampaignHandler; +use crate::handler::organisation::OrganisationHandler; +use crate::handler::question::QuestionHandler; +use crate::handler::rating::RatingHandler; +use crate::handler::role::RoleHandler; +use crate::handler::user::UserHandler; +use crate::models::error::ChaosError; +use crate::models::storage::Storage; +use axum::routing::{get, patch, post}; +use axum::Router; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use reqwest::Client as ReqwestClient; use s3::Bucket; use snowflake::SnowflakeIdGenerator; +use sqlx::postgres::PgPoolOptions; use sqlx::{Pool, Postgres}; +use std::env; #[derive(Clone)] pub struct AppState { @@ -15,3 +30,187 @@ pub struct AppState { pub snowflake_generator: SnowflakeIdGenerator, pub storage_bucket: Bucket, } + +pub async fn app() -> Result { + // Initialise DB connection + let db_url = env::var("DATABASE_URL") + .expect("Error getting DATABASE_URL") + .to_string(); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(db_url.as_str()) + .await + .expect("Cannot connect to database"); + + // Initialise JWT settings + let jwt_secret = env::var("JWT_SECRET") + .expect("Error getting JWT_SECRET") + .to_string(); + // let jwt_secret = "I want to cry"; + let encoding_key = EncodingKey::from_secret(jwt_secret.as_bytes()); + let decoding_key = DecodingKey::from_secret(jwt_secret.as_bytes()); + let jwt_header = Header::new(Algorithm::HS512); + let mut jwt_validator = Validation::new(Algorithm::HS512); + jwt_validator.set_issuer(&["Chaos"]); + jwt_validator.set_audience(&["chaos.devsoc.app"]); + + // Initialise reqwest client + let ctx = reqwest::Client::new(); + + // Initialise Snowflake Generator + let snowflake_generator = SnowflakeIdGenerator::new(1, 1); + + // Initialise S3 bucket + let storage_bucket = Storage::init_bucket(); + + // Add all data to AppState + let state = AppState { + db: pool, + ctx, + encoding_key, + decoding_key, + jwt_header, + jwt_validator, + snowflake_generator, + storage_bucket, + }; + + Ok(Router::new() + .route("/", get(|| async { "Join DevSoc! https://devsoc.app/" })) + .route("/api/auth/callback/google", get(google_callback)) + .route("/api/v1/user", get(UserHandler::get)) + .route("/api/v1/user/name", patch(UserHandler::update_name)) + .route("/api/v1/user/pronouns", patch(UserHandler::update_pronouns)) + .route("/api/v1/user/gender", patch(UserHandler::update_gender)) + .route("/api/v1/user/zid", patch(UserHandler::update_zid)) + .route("/api/v1/user/degree", patch(UserHandler::update_degree)) + .route( + "/api/v1/user/applications", + get(ApplicationHandler::get_from_curr_user), + ) + .route("/api/v1/organisation", post(OrganisationHandler::create)) + .route( + "/api/v1/organisation/:organisation_id", + get(OrganisationHandler::get).delete(OrganisationHandler::delete), + ) + .route( + "/api/v1/organisation/:organisation_id/campaign", + post(OrganisationHandler::create_campaign), + ) + .route( + "/api/v1/organisation/:organisation_id/campaigns", + get(OrganisationHandler::get_campaigns), + ) + .route( + "/api/v1/organisation/:organisation_id/logo", + patch(OrganisationHandler::update_logo), + ) + .route( + "/api/v1/organisation/:organisation_id/member", + get(OrganisationHandler::get_members) + .put(OrganisationHandler::update_members) + .delete(OrganisationHandler::remove_member), + ) + .route( + "/api/v1/organisation/:organisation_id/admin", + get(OrganisationHandler::get_admins) + .put(OrganisationHandler::update_admins) + .delete(OrganisationHandler::remove_admin), + ) + .route( + "/api/v1/ratings/:rating_id", + get(RatingHandler::get) + .delete(RatingHandler::delete) + .put(RatingHandler::update), + ) + .route( + "/api/v1/:application_id/rating", + post(RatingHandler::create), + ) + .route( + "/api/v1/:application_id/ratings", + get(RatingHandler::get_ratings_for_application), + ) + .route( + "/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), + ) + .route( + "/api/v1/campaign/:campaign_id/applications", + get(CampaignHandler::get_applications), + ) + .route( + "/api/v1/role/:role_id", + get(RoleHandler::get) + .put(RoleHandler::update) + .delete(RoleHandler::delete), + ) + .route( + "/api/v1/role/:role_id/applications", + get(RoleHandler::get_applications), + ) + .route( + "/api/v1/campaign/:campaign_id", + get(CampaignHandler::get) + .put(CampaignHandler::update) + .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", + patch(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), + ) + .route( + "/api/v1/campaign/:campaign_id/application", + post(CampaignHandler::create_application), + ) + .route( + "/api/v1/application/:application_id", + get(ApplicationHandler::get), + ) + .route( + "/api/v1/application/:application_id/status", + patch(ApplicationHandler::set_status), + ) + .route( + "/api/v1/application/:application_id/private", + patch(ApplicationHandler::set_private_status), + ) + .route( + "/api/v1/application/:application_id/answers/common", + get(AnswerHandler::get_all_common_by_application), + ) + .route( + "/api/v1/application/:applicaiton_id/answer", + post(AnswerHandler::create), + ) + .route( + "/api/v1/application/:application_id/answers/role/:role_id", + get(AnswerHandler::get_all_by_application_and_role), + ) + .route( + "/api/v1/answer/:answer_id", + patch(AnswerHandler::update).delete(AnswerHandler::delete), + ) + .with_state(state)) +} diff --git a/backend/server/src/models/application.rs b/backend/server/src/models/application.rs index 65e55d21..1cd9a74f 100644 --- a/backend/server/src/models/application.rs +++ b/backend/server/src/models/application.rs @@ -1,10 +1,10 @@ use crate::models::error::ChaosError; +use crate::models::user::UserDetails; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use snowflake::SnowflakeIdGenerator; use sqlx::{FromRow, Pool, Postgres, Transaction}; use std::ops::DerefMut; -use crate::models::user::UserDetails; #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct Application { @@ -41,7 +41,7 @@ pub struct ApplicationDetails { pub user: UserDetails, pub status: ApplicationStatus, pub private_status: ApplicationStatus, - pub applied_roles: Vec + pub applied_roles: Vec, } #[derive(Deserialize, Serialize)] @@ -66,7 +66,6 @@ pub struct ApplicationAppliedRoleDetails { pub role_name: String, } - #[derive(Deserialize, Serialize, sqlx::Type, Clone, Debug)] #[sqlx(type_name = "application_status", rename_all = "PascalCase")] pub enum ApplicationStatus { @@ -116,9 +115,12 @@ impl Application { } /* - Get Application given an application id - */ - pub async fn get(id: i64, transaction: &mut Transaction<'_, Postgres>,) -> Result { + Get Application given an application id + */ + pub async fn get( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { let application_data = sqlx::query_as!( ApplicationData, " @@ -149,33 +151,32 @@ impl Application { .fetch_all(transaction.deref_mut()) .await?; - Ok( - ApplicationDetails { - id: application_data.id, - campaign_id: application_data.campaign_id, - status: application_data.status, - private_status: application_data.private_status, - applied_roles, - user: UserDetails { - id: application_data.user_id, - email: application_data.user_email, - zid: application_data.user_zid, - name: application_data.user_name, - pronouns: application_data.user_pronouns, - gender: application_data.user_gender, - degree_name: application_data.user_degree_name, - degree_starting_year: application_data.user_degree_starting_year, - }, - } - ) + Ok(ApplicationDetails { + id: application_data.id, + campaign_id: application_data.campaign_id, + status: application_data.status, + private_status: application_data.private_status, + applied_roles, + user: UserDetails { + id: application_data.user_id, + email: application_data.user_email, + zid: application_data.user_zid, + name: application_data.user_name, + pronouns: application_data.user_pronouns, + gender: application_data.user_gender, + degree_name: application_data.user_degree_name, + degree_starting_year: application_data.user_degree_starting_year, + }, + }) } - /* - Get All applications that apply for a given role - */ - pub async fn get_from_role_id(role_id: i64, transaction: &mut Transaction<'_, Postgres>,) - -> Result, ChaosError> { + Get All applications that apply for a given role + */ + pub async fn get_from_role_id( + role_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { let application_data_list = sqlx::query_as!( ApplicationData, " @@ -205,8 +206,8 @@ impl Application { ", application_data.id ) - .fetch_all(transaction.deref_mut()) - .await?; + .fetch_all(transaction.deref_mut()) + .await?; let details = ApplicationDetails { id: application_data.id, @@ -233,10 +234,12 @@ impl Application { } /* - Get All applications that apply for a given campaign - */ - pub async fn get_from_campaign_id(campaign_id: i64, transaction: &mut Transaction<'_, Postgres>,) - -> Result, ChaosError> { + Get All applications that apply for a given campaign + */ + pub async fn get_from_campaign_id( + campaign_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { let application_data_list = sqlx::query_as!( ApplicationData, " @@ -250,8 +253,8 @@ impl Application { ", campaign_id ) - .fetch_all(transaction.deref_mut()) - .await?; + .fetch_all(transaction.deref_mut()) + .await?; let mut application_details_list = Vec::new(); for application_data in application_data_list { @@ -286,7 +289,7 @@ impl Application { degree_starting_year: application_data.user_degree_starting_year, }, }; - + application_details_list.push(details) } @@ -294,10 +297,12 @@ impl Application { } /* - Get All applications that are made by a given user - */ - pub async fn get_from_user_id(user_id: i64, transaction: &mut Transaction<'_, Postgres>,) - -> Result, ChaosError> { + Get All applications that are made by a given user + */ + pub async fn get_from_user_id( + user_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { let application_data_list = sqlx::query_as!( ApplicationData, " @@ -311,8 +316,8 @@ impl Application { ", user_id ) - .fetch_all(transaction.deref_mut()) - .await?; + .fetch_all(transaction.deref_mut()) + .await?; let mut application_details_list = Vec::new(); for application_data in application_data_list { @@ -327,8 +332,8 @@ impl Application { ", application_data.id ) - .fetch_all(transaction.deref_mut()) - .await?; + .fetch_all(transaction.deref_mut()) + .await?; let details = ApplicationDetails { id: application_data.id, @@ -354,36 +359,43 @@ impl Application { Ok(application_details_list) } - pub async fn set_status(id: i64, new_status: ApplicationStatus, pool: &Pool) -> Result<(), ChaosError> { - sqlx::query!( + pub async fn set_status( + id: i64, + new_status: ApplicationStatus, + pool: &Pool, + ) -> Result<(), ChaosError> { + _ = sqlx::query!( " UPDATE applications SET status = $2 - WHERE id = $1; + WHERE id = $1 RETURNING id ", id, new_status as ApplicationStatus ) - .execute(pool) + .fetch_one(pool) .await?; Ok(()) } - pub async fn set_private_status(id: i64, new_status: ApplicationStatus, pool: &Pool) -> Result<(), ChaosError> { - sqlx::query!( + pub async fn set_private_status( + id: i64, + new_status: ApplicationStatus, + pool: &Pool, + ) -> Result<(), ChaosError> { + _ = sqlx::query!( " UPDATE applications SET private_status = $2 - WHERE id = $1; + WHERE id = $1 RETURNING id ", id, new_status as ApplicationStatus ) - .execute(pool) + .fetch_one(pool) .await?; Ok(()) } - -} \ No newline at end of file +} diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index 89e82277..f28ac9f0 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -1,12 +1,13 @@ -use std::collections::HashMap; use crate::models::app::AppState; use crate::models::error::ChaosError; -use crate::service::application::user_is_application_admin; +use crate::service::answer::user_is_answer_owner; +use crate::service::application::{user_is_application_admin, user_is_application_owner}; 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::assert_user_is_organisation_admin; -use crate::service::ratings::{ +use crate::service::question::user_is_question_admin; +use crate::service::rating::{ assert_user_is_application_reviewer_given_rating_id, assert_user_is_organisation_member, assert_user_is_rating_creator_and_organisation_member, }; @@ -17,6 +18,7 @@ use axum::response::{IntoResponse, Redirect, Response}; use axum::{async_trait, RequestPartsExt}; use axum_extra::{headers::Cookie, TypedHeader}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; // tells the web framework how to take the url query params they will have #[derive(Deserialize, Serialize)] @@ -44,7 +46,7 @@ pub struct AuthUser { pub user_id: i64, } -// extractor - takes a request, and we define what we do to it, +// extractor - takes a request, and we define what we do to it, // returns the struct of the type defined #[async_trait] impl FromRequestParts for AuthUser @@ -146,7 +148,7 @@ where let user_id = claims.sub; let organisation_id = *parts - .extract::>>() + .extract::>>() .await .map_err(|_| ChaosError::BadRequest)? .get("organisation_id") @@ -455,3 +457,129 @@ 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 }) + } +} + +pub struct ApplicationOwner { + pub user_id: i64, +} + +#[async_trait] +impl FromRequestParts for ApplicationOwner +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 application_id = *parts + .extract::>>() + .await + .map_err(|_| ChaosError::BadRequest)? + .get("application_id") + .ok_or(ChaosError::BadRequest)?; + + user_is_application_owner(user_id, application_id, pool).await?; + + Ok(ApplicationOwner { user_id }) + } +} + +pub struct AnswerOwner { + pub user_id: i64, +} + +#[async_trait] +impl FromRequestParts for AnswerOwner +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 application_id = *parts + .extract::>>() + .await + .map_err(|_| ChaosError::BadRequest)? + .get("application_id") + .ok_or(ChaosError::BadRequest)?; + + user_is_answer_owner(user_id, application_id, pool).await?; + + Ok(AnswerOwner { user_id }) + } +} diff --git a/backend/server/src/models/campaign.rs b/backend/server/src/models/campaign.rs index 40590fee..eb750de7 100644 --- a/backend/server/src/models/campaign.rs +++ b/backend/server/src/models/campaign.rs @@ -96,11 +96,11 @@ impl Campaign { update: CampaignUpdate, pool: &Pool, ) -> Result<(), ChaosError> { - sqlx::query!( + _ = sqlx::query!( " UPDATE campaigns SET name = $1, description = $2, starts_at = $3, ends_at = $4 - WHERE id = $5 + WHERE id = $5 RETURNING id ", update.name, update.description, @@ -108,7 +108,7 @@ impl Campaign { update.ends_at, id ) - .execute(pool) + .fetch_one(pool) .await?; Ok(()) @@ -125,17 +125,17 @@ impl Campaign { let image_id = Uuid::new_v4(); let current_time = dt; - sqlx::query!( + _ = sqlx::query!( " UPDATE campaigns SET cover_image = $1, updated_at = $2 - WHERE id = $3 + WHERE id = $3 RETURNING id ", image_id, current_time, id ) - .execute(pool) + .fetch_one(pool) .await?; let upload_url = @@ -146,13 +146,13 @@ impl Campaign { /// Delete a campaign from the database pub async fn delete(id: i64, pool: &Pool) -> Result<(), ChaosError> { - sqlx::query!( + _ = sqlx::query!( " - DELETE FROM campaigns WHERE id = $1 + DELETE FROM campaigns WHERE id = $1 RETURNING id ", id ) - .execute(pool) + .fetch_one(pool) .await?; Ok(()) diff --git a/backend/server/src/models/error.rs b/backend/server/src/models/error.rs index 3a2869e3..d541562b 100644 --- a/backend/server/src/models/error.rs +++ b/backend/server/src/models/error.rs @@ -36,6 +36,9 @@ pub enum ChaosError { #[error("S3 error")] StorageError(#[from] s3::error::S3Error), + + #[error("DotEnvy error")] + DotEnvyError(#[from] dotenvy::Error), } /// Implementation for converting errors into responses. Manages error code and message returned. diff --git a/backend/server/src/models/mod.rs b/backend/server/src/models/mod.rs index 1bc24e05..9f30a8bb 100644 --- a/backend/server/src/models/mod.rs +++ b/backend/server/src/models/mod.rs @@ -1,14 +1,13 @@ pub mod answer; pub mod app; +pub mod application; pub mod auth; pub mod campaign; pub mod error; -pub mod question; pub mod organisation; -pub mod ratings; +pub mod question; +pub mod rating; pub mod role; pub mod storage; pub mod transaction; pub mod user; -pub mod application; - diff --git a/backend/server/src/models/organisation.rs b/backend/server/src/models/organisation.rs index 984deef3..94a34c24 100644 --- a/backend/server/src/models/organisation.rs +++ b/backend/server/src/models/organisation.rs @@ -115,13 +115,13 @@ impl Organisation { } pub async fn delete(id: i64, pool: &Pool) -> Result<(), ChaosError> { - sqlx::query!( + _ = sqlx::query!( " - DELETE FROM organisations WHERE id = $1 + DELETE FROM organisations WHERE id = $1 RETURNING id ", id ) - .execute(pool) + .fetch_one(pool) .await?; Ok(()) @@ -175,6 +175,13 @@ impl Organisation { admin_id_list: Vec, transaction: &mut Transaction<'_, Postgres>, ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + "SELECT id FROM organisations WHERE id = $1", + organisation_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + sqlx::query!( "DELETE FROM organisation_members WHERE organisation_id = $1 AND role = $2", organisation_id, @@ -205,6 +212,13 @@ impl Organisation { member_id_list: Vec, transaction: &mut Transaction<'_, Postgres>, ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + "SELECT id FROM organisations WHERE id = $1", + organisation_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + sqlx::query!( "DELETE FROM organisation_members WHERE organisation_id = $1 AND role = $2", organisation_id, @@ -233,8 +247,15 @@ impl Organisation { pub async fn remove_admin( organisation_id: i64, admin_to_remove: i64, - pool: &Pool, + transaction: &mut Transaction<'_, Postgres>, ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + "SELECT id FROM organisations WHERE id = $1", + organisation_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + sqlx::query!( " UPDATE organisation_members SET role = $3 WHERE user_id = $1 AND organisation_id = $2 @@ -243,7 +264,7 @@ impl Organisation { organisation_id, OrganisationRole::User as OrganisationRole ) - .execute(pool) + .execute(transaction.deref_mut()) .await?; Ok(()) @@ -252,8 +273,15 @@ impl Organisation { pub async fn remove_member( organisation_id: i64, user_id: i64, - pool: &Pool, + transaction: &mut Transaction<'_, Postgres>, ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + "SELECT id FROM organisations WHERE id = $1", + organisation_id + ) + .fetch_one(transaction.deref_mut()) + .await?; + sqlx::query!( " DELETE FROM organisation_members WHERE user_id = $1 AND organisation_id = $2 @@ -261,7 +289,7 @@ impl Organisation { user_id, organisation_id ) - .execute(pool) + .execute(transaction.deref_mut()) .await?; Ok(()) @@ -276,17 +304,17 @@ impl Organisation { let logo_id = Uuid::new_v4(); let current_time = dt; - sqlx::query!( + _ = sqlx::query!( " UPDATE organisations SET logo = $2, updated_at = $3 - WHERE id = $1 + WHERE id = $1 RETURNING id ", id, logo_id, current_time ) - .execute(pool) + .fetch_one(pool) .await?; let upload_url = diff --git a/backend/server/src/models/question.rs b/backend/server/src/models/question.rs index 27eb5e85..db1f70f3 100644 --- a/backend/server/src/models/question.rs +++ b/backend/server/src/models/question.rs @@ -1,8 +1,9 @@ use crate::models::error::ChaosError; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::{Pool, Postgres, QueryBuilder, Row}; use snowflake::SnowflakeIdGenerator; +use sqlx::{Postgres, QueryBuilder, Transaction}; +use std::ops::DerefMut; /// The `Question` type that will be sent in API responses. /// @@ -53,6 +54,397 @@ pub struct Question { updated_at: DateTime, } +#[derive(Deserialize)] +pub struct NewQuestion { + pub title: String, + pub description: Option, + pub common: bool, + pub roles: Option>, + pub required: bool, + + #[serde(flatten)] + pub question_data: QuestionData, +} + +#[derive(Deserialize, sqlx::FromRow)] +pub struct QuestionRawData { + id: i64, + title: String, + description: Option, + common: bool, // Common question are shown at the start + required: bool, + + question_type: QuestionType, + multi_option_data: Option>>, + + created_at: DateTime, + updated_at: DateTime, +} + +impl Question { + pub async fn create( + campaign_id: i64, + title: String, + description: Option, + common: bool, + roles: Option>, + required: bool, + question_data: QuestionData, + mut snowflake_generator: SnowflakeIdGenerator, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + question_data.validate()?; + + let id = snowflake_generator.generate(); + + sqlx::query!( + " + INSERT INTO questions ( + id, title, description, common, + required, question_type, campaign_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ", + id, + title, + description, + common, + required, + QuestionType::from_question_data(&question_data) as QuestionType, + campaign_id + ) + .execute(transaction.deref_mut()) + .await?; + + question_data + .insert_into_db(id, transaction, snowflake_generator) + .await?; + + if !common { + for role in roles.expect("Should be !None if !common") { + sqlx::query!( + " + INSERT INTO question_roles (question_id, role_id) VALUES ($1, $2) + ", + id, + role + ) + .execute(transaction.deref_mut()) + .await?; + } + } + + Ok(id) + } + + pub async fn get( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let question_raw_data: QuestionRawData = sqlx::query_as( + " + SELECT + q.id, + q.title, + q.description, + q.common, + q.required, + q.question_type AS \"question_type: QuestionType\", + q.created_at, + q.updated_at, + array_agg( + jsonb_build_object( + 'id', mod.id, + 'display_order', mod.display_order, + 'text', mod.text + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) AS \"multi_option_data: Option>>\" + FROM + questions q + LEFT JOIN + multi_option_question_options mod ON q.id = mod.question_id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking') + WHERE q.id = $1 + GROUP BY + q.id + " + ) + .bind(id) + .fetch_one(transaction.deref_mut()) + .await?; + + let question_data = QuestionData::from_question_raw_data( + question_raw_data.question_type, + question_raw_data.multi_option_data, + ); + + Ok(Question { + id, + title: question_raw_data.title, + description: question_raw_data.description, + common: question_raw_data.common, + required: question_raw_data.required, + question_data, + created_at: question_raw_data.created_at, + updated_at: question_raw_data.updated_at, + }) + } + + pub async fn get_all_by_campaign( + campaign_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let question_raw_data: Vec = sqlx::query_as( + " + SELECT + q.id, + q.title, + q.description, + q.common, + q.required, + q.question_type AS \"question_type: QuestionType\", + q.created_at, + q.updated_at, + array_agg( + jsonb_build_object( + 'id', mod.id, + 'display_order', mod.display_order, + 'text', mod.text + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) AS \"multi_option_data: Option>>\" + FROM + questions q + LEFT JOIN + multi_option_question_options mod ON q.id = mod.question_id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking') + WHERE q.campaign_id = $1 + GROUP BY + q.id + " + ) + .bind(campaign_id) + .fetch_all(transaction.deref_mut()) + .await?; + + let questions = question_raw_data + .into_iter() + .map(|question_raw_data| { + let question_data = QuestionData::from_question_raw_data( + question_raw_data.question_type, + question_raw_data.multi_option_data, + ); + + Question { + id: question_raw_data.id, + title: question_raw_data.title, + description: question_raw_data.description, + common: question_raw_data.common, + required: question_raw_data.required, + question_data, + created_at: question_raw_data.created_at, + updated_at: question_raw_data.updated_at, + } + }) + .collect(); + + Ok(questions) + } + + pub async fn get_all_by_campaign_and_role( + campaign_id: i64, + role_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let question_raw_data: Vec = sqlx::query_as( + " + SELECT + q.id, + q.title, + q.description, + q.common, + q.required, + q.question_type AS \"question_type: QuestionType\", + q.created_at, + q.updated_at, + array_agg( + jsonb_build_object( + 'id', mod.id, + 'display_order', mod.display_order, + 'text', mod.text + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) AS \"multi_option_data: Option>>\" + FROM + questions q + JOIN + question_roles qr ON q.id = qr.question_id + LEFT JOIN + multi_option_question_options mod ON q.id = mod.question_id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking') + WHERE q.campaign_id = $1 AND q.common = true AND qr.role_id = $2 + GROUP BY + q.id + " + ) + .bind(campaign_id) + .bind(role_id) + .fetch_all(transaction.deref_mut()) + .await?; + + let questions = question_raw_data + .into_iter() + .map(|question_raw_data| { + let question_data = QuestionData::from_question_raw_data( + question_raw_data.question_type, + question_raw_data.multi_option_data, + ); + + Question { + id: question_raw_data.id, + title: question_raw_data.title, + description: question_raw_data.description, + common: question_raw_data.common, + required: question_raw_data.required, + question_data, + created_at: question_raw_data.created_at, + updated_at: question_raw_data.updated_at, + } + }) + .collect(); + + Ok(questions) + } + + pub async fn get_all_common_by_campaign( + campaign_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let question_raw_data: Vec = sqlx::query_as( + " + SELECT + q.id, + q.title, + q.description, + q.common, + q.required, + q.question_type AS \"question_type: QuestionType\", + q.created_at, + q.updated_at, + array_agg( + jsonb_build_object( + 'id', mod.id, + 'display_order', mod.display_order, + 'text', mod.text + ) ORDER BY mod.display_order + ) FILTER (WHERE mod.id IS NOT NULL) AS \"multi_option_data: Option>>\" + FROM + questions q + LEFT JOIN + multi_option_question_options mod ON q.id = mod.question_id + AND q.question_type IN ('MultiChoice', 'MultiSelect', 'DropDown', 'Ranking') + WHERE q.campaign_id = $1 AND q.common = true + GROUP BY + q.id + " + ) + .bind(campaign_id) + .fetch_all(transaction.deref_mut()) + .await?; + + let questions = question_raw_data + .into_iter() + .map(|question_raw_data| { + let question_data = QuestionData::from_question_raw_data( + question_raw_data.question_type, + question_raw_data.multi_option_data, + ); + + Question { + id: question_raw_data.id, + title: question_raw_data.title, + description: question_raw_data.description, + common: question_raw_data.common, + required: question_raw_data.required, + question_data, + created_at: question_raw_data.created_at, + updated_at: question_raw_data.updated_at, + } + }) + .collect(); + + Ok(questions) + } + + pub async fn update( + id: i64, + title: String, + description: Option, + common: bool, + roles: Option>, + required: bool, + question_data: QuestionData, + transaction: &mut Transaction<'_, Postgres>, + snowflake_generator: SnowflakeIdGenerator, + ) -> Result<(), ChaosError> { + question_data.validate()?; + + let question_type_parent: QuestionTypeParent = sqlx::query_as!( + QuestionTypeParent, + " + UPDATE questions SET + title = $2, description = $3, common = $4, + required = $5, question_type = $6, updated_at = $7 + WHERE id = $1 + RETURNING question_type AS \"question_type: QuestionType\" + ", + id, + title, + description, + common, + required, + QuestionType::from_question_data(&question_data) as QuestionType, + Utc::now() + ) + .fetch_one(transaction.deref_mut()) + .await?; + + let old_data = QuestionData::from_question_type(&question_type_parent.question_type); + old_data.delete_from_db(id, transaction).await?; + + question_data + .insert_into_db(id, transaction, snowflake_generator) + .await?; + + sqlx::query!("DELETE FROM question_roles WHERE question_id = $1", id) + .execute(transaction.deref_mut()) + .await?; + if !common { + for role in roles.expect("Should be !None if !common") { + sqlx::query!( + " + INSERT INTO question_roles (question_id, role_id) VALUES ($1, $2) + ", + id, + role + ) + .execute(transaction.deref_mut()) + .await?; + } + } + + Ok(()) + } + + pub async fn delete( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + sqlx::query!("DELETE FROM questions WHERE id = $1 RETURNING id", id) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } +} + /// An enum that represents all the data types of question data that CHAOS can handle. /// This stores all the data for each question type. /// @@ -70,23 +462,99 @@ pub enum QuestionData { Ranking(MultiOptionData), } +/// An enum needed to track QuestionType in the database, +/// as DB enum does not contain the inner data. +#[derive(Deserialize, Serialize, PartialEq, sqlx::Type)] +#[sqlx(type_name = "question_type", rename_all = "PascalCase")] +pub enum QuestionType { + ShortAnswer, + MultiChoice, + MultiSelect, + DropDown, + Ranking, +} + +#[derive(Deserialize)] +pub struct QuestionTypeParent { + pub question_type: QuestionType, +} + +impl QuestionType { + fn from_question_data(question_data: &QuestionData) -> Self { + match question_data { + QuestionData::ShortAnswer => QuestionType::ShortAnswer, + QuestionData::MultiChoice(_) => QuestionType::MultiChoice, + QuestionData::MultiSelect(_) => QuestionType::MultiSelect, + QuestionData::DropDown(_) => QuestionType::DropDown, + QuestionData::Ranking(_) => QuestionType::Ranking, + } + } +} + #[derive(Deserialize, Serialize)] pub struct MultiOptionData { options: Vec, } +impl Default for MultiOptionData { + fn default() -> Self { + Self { + // Return an empty vector to be replaced by real data later on. + options: vec![], + } + } +} + /// Each of these structs represent a row in the `multi_option_question_options` /// table. For a `MultiChoice` question like "What is your favourite programming /// language?", there would be rows for "Rust", "Java" and "TypeScript". #[derive(Deserialize, Serialize)] pub struct MultiOptionQuestionOption { - id: i32, + id: i64, display_order: i32, text: String, } impl QuestionData { - pub async fn validate(self) -> Result<(), ChaosError> { + fn from_question_type(question_type: &QuestionType) -> Self { + match question_type { + QuestionType::ShortAnswer => QuestionData::ShortAnswer, + QuestionType::MultiChoice => QuestionData::MultiChoice(MultiOptionData::default()), + QuestionType::MultiSelect => QuestionData::MultiSelect(MultiOptionData::default()), + QuestionType::DropDown => QuestionData::DropDown(MultiOptionData::default()), + QuestionType::Ranking => QuestionData::Ranking(MultiOptionData::default()), + } + } + + fn from_question_raw_data( + question_type: QuestionType, + multi_option_data: Option>>, + ) -> Self { + return if question_type == QuestionType::ShortAnswer { + QuestionData::ShortAnswer + } else if question_type == QuestionType::MultiChoice + || question_type == QuestionType::MultiSelect + || question_type == QuestionType::DropDown + || question_type == QuestionType::Ranking + { + let options = multi_option_data + .expect("Data should exist for MultiOptionData variants") + .0; + let data = MultiOptionData { options }; + + match question_type { + QuestionType::MultiChoice => QuestionData::MultiChoice(data), + QuestionType::MultiSelect => QuestionData::MultiSelect(data), + QuestionType::DropDown => QuestionData::DropDown(data), + QuestionType::Ranking => QuestionData::Ranking(data), + _ => QuestionData::ShortAnswer, // Should never be reached, hence return ShortAnswer + } + } else { + QuestionData::ShortAnswer // Should never be reached, hence return ShortAnswer + }; + } + + pub fn validate(&self) -> Result<(), ChaosError> { match self { Self::ShortAnswer => Ok(()), Self::MultiChoice(data) @@ -102,7 +570,12 @@ impl QuestionData { } } - pub async fn insert_into_db(self, question_id: i64, pool: &Pool, mut snowflake_generator: SnowflakeIdGenerator) -> Result<(), ChaosError> { + pub async fn insert_into_db( + self, + question_id: i64, + transaction: &mut Transaction<'_, Postgres>, + mut snowflake_generator: SnowflakeIdGenerator, + ) -> Result<(), ChaosError> { match self { Self::ShortAnswer => Ok(()), Self::MultiChoice(data) @@ -114,11 +587,34 @@ impl QuestionData { query_builder.push_values(data.options, |mut b, option| { let id = snowflake_generator.real_time_generate(); - b.push_bind(id).push_bind(option.text).push_bind(question_id).push_bind(option.display_order); + b.push_bind(id) + .push_bind(option.text) + .push_bind(question_id) + .push_bind(option.display_order); }); let query = query_builder.build(); - let result = query.execute(pool).await?; + query.execute(transaction.deref_mut()).await?; + + Ok(()) + } + } + } + + pub async fn delete_from_db( + self, + question_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + match self { + Self::ShortAnswer => Ok(()), + Self::MultiChoice(_) | Self::MultiSelect(_) | Self::DropDown(_) | Self::Ranking(_) => { + sqlx::query!( + "DELETE FROM multi_option_question_options WHERE question_id = $1", + question_id + ) + .execute(transaction.deref_mut()) + .await?; Ok(()) } diff --git a/backend/server/src/models/ratings.rs b/backend/server/src/models/rating.rs similarity index 96% rename from backend/server/src/models/ratings.rs rename to backend/server/src/models/rating.rs index de01838f..8a8c2f38 100644 --- a/backend/server/src/models/ratings.rs +++ b/backend/server/src/models/rating.rs @@ -18,7 +18,6 @@ pub struct Rating { #[derive(Deserialize, Serialize)] pub struct NewRating { - pub rater_user_id: i64, pub rating: i32, pub comment: Option, } @@ -43,11 +42,11 @@ impl Rating { pub async fn create( new_rating: NewRating, application_id: i64, + rater_id: i64, mut snowflake_generator: SnowflakeIdGenerator, transaction: &mut Transaction<'_, Postgres>, ) -> Result<(), ChaosError> { let rating_id = snowflake_generator.generate(); - let rater_user_id = new_rating.rater_user_id; let rating = new_rating.rating; let comment = new_rating.comment; @@ -58,7 +57,7 @@ impl Rating { ", rating_id, application_id, - rater_user_id, + rater_id, rating, comment ) @@ -83,7 +82,7 @@ impl Rating { UPDATE application_ratings SET rating = $2, comment = $3, updated_at = $4 WHERE id = $1 - RETURNING id; + RETURNING id ", rating_id, rating, @@ -145,7 +144,7 @@ impl Rating { let _ = sqlx::query!( " DELETE FROM application_ratings WHERE id = $1 - RETURNING id; + RETURNING id ", rating_id ) diff --git a/backend/server/src/models/role.rs b/backend/server/src/models/role.rs index a52eb1d4..ff53014b 100644 --- a/backend/server/src/models/role.rs +++ b/backend/server/src/models/role.rs @@ -82,13 +82,13 @@ impl Role { } pub async fn delete(id: i64, pool: &Pool) -> Result<(), ChaosError> { - sqlx::query!( + let _ = sqlx::query!( " - DELETE FROM campaign_roles WHERE id = $1 + DELETE FROM campaign_roles WHERE id = $1 RETURNING id ", id ) - .execute(pool) + .fetch_one(pool) .await?; Ok(()) @@ -99,11 +99,11 @@ impl Role { role_data: RoleUpdate, pool: &Pool, ) -> Result<(), ChaosError> { - sqlx::query!( + let _ = sqlx::query!( " UPDATE campaign_roles SET (name, description, min_available, max_available, finalised) = ($2, $3, $4, $5, $6) - WHERE id = $1; + WHERE id = $1 RETURNING id ", id, role_data.name, @@ -112,7 +112,7 @@ impl Role { role_data.max_avaliable, role_data.finalised ) - .execute(pool) + .fetch_one(pool) .await?; Ok(()) diff --git a/backend/server/src/models/user.rs b/backend/server/src/models/user.rs index 19009a1f..cf5fb8e4 100644 --- a/backend/server/src/models/user.rs +++ b/backend/server/src/models/user.rs @@ -1,6 +1,6 @@ +use crate::models::error::ChaosError; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Pool, Postgres}; -use crate::models::error::ChaosError; #[derive(Deserialize, Serialize, sqlx::Type, Clone)] #[sqlx(type_name = "user_role", rename_all = "PascalCase")] @@ -63,56 +63,68 @@ pub struct UserDegree { impl User { pub async fn get(id: i64, pool: &Pool) -> Result { let user = sqlx::query_as!( - User, - r#" + User, + r#" SELECT id, email, zid, name, pronouns, gender, degree_name, degree_starting_year, role AS "role!: UserRole" FROM users WHERE id = $1 "#, - id - ) + id + ) .fetch_one(pool) .await?; Ok(user) } - pub async fn update_name(id: i64, name: String, pool: &Pool) -> Result<(), ChaosError> { + pub async fn update_name( + id: i64, + name: String, + pool: &Pool, + ) -> Result<(), ChaosError> { let _ = sqlx::query!( - " + " UPDATE users SET name = $1 WHERE id = $2 RETURNING id ", - name, - id - ) + name, + id + ) .fetch_one(pool) .await?; Ok(()) } - pub async fn update_pronouns(id: i64, pronouns: String, pool: &Pool) -> Result<(), ChaosError> { + pub async fn update_pronouns( + id: i64, + pronouns: String, + pool: &Pool, + ) -> Result<(), ChaosError> { let _ = sqlx::query!( - " + " UPDATE users SET pronouns = $1 WHERE id = $2 RETURNING id ", - pronouns, - id - ) + pronouns, + id + ) .fetch_one(pool) .await?; Ok(()) } - pub async fn update_gender(id: i64, gender: String, pool: &Pool) -> Result<(), ChaosError> { + pub async fn update_gender( + id: i64, + gender: String, + pool: &Pool, + ) -> Result<(), ChaosError> { let _ = sqlx::query!( - " + " UPDATE users SET gender = $1 WHERE id = $2 RETURNING id ", - gender, - id - ) + gender, + id + ) .fetch_one(pool) .await?; @@ -121,12 +133,12 @@ impl User { pub async fn update_zid(id: i64, zid: String, pool: &Pool) -> Result<(), ChaosError> { let _ = sqlx::query!( - " + " UPDATE users SET zid = $1 WHERE id = $2 RETURNING id ", - zid, - id - ) + zid, + id + ) .fetch_one(pool) .await?; @@ -140,13 +152,13 @@ impl User { pool: &Pool, ) -> Result<(), ChaosError> { let _ = sqlx::query!( - " + " UPDATE users SET degree_name = $1, degree_starting_year = $2 WHERE id = $3 RETURNING id ", - degree_name, - degree_starting_year, - id - ) + degree_name, + degree_starting_year, + id + ) .fetch_one(pool) .await?; diff --git a/backend/server/src/service/answer.rs b/backend/server/src/service/answer.rs new file mode 100644 index 00000000..682656a0 --- /dev/null +++ b/backend/server/src/service/answer.rs @@ -0,0 +1,32 @@ +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres}; + +pub async fn user_is_answer_owner( + user_id: i64, + answer_id: i64, + pool: &Pool, +) -> Result<(), ChaosError> { + let is_owner = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM ( + SELECT FROM answers ans + JOIN applications app ON ans.application_id = app.id + WHERE ans.id = $1 AND app.user_id = $2 + ) + ) + ", + answer_id, + user_id + ) + .fetch_one(pool) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_owner { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} diff --git a/backend/server/src/service/application.rs b/backend/server/src/service/application.rs index 4fdf052b..dcdf755b 100644 --- a/backend/server/src/service/application.rs +++ b/backend/server/src/service/application.rs @@ -1,7 +1,6 @@ use crate::models::error::ChaosError; use sqlx::{Pool, Postgres}; - pub async fn user_is_application_admin( user_id: i64, application_id: i64, @@ -32,5 +31,32 @@ pub async fn user_is_application_admin( } Ok(()) +} + +pub async fn user_is_application_owner( + user_id: i64, + application_id: i64, + pool: &Pool, +) -> Result<(), ChaosError> { + let is_owner = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM ( + SELECT FROM applications WHERE id = $1 AND user_id = $2 + ) + ) + ", + application_id, + user_id + ) + .fetch_one(pool) + .await? + .exists + .expect("`exists` should always exist in this query result"); -} \ No newline at end of file + if !is_owner { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} diff --git a/backend/server/src/service/mod.rs b/backend/server/src/service/mod.rs index 2e083622..8fd957f6 100644 --- a/backend/server/src/service/mod.rs +++ b/backend/server/src/service/mod.rs @@ -1,8 +1,10 @@ +pub mod answer; +pub mod application; pub mod auth; pub mod campaign; pub mod jwt; pub mod oauth2; pub mod organisation; -pub mod ratings; +pub mod question; +pub mod rating; pub mod role; -pub mod application; diff --git a/backend/server/src/service/question.rs b/backend/server/src/service/question.rs new file mode 100644 index 00000000..7dc41213 --- /dev/null +++ b/backend/server/src/service/question.rs @@ -0,0 +1,32 @@ +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres}; + +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(()) +} diff --git a/backend/server/src/service/ratings.rs b/backend/server/src/service/rating.rs similarity index 93% rename from backend/server/src/service/ratings.rs rename to backend/server/src/service/rating.rs index 0ff6b095..0d2dfe60 100644 --- a/backend/server/src/service/ratings.rs +++ b/backend/server/src/service/rating.rs @@ -1,11 +1,5 @@ -use crate::models::campaign::Campaign; use crate::models::error::ChaosError; -use crate::models::organisation::{Member, MemberList, OrganisationDetails, OrganisationRole}; -use chrono::{DateTime, Utc}; -use snowflake::SnowflakeIdGenerator; -use sqlx::{Pool, Postgres, Transaction}; -use std::ops::DerefMut; -use uuid::Uuid; +use sqlx::{Pool, Postgres}; /// Any member of the organisation that owns the campaign is an application /// viewer, because all members are either directors or execs (TODO: might be