Skip to content

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

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

  1. Check Interview.stageOverrides — if recruiter customized this candidate's session, those overrides apply.
  2. Fall back to JobOpening.stages[stageIndex] — if no overrides, use the job-level stage config.
typescript
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/:declineToken

The 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[] }

  1. Interview.findOne({ declineToken }) — 404 if not found. No status filter — accepts a decline at any status.
  2. Interview.findByIdAndUpdate(id, { status: 'declined', participantRsvp: 'declined', 'stageData.declineData': { reason, tags, submittedAt: now } })
  3. pipelineService.markStageDeclined(pipeline._id, stageIndex):
    • stageProgression[stageIndex].status = 'declined'
    • stageProgression[stageIndex].candidateStatus = 'declined'
  4. Queue recruiter notification email: "Candidate declined [stage name] for [job title]"
  5. 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

TokenFieldScopeAuth
screeningTokenInterview.screeningTokenOne async screening sessionNone — token in URL
declineTokenInterview.declineTokenOne-click decline for any inviteNone — token in URL
Firebase JWTAuthorization: Bearer headerAll /v1/candidate/* dashboard routesFirebase Auth