Candidate Dashboard
Identity Model: Recruiter-Created, Candidate-Claimed
The fundamental design contract:
- A
Participantrecord is created by the system (not the candidate) the moment a recruiter schedules any interview for an email.Participant.authIdis null — it's an "unclaimed" record. - Interview sessions do NOT require auth — they're accessed via
screeningTokenormeetingLink. Anyone with the link can attend. - Dashboard access requires Firebase auth — when a candidate logs in, the
onCandidateFirstLoginhook links theirauthIdto the existingParticipantrecord, giving access to all pipeline history across organizations.
This means:
- A candidate who receives an email can attend their session without ever creating an account.
- Once they do log in (using the same email), they see their complete history across all companies that have ever invited them.
Authentication Strategy
Firebase Auth — same infrastructure as recruiters, different role.
Candidate Auth Flow:
1. Candidate registers via Firebase (email + password, Google OAuth, magic link)
→ Firebase creates user with their email and issues an authId
2. Backend onCandidateFirstLogin hook (triggered on first call to any /v1/candidate/* endpoint):
→ Verify Firebase JWT
→ Check if Participant record exists for this email:
- If yes and authId is null → update Participant { authId, userId } ← CLAIMING
- If yes and authId already set → verify it matches (same person)
- If no → create Participant { email, name, authId, userId }
→ Attach req.participant to the request
3. All subsequent calls to /v1/candidate/* use Firebase JWT.
candidateAuthMiddleware reads Participant.authId to find the right participant record.
Interview Session Auth (no login required):
→ Screening sessions: accessed via Interview.screeningToken in the email link
→ Live sessions: accessed via Interview.meetingLink (no auth enforced at link level)
→ Starting/submitting screening: POST /v1/screening/:token/* (token-based, no Firebase)Candidate API Endpoints
All routes use candidateAuthMiddleware (Firebase JWT → req.participant).
| Endpoint | Purpose |
|---|---|
GET /v1/candidate/dashboard | All pipelines sorted by lastActivityAt |
GET /v1/candidate/pipeline/:id | Full pipeline detail (projected) |
GET /v1/candidate/interviews/:id | Past interview session (own answers only) |
PATCH /v1/candidate/interviews/:id/rsvp | Accept or decline a live invite |
PATCH /v1/candidate/preferences | Update notification preferences |
What the Dashboard Shows
Application List (/candidate/dashboard)
For each pipeline:
jobSnapshot.title— job title at enrollment timejobSnapshot.organizationName+organizationLogo— company infocandidateFacingStatus— diplomatic global statusstageProgression[].candidateStatus— per-stage dots/badgesstageProgression[].stageName— stage label (name only, not type key)
Pipeline Detail (/candidate/pipeline/:id)
- Full stage timeline with
candidateStatusfor each stage - For past sessions: link to view own answers
- For scheduled live sessions: Accept / Decline RSVP buttons
candidateAggregateScoreshown on completed stages
Past Session View (/candidate/interviews/:id)
- Own screening answers (question + response + timestamp)
- Own DSA code + per-test pass/fail (not aggregate score)
- Own AI conversation transcript (not AI analysis/scores)
- No AI scores, no recruiter feedback, no internal results
Privacy Enforcement
The candidateProjection() utility in src/utils/candidateProjection.ts is applied to every response on /v1/candidate/*. It is a whitelist — only explicitly allowed fields pass through.
Never exposed to candidates:
feedbacks[]stageData.screeningAiReportstageData.aiReportstageData.*.aiScorestageData.*.aiAnalysisstageData.dsaSubmissions[].score(aggregate % — they see per-test pass/fail instead)stageData.conversationalTurns[].audioUrlinterviewers[].email/interviewers[].rsvpStatusCandidatePipeline.status(internal:shortlisted/rejected)CandidatePipeline.stageProgression[].resultCandidatePipeline.notes[]screeningToken/declineToken
Frontend Routes (Phase 2)
| Route | Page | Component |
|---|---|---|
/candidate/dashboard | All applications | src/pages/candidate/Dashboard.tsx |
/candidate/pipeline/:id | Application detail | src/pages/candidate/PipelineDetail.tsx |
/candidate/interviews/:id | Past session view | src/pages/candidate/PastSession.tsx |
All behind ProtectedRoute with allowedRoles={['candidate']}.
New service: src/services/candidateDashboard.service.ts
Public Session Routes (No Auth)
| Route | Page | Auth |
|---|---|---|
/candidate/screening?token=TOKEN | Automated screening session | screeningToken in query param |
/candidate/decline/:token | Decline confirmation page (Phase 1A complete) | declineToken in path |
/dsa/:token | DSA round (Phase 3) | dsaToken |
/ai-session/:token | AI-assisted session (Phase 4) | sessionToken |
/lobby/:roomId | Live interview lobby | meetingLink |
/room/:roomId | Live interview room | meetingLink |
The DeclineConfirmation page (/candidate/decline/:token) shows a form with an optional reason text area and reason chips before the candidate confirms. On confirmation it POSTs { reason?, tags? } to POST /v1/interviews/decline/:declineToken.