From 467a1a6c3db3c448258c9e437a7688d32a29bbc8 Mon Sep 17 00:00:00 2001 From: jolinhuang224 Date: Wed, 8 Apr 2026 05:25:43 -0400 Subject: [PATCH 1/3] update endpoint service and connect frontend --- src/app/(web)/crm/positions/[id]/page.tsx | 64 +++++++++++++++- .../api/assessments/send-invitation/route.ts | 73 +++++++++++++++++-- src/lib/api/assessments.ts | 23 +++--- src/lib/components/core/CandidateTable.tsx | 1 + 4 files changed, 143 insertions(+), 18 deletions(-) diff --git a/src/app/(web)/crm/positions/[id]/page.tsx b/src/app/(web)/crm/positions/[id]/page.tsx index f997853c..daf55857 100644 --- a/src/app/(web)/crm/positions/[id]/page.tsx +++ b/src/app/(web)/crm/positions/[id]/page.tsx @@ -7,15 +7,19 @@ import UploadCSVModal from '@/lib/components/modal/UploadCSVModal'; import useCandidates from '@/lib/hooks/useCandidates'; import { Search } from '@/lib/components/core/Search'; import { Tabs, TabsContent, TabsList, UnderlineTabsTrigger } from '@/lib/components/ui/Tabs'; -import { Plus, ArrowUpDown, SlidersHorizontal } from 'lucide-react'; +import { Chip } from '@/lib/components/ui/Chip'; +import { Plus, ArrowUpDown, SlidersHorizontal, Mail } from 'lucide-react'; import { use, useState } from 'react'; import useSearch from '@/lib/hooks/useSearch'; import Breadcrumbs from '@/lib/components/core/Breadcrumbs'; +import { sendAssessmentInvitation } from '@/lib/api/assessments'; +import { toast } from 'sonner'; export default function CandidatesPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const [isModalManualOpen, setIsModalManualOpen] = useState(false); const [isCSVModalOpen, setIsCSVModalOpen] = useState(false); + const [isSendingAssessments, setIsSendingAssessments] = useState(false); const { candidates, loading, error, positionTitle, createCandidate, batchCreateCandidates } = useCandidates(id); const { value: searchValue, onChange: onSearchChange } = useSearch('applications'); @@ -26,6 +30,38 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin ) : candidates; + const handleSendAssessments = async () => { + try { + setIsSendingAssessments(true); + const result = await sendAssessmentInvitation(id); + + if (result.totalSent > 0) { + toast.success( + `Successfully sent ${result.totalSent} assessment invitation${result.totalSent !== 1 ? 's' : ''}` + ); + } + + if (result.totalFailed > 0) { + toast.error( + `Failed to send ${result.totalFailed} invitation${result.totalFailed !== 1 ? 's' : ''}` + ); + } + + if (result.totalSent === 0 && result.totalFailed === 0) { + toast.info('No candidates with pending assessments to send'); + } + } catch (err) { + const errorMessage = (err as Error).message; + if (errorMessage.includes('does not have an assessment template assigned')) { + toast.error('This position does not have an assessment template assigned'); + } else { + toast.error(errorMessage || 'Failed to send assessment invitations'); + } + } finally { + setIsSendingAssessments(false); + } + }; + return ( <>
@@ -91,7 +127,31 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin
- {/* No content yet */} + +
+
+ + Software Engineer Assessment + +
+ 10/10 sent + 0/10 submitted +
+
+
+ +
+
+
)}
diff --git a/src/app/api/assessments/send-invitation/route.ts b/src/app/api/assessments/send-invitation/route.ts index f59bbc56..c86e986e 100644 --- a/src/app/api/assessments/send-invitation/route.ts +++ b/src/app/api/assessments/send-invitation/route.ts @@ -4,9 +4,11 @@ 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'; +import { prisma } from '@/lib/prisma'; +import { AssessmentStatus } from '@/generated/prisma'; const sendAssessmentInvitationSchema = z.object({ - candidateId: z.string().cuid(), + positionId: z.string().cuid(), }); export async function POST(request: NextRequest) { @@ -15,16 +17,73 @@ export async function POST(request: NextRequest) { await assertRecruiterOrAbove(request.headers); const body = await request.json(); - const { candidateId } = sendAssessmentInvitationSchema.parse(body); + const { positionId } = sendAssessmentInvitationSchema.parse(body); - const result = await emailService.sendAssessmentInvitationEmail( - candidateId, - session.activeOrganizationId - ); + const position = await prisma.position.findUnique({ + where: { id: positionId }, + select: { assessmentId: true, orgId: true }, + }); + + if (!position) { + return Response.json({ message: 'Position not found' }, { status: 404 }); + } + + if (position.orgId !== session.activeOrganizationId) { + return Response.json({ message: 'Unauthorized' }, { status: 403 }); + } + + if (!position.assessmentId) { + return Response.json( + { message: 'Position does not have an assessment template assigned' }, + { status: 400 } + ); + } + + const applications = await prisma.application.findMany({ + where: { + positionId, + assessmentStatus: AssessmentStatus.NOT_SENT, + }, + include: { + candidate: true, + }, + }); + + const results = []; + for (const application of applications) { + try { + const result = await emailService.sendAssessmentInvitationEmail( + application.candidate.id, + session.activeOrganizationId + ); + + await prisma.application.update({ + where: { id: application.id }, + data: { assessmentStatus: AssessmentStatus.NOT_STARTED }, + }); + + results.push({ + ...result, + applicationId: application.id, + }); + } catch (err) { + results.push({ + success: false, + message: `Failed to send invitation to ${application.candidate.name}: ${(err as Error).message}`, + candidateName: application.candidate.name, + positionTitle: '', + assessmentId: '', + }); + } + } return Response.json( { - data: result, + data: { + totalSent: results.filter((r) => r.success).length, + totalFailed: results.filter((r) => !r.success).length, + results, + }, }, { status: 200 } ); diff --git a/src/lib/api/assessments.ts b/src/lib/api/assessments.ts index 2e73f4b0..30dba4b9 100644 --- a/src/lib/api/assessments.ts +++ b/src/lib/api/assessments.ts @@ -67,27 +67,32 @@ export async function updateAssessmentStatus( /** * POST /api/assessments/send-invitation - * Sends an assessment invitation email to a candidate + * Sends assessment invitation emails to all NOT_SENT candidates of a position */ -export async function sendAssessmentInvitation(candidateId: string): Promise<{ - success: boolean; - message: string; - candidateName: string; - positionTitle: string; - assessmentId: string; +export async function sendAssessmentInvitation(positionId: string): Promise<{ + totalSent: number; + totalFailed: number; + results: Array<{ + success: boolean; + message: string; + candidateName: string; + positionTitle: string; + assessmentId: string; + applicationId: string; + }>; }> { const res = await fetch('/api/assessments/send-invitation', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ candidateId }), + body: JSON.stringify({ positionId }), }); const json = await res.json(); if (!res.ok) { - throw new Error(json.error ?? json.message ?? 'Failed to send assessment invitation'); + throw new Error(json.message ?? 'Failed to send assessment invitation'); } return json.data; diff --git a/src/lib/components/core/CandidateTable.tsx b/src/lib/components/core/CandidateTable.tsx index 72aa3eb2..bac6ccff 100644 --- a/src/lib/components/core/CandidateTable.tsx +++ b/src/lib/components/core/CandidateTable.tsx @@ -22,6 +22,7 @@ const getAssessmentLabel = (status?: string) => { if (s === 'NOT_STARTED') return 'Not started'; if (s === 'NOT_ASSIGNED') return 'Not assigned'; if (s === 'EXPIRED') return 'Expired'; + if (s === 'NOT_SENT') return 'Not sent'; return status ?? 'N/A'; }; From a04fe41aa2d8c5cd5eb250e84368ce2f6c77dc83 Mon Sep 17 00:00:00 2001 From: jolinhuang224 Date: Wed, 8 Apr 2026 17:17:32 -0400 Subject: [PATCH 2/3] comments --- src/app/(web)/crm/positions/[id]/page.tsx | 78 +++++------------ .../api/assessments/send-invitation/route.ts | 83 ++----------------- src/lib/api/assessments.ts | 20 ++--- src/lib/components/core/CandidateTable.tsx | 1 - src/lib/hooks/useCandidates.ts | 38 +++++++++ src/lib/services/assessment.service.ts | 74 ++++++++++++++++- src/lib/types/assessment-template.types.ts | 13 +++ 7 files changed, 157 insertions(+), 150 deletions(-) diff --git a/src/app/(web)/crm/positions/[id]/page.tsx b/src/app/(web)/crm/positions/[id]/page.tsx index daf55857..a566663e 100644 --- a/src/app/(web)/crm/positions/[id]/page.tsx +++ b/src/app/(web)/crm/positions/[id]/page.tsx @@ -12,16 +12,21 @@ import { Plus, ArrowUpDown, SlidersHorizontal, Mail } from 'lucide-react'; import { use, useState } from 'react'; import useSearch from '@/lib/hooks/useSearch'; import Breadcrumbs from '@/lib/components/core/Breadcrumbs'; -import { sendAssessmentInvitation } from '@/lib/api/assessments'; -import { toast } from 'sonner'; export default function CandidatesPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const [isModalManualOpen, setIsModalManualOpen] = useState(false); const [isCSVModalOpen, setIsCSVModalOpen] = useState(false); - const [isSendingAssessments, setIsSendingAssessments] = useState(false); - const { candidates, loading, error, positionTitle, createCandidate, batchCreateCandidates } = - useCandidates(id); + const { + candidates, + loading, + error, + positionTitle, + createCandidate, + batchCreateCandidates, + isSendingAssessments, + handleSendAssessments, + } = useCandidates(id); const { value: searchValue, onChange: onSearchChange } = useSearch('applications'); const displayedCandidates = searchValue.trim().length @@ -30,38 +35,6 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin ) : candidates; - const handleSendAssessments = async () => { - try { - setIsSendingAssessments(true); - const result = await sendAssessmentInvitation(id); - - if (result.totalSent > 0) { - toast.success( - `Successfully sent ${result.totalSent} assessment invitation${result.totalSent !== 1 ? 's' : ''}` - ); - } - - if (result.totalFailed > 0) { - toast.error( - `Failed to send ${result.totalFailed} invitation${result.totalFailed !== 1 ? 's' : ''}` - ); - } - - if (result.totalSent === 0 && result.totalFailed === 0) { - toast.info('No candidates with pending assessments to send'); - } - } catch (err) { - const errorMessage = (err as Error).message; - if (errorMessage.includes('does not have an assessment template assigned')) { - toast.error('This position does not have an assessment template assigned'); - } else { - toast.error(errorMessage || 'Failed to send assessment invitations'); - } - } finally { - setIsSendingAssessments(false); - } - }; - return ( <>
@@ -128,28 +101,15 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin -
-
- - Software Engineer Assessment - -
- 10/10 sent - 0/10 submitted -
-
-
- -
+
+
diff --git a/src/app/api/assessments/send-invitation/route.ts b/src/app/api/assessments/send-invitation/route.ts index c86e986e..6d960e93 100644 --- a/src/app/api/assessments/send-invitation/route.ts +++ b/src/app/api/assessments/send-invitation/route.ts @@ -1,15 +1,8 @@ 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'; -import { prisma } from '@/lib/prisma'; -import { AssessmentStatus } from '@/generated/prisma'; - -const sendAssessmentInvitationSchema = z.object({ - positionId: z.string().cuid(), -}); +import AssessmentService from '@/lib/services/assessment.service'; export async function POST(request: NextRequest) { try { @@ -17,76 +10,14 @@ export async function POST(request: NextRequest) { await assertRecruiterOrAbove(request.headers); const body = await request.json(); - const { positionId } = sendAssessmentInvitationSchema.parse(body); - - const position = await prisma.position.findUnique({ - where: { id: positionId }, - select: { assessmentId: true, orgId: true }, - }); - - if (!position) { - return Response.json({ message: 'Position not found' }, { status: 404 }); - } - - if (position.orgId !== session.activeOrganizationId) { - return Response.json({ message: 'Unauthorized' }, { status: 403 }); - } - - if (!position.assessmentId) { - return Response.json( - { message: 'Position does not have an assessment template assigned' }, - { status: 400 } - ); - } + const { positionId } = body as { positionId: string }; - const applications = await prisma.application.findMany({ - where: { - positionId, - assessmentStatus: AssessmentStatus.NOT_SENT, - }, - include: { - candidate: true, - }, - }); - - const results = []; - for (const application of applications) { - try { - const result = await emailService.sendAssessmentInvitationEmail( - application.candidate.id, - session.activeOrganizationId - ); - - await prisma.application.update({ - where: { id: application.id }, - data: { assessmentStatus: AssessmentStatus.NOT_STARTED }, - }); - - results.push({ - ...result, - applicationId: application.id, - }); - } catch (err) { - results.push({ - success: false, - message: `Failed to send invitation to ${application.candidate.name}: ${(err as Error).message}`, - candidateName: application.candidate.name, - positionTitle: '', - assessmentId: '', - }); - } - } - - return Response.json( - { - data: { - totalSent: results.filter((r) => r.success).length, - totalFailed: results.filter((r) => !r.success).length, - results, - }, - }, - { status: 200 } + const result = await AssessmentService.sendAssessmentInvitationsToPosition( + positionId, + session.activeOrganizationId ); + + return Response.json({ data: result }, { status: 200 }); } catch (err) { return handleError(err); } diff --git a/src/lib/api/assessments.ts b/src/lib/api/assessments.ts index 30dba4b9..6a4b270a 100644 --- a/src/lib/api/assessments.ts +++ b/src/lib/api/assessments.ts @@ -1,7 +1,10 @@ import { type Application } from '@/generated/prisma'; import { type AssessmentStatus } from '@/generated/prisma'; import { type Assessment, type UpdateAssessmentDTO } from '@/lib/schemas/assessment.schema'; -import { type AssessmentWithRelations } from '@/lib/types/assessment-template.types'; +import { + type AssessmentWithRelations, + AssessmentInvitationResult, +} from '@/lib/types/assessment-template.types'; /** * GET /api/assessments/:assessmentId @@ -69,18 +72,9 @@ export async function updateAssessmentStatus( * POST /api/assessments/send-invitation * Sends assessment invitation emails to all NOT_SENT candidates of a position */ -export async function sendAssessmentInvitation(positionId: string): Promise<{ - totalSent: number; - totalFailed: number; - results: Array<{ - success: boolean; - message: string; - candidateName: string; - positionTitle: string; - assessmentId: string; - applicationId: string; - }>; -}> { +export async function sendAssessmentInvitation( + positionId: string +): Promise { const res = await fetch('/api/assessments/send-invitation', { method: 'POST', headers: { diff --git a/src/lib/components/core/CandidateTable.tsx b/src/lib/components/core/CandidateTable.tsx index bac6ccff..72aa3eb2 100644 --- a/src/lib/components/core/CandidateTable.tsx +++ b/src/lib/components/core/CandidateTable.tsx @@ -22,7 +22,6 @@ const getAssessmentLabel = (status?: string) => { if (s === 'NOT_STARTED') return 'Not started'; if (s === 'NOT_ASSIGNED') return 'Not assigned'; if (s === 'EXPIRED') return 'Expired'; - if (s === 'NOT_SENT') return 'Not sent'; return status ?? 'N/A'; }; diff --git a/src/lib/hooks/useCandidates.ts b/src/lib/hooks/useCandidates.ts index c2b23188..9b05ce1b 100644 --- a/src/lib/hooks/useCandidates.ts +++ b/src/lib/hooks/useCandidates.ts @@ -8,6 +8,7 @@ import { createCandidate as createCandidateApi, batchCreateCandidates as batchCreateCandidatesApi, } from '@/lib/api/positions'; +import { sendAssessmentInvitation } from '@/lib/api/assessments'; interface UseCandidatesReturn { candidates: ApplicationDisplayInfo[]; @@ -16,6 +17,8 @@ interface UseCandidatesReturn { positionTitle: string | null; createCandidate: (candidate: AddApplicationWithCandidateDataDTO) => Promise; batchCreateCandidates: (candidates: AddApplicationWithCandidateDataDTO[]) => Promise; + isSendingAssessments: boolean; + handleSendAssessments: () => Promise; } export default function useCandidates(positionId: string): UseCandidatesReturn { @@ -23,6 +26,7 @@ export default function useCandidates(positionId: string): UseCandidatesReturn { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [positionTitle, setPositionTitle] = useState(null); + const [isSendingAssessments, setIsSendingAssessments] = useState(false); useEffect(() => { if (!positionId) return; @@ -89,6 +93,38 @@ export default function useCandidates(positionId: string): UseCandidatesReturn { } }; + const handleSendAssessments = async () => { + try { + setIsSendingAssessments(true); + const result = await sendAssessmentInvitation(positionId); + + if (result.totalSent > 0) { + toast.success( + `Successfully sent ${result.totalSent} assessment invitation${result.totalSent !== 1 ? 's' : ''}` + ); + } + + if (result.totalFailed > 0) { + toast.error( + `Failed to send ${result.totalFailed} invitation${result.totalFailed !== 1 ? 's' : ''}` + ); + } + + if (result.totalSent === 0 && result.totalFailed === 0) { + toast.info('No candidates with pending assessments to send'); + } + } catch (err) { + const errorMessage = (err as Error).message; + if (errorMessage.includes('does not have an assessment template assigned')) { + toast.error('This position does not have an assessment template assigned'); + } else { + toast.error(errorMessage || 'Failed to send assessment invitations'); + } + } finally { + setIsSendingAssessments(false); + } + }; + return { candidates, loading, @@ -96,5 +132,7 @@ export default function useCandidates(positionId: string): UseCandidatesReturn { positionTitle, createCandidate, batchCreateCandidates, + isSendingAssessments, + handleSendAssessments, }; } diff --git a/src/lib/services/assessment.service.ts b/src/lib/services/assessment.service.ts index 96569046..fa752e7f 100644 --- a/src/lib/services/assessment.service.ts +++ b/src/lib/services/assessment.service.ts @@ -7,10 +7,14 @@ import type { } from '@/lib/schemas/assessment.schema'; import { AssessmentStatus } from '@/generated/prisma'; import { BadRequestException, NotFoundException } from '@/lib/utils/errors.utils'; -import { type AssessmentWithRelations } from '@/lib/types/assessment-template.types'; +import { + type AssessmentWithRelations, + type AssessmentInvitationResult, +} from '@/lib/types/assessment-template.types'; import type { CandidateAssessment } from '@/lib/types/candidate-assessment.types'; import type { BlockNoteContent } from '@/lib/types/task-template.types'; import type { TestCaseDTO } from '@/lib/schemas/task-template.schema'; +import emailService from '@/lib/services/email.service'; async function getAssessmentWithRelations( id: string, @@ -338,6 +342,73 @@ async function submitAssessmentForCandidate(assessmentId: string): Promise ]); } +async function sendAssessmentInvitationsToPosition( + positionId: string, + orgId: string +): Promise { + const position = await prisma.position.findUnique({ + where: { id: positionId }, + select: { assessmentId: true, orgId: true }, + }); + + if (!position) { + throw new NotFoundException('Position', positionId); + } + + if (position.orgId !== orgId) { + throw new BadRequestException('Position does not belong to your organization'); + } + + if (!position.assessmentId) { + throw new BadRequestException('Position does not have an assessment template assigned'); + } + + const applications = await prisma.application.findMany({ + where: { + positionId, + assessmentStatus: 'NOT_SENT', + }, + include: { + candidate: true, + }, + }); + + const results = []; + for (const application of applications) { + try { + const result = await emailService.sendAssessmentInvitationEmail( + application.candidate.id, + orgId + ); + + await prisma.application.update({ + where: { id: application.id }, + data: { assessmentStatus: 'NOT_STARTED' }, + }); + + results.push({ + ...result, + applicationId: application.id, + }); + } catch (err) { + results.push({ + success: false, + message: `Failed to send invitation to ${application.candidate.name}: ${(err as Error).message}`, + candidateName: application.candidate.name, + positionTitle: '', + assessmentId: '', + applicationId: application.id, + }); + } + } + + return { + totalSent: results.filter((r) => r.success).length, + totalFailed: results.filter((r) => !r.success).length, + results, + }; +} + const AssessmentService = { getAssessmentWithRelations, getAssessmentForCandidate, @@ -346,6 +417,7 @@ const AssessmentService = { assignTemplateToPosition, deleteAssessment, updateAssessment, + sendAssessmentInvitationsToPosition, }; export default AssessmentService; diff --git a/src/lib/types/assessment-template.types.ts b/src/lib/types/assessment-template.types.ts index 78e4e4ce..fdf75852 100644 --- a/src/lib/types/assessment-template.types.ts +++ b/src/lib/types/assessment-template.types.ts @@ -22,6 +22,19 @@ export type TaskSection = { order: number; }; +export type AssessmentInvitationResult = { + totalSent: number; + totalFailed: number; + results: Array<{ + success: boolean; + message: string; + candidateName: string; + positionTitle: string; + assessmentId: string; + applicationId: string; + }>; +}; + // TODO: TextSection will be added here // export type TextSection = { // type: 'text'; From 954c94fe94f5e43b7ae43f53c431d5fc26b99639 Mon Sep 17 00:00:00 2001 From: jolinhuang224 Date: Wed, 8 Apr 2026 17:26:31 -0400 Subject: [PATCH 3/3] lint --- src/app/(web)/crm/positions/[id]/page.tsx | 1 - src/lib/api/assessments.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/(web)/crm/positions/[id]/page.tsx b/src/app/(web)/crm/positions/[id]/page.tsx index a566663e..52d57908 100644 --- a/src/app/(web)/crm/positions/[id]/page.tsx +++ b/src/app/(web)/crm/positions/[id]/page.tsx @@ -7,7 +7,6 @@ import UploadCSVModal from '@/lib/components/modal/UploadCSVModal'; import useCandidates from '@/lib/hooks/useCandidates'; import { Search } from '@/lib/components/core/Search'; import { Tabs, TabsContent, TabsList, UnderlineTabsTrigger } from '@/lib/components/ui/Tabs'; -import { Chip } from '@/lib/components/ui/Chip'; import { Plus, ArrowUpDown, SlidersHorizontal, Mail } from 'lucide-react'; import { use, useState } from 'react'; import useSearch from '@/lib/hooks/useSearch'; diff --git a/src/lib/api/assessments.ts b/src/lib/api/assessments.ts index 6a4b270a..b2959c33 100644 --- a/src/lib/api/assessments.ts +++ b/src/lib/api/assessments.ts @@ -3,7 +3,7 @@ import { type AssessmentStatus } from '@/generated/prisma'; import { type Assessment, type UpdateAssessmentDTO } from '@/lib/schemas/assessment.schema'; import { type AssessmentWithRelations, - AssessmentInvitationResult, + type AssessmentInvitationResult, } from '@/lib/types/assessment-template.types'; /**