Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
64 changes: 62 additions & 2 deletions src/app/(web)/crm/positions/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -26,6 +30,38 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin
)
: candidates;

const handleSendAssessments = async () => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move this function and the rest of the values (the isSendingAssessments state variable, etc) you need to the useCandidates hook.

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 (
<>
<div className="flex max-h-screen flex-col gap-8 px-8 py-7 pb-20">
Expand Down Expand Up @@ -91,7 +127,31 @@ export default function CandidatesPage({ params }: { params: Promise<{ id: strin
<br />
</TabsContent>

<TabsContent value="assessment">{/* No content yet */}</TabsContent>
<TabsContent value="assessment">
<div className="border-sarge-gray-200 flex items-center justify-between self-stretch rounded-lg border bg-white p-4">
Copy link
Copy Markdown
Collaborator

@LOTaher LOTaher Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't hardcode an assessment here. We only need the button on the top right for now. Because we are already within a specific position page, we don't need to list it here. You should already have that data!

<div className="flex flex-col gap-3">
<span className="text-sarge-gray-800 overflow-hidden text-lg leading-6 font-medium tracking-wide text-ellipsis">
Software Engineer Assessment
</span>
<div className="flex items-center gap-3">
<Chip variant="neutral">10/10 sent</Chip>
<Chip variant="neutral">0/10 submitted</Chip>
</div>
</div>
<div className="flex items-end gap-3">
<Button
className="px-4 py-3"
onClick={handleSendAssessments}
disabled={isSendingAssessments}
>
<Mail className="size-5" />
{isSendingAssessments
? 'Sending...'
: 'Send to all candidates'}
</Button>
</div>
</div>
</TabsContent>
</Tabs>
)}
</div>
Expand Down
73 changes: 66 additions & 7 deletions src/app/api/assessments/send-invitation/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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({
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you make all this logic in a service function please!

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 }
);
Expand Down
23 changes: 14 additions & 9 deletions src/lib/api/assessments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you define this to be its own type like we've done in other functions?

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;
Expand Down
1 change: 1 addition & 0 deletions src/lib/components/core/CandidateTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a duplicate. Line 21 has he same check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops sorry i added that before i pulled

return status ?? 'N/A';
};

Expand Down
Loading