JobOpening
Stages now carry full type-specific configuration, not just a name and a flat list of screening questions.
Schema
interface IJobOpening {
_id: ObjectId;
organizationId: ObjectId; // ref → Organization
recruiterId: ObjectId; // ref → User
title: string;
description: string;
department?: string;
location?: string;
employmentType?: 'full_time' | 'part_time' | 'contract' | 'internship';
stages: IStageConfig[];
status: 'draft' | 'open' | 'closed' | 'archived';
closingReason?: string;
// Denormalized counters — updated via atomic $inc
stats: {
totalCandidates: number;
activeCandidates: number;
shortlisted: number;
rejected: number;
};
isDeleted: boolean;
createdAt: Date;
updatedAt: Date;
}Indexes:
{ organizationId: 1, status: 1 }{ recruiterId: 1, status: 1 }{ isDeleted: 1, status: 1 }{ title: 'text', description: 'text' }— full text search
IStageConfig (embedded subdocument)
Each element in stages[] describes one round in the hiring funnel.
interface IStageConfig {
_id: ObjectId; // stage has its own ID — referenced by CandidatePipeline + Interview
name: string; // recruiter-given label e.g. "Technical Round 1"
stageTypeKey: StageTypeKey; // ref → StageTypeConfig.key
order: number; // 0-indexed position in pipeline
// --- Type-specific configs (only ONE block is set per stage) ---
// For stageTypeKey === 'automated_screening'
screeningConfig?: {
questions: { questionId: ObjectId; order: number }[]; // refs → ScreeningQuestion
timeLimitSeconds?: number; // per-question time limit (null = untimed)
totalTimeLimitMinutes?: number; // overall session cap
allowRetakes: boolean;
};
// For stageTypeKey === 'technical_dsa'
dsaConfig?: {
problems: {
problemId: ObjectId; // ref → DSAProblem
order: number;
timeLimitOverrideMs?: number; // per-problem override of DSAProblem.timeLimitMs
}[];
expiryHours: number; // deadline from when candidate first opens the test
allowedLanguages: string[]; // ['javascript', 'python', 'java', 'cpp']
proctoringEnabled: boolean;
aiChatbotEnabled: boolean;
};
// For stageTypeKey === 'technical_ai_assisted'
aiTechnicalConfig?: {
questions: { questionId: ObjectId; order: number }[]; // refs → ScenarioQuestion
expiryHours: number;
toolsEnabled: {
whiteboard: boolean;
ide: boolean;
aiChatbot: boolean;
audio: boolean;
video: boolean;
};
};
// For stageTypeKey === 'ai_conversational'
aiConversationalConfig?: {
questions: { questionId: ObjectId; order: number }[]; // refs → ScenarioQuestion
expiryHours: number;
responseTimeLimitSeconds: number;
voiceEnabled: boolean;
};
// For stageTypeKey === 'live_1on1'
live1on1Config?: {
privateQuestions: { questionId: ObjectId; order: number }[]; // refs → InterviewQuestion
defaultDurationMinutes: number;
toolsEnabled: {
whiteboard: boolean;
ide: boolean;
screenShare: boolean;
audio: boolean;
video: boolean;
aiChatbot: boolean;
};
feedbackRequired: boolean;
};
// For stageTypeKey === 'culture_fit_hr'
cultureFitConfig?: {
privateQuestions: { questionId: ObjectId; order: number }[]; // refs → InterviewQuestion
defaultDurationMinutes: number;
toolsEnabled: {
whiteboard: boolean;
ide: boolean;
screenShare: boolean;
audio: boolean;
video: boolean;
aiChatbot: boolean;
};
feedbackRequired: boolean;
};
}Stage Deletion Behavior
When a recruiter deletes a stage from a job that already has active candidate pipelines:
stageProgressionentries withstatus: 'pending'or'unlocked'are soft-removed from all active pipelines (entry deleted from array,currentStageIndexadjusted).- Entries with
status: 'in_progress','completed','expired','declined', or'skipped'are frozen — historical record preserved withdeletedFromJob: trueflag added to the entry. - The corresponding
Interviewdocuments are never deleted — they remain as permanent historical records.
Stage Sync on Job Edit
Deferred — not implemented in Phase 1A.
PATCH /v1/jobs/:idcurrently saves the updated job without syncing active pipelines. ThesyncActivePipelinesoperation will be added in Phase 1B or as a standalone patch.
Planned behaviour (for future reference): when a recruiter edits a job's stages after candidates are in the pipeline, only pending and unlocked stage entries are updated — completed/declined/expired/skipped entries are frozen as historical records.
// Planned — run after saving the updated JobOpening:
await CandidatePipeline.updateMany(
{ jobOpeningId },
{
$set: {
'stageProgression.$[elem].stageName': newName,
'stageProgression.$[elem].stageTypeKey': newKey
}
},
{
arrayFilters: [{ 'elem.status': { $in: ['pending', 'unlocked'] } }]
}
);
// Stages with 'in_progress', 'completed', 'expired', 'declined', 'skipped' are NEVER altered.Question Resolution on Create/Update
When a job is created or updated, screening question text is resolved to ScreeningQuestion IDs via findOrCreateQuestion():
// In jobOpeningController.ts:
for (const stage of stages) {
if (stage.stageTypeKey === 'automated_screening' && stage.screeningConfig) {
stage.screeningConfig.questions = await Promise.all(
stage.screeningConfig.questions.map(async (q) => {
const sq = await findOrCreateQuestion(q.text); // dedup by contentHash
return { questionId: sq._id, order: q.order };
})
);
}
}For DSA, AI-assisted, and live stages, questions are selected by ID from the question bank — they are never created inline.