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
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 filteringInitialization
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.
// 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 status | candidateFacingStatus |
|---|---|
active | in_progress |
shortlisted | advanced |
rejected | not_selected |
hired | offer_extended |
withdrawn | withdrawn |
Internal stageProgression[].status | candidateStatus |
|---|---|
pending | upcoming |
unlocked | upcoming |
invited | scheduled |
in_progress | in_progress |
completed | completed |
expired | expired |
declined | declined |
skipped | skipped |
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 noteUnlock-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 stagestatus = 'unlocked'
The frontend shows a confirmation dialog listing incomplete preceding stages before sending force: true.
// 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 } }] }
);