Skip to content

Commit e0c6a56

Browse files
committed
send email
1 parent 4154aaa commit e0c6a56

4 files changed

Lines changed: 234 additions & 6 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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 { prisma } from '@/lib/prisma';
7+
import sesConnector from '@/lib/connectors/ses.connector';
8+
import { generateAssessmentInvitationHTML } from '@/lib/templates/assessment-invitation-email';
9+
import { type Application } from '@/generated/prisma';
10+
11+
const sendAssessmentInvitationSchema = z.object({
12+
candidateId: z.string().cuid(),
13+
});
14+
15+
export async function POST(request: NextRequest) {
16+
try {
17+
const session = await getSession();
18+
await assertRecruiterOrAbove(request.headers);
19+
20+
const body = await request.json();
21+
const { candidateId } = sendAssessmentInvitationSchema.parse(body);
22+
23+
const candidate = await prisma.candidate.findUnique({
24+
where: { id: candidateId },
25+
include: {
26+
applications: {
27+
include: {
28+
assessment: true,
29+
position: true,
30+
},
31+
},
32+
organization: true,
33+
},
34+
});
35+
36+
//validation
37+
if (!candidate) {
38+
return Response.json({ error: 'Candidate not found' }, { status: 404 });
39+
}
40+
41+
if (candidate.orgId !== session.activeOrganizationId) {
42+
return Response.json({ error: 'Unauthorized' }, { status: 403 });
43+
}
44+
45+
const applicationWithAssessment = candidate.applications.find(
46+
(app: Application & { assessment: any }) => app.assessment !== null
47+
);
48+
49+
if (!applicationWithAssessment || !applicationWithAssessment.assessment) {
50+
return Response.json(
51+
{ error: 'Candidate does not have an assigned assessment' },
52+
{ status: 400 }
53+
);
54+
}
55+
56+
const assessment = applicationWithAssessment.assessment;
57+
const position = applicationWithAssessment.position;
58+
const organization = candidate.organization;
59+
60+
//create url
61+
const baseUrl =
62+
process.env.BETTER_AUTH_URL ||
63+
process.env.NEXT_PUBLIC_APP_URL ||
64+
'http://localhost:3000';
65+
const assessmentUrl = `${baseUrl}/assessment/${assessment.uniqueLink}`;
66+
const logoUrl = `${baseUrl}/Sarge_logo.svg`;
67+
68+
//email html
69+
const htmlContent = generateAssessmentInvitationHTML({
70+
candidateName: candidate.name,
71+
positionTitle: position.title,
72+
organizationName: organization.name,
73+
assessmentId: assessment.id,
74+
assessmentUrl,
75+
logoUrl,
76+
});
77+
78+
//send email
79+
const emailSent = await sesConnector.sendEmail(
80+
candidate.email,
81+
`${organization.name} Software Engineering Role: Online Assessment Invitation`,
82+
`Hello ${candidate.name}, you have been invited to complete an online assessment for the ${position.title} position at ${organization.name}. Visit ${assessmentUrl} to begin.`,
83+
{ html: htmlContent }
84+
);
85+
86+
if (!emailSent) {
87+
return Response.json({ error: 'Failed to send invitation email' }, { status: 500 });
88+
}
89+
90+
return Response.json(
91+
{
92+
data: {
93+
success: true,
94+
message: `Assessment invitation sent to ${candidate.email}`,
95+
candidateName: candidate.name,
96+
positionTitle: position.title,
97+
assessmentId: assessment.id,
98+
},
99+
},
100+
{ status: 200 }
101+
);
102+
} catch (err) {
103+
return handleError(err);
104+
}
105+
}

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: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
22

3+
interface EmailOptions {
4+
html?: string;
5+
}
6+
37
class SESConnector {
48
private client: SESClient;
59

@@ -8,8 +12,25 @@ class SESConnector {
812
this.client = new SESClient({ region: 'us-east-2' });
913
}
1014

11-
async sendEmail(to: string, subject: string, body: string): Promise<boolean> {
15+
async sendEmail(
16+
to: string,
17+
subject: string,
18+
body: string,
19+
options?: EmailOptions
20+
): Promise<boolean> {
1221
try {
22+
const bodyConfig: any = {
23+
Text: {
24+
Data: body,
25+
},
26+
};
27+
28+
if (options?.html) {
29+
bodyConfig.Html = {
30+
Data: options.html,
31+
};
32+
}
33+
1334
const params = {
1435
Source: `no-reply@${process.env.EMAIL_DOMAIN}`,
1536
Destination: {
@@ -19,11 +40,7 @@ class SESConnector {
1940
Subject: {
2041
Data: subject,
2142
},
22-
Body: {
23-
Text: {
24-
Data: body,
25-
},
26-
},
43+
Body: bodyConfig,
2744
},
2845
};
2946
const res = await this.client.send(new SendEmailCommand(params));
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
export interface AssessmentInvitationEmailData {
2+
candidateName: string;
3+
positionTitle: string;
4+
organizationName: string;
5+
assessmentId: string;
6+
assessmentUrl: string;
7+
logoUrl: string;
8+
}
9+
10+
export function generateAssessmentInvitationHTML(data: AssessmentInvitationEmailData): string {
11+
return `
12+
<!doctype html>
13+
<html lang="en">
14+
<head>
15+
<meta charset="UTF-8" />
16+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
17+
<title>Online Assessment Invitation</title>
18+
</head>
19+
<body style="margin: 0; padding: 20px; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;">
20+
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #e5e5e7; border-radius: 12px; overflow: hidden;">
21+
22+
<!-- Header -->
23+
<div style="background-color: #4D38EF; padding: 24px; display: flex; align-items: flex-end; justify-content: space-between; gap: 20px;">
24+
<div style="flex: 1;">
25+
<h1 style="margin: 0; font-size: 18px; font-weight: 700; line-height: 1.3; color: white; letter-spacing: 0.5px;">
26+
${data.organizationName} ${data.positionTitle} Role: Online Assessment Invitation
27+
</h1>
28+
</div>
29+
<!-- where the logo should be -->
30+
</div>
31+
32+
<!-- Body -->
33+
<div style="padding: 32px 24px; color: #000000;">
34+
<p style="margin: 0 0 16px 0; font-size: 16px; line-height: 1.5;">Hello ${data.candidateName},</p>
35+
36+
<p style="margin: 0 0 24px 0; font-size: 14px; line-height: 1.6; color: #333333;">
37+
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.
38+
</p>
39+
40+
<!-- Guidelines -->
41+
<div style="margin: 0 0 32px 0;">
42+
<h3 style="margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #000000;">Online Assessment Guidelines:</h3>
43+
<ul style="margin: 0; padding-left: 20px; color: #333333; font-size: 14px; line-height: 1.6;">
44+
<li style="margin-bottom: 8px;">Your test will auto-submit your saved progress if you refresh or close the page.</li>
45+
<li style="margin-bottom: 8px;">Changing tabs or windows during the exam will be reported to the exam administrator.</li>
46+
<li>Questions are linear, meaning you will not be able to navigate between questions.</li>
47+
</ul>
48+
</div>
49+
50+
<div style="text-align: center; margin: 0 0 32px 0; padding: 24px 0;">
51+
<div style="margin-bottom: 16px; font-size: 14px; color: #333333;">
52+
<strong>Duration:</strong> 120 minutes
53+
</div>
54+
<div style="font-size: 14px; color: #333333;">
55+
<strong>Test Expiration Date:</strong> March 16, 2026 11:59PM EST
56+
</div>
57+
</div>
58+
59+
<div style="text-align: center; margin: 0 0 24px 0;">
60+
<a
61+
href="/assessment/${data.assessmentId}"
62+
style="display: inline-block; background-color: #5D5BF7; color: white; font-weight: 500; padding: 12px 28px; text-decoration: none; border-radius: 8px; font-size: 15px;"
63+
>
64+
Open Assessment
65+
</a>
66+
</div>
67+
68+
<div style="text-align: center; font-size: 14px; color: #333333; margin: 0 0 16px 0;">
69+
<p style="margin: 0;">
70+
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.
71+
</p>
72+
</div>
73+
</div>
74+
</div>
75+
</body>
76+
</html>
77+
`.trim();
78+
}

0 commit comments

Comments
 (0)