Skip to content

CandidatePipeline

The single source of truth for a candidate's state within a job.

One document per (jobOpeningId, participantId) pair. Contains two parallel views of the same data: an internal recruiter view and a candidate-facing view with deliberately different vocabulary.


Schema

typescript
interface ICandidatePipeline {
  _id: ObjectId;
  jobOpeningId: ObjectId;       // ref → JobOpening
  participantId: ObjectId;      // ref → Participant
  organizationId: ObjectId;     // ref → Organization (denormalized for org-scoped queries)

  // ── Recruiter-internal global status ──────────────────────────────────────
  // Recruiter can set at any point in the pipeline
  status: 'active' | 'shortlisted' | 'rejected' | 'withdrawn' | 'hired';

  // ── Candidate-facing global status ────────────────────────────────────────
  // Set by the API when internal status changes — uses deliberate language
  // that doesn't leak internal evaluation terminology.
  candidateFacingStatus:
    | 'in_progress'       // actively moving through rounds
    | 'under_review'      // recruiter has paused/is deliberating
    | 'advanced'          // shortlisted (positive signal)
    | 'not_selected'      // rejected (diplomatic)
    | 'offer_extended'    // hired
    | 'withdrawn';        // candidate withdrew

  currentStageIndex: number;    // which stage in JobOpening.stages[] they're at

  // ── Stage progression (one entry per stage, in order) ─────────────────────
  stageProgression: {
    stageId: ObjectId;          // ref → JobOpening.stages._id
    stageName: string;          // snapshot at invite time
    stageTypeKey: StageTypeKey; // snapshot

    // Internal status — full resolution (RECRUITER ONLY)
    status:
      | 'pending'               // not yet unlocked by recruiter
      | 'unlocked'              // recruiter unlocked, not yet invited
      | 'invited'               // invitation sent to candidate
      | 'in_progress'           // candidate has opened/started
      | 'completed'             // candidate submitted / live interview ended
      | 'expired'               // async deadline passed without submission
      | 'declined'              // candidate clicked the decline link in invite email
      | 'skipped';              // recruiter manually skipped this stage

    interviewId?: ObjectId;     // ref → Interview once scheduled
    unlockedAt?: Date;
    invitedAt?: Date;
    startedAt?: Date;
    completedAt?: Date;
    expiresAt?: Date;

    // Internal result — NEVER sent to candidate
    result?: 'pass' | 'fail' | 'hold' | 'pending';

    // Candidate-facing status for this stage — coarse-grained (CANDIDATE VISIBLE)
    candidateStatus:
      | 'upcoming'              // pending or unlocked
      | 'scheduled'             // invited
      | 'in_progress'
      | 'submitted'             // async submission done
      | 'completed'             // live session ended
      | 'expired'
      | 'declined'
      | 'skipped';

    // Set to true when the stage was deleted from the job but had already started
    deletedFromJob?: boolean;
  }[];

  // ── Recruiter-internal notes (NEVER exposed to candidates) ────────────────
  notes: {
    _id: ObjectId;
    authorId: ObjectId;         // ref → User
    content: string;
    createdAt: Date;
  }[];

  // Custom tags for filtering on recruiter dashboard
  tags: string[];

  // Snapshot of job info for candidate dashboard — reduces join depth
  jobSnapshot: {
    title: string;              // snapshot of JobOpening.title at enrollment time
    organizationName: string;   // snapshot of Organization.name
    organizationLogo?: string;  // snapshot of Organization.logo
  };

  appliedAt: Date;              // when first invite was sent
  lastActivityAt: Date;         // updated on any status change
  createdAt: Date;
  updatedAt: Date;
}

Indexes

{ jobOpeningId: 1, participantId: 1 }        unique — one pipeline per candidate per job
{ jobOpeningId: 1, status: 1 }               recruiter: filter candidates by status in a job
{ organizationId: 1, status: 1 }             recruiter: org-wide pipeline view
{ participantId: 1 }                         candidate: find all their pipelines (cross-org)
{ 'stageProgression.interviewId': 1 }        sparse — navigate from interview → pipeline
{ lastActivityAt: -1 }
{ candidateFacingStatus: 1 }                 candidate dashboard filtering

Initialization

When a CandidatePipeline is first created (the very first invite for a candidate to a job), stageProgression[] is immediately populated with one entry per stage defined in JobOpening.stages[]. All entries start with status: 'pending'. Only the stage being invited for is immediately set to status: 'invited'.

This gives the recruiter a complete at-a-glance view of the candidate's full pipeline from day one.

typescript
// On POST /v1/interviews — first invite for this candidate to this job:
const pipeline = await CandidatePipeline.create({
  jobOpeningId,
  participantId,
  organizationId,
  status: 'active',
  candidateFacingStatus: 'in_progress',
  currentStageIndex: stageIndex,
  stageProgression: jobOpening.stages.map((stage, i) => ({
    stageId: stage._id,
    stageName: stage.name,
    stageTypeKey: stage.stageTypeKey,
    status: i === stageIndex ? 'invited' : 'pending',
    candidateStatus: i === stageIndex ? 'scheduled' : 'upcoming',
  })),
  jobSnapshot: {
    title: jobOpening.title,
    organizationName: org.name,
    organizationLogo: org.logo,
  },
  appliedAt: new Date(),
  lastActivityAt: new Date(),
});

Dual Status Fields — Why?

status / stageProgression[].result are internal — they use frank evaluation terms (pass, fail, shortlisted, rejected). The candidateFacingStatus / stageProgression[].candidateStatus use deliberately vague, humane language. The API always uses a separate projection function when serving candidate endpoints.

Status Mapping (applied automatically by service layer)

Internal statuscandidateFacingStatus
activein_progress
shortlistedadvanced
rejectednot_selected
hiredoffer_extended
withdrawnwithdrawn
Internal stageProgression[].statuscandidateStatus
pendingupcoming
unlockedupcoming
invitedscheduled
in_progressin_progress
completedcompleted
expiredexpired
declineddeclined
skippedskipped

stageProgression[].result (pass/fail/hold) is never mapped to any candidate-visible field.


Pipeline API Endpoints

GET  /v1/pipeline?jobId=:jobId           — list all pipelines for a job (recruiter)
GET  /v1/pipeline/:id                    — full pipeline with populated interviews (recruiter)
PATCH /v1/pipeline/:id/status            — set global status (shortlist/reject/hire)
POST /v1/pipeline/:id/unlock-stage       — unlock next stage (enables scheduling)
POST /v1/pipeline/:id/notes              — add internal note

Unlock-Stage Guard

POST /v1/pipeline/:id/unlock-stage — body: { stageIndex: number, force?: boolean }

Without force (default):

  • Check all preceding stages are completed → 409 if any aren't
  • Check feedback required for current stage (live stages): if feedbackRequired && feedbacks.length === 0 → 400

With force: true:

  • Skips the preceding-stages check
  • Runs a bulk cascade-complete: all preceding stages where status ∉ ['completed','declined','expired','skipped']status: 'completed'
  • Sets currentStageIndex = stageIndex, target stage status = 'unlocked'

The frontend shows a confirmation dialog listing incomplete preceding stages before sending force: true.

typescript
// Cascade-complete example (runs when force === true):
await CandidatePipeline.updateOne(
  { _id: pipelineId },
  { $set: {
    'stageProgression.$[elem].status': 'completed',
    [`stageProgression.${stageIndex}.status`]: 'unlocked',
    currentStageIndex: stageIndex,
  }},
  { arrayFilters: [{ 'elem.status': { $nin: ['completed','declined','expired','skipped'] }, 'elem._id': { $ne: targetStageId } }] }
);