From 9d346e01cb50b85ebdd11d9476a043adaeea8c1b Mon Sep 17 00:00:00 2001 From: Kavika Date: Wed, 27 Nov 2024 00:27:19 +1100 Subject: [PATCH] offer CRUD --- backend/server/src/handler/campaign.rs | 25 ++++++++++ backend/server/src/handler/mod.rs | 1 + backend/server/src/handler/offer.rs | 67 ++++++++++++++++++++++++++ backend/server/src/models/app.rs | 21 ++++++++ backend/server/src/models/auth.rs | 62 +++++++++++++++++++++++- backend/server/src/models/offer.rs | 57 ++++++++++++---------- backend/server/src/service/mod.rs | 3 +- backend/server/src/service/offer.rs | 47 ++++++++++++++++++ 8 files changed, 255 insertions(+), 28 deletions(-) create mode 100644 backend/server/src/handler/offer.rs create mode 100644 backend/server/src/service/offer.rs diff --git a/backend/server/src/handler/campaign.rs b/backend/server/src/handler/campaign.rs index 80223e9d..30316ee2 100644 --- a/backend/server/src/handler/campaign.rs +++ b/backend/server/src/handler/campaign.rs @@ -11,6 +11,7 @@ use crate::models::transaction::DBTransaction; use axum::extract::{Json, Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; +use crate::models::offer::Offer; use crate::models::organisation::{Organisation, SlugCheck}; pub struct CampaignHandler; @@ -126,4 +127,28 @@ 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/mod.rs b/backend/server/src/handler/mod.rs index 5d8b5838..037cbbc2 100644 --- a/backend/server/src/handler/mod.rs +++ b/backend/server/src/handler/mod.rs @@ -8,3 +8,4 @@ pub mod rating; pub mod role; pub mod user; pub mod email_template; +pub mod offer; diff --git a/backend/server/src/handler/offer.rs b/backend/server/src/handler/offer.rs new file mode 100644 index 00000000..6bb0fc9e --- /dev/null +++ b/backend/server/src/handler/offer.rs @@ -0,0 +1,67 @@ +use axum::extract::{Path, Json}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use crate::models::auth::{AuthUser, CampaignAdmin, OfferAdmin, OfferRecipient}; +use crate::models::error::ChaosError; +use crate::models::offer::{Offer, OfferReply}; +use crate::models::transaction::DBTransaction; + + +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")) + } +} \ No newline at end of file diff --git a/backend/server/src/models/app.rs b/backend/server/src/models/app.rs index 29b4124a..91ae5f26 100644 --- a/backend/server/src/models/app.rs +++ b/backend/server/src/models/app.rs @@ -19,6 +19,7 @@ use sqlx::postgres::PgPoolOptions; use sqlx::{Pool, Postgres}; use std::env; use crate::handler::email_template::EmailTemplateHandler; +use crate::handler::offer::OfferHandler; use crate::models::organisation::Organisation; #[derive(Clone)] @@ -205,6 +206,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), @@ -239,5 +248,17 @@ pub async fn app() -> Result { .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 474b811d..06be344c 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -19,7 +19,9 @@ use axum::{async_trait, RequestPartsExt}; use axum_extra::{headers::Cookie, TypedHeader}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use crate::models::offer::Offer; use crate::service::email_template::user_is_email_template_admin; +use crate::service::offer::{assert_user_is_offer_admin, assert_user_is_offer_recipient}; // tells the web framework how to take the url query params they will have #[derive(Deserialize, Serialize)] @@ -431,7 +433,7 @@ where .extract::>>() .await .map_err(|_| ChaosError::BadRequest)? - .get("application_id") + .get("template_id") .ok_or(ChaosError::BadRequest)?; user_is_email_template_admin(user_id, template_id, &app_state.db).await?; @@ -439,3 +441,61 @@ where Ok(EmailTemplateAdmin { user_id }) } } + +pub struct OfferAdmin { + pub user_id: i64, +} + +#[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("offer_id") + .ok_or(ChaosError::BadRequest)?; + + assert_user_is_offer_admin(user_id, offer_id, &app_state.db).await?; + + 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/offer.rs b/backend/server/src/models/offer.rs index 5a0ac9ca..52725601 100644 --- a/backend/server/src/models/offer.rs +++ b/backend/server/src/models/offer.rs @@ -10,32 +10,32 @@ use crate::models::error::ChaosError; #[derive(Deserialize)] pub struct Offer { - id: i64, - campaign_id: i64, - application_id: i64, - email_template_id: i64, - role_id: i64, - expiry: DateTime, - status: OfferStatus, - created_at: DateTime, + 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 { - id: i64, - campaign_id: i64, - organisation_name: String, - campaign_name: String, - application_id: i64, - user_id: i64, - user_name: String, - user_email: String, - email_template_id: i64, - role_id: i64, - role_name: String, - expiry: DateTime, - status: OfferStatus, - created_at: DateTime, + 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)] @@ -47,8 +47,13 @@ pub enum OfferStatus { 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<(), ChaosError> { + 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!( @@ -65,7 +70,7 @@ impl Offer { .execute(transaction.deref_mut()) .await?; - Ok(()) + Ok(id) } pub async fn get(id: i64, transaction: &mut Transaction<'_, Postgres>) -> Result { @@ -136,10 +141,10 @@ impl Offer { Ok(()) } - pub async fn reply(id: i64, accepting_user_id: i64, accept: bool, transaction: &mut Transaction<'_, Postgres>) -> Result<(), ChaosError> { + 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 || accepting_user_id != offer.user_id { + if Utc::now() > offer.expiry { return Err(ChaosError::BadRequest) } diff --git a/backend/server/src/service/mod.rs b/backend/server/src/service/mod.rs index 98720276..53f76596 100644 --- a/backend/server/src/service/mod.rs +++ b/backend/server/src/service/mod.rs @@ -8,4 +8,5 @@ pub mod organisation; pub mod question; pub mod rating; pub mod role; -pub mod email_template; \ No newline at end of file +pub mod email_template; +pub mod offer; \ No newline at end of file diff --git a/backend/server/src/service/offer.rs b/backend/server/src/service/offer.rs new file mode 100644 index 00000000..1a639441 --- /dev/null +++ b/backend/server/src/service/offer.rs @@ -0,0 +1,47 @@ +use crate::models::error::ChaosError; +use sqlx::{Pool, Postgres}; +use crate::models::offer::Offer; + +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(()) +} \ No newline at end of file