diff --git a/backend/migrations/20240406024211_create_organisations.sql b/backend/migrations/20240406024211_create_organisations.sql index ec67f190..2c3bfdf7 100644 --- a/backend/migrations/20240406024211_create_organisations.sql +++ b/backend/migrations/20240406024211_create_organisations.sql @@ -1,5 +1,6 @@ CREATE TABLE organisations ( id BIGINT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE, logo UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/backend/migrations/20240406025537_create_campaigns.sql b/backend/migrations/20240406025537_create_campaigns.sql index c300ee80..b1d8b6ec 100644 --- a/backend/migrations/20240406025537_create_campaigns.sql +++ b/backend/migrations/20240406025537_create_campaigns.sql @@ -1,6 +1,7 @@ CREATE TABLE campaigns ( id BIGINT PRIMARY KEY, organisation_id BIGINT NOT NULL, + slug TEXT NOT NULL, name TEXT NOT NULL, cover_image UUID, description TEXT, @@ -12,7 +13,8 @@ CREATE TABLE campaigns ( FOREIGN KEY(organisation_id) REFERENCES organisations(id) ON DELETE CASCADE - ON UPDATE CASCADE + ON UPDATE CASCADE, + UNIQUE (organisation_id, slug) ); CREATE TABLE campaign_roles ( diff --git a/backend/migrations/20241124054711_email_templates.sql b/backend/migrations/20241124054711_email_templates.sql new file mode 100644 index 00000000..393123c9 --- /dev/null +++ b/backend/migrations/20241124054711_email_templates.sql @@ -0,0 +1,12 @@ +CREATE TABLE email_templates ( + id BIGINT PRIMARY KEY, + organisation_id BIGINT NOT NULL, + name TEXT NOT NULL, + template TEXT NOT NULL, + CONSTRAINT FK_email_templates_organisations + FOREIGN KEY(organisation_id) + REFERENCES organisations(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE (organisation_id, name) +); \ No newline at end of file diff --git a/backend/migrations/20241126113027_offers.sql b/backend/migrations/20241126113027_offers.sql new file mode 100644 index 00000000..afe63925 --- /dev/null +++ b/backend/migrations/20241126113027_offers.sql @@ -0,0 +1,32 @@ +CREATE TYPE offer_status AS ENUM ('Draft', 'Sent', 'Accepted', 'Declined'); + +CREATE TABLE offers ( + id BIGINT PRIMARY KEY, + campaign_id BIGINT NOT NULL, + application_id BIGINT NOT NULL, + email_template_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + expiry TIMESTAMPTZ NOT NULL, + status offer_status NOT NULL DEFAULT 'Draft', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT FK_offers_campaigns + FOREIGN KEY(campaign_id) + REFERENCES campaigns(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT FK_offers_applications + FOREIGN KEY(application_id) + REFERENCES applications(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT FK_offers_email_templates + FOREIGN KEY(email_template_id) + REFERENCES email_templates(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT FK_offers_roles + FOREIGN KEY(role_id) + REFERENCES campaign_roles(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); \ No newline at end of file diff --git a/backend/server/Cargo.toml b/backend/server/Cargo.toml index 9c4babe2..de3d9bb6 100644 --- a/backend/server/Cargo.toml +++ b/backend/server/Cargo.toml @@ -26,4 +26,4 @@ rust-s3 = "0.34.0" rs-snowflake = "0.6" jsonwebtoken = "9.1" dotenvy = "0.15" - +handlebars = "6.2" diff --git a/backend/server/src/handler/answer.rs b/backend/server/src/handler/answer.rs index 34f3e0ea..065be1da 100644 --- a/backend/server/src/handler/answer.rs +++ b/backend/server/src/handler/answer.rs @@ -1,6 +1,6 @@ use crate::models::answer::{Answer, NewAnswer}; use crate::models::app::AppState; -use crate::models::auth::{AnswerOwner, ApplicationOwner, AuthUser}; +use crate::models::auth::{AnswerOwner, ApplicationOwner}; use crate::models::error::ChaosError; use crate::models::transaction::DBTransaction; use axum::extract::{Json, Path, State}; @@ -13,14 +13,14 @@ pub struct AnswerHandler; impl AnswerHandler { pub async fn create( State(state): State, - Path(path): Path, - user: AuthUser, + Path(application_id): Path, + _user: ApplicationOwner, mut transaction: DBTransaction<'_>, Json(data): Json, ) -> Result { + // TODO: Check whether the question is contained in the campaign being applied to let id = Answer::create( - user.user_id, - data.application_id, + application_id, data.question_id, data.answer_data, state.snowflake_generator, diff --git a/backend/server/src/handler/campaign.rs b/backend/server/src/handler/campaign.rs index ed5b9e24..0e1db393 100644 --- a/backend/server/src/handler/campaign.rs +++ b/backend/server/src/handler/campaign.rs @@ -6,6 +6,7 @@ use crate::models::auth::AuthUser; use crate::models::auth::CampaignAdmin; use crate::models::campaign::Campaign; use crate::models::error::ChaosError; +use crate::models::offer::Offer; use crate::models::role::{Role, RoleUpdate}; use crate::models::transaction::DBTransaction; use axum::extract::{Json, Path, State}; @@ -15,67 +16,87 @@ use axum::response::IntoResponse; pub struct CampaignHandler; impl CampaignHandler { pub async fn get( - State(state): State, + mut transaction: DBTransaction<'_>, Path(id): Path, _user: AuthUser, ) -> Result { - let campaign = Campaign::get(id, &state.db).await?; + let campaign = Campaign::get(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + Ok((StatusCode::OK, Json(campaign))) + } + + pub async fn get_by_slugs( + mut transaction: DBTransaction<'_>, + Path((organisation_slug, campaign_slug)): Path<(String, String)>, + _user: AuthUser, + ) -> Result { + let campaign = + Campaign::get_by_slugs(organisation_slug, campaign_slug, &mut transaction.tx).await?; + transaction.tx.commit().await?; Ok((StatusCode::OK, Json(campaign))) } pub async fn get_all( - State(state): State, + mut transaction: DBTransaction<'_>, _user: AuthUser, ) -> Result { - let campaigns = Campaign::get_all(&state.db).await?; + let campaigns = Campaign::get_all(&mut transaction.tx).await?; + transaction.tx.commit().await?; Ok((StatusCode::OK, Json(campaigns))) } pub async fn update( - State(state): State, + mut transaction: DBTransaction<'_>, Path(id): Path, _admin: CampaignAdmin, Json(request_body): Json, ) -> Result { - Campaign::update(id, request_body, &state.db).await?; + Campaign::update(id, request_body, &mut transaction.tx).await?; + transaction.tx.commit().await?; Ok((StatusCode::OK, "Successfully updated campaign")) } pub async fn update_banner( + mut transaction: DBTransaction<'_>, State(state): State, Path(id): Path, _admin: CampaignAdmin, ) -> Result { - let banner_url = Campaign::update_banner(id, &state.db, &state.storage_bucket).await?; + let banner_url = + Campaign::update_banner(id, &mut transaction.tx, &state.storage_bucket).await?; + transaction.tx.commit().await?; Ok((StatusCode::OK, Json(banner_url))) } pub async fn delete( - State(state): State, + mut transaction: DBTransaction<'_>, Path(id): Path, _admin: CampaignAdmin, ) -> Result { - Campaign::delete(id, &state.db).await?; + Campaign::delete(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; Ok((StatusCode::OK, "Successfully deleted campaign")) } pub async fn create_role( + mut transaction: DBTransaction<'_>, State(state): State, Path(id): Path, _admin: CampaignAdmin, Json(data): Json, ) -> Result { - Role::create(id, data, &state.db, state.snowflake_generator).await?; + Role::create(id, data, &mut transaction.tx, state.snowflake_generator).await?; + transaction.tx.commit().await?; Ok((StatusCode::OK, "Successfully created role")) } pub async fn get_roles( - State(state): State, + mut transaction: DBTransaction<'_>, Path(id): Path, _user: AuthUser, ) -> Result { - let roles = Role::get_all_in_campaign(id, &state.db).await?; - + let roles = Role::get_all_in_campaign(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; Ok((StatusCode::OK, Json(roles))) } @@ -107,4 +128,37 @@ impl CampaignHandler { transaction.tx.commit().await?; Ok((StatusCode::OK, Json(applications))) } + + pub async fn create_offer( + Path(id): Path, + State(state): State, + _admin: CampaignAdmin, + mut transaction: DBTransaction<'_>, + Json(data): Json, + ) -> Result { + let _ = Offer::create( + id, + data.application_id, + data.email_template_id, + data.role_id, + data.expiry, + &mut transaction.tx, + state.snowflake_generator, + ) + .await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully created offer")) + } + + pub async fn get_offers( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: CampaignAdmin, + ) -> Result { + let offers = Offer::get_by_campaign(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(offers))) + } } diff --git a/backend/server/src/handler/email_template.rs b/backend/server/src/handler/email_template.rs new file mode 100644 index 00000000..e392834f --- /dev/null +++ b/backend/server/src/handler/email_template.rs @@ -0,0 +1,42 @@ +use crate::models::app::AppState; +use crate::models::auth::EmailTemplateAdmin; +use crate::models::email_template::EmailTemplate; +use crate::models::error::ChaosError; +use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +pub struct EmailTemplateHandler; +impl EmailTemplateHandler { + pub async fn get( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: EmailTemplateAdmin, + ) -> Result { + let email_template = EmailTemplate::get(id, &mut transaction.tx).await?; + + Ok((StatusCode::OK, Json(email_template))) + } + + pub async fn update( + _user: EmailTemplateAdmin, + Path(id): Path, + State(state): State, + Json(request_body): Json, + ) -> Result { + EmailTemplate::update(id, request_body.name, request_body.template, &state.db).await?; + + Ok((StatusCode::OK, "Successfully updated email template")) + } + + pub async fn delete( + _user: EmailTemplateAdmin, + Path(id): Path, + State(state): State, + ) -> Result { + EmailTemplate::delete(id, &state.db).await?; + + Ok((StatusCode::OK, "Successfully delete email template")) + } +} diff --git a/backend/server/src/handler/mod.rs b/backend/server/src/handler/mod.rs index 73a94627..33675427 100644 --- a/backend/server/src/handler/mod.rs +++ b/backend/server/src/handler/mod.rs @@ -2,6 +2,8 @@ pub mod answer; pub mod application; pub mod auth; pub mod campaign; +pub mod email_template; +pub mod offer; pub mod organisation; pub mod question; pub mod rating; diff --git a/backend/server/src/handler/offer.rs b/backend/server/src/handler/offer.rs new file mode 100644 index 00000000..92297c63 --- /dev/null +++ b/backend/server/src/handler/offer.rs @@ -0,0 +1,66 @@ +use crate::models::auth::{OfferAdmin, OfferRecipient}; +use crate::models::error::ChaosError; +use crate::models::offer::{Offer, OfferReply}; +use crate::models::transaction::DBTransaction; +use axum::extract::{Json, Path}; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +pub struct OfferHandler; +impl OfferHandler { + pub async fn get( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: OfferAdmin, + ) -> Result { + let offer = Offer::get(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, Json(offer))) + } + + pub async fn delete( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: OfferAdmin, + ) -> Result { + Offer::delete(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully deleted offer")) + } + + pub async fn reply( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: OfferRecipient, + Json(reply): Json, + ) -> Result { + Offer::reply(id, reply.accept, &mut transaction.tx).await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully accepted offer")) + } + + pub async fn preview_email( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: OfferAdmin, + ) -> Result { + let string = Offer::preview_email(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, string)) + } + + pub async fn send_offer( + mut transaction: DBTransaction<'_>, + Path(id): Path, + _user: OfferAdmin, + ) -> Result { + Offer::send_offer(id, &mut transaction.tx).await?; + transaction.tx.commit().await?; + + Ok((StatusCode::OK, "Successfully sent offer")) + } +} diff --git a/backend/server/src/handler/organisation.rs b/backend/server/src/handler/organisation.rs index f98b27b2..7950f1fb 100644 --- a/backend/server/src/handler/organisation.rs +++ b/backend/server/src/handler/organisation.rs @@ -2,8 +2,12 @@ use crate::models; use crate::models::app::AppState; use crate::models::auth::SuperUser; use crate::models::auth::{AuthUser, OrganisationAdmin}; +use crate::models::campaign::Campaign; +use crate::models::email_template::EmailTemplate; use crate::models::error::ChaosError; -use crate::models::organisation::{AdminToRemove, AdminUpdateList, NewOrganisation, Organisation}; +use crate::models::organisation::{ + AdminToRemove, AdminUpdateList, NewOrganisation, Organisation, SlugCheck, +}; use crate::models::transaction::DBTransaction; use axum::extract::{Json, Path, State}; use axum::http::StatusCode; @@ -20,6 +24,7 @@ impl OrganisationHandler { ) -> Result { Organisation::create( data.admin, + data.slug, data.name, state.snowflake_generator, &mut transaction.tx, @@ -30,6 +35,16 @@ impl OrganisationHandler { Ok((StatusCode::OK, "Successfully created organisation")) } + pub async fn check_organisation_slug_availability( + State(state): State, + _user: SuperUser, + Json(data): Json, + ) -> Result { + Organisation::check_slug_availability(data.slug, &state.db).await?; + + Ok((StatusCode::OK, "Organisation slug is available")) + } + pub async fn get( State(state): State, Path(id): Path, @@ -39,6 +54,15 @@ impl OrganisationHandler { Ok((StatusCode::OK, Json(org))) } + pub async fn get_by_slug( + State(state): State, + Path(slug): Path, + _user: AuthUser, + ) -> Result { + let org = Organisation::get_by_slug(slug, &state.db).await?; + Ok((StatusCode::OK, Json(org))) + } + pub async fn delete( State(state): State, Path(id): Path, @@ -141,21 +165,61 @@ impl OrganisationHandler { pub async fn create_campaign( Path(id): Path, - State(mut state): State, + State(state): State, _admin: OrganisationAdmin, - Json(request_body): Json, + Json(request_body): Json, ) -> Result { Organisation::create_campaign( id, + request_body.slug, request_body.name, request_body.description, request_body.starts_at, request_body.ends_at, &state.db, - &mut state.snowflake_generator, + state.snowflake_generator, ) .await?; Ok((StatusCode::OK, "Successfully created campaign")) } + + pub async fn check_campaign_slug_availability( + Path(organisation_id): Path, + State(state): State, + _user: OrganisationAdmin, + Json(data): Json, + ) -> Result { + Campaign::check_slug_availability(organisation_id, data.slug, &state.db).await?; + + Ok((StatusCode::OK, "Campaign slug is available")) + } + + pub async fn create_email_template( + Path(id): Path, + State(state): State, + _admin: OrganisationAdmin, + Json(request_body): Json, + ) -> Result { + Organisation::create_email_template( + id, + request_body.name, + request_body.template, + &state.db, + state.snowflake_generator, + ) + .await?; + + Ok((StatusCode::OK, "Successfully created email template")) + } + + pub async fn get_all_email_templates( + _user: OrganisationAdmin, + Path(id): Path, + State(state): State, + ) -> Result { + let email_templates = EmailTemplate::get_all_by_organisation(id, &state.db).await?; + + Ok((StatusCode::OK, Json(email_templates))) + } } diff --git a/backend/server/src/handler/rating.rs b/backend/server/src/handler/rating.rs index 2ec36960..5c93e203 100644 --- a/backend/server/src/handler/rating.rs +++ b/backend/server/src/handler/rating.rs @@ -1,7 +1,6 @@ use crate::models::app::AppState; use crate::models::auth::{ - ApplicationCreatorGivenApplicationId, ApplicationReviewerGivenApplicationId, - ApplicationReviewerGivenRatingId, RatingCreator, + ApplicationReviewerGivenApplicationId, ApplicationReviewerGivenRatingId, RatingCreator, }; use crate::models::error::ChaosError; use crate::models::rating::{NewRating, Rating}; diff --git a/backend/server/src/models/answer.rs b/backend/server/src/models/answer.rs index 670a7ba8..c9f51772 100644 --- a/backend/server/src/models/answer.rs +++ b/backend/server/src/models/answer.rs @@ -1,11 +1,9 @@ use crate::models::error::ChaosError; -use crate::models::question::{ - MultiOptionData, MultiOptionQuestionOption, QuestionData, QuestionType, QuestionTypeParent, -}; +use crate::models::question::QuestionType; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use snowflake::SnowflakeIdGenerator; -use sqlx::{Pool, Postgres, Transaction}; +use sqlx::{Postgres, Transaction}; use std::ops::DerefMut; /// The `Answer` type that will be sent in API responses. @@ -64,7 +62,6 @@ pub struct AnswerTypeApplicationId { impl Answer { pub async fn create( - user_id: i64, application_id: i64, question_id: i64, answer_data: AnswerData, @@ -379,9 +376,6 @@ impl AnswerData { 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 - } }; } diff --git a/backend/server/src/models/app.rs b/backend/server/src/models/app.rs index ed41b78f..5fd59143 100644 --- a/backend/server/src/models/app.rs +++ b/backend/server/src/models/app.rs @@ -2,6 +2,8 @@ use crate::handler::answer::AnswerHandler; use crate::handler::application::ApplicationHandler; use crate::handler::auth::google_callback; use crate::handler::campaign::CampaignHandler; +use crate::handler::email_template::EmailTemplateHandler; +use crate::handler::offer::OfferHandler; use crate::handler::organisation::OrganisationHandler; use crate::handler::question::QuestionHandler; use crate::handler::rating::RatingHandler; @@ -89,18 +91,38 @@ pub async fn app() -> Result { get(ApplicationHandler::get_from_curr_user), ) .route("/api/v1/organisation", post(OrganisationHandler::create)) + .route( + "/api/v1/organisation/slug_check", + post(OrganisationHandler::check_organisation_slug_availability), + ) .route( "/api/v1/organisation/:organisation_id", get(OrganisationHandler::get).delete(OrganisationHandler::delete), ) + .route( + "/api/v1/organisation/slug/:slug", + get(OrganisationHandler::get_by_slug), + ) .route( "/api/v1/organisation/:organisation_id/campaign", post(OrganisationHandler::create_campaign), ) + .route( + "/api/v1/organisation/:organisation_id/campaign/slug_check", + post(OrganisationHandler::check_campaign_slug_availability), + ) .route( "/api/v1/organisation/:organisation_id/campaigns", get(OrganisationHandler::get_campaigns), ) + .route( + "/api/v1/organisation/:organisation_id/email_template", + post(OrganisationHandler::create_email_template), + ) + .route( + "/api/v1/organisation/:organisation_id/email_templates", + get(OrganisationHandler::get_all_email_templates), + ) .route( "/api/v1/organisation/:organisation_id/logo", patch(OrganisationHandler::update_logo), @@ -163,6 +185,10 @@ pub async fn app() -> Result { .put(CampaignHandler::update) .delete(CampaignHandler::delete), ) + .route( + "/api/v1/campaign/slug/:organisation_slug/:campaign_slug", + get(CampaignHandler::get_by_slugs), + ) .route("/api/v1/campaign", get(CampaignHandler::get_all)) .route( "/api/v1/campaign/:campaign_id/question", @@ -184,6 +210,14 @@ pub async fn app() -> Result { "/api/v1/campaign/:campaign_id/application", post(CampaignHandler::create_application), ) + .route( + "/api/v1/campaign/:campaign_id/offer", + post(CampaignHandler::create_offer), + ) + .route( + "/api/v1/campaign/:campaign_id/offers", + get(CampaignHandler::get_offers), + ) .route( "/api/v1/application/:application_id", get(ApplicationHandler::get), @@ -201,7 +235,7 @@ pub async fn app() -> Result { get(AnswerHandler::get_all_common_by_application), ) .route( - "/api/v1/application/:applicaiton_id/answer", + "/api/v1/application/:application_id/answer", post(AnswerHandler::create), ) .route( @@ -212,5 +246,25 @@ pub async fn app() -> Result { "/api/v1/answer/:answer_id", patch(AnswerHandler::update).delete(AnswerHandler::delete), ) + .route( + "/api/v1/email_template/:template_id", + get(EmailTemplateHandler::get) + .patch(EmailTemplateHandler::update) + .delete(EmailTemplateHandler::delete), + ) + .route( + "/api/v1/offer/:offer_id", + get(OfferHandler::get) + .delete(OfferHandler::delete) + .post(OfferHandler::reply), + ) + .route( + "/api/v1/offer/:offer_id/preview", + get(OfferHandler::preview_email), + ) + .route( + "/api/v1/offer/:offer_id/send", + post(OfferHandler::send_offer), + ) .with_state(state)) } diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index f28ac9f0..38902d2b 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -2,9 +2,10 @@ use crate::models::app::AppState; use crate::models::error::ChaosError; 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::auth::{assert_is_super_user, extract_user_id_from_request}; use crate::service::campaign::user_is_campaign_admin; -use crate::service::jwt::decode_auth_token; +use crate::service::email_template::user_is_email_template_admin; +use crate::service::offer::{assert_user_is_offer_admin, assert_user_is_offer_recipient}; use crate::service::organisation::assert_user_is_organisation_admin; use crate::service::question::user_is_question_admin; use crate::service::rating::{ @@ -16,7 +17,6 @@ use axum::extract::{FromRef, FromRequestParts, Path}; use axum::http::request::Parts; 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; @@ -58,21 +58,9 @@ where 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 user_id = extract_user_id_from_request(parts, &app_state).await?; - let claims = - decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; - - Ok(AuthUser { - user_id: claims.sub, - }) + Ok(AuthUser { user_id }) } } @@ -91,30 +79,11 @@ where 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 user_id = extract_user_id_from_request(parts, &app_state).await?; - let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; + assert_is_super_user(user_id, &app_state.db).await?; - let claims = - decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; - - let pool = &app_state.db; - let possible_user = is_super_user(claims.sub, pool).await; - - if let Ok(is_auth_user) = possible_user { - if is_auth_user { - return Ok(SuperUser { - user_id: claims.sub, - }); - } - } - - Err(ChaosError::Unauthorized) + Ok(SuperUser { user_id }) } } @@ -132,20 +101,7 @@ where 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 user_id = extract_user_id_from_request(parts, &app_state).await?; let organisation_id = *parts .extract::>>() @@ -154,7 +110,7 @@ where .get("organisation_id") .ok_or(ChaosError::BadRequest)?; - assert_user_is_organisation_admin(user_id, organisation_id, pool).await?; + assert_user_is_organisation_admin(user_id, organisation_id, &app_state.db).await?; Ok(OrganisationAdmin { user_id }) } @@ -174,20 +130,7 @@ where 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 user_id = extract_user_id_from_request(parts, &app_state).await?; let campaign_id = *parts .extract::>>() @@ -196,7 +139,7 @@ where .get("campaign_id") .ok_or(ChaosError::BadRequest)?; - user_is_campaign_admin(user_id, campaign_id, pool).await?; + user_is_campaign_admin(user_id, campaign_id, &app_state.db).await?; Ok(CampaignAdmin { user_id }) } @@ -216,20 +159,7 @@ where 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 user_id = extract_user_id_from_request(parts, &app_state).await?; let role_id = *parts .extract::>>() @@ -238,7 +168,7 @@ where .get("role_id") .ok_or(ChaosError::BadRequest)?; - user_is_role_admin(user_id, role_id, pool).await?; + user_is_role_admin(user_id, role_id, &app_state.db).await?; Ok(RoleAdmin { user_id }) } @@ -258,27 +188,14 @@ where 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 user_id = extract_user_id_from_request(parts, &app_state).await?; let Path(application_id) = parts .extract::>() .await .map_err(|_| ChaosError::BadRequest)?; - user_is_application_admin(user_id, application_id, pool).await?; + user_is_application_admin(user_id, application_id, &app_state.db).await?; Ok(ApplicationAdmin { user_id }) } @@ -305,29 +222,15 @@ where type Rejection = ChaosError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - // TODO: put into separate function, since this is just getting the id through jwt, and duplicated here. 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 user_id = extract_user_id_from_request(parts, &app_state).await?; let Path(application_id) = parts .extract::>() .await .map_err(|_| ChaosError::BadRequest)?; - assert_user_is_organisation_member(user_id, application_id, pool).await?; + assert_user_is_organisation_member(user_id, application_id, &app_state.db).await?; Ok(ApplicationReviewerGivenApplicationId { user_id }) } @@ -347,29 +250,15 @@ where type Rejection = ChaosError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - // TODO: put into separate function, since this is just getting the id through jwt, and duplicated here. 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 user_id = extract_user_id_from_request(parts, &app_state).await?; let Path(application_id) = parts .extract::>() .await .map_err(|_| ChaosError::BadRequest)?; - assert_user_is_organisation_member(user_id, application_id, pool).await?; + assert_user_is_organisation_member(user_id, application_id, &app_state.db).await?; Ok(ApplicationCreatorGivenApplicationId { user_id }) } @@ -389,29 +278,16 @@ where type Rejection = ChaosError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - // TODO: put into separate function, since this is just getting the id through jwt, and duplicated here. 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 user_id = extract_user_id_from_request(parts, &app_state).await?; let Path(rating_id) = parts .extract::>() .await .map_err(|_| ChaosError::BadRequest)?; - assert_user_is_application_reviewer_given_rating_id(user_id, rating_id, pool).await?; + assert_user_is_application_reviewer_given_rating_id(user_id, rating_id, &app_state.db) + .await?; Ok(ApplicationReviewerGivenRatingId { user_id }) } @@ -430,29 +306,16 @@ where type Rejection = ChaosError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - // TODO: put into separate function, since this is just getting the id through jwt, and duplicated here. 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 user_id = extract_user_id_from_request(parts, &app_state).await?; let Path(rating_id) = parts .extract::>() .await .map_err(|_| ChaosError::BadRequest)?; - assert_user_is_rating_creator_and_organisation_member(user_id, rating_id, pool).await?; + assert_user_is_rating_creator_and_organisation_member(user_id, rating_id, &app_state.db) + .await?; Ok(RatingCreator { user_id }) } @@ -472,20 +335,7 @@ where 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 user_id = extract_user_id_from_request(parts, &app_state).await?; let question_id = *parts .extract::>>() @@ -494,7 +344,7 @@ where .get("question_id") .ok_or(ChaosError::BadRequest)?; - user_is_question_admin(user_id, question_id, pool).await?; + user_is_question_admin(user_id, question_id, &app_state.db).await?; Ok(QuestionAdmin { user_id }) } @@ -514,20 +364,36 @@ where 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::>() + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let application_id = *parts + .extract::>>() .await - .map_err(|_| ChaosError::NotLoggedIn)?; + .map_err(|_| ChaosError::BadRequest)? + .get("application_id") + .ok_or(ChaosError::BadRequest)?; - let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; + user_is_application_owner(user_id, application_id, &app_state.db).await?; - let claims = - decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; + Ok(ApplicationOwner { user_id }) + } +} - let pool = &app_state.db; - let user_id = claims.sub; +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 user_id = extract_user_id_from_request(parts, &app_state).await?; let application_id = *parts .extract::>>() @@ -536,18 +402,18 @@ where .get("application_id") .ok_or(ChaosError::BadRequest)?; - user_is_application_owner(user_id, application_id, pool).await?; + user_is_answer_owner(user_id, application_id, &app_state.db).await?; - Ok(ApplicationOwner { user_id }) + Ok(AnswerOwner { user_id }) } } -pub struct AnswerOwner { +pub struct EmailTemplateAdmin { pub user_id: i64, } #[async_trait] -impl FromRequestParts for AnswerOwner +impl FromRequestParts for EmailTemplateAdmin where AppState: FromRef, S: Send + Sync, @@ -556,30 +422,75 @@ where 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::>() + let user_id = extract_user_id_from_request(parts, &app_state).await?; + + let template_id = *parts + .extract::>>() .await - .map_err(|_| ChaosError::NotLoggedIn)?; + .map_err(|_| ChaosError::BadRequest)? + .get("template_id") + .ok_or(ChaosError::BadRequest)?; - let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; + user_is_email_template_admin(user_id, template_id, &app_state.db).await?; - let claims = - decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; + Ok(EmailTemplateAdmin { user_id }) + } +} - let pool = &app_state.db; - let user_id = claims.sub; +pub struct OfferAdmin { + pub user_id: i64, +} - let application_id = *parts +#[async_trait] +impl FromRequestParts for OfferAdmin +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 user_id = extract_user_id_from_request(parts, &app_state).await?; + + let offer_id = *parts .extract::>>() .await .map_err(|_| ChaosError::BadRequest)? - .get("application_id") + .get("offer_id") .ok_or(ChaosError::BadRequest)?; - user_is_answer_owner(user_id, application_id, pool).await?; + assert_user_is_offer_admin(user_id, offer_id, &app_state.db).await?; - Ok(AnswerOwner { user_id }) + Ok(OfferAdmin { user_id }) + } +} + +pub struct OfferRecipient { + pub user_id: i64, +} + +#[async_trait] +impl FromRequestParts for OfferRecipient +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 user_id = extract_user_id_from_request(parts, &app_state).await?; + + let offer_id = *parts + .extract::>>() + .await + .map_err(|_| ChaosError::BadRequest)? + .get("offer_id") + .ok_or(ChaosError::BadRequest)?; + + assert_user_is_offer_recipient(user_id, offer_id, &app_state.db).await?; + + Ok(OfferRecipient { user_id }) } } diff --git a/backend/server/src/models/campaign.rs b/backend/server/src/models/campaign.rs index eb750de7..5897dc27 100644 --- a/backend/server/src/models/campaign.rs +++ b/backend/server/src/models/campaign.rs @@ -1,8 +1,9 @@ use chrono::{DateTime, Utc}; use s3::Bucket; use serde::{Deserialize, Serialize}; -use sqlx::FromRow; +use sqlx::{FromRow, Transaction}; use sqlx::{Pool, Postgres}; +use std::ops::DerefMut; use uuid::Uuid; use super::{error::ChaosError, storage::Storage}; @@ -10,6 +11,7 @@ use super::{error::ChaosError, storage::Storage}; #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct Campaign { pub id: i64, + pub slug: String, pub name: String, pub organisation_id: i64, pub organisation_name: String, @@ -24,8 +26,10 @@ pub struct Campaign { #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct CampaignDetails { pub id: i64, + pub campaign_slug: String, pub name: String, pub organisation_id: i64, + pub organisation_slug: String, pub organisation_name: String, pub cover_image: Option, pub description: Option, @@ -35,6 +39,7 @@ pub struct CampaignDetails { #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct OrganisationCampaign { pub id: i64, + pub slug: String, pub name: String, pub cover_image: Option, pub description: Option, @@ -44,6 +49,7 @@ pub struct OrganisationCampaign { #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct CampaignUpdate { + pub slug: String, pub name: String, pub description: String, pub starts_at: DateTime, @@ -57,7 +63,9 @@ pub struct CampaignBannerUpdate { impl Campaign { /// Get a list of all campaigns, both published and unpublished - pub async fn get_all(pool: &Pool) -> Result, ChaosError> { + pub async fn get_all( + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { let campaigns = sqlx::query_as!( Campaign, " @@ -65,26 +73,82 @@ impl Campaign { JOIN organisations o on c.organisation_id = o.id " ) - .fetch_all(pool) + .fetch_all(transaction.deref_mut()) .await?; Ok(campaigns) } /// Get a campaign based on it's id - pub async fn get(id: i64, pool: &Pool) -> Result { + pub async fn get( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { let campaign = sqlx::query_as!( CampaignDetails, " - SELECT c.id, c.name, c.organisation_id, o.name as organisation_name, - c.cover_image, c.description, c.starts_at, c.ends_at + SELECT c.id, c.slug AS campaign_slug, c.name, c.organisation_id, + o.slug AS organisation_slug, o.name as organisation_name, c.cover_image, + c.description, c.starts_at, c.ends_at FROM campaigns c JOIN organisations o on c.organisation_id = o.id WHERE c.id = $1 ", id ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(campaign) + } + + pub async fn check_slug_availability( + organisation_id: i64, + slug: String, + pool: &Pool, + ) -> Result<(), ChaosError> { + if !slug.is_ascii() { + return Err(ChaosError::BadRequest); + } + + let exists = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM campaigns WHERE organisation_id = $1 AND slug = $2) + ", + organisation_id, + slug + ) .fetch_one(pool) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if exists { + return Err(ChaosError::BadRequest); + } + + Ok(()) + } + + pub async fn get_by_slugs( + organisation_slug: String, + campaign_slug: String, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let campaign = sqlx::query_as!( + CampaignDetails, + " + SELECT c.id, c.slug AS campaign_slug, c.name, c.organisation_id, + o.slug AS organisation_slug, o.name as organisation_name, c.cover_image, + c.description, c.starts_at, c.ends_at + FROM campaigns c + JOIN organisations o on c.organisation_id = o.id + WHERE c.slug = $1 AND o.slug = $2 + ", + campaign_slug, + organisation_slug + ) + .fetch_one(transaction.deref_mut()) .await?; Ok(campaign) @@ -94,21 +158,22 @@ impl Campaign { pub async fn update( id: i64, update: CampaignUpdate, - pool: &Pool, + transaction: &mut Transaction<'_, Postgres>, ) -> Result<(), ChaosError> { _ = sqlx::query!( " UPDATE campaigns - SET name = $1, description = $2, starts_at = $3, ends_at = $4 - WHERE id = $5 RETURNING id + SET slug = $1, name = $2, description = $3, starts_at = $4, ends_at = $5 + WHERE id = $6 RETURNING id ", + update.slug, update.name, update.description, update.starts_at, update.ends_at, id ) - .fetch_one(pool) + .fetch_one(transaction.deref_mut()) .await?; Ok(()) @@ -118,7 +183,7 @@ impl Campaign { /// Returns the updated campaign pub async fn update_banner( id: i64, - pool: &Pool, + transaction: &mut Transaction<'_, Postgres>, storage_bucket: &Bucket, ) -> Result { let dt = Utc::now(); @@ -135,7 +200,7 @@ impl Campaign { current_time, id ) - .fetch_one(pool) + .fetch_one(transaction.deref_mut()) .await?; let upload_url = @@ -145,14 +210,17 @@ impl Campaign { } /// Delete a campaign from the database - pub async fn delete(id: i64, pool: &Pool) -> Result<(), ChaosError> { + pub async fn delete( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { _ = sqlx::query!( " DELETE FROM campaigns WHERE id = $1 RETURNING id ", id ) - .fetch_one(pool) + .fetch_one(transaction.deref_mut()) .await?; Ok(()) diff --git a/backend/server/src/models/email_template.rs b/backend/server/src/models/email_template.rs new file mode 100644 index 00000000..dd91cf00 --- /dev/null +++ b/backend/server/src/models/email_template.rs @@ -0,0 +1,114 @@ +use crate::models::error::ChaosError; +use chrono::{DateTime, Local, Utc}; +use handlebars::Handlebars; +use serde::{Deserialize, Serialize}; +use sqlx::{Pool, Postgres, Transaction}; +use std::collections::HashMap; +use std::ops::DerefMut; + +/// Email templates to update applicants +/// Supported tags: +/// - `name` +/// - `role` +/// - `organisation_name` +/// - `expiry_date` +/// - `campaign_name` +#[derive(Deserialize, Serialize)] +pub struct EmailTemplate { + pub id: i64, + pub organisation_id: i64, + pub name: String, + pub template: String, +} + +impl EmailTemplate { + pub async fn get( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let template = sqlx::query_as!( + EmailTemplate, + "SELECT * FROM email_templates WHERE id = $1", + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(template) + } + + pub async fn get_all_by_organisation( + organisation_id: i64, + pool: &Pool, + ) -> Result, ChaosError> { + let templates = sqlx::query_as!( + EmailTemplate, + "SELECT * FROM email_templates WHERE organisation_id = $1", + organisation_id + ) + .fetch_all(pool) + .await?; + + Ok(templates) + } + + pub async fn update( + id: i64, + name: String, + template: String, + pool: &Pool, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + " + UPDATE email_templates SET name = $2, template = $3 WHERE id = $1 RETURNING id + ", + id, + name, + template + ) + .fetch_one(pool) + .await?; + + Ok(()) + } + + pub async fn delete(id: i64, pool: &Pool) -> Result<(), ChaosError> { + let _ = sqlx::query!("DELETE FROM email_templates WHERE id = $1 RETURNING id", id) + .fetch_one(pool) + .await?; + + Ok(()) + } + + pub async fn generate_email( + name: String, + role: String, + organisation_name: String, + campaign_name: String, + expiry_date: DateTime, + email_template_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let template = EmailTemplate::get(email_template_id, transaction).await?; + + let mut handlebars = Handlebars::new(); + handlebars.register_template_string("template", template.template)?; + + let mut data = HashMap::new(); + data.insert("name", name); + data.insert("role", role); + data.insert("organisation_name", organisation_name); + data.insert("campaign_name", campaign_name); + data.insert( + "expiry_date", + expiry_date + .with_timezone(&Local) + .format("%d/%m/%Y %H:%M") + .to_string(), + ); + + let final_string = handlebars.render("template", &data)?; + + Ok(final_string) + } +} diff --git a/backend/server/src/models/error.rs b/backend/server/src/models/error.rs index d541562b..25f3f794 100644 --- a/backend/server/src/models/error.rs +++ b/backend/server/src/models/error.rs @@ -39,6 +39,12 @@ pub enum ChaosError { #[error("DotEnvy error")] DotEnvyError(#[from] dotenvy::Error), + + #[error("Templating error")] + TemplateError(#[from] handlebars::TemplateError), + + #[error("Template rendering error")] + TemplateRendorError(#[from] handlebars::RenderError), } /// 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 9f30a8bb..e2d03ca5 100644 --- a/backend/server/src/models/mod.rs +++ b/backend/server/src/models/mod.rs @@ -3,7 +3,9 @@ pub mod app; pub mod application; pub mod auth; pub mod campaign; +pub mod email_template; pub mod error; +pub mod offer; pub mod organisation; pub mod question; pub mod rating; diff --git a/backend/server/src/models/offer.rs b/backend/server/src/models/offer.rs new file mode 100644 index 00000000..d0201756 --- /dev/null +++ b/backend/server/src/models/offer.rs @@ -0,0 +1,223 @@ +use crate::models::email_template::EmailTemplate; +use crate::models::error::ChaosError; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use snowflake::SnowflakeIdGenerator; +use sqlx::{Postgres, Transaction}; +use std::ops::DerefMut; + +#[derive(Deserialize)] +pub struct Offer { + pub id: i64, + pub campaign_id: i64, + pub application_id: i64, + pub email_template_id: i64, + pub role_id: i64, + pub expiry: DateTime, + pub status: OfferStatus, + pub created_at: DateTime, +} + +#[derive(Deserialize, Serialize)] +pub struct OfferDetails { + pub id: i64, + pub campaign_id: i64, + pub organisation_name: String, + pub campaign_name: String, + pub application_id: i64, + pub user_id: i64, + pub user_name: String, + pub user_email: String, + pub email_template_id: i64, + pub role_id: i64, + pub role_name: String, + pub expiry: DateTime, + pub status: OfferStatus, + pub created_at: DateTime, +} + +#[derive(Deserialize, Serialize, sqlx::Type, Clone, Debug)] +#[sqlx(type_name = "offer_status", rename_all = "PascalCase")] +pub enum OfferStatus { + Draft, + Sent, + Accepted, + Declined, +} + +#[derive(Deserialize)] +pub struct OfferReply { + pub accept: bool, +} + +impl Offer { + pub async fn create( + campaign_id: i64, + application_id: i64, + email_template_id: i64, + role_id: i64, + expiry: DateTime, + transaction: &mut Transaction<'_, Postgres>, + mut snowflake_id_generator: SnowflakeIdGenerator, + ) -> Result { + let id = snowflake_id_generator.real_time_generate(); + + let _ = sqlx::query!( + " + INSERT INTO offers (id, campaign_id, application_id, email_template_id, role_id, expiry) VALUES ($1, $2, $3, $4, $5, $6) + ", + id, + campaign_id, + application_id, + email_template_id, + role_id, + expiry + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(id) + } + + pub async fn get( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let offer = sqlx::query_as!( + OfferDetails, + r#" + SELECT + off.id, off.campaign_id, off.application_id, off.email_template_id, + off.role_id, off.expiry, off.created_at, + off.status AS "status!: OfferStatus", + c.name as campaign_name, + o.name as organisation_name, + u.id as user_id, + u.name as user_name, + u.email as user_email, + r.name as role_name + FROM offers off + JOIN campaigns c ON c.id = off.campaign_id + JOIN organisations o ON o.id = c.organisation_id + JOIN applications a ON a.id = off.application_id + JOIN users u ON u.id = a.user_id + JOIN campaign_roles r ON r.id = off.role_id + WHERE off.id = $1 + "#, + id + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(offer) + } + + pub async fn get_by_campaign( + campaign_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result, ChaosError> { + let offers = sqlx::query_as!( + OfferDetails, + r#" + SELECT + off.id, off.campaign_id, off.application_id, off.email_template_id, + off.role_id, off.expiry, off.created_at, + off.status AS "status!: OfferStatus", + c.name as campaign_name, + o.name as organisation_name, + u.id as user_id, + u.name as user_name, + u.email as user_email, + r.name as role_name + FROM offers off + JOIN campaigns c on c.id = off.campaign_id + JOIN organisations o on o.id = c.organisation_id + JOIN applications a ON a.id = off.application_id + JOIN users u on u.id = a.user_id + JOIN campaign_roles r on r.id = off.role_id + WHERE off.id = $1 + "#, + campaign_id + ) + .fetch_all(transaction.deref_mut()) + .await?; + + Ok(offers) + } + + pub async fn delete( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!("DELETE FROM offers WHERE id = $1 RETURNING id", id) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + pub async fn reply( + id: i64, + accept: bool, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let offer = Offer::get(id, transaction).await?; + + if Utc::now() > offer.expiry { + return Err(ChaosError::BadRequest); + } + + let mut status = OfferStatus::Accepted; + if !accept { + status = OfferStatus::Declined; + } + + let _ = sqlx::query!( + "UPDATE offers SET status = $2 WHERE id = $1", + id, + status as OfferStatus + ) + .execute(transaction.deref_mut()) + .await?; + + Ok(()) + } + + pub async fn preview_email( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let offer = Offer::get(id, transaction).await?; + let email = EmailTemplate::generate_email( + offer.user_name, + offer.role_name, + offer.organisation_name, + offer.campaign_name, + offer.expiry, + offer.email_template_id, + transaction, + ) + .await?; + Ok(email) + } + + pub async fn send_offer( + id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let offer = Offer::get(id, transaction).await?; + let email = EmailTemplate::generate_email( + offer.user_name, + offer.role_name, + offer.organisation_name, + offer.campaign_name, + offer.expiry, + offer.email_template_id, + transaction, + ) + .await?; + + // TODO: Send email e.g. send_email(offer.user_email, email).await?; + Ok(()) + } +} diff --git a/backend/server/src/models/organisation.rs b/backend/server/src/models/organisation.rs index 94a34c24..94824c37 100644 --- a/backend/server/src/models/organisation.rs +++ b/backend/server/src/models/organisation.rs @@ -12,6 +12,7 @@ use uuid::Uuid; #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct Organisation { pub id: i64, + pub slug: String, pub name: String, pub logo: Option, pub created_at: DateTime, @@ -22,6 +23,7 @@ pub struct Organisation { #[derive(Deserialize, Serialize)] pub struct NewOrganisation { + pub slug: String, pub name: String, pub admin: i64, } @@ -29,6 +31,7 @@ pub struct NewOrganisation { #[derive(Deserialize, Serialize)] pub struct OrganisationDetails { pub id: i64, + pub slug: String, pub name: String, pub logo: Option, pub created_at: DateTime, @@ -63,21 +66,32 @@ pub struct AdminToRemove { pub user_id: i64, } +#[derive(Deserialize)] +pub struct SlugCheck { + pub slug: String, +} + impl Organisation { pub async fn create( admin_id: i64, + slug: String, name: String, mut snowflake_generator: SnowflakeIdGenerator, transaction: &mut Transaction<'_, Postgres>, ) -> Result<(), ChaosError> { + if !slug.is_ascii() { + return Err(ChaosError::BadRequest); + } + let id = snowflake_generator.generate(); sqlx::query!( " - INSERT INTO organisations (id, name) - VALUES ($1, $2) + INSERT INTO organisations (id, slug, name) + VALUES ($1, $2, $3) ", id, + slug, name ) .execute(transaction.deref_mut()) @@ -98,11 +112,37 @@ impl Organisation { Ok(()) } + pub async fn check_slug_availability( + slug: String, + pool: &Pool, + ) -> Result<(), ChaosError> { + if !slug.is_ascii() { + return Err(ChaosError::BadRequest); + } + + let exists = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM organisations WHERE slug = $1) + ", + slug + ) + .fetch_one(pool) + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if exists { + return Err(ChaosError::BadRequest); + } + + Ok(()) + } + pub async fn get(id: i64, pool: &Pool) -> Result { let organisation = sqlx::query_as!( OrganisationDetails, " - SELECT id, name, logo, created_at + SELECT id, slug, name, logo, created_at FROM organisations WHERE id = $1 ", @@ -114,6 +154,25 @@ impl Organisation { Ok(organisation) } + pub async fn get_by_slug( + slug: String, + pool: &Pool, + ) -> Result { + let organisation = sqlx::query_as!( + OrganisationDetails, + " + SELECT id, slug, name, logo, created_at + FROM organisations + WHERE slug = $1 + ", + slug + ) + .fetch_one(pool) + .await?; + + Ok(organisation) + } + pub async fn delete(id: i64, pool: &Pool) -> Result<(), ChaosError> { _ = sqlx::query!( " @@ -330,7 +389,7 @@ impl Organisation { let campaigns = sqlx::query_as!( OrganisationCampaign, " - SELECT id, name, cover_image, description, starts_at, ends_at + SELECT id, slug, name, cover_image, description, starts_at, ends_at FROM campaigns WHERE organisation_id = $1 ", @@ -344,22 +403,28 @@ impl Organisation { pub async fn create_campaign( organisation_id: i64, + slug: String, name: String, description: Option, starts_at: DateTime, ends_at: DateTime, pool: &Pool, - snowflake_id_generator: &mut SnowflakeIdGenerator, + mut snowflake_id_generator: SnowflakeIdGenerator, ) -> Result<(), ChaosError> { + if !slug.is_ascii() { + return Err(ChaosError::BadRequest); + } + let new_campaign_id = snowflake_id_generator.real_time_generate(); sqlx::query!( " - INSERT INTO campaigns (id, organisation_id, name, description, starts_at, ends_at) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO campaigns (id, organisation_id, slug, name, description, starts_at, ends_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) ", new_campaign_id, organisation_id, + slug, name, description, starts_at, @@ -370,4 +435,29 @@ impl Organisation { Ok(()) } + + pub async fn create_email_template( + organisation_id: i64, + name: String, + template: String, + pool: &Pool, + mut snowflake_generator: SnowflakeIdGenerator, + ) -> Result { + let id = snowflake_generator.generate(); + + let _ = sqlx::query!( + " + INSERT INTO email_templates (id, organisation_id, name, template) + VALUES ($1, $2, $3, $4) + ", + id, + organisation_id, + name, + template + ) + .execute(pool) + .await?; + + Ok(id) + } } diff --git a/backend/server/src/models/role.rs b/backend/server/src/models/role.rs index ff53014b..ec07082e 100644 --- a/backend/server/src/models/role.rs +++ b/backend/server/src/models/role.rs @@ -2,7 +2,8 @@ use crate::models::error::ChaosError; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use snowflake::SnowflakeIdGenerator; -use sqlx::{FromRow, Pool, Postgres}; +use sqlx::{FromRow, Pool, Postgres, Transaction}; +use std::ops::DerefMut; #[derive(Deserialize, Serialize, Clone, FromRow, Debug)] pub struct Role { @@ -41,7 +42,7 @@ impl Role { pub async fn create( campaign_id: i64, role_data: RoleUpdate, - pool: &Pool, + transaction: &mut Transaction<'_, Postgres>, mut snowflake_generator: SnowflakeIdGenerator, ) -> Result<(), ChaosError> { let id = snowflake_generator.generate(); @@ -59,7 +60,7 @@ impl Role { role_data.max_avaliable, role_data.finalised ) - .execute(pool) + .execute(transaction.deref_mut()) .await?; Ok(()) @@ -123,7 +124,7 @@ impl Role { */ pub async fn get_all_in_campaign( campaign_id: i64, - pool: &Pool, + transaction: &mut Transaction<'_, Postgres>, ) -> Result, ChaosError> { let roles = sqlx::query_as!( RoleDetails, @@ -134,7 +135,7 @@ impl Role { ", campaign_id ) - .fetch_all(pool) + .fetch_all(transaction.deref_mut()) .await?; Ok(roles) diff --git a/backend/server/src/service/auth.rs b/backend/server/src/service/auth.rs index cf136cfd..68b9302f 100644 --- a/backend/server/src/service/auth.rs +++ b/backend/server/src/service/auth.rs @@ -1,5 +1,11 @@ +use crate::models::app::AppState; +use crate::models::error::ChaosError; use crate::models::user::UserRole; -use anyhow::Result; +use crate::service::jwt::decode_auth_token; +use axum::http::request::Parts; +use axum::RequestPartsExt; +use axum_extra::headers::Cookie; +use axum_extra::TypedHeader; use snowflake::SnowflakeIdGenerator; use sqlx::{Pool, Postgres}; @@ -13,7 +19,7 @@ pub async fn create_or_get_user_id( name: String, pool: Pool, mut snowflake_generator: SnowflakeIdGenerator, -) -> Result { +) -> Result { let possible_user_id = sqlx::query!( "SELECT id FROM users WHERE lower(email) = $1", email.to_lowercase() @@ -39,14 +45,39 @@ pub async fn create_or_get_user_id( Ok(user_id) } -pub async fn is_super_user(user_id: i64, pool: &Pool) -> Result { +pub async fn assert_is_super_user(user_id: i64, pool: &Pool) -> Result<(), ChaosError> { let is_super_user = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND role = $2)", user_id, UserRole::SuperUser as UserRole ) .fetch_one(pool) - .await?; + .await? + .exists + .expect("`exists` should always exist in this query result"); + + if !is_super_user { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +} + +pub async fn extract_user_id_from_request( + parts: &mut Parts, + state: &AppState, +) -> Result { + let decoding_key = &state.decoding_key; + let jwt_validator = &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)?; - Ok(is_super_user.exists.unwrap()) + Ok(claims.sub) } diff --git a/backend/server/src/service/email_template.rs b/backend/server/src/service/email_template.rs new file mode 100644 index 00000000..3ef19e2a --- /dev/null +++ b/backend/server/src/service/email_template.rs @@ -0,0 +1,30 @@ +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres}; + +pub async fn user_is_email_template_admin( + user_id: i64, + template_id: i64, + pool: &Pool, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM email_templates et + JOIN organisation_members m on et.organisation_id = m.organisation_id + WHERE et.id = $1 AND m.user_id = $2 AND m.role = 'Admin' + ) + ", + template_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/mod.rs b/backend/server/src/service/mod.rs index 8fd957f6..e4ad6769 100644 --- a/backend/server/src/service/mod.rs +++ b/backend/server/src/service/mod.rs @@ -2,8 +2,10 @@ pub mod answer; pub mod application; pub mod auth; pub mod campaign; +pub mod email_template; pub mod jwt; pub mod oauth2; +pub mod offer; pub mod organisation; pub mod question; pub mod rating; diff --git a/backend/server/src/service/offer.rs b/backend/server/src/service/offer.rs new file mode 100644 index 00000000..f044ea7b --- /dev/null +++ b/backend/server/src/service/offer.rs @@ -0,0 +1,47 @@ +use crate::models::error::ChaosError; +use crate::models::offer::Offer; +use sqlx::{Pool, Postgres}; + +pub async fn assert_user_is_offer_admin( + user_id: i64, + offer_id: i64, + pool: &Pool, +) -> Result<(), ChaosError> { + let is_admin = sqlx::query!( + " + SELECT EXISTS( + SELECT 1 FROM offers off + JOIN campaigns c ON c.id = off.campaign_id + JOIN organisation_members m on c.organisation_id = m.organisation_id + WHERE off.id = $1 AND m.user_id = $2 AND m.role = 'Admin' + ) + ", + offer_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(()) +} + +pub async fn assert_user_is_offer_recipient( + user_id: i64, + offer_id: i64, + pool: &Pool, +) -> Result<(), ChaosError> { + let tx = &mut pool.begin().await?; + let offer = Offer::get(offer_id, tx).await?; + + if offer.user_id != user_id { + return Err(ChaosError::Unauthorized); + } + + Ok(()) +}