Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/app/api/assessments/send-invitation/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { type NextRequest } from 'next/server';
import { z } from 'zod';
import { handleError } from '@/lib/utils/errors.utils';
import { getSession } from '@/lib/utils/auth.utils';
import { assertRecruiterOrAbove } from '@/lib/utils/permissions.utils';
import emailService from '@/lib/services/email.service';

const sendAssessmentInvitationSchema = z.object({
candidateId: z.string().cuid(),
});

export async function POST(request: NextRequest) {
Copy link
Copy Markdown
Collaborator

@LOTaher LOTaher Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please create a service function to define logic, and call that service function within this controller? Simply just for code cleanliness purposes.

You can create a new EmailService file and follow the same conventions as we do in the other files.

try {
const session = await getSession();
await assertRecruiterOrAbove(request.headers);

const body = await request.json();
const { candidateId } = sendAssessmentInvitationSchema.parse(body);

const result = await emailService.sendAssessmentInvitationEmail(
candidateId,
session.activeOrganizationId
);

return Response.json(
{
data: result,
},
{ status: 200 }
);
} catch (err) {
return handleError(err);
}
}
28 changes: 28 additions & 0 deletions src/lib/api/assessments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,31 @@ export async function updateAssessmentStatus(

return json.data;
}

/**
* POST /api/assessments/send-invitation
* Sends an assessment invitation email to a candidate
*/
export async function sendAssessmentInvitation(candidateId: string): Promise<{
success: boolean;
message: string;
candidateName: string;
positionTitle: string;
assessmentId: string;
}> {
const res = await fetch('/api/assessments/send-invitation', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ candidateId }),
});

const json = await res.json();

if (!res.ok) {
throw new Error(json.error ?? json.message ?? 'Failed to send assessment invitation');
}

return json.data;
}
38 changes: 32 additions & 6 deletions src/lib/connectors/ses.connector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';

interface EmailOptions {
html?: string;
}

interface MessageBody {
Text: {
Data: string;
};
Html?: {
Data: string;
};
}

