Skip to content

Commit a04fe41

Browse files
committed
comments
1 parent 467a1a6 commit a04fe41

7 files changed

Lines changed: 157 additions & 150 deletions

File tree

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

Lines changed: 19 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,21 @@ import { Plus, ArrowUpDown, SlidersHorizontal, Mail } from 'lucide-react';
1212
import { use, useState } from 'react';
1313
import useSearch from '@/lib/hooks/useSearch';
1414
import Breadcrumbs from '@/lib/components/core/Breadcrumbs';
15-
import { sendAssessmentInvitation } from '@/lib/api/assessments';
16-
import { toast } from 'sonner';
1715

1816
export default function CandidatesPage({ params }: { params: Promise<{ id: string }> }) {
1917
const { id } = use(params);
2018
const [isModalManualOpen, setIsModalManualOpen] = useState(false);
2119
const [isCSVModalOpen, setIsCSVModalOpen] = useState(false);
22-
const [isSendingAssessments, setIsSendingAssessments] = useState(false);
23-
const { candidates, loading, error, positionTitle, createCandidate, batchCreateCandidates } =
24-
useCandidates(id);
20+
const {
21+
candidates,
22+
loading,
23+
error,
24+
positionTitle,
25+
createCandidate,
26+
batchCreateCandidates,
27+
isSendingAssessments,
28+
handleSendAssessments,
29+
} = useCandidates(id);
2530
const { value: searchValue, onChange: onSearchChange } = useSearch('applications');
2631

2732
const displayedCandidates = searchValue.trim().length
@@ -30,38 +35,6 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin
3035
)
3136
: candidates;
3237

