Skip to content

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.tssrc/services/screeningQuestionService.ts:

  • Keep normalizeText, computeContentHash, findOrCreateQuestion (pointed at new ScreeningQuestion model)

1A.2 Backend — JobOpening Controller Update

  • Validate stage configs using stageTypeKey from StageTypeConfig lookup (not string comparison)
  • For automated_screening stages: validate screeningConfig.questions.length >= stageTypeConfig.questionBankConfig.minQuestions
  • On create/update: resolve question text → ScreeningQuestion ID via findOrCreateQuestion
  • Store { questionId: ObjectId, order: number } in stages[].screeningConfig.questions

1A.3 Backend — CandidatePipeline Service + Controller

src/services/candidatePipelineService.ts

typescript
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 too

src/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/notes

1A.4 Backend — Interview Scheduling for Automated Screening

POST /v1/interviews — the presence of stageId in the request body triggers the pipeline-aware path (_createScreeningInterview).

  1. Validate stageId exists on the job and belongs to the org.
  2. Upsert Participant by email (create if not exists).
  3. Find or create CandidatePipeline via findOrCreatePipeline. On creation, stageProgression[] is initialized with one pending entry per stage.
  4. 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.
  5. Secondary race-condition check: look for an existing non-cancelled/non-declined Interview for (candidatePipelineId, stageId) → 409 if found.
  6. Generate screeningToken and declineToken via crypto.randomBytes(32).toString('hex').
  7. Create Interview with stageTypeKey: 'automated_screening', schedulingType: 'async', status: 'scheduled'.
  8. Call markStageInvited(pipeline._id, stageIndex, interview._id).
  9. 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)
    • 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 evaluation

GET /v1/screening/:token:

  • Find Interview by screeningToken where status ≠ '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[] }

  1. Interview.findOne({ declineToken }) — 404 if not found. No status filter — accepts a decline on any status (the frontend confirmation page is the UX gate).
  2. Interview.findByIdAndUpdate(id, { status: 'declined', participantRsvp: 'declined', 'stageData.declineData': { reason, tags, submittedAt: now } })
  3. pipelineService.markStageDeclined(pipeline._id, stageIndex):
    • stageProgression[stageIndex].status = 'declined'
    • stageProgression[stageIndex].candidateStatus = 'declined'
  4. Queue recruiter notification email: "Candidate declined [stage name] for [job title]"
  5. 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 stage status = '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.

  1. Fetch Interview with stageData.screeningResponses
  2. Resolve effective questions (overrides → job config)
  3. Build prompt: job description + all Q&A pairs
  4. Call @anthropic-ai/sdk with model claude-sonnet-4-6 → parse into screeningAiReport shape + compute candidateAggregateScore (0–100)
  5. 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/:id currently saves the updated job without syncing active pipelines. The syncActivePipelines operation will be added in Phase 1B or as a standalone patch.

Planned behaviour (for future reference):

typescript
await 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 pickers
  • questionBankConfig.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

  1. Extract token from window.location.search
  2. GET /v1/screening/:token → load questions and session metadata
  3. Multi-step text Q&A — one question per screen; progress indicator shown
  4. POST /v1/screening/:token/start on first answer
  5. POST /v1/screening/:token/submit on final submission
  6. Thank-you / completion screen

No Firebase auth required. The screeningToken in the URL is the only credential.

Decline confirmation page: /candidate/decline/:tokensrc/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/:declineToken with { 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=:jobId on 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
  • onClick callback onSelectCandidate(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_screening stage shows full screeningAiReport (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 unlocked stage with no interview scheduled, a "Schedule" button in the stage card header → closes the candidate modal, opens the schedule modal (ResponsiveSheet) with participantName, participantEmail, and round pre-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_screening stage and ≥2 questions
  • [ ] Recruiter can customize questions per-candidate in the invite modaldeferred (stageOverrides field 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.screeningResponses persisted
  • [x] screeningAiReport generated via claude-sonnet-4-6, candidateAggregateScore set → full report visible to recruiter; score only to candidate (Phase 2 dashboard)
  • [x] Candidate clicks decline link → DeclineConfirmation page collects optional reason + chips → POST /v1/interviews/decline/:declineTokenInterview.status = 'declined', stageData.declineData saved, 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 stageProgression for candidates in pending/unlocked status onlydeferred (syncActivePipelines not yet implemented)
  • [x] Job creation returns 403 when org maxActiveJobs limit is reached
  • [x] Candidates tab embedded in JobDetails.tsx — no separate page route; candidate detail opens as a ResponsiveSheet modal
  • [x] Schedule CTA in candidate detail modal pre-fills participant name, email, and stage into the schedule form