Skip to content

Commit fe838cc

Browse files
authored
Merge pull request #708 from devsoc-unsw/CHAOS-702-create-offers-before-send-email
Chaos 702 create offers before send email
2 parents 501b4bd + a7c4cc9 commit fe838cc

File tree

13 files changed

+460
-19
lines changed

13 files changed

+460
-19
lines changed

backend/server/src/handler/campaign.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ use crate::models::transaction::DBTransaction;
2222
use axum::extract::{Json, Path, State};
2323
use axum::http::StatusCode;
2424
use axum::response::IntoResponse;
25+
use chrono::{DateTime, Utc};
26+
use serde::Deserialize;
2527

2628
/// Handler for campaign-related HTTP requests.
2729
pub struct CampaignHandler;

backend/server/src/handler/offer.rs

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,50 @@
44
//! - Creating and retrieving offers
55
//! - Replying to offers
66
//! - Previewing and sending offer emails
7+
//! - Queuing offer emails for the background worker (`EmailQueue`)
78
89
use crate::models::app::{AppMessage, AppState};
9-
use crate::models::auth::{OfferAdmin, OfferRecipient};
10+
use crate::models::auth::{OfferAdmin, OfferRecipient, CampaignAdmin};
1011
use crate::models::error::ChaosError;
1112
use crate::models::offer::{Offer, OfferReply};
1213
use crate::models::transaction::DBTransaction;
1314
use axum::extract::{Json, Path, State};
1415
use axum::http::StatusCode;
1516
use axum::response::IntoResponse;
17+
use chrono::{DateTime, Utc};
18+
use serde::Deserialize;
19+
use crate::models::email::{EmailQueue, EmailType};
1620

1721
/// Handler for offer-related HTTP requests.
1822
pub struct OfferHandler;
1923

24+
/// One outcome email row (subject/body already resolved on the client).
25+
#[derive(Deserialize)]
26+
#[allow(dead_code)]
27+
pub struct QueueOutcomeEmailItem {
28+
#[serde(default)]
29+
pub id: Option<String>,
30+
#[serde(deserialize_with = "crate::models::serde_string::deserialize")]
31+
pub application_id: i64,
32+
pub email: String,
33+
pub name: String,
34+
#[serde(default)]
35+
pub role: Option<String>,
36+
#[serde(deserialize_with = "crate::models::serde_string::deserialize")]
37+
pub role_id: i64,
38+
#[serde(deserialize_with = "crate::models::serde_string::deserialize")]
39+
pub email_template_id: i64,
40+
pub expiry: DateTime<Utc>,
41+
pub subject: String,
42+
pub body: String,
43+
pub email_type: EmailType,
44+
}
45+
46+
#[derive(Deserialize)]
47+
pub struct QueueOutcomeEmailsRequest {
48+
pub emails: Vec<QueueOutcomeEmailItem>,
49+
}
50+
2051
impl OfferHandler {
2152
/// Retrieves the details of a specific offer.
2253
///
@@ -141,4 +172,63 @@ impl OfferHandler {
141172

142173
Ok(AppMessage::OkMessage("Successfully sent offer"))
143174
}
175+
176+
/// Queues outcome emails for the worker (`EmailQueue`, same pipeline as offer email queue).
177+
///
178+
/// Auth matches viewing application ratings summary: org member for the campaign.
179+
pub async fn queue_outcome_emails(
180+
_user: CampaignAdmin,
181+
Path(campaign_id): Path<i64>,
182+
mut transaction: DBTransaction<'_>,
183+
State(mut state): State<AppState>,
184+
Json(body): Json<QueueOutcomeEmailsRequest>,
185+
) -> Result<impl IntoResponse, ChaosError> {
186+
if body.emails.is_empty() {
187+
return Err(ChaosError::BadRequestWithMessage(
188+
"No emails to queue".to_string(),
189+
));
190+
}
191+
192+
let count = body.emails.len();
193+
for item in body.emails {
194+
if matches!(item.email_type, EmailType::Accept) {
195+
let offer_id = Offer::create(
196+
campaign_id,
197+
item.application_id,
198+
item.email_template_id,
199+
item.role_id,
200+
item.expiry,
201+
&mut transaction.tx,
202+
&mut state.snowflake_generator,
203+
)
204+
.await?;
205+
if state.is_dev_env {
206+
let email = item.email;
207+
println!("need to call offers here, but sent to: {email}");
208+
} else {
209+
Offer::send_offer(
210+
offer_id,
211+
&mut transaction.tx,
212+
state.email_credentials.clone(),
213+
).await?;
214+
}
215+
} else {
216+
if state.is_dev_env {
217+
let email = item.email;
218+
println!("Sending reject email to {email}");
219+
} else {
220+
EmailQueue::add_to_queue(
221+
Some(item.name),
222+
item.email,
223+
item.subject,
224+
item.body,
225+
&mut transaction.tx,
226+
).await?;
227+
}
228+
}
229+
}
230+
231+
transaction.tx.commit().await?;
232+
Ok(AppMessage::OkMessage(format!("Queued {count} email(s) for delivery")))
233+
}
144234
}