33-
const handleSendAssessments = async () => {
34-
try {
35-
setIsSendingAssessments(true);
36-
const result = await sendAssessmentInvitation(id);
37-
38-
if (result.totalSent > 0) {
39-
toast.success(
40-
`Successfully sent ${result.totalSent} assessment invitation${result.totalSent !== 1 ? 's' : ''}`
41-
);
42-
}
43-
44-
if (result.totalFailed > 0) {
45-
toast.error(
46-
`Failed to send ${result.totalFailed} invitation${result.totalFailed !== 1 ? 's' : ''}`
47-
);
48-
}
49-
50-
if (result.totalSent === 0 && result.totalFailed === 0) {
51-
toast.info('No candidates with pending assessments to send');
52-
}
53-
} catch (err) {
54-
const errorMessage = (err as Error).message;
55-
if (errorMessage.includes('does not have an assessment template assigned')) {
56-
toast.error('This position does not have an assessment template assigned');
57-
} else {
58-
toast.error(errorMessage || 'Failed to send assessment invitations');
59-
}
60-
} finally {
61-
setIsSendingAssessments(false);
62-
}
63-
};
64-
6538
return (
6639
<>
6740
<div className="flex max-h-screen flex-col gap-8 px-8 py-7 pb-20">
@@ -128,28 +101,15 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin
128101
</TabsContent>
129102

130103
<TabsContent value="assessment">
131-
<div className="border-sarge-gray-200 flex items-center justify-between self-stretch rounded-lg border bg-white p-4">
132-
<div className="flex flex-col gap-3">
133-
<span className="text-sarge-gray-800 overflow-hidden text-lg leading-6 font-medium tracking-wide text-ellipsis">
134-
Software Engineer Assessment
135-
</span>
136-
<div className="flex items-center gap-3">
137-
<Chip variant="neutral">10/10 sent</Chip>
138-
<Chip variant="neutral">0/10 submitted</Chip>
139-
</div>
140-
</div>
141-
<div className="flex items-end gap-3">
142-
<Button
143-
className="px-4 py-3"
144-
onClick={handleSendAssessments}
145-
disabled={isSendingAssessments}
146-
>
147-
<Mail className="size-5" />
148-
{isSendingAssessments
149-
? 'Sending...'
150-
: 'Send to all candidates'}
151-
</Button>
152-
</div>
104+
<div className="flex w-full justify-end">
105+
<Button
106+
className="px-4 py-3"
107+
onClick={handleSendAssessments}
108+
disabled={isSendingAssessments}
109+
>
110+
<Mail className="size-5" />
111+
{isSendingAssessments ? 'Sending...' : 'Send to all candidates'}
112+
</Button>
153113
</div>
154114
</TabsContent>
155115
</Tabs>

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

Lines changed: 7 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +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-
import { prisma } from '@/lib/prisma';
8-
import { AssessmentStatus } from '@/generated/prisma';
9-
10-
const sendAssessmentInvitationSchema = z.object({
11-
positionId: z.string().cuid(),
12-
});
5+
import AssessmentService from '@/lib/services/assessment.service';
136

147
export async function POST(request: NextRequest) {
158
try {
169
const session = await getSession();
1710
await assertRecruiterOrAbove(request.headers);
1811

1912
const body = await request.json();
20-
const { positionId } = sendAssessmentInvitationSchema.parse(body);
21-
22-
const position = await prisma.position.findUnique({
23-
where: { id: positionId },
24-
select: { assessmentId: true, orgId: true },
25-
});
26-
27-
if (!position) {
28-
return Response.json({ message: 'Position not found' }, { status: 404 });
29-
}
30-
31-
if (position.orgId !== session.activeOrganizationId) {
32-
return Response.json({ message: 'Unauthorized' }, { status: 403 });
33-
}
34-
35-
if (!position.assessmentId) {
36-
return Response.json(
37-
{ message: 'Position does not have an assessment template assigned' },
38-
{ status: 400 }
39-
);
40-
}
13+
const { positionId } = body as { positionId: string };
4114

42-
const applications = await prisma.application.findMany({
43-
where: {
44-
positionId,
45-
assessmentStatus: AssessmentStatus.NOT_SENT,
46-
},
47-
include: {
48-
candidate: true,
49-
},
50-
});
51-
52-
const results = [];
53-
for (const application of applications) {
54-
try {
55-
const result = await emailService.sendAssessmentInvitationEmail(
56-
application.candidate.id,
57-
session.activeOrganizationId
58-
);
59-
60-
await prisma.application.update({
61-
where: { id: application.id },
62-
data: { assessmentStatus: AssessmentStatus.NOT_STARTED },
63-
});
64-
65-
results.push({
66-
...result,
67-
applicationId: application.id,
68-
});
69-
} catch (err) {
70-
results.push({
71-
success: false,
72-
message: `Failed to send invitation to ${application.candidate.name}: ${(err as Error).message}`,
73-
candidateName: application.candidate.name,
74-
positionTitle: '',
75-
assessmentId: '',
76-
});
77-
}
78-
}
79-
80-
return Response.json(
81-
{
82-
data: {
83-
totalSent: results.filter((r) => r.success).length,
84-
totalFailed: results.filter((r) => !r.success).length,
85-
results,
86-
},
87-
},
88-
{ status: 200 }
15+
const result = await AssessmentService.sendAssessmentInvitationsToPosition(
16+
positionId,
17+
session.activeOrganizationId
8918
);
19+
20+
return Response.json({ data: result }, { status: 200 });
9021
} catch (err) {
9122
return handleError(err);
9223
}

src/lib/api/assessments.ts

Lines changed: 7 additions & 13 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+
AssessmentInvitationResult,
7+
} from '@/lib/types/assessment-template.types';
58

69
/**
710
* GET /api/assessments/:assessmentId
@@ -69,18 +72,9 @@ export async function updateAssessmentStatus(
6972
* POST /api/assessments/send-invitation
7073
* Sends assessment invitation emails to all NOT_SENT candidates of a position
7174
*/
72-
export async function sendAssessmentInvitation(positionId: string): Promise<{
73-
totalSent: number;
74-
totalFailed: number;
75-
results: Array<{
76-
success: boolean;
77-
message: string;
78-
candidateName: string;
79-
positionTitle: string;
80-
assessmentId: string;
81-
applicationId: string;
82-
}>;
83-
}> {
75+
export async function sendAssessmentInvitation(
76+
positionId: string
77+
): Promise<AssessmentInvitationResult> {
8478
const res = await fetch('/api/assessments/send-invitation', {
8579
method: 'POST',
8680
headers: {

src/lib/components/core/CandidateTable.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ const getAssessmentLabel = (status?: string) => {
2222
if (s === 'NOT_STARTED') return 'Not started';
2323
if (s === 'NOT_ASSIGNED') return 'Not assigned';
2424
if (s === 'EXPIRED') return 'Expired';
25-
if (s === 'NOT_SENT') return 'Not sent';
2625
return status ?? 'N/A';
2726
};
2827

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
}

0 commit comments

Comments
 (0)