Skip to content

Interview Lifecycle


Overview

The interview lifecycle spans three connected documents:

  1. JobOpening — defines the stages (what types, what config)
  2. CandidatePipeline — tracks where the candidate is (state machine per job)
  3. 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_1on1 and culture_fit_hr, the stage does not complete when the call ends. It completes only when the recruiter submits feedback via POST /v1/interviews/:id/feedback.

Additionally, a recruiter can set a stage to skipped at any point.


Scheduling an Interview

POST /v1/interviews body:

typescript
{
  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:

  1. Find or create Participant by email (upsert on email unique)
  2. Find or create CandidatePipeline for (jobId, participantId) — initialize all stages as pending if first invite
  3. 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
  4. Check org.featureSnapshot usage limits → 403 if exceeded
  5. Secondary race-condition check: existing non-cancelled/non-declined Interview for (candidatePipelineId, stageId) → 409 if found
  6. Generate screeningToken (async) or meetingLink (live)
  7. Generate declineToken (always — included in every invite email)
  8. Create Interview doc
  9. Update CandidatePipeline.stageProgression[stageIndex]status: 'invited', interviewId
  10. 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 evaluation

Config resolution:

  1. Check Interview.stageOverrides.screeningConfig — use if present
  2. Fall back to JobOpening.stages[stageIndex].screeningConfig

AI Evaluation (async):

  • screeningEvaluationService is triggered after submit
  • Calls Claude API (claude-sonnet-4-6) with job description + all Q&A pairs
  • Stores screeningAiReport on Interview.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:

  1. Validates stage state via pipeline (must be unlocked)
  2. Checks for duplicate live interview on (candidatePipelineId, stageId)
  3. Creates Interview doc storing candidatePipelineId and stageId
  4. Sets meetingLink = ${baseUrl}/room/${interview._id} — the room ID equals the interview document ID
  5. Calls pipelineService.markStageInvited() → stage status becomes invited
  6. Queues calendar invite email

Session flow:

  1. Both parties join /room/:interviewId — the WebRTC room
  2. When either party ends the call, Room.tsx navigates to /feedback/:interviewId
  3. Call end does not mark the stage complete

Post-call feedback:

  1. /feedback/:interviewId page detects role from dbUser.roles
  2. 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'
  3. Candidate path: lighter form (rating + recommendation + traits + optional comment) → POST /v1/interviews/:id/candidate-feedback
    • Stored in interview.candidateFeedback — does NOT change pipeline state

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/feedbackauth required

typescript
{
  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 + stageId set):
    • Sets interview.status = 'completed', captures endTime if missing
    • Calls markStageCompleted(pipelineId, stageIndex, result, 'completed')
    • result derived: strong_yes | yes → 'pass', no | strong_no → 'hold'
  • Required before unlock-stage for live stages (enforced at API level — force: true cannot bypass)

Candidate Feedback

POST /v1/interviews/:id/candidate-feedbackauth required

typescript
{
  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 in feedbacks[])
  • Does not affect pipeline state or interview status
  • Informational only — visible to recruiter in interview detail

Pipeline Management

EndpointPurpose
GET /v1/pipeline?jobId=:idList all candidates for a job
GET /v1/pipeline/:idFull candidate detail with all stage interviews
PATCH /v1/pipeline/:id/statusSet global status (shortlist / reject / hire)
POST /v1/pipeline/:id/unlock-stageUnlock 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/notesAdd internal recruiter note

Global Status Transitions

Recruiters set global pipeline status at any time:

Actionstatus (internal)candidateFacingStatus
Move to shortlistshortlistedadvanced
Rejectrejectednot_selected
Hirehiredoffer_extended
Reactivateactivein_progress
Mark withdrawnwithdrawnwithdrawn

The API applies the candidateFacingStatus mapping automatically — recruiters never set it directly.