backend/server/src/models/app.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ use crate::handler::answer::AnswerHandler;
22
use crate::handler::application::ApplicationHandler;
33
use crate::handler::auth::{DevLoginHandler, google_auth_init, google_callback, logout};
44
use crate::handler::campaign::CampaignHandler;
5-
use crate::handler::comment::CommentHandler;
65
use crate::handler::email_template::EmailTemplateHandler;
76
use crate::handler::offer::OfferHandler;
87
use crate::handler::organisation::OrganisationHandler;
@@ -488,9 +487,10 @@ pub async fn app() -> Result<(Router, AppState), ChaosError> {
488487
"/api/v1/offer/:offer_id/send",
489488
post(OfferHandler::send_offer),
490489
)
490+
// change below, needs to also create offers.
491491
.route(
492-
"/api/v1/comment/create",
493-
post(CommentHandler::create_comment),
492+
"/api/v1/offer/:campaign_id/outcome-emails/queue",
493+
post(OfferHandler::queue_outcome_emails),
494494
)
495495
// Invite routes
496496
// - GET /api/v1/invite/:code -> invite details
@@ -503,3 +503,4 @@ pub async fn app() -> Result<(Router, AppState), ChaosError> {
503503

504504
Ok((router, state_clone))
505505
}
506+

backend/server/src/models/email.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
use crate::models::error::ChaosError;
88
use lettre::transport::smtp::authentication::Credentials;
99
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
10-
use serde::Serialize;
10+
use serde::{Serialize, Deserialize};
1111
use sqlx::{Postgres, Transaction};
1212
use std::env;
1313
use std::ops::DerefMut;
@@ -46,6 +46,17 @@ pub struct EmailParts {
4646
pub body: String,
4747
}
4848