class SESConnector {
private client: SESClient;

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

async sendEmail(to: string, subject: string, body: string): Promise<boolean> {
async sendEmail(
to: string,
subject: string,
body: string,
options?: EmailOptions
): Promise<boolean> {
try {
const bodyConfig: MessageBody = {
Text: {
Data: body,
},
};

if (options?.html) {
bodyConfig.Html = {
Data: options.html,
};
}

const params = {
Source: `no-reply@${process.env.EMAIL_DOMAIN}`,
Destination: {
Expand All @@ -19,11 +49,7 @@ class SESConnector {
Subject: {
Data: subject,
},
Body: {
Text: {
Data: body,
},
},
Body: bodyConfig,
},
};
const res = await this.client.send(new SendEmailCommand(params));
Expand Down
92 changes: 92 additions & 0 deletions src/lib/services/email.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { prisma } from '@/lib/prisma';
import sesConnector from '@/lib/connectors/ses.connector';
import { generateAssessmentInvitationHTML } from '@/lib/templates/invitation';

interface SendAssessmentInvitationResult {
success: boolean;
message: string;
candidateName: string;
positionTitle: string;
assessmentId: string;
}

export async function sendAssessmentInvitationEmail(
candidateId: string,
activeOrganizationId: string
): Promise<SendAssessmentInvitationResult> {
const candidate = await prisma.candidate.findUnique({
where: { id: candidateId },
include: {
applications: {
include: {
assessment: true,
position: true,
},
},
organization: true,
},
});

if (!candidate) {
throw new Error('Candidate not found');
}

if (candidate.orgId !== activeOrganizationId) {
throw new Error('Unauthorized');
}

const applicationWithAssessment = candidate.applications.find((app) => app.assessment !== null);

if (!applicationWithAssessment?.assessment) {
throw new Error('Candidate does not have an assigned assessment');
}

const assessment = applicationWithAssessment.assessment;
const position = applicationWithAssessment.position;
const organization = candidate.organization;

const baseUrl =
process.env.BETTER_AUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000';
const assessmentUrl = `${baseUrl}/assessment/${assessment.uniqueLink}`;
const logoUrl = `${baseUrl}/Sarge_logo.svg`;

//placeholder duration and expiration
const durationMinutes = 120;
const expirationDate = 'March 16, 2026 11:59PM EST';
const htmlContent = generateAssessmentInvitationHTML({
candidateName: candidate.name,
positionTitle: position.title,
organizationName: organization.name,
assessmentId: assessment.id,
assessmentUrl,
logoUrl,
durationMinutes,
expirationDate,
});

// Send email
const emailSent = await sesConnector.sendEmail(
candidate.email,
`${organization.name} Software Engineering Role: Online Assessment Invitation`,
`Hello ${candidate.name}, you have been invited to complete an online assessment for the ${position.title} position at ${organization.name}. Visit ${assessmentUrl} to begin.`,
{ html: htmlContent }
);

if (!emailSent) {
throw new Error('Failed to send invitation email');
}

return {
success: true,
message: `Assessment invitation sent to ${candidate.email}`,
candidateName: candidate.name,
positionTitle: position.title,
assessmentId: assessment.id,
};
}

const emailService = {
sendAssessmentInvitationEmail,
};

export default emailService;
80 changes: 80 additions & 0 deletions src/lib/templates/invitation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export interface AssessmentInvitationEmailData {
candidateName: string;
positionTitle: string;
organizationName: string;
assessmentId: string;
assessmentUrl: string;
logoUrl: string;
durationMinutes: number;
expirationDate: string;
}

export function generateAssessmentInvitationHTML(data: AssessmentInvitationEmailData): string {
return `
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Online Assessment Invitation</title>
</head>
<body style="margin: 0; padding: 20px; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #e5e5e7; border-radius: 12px; overflow: hidden;">

<!-- Header -->
<div style="background-color: #4D38EF; padding: 24px; display: flex; align-items: flex-end; justify-content: space-between; gap: 20px;">
<div style="flex: 1;">
<h1 style="margin: 0; font-size: 18px; font-weight: 700; line-height: 1.3; color: white; letter-spacing: 0.5px;">
${data.organizationName} ${data.positionTitle} Role: Online Assessment Invitation
</h1>
</div>
<!-- where the logo should be -->
</div>

<!-- Body -->
<div style="padding: 32px 24px; color: #000000;">
<p style="margin: 0 0 16px 0; font-size: 16px; line-height: 1.5;">Hello ${data.candidateName},</p>

<p style="margin: 0 0 24px 0; font-size: 14px; line-height: 1.6; color: #333333;">
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.
</p>

<!-- Guidelines -->
<div style="margin: 0 0 32px 0;">
<h3 style="margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #000000;">Online Assessment Guidelines:</h3>
<ul style="margin: 0; padding-left: 20px; color: #333333; font-size: 14px; line-height: 1.6;">
<li style="margin-bottom: 8px;">Your test will auto-submit your saved progress if you refresh or close the page.</li>
<li style="margin-bottom: 8px;">Changing tabs or windows during the exam will be reported to the exam administrator.</li>
<li>Questions are linear, meaning you will not be able to navigate between questions.</li>
</ul>
</div>

<div style="text-align: center; margin: 0 0 32px 0; padding: 24px 0;">
<div style="margin-bottom: 16px; font-size: 14px; color: #333333;">
<strong>Duration:</strong> ${data.durationMinutes} minutes
</div>
<div style="font-size: 14px; color: #333333;">
<strong>Test Expiration Date:</strong> ${data.expirationDate}
</div>
</div>

<div style="text-align: center; margin: 0 0 24px 0;">
<a
href="/assessment/${data.assessmentId}"
style="display: inline-block; background-color: #5D5BF7; color: white; font-weight: 500; padding: 12px 28px; text-decoration: none; border-radius: 8px; font-size: 15px;"
>
Open Assessment
</a>
</div>

<div style="text-align: center; font-size: 14px; color: #333333; margin: 0 0 16px 0;">
<p style="margin: 0;">
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.
</p>
</div>
</div>
</div>
</body>
</html>
`.trim();
}
Loading