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:
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):
interface ICandidateFeedback {
overallRating: number;
traits: string[];
recommendation: 'strong_yes' | 'yes' | 'no' | 'strong_no';
comments?: string; // optional for candidates
submittedAt: Date;
}2. candidatePipelineService.ts — markStageCompleted() extended
Added optional candidateStatus param (default 'submitted'). Live interview feedback callers pass 'completed':
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:
| Condition | Handler |
|---|---|
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):
- Validates stage state via pipeline
- Checks for duplicate live interview on
(candidatePipelineId, stageId) - Creates
Interviewdoc withcandidatePipelineIdandstageIdstored - Sets
meetingLink = ${baseUrl}/room/${interview._id}(soroomId === interviewIdon the feedback page) - Calls
pipelineService.markStageInvited()
feedback() — updated:
Now requires auth (req.user). Updated Zod schema:
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[]:
- Sets
interview.status = 'completed', setsendTimeif not already set - If
interview.candidatePipelineId+interview.stageIdexist: finds pipeline, finds stage index, derives result (strong_yes | yes → 'pass', else'hold'), callsmarkStageCompleted(..., 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
authMiddlewaretoPOST /: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:
// 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).
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:
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
roomIdfromuseParams()=interviewId- Calls
interviewApi.getOne(roomId)to load interview title - Role detected from
dbUser.roles.includes('recruiter') - Shows compact call stats card if
lastCallStatsexists (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
interviewIdAND is notpending/completedANDfeedbacks[]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
- Rating badge (
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:
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:
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
| Method | Route | Auth | Description |
|---|---|---|---|
POST | /v1/interviews/:id/feedback | Required | Submit recruiter feedback; triggers pipeline stage completion |
POST | /v1/interviews/:id/candidate-feedback | Required | Submit 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: trueResponse when feedback is required:
{
"success": false,
"requiresFeedback": true,
"message": "Submit interview feedback before advancing to the next stage.",
"pendingStages": ["Technical Interview Round 1"]
}Deferred to Phase 1C
InterviewQuestionmodel +GET/POST /v1/interview-questionsGET /v1/interviews/:id/private-questions- Private question panel in
Room.tsx+push_questionsocket event - Candidate question display in room
- JobForm
live1on1Configprivate 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'andstageProgression[n].status = 'completed' - [x]
recommendation: 'strong_yes' | 'yes'→result: 'pass';'no' | 'strong_no'→result: 'hold' - [x]
POST /v1/pipeline/:id/unlock-stage→ 400requiresFeedback: truebefore feedback is submitted - [x]
force: truestill returns 400 — hard guard fires first - [x] After feedback is submitted:
unlock-stageproceeds normally - [x] Candidate submits feedback via
POST /v1/interviews/:id/candidate-feedback— stored incandidateFeedback, no stage change - [x]
/feedback/:roomIdpage works after call ends (roomId = interviewId) - [x]
/feedback/:roomIdworks 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]
CandidateDetailPanelshows "Feedback pending" badge + button for live stages without feedback - [x] Late-submission dialog opens
FeedbackForminline — 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+stageIdstored,markStageInvited()called