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
27 changes: 23 additions & 4 deletions src/app/(web)/crm/positions/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -91,7 +99,18 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin
<br />
</TabsContent>

<TabsContent value="assessment">{/* No content yet */}</TabsContent>
<TabsContent value="assessment">
<div className="flex w-full justify-end">
<Button
className="px-4 py-3"
onClick={handleSendAssessments}
disabled={isSendingAssessments}
>
<Mail className="size-5" />
{isSendingAssessments ? 'Sending...' : 'Send to all candidates'}
</Button>
</div>
</TabsContent>
</Tabs>
)}
</div>
Expand Down
20 changes: 5 additions & 15 deletions src/app/api/assessments/send-invitation/route.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,23 @@
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 {
const session = await getSession();
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);
}
Expand Down
21 changes: 10 additions & 11 deletions src/lib/api/assessments.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<AssessmentInvitationResult> {
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;
Expand Down
38 changes: 38 additions & 0 deletions src/lib/hooks/useCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -16,13 +17,16 @@ interface UseCandidatesReturn {
positionTitle: string | null;
createCandidate: (candidate: AddApplicationWithCandidateDataDTO) => Promise<void>;
batchCreateCandidates: (candidates: AddApplicationWithCandidateDataDTO[]) => Promise<void>;
isSendingAssessments: boolean;
handleSendAssessments: () => Promise<void>;
}

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

useEffect(() => {
if (!positionId) return;
Expand Down Expand Up @@ -89,12 +93,46 @@ 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,
error,
positionTitle,
createCandidate,
batchCreateCandidates,
isSendingAssessments,
handleSendAssessments,
};
}
74 changes: 73 additions & 1 deletion src/lib/services/assessment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -338,6 +342,73 @@ async function submitAssessmentForCandidate(assessmentId: string): Promise<void>
]);
}

async function sendAssessmentInvitationsToPosition(
positionId: string,
orgId: string
): Promise<AssessmentInvitationResult> {
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,
Expand All @@ -346,6 +417,7 @@ const AssessmentService = {
assignTemplateToPosition,
deleteAssessment,
updateAssessment,
sendAssessmentInvitationsToPosition,
};

export default AssessmentService;
13 changes: 13 additions & 0 deletions src/lib/types/assessment-template.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading