-
Notifications
You must be signed in to change notification settings - Fork 0
277/Add Send Email Button #281
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ( | ||
| <> | ||
| <div className="flex max-h-screen flex-col gap-8 px-8 py-7 pb-20"> | ||
|
|
@@ -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"> | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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({ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 } | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a duplicate. Line 21 has he same check.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oops sorry i added that before i pulled |
||
| return status ?? 'N/A'; | ||
| }; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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.