Skip to content

Commit ccdff82

Browse files
263/Send Email to Candidates (#268)
* send email * lint * lint * move functionality to service * changes * lint * fix error
1 parent 8ae813c commit ccdff82

5 files changed

Lines changed: 266 additions & 6 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { type NextRequest } from 'next/server';
2+
import { z } from 'zod';
3+
import { handleError } from '@/lib/utils/errors.utils';
4+
import { getSession } from '@/lib/utils/auth.utils';
5+
import { assertRecruiterOrAbove } from '@/lib/utils/permissions.utils';
6+
import emailService from '@/lib/services/email.service';
7+
8+
const sendAssessmentInvitationSchema = z.object({
9+
candidateId: z.string().cuid(),
10+
});
11+
12+
export async function POST(request: NextRequest) {
13+
try {
14+
const session = await getSession();
15+
await assertRecruiterOrAbove(request.headers);
16+
17+
const body = await request.json();
18+
const { candidateId } = sendAssessmentInvitationSchema.parse(body);
19+
20+
const result = await emailService.sendAssessmentInvitationEmail(
21+
candidateId,
22+
session.activeOrganizationId
23+
);
24+
25+
return Response.json(
26+
{
27+
data: result,
28+
},
29+
{ status: 200 }
30+
);
31+
} catch (err) {
32+
return handleError(err);
33+
}
34+
}

src/lib/api/assessments.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,31 @@ export async function updateAssessmentStatus(
6464

6565
return json.data;
6666
}
67+
68+
/**
69+
* POST /api/assessments/send-invitation
70+
* Sends an assessment invitation email to a candidate
71+
*/
72+
export async function sendAssessmentInvitation(candidateId: string): Promise<{
73+
success: boolean;
74+
message: string;
75+
candidateName: string;
76+
positionTitle: string;
77+
assessmentId: string;
78+
}> {
79+
const res = await fetch('/api/assessments/send-invitation', {
80+
method: 'POST',
81+
headers: {
82+
'Content-Type': 'application/json',
83+
},
84+
body: JSON.stringify({ candidateId }),
85+
});
86+
87+
const json = await res.json();
88+
89+
if (!res.ok) {
90+
throw new Error(json.error ?? json.message ?? 'Failed to send assessment invitation');
91+
}
92+
93+
return json.data;
94+
}

src/lib/connectors/ses.connector.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
22

3+
interface EmailOptions {
4+
html?: string;
5+
}
6+
7+
interface MessageBody {
8+
Text: {
9+
Data: string;
10+
};
11+
Html?: {
12+
Data: string;
13+
};
14+
}
15+
316
class SESConnector {
417
private client: SESClient;
518

@@ -8,8 +21,25 @@ class SESConnector {
821
this.client = new SESClient({ region: 'us-east-2' });
922
}
1023

11-
async sendEmail(to: string, subject: string, body: string): Promise<boolean> {
24+
async sendEmail(
25+
to: string,
26+
subject: string,
27+
body: string,
28+
options?: EmailOptions
29+
): Promise<boolean> {
1230
try {
31+
const bodyConfig: MessageBody = {
32+
Text: {
33+
Data: body,
34+
},
35+
};
36+
37+
if (options?.html) {
38+
bodyConfig.Html = {
39+
Data: options.html,
40+
};
41+
}
42+
1343
const params = {
1444
Source: `no-reply@${process.env.EMAIL_DOMAIN}`,
1545
Destination: {
@@ -19,11 +49,7 @@ class SESConnector {
1949
Subject: {
2050
Data: subject,
2151
},
22-
Body: {
23-
Text: {
24-
Data: body,
25-
},
26-
},
52+
Body: bodyConfig,
2753
},
2854
};
2955
const res = await this.client.send(new SendEmailCommand(params));

src/lib/services/email.service.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { prisma } from '@/lib/prisma';
2+
import sesConnector from '@/lib/connectors/ses.connector';
3+
import { generateAssessmentInvitationHTML } from '@/lib/templates/invitation';
4+
5+
interface SendAssessmentInvitationResult {
6+
success: boolean;
7+
message: string;
8+
candidateName: string;
9+
positionTitle: string;
10+
assessmentId: string;
11+
}
12+
13+
export async function sendAssessmentInvitationEmail(
14+
candidateId: string,
15+
activeOrganizationId: string
16+
): Promise<SendAssessmentInvitationResult> {
17+
const candidate = await prisma.candidate.findUnique({
18+
where: { id: candidateId },
19+
include: {
20+
applications: {
21+
include: {
22+
assessment: true,
23+
position: true,
24+
},
25+
},
26+
organization: true,
27+
},
28+
});
29+
30+
if (!candidate) {
31+
throw new Error('Candidate not found');
32+
}
33+
34+
if (candidate.orgId !== activeOrganizationId) {
35+
throw new Error('Unauthorized');
36+
}
37+
38+
const applicationWithAssessment = candidate.applications.find((app) => app.assessment !== null);
39+
40+
if (!applicationWithAssessment?.assessment) {
41+
throw new Error('Candidate does not have an assigned assessment');
42+
}
43+
44+
const assessment = applicationWithAssessment.assessment;
45+
const position = applicationWithAssessment.position;
46+
const organization = candidate.organization;
47+
48+
const baseUrl =
49+
process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000';
50+
const assessmentUrl = `${baseUrl}/assessment/${assessment.uniqueLink}`;
51+
const logoUrl = `${baseUrl}/Sarge_logo.svg`;
52+
53+
//placeholder duration and expiration
54+
const durationMinutes = 120;
55+
const expirationDate = 'March 16, 2026 11:59PM EST';
56+
const htmlContent = generateAssessmentInvitationHTML({
57+
candidateName: candidate.name,
58+
positionTitle: position.title,
59+
organizationName: organization.name,
60+
assessmentId: assessment.id,
61+
assessmentUrl,
62+
logoUrl,
63+
durationMinutes,
64+
expirationDate,
65+
});
66+
67+
// Send email
68+
const emailSent = await sesConnector.sendEmail(
69+
candidate.email,
70+
`${organization.name} Software Engineering Role: Online Assessment Invitation`,
71+
`Hello ${candidate.name}, you have been invited to complete an online assessment for the ${position.title} position at ${organization.name}. Visit ${assessmentUrl} to begin.`,
72+
{ html: htmlContent }
73+
);
74+
75+
if (!emailSent) {
76+
throw new Error('Failed to send invitation email');
77+
}
78+
79+
return {
80+
success: true,
81+
message: `Assessment invitation sent to ${candidate.email}`,
82+
candidateName: candidate.name,
83+
positionTitle: position.title,
84+
assessmentId: assessment.id,
85+
};
86+
}
87+
88+
const emailService = {
89+
sendAssessmentInvitationEmail,
90+
};
91+
92+
export default emailService;

src/lib/templates/invitation.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
export interface AssessmentInvitationEmailData {
2+
candidateName: string;
3+
positionTitle: string;
4+
organizationName: string;
5+
assessmentId: string;
6+
assessmentUrl: string;
7+
logoUrl: string;
8+
durationMinutes: number;
9+
expirationDate: string;
10+
}
11+
12+
export function generateAssessmentInvitationHTML(data: AssessmentInvitationEmailData): string {
13+
return `
14+
<!doctype html>
15+
<html lang="en">
16+
<head>
17+
<meta charset="UTF-8" />
18+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
19+
<title>Online Assessment Invitation</title>
20+
</head>
21+
<body style="margin: 0; padding: 20px; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;">
22+
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #e5e5e7; border-radius: 12px; overflow: hidden;">
23+
24+
<!-- Header -->
25+
<div style="background-color: #4D38EF; padding: 24px; display: flex; align-items: flex-end; justify-content: space-between; gap: 20px;">
26+
<div style="flex: 1;">
27+
<h1 style="margin: 0; font-size: 18px; font-weight: 700; line-height: 1.3; color: white; letter-spacing: 0.5px;">
28+
${data.organizationName} ${data.positionTitle} Role: Online Assessment Invitation
29+
</h1>
30+
</div>
31+
<!-- where the logo should be -->
32+
</div>
33+
34+
<!-- Body -->
35+
<div style="padding: 32px 24px; color: #000000;">
36+
<p style="margin: 0 0 16px 0; font-size: 16px; line-height: 1.5;">Hello ${data.candidateName},</p>
37+
38+
<p style="margin: 0 0 24px 0; font-size: 14px; line-height: 1.6; color: #333333;">
39+
Thank you for your application and interest in ${data.organizationName}! The first step in our application process is a coding assessment to evaluate your technical and problem-solving skills.
40+
</p>
41+
42+
<!-- Guidelines -->
43+
<div style="margin: 0 0 32px 0;">
44+
<h3 style="margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #000000;">Online Assessment Guidelines:</h3>
45+
<ul style="margin: 0; padding-left: 20px; color: #333333; font-size: 14px; line-height: 1.6;">
46+
<li style="margin-bottom: 8px;">Your test will auto-submit your saved progress if you refresh or close the page.</li>
47+
<li style="margin-bottom: 8px;">Changing tabs or windows during the exam will be reported to the exam administrator.</li>
48+
<li>Questions are linear, meaning you will not be able to navigate between questions.</li>
49+
</ul>
50+
</div>
51+
52+
<div style="text-align: center; margin: 0 0 32px 0; padding: 24px 0;">
53+
<div style="margin-bottom: 16px; font-size: 14px; color: #333333;">
54+
<strong>Duration:</strong> ${data.durationMinutes} minutes
55+
</div>
56+
<div style="font-size: 14px; color: #333333;">
57+
<strong>Test Expiration Date:</strong> ${data.expirationDate}
58+
</div>
59+
</div>
60+
61+
<div style="text-align: center; margin: 0 0 24px 0;">
62+
<a
63+
href="/assessment/${data.assessmentId}"
64+
style="display: inline-block; background-color: #5D5BF7; color: white; font-weight: 500; padding: 12px 28px; text-decoration: none; border-radius: 8px; font-size: 15px;"
65+
>
66+
Open Assessment
67+
</a>
68+
</div>
69+
70+
<div style="text-align: center; font-size: 14px; color: #333333; margin: 0 0 16px 0;">
71+
<p style="margin: 0;">
72+
You can also use <a href="/assessment/${data.assessmentId}" style="color: #4D38EF; text-decoration: underline; font-weight: 600;">this link</a> to access your assessment. Visiting this link will not begin the assessment.
73+
</p>
74+
</div>
75+
</div>
76+
</div>
77+
</body>
78+
</html>
79+
`.trim();
80+
}

0 commit comments

Comments
 (0)