Skip to content

Phase 1B — Live Interview + Feedback Gate

Status: ✅ Complete (March 2026)

Scope (revised from original plan): A recruiter can schedule a live video call with a candidate, conduct the interview using the existing WebRTC room, and collect structured post-call feedback from both parties. Recruiter feedback submission is the single trigger that marks the pipeline stage completed — only then can the recruiter unlock subsequent stages.

Phase 1C (deferred from original 1B): Private question bank CRUD, push-to-candidate in room, JobForm live1on1Config question picker.


What Was Implemented

Backend Changes

1. Interview.ts — Schema Updates

IFeedback — updated:

typescript
interface IFeedback {
  interviewerEmail: string;
  overallRating: number;          // 1–10
  traits: string[];               // NEW: selected trait chips
  recommendation: 'strong_yes' | 'yes' | 'no' | 'strong_no'; // now required
  comments: string;
  criteriaScores?: { key: string; score: number }[];
  submittedAt: Date;
}

ICandidateFeedback — new sub-document (stored at interview.candidateFeedback, not in the feedbacks[] array):

typescript
interface ICandidateFeedback {
  overallRating: number;
  traits: string[];
  recommendation: 'strong_yes' | 'yes' | 'no' | 'strong_no';
  comments?: string;              // optional for candidates
  submittedAt: Date;
}

2. candidatePipelineService.tsmarkStageCompleted() extended

Added optional candidateStatus param (default 'submitted'). Live interview feedback callers pass 'completed':

typescript
async markStageCompleted(
  pipelineId: Types.ObjectId,
  stageIndex: number,
  result?: 'pass' | 'fail' | 'hold' | 'pending',
  candidateStatus: StageCandidateStatus = 'submitted'
): Promise<ICandidatePipeline | null>

3. interviewController.ts — Major update

Routing logic updated:

ConditionHandler
stageId present + isAutomated !== false_createScreeningInterview
no stageId, OR stageId + isAutomated === false_createLiveInterview

_createLiveInterview — pipeline-aware path added:

When stageId, stageIndex, and candidatePipelineId are provided (from the "Schedule" button in CandidateDetailPanel):

  1. Validates stage state via pipeline
  2. Checks for duplicate live interview on (candidatePipelineId, stageId)
  3. Creates Interview doc with candidatePipelineId and stageId stored
  4. Sets meetingLink = ${baseUrl}/room/${interview._id} (so roomId === interviewId on the feedback page)
  5. Calls pipelineService.markStageInvited()

feedback() — updated:

Now requires auth (req.user). Updated Zod schema:

typescript
const feedbackSchema = z.object({
  interviewerEmail: z.string().email(),
  overallRating: z.number().int().min(1).max(10),
  traits: z.array(z.string()).default([]),
  recommendation: z.enum(['strong_yes', 'yes', 'no', 'strong_no']),
  comments: z.string().min(5),
});

After pushing to interview.feedbacks[]:

  1. Sets interview.status = 'completed', sets endTime if not already set
  2. If interview.candidatePipelineId + interview.stageId exist: finds pipeline, finds stage index, derives result (strong_yes | yes → 'pass', else 'hold'), calls markStageCompleted(..., result, 'completed')

candidateFeedback() — new method:

Saves to interview.candidateFeedback (the single sub-doc, not the array). Does NOT touch interview status or pipeline. Requires auth.

4. interviewRoutes.ts

  • Added authMiddleware to POST /:id/feedback
  • Added POST /:id/candidate-feedback (auth required)

5. candidatePipelineController.ts — Hard guard in unlockStage()

Fires before the force check — force: true cannot bypass it:

typescript
// HARD BLOCK: live stages require feedback before ANY unlock (force cannot bypass)
const liveStagesBlocking = existing.stageProgression
  .slice(0, stageIndex)
  .filter(s =>
    ['live_1on1', 'culture_fit_hr'].includes(s.stageTypeKey) &&
    !['completed', 'skipped', 'declined'].includes(s.status)
  );

if (liveStagesBlocking.length > 0) {
  // Checks Interview.feedbacks array for those interview IDs
  // Returns 400 requiresFeedback: true if any are missing feedback
}

Frontend Changes

6. FeedbackForm.tsx — New shared component

src/components/interviews/FeedbackForm.tsx

Used by both Feedback.tsx (post-call page) and CandidateDetailPanel.tsx (late-submission dialog).

typescript
interface FeedbackFormProps {
  interviewId: string;
  role: 'recruiter' | 'candidate';
  onSuccess: () => void;
  onSkip?: () => void;  // shows "Submit later" link if provided
}

Fields:

  • Rating: 1–10 segmented button grid
  • Recommendation: 4 pill buttons (role-specific labels)
  • Traits: multi-select chip grid (10 recruiter traits / 10 candidate traits)
  • Comment: required for recruiter (min 5 chars), optional for candidate

Exports:

typescript
export const RECRUITER_TRAITS = [
  'Confident', 'Knowledgeable', 'Strong Basics', 'Problem Solver',
  'Communicative', 'Analytical', 'Leadership Potential',
  'Team Player', 'Detail Oriented', 'Creative Thinker',
];

export const CANDIDATE_TRAITS = [
  'Friendly', 'Clear Questions', 'Professional', 'Encouraging',
  'Well Prepared', 'Knowledgeable', 'Fair Assessment',
  'Good Listener', 'Structured', 'Supportive',
];

Recommendation labels differ by role:

  • Recruiter: "Strong Yes / Yes / No / Strong No"
  • Candidate: "Highly Recommend / Recommend / Not Recommend / Strong No"

7. Feedback.tsx — Full rework

src/pages/Feedback.tsx — route: /feedback/:roomId

  • roomId from useParams() = interviewId
  • Calls interviewApi.getOne(roomId) to load interview title
  • Role detected from dbUser.roles.includes('recruiter')
  • Shows compact call stats card if lastCallStats exists (no longer redirects to home if missing)
  • Renders <FeedbackForm interviewId={roomId} role={role} />
  • Recruiter onSuccess: shows success message + auto-navigates to /dashboard
  • Candidate onSuccess: shows inline thank-you screen (no redirect)
  • onSkip: navigates to /dashboard (recruiter) or /candidate/dashboard (candidate)

8. CandidateDetailPanel.tsx — Live stage feedback

For live_1on1 and culture_fit_hr stage cards:

Stage header (collapsed view):

  • When stage has an interviewId AND is not pending/completed AND feedbacks[] is empty: shows amber "Feedback" button that opens the late-submission dialog

Expanded view:

  • Loads interview data via interviewApi.getOne(interviewId) (already fetched on expand)
  • If feedbacks.length === 0: shows amber "Feedback pending" badge + "Submit Feedback" button
  • If feedbacks.length > 0: shows feedback summary card:
    • Rating badge (n/10)
    • Recommendation chip (color-coded)
    • Trait pills
    • Comment excerpt
    • Submission timestamp

Late-submission Dialog:

  • Contains <FeedbackForm role="recruiter" onSuccess={handleFeedbackSuccess} />
  • onSuccess: closes dialog + re-fetches pipeline to reflect stage completion

9. interview.service.ts — Updated types and methods

New TS interfaces:

typescript
interface IFeedback {
  interviewerEmail: string;
  overallRating: number;
  traits: string[];
  recommendation: 'strong_yes' | 'yes' | 'no' | 'strong_no';
  comments: string;
  submittedAt: string;
}

interface ICandidateFeedback {
  overallRating: number;
  traits: string[];
  recommendation: 'strong_yes' | 'yes' | 'no' | 'strong_no';
  comments?: string;
  submittedAt: string;
}

Updated Interview interface: added feedbacks: IFeedback[], candidateFeedback?: ICandidateFeedback, stageTypeKey?, candidatePipelineId?, stageId?, full status enum.

New methods:

typescript
submitFeedback(id, { interviewerEmail, overallRating, traits, recommendation, comments })
submitCandidateFeedback(id, { overallRating, traits, recommendation, comments? })

getOne return type updated to Promise<{ success: boolean; data: Interview }>.


New API Endpoints

MethodRouteAuthDescription
POST/v1/interviews/:id/feedbackRequiredSubmit recruiter feedback; triggers pipeline stage completion
POST/v1/interviews/:id/candidate-feedbackRequiredSubmit candidate feedback; no pipeline side-effects

Feedback Gate Logic

POST /v1/pipeline/:id/unlock-stage

  ├─ HARD BLOCK (before force check):
  │   For every live stage (live_1on1, culture_fit_hr) before stageIndex
  │   that is NOT (completed | skipped | declined):
  │     Check Interview.feedbacks[] → if empty → 400 requiresFeedback: true
  │     force: true does NOT bypass this block

  └─ Standard guard (bypassed by force: true):
      If preceding stages are incomplete → 409 requiresForce: true

Response when feedback is required:

json
{
  "success": false,
  "requiresFeedback": true,
  "message": "Submit interview feedback before advancing to the next stage.",
  "pendingStages": ["Technical Interview Round 1"]
}

Deferred to Phase 1C

  • InterviewQuestion model + GET/POST /v1/interview-questions
  • GET /v1/interviews/:id/private-questions
  • Private question panel in Room.tsx + push_question socket event
  • Candidate question display in room
  • JobForm live1on1Config private question picker

Acceptance Criteria (All Verified)

  • [x] Recruiter submits structured feedback (rating + recommendation + traits + comment) via POST /v1/interviews/:id/feedback
  • [x] Feedback submission marks interview.status = 'completed' and stageProgression[n].status = 'completed'
  • [x] recommendation: 'strong_yes' | 'yes'result: 'pass'; 'no' | 'strong_no'result: 'hold'
  • [x] POST /v1/pipeline/:id/unlock-stage → 400 requiresFeedback: true before feedback is submitted
  • [x] force: true still returns 400 — hard guard fires first
  • [x] After feedback is submitted: unlock-stage proceeds normally
  • [x] Candidate submits feedback via POST /v1/interviews/:id/candidate-feedback — stored in candidateFeedback, no stage change
  • [x] /feedback/:roomId page works after call ends (roomId = interviewId)
  • [x] /feedback/:roomId works on direct URL load (no redirect-to-home guard)
  • [x] Role auto-detected from dbUser.roles — recruiter and candidate see different trait lists and recommendation labels
  • [x] CandidateDetailPanel shows "Feedback pending" badge + button for live stages without feedback
  • [x] Late-submission dialog opens FeedbackForm inline — no page navigation required
  • [x] Feedback summary (rating badge, recommendation chip, traits, comment) shown after submission
  • [x] Pipeline-aware live interview scheduling: meetingLink = /room/:interviewId, candidatePipelineId + stageId stored, markStageInvited() called