diff --git a/src/app/(web)/crm/positions/[id]/page.tsx b/src/app/(web)/crm/positions/[id]/page.tsx index f997853c..52d57908 100644 --- a/src/app/(web)/crm/positions/[id]/page.tsx +++ b/src/app/(web)/crm/positions/[id]/page.tsx @@ -7,7 +7,7 @@ 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 { 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'; @@ -16,8 +16,16 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin const { id } = use(params); const [isModalManualOpen, setIsModalManualOpen] = useState(false); const [isCSVModalOpen, setIsCSVModalOpen] = 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 @@ -91,7 +99,18 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin
- {/* No content yet */} + +
+ +
+
)} diff --git a/src/app/api/assessments/send-invitation/route.ts b/src/app/api/assessments/send-invitation/route.ts index f59bbc56..6d960e93 100644 --- a/src/app/api/assessments/send-invitation/route.ts +++ b/src/app/api/assessments/send-invitation/route.ts @@ -1,13 +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'; - -const sendAssessmentInvitationSchema = z.object({ - candidateId: z.string().cuid(), -}); +import AssessmentService from '@/lib/services/assessment.service'; export async function POST(request: NextRequest) { try { @@ -15,19 +10,14 @@ export async function POST(request: NextRequest) { await assertRecruiterOrAbove(request.headers); const body = await request.json(); - const { candidateId } = sendAssessmentInvitationSchema.parse(body); + const { positionId } = body as { positionId: string }; - const result = await emailService.sendAssessmentInvitationEmail( - candidateId, + const result = await AssessmentService.sendAssessmentInvitationsToPosition( + positionId, session.activeOrganizationId ); - return Response.json( - { - data: result, - }, - { status: 200 } - ); + 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 2e73f4b0..b2959c33 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, + type AssessmentInvitationResult, +} from '@/lib/types/assessment-template.types'; /** * GET /api/assessments/:assessmentId @@ -67,27 +70,23 @@ 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 { 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/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';