Phase 1A — Automated Screening (End-to-End) ✅ Complete
Goal: A recruiter can create a job with an automated screening stage, invite a candidate via email, and the candidate can complete the screening without any account. The recruiter sees the AI-graded responses.
1A.1 Backend — ScreeningQuestion Service
Rename/rewrite src/services/questionService.ts → src/services/screeningQuestionService.ts:
- Keep
normalizeText,computeContentHash,findOrCreateQuestion(pointed at newScreeningQuestionmodel)
1A.2 Backend — JobOpening Controller Update
- Validate stage configs using
stageTypeKeyfromStageTypeConfiglookup (not string comparison) - For
automated_screeningstages: validatescreeningConfig.questions.length >= stageTypeConfig.questionBankConfig.minQuestions - On create/update: resolve question text →
ScreeningQuestionID viafindOrCreateQuestion - Store
{ questionId: ObjectId, order: number }instages[].screeningConfig.questions
1A.3 Backend — CandidatePipeline Service + Controller
src/services/candidatePipelineService.ts
createPipeline(job, participant, organizationId, organizationName) → CandidatePipeline
findOrCreatePipeline(job, participant, organizationId, organizationName) → CandidatePipeline
unlockStage(pipelineId, stageIndex, force?) → updated pipeline
// Without force: returns 409 if any preceding stage is not 'completed'
// With force: cascade-completes all preceding incomplete stages, then sets
// currentStageIndex = stageIndex and target stage status = 'unlocked'
markStageInvited(pipelineId, stageIndex, interviewId, expiresAt?) → updated pipeline
markStageStarted(pipelineId, stageIndex) → updated pipeline
markStageCompleted(pipelineId, stageIndex, result?) → updated pipeline
markStageDeclined(pipelineId, stageIndex) → updated pipeline
setGlobalStatus(pipelineId, status) → syncs candidateFacingStatus toosrc/controllers/candidatePipelineController.ts
GET /v1/pipeline?jobId=:jobId
GET /v1/pipeline/:id
PATCH /v1/pipeline/:id/status
POST /v1/pipeline/:id/unlock-stage
POST /v1/pipeline/:id/notes1A.4 Backend — Interview Scheduling for Automated Screening
POST /v1/interviews — the presence of stageId in the request body triggers the pipeline-aware path (_createScreeningInterview).
- Validate
stageIdexists on the job and belongs to the org. - Upsert
Participantby email (create if not exists). - Find or create
CandidatePipelineviafindOrCreatePipeline. On creation,stageProgression[]is initialized with onependingentry per stage. - Stage status guard — read
pipeline.stageProgression[stageIndex].status:'pending'→ 409 "This stage is locked. Unlock it from the candidate pipeline first."'invited' | 'in_progress' | 'completed'→ 409 "Cannot invite: stage is already '{status}'."'unlocked'→ proceed.
- Secondary race-condition check: look for an existing non-cancelled/non-declined Interview for
(candidatePipelineId, stageId)→ 409 if found. - Generate
screeningTokenanddeclineTokenviacrypto.randomBytes(32).toString('hex'). - Create
InterviewwithstageTypeKey: 'automated_screening',schedulingType: 'async',status: 'scheduled'. - Call
markStageInvited(pipeline._id, stageIndex, interview._id). - Send email (branched on
sendAutomatedLink):true→ includes screening link AND decline link:- Attend:
${FRONTEND_URL}/candidate/screening?token=${screeningToken} - Decline:
${FRONTEND_URL}/candidate/decline/${declineToken}(frontend URL — leads to DeclineConfirmation page)
- Attend:
false→ notification only, no links.
1A.5 Backend — Screening Session Endpoints (No Auth)
src/controllers/screeningSessionController.ts
GET /v1/screening/:token — validate token, return session config + questions
POST /v1/screening/:token/start — mark in_progress
POST /v1/screening/:token/submit — submit all responses → trigger inline AI evaluationGET /v1/screening/:token:
- Find
InterviewbyscreeningTokenwherestatus ≠ 'cancelled' - Resolve effective config (overrides → fallback to job config)
- Return:
{ title, expiresAt, questions: [{ questionId, text }] }— no AI scores, no internal fields
POST /v1/screening/:token/start:
- Update Interview status →
in_progress,startTime = now - Update pipeline stage →
status: 'in_progress',startedAt = now Participant.$inc({ 'stats.totalInterviews': 1 })
POST /v1/screening/:token/submit:
- Save responses to
Interview.stageData.screeningResponses[] - Update
Interview.status = 'completed' - Update pipeline stage →
status: 'completed',candidateStatus: 'submitted',completedAt - Trigger inline AI evaluation (see §1A.7)
1A.6 Backend — Decline Endpoint
POST /v1/interviews/decline/:declineToken (no auth)
Registered before /:id routes in interviewRoutes.ts to prevent param collision.
Body (optional): { reason?: string, tags?: string[] }
Interview.findOne({ declineToken })— 404 if not found. Nostatusfilter — accepts a decline on any status (the frontend confirmation page is the UX gate).Interview.findByIdAndUpdate(id, { status: 'declined', participantRsvp: 'declined', 'stageData.declineData': { reason, tags, submittedAt: now } })pipelineService.markStageDeclined(pipeline._id, stageIndex):stageProgression[stageIndex].status = 'declined'stageProgression[stageIndex].candidateStatus = 'declined'
- Queue recruiter notification email: "Candidate declined [stage name] for [job title]"
- Return
200 { message: 'Declined successfully' }
A declined interview does not block a re-invite.
1A.6b Backend — Unlock Stage Force Flag
POST /v1/pipeline/:id/unlock-stage
Body: { stageIndex: number, force?: boolean }
Without force (default):
- Check all preceding stages are
completed→ 409 "Previous stages not completed" if any aren't
With force: true:
- Cascade-complete all preceding stages where
status ∉ ['completed', 'declined', 'expired', 'skipped'] - Set
currentStageIndex = stageIndex, target stagestatus = 'unlocked'
The frontend shows a confirmation dialog listing incomplete preceding stages before sending force: true.
1A.7 Backend — AI Evaluation
src/services/screeningEvaluationService.ts
Triggered inline after submit (Phase 1A). Moved to job queue in Phase 6.
- Fetch Interview with
stageData.screeningResponses - Resolve effective questions (overrides → job config)
- Build prompt: job description + all Q&A pairs
- Call
@anthropic-ai/sdkwith modelclaude-sonnet-4-6→ parse intoscreeningAiReportshape + computecandidateAggregateScore(0–100) Interview.findByIdAndUpdate(id, { 'stageData.screeningAiReport': report, candidateAggregateScore: score })
screeningAiReport fields: score, summary, strengths, concerns, recommendation, generatedAt
1A.8 Backend — Stage Sync on Job Edit
Deferred — not implemented in Phase 1A.
PATCH /v1/jobs/:idcurrently saves the updated job without syncing active pipelines. ThesyncActivePipelinesoperation will be added in Phase 1B or as a standalone patch.Planned behaviour (for future reference):
typescriptawait CandidatePipeline.updateMany( { jobOpeningId }, { $set: { 'stageProgression.$[elem].stageName': newName, 'stageProgression.$[elem].stageTypeKey': newKey }}, { arrayFilters: [{ 'elem.status': { $in: ['pending', 'unlocked'] } }] } );
1A.9 Frontend — Job Creation with Screening Config
JobForm.tsx:
- Stage type
automated_screening→ render<ScreeningQuestionsConfig /> schedulingConfig.type === 'async'→ hide date/time pickersquestionBankConfig.minQuestions→ show validation hint
1A.10 Frontend — Screening Session Page
Public route: /candidate/screening — token passed as query param ?token=TOKEN
Page: src/pages/candidate/Screening.tsx
- Extract
tokenfromwindow.location.search GET /v1/screening/:token→ load questions and session metadata- Multi-step text Q&A — one question per screen; progress indicator shown
POST /v1/screening/:token/starton first answerPOST /v1/screening/:token/submiton final submission- Thank-you / completion screen
No Firebase auth required. The screeningToken in the URL is the only credential.
Decline confirmation page: /candidate/decline/:token — src/pages/candidate/DeclineConfirmation.tsx
Loaded from the email decline link. Shows:
- Text area (max 1000 chars) for optional reason
- Multi-select reason chips (pre-set options)
- "Confirm Decline" CTA →
POST /v1/interviews/decline/:declineTokenwith{ reason?, tags? } - Success/thank-you screen after confirmation
1A.11 Frontend — Recruiter: Candidate Pipeline List
Implemented as an embedded tab — no dedicated page route.
The candidate list is rendered as the "Candidates" tab inside JobDetails.tsx, not as a separate page.
Component: src/components/jobs/CandidatePipelineTab.tsx
GET /v1/pipeline?jobId=:jobIdon mount; all filtering is client-side- Client-side search (debounced, matches name or email) + stage filter dropdown (populated from
job.stages) - Pagination:
PAGE_SIZE = 10, Prev/Next controls - Each row: avatar initial, name + email, current stage name,
{completed}/{total}stages count, status badge onClickcallbackonSelectCandidate(pipelineId)→ opens candidate detail modal
1A.12 Frontend — Recruiter: Candidate Detail
Implemented as an embedded ResponsiveSheet modal — no dedicated page route.
When a recruiter clicks a candidate row in the Candidates tab, a slide-in sheet opens inside JobDetails.tsx.
Component: src/components/jobs/CandidateDetailPanel.tsx (prop-driven: pipelineId: string)
GET /v1/pipeline/:id→ renders all stages as a timeline- Stage timeline: each stage shows status badge, invited/started/completed timestamps
- AI report viewer: expanding a completed
automated_screeningstage shows fullscreeningAiReport(score, summary, recommendation, strengths, concerns) plus Q&A pair listing — recruiter only - Decline data: if a stage was declined, the candidate's optional reason text and selected chips are shown in the stage card
- Unlock stage: "Unlock Next Stage" button →
POST /v1/pipeline/:id/unlock-stage. If preceding stages are incomplete, a confirmation dialog lists them and sends{ force: true }after recruiter confirms - Schedule CTA: for an
unlockedstage with no interview scheduled, a "Schedule" button in the stage card header → closes the candidate modal, opens the schedule modal (ResponsiveSheet) withparticipantName,participantEmail, androundpre-filled - Notes: recruiter notes panel — add note →
POST /v1/pipeline/:id/notes - Global status selector:
PATCH /v1/pipeline/:id/status— status badge dropdown (Active / Shortlisted / Rejected / Hired)
The CandidateDetailPanel is also reused by src/pages/recruiter/CandidateDetail.tsx for the legacy full-page route (/dashboard/jobs/:jobId/candidates/:pipelineId), which exists in App.tsx but is not linked from the main UI.
Phase 1A — Acceptance Criteria
- [x] Recruiter creates a job with
automated_screeningstage and ≥2 questions - [ ]
Recruiter can customize questions per-candidate in the invite modal— deferred (stageOverridesfield exists on Interview for forward compat) - [x] Recruiter invites candidate by email → candidate receives screening link AND decline link
- [x] Candidate opens screening link (
/candidate/screening?token=TOKEN, no login), answers all questions, submits - [x]
Interview.status = 'completed',stageData.screeningResponsespersisted - [x]
screeningAiReportgenerated viaclaude-sonnet-4-6,candidateAggregateScoreset → full report visible to recruiter; score only to candidate (Phase 2 dashboard) - [x] Candidate clicks decline link →
DeclineConfirmationpage collects optional reason + chips →POST /v1/interviews/decline/:declineToken→Interview.status = 'declined',stageData.declineDatasaved, recruiter notified by email - [x] Recruiter can unlock next stage (with confirmation dialog if preceding stages incomplete, sends
force: true); duplicate interview creation blocked by stage status guard + secondary race-condition check - [x] Global shortlist/reject/hire dropdown available from candidate detail modal
- [ ]
Editing a job stage updates— deferred (stageProgressionfor candidates inpending/unlockedstatus onlysyncActivePipelinesnot yet implemented) - [x] Job creation returns 403 when org
maxActiveJobslimit is reached - [x] Candidates tab embedded in
JobDetails.tsx— no separate page route; candidate detail opens as aResponsiveSheetmodal - [x] Schedule CTA in candidate detail modal pre-fills participant name, email, and stage into the schedule form