Interview
One Interview document = one session for one stage. The stageData sub-document holds all type-specific runtime data (responses, code submissions, AI reports, live feedback).
Each field is annotated with its visibility: [RECRUITER ONLY] or [CANDIDATE VISIBLE].
Schema
interface IInterview {
_id: ObjectId;
jobOpeningId: ObjectId; // ref → JobOpening
participantId: ObjectId; // ref → Participant
organizationId: ObjectId; // denormalized
candidatePipelineId?: ObjectId; // ref → CandidatePipeline — optional (absent on legacy live interviews)
// Stage identity — snapshot at schedule time
stageId?: ObjectId; // ref → JobOpening.stages._id — optional (absent on legacy live interviews)
stageTypeKey: StageTypeKey; // snapshot
round: string; // human-readable stage name (snapshot) [CANDIDATE VISIBLE]
hostId: string; // Firebase UID of scheduler [RECRUITER ONLY]
title: string; // [CANDIDATE VISIBLE]
description?: string; // [CANDIDATE VISIBLE]
// Scheduling
schedulingType: 'async' | 'scheduled'; // [CANDIDATE VISIBLE]
status: // [CANDIDATE VISIBLE]
| 'scheduled'
| 'in_progress'
| 'completed'
| 'cancelled'
| 'expired'
| 'no_show'
| 'declined';
startTime?: Date; // [CANDIDATE VISIBLE]
endTime?: Date; // [CANDIDATE VISIBLE]
expiresAt?: Date; // [CANDIDATE VISIBLE]
meetingLink?: string; // live rounds only [CANDIDATE VISIBLE]
// Interviewers — candidate sees names only
interviewers: {
name: string; // [CANDIDATE VISIBLE]
email: string; // [RECRUITER ONLY]
rsvpStatus: 'pending' | 'accepted' | 'rejected'; // [RECRUITER ONLY]
}[];
participantRsvp: 'pending' | 'accepted' | 'declined'; // [CANDIDATE VISIBLE]
// Async automation config [RECRUITER ONLY]
// Note: isAutomated field removed — derivable from stageTypeKey
// (!['live_1on1','culture_fit_hr'].includes(stageTypeKey))
sendAutomatedLink: boolean;
screeningToken?: string; // token for async screening sessions [SYSTEM ONLY]
declineToken?: string; // one-click decline token in every invite email [SYSTEM ONLY]
// POST /v1/interviews/decline/:declineToken — no auth required
// ── Per-candidate stage overrides (sparse) ────────────────────────────────
// Only present if recruiter customized this candidate's session before sending invite.
// If absent, runtime resolves config from JobOpening.stages[stageIndex].
stageOverrides?: {
screeningConfig?: Partial<IStageConfig['screeningConfig']>;
dsaConfig?: Partial<IStageConfig['dsaConfig']>;
aiTechnicalConfig?: Partial<IStageConfig['aiTechnicalConfig']>;
aiConversationalConfig?: Partial<IStageConfig['aiConversationalConfig']>;
live1on1Config?: Partial<IStageConfig['live1on1Config']>;
cultureFitConfig?: Partial<IStageConfig['cultureFitConfig']>;
};
// ── Candidate-visible aggregate score ──────────────────────────────────────
// Set after stage evaluation completes. Candidates see this single number;
// they never see per-question scores, criteria breakdowns, or AI analysis.
candidateAggregateScore?: number; // 0–100 [CANDIDATE VISIBLE]
// ── Stage-type-specific runtime data ──────────────────────────────────────
stageData: {
// For stageTypeKey === 'automated_screening'
screeningResponses?: {
questionId: ObjectId; // ref → ScreeningQuestion
questionText: string; // snapshot [CANDIDATE VISIBLE]
response: string; // candidate's answer [CANDIDATE VISIBLE]
aiScore?: number; // [RECRUITER ONLY]
aiAnalysis?: string; // [RECRUITER ONLY]
submittedAt: Date; // [CANDIDATE VISIBLE]
}[];
screeningAiReport?: { // [RECRUITER ONLY — never sent to candidate]
score: number; // 0–100
summary: string;
strengths: string[];
concerns: string[];
recommendation: 'proceed' | 'reject' | 'review';
generatedAt: Date;
};
// Populated when candidate declines via the DeclineConfirmation page
declineData?: {
reason?: string; // max 1 000 chars [RECRUITER ONLY]
tags?: string[]; // selected reason chips [RECRUITER ONLY]
submittedAt: Date;
};
// For stageTypeKey === 'technical_dsa'
dsaSubmissions?: {
problemId: ObjectId; // ref → DSAProblem
problemTitle: string; // snapshot [CANDIDATE VISIBLE]
language: string; // [CANDIDATE VISIBLE]
code: string; // candidate's code [CANDIDATE VISIBLE]
testResults: {
testCaseId: ObjectId;
passed: boolean; // [CANDIDATE VISIBLE]
executionTimeMs: number; // [CANDIDATE VISIBLE]
memoryUsedKB: number; // [CANDIDATE VISIBLE]
stdout?: string; // [CANDIDATE VISIBLE]
stderr?: string; // [CANDIDATE VISIBLE]
}[];
score: number; // [RECRUITER ONLY — overall % test cases passed]
submittedAt: Date; // [CANDIDATE VISIBLE]
}[];
// For stageTypeKey === 'technical_ai_assisted'
aiTechnicalResponses?: {
questionId: ObjectId; // ref → ScenarioQuestion
questionTitle: string; // snapshot [CANDIDATE VISIBLE]
initialResponse: string; // [CANDIDATE VISIBLE]
followUps: {
aiQuestion: string; // [CANDIDATE VISIBLE]
candidateResponse: string; // [CANDIDATE VISIBLE]
}[];
aiScore?: number; // [RECRUITER ONLY]
aiAnalysis?: string; // [RECRUITER ONLY]
}[];
// For stageTypeKey === 'ai_conversational'
conversationalTurns?: {
speaker: 'ai' | 'candidate';
text: string; // [CANDIDATE VISIBLE]
audioUrl?: string; // [RECRUITER ONLY — audio recording]
timestamp: Date; // [CANDIDATE VISIBLE]
}[];
// Shared AI evaluation report [RECRUITER ONLY — never sent to candidate]
aiReport?: {
overallScore: number;
summary: string;
criteriaScores: { criterion: string; score: number; notes: string }[];
recommendation: 'proceed' | 'reject' | 'review';
generatedAt: Date;
};
};
// Human feedback from interviewers (Phase 1B) [RECRUITER ONLY]
// Populated via POST /v1/interviews/:id/feedback (auth required)
// For live_1on1 / culture_fit_hr: submitting feedback triggers pipeline stage completion
feedbacks: {
interviewerEmail: string;
overallRating: number; // 1–10
traits: string[]; // selected trait chips (e.g. 'Confident', 'Analytical')
recommendation: 'strong_yes' | 'yes' | 'no' | 'strong_no'; // required
comments: string; // min 5 chars when submitted via recruiter form
criteriaScores?: {
key: string;
score: number;
}[];
submittedAt: Date;
}[];
// Candidate's post-call feedback (Phase 1B) [RECRUITER ONLY]
// Populated via POST /v1/interviews/:id/candidate-feedback (auth required)
// Does NOT affect pipeline state — purely informational
candidateFeedback?: {
overallRating: number; // 1–10
traits: string[]; // selected from candidate-specific trait list
recommendation: 'strong_yes' | 'yes' | 'no' | 'strong_no';
comments?: string; // optional
submittedAt: Date;
};
// Internal result [RECRUITER ONLY]
result: 'pending' | 'selected' | 'rejected' | 'next_round';
isDeleted: boolean;
createdAt: Date;
updatedAt: Date;
}Indexes
{ candidatePipelineId: 1, stageId: 1 } NOT a DB-level unique constraint.
Enforced in application code:
Interview.findOne({
candidatePipelineId,
stageId,
status: { $nin: ['declined', 'cancelled'] }
}) → 409 if found
{ organizationId: 1, createdAt: 1 } monthly interview count for usage limit enforcement
{ jobOpeningId: 1, status: 1 }
{ hostId: 1, status: 1 }
{ participantId: 1, status: 1 }
{ screeningToken: 1 } unique sparse
{ declineToken: 1 } unique sparse
{ expiresAt: 1 } for expiry cron job / TTL processing
{ isDeleted: 1 }
{ stageTypeKey: 1, status: 1 }Config Resolution at Runtime
The effective config for a session is resolved as follows:
- Check
Interview.stageOverrides— if recruiter customized this candidate's session, those overrides apply. - Fall back to
JobOpening.stages[stageIndex]— if no overrides, use the job-level stage config.
function resolveEffectiveScreeningConfig(
interview: IInterview,
jobOpening: IJobOpening
): IStageConfig['screeningConfig'] {
return interview.stageOverrides?.screeningConfig
?? jobOpening.stages.find(s => s._id.equals(interview.stageId))?.screeningConfig;
}Decline Flow
Every invite email contains a declineToken link. No auth is required to click it.
POST /v1/interviews/decline/:declineTokenThe decline link in every invite email points to the frontend DeclineConfirmation page (/candidate/decline/:token), which collects an optional reason + chips before POSTing.
Body (optional): { reason?: string, tags?: string[] }
Interview.findOne({ declineToken })— 404 if not found. Nostatusfilter — accepts a decline at any status.Interview.findByIdAndUpdate(id, { status: 'declined', participantRsvp: 'declined', 'stageData.declineData': { reason, tags, submittedAt: now } })pipelineService.markStageDeclined(pipeline._id, stageIndex):stageProgression[stageIndex].status = 'declined'stageProgression[stageIndex].candidateStatus = 'declined'
- Queue recruiter notification email: "Candidate declined [stage name] for [job title]"
- Return
200 { message: 'Declined successfully' }
A declined interview does not block a re-invite — the recruiter may re-invite the same candidate for the same stage after a decline.
Token Types
| Token | Field | Scope | Auth |
|---|---|---|---|
screeningToken | Interview.screeningToken | One async screening session | None — token in URL |
declineToken | Interview.declineToken | One-click decline for any invite | None — token in URL |
| Firebase JWT | Authorization: Bearer header | All /v1/candidate/* dashboard routes | Firebase Auth |