Interview Lifecycle
Overview
The interview lifecycle spans three connected documents:
JobOpening— defines the stages (what types, what config)CandidatePipeline— tracks where the candidate is (state machine per job)Interview— one session per stage (all runtime data)
Stage Status State Machine
Each stage in CandidatePipeline.stageProgression[] moves through these states:
pending
│
▼ (recruiter unlocks)
unlocked
│
▼ (recruiter schedules / sends invite)
invited
│
├─── (candidate clicks decline link) ──► declined
│
▼ (candidate opens session)
in_progress
│
├─── (async: candidate submits) ──► completed [candidateStatus: 'submitted']
│
├─── (async: deadline passes) ──► expired
│
└─── (live: recruiter submits feedback) ──► completed [candidateStatus: 'completed']Live stage difference: For
live_1on1andculture_fit_hr, the stage does not complete when the call ends. It completes only when the recruiter submits feedback viaPOST /v1/interviews/:id/feedback.
Additionally, a recruiter can set a stage to skipped at any point.
Scheduling an Interview
POST /v1/interviews body:
{
jobId: string;
stageId: string; // which stage in the job to schedule
participantEmail: string; // candidate's email
// For live stages:
startTime?: Date;
endTime?: Date;
interviewers?: string[]; // recruiter emails
// Per-candidate customization (optional):
stageOverrides?: {
screeningConfig?: Partial<...>;
live1on1Config?: Partial<...>;
// etc.
};
}What happens:
- Find or create
Participantby email (upsert onemailunique) - Find or create
CandidatePipelinefor(jobId, participantId)— initialize all stages aspendingif first invite - Stage status guard — read
pipeline.stageProgression[stageIndex].status:'pending'→ 409 "Stage is locked — unlock it first"'invited' | 'in_progress' | 'completed'→ 409 "Cannot invite: stage already {status}"'unlocked'→ proceed
- Check
org.featureSnapshotusage limits → 403 if exceeded - Secondary race-condition check: existing non-cancelled/non-declined Interview for
(candidatePipelineId, stageId)→ 409 if found - Generate
screeningToken(async) ormeetingLink(live) - Generate
declineToken(always — included in every invite email) - Create
Interviewdoc - Update
CandidatePipeline.stageProgression[stageIndex]→status: 'invited',interviewId - Send email: attend link (
/candidate/screening?token=...) AND decline link (/candidate/decline/...)
Automated Screening Session
Token-gated, no auth required.
GET /v1/screening/:token — validate token, return questions (no AI scores)
POST /v1/screening/:token/start — mark in_progress
POST /v1/screening/:token/submit — submit all responses → queue AI evaluationConfig resolution:
- Check
Interview.stageOverrides.screeningConfig— use if present - Fall back to
JobOpening.stages[stageIndex].screeningConfig
AI Evaluation (async):
screeningEvaluationServiceis triggered after submit- Calls Claude API (
claude-sonnet-4-6) with job description + all Q&A pairs - Stores
screeningAiReportonInterview.stageData(recruiter-only) - Sets
candidateAggregateScore(0–100, candidate-visible)
Live Interview Session
Pipeline-aware scheduling (Phase 1B):
When scheduling from CandidateDetailPanel (Schedule button on an unlocked live stage), the frontend sends stageId, stageIndex, candidatePipelineId, and isAutomated: false. The controller:
- Validates stage state via pipeline (must be
unlocked) - Checks for duplicate live interview on
(candidatePipelineId, stageId) - Creates
Interviewdoc storingcandidatePipelineIdandstageId - Sets
meetingLink = ${baseUrl}/room/${interview._id}— the room ID equals the interview document ID - Calls
pipelineService.markStageInvited()→ stage status becomesinvited - Queues calendar invite email
Session flow:
- Both parties join
/room/:interviewId— the WebRTC room - When either party ends the call, Room.tsx navigates to
/feedback/:interviewId - Call end does not mark the stage complete
Post-call feedback:
/feedback/:interviewIdpage detects role fromdbUser.roles- Recruiter path: structured form (rating + recommendation + traits + required comment) →
POST /v1/interviews/:id/feedback- Marks
interview.status = 'completed' - Marks
stageProgression[n].status = 'completed',candidateStatus = 'completed' - Derives result:
strong_yes | yes → 'pass',no | strong_no → 'hold'
- Marks
- Candidate path: lighter form (rating + recommendation + traits + optional comment) →
POST /v1/interviews/:id/candidate-feedback- Stored in
interview.candidateFeedback— does NOT change pipeline state
- Stored in
Late submission (if recruiter skipped feedback page):
The CandidateDetailPanel stage card shows an amber "Feedback pending" badge + "Submit Feedback" button for any live stage with an interview but no recruiter feedback. Clicking opens an inline Dialog with FeedbackForm. On success, the panel re-fetches the pipeline.
Feedback gate:
POST /v1/pipeline/:id/unlock-stage returns 400 requiresFeedback: true if any preceding live_1on1 or culture_fit_hr stage lacks recruiter feedback. force: true does not bypass this guard.
Phase 1C (deferred): Private question panel in Room.tsx, push_question socket event, JobForm live1on1 question picker.
Feedback Submission
Recruiter Feedback
POST /v1/interviews/:id/feedback — auth required
{
interviewerEmail: string;
overallRating: number; // 1–10
traits: string[]; // e.g. ['Confident', 'Analytical']
recommendation: 'strong_yes' | 'yes' | 'no' | 'strong_no'; // required
comments: string; // min 5 chars
}- For pipeline-linked interviews (
candidatePipelineId+stageIdset):- Sets
interview.status = 'completed', capturesendTimeif missing - Calls
markStageCompleted(pipelineId, stageIndex, result, 'completed') resultderived:strong_yes | yes → 'pass',no | strong_no → 'hold'
- Sets
- Required before
unlock-stagefor live stages (enforced at API level —force: truecannot bypass)
Candidate Feedback
POST /v1/interviews/:id/candidate-feedback — auth required
{
overallRating: number; // 1–10
traits: string[]; // from candidate-specific trait list
recommendation: 'strong_yes' | 'yes' | 'no' | 'strong_no';
comments?: string; // optional
}- Stored in
interview.candidateFeedback(single sub-doc, not infeedbacks[]) - Does not affect pipeline state or interview status
- Informational only — visible to recruiter in interview detail
Pipeline Management
| Endpoint | Purpose |
|---|---|
GET /v1/pipeline?jobId=:id | List all candidates for a job |
GET /v1/pipeline/:id | Full candidate detail with all stage interviews |
PATCH /v1/pipeline/:id/status | Set global status (shortlist / reject / hire) |
POST /v1/pipeline/:id/unlock-stage | Unlock next stage. Body: { stageIndex, force? }. Hard guard: 400 if preceding live stages lack recruiter feedback (cannot be bypassed by force: true). Without force: 409 if preceding stages incomplete. With force: true: cascade-completes preceding non-live stages. |
POST /v1/pipeline/:id/notes | Add internal recruiter note |
Global Status Transitions
Recruiters set global pipeline status at any time:
| Action | status (internal) | candidateFacingStatus |
|---|---|---|
| Move to shortlist | shortlisted | advanced |
| Reject | rejected | not_selected |
| Hire | hired | offer_extended |
| Reactivate | active | in_progress |
| Mark withdrawn | withdrawn | withdrawn |
The API applies the candidateFacingStatus mapping automatically — recruiters never set it directly.