Skip to content

JobOpening

Stages now carry full type-specific configuration, not just a name and a flat list of screening questions.


Schema

typescript
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.

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

  • stageProgression entries with status: 'pending' or 'unlocked' are soft-removed from all active pipelines (entry deleted from array, currentStageIndex adjusted).
  • Entries with status: 'in_progress', 'completed', 'expired', 'declined', or 'skipped' are frozen — historical record preserved with deletedFromJob: true flag added to the entry.
  • The corresponding Interview documents are never deleted — they remain as permanent historical records.

Stage Sync on Job Edit

Deferred — not implemented in Phase 1A.

PATCH /v1/jobs/:id currently saves the updated job without syncing active pipelines. The syncActivePipelines operation 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.

typescript
// 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():

typescript
// 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.