Skip to content

Commit 23c76a0

Browse files
277/Add Send Email Button (#281)
* update endpoint service and connect frontend * comments * lint
1 parent e271f70 commit 23c76a0

6 files changed

Lines changed: 162 additions & 31 deletions

File tree

src/app/(web)/crm/positions/[id]/page.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import UploadCSVModal from '@/lib/components/modal/UploadCSVModal';
77
import useCandidates from '@/lib/hooks/useCandidates';
88
import { Search } from '@/lib/components/core/Search';
99
import { Tabs, TabsContent, TabsList, UnderlineTabsTrigger } from '@/lib/components/ui/Tabs';
10-
import { Plus, ArrowUpDown, SlidersHorizontal } from 'lucide-react';
10+
import { Plus, ArrowUpDown, SlidersHorizontal, Mail } from 'lucide-react';
1111
import { use, useState } from 'react';
1212
import useSearch from '@/lib/hooks/useSearch';
1313
import Breadcrumbs from '@/lib/components/core/Breadcrumbs';
@@ -16,8 +16,16 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin
1616
const { id } = use(params);
1717
const [isModalManualOpen, setIsModalManualOpen] = useState(false);
1818
const [isCSVModalOpen, setIsCSVModalOpen] = useState(false);
19-
const { candidates, loading, error, positionTitle, createCandidate, batchCreateCandidates } =
20-
useCandidates(id);
19+
const {
20+
candidates,
21+
loading,
22+
error,
23+
positionTitle,
24+
createCandidate,
25+
batchCreateCandidates,
26+
isSendingAssessments,
27+
handleSendAssessments,
28+
} = useCandidates(id);
2129
const { value: searchValue, onChange: onSearchChange } = useSearch('applications');
2230

2331
const displayedCandidates = searchValue.trim().length
@@ -91,7 +99,18 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin
9199
<br />
92100
</TabsContent>
93101

94-
<TabsContent value="assessment">{/* No content yet */}</TabsContent>
102+
<TabsContent value="assessment">
103+
<div className="flex w-full justify-end">
104+
<Button
105+
className="px-4 py-3"
106+
onClick={handleSendAssessments}
107+
disabled={isSendingAssessments}
108+
>
109+
<Mail className="size-5" />
110+
{isSendingAssessments ? 'Sending...' : 'Send to all candidates'}
111+
</Button>
112+
</div>
113+
</TabsContent>
95114
</Tabs>
96115
)}
97116
</div>

src/app/api/assessments/send-invitation/route.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,23 @@
11
import { type NextRequest } from 'next/server';
2-
import { z } from 'zod';
32
import { handleError } from '@/lib/utils/errors.utils';
43
import { getSession } from '@/lib/utils/auth.utils';
54
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-
});
5+
import AssessmentService from '@/lib/services/assessment.service';
116

127
export async function POST(request: NextRequest) {
138
try {
149
const session = await getSession();
1510
await assertRecruiterOrAbove(request.headers);
1611

1712
const body = await request.json();
18-
const { candidateId } = sendAssessmentInvitationSchema.parse(body);
13+
const { positionId } = body as { positionId: string };
1914

20-
const result = await emailService.sendAssessmentInvitationEmail(
21-
candidateId,
15+
const result = await AssessmentService.sendAssessmentInvitationsToPosition(
16+
positionId,
2217
session.activeOrganizationId
2318
);
2419

25-
return Response.json(
26-
{
27-
data: result,
28-
},
29-
{ status: 200 }
30-
);
20+
return Response.json({ data: result }, { status: 200 });
3121
} catch (err) {
3222
return handleError(err);
3323
}

src/lib/api/assessments.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { type Application } from '@/generated/prisma';
22
import { type AssessmentStatus } from '@/generated/prisma';
33
import { type Assessment, type UpdateAssessmentDTO } from '@/lib/schemas/assessment.schema';
4-
import { type AssessmentWithRelations } from '@/lib/types/assessment-template.types';
4+
import {
5+
type AssessmentWithRelations,
6+
type AssessmentInvitationResult,
7+
} from '@/lib/types/assessment-template.types';
58

69
/**
710
* GET /api/assessments/:assessmentId
@@ -67,27 +70,23 @@ export async function updateAssessmentStatus(
6770

6871
/**
6972
* POST /api/assessments/send-invitation
70-
* Sends an assessment invitation email to a candidate
73+
* Sends assessment invitation emails to all NOT_SENT candidates of a position
7174
*/
72-
export async function sendAssessmentInvitation(candidateId: string): Promise<{
73-
success: boolean;
74-
message: string;
75-
candidateName: string;
76-
positionTitle: string;
77-
assessmentId: string;
78-
}> {
75+
export async function sendAssessmentInvitation(
76+
positionId: string
77+
): Promise<AssessmentInvitationResult> {
7978
const res = await fetch('/api/assessments/send-invitation', {
8079
method: 'POST',
8180
headers: {
8281
'Content-Type': 'application/json',
8382
},
84-
body: JSON.stringify({ candidateId }),
83+
body: JSON.stringify({ positionId }),
8584
});
8685

8786
const json = await res.json();
8887

8988
if (!res.ok) {
90-
throw new Error(json.error ?? json.message ?? 'Failed to send assessment invitation');
89+
throw new Error(json.message ?? 'Failed to send assessment invitation');
9190
}
9291

9392
return json.data;

src/lib/hooks/useCandidates.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createCandidate as createCandidateApi,
99
batchCreateCandidates as batchCreateCandidatesApi,
1010
} from '@/lib/api/positions';
11+
import { sendAssessmentInvitation } from '@/lib/api/assessments';
1112

1213
interface UseCandidatesReturn {
1314
candidates: ApplicationDisplayInfo[];
@@ -16,13 +17,16 @@ interface UseCandidatesReturn {
1617
positionTitle: string | null;
1718
createCandidate: (candidate: AddApplicationWithCandidateDataDTO) => Promise<void>;
1819
batchCreateCandidates: (candidates: AddApplicationWithCandidateDataDTO[]) => Promise<void>;
20+
isSendingAssessments: boolean;
21+
handleSendAssessments: () => Promise<void>;
1922
}
2023

2124
export default function useCandidates(positionId: string): UseCandidatesReturn {
2225
const [candidates, setCandidates] = useState<ApplicationDisplayInfo[]>([]);
2326
const [loading, setLoading] = useState(true);
2427
const [error, setError] = useState<string | null>(null);
2528
const [positionTitle, setPositionTitle] = useState<string | null>(null);
29+
const [isSendingAssessments, setIsSendingAssessments] = useState(false);
2630

2731
useEffect(() => {
2832
if (!positionId) return;
@@ -89,12 +93,46 @@ export default function useCandidates(positionId: string): UseCandidatesReturn {
8993
}
9094
};
9195

96+
const handleSendAssessments = async () => {
97+
try {
98+
setIsSendingAssessments(true);
99+
const result = await sendAssessmentInvitation(positionId);
100+
101+
if (result.totalSent > 0) {
102+
toast.success(
103+
`Successfully sent ${result.totalSent} assessment invitation${result.totalSent !== 1 ? 's' : ''}`
104+
);
105+
}
106+
107+
if (result.totalFailed > 0) {
108+
toast.error(
109+
`Failed to send ${result.totalFailed} invitation${result.totalFailed !== 1 ? 's' : ''}`
110+
);
111+
}
112+
113+
if (result.totalSent === 0 && result.totalFailed === 0) {
114+
toast.info('No candidates with pending assessments to send');
115+
}
116+
} catch (err) {
117+
const errorMessage = (err as Error).message;
118+
if (errorMessage.includes('does not have an assessment template assigned')) {
119+
toast.error('This position does not have an assessment template assigned');
120+
} else {
121+
toast.error(errorMessage || 'Failed to send assessment invitations');
122+
}
123+
} finally {
124+
setIsSendingAssessments(false);
125+
}
126+
};
127+
92128
return {
93129
candidates,
94130
loading,
95131
error,
96132
positionTitle,
97133
createCandidate,
98134
batchCreateCandidates,
135+
isSendingAssessments,
136+
handleSendAssessments,
99137
};
100138
}

src/lib/services/assessment.service.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ import type {
77
} from '@/lib/schemas/assessment.schema';
88
import { AssessmentStatus } from '@/generated/prisma';
99
import { BadRequestException, NotFoundException } from '@/lib/utils/errors.utils';
10-
import { type AssessmentWithRelations } from '@/lib/types/assessment-template.types';
10+
import {
11+
type AssessmentWithRelations,
12+
type AssessmentInvitationResult,
13+
} from '@/lib/types/assessment-template.types';
1114
import type { CandidateAssessment } from '@/lib/types/candidate-assessment.types';
1215
import type { BlockNoteContent } from '@/lib/types/task-template.types';
1316
import type { TestCaseDTO } from '@/lib/schemas/task-template.schema';
17+
import emailService from '@/lib/services/email.service';
1418

1519
async function getAssessmentWithRelations(
1620
id: string,
@@ -338,6 +342,73 @@ async function submitAssessmentForCandidate(assessmentId: string): Promise<void>
338342
]);
339343
}
340344

345+
async function sendAssessmentInvitationsToPosition(
346+
positionId: string,
347+
orgId: string
348+
): Promise<AssessmentInvitationResult> {
349+
const position = await prisma.position.findUnique({
350+
where: { id: positionId },
351+
select: { assessmentId: true, orgId: true },
352+
});
353+
354+
if (!position) {
355+
throw new NotFoundException('Position', positionId);
356+
}
357+
358+
if (position.orgId !== orgId) {
359+
throw new BadRequestException('Position does not belong to your organization');
360+
}
361+
362+
if (!position.assessmentId) {
363+
throw new BadRequestException('Position does not have an assessment template assigned');
364+
}
365+
366+
const applications = await prisma.application.findMany({
367+
where: {
368+
positionId,
369+
assessmentStatus: 'NOT_SENT',
370+
},
371+
include: {
372+
candidate: true,
373+
},
374+
});
375+
376+
const results = [];
377+
for (const application of applications) {
378+
try {
379+
const result = await emailService.sendAssessmentInvitationEmail(
380+
application.candidate.id,
381+
orgId
382+
);
383+
384+
await prisma.application.update({
385+
where: { id: application.id },
386+
data: { assessmentStatus: 'NOT_STARTED' },
387+
});
388+
389+
results.push({
390+
...result,
391+
applicationId: application.id,
392+
});
393+
} catch (err) {
394+
results.push({
395+
success: false,
396+
message: `Failed to send invitation to ${application.candidate.name}: ${(err as Error).message}`,
397+
candidateName: application.candidate.name,
398+
positionTitle: '',
399+
assessmentId: '',
400+
applicationId: application.id,
401+
});
402+
}
403+
}
404+
405+
return {
406+
totalSent: results.filter((r) => r.success).length,
407+
totalFailed: results.filter((r) => !r.success).length,
408+
results,
409+
};
410+
}
411+
341412
const AssessmentService = {
342413
getAssessmentWithRelations,
343414
getAssessmentForCandidate,
@@ -346,6 +417,7 @@ const AssessmentService = {
346417
assignTemplateToPosition,
347418
deleteAssessment,
348419
updateAssessment,
420+
sendAssessmentInvitationsToPosition,
349421
};
350422

351423
export default AssessmentService;

src/lib/types/assessment-template.types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ export type TaskSection = {
2222
order: number;
2323
};
2424

25+
export type AssessmentInvitationResult = {
26+
totalSent: number;
27+
totalFailed: number;
28+
results: Array<{
29+
success: boolean;
30+
message: string;
31+
candidateName: string;
32+
positionTitle: string;
33+
assessmentId: string;
34+
applicationId: string;
35+
}>;
36+
};
37+
2538
// TODO: TextSection will be added here
2639
// export type TextSection = {
2740
// type: 'text';

0 commit comments

Comments
 (0)