49+
/// Email Type
50+
///
51+
/// This enum represents the different types of email that can be sent(Interview, Accept, Reject)
52+
#[derive(Deserialize, Serialize, sqlx::Type, Clone, Debug)]
53+
#[sqlx(type_name = "email_type", rename_all = "PascalCase")]
54+
pub enum EmailType{
55+
Interview,
56+
Accept,
57+
Reject,
58+
}
59+
4960
impl ChaosEmail {
5061
/// Sets up email credentials from environment variables.
5162
///
@@ -197,7 +208,7 @@ impl EmailQueue {
197208
.fetch_optional(transaction.deref_mut())
198209
.await?;
199210

200-
if let Some(email) = email {
211+
if let Some(email) = email {
201212
ChaosEmail::send_message(
202213
email.recepient_name,
203214
email.recepient_email_address,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { useQuery } from "@tanstack/react-query";
5+
import { Button } from "@/components/ui/button";
6+
import { getOffer, OfferDetails, OfferReply, replyToOffer, OfferStatus } from "@/models/offer";
7+
import { dateToString } from "@/lib/utils";
8+
9+
export default function AcceptOffer({offerId}:{offerId:string}) {
10+
const { data: offerDetails } = useQuery({
11+
queryKey: [`offer-${offerId}`],
12+
queryFn: () => getOffer(offerId),
13+
});
14+
15+
const [ answered, setAnswered ] = useState(offerDetails?.status !== "Draft");
16+
17+
const handleReply = async (reply:OfferReply) => {
18+
await replyToOffer(offerId, reply)
19+
setAnswered(true);
20+
}
21+
22+
if (answered) {
23+
return (
24+
<div className="flex min-h-screen items-center justify-center">
25+
<p>
26+
Thank you for making a decision on this offer. You will be returned to the homepage shortly.
27+
</p>
28+
</div>
29+
)
30+
}
31+
return (
32+
<div className="flex min-h-screen items-center justify-center">
33+
<div className="w-full max-w-xl space-y-4 rounded-lg border bg-background p-6 shadow-sm">
34+
<div>
35+
<h2 className="text-lg font-semibold">Congratulations!</h2>
36+
</div>
37+
38+
<div className="space-y-3 text-sm">
39+
{!answered && !offerDetails && <p>Loading offer...</p>}
40+
41+
{!answered && offerDetails && (
42+
<div className="space-y-2">
43+
<p>
44+
Dear {offerDetails.user_name}, congratulations! We are please to inform you that you have been successful in your application to {offerDetails.organisation_name}'s {offerDetails.campaign_name}!
45+
You have been accepted for the following role: {offerDetails.role_name}. Please accept this offer by {offerDetails.expiry}.
46+
</p>
47+
<p>
48+
You have been accepted for the following role: {offerDetails.role_name}.
49+
</p>
50+
<p>
51+
Please accept this offer by {dateToString(offerDetails.expiry)}.
52+
</p>
53+
</div>
54+
)}
55+
</div>
56+
57+
<div className="flex flex-wrap gap-2">
58+
<Button
59+
variant="destructive"
60+
onClick={() => {
61+
handleReply({accept: false})
62+
}}
63+
disabled={!offerDetails || answered}
64+
>
65+
Reject
66+
</Button>
67+
<Button
68+
onClick={() => {
69+
handleReply({accept: true})
70+
}}
71+
disabled={!offerDetails || answered}
72+
>
73+
Accept
74+
</Button>
75+
</div>
76+
</div>
77+
</div>
78+
);
79+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
2+
import { getOffer } from "@/models/offer";
3+
import { redirect } from "next/navigation";
4+
import { OfferDetails } from "@/models/offer";
5+
import AcceptOffer from "./accept-offer";
6+
7+
export default async function OfferPage({
8+
params,
9+
}: {
10+
params: Promise<{
11+
lang: string;
12+
orgSlug: string;
13+
campaignSlug: string;
14+
offerId: string;
15+
}>;
16+
}) {
17+
const { offerId } = await params;
18+
19+
const queryClient = new QueryClient();
20+
await queryClient.prefetchQuery({
21+
queryKey: [`offer-${offerId}`],
22+
queryFn: () => getOffer(offerId),
23+
});
24+
25+
const offer: OfferDetails | undefined = queryClient.getQueryData([`offer-${offerId}`]);
26+
27+
if (!offer) {
28+
redirect(`/error`);
29+
}
30+
31+
return (
32+
<HydrationBoundary state={dehydrate(queryClient)}>
33+
<AcceptOffer offerId={offerId} />
34+
</HydrationBoundary>
35+
);
36+
}
37+

frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/campaigns/[campaignId]/applications/application-summary.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ function toApplicant(
6666
id: app.application_id,
6767
name: app.user_name,
6868
email: app.user_email,
69+
roleIds: applied,
6970
roles: applied.map((rid) => roleIdsToNames[rid] ?? rid),
7071
};
7172
}

0 commit comments

Comments
 (0)