Skip to content

Candidate Dashboard


Identity Model: Recruiter-Created, Candidate-Claimed

The fundamental design contract:

  1. A Participant record is created by the system (not the candidate) the moment a recruiter schedules any interview for an email. Participant.authId is null — it's an "unclaimed" record.
  2. Interview sessions do NOT require auth — they're accessed via screeningToken or meetingLink. Anyone with the link can attend.
  3. Dashboard access requires Firebase auth — when a candidate logs in, the onCandidateFirstLogin hook links their authId to the existing Participant record, 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).

EndpointPurpose
GET /v1/candidate/dashboardAll pipelines sorted by lastActivityAt
GET /v1/candidate/pipeline/:idFull pipeline detail (projected)
GET /v1/candidate/interviews/:idPast interview session (own answers only)
PATCH /v1/candidate/interviews/:id/rsvpAccept or decline a live invite
PATCH /v1/candidate/preferencesUpdate notification preferences

What the Dashboard Shows

Application List (/candidate/dashboard)

For each pipeline:

  • jobSnapshot.title — job title at enrollment time
  • jobSnapshot.organizationName + organizationLogo — company info
  • candidateFacingStatus — diplomatic global status
  • stageProgression[].candidateStatus — per-stage dots/badges
  • stageProgression[].stageName — stage label (name only, not type key)

Pipeline Detail (/candidate/pipeline/:id)

  • Full stage timeline with candidateStatus for each stage
  • For past sessions: link to view own answers
  • For scheduled live sessions: Accept / Decline RSVP buttons
  • candidateAggregateScore shown 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.screeningAiReport
  • stageData.aiReport
  • stageData.*.aiScore
  • stageData.*.aiAnalysis
  • stageData.dsaSubmissions[].score (aggregate % — they see per-test pass/fail instead)
  • stageData.conversationalTurns[].audioUrl
  • interviewers[].email / interviewers[].rsvpStatus
  • CandidatePipeline.status (internal: shortlisted/rejected)
  • CandidatePipeline.stageProgression[].result
  • CandidatePipeline.notes[]
  • screeningToken / declineToken

Frontend Routes (Phase 2)

RoutePageComponent
/candidate/dashboardAll applicationssrc/pages/candidate/Dashboard.tsx
/candidate/pipeline/:idApplication detailsrc/pages/candidate/PipelineDetail.tsx
/candidate/interviews/:idPast session viewsrc/pages/candidate/PastSession.tsx

All behind ProtectedRoute with allowedRoles={['candidate']}.

New service: src/services/candidateDashboard.service.ts


Public Session Routes (No Auth)

RoutePageAuth
/candidate/screening?token=TOKENAutomated screening sessionscreeningToken in query param
/candidate/decline/:tokenDecline confirmation page (Phase 1A complete)declineToken in path
/dsa/:tokenDSA round (Phase 3)dsaToken
/ai-session/:tokenAI-assisted session (Phase 4)sessionToken
/lobby/:roomIdLive interview lobbymeetingLink
/room/:roomIdLive interview roommeetingLink

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.