&access_type=offline&prompt=consent\nNo JSON body.\n```\n\nStatus codes: 302 redirect (success); 400 \"Google OAuth client credentials are not configured globally.\" (missing GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET); 422 if user_id absent.\n\nNotes: Scopes: [\"https://www.googleapis.com/auth/calendar\"]. redirect_uri = settings.GOOGLE_REDIRECT_URI. Uses Flow.from_client_config. No DB access.\n\n#### GET /api/public/oauth2callback\n\nGoogle OAuth redirect handler. Exchanges the authorization code for tokens, looks up the User by the UUID in `state`, persists the Google refresh token + client credentials onto that User, returns a styled HTML success page.\n\n- **Auth:** public (no cookie/JWT). Trust via Google-issued code; user identified by UUID in `state`.\n- **Path params:** none\n- **Query params:** `code`:str (required) — Google OAuth authorization code. `state`:str (required) — the user_id (User UUID) round-tripped from /oauth/connect.\n\nRequest: none\n\nResponse:\n```html\n200 OK, Content-Type: text/html (HTMLResponse)\n...Google Calendar Connected!
... \"Your calendar has been successfully connected to IntervieHire.\" ...\n```\n\nStatus codes: 200 HTML success page; 400 \"Google OAuth client credentials are not configured globally.\"; 400 \"Invalid user ID in state.\" (state not a valid UUID); 404 \"User not found\"; 422 if code or state missing.\n\nNotes: Side effects (committed): user.google_refresh_token = credentials.refresh_token; user.google_client_id = settings.GOOGLE_CLIENT_ID; user.google_client_secret = settings.GOOGLE_CLIENT_SECRET. Scopes: [\"https://www.googleapis.com/auth/calendar\"]. redirect_uri = settings.GOOGLE_REDIRECT_URI.\n\n#### GET /api/public/schedule/{token}\n\nPublic lookup of scheduling info for a candidate by their opaque scheduling_token. Returns candidate name/email, resolved job title, current interview stage, and the proposed/scheduled time.\n\n- **Auth:** public (no cookie/JWT). Possession of applicant.scheduling_token is the only access control.\n- **Path params:** `token`:str — the Applicant.scheduling_token (opaque string).\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{\n \"candidate_name\": \"string\",\n \"email\": \"string\",\n \"job_title\": \"string\",\n \"stage\": \"string\",\n \"scheduled_at\": \"string|null\"\n}\n```\njob_title = Job.role_name or Job.title, else \"General Position\". stage = \"Functional Interview\" | \"Recruiter Screening\" | \"Resume\". scheduled_at = ISO-8601 datetime or null.\n\nStatus codes: 200 OK; 404 \"Invalid or expired scheduling token.\" (no Applicant with that token); **429 Too Many Requests — \"Too many requests. Please slow down and try again shortly.\" (2026-07-11; in-process rate limit ~60/60s per IP).**\n\nNotes: Plain dict (no Pydantic model). Stage resolution: functional_status not None → \"Functional Interview\" (scheduled_at=functional_scheduled_at); elif screening_status not None → \"Recruiter Screening\" (scheduled_at=screening_scheduled_at); else \"Resume\" (scheduled_at=None). Presence/absence (not value) drives the stage.\n\n#### GET /api/public/interview-session/{session_id}\n\nPublic lookup of interview-session info by Applicant id (used as the session id). Same payload shape as /schedule/{token} but keyed on the Applicant UUID.\n\n- **Auth:** public (no cookie/JWT). Access gated only by knowing the Applicant UUID.\n- **Path params:** `session_id`:UUID — the Applicant.id, treated as the interview session id.\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{\n \"candidate_name\": \"string\",\n \"email\": \"string\",\n \"job_title\": \"string\",\n \"stage\": \"string\",\n \"scheduled_at\": \"string|null\"\n}\n```\n\nStatus codes: 200 OK; 404 \"Session not found.\" (no Applicant with that id); 422 if session_id is not a valid UUID; **429 Too Many Requests — \"Too many requests. Please slow down and try again shortly.\" (2026-07-11; in-process rate limit ~60/60s per IP).**\n\nNotes: Plain dict (no Pydantic model). Identical stage-resolution logic to /schedule/{token}.\n\n#### GET /api/public/confirm/{token}\n\nCandidate confirms the proposed interview slot via their scheduling_token (typically from the invitation email). Sets a default slot (1 PM next day) if none exists, marks the stage status scheduled, resets the calendar sequence, creates a Google Calendar event, sends an iCal confirmation email, returns an HTML confirmation page.\n\n- **Auth:** public (no cookie/JWT). Possession of applicant.scheduling_token authorizes confirmation.\n- **Path params:** `token`:str — the Applicant.scheduling_token.\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```html\n200 OK, Content-Type: text/html (HTMLResponse)\n...Interview Confirmed!
\n \"Your {stage} has been scheduled for the following time:\"\n {Month DD, YYYY at HH:MM AM/PM UTC}
\n Go to Interview Room\n...\n```\n\nStatus codes: 200 HTML confirmation page; 404 \"Invalid or expired scheduling token.\"; 400 \"No proposed time is set for the interview.\" (proposed_time falsy — neither functional_status nor screening_status set); **429 Too Many Requests — \"Too many requests. Please slow down and try again shortly.\" (2026-07-11; in-process rate limit ~30/60s per IP).**\n\nNotes: Mutates and commits the Applicant: functional stage sets functional_scheduled_at (default next-day 13:00 UTC) and functional_status=scheduled, plus sync_applicant_to_ai (non-fatal); screening stage sets screening_scheduled_at (default next-day 13:00 UTC) and screening_status=scheduled. Always sets calendar_sequence=0. Creates Google Calendar event via create_calendar_event and stores id in calendar_event_id (non-fatal). Sends send_ical_invitation_email (duration_minutes=30, sequence=0, uid=interview-{stage-slug}-{applicant.id}@interviehire.com; non-fatal). Organizer from Organisation (org_name/contact_email) falling back to \"IntervieHire Host\" and settings.SMTP_FROM or \"hr@interviehire.com\". reschedule_link={FRONTEND_URL}/reschedule.html?token=...; interview_link={FRONTEND_URL}/interview?sessionId={applicant.id}.\n\n#### POST /api/public/reschedule/{token}\n\nCandidate reschedules their interview to a new time via scheduling_token. Parses the supplied ISO datetime, updates the relevant stage's scheduled_at and status, bumps the calendar sequence, updates the existing Google Calendar event, resends an iCal invitation email.\n\n- **Auth:** public (no cookie/JWT). Possession of applicant.scheduling_token authorizes the reschedule.\n- **Path params:** `token`:str — the Applicant.scheduling_token.\n- **Query params:** none\n\nRequest:\n```json\n{\n \"new_time\": \"string\" // required; ISO-8601 datetime. Body(..., embed=True) — MUST be a JSON object with top-level \"new_time\". Parsed via datetime.fromisoformat(new_time.replace('Z','+00:00')).\n}\n```\n\nResponse:\n```json\n{\n \"status\": \"success\",\n \"new_scheduled_time\": \"string\" // parsed_time.isoformat()\n}\n```\n\nStatus codes: 200 OK; 404 \"Invalid or expired scheduling token.\"; 400 \"Invalid ISO datetime format.\"; 422 if request body missing the required \"new_time\" field; **429 Too Many Requests — \"Too many requests. Please slow down and try again shortly.\" (2026-07-11; in-process rate limit ~30/60s per IP).**\n\nNotes: Mutates and commits the Applicant: functional stage sets functional_scheduled_at=parsed_time, functional_status=scheduled, sync_applicant_to_ai (non-fatal); screening stage sets screening_scheduled_at=parsed_time, screening_status=scheduled. Always increments calendar_sequence = (calendar_sequence or 0) + 1. If calendar_event_id exists, calls update_calendar_event (non-fatal). Resends send_ical_invitation_email (duration_minutes=30, sequence=calendar_sequence; non-fatal). Unlike /confirm, does NOT raise if no stage status is set.\n\n#### GET /api/public/careers/{subdomain}\n\nPublic career page for an organisation: returns the org's public-facing profile plus its published, career-listed jobs. The org is resolved by its `career_subdomain`.\n\n- **Auth:** none (public, no cookie/JWT). Access is by knowing the org's `career_subdomain`.\n- **Path params:** `subdomain`:str — the Organisation.career_subdomain to resolve.\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{\n \"organisation\": {\n \"org_name\": \"string\",\n \"logo_url\": \"string | null\",\n \"career_intro\": \"string | null\",\n \"career_subdomain\": \"string | null\"\n },\n \"jobs\": [\n {\n \"id\": \"string\",\n \"title\": \"string\", // job.title, falls back to role_name\n \"role_name\": \"string\",\n \"location\": \"string | null\",\n \"job_type\": \"string | null\",\n \"experience_band\": \"string | null\",\n \"description\": \"string | null\"\n }\n ]\n}\n```\n\nStatus codes: 200 OK; 404 \"Career page not found\" (no Organisation has the given career_subdomain).\n\nNotes: Plain dict response (no response_model). Resolves the Organisation by `career_subdomain`; `jobs` is filtered to that org AND `is_job_listed == True` AND `status == published` (JobStatus.published). Each job `id` is stringified; `title` falls back to `role_name` when null.\n\n#### POST /api/public/interview-session/{session_id}/screening-outcome\n\nServer-to-server route called by the interview engine (`POST /api/interviews/{sessionId}/screening-outcome`) immediately after a **recruiter-screening** interview has been scored. Reads the already-persisted evaluation off the shared engine `InterviewSession` row (never trusts the caller), records the real screening verdict onto the applicant, and — only when the candidate is a fit — auto-provisions and emails the functional-interview invite via the existing `_provision_invite_for_applicant(stage=\"functional\")` path.\n\n- **Auth:** optional shared-secret header `X-Webhook-Secret`, checked against `settings.WEBHOOK_SECRET` when that setting is non-empty (permissive — no check — when unset, so local dev needs no keys). Same pattern as the existing recording-upload webhook.\n- **Path params:** `session_id`:UUID — the Applicant id / engine InterviewSession id.\n- **Query params:** none\n\nRequest: none\n\nResponse (fits — invite minted):\n```json\n{ \"fits\": true, \"link\": \"string\", \"sent\": true }\n```\nResponse (fits — already advanced to functional previously; idempotent replay):\n```json\n{ \"fits\": true, \"alreadyAdvanced\": true, \"link\": \"string | null\" }\n```\nResponse (does not fit):\n```json\n{ \"fits\": false, \"fitLabel\": \"Moderate fit\" | \"Poor fit\" }\n```\n\nStatus codes: 200 OK; 403 \"Invalid webhook secret.\" (secret configured and mismatched); 404 \"Applicant not found.\"; 409 \"Screening evaluation is not ready yet.\" (engine session missing or not yet evaluated).\n\nNotes: Maps the engine's `evaluation.recommendation` (`strong_proceed`/`proceed` → `\"Good fit\"`, `hold`/`needs_human_review` → `\"Moderate fit\"`, `reject` → `\"Poor fit\"`) onto `Applicant.recruiter_screening`, mirrors the score onto `recruiter_screening_score` and `screening_score`, and sets `screening_status = InterviewStatus.completed`. These are the exact fields `dashboard/src/dashboard/report-page.js` (`renderScreeningPane`) and `dashboard/src/dashboard/deep-analysis.js` (`screeningBlock` + funnel stage dots) already read, so the real result surfaces there with no dashboard changes. Only a `\"Good fit\"` mapping triggers `_provision_invite_for_applicant` (mints invite, re-runs `sync_applicant_to_ai`, binds the invite token, emails it) — an applicant who already has `functional_status` set is treated as already-advanced and is never re-provisioned.\n\n### `backend/app/routers/leaderboard.py`\n\n#### GET /api/leaderboard/jobs/{job_id}\n\nReturns a ranked leaderboard of all applicants for a given job. Each entry blends resume match, recruiter-screening, and functional-interview scores into a weighted overall score (resume*0.2 + screening*0.3 + functional*0.5), pulls per-dimension rubric scores and pros/cons counts from the candidate's InterviewSession evaluation, counts proctoring warnings, reports cheat probability and current stage. Sorted by overall_score descending.\n\n- **Auth:** JWT httpOnly cookie required (get_current_user + get_active_org_id). No explicit role gate, but org-scoping enforced: non-super_admin → own organisation_id; super_admin → active_org_id cookie (or first org fallback). If job belongs to a different organisation than active_org_id, rejected with 403.\n- **Path params:** `job_id`:UUID (required) — the Job.id (422 if not a valid UUID).\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n// 200 OK — JSON array, sorted by overall_score descending. One object per applicant.\n[\n {\n \"candidate_id\": \"string\",\n \"name\": \"string\",\n \"email\": \"string\",\n \"phone\": \"string\",\n \"overall_score\": 0.0,\n \"resume_match_score\": 0.0,\n \"screening_score\": 0.0,\n \"functional_score\": 0.0,\n \"cheat_probability\": \"low\",\n \"proctoring_warnings\": 0,\n \"pros_count\": 0,\n \"cons_count\": 0,\n \"rubrics\": {\n \"Some Dimension\": 0\n },\n \"status\": \"string\"\n }\n]\n```\nDerivation: `overall_score` = resume_score*0.2 + screening_score*0.3 + functional_score*0.5 (rounded 1 dp). `resume_match_score` is the raw Applicant.match_score (un-normalized), unlike resume_score used in the calc (which is *10 if <=10). `screening_score` = Applicant.screening_score else recruiter_screening_score else 0.0. `cheat_probability` ∈ low|medium|high (default low). `proctoring_warnings` = count of ProctoringLog where sessionId == str(applicant.id). `pros_count`/`cons_count` = len(evaluation['strengths'/'weaknesses']) else 0. `rubrics` from evaluation['dimensionScores'] (Title-Cased keys → dim['score']); fallback {\"Technical Fit\",\"Communication\",\"Problem Solving\",\"Culture Fit\"}=functional_score if empty and functional_score not None. `status` = remarks else \"Functional Stage\"/\"Screening Stage\"/\"Resume Stage\".\n\nStatus codes: 200 OK (possibly empty array); 401 Unauthorized; 403 Forbidden — \"Not authorized to access this job's candidates\" (job.organisation_id and active_org_id both set and differ); 404 Not Found — \"Job not found\"; 422 Unprocessable Entity — invalid UUID.\n\nNotes: Untyped list of dicts (no response_model). Cross-service linkage uses str(applicant.id) as the key into ProctoringLog.sessionId and InterviewSession.id. 403 org check only fires when BOTH job.organisation_id and active_org_id are truthy. Router prefix '/api/leaderboard' applied at mount in main.py.\n\n### `backend/app/talent_finder/routes.py`\n\n> AI-driven candidate sourcing (\"Talent Finder\"). Router mounted in `main.py` with prefix `/api/talent-finder`. **Every route** depends on `get_current_user` (auth required — JWT `token` cookie or `Authorization: Bearer`) and `get_active_org_id` (org scoping). `org_id` is `Optional[UUID]`: for super_admin it resolves from the `active_org_id` cookie (first-org fallback); for others it is the user's `organisation_id`; it may be `None`. **There is no explicit super_admin/admin role gate on any route** (docstrings label delete/configure as \"admin\", but the code does not enforce a role). Org isolation: `_get_search`/`_get_candidate` 404 when the row's `organisation_id` is set and differs from the caller's `org_id` (rows with a null `organisation_id`, or a caller with a null `org_id`, are not isolated). **No route declares a `response_model`** — all responses are plain dicts (shapes below are the literal dicts the handlers return). Most mutating routes also append a `TalentFinderAuditLog` row (best-effort; failures are swallowed). Enum string values referenced below come from `backend/app/talent_finder/models.py`: `SearchStatus` (pending|searching|normalizing|deduping|ranking|done|failed), `ResultStatus` (new|shortlisted|rejected|saved|invited), `OutreachStatus` (none|draft|approved|sent|opted_out).\n\n#### POST /api/talent-finder/extract-brief\n\nAuto-derive a search brief from a job (authored blueprint topics → must-haves; JD skill extraction → good-to-haves) and/or raw JD text. Deterministic, keyless.\n\n- **Auth:** required (get_current_user) + get_active_org_id.\n- **Path params:** none\n- **Query params:** none\n\nRequest: raw JSON object (body typed as `dict`, no Pydantic model):\n```json\n{\n \"jobRoleId\": \"uuid string | null (optional) — if present, the Job is loaded by id (no org check) and used to seed the brief\",\n \"jdText\": \"string | null (optional) — raw JD text used when no job description is available\"\n}\n```\n\nResponse:\n```json\n{\n \"ok\": true,\n \"brief\": {\n \"title\": \"string | null\",\n \"location\": \"string | null\",\n \"experienceMin\": \"number | null\",\n \"experienceMax\": \"number | null\",\n \"mustHaveSkills\": [\"string\"],\n \"goodToHaveSkills\": [\"string\"],\n \"jdText\": \"string\"\n }\n}\n```\n\nStatus codes: 200 OK; 401 not authenticated.\n\nNotes: `mustHaveSkills` come from the job's `functional_parameters.topics[].name`; if none, seeded from the top 6 extracted JD skills. `goodToHaveSkills` = up to 8 extracted skills not already in must-haves. `experienceMin`/`experienceMax` parsed from the job's `experience_band`. No audit log. If `jobRoleId` is absent or not found, `job` is None and the brief is derived from `jdText` only.\n\n#### POST /api/talent-finder/search\n\nCreate and run a talent search synchronously: builds a brief from the (optional) job + request body, discovers candidates across the selected sources, dedups, hard-filters, weight-scores, persists profiles/sources/fit-scores/results, and returns a summary.\n\n- **Auth:** required (get_current_user) + get_active_org_id.\n- **Path params:** none\n- **Query params:** none\n\nRequest: raw JSON object (body typed as `dict`, no Pydantic model). Fields consumed by `service.build_brief` / `service._selected_sources` / the search runner:\n```json\n{\n \"jobRoleId\": \"uuid string | null (optional) — Job loaded by id (no org check) to merge into the brief\",\n \"title\": \"string | null (optional) — used when no job\",\n \"location\": \"string | null (optional)\",\n \"remoteOrOnsite\": \"string | null (optional)\",\n \"experienceRange\": { \"min\": \"number | null\", \"max\": \"number | null\" },\n \"mustHaveSkills\": [\"string\"],\n \"goodToHaveSkills\": [\"string\"],\n \"shouldNotHave\": [\"string\"],\n \"excludeKeywords\": [\"string\"],\n \"educationRequirement\": \"string | null (optional)\",\n \"industryPreference\": \"string | null (optional)\",\n \"jdText\": \"string | null (optional)\",\n \"requireAvailable\": \"boolean (optional, default false)\",\n \"includeInternational\": \"boolean (optional, default false)\",\n \"studentFocus\": \"boolean (optional, default false)\",\n \"targetCountries\": [\"string\"],\n \"maxCandidates\": \"integer (optional, default 50)\",\n \"sources\": [\"string (source_type)\"],\n \"includeInternalDatabase\": \"boolean (optional, default true)\",\n \"includeUploadedFiles\": \"boolean (optional)\",\n \"includePublicWeb\": \"boolean (optional)\",\n \"includeApprovedAPIs\": \"boolean (optional)\",\n \"csvRows\": [ { } ],\n \"manualProfiles\": [ { } ],\n \"sourceConfig\": { }\n}\n```\nSource selection: if `sources` is provided it is used verbatim; otherwise it is derived from the `include*` flags (`includeInternalDatabase` → `internal_db`,`resume_db`; `includeUploadedFiles` → `uploaded_csv`,`manual_import`; `includePublicWeb` → `public_web`; `includeApprovedAPIs` → `approved_api`), defaulting to `[\"internal_db\",\"resume_db\"]`.\n\nResponse:\n```json\n{\n \"searchId\": \"uuid string\",\n \"status\": \"done\",\n \"found\": 0,\n \"deduped\": 0,\n \"ranked\": 0,\n \"source_notes\": { \"\": \"string note (e.g. \\\"ok\\\" or a permission/error message)\" },\n \"no_results_hint\": \"string | null\"\n}\n```\n(The `status`/`found`/`deduped`/`ranked`/`source_notes`/`no_results_hint` keys are the `run_search` summary spread into the response; on success `status` is `done`.)\n\nStatus codes: 200 OK; 401 not authenticated; 500 — \"Search failed: \" (any exception during `run_search`; the TalentSearch row is marked `status='failed'` with the error stored).\n\nNotes: Persists a `TalentSearch` row (status pending→searching→deduping→ranking→done) plus `CandidateProfile` (upserted by `dedup_key` within the org), `CandidateSource`, `CandidateFitScore`, and `TalentSearchResult` rows (ranked, capped at `max_candidates`). `no_results_hint` is a non-null guidance string when zero candidates ranked, else null. Audit: `search.run`.\n\n#### GET /api/talent-finder/search/{search_id}/status\n\nPoll a search's progress/counters.\n\n- **Auth:** required (get_current_user) + get_active_org_id (org-isolated via `_get_search`).\n- **Path params:** `search_id`:UUID (required) — the TalentSearch id.\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{\n \"searchId\": \"uuid string\",\n \"status\": \"pending | searching | normalizing | deduping | ranking | done | failed\",\n \"found\": 0,\n \"deduped\": 0,\n \"ranked\": 0,\n \"error\": \"string | null\",\n \"source_notes\": { \"\": \"string\" }\n}\n```\n\nStatus codes: 200 OK; 401 not authenticated; 404 — \"Search not found\" (missing, or org mismatch); 422 invalid UUID.\n\nNotes: `found`/`deduped`/`ranked` map to the row's `found_count`/`deduped_count`/`ranked_count`. `source_notes` read from `brief._source_notes` (empty object until the search has run).\n\n#### GET /api/talent-finder/search/{search_id}/results\n\nList a search's ranked candidates with full normalized profiles.\n\n- **Auth:** required (get_current_user) + get_active_org_id (org-isolated via `_get_search`).\n- **Path params:** `search_id`:UUID (required) — the TalentSearch id.\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{\n \"searchId\": \"uuid string\",\n \"count\": 0,\n \"brief\": { },\n \"results\": [\n {\n \"id\": \"uuid string\",\n \"full_name\": \"string\",\n \"current_title\": \"string | null\",\n \"current_company\": \"string | null\",\n \"location\": \"string | null\",\n \"email\": \"string | null\",\n \"phone\": \"string | null\",\n \"profile_url\": \"string | null\",\n \"source_name\": \"string | null\",\n \"source_type\": \"string | null\",\n \"source_permission_status\": \"string | null\",\n \"skills\": [\"string\"],\n \"years_of_experience\": \"number | null\",\n \"education\": [\"string\"],\n \"previous_companies\": [\"string\"],\n \"resume_url\": \"string | null\",\n \"portfolio_url\": \"string | null\",\n \"github_url\": \"string | null\",\n \"linkedin_url\": \"string | null\",\n \"availability_status\": \"string | null\",\n \"salary_expectation\": \"string | null\",\n \"notice_period\": \"string | null\",\n \"consent_status\": \"string\",\n \"outreach_status\": \"none | draft | approved | sent | opted_out\",\n \"fit_score\": \"number | null\",\n \"fit_breakdown\": { },\n \"fit_reasoning\": \"string | null\",\n \"risk_flags\": [\"string\"],\n \"completeness\": \"number\",\n \"sources\": [\n { \"source_name\": \"string | null\", \"source_type\": \"string | null\", \"source_permission_status\": \"string | null\", \"profile_url\": \"string | null\" }\n ],\n \"rank\": \"integer | null\",\n \"result_status\": \"new | shortlisted | rejected | saved | invited\",\n \"result_id\": \"uuid string\"\n }\n ]\n}\n```\n\nStatus codes: 200 OK; 401 not authenticated; 404 — \"Search not found\"; 422 invalid UUID.\n\nNotes: `brief` is the stored TalentSearch.brief (includes `_source_notes`). Results ordered by `fit_score` descending. Each item is `_serialize_profile(profile, sources)` plus `rank`, `result_status` (the `TalentSearchResult.status`), and `result_id`. No audit log.\n\n#### POST /api/talent-finder/candidates/{candidate_id}/shortlist\n\nMark a sourced candidate shortlisted (sets every TalentSearchResult row for that candidate to `shortlisted`).\n\n- **Auth:** required (get_current_user) + get_active_org_id (org-isolated via `_get_candidate`).\n- **Path params:** `candidate_id`:UUID (required) — the CandidateProfile id.\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{ \"ok\": true, \"status\": \"shortlisted\" }\n```\n\nStatus codes: 200 OK; 401 not authenticated; 404 — \"Candidate not found\" (missing, or org mismatch); 422 invalid UUID.\n\nNotes: Audit: `candidate.shortlist`.\n\n#### POST /api/talent-finder/candidates/{candidate_id}/reject\n\nMark a sourced candidate rejected (sets every TalentSearchResult row for that candidate to `rejected`).\n\n- **Auth:** required (get_current_user) + get_active_org_id (org-isolated via `_get_candidate`).\n- **Path params:** `candidate_id`:UUID (required) — the CandidateProfile id.\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{ \"ok\": true, \"status\": \"rejected\" }\n```\n\nStatus codes: 200 OK; 401 not authenticated; 404 — \"Candidate not found\"; 422 invalid UUID.\n\nNotes: Audit: `candidate.reject`.\n\n#### POST /api/talent-finder/candidates/{candidate_id}/opt-out\n\nHonor an opt-out: set the candidate's `outreach_status=opted_out` and `consent_status=\"opted_out\"`.\n\n- **Auth:** required (get_current_user) + get_active_org_id (org-isolated via `_get_candidate`).\n- **Path params:** `candidate_id`:UUID (required) — the CandidateProfile id.\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{ \"ok\": true, \"status\": \"opted_out\" }\n```\n\nStatus codes: 200 OK; 401 not authenticated; 404 — \"Candidate not found\"; 422 invalid UUID.\n\nNotes: Audit: `candidate.opt_out`.\n\n#### POST /api/talent-finder/candidates/{candidate_id}/move-to-pipeline\n\nMove a sourced candidate into the existing interview pipeline by creating an `Applicant` on a target job, and mark the candidate's result rows `invited`.\n\n- **Auth:** required (get_current_user) + get_active_org_id (org-isolated via `_get_candidate`).\n- **Path params:** `candidate_id`:UUID (required) — the CandidateProfile id.\n- **Query params:** none\n\nRequest: raw JSON object (body typed as `dict`, optional — may be omitted/null):\n```json\n{ \"jobId\": \"uuid string | null (optional) — target Job; if omitted, falls back to the job_id of the candidate's first TalentSearchResult's search\" }\n```\n\nResponse:\n```json\n{ \"ok\": true, \"applicantId\": \"uuid string\", \"jobId\": \"uuid string\" }\n```\n\nStatus codes: 200 OK; 400 — \"jobId required to move into the interview pipeline.\" (no jobId and none derivable) OR \"Candidate has no email (needs a permissioned source) to invite.\" (candidate.email is null); 401 not authenticated; 404 — \"Candidate not found\"; 422 invalid UUID.\n\nNotes: Creates `Applicant(name, email, phone, job_id, source=ApplicantSource.scheduled, resume_url, resume_text=raw_source_payload.resume_text)`; sets all of the candidate's `TalentSearchResult` rows to `invited`. Audit: `candidate.move_to_pipeline` (detail includes applicant_id, job_id).\n\n#### POST /api/talent-finder/candidates/{candidate_id}/generate-outreach\n\nGenerate a DRAFT outreach message for a candidate (recruiter must approve before sending). Refused if the candidate opted out.\n\n- **Auth:** required (get_current_user) + get_active_org_id (org-isolated via `_get_candidate`).\n- **Path params:** `candidate_id`:UUID (required) — the CandidateProfile id.\n- **Query params:** none\n\nRequest: raw JSON object (body typed as `dict = None`, optional — may be omitted/null):\n```json\n{\n \"channel\": \"string (optional, default \\\"email\\\") — stored on the outreach message\",\n \"brief\": \"object | null (optional) — context for message generation; falls back to the candidate's fit_breakdown\"\n}\n```\n\nResponse:\n```json\n{\n \"ok\": true,\n \"outreachId\": \"uuid string\",\n \"message\": \"string (generated draft message body)\",\n \"status\": \"draft\",\n \"note\": \"Draft only — recruiter must approve before sending.\"\n}\n```\n\nStatus codes: 200 OK; 401 not authenticated; 404 — \"Candidate not found\"; 409 — \"Candidate has opted out of outreach.\" (outreach_status == opted_out); 422 invalid UUID.\n\nNotes: Persists a `CandidateOutreachMessage(channel, message, status='draft')` and sets the candidate's `outreach_status='draft'`. Company name resolved from the active Organisation's `org_name` (else \"our team\"). Audit: `outreach.generate`.\n\n#### DELETE /api/talent-finder/candidates/{candidate_id}\n\nPrivacy: hard-delete a candidate's sourced data (cascades sources / fit-scores / results / outreach via FK ondelete).\n\n- **Auth:** required (get_current_user) + get_active_org_id (org-isolated via `_get_candidate`). Docstring calls this admin-only, but **no role gate is enforced**.\n- **Path params:** `candidate_id`:UUID (required) — the CandidateProfile id.\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{ \"ok\": true, \"deleted\": \"uuid string (the candidate_id)\" }\n```\n\nStatus codes: 200 OK; 401 not authenticated; 404 — \"Candidate not found\"; 422 invalid UUID.\n\nNotes: `db.delete(candidate)`; FK cascades remove CandidateSource / CandidateFitScore / TalentSearchResult rows (CandidateOutreachMessage.search_id is SET NULL on search delete but candidate_id cascades). Audit: `data.delete`.\n\n#### POST /api/talent-finder/import/csv\n\nUpload a CSV of candidates; parses + normalizes header keys, records an import batch, and returns the parsed rows (does not create CandidateProfiles directly — rows are meant to be fed back into a search via `csvRows`).\n\n- **Auth:** required (get_current_user) + get_active_org_id.\n- **Path params:** none\n- **Query params:** none\n\nRequest: `multipart/form-data`\n```\nfile: UploadFile (required) — CSV; decoded utf-8 (errors ignored), parsed with csv.DictReader\n```\n\nResponse:\n```json\n{\n \"ok\": true,\n \"batchId\": \"uuid string\",\n \"rows\": [ { \"\": \"string value\" } ],\n \"imported\": 0,\n \"skipped\": 0\n}\n```\n\nStatus codes: 200 OK; 401 not authenticated; 422 — missing file.\n\nNotes: Header keys are lowercased, trimmed, spaces→underscores. A row is kept only if it has `full_name`, `name`, or `email`; otherwise it counts toward `skipped`. Persists a `CandidateImportBatch(source_type=\"uploaded_csv\", filename, row_count, imported_count, skipped_count)`. Audit: `import.csv`.\n\n#### GET /api/talent-finder/sources\n\nList all configured sourcing adapters with live enabled/permission status (powers the admin source panel).\n\n- **Auth:** required (get_current_user) + get_active_org_id.\n- **Path params:** none\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{\n \"sources\": [\n {\n \"source_type\": \"string (e.g. internal_db | resume_db | uploaded_csv | manual_import | github | web_search | public_web | approved_api | linkedin | internshala | naukri | indeed)\",\n \"source_name\": \"string\",\n \"permission_mode\": \"permissioned | public_allowed | user_provided | requires_permission\",\n \"is_enabled\": \"boolean\",\n \"available\": \"boolean\",\n \"note\": \"string | null (recruiter-facing reason when not available)\",\n \"rate_limit\": { \"max_per_minute\": 30, \"concurrency\": 1 }\n }\n ]\n}\n```\n\nStatus codes: 200 OK; 401 not authenticated.\n\nNotes: One entry per adapter in the registry. `available`/`note` come from each adapter's `validate_permissions()`; restricted adapters (linkedin/internshala/naukri/indeed) ship disabled with a permission note. `rate_limit` is the adapter's `rate_limit_config`. No audit log.\n\n#### POST /api/talent-finder/sources/configure\n\nCreate or update an organisation's source-adapter config (enable/disable, permission mode, opaque config). Docstring calls this admin-only.\n\n- **Auth:** required (get_current_user) + get_active_org_id. **No role gate is enforced** despite the \"admin\" label.\n- **Path params:** none\n- **Query params:** none\n\nRequest: raw JSON object (body typed as `dict`, no Pydantic model):\n```json\n{\n \"source_type\": \"string (required)\",\n \"source_name\": \"string | null (optional, default = source_type when creating)\",\n \"is_enabled\": \"boolean (optional, default = existing value)\",\n \"permission_mode\": \"string (optional, default = existing value)\",\n \"config\": \"object | null (optional) — only applied when non-null\"\n}\n```\n\nResponse:\n```json\n{ \"ok\": true, \"source_type\": \"string\", \"is_enabled\": \"boolean\" }\n```\n\nStatus codes: 200 OK; 400 — \"source_type required\" (missing source_type); 401 not authenticated.\n\nNotes: Upserts a `SourceAdapterConfig` keyed by (organisation_id, source_type). `config` is stored verbatim (intended to hold API-key refs/endpoints, never raw secrets in logs). Audit: `sources.configure` (detail includes is_enabled).\n\n### `backend/app/routers/invites.py`\n\n> Per-candidate unique interview links. A token (`uuid4().hex`, 32 hex chars) is minted into the `interview_invites` table and bound to an applicant (or left standalone). The emailed link `{INVITE_LINK_BASE}/i/{token}` carries the unguessable token (not the bare applicant id), so only the candidate who received it can enter; the lifecycle (`pending → started → completed`/`expired`) is single-use. Config: INVITE_LINK_BASE (default `http://localhost:8000`), INVITE_FROM_EMAIL (default `interviews@interviehire.com`), INVITE_TTL_DAYS (default 7). Mounted in `main.py`: the authed router at `/api/invites`, plus `public_link_router` at root for `GET /i/{token}`.\n\n#### POST /api/invites\n\nMint (and optionally email) a unique interview link bound to one applicant. Provisions the stage + engine InterviewSession so the room has questions when the candidate opens the link.\n\n- **Auth:** JWT httpOnly cookie `token` (or Bearer) required via get_current_user; org context via get_active_org_id.\n- **Path params:** none\n- **Query params:** none\n\nRequest (raw JSON object, body typed as `dict`):\n```json\n{\n \"applicant_id\": \"string, UUID (required)\",\n \"stage\": \"screening | functional (optional) — default derived from applicant: 'functional' if applicant.functional_status is set, else 'screening'\",\n \"send\": \"boolean (optional, default false)\"\n}\n```\n\nResponse:\n```json\n{\n \"token\": \"string (uuid4 hex)\",\n \"link\": \"string — e.g. {INVITE_LINK_BASE}/i/{token}\",\n \"status\": \"pending\",\n \"applicant_id\": \"string (UUID)\",\n \"candidate_email\": \"string\",\n \"candidate_name\": \"string | null\",\n \"role\": \"string | null\",\n \"stage\": \"screening | functional\",\n \"expires_at\": \"datetime ISO8601 | null\",\n \"sent\": \"boolean\"\n}\n```\n\nStatus codes: 200 OK; 400 — \"applicant_id is required\" (missing) / \"Invalid applicant_id\" (not a UUID) / \"Cannot create an interview invite for a test applicant.\" (applicant is a test applicant, via `_is_test_applicant`) / \"This applicant has no email address to send an invite to.\" (applicant.email blank); 401 Not authenticated; 403/404 — applicant access denied / not found (via `_verify_applicant_access`).\n\nNotes: Plain dict response. Verifies applicant access, rejects test applicants and applicants with no email, then provisions the stage — for screening sets screening_status=scheduled (+screening_scheduled_at) when unset; for functional sets functional_status=scheduled (+functional_scheduled_at) when unset. Mints an `interview_invites` row (token=uuid4().hex, expires_at=now+INVITE_TTL_DAYS, status=pending) via `create_invite`, which **supersedes any prior PENDING invite for the same applicant+stage** (sets them status=expired) so only one active link exists; an already-`started` invite is left alone. Then calls `sync_applicant_to_ai` to provision the engine InterviewSession (failure is logged, non-fatal), and **binds the minted token onto the shared `InterviewSession.inviteToken`** (matching engine session id = str(applicant.id)) so the engine enforces it at the room + WebSocket layer (failure logged, non-fatal). role = job.role_name or job.title. When `send=true`, emails the link via `send_interview_invite_email` (failure logged, non-fatal — `sent` reflects the result).\n\n#### GET /api/invites\n\nList invites and their statuses (newest first) for one candidate (`applicant_id`) OR a whole job (`job_id`) so the dashboard can show link status without re-minting. Does NOT transition the lifecycle.\n\n- **Auth:** JWT httpOnly cookie `token` (or Bearer) required via get_current_user; org context via get_active_org_id.\n- **Path params:** none\n- **Query params:** `applicant_id` (string, UUID) OR `job_id` (string, UUID) — **exactly one required**. `applicant_id` filters to that candidate's invites; `job_id` returns all invites for that job.\n\nRequest: none\n\nResponse:\n```json\n{\n \"invites\": [\n {\n \"token\": \"string\",\n \"link\": \"string — {INVITE_LINK_BASE}/i/{token}\",\n \"status\": \"pending | started | completed | expired\",\n \"stage\": \"string | null\",\n \"applicant_id\": \"string (UUID) | null\",\n \"created_at\": \"datetime ISO8601 | null\",\n \"expires_at\": \"datetime ISO8601 | null\",\n \"started_at\": \"datetime ISO8601 | null\",\n \"completed_at\": \"datetime ISO8601 | null\"\n }\n ],\n \"count\": \"integer\"\n}\n```\n\nStatus codes: 200 OK; 400 — \"applicant_id or job_id query param is required\" (neither supplied) / \"Invalid applicant_id\" / \"Invalid job_id\" (not a UUID); 401 Not authenticated; 403/404 — applicant access denied / not found (via `_verify_applicant_access`, when `applicant_id`) or job access denied / not found (via `_verify_job_access`, when `job_id`).\n\nNotes: Plain dict response. With `applicant_id`, verifies applicant access then filters to that applicant's `interview_invites` rows; with `job_id`, verifies job access then filters to that job's rows. Rows are ordered by created_at desc. `count` = number of rows.\n\n#### POST /api/invites/applicants\n\nBulk-mint per-applicant invites (e.g. \"invite all shortlisted\") and optionally email them. Reuses the exact same per-applicant provisioning as POST /api/invites (stage provisioning, engine session sync, token binding, optional email) for each applicant.\n\n- **Auth:** JWT httpOnly cookie `token` (or Bearer) required via get_current_user; org context via get_active_org_id.\n- **Path params:** none\n- **Query params:** none\n\nRequest (raw JSON object, body typed as `dict`):\n```json\n{\n \"applicant_ids\": [\"string, UUID\"],\n \"stage\": \"screening | functional (optional) — derived per applicant if omitted\",\n \"send\": \"boolean (optional, default false)\"\n}\n```\n`applicant_ids` is required and must be a non-empty list.\n\nResponse:\n```json\n{\n \"invited\": [\n {\n \"token\": \"string (uuid4 hex)\",\n \"link\": \"string — {INVITE_LINK_BASE}/i/{token}\",\n \"status\": \"pending\",\n \"applicant_id\": \"string (UUID)\",\n \"candidate_email\": \"string\",\n \"candidate_name\": \"string | null\",\n \"role\": \"string | null\",\n \"stage\": \"screening | functional\",\n \"expires_at\": \"datetime ISO8601 | null\",\n \"sent\": \"boolean\"\n }\n ],\n \"errors\": [\n { \"applicant_id\": \"string\", \"error\": \"string\" }\n ],\n \"count\": \"integer\"\n}\n```\nEach `invited[]` entry is the same per-applicant object returned by POST /api/invites.\n\nStatus codes: 200 OK; 400 — \"applicant_ids must be a non-empty list\" (not a list, or empty); 401 Not authenticated.\n\nNotes: Plain dict response. Iterates `applicant_ids`, verifying access and calling the shared `_provision_invite_for_applicant` for each. **Per-applicant failures are collected in `errors` instead of aborting the batch** — invalid UUID → `{error:\"Invalid applicant_id\"}`; an `HTTPException` (test applicant, no email, access denied / not found via `_verify_applicant_access`) → its detail string; any other exception → `db.rollback()` + `{error:\"Failed to create invite\"}`. `count` = number of invites actually minted (length of `invited`).\n\n#### POST /api/invites/bulk\n\nBatch-mint STANDALONE invites (not bound to any applicant) and optionally email them.\n\n- **Auth:** JWT httpOnly cookie `token` (or Bearer) required via get_current_user; get_active_org_id resolved (not gated).\n- **Path params:** none\n- **Query params:** none\n\nRequest (raw JSON object, body typed as `dict`):\n```json\n{\n \"candidates\": [\n { \"email\": \"string (required)\", \"name\": \"string | null (optional)\", \"role\": \"string | null (optional)\" }\n ],\n \"send\": \"boolean (optional, default true)\"\n}\n```\n`candidates` is required and must be a non-empty list.\n\nResponse:\n```json\n{\n \"invited\": [\n { \"email\": \"string\", \"token\": \"string\", \"link\": \"string\" }\n ],\n \"count\": \"integer\"\n}\n```\n\nStatus codes: 200 OK; 400 — \"candidates must be a non-empty list\" (not a list, or empty); 401 Not authenticated.\n\nNotes: Plain dict response. Per candidate, `invite_candidates` skips rows with a blank email, mints a standalone invite (applicant_id null) via `create_invite`, and — when `send=true` (default) — emails the link (per-row send failures are logged, non-fatal). `count` = number of invites actually minted.\n\n#### POST /api/invites/{token}/send\n\nRe-email an already-minted invite to its candidate — reuses the SAME link, so it never re-mints/invalidates a link the recruiter already copied.\n\n- **Auth:** JWT httpOnly cookie `token` (or Bearer) required via get_current_user; get_active_org_id resolved.\n- **Path params:** `token` (string) — the invite token.\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{\n \"token\": \"string\",\n \"link\": \"string\",\n \"sent\": \"boolean\",\n \"candidate_email\": \"string\"\n}\n```\n\nStatus codes: 200 OK; 404 — \"Invite not found\"; 403 — applicant access denied (only when the invite is bound to an applicant, via `_verify_applicant_access`); 502 — \"Failed to send invite email\"; 401 Not authenticated.\n\nNotes: Plain dict response. Does NOT re-mint — rebuilds the link from the stored token via `build_invite_link`. Authorises against the bound candidate's job only when `invite.applicant_id` is set.\n\n#### POST /api/invites/{token}/revoke\n\nImmediately kill a link (sets status=expired). No-op if the invite is already completed. Use when a link leaks or a candidate is withdrawn.\n\n- **Auth:** JWT httpOnly cookie `token` (or Bearer) required via get_current_user; get_active_org_id resolved.\n- **Path params:** `token` (string) — the invite token.\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{\n \"token\": \"string\",\n \"status\": \"expired | completed\"\n}\n```\n\nStatus codes: 200 OK; 404 — \"Invite not found\"; 403 — applicant access denied (only when the invite is bound to an applicant, via `_verify_applicant_access`); 401 Not authenticated.\n\nNotes: Plain dict response. When `invite.status != completed`, sets status=expired and commits; an already-completed invite is left untouched (returns its `completed` status). Authorises against the bound candidate's job only when `invite.applicant_id` is set.\n\n#### GET /api/invites/{token}\n\nRead-only resolution of a token → its room session id + status. Public (no auth) so the interview room or status checks can look it up. Does NOT transition the lifecycle.\n\n- **Auth:** public (none) — no get_current_user dependency.\n- **Path params:** `token` (string) — the invite token.\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{\n \"token\": \"string\",\n \"status\": \"pending | started | completed | expired\",\n \"candidate_name\": \"string | null\",\n \"role\": \"string | null\",\n \"stage\": \"string | null\",\n \"session_id\": \"string | null — the applicant id (engine session id)\",\n \"expires_at\": \"datetime ISO8601 | null\"\n}\n```\n\nStatus codes: 200 OK; 404 — \"Invite not found\"; 429 — \"Too many requests. Please slow down and try again shortly.\" (per-client-IP rate limit exceeded).\n\nNotes: Plain dict response. `session_id` = str(invite.applicant_id) when bound, else null. No lifecycle change. **Rate-limited per client IP** (basic in-memory fixed window via `_rate_limit`): **60 requests / 60 s**; exceeding returns 429. Best-effort, per-process (resets on restart).\n\n#### GET /i/{token}\n\nThe candidate-facing unique link (mounted at root via `public_link_router`). Validates the single-use lifecycle and redirects into the interview room.\n\n- **Auth:** public (none).\n- **Path params:** `token` (string) — the invite token.\n- **Query params:** none\n\nRequest: none\n\nResponse (success): **302** redirect — `Location: {INTERVIEW_ROOM_URL}/interviewcandidateroom?sessionId={applicant_id}&ih_invite={token}`.\n\nStatus codes / lifecycle (`_transition_for_entry`):\n- 302 — success: keys the room on the bound applicant id (engine session id) and carries `ih_invite={token}`. A `pending` invite flips to `started` (+started_at); an already-`started` invite is re-enterable (candidate reconnects after a drop).\n- 404 — unknown token (text/html error page \"Link not found\").\n- 410 — `status == completed` (\"This interview has already been completed.\"), or past `expires_at` → sets status=expired then rejects, or `status == expired` (\"This interview link has expired.\") (all text/html). **(2026-06-28)** The EXPIRED 410 page now renders an \"Email me a new link\" button that POSTs to `/i/{token}/request-new`; the COMPLETED 410 page does NOT (completed interviews can't be re-issued).\n- 409 — valid/enterable invite but no bound applicant (\"Interview not ready\" — text/html).\n- 429 — \"Too many requests. Please slow down and try again shortly.\" (per-client-IP rate limit exceeded; JSON HTTPException detail, not an HTML page).\n\nNotes: Non-302 responses (except 429) return an HTMLResponse error page (`_invite_error_html`) with the corresponding status code; only the success path is a RedirectResponse. **Rate-limited per client IP** (basic in-memory fixed window via `_rate_limit`): **30 requests / 60 s**; exceeding returns 429. Best-effort, per-process (resets on restart).\n\n#### POST /i/{token}/request-new\n\nSelf-serve: a candidate whose link has expired requests a fresh one, emailed to the SAME address. Mounted at root via `public_link_router` (NOT under `/api`). Not available once the interview is completed.\n\n- **Auth:** public (none).\n- **Path params:** `token` (string) — the invite token.\n- **Query params:** none\n\nRequest: none (no body).\n\nResponse: text/html page (`_invite_error_html`) for every status — there is no JSON/redirect path.\n\nStatus codes / behavior:\n- 200 — success: minted a fresh invite for the same candidate and emailed it. Applicant-bound invites are re-provisioned via the shared `_provision_invite_for_applicant` (stage provisioning + engine-session sync + token binding); standalone invites use `create_invite` + `send_interview_invite_email`. HTML confirmation page (\"New link sent\" — \"We've emailed a fresh interview link to j***@domain…\").\n- 404 — unknown token (\"Link not found\" — \"We couldn't find that interview link.\").\n- 410 — `status == completed` (\"Already completed\" — \"This interview has already been completed, so a new link can't be issued.\").\n- 429 — \"Too many requests. Please slow down and try again shortly.\" (per-client-IP rate limit exceeded; JSON HTTPException detail, not an HTML page).\n- 500 — internal failure while re-issuing/emailing (\"Something went wrong\" — \"We couldn't send a new link right now. Please contact your recruiter.\").\n\nNotes: All HTML responses use `_invite_error_html`. **Rate-limited per client IP** (basic in-memory fixed window via `_rate_limit`): **5 requests / 300 s**; exceeding returns 429. Best-effort, per-process (resets on restart). The masked email in the confirmation copy comes from `_mask_email` (`jane@acme.com` → `j***@acme.com`).\n\n---\n\n### `backend/app/routers/privacy.py`\n\n> Candidate self-serve data-rights API (DPDP Act 2023). Public, unauthenticated intake → an emailed **one-time verification link** → fulfilment: **access/export** serves a downloadable ZIP of everything held (DPDP §11); **rectification** applies the candidate's own corrections (self-edit, incl. email); **erasure** anonymises-in-place across every store behind a **double confirm**. Plus a **grievance** channel (DPDP §13) and recruiter/admin visibility. **Identity = possession of the emailed inbox**, proven by a single-use, time-boxed token whose **hash only** is stored (`app/utils/privacy_tokens.py`: `generate_token`/`hash_token`/`verify_token`); tokens expire after `DSAR_TOKEN_TTL_HOURS` (default 48). **Every public route is rate-limited** (in-memory fixed window via `_rate_limit`, reused from `invites.py`; per-IP and per-email keys). **Every fulfilment is audited** to `compliance_audit_logs` (`app/utils/audit.py:record_audit`). Heavy lifting lives in the runtime-verified services `app/utils/data_rights.py` (rectify/erase) + `app/utils/data_export.py` (build/zip package) — the router is intake, verification, dispatch, and delivery only. Requests persist in the `data_subject_requests` table; the request due date = `now + DSAR_SLA_DAYS` (default 30). Config (`app/config.py`): `DSAR_SLA_DAYS`, `DSAR_TOKEN_TTL_HOURS`, `DPO_CONTACT_EMAIL` (default `privacy@interviehire.com`); links are built off `INVITE_LINK_BASE`. Mounted in `main.py` at prefix `/api/privacy`.\n\n#### POST /api/privacy/requests\n\nCreate a data-rights request and email a one-time verification link. Optionally pin the request to one Fiduciary (organisation) via an interview `invite_token`.\n\n- **Auth:** public (none). **Rate-limited:** 5/300 s per IP AND 3/3600 s per email (`dsar:email:`); exceeding → 429.\n- **Path params:** none\n- **Query params:** none\n\nRequest (Pydantic `CreateRequestIn`):\n```json\n{\n \"email\": \"string (required)\",\n \"request_type\": \"access_export | erasure | rectification (required)\",\n \"scope\": \"company | platform (optional, default 'company')\",\n \"organisation_id\": \"string, UUID (optional)\",\n \"invite_token\": \"string (optional)\",\n \"rectification\": \"object | null (optional) — { name?, phone?, email? }\"\n}\n```\n\nResponse:\n```json\n{\n \"request_id\": \"string (UUID)\",\n \"status\": \"string — DSARStatus (e.g. 'pending_verification')\",\n \"due_at\": \"datetime ISO8601\"\n}\n```\n\nStatus codes: 200 OK; 400 — \"A valid email is required\" (no `@`) / \"Invalid request_type or scope\" (not a valid `DSARType`/`DSARScope`); 429 Too Many Requests.\n\nNotes: Plain dict response. Email is stripped + lowercased. If `invite_token` resolves to an `InterviewInvite` → `Job` with an `organisation_id`, the request is pinned to that org and `scope` is forced to `company`. Mints a `DataSubjectRequest` (status `pending_verification`, `verification_token_hash`, `token_expires_at`, `due_at`, requester IP/UA, `payload={\"rectification\": …}` when present), emails the verify link (`/api/privacy/requests/verify?rid=&token=`, best-effort), and audits `dsar.request.created`.\n\n#### GET /api/privacy/requests/verify\n\nVerify the emailed token, then fulfil or advance the request. Returns a **human HTML page** (clicked from an email), not JSON.\n\n- **Auth:** public (none) — identity proven by the emailed token. **Rate-limited:** 30/60 s per IP.\n- **Path params:** none\n- **Query params:** `rid` (UUID, required), `token` (string, required)\n\nRequest: none\n\nResponse: `text/html` page (`HTMLResponse`). On success, transitions `pending_verification → verified` (audits `dsar.verified`) and then branches by `request_type`:\n- **access_export** — mints an export token (`export_token_hash` + `export_expires_at`) and renders a **\"Download my data (.zip)\"** link to `/api/privacy/exports/{rid}?token=…`.\n- **rectification** — applies the correction immediately (`data_rights.rectify_subject`), sets status `fulfilled`, clears the verification token, renders a \"Details updated\" page.\n- **erasure** — renders a **double-confirm form** that POSTs to `/api/privacy/requests/{rid}/confirm?token=…` (irreversible).\n\nStatus codes / behavior: 200 — verified/fulfilled/confirm-form page; 200 — \"Already processed\" when status is already `fulfilled`/`cancelled`/`expired`; 400 (HTML) — \"Link invalid or expired\" when `rid` is unknown or `verify_token` fails (bad/expired token).\n\n#### POST /api/privacy/requests/{rid}/confirm\n\nSecond confirmation for an **erasure** — actually runs the anonymise-in-place across every store. Returns HTML.\n\n- **Auth:** public (none) — gated by the emailed token. **Rate-limited:** 10/300 s per IP.\n- **Path params:** `rid` (UUID, required)\n- **Query params:** `token` (string, required)\n\nRequest: none (the verify page posts an empty form)\n\nResponse: `text/html` page.\n\nStatus codes / behavior: 200 — \"Your data has been erased\" (runs `data_rights.erase_subject`, clears the verification token, emails a completion confirmation best-effort); 200 — \"Already erased\" when status is already `fulfilled`; 404 (HTML) — \"Not found\" when `rid` is unknown or the request is **not an erasure**; 400 (HTML) — \"Link invalid or expired\" (bad/expired token) or \"Not verified\" (status not `verified`/`in_progress`); 500 (HTML) — \"We hit a snag\" when erasure fails (the request is left `in_progress` and is **resumable**; `erase_subject` records the failure).\n\n#### GET /api/privacy/requests/{rid}/status\n\nJSON status check for a request (token-gated).\n\n- **Auth:** public (none) — gated by the emailed verification token. **Rate-limited:** 30/60 s per IP.\n- **Path params:** `rid` (UUID, required)\n- **Query params:** `token` (string, required)\n\nRequest: none\n\nResponse:\n```json\n{\n \"request_id\": \"string (UUID)\",\n \"status\": \"string — DSARStatus\",\n \"request_type\": \"access_export | erasure | rectification\",\n \"scope\": \"company | platform\",\n \"due_at\": \"datetime ISO8601 | null\",\n \"created_at\": \"datetime ISO8601 | null\",\n \"fulfilled_at\": \"datetime ISO8601 | null\"\n}\n```\n\nStatus codes: 200 OK; 404 — \"Not found\" when `rid` is unknown or `verify_token` fails (bad/expired token).\n\n#### GET /api/privacy/exports/{rid}\n\nServe the subject's data package as a ZIP (export-token-gated, expiring). Marks the request `fulfilled` on first successful download and audits `dsar.export.fulfilled`.\n\n- **Auth:** public (none) — gated by the **export** token issued on verify. **Rate-limited:** 20/300 s per IP.\n- **Path params:** `rid` (UUID, required)\n- **Query params:** `token` (string, required)\n\nRequest: none\n\nResponse: `application/zip` — `200` with `Content-Disposition: attachment; filename=\"my-interviehire-data.zip\"` (raw bytes from `data_export.build_export_package` → `zip_export_package`).\n\nStatus codes / behavior: 200 — ZIP attachment; 404 — \"Not found\" (JSON) when `rid` is unknown or the request is **not an access_export**; 403 (HTML) — \"Link invalid or expired\" when the export token is invalid/expired (`verify_token` on `export_token_hash`/`export_expires_at`).\n\n#### POST /api/privacy/grievances\n\nFile a grievance (DPDP §13). Recorded to the audit log + emailed to the DPO.\n\n- **Auth:** public (none). **Rate-limited:** 5/3600 s per IP AND 3/3600 s per email (`grievance:email:`); exceeding → 429.\n- **Path params:** none\n- **Query params:** none\n\nRequest (Pydantic `GrievanceIn`):\n```json\n{\n \"email\": \"string (required)\",\n \"message\": \"string (required)\",\n \"request_id\": \"string, UUID (optional)\"\n}\n```\n\nResponse:\n```json\n{\n \"ok\": true,\n \"contact\": \"string — DPO_CONTACT_EMAIL\"\n}\n```\n\nStatus codes: 200 OK; 400 — \"Email and message are required\" (invalid/missing email, or blank message); 429 Too Many Requests.\n\nNotes: Plain dict response. Audits `dsar.grievance.filed` (the audit is the source of truth; the emails to the DPO and an acknowledgement to the subject are best-effort). `message` is truncated to 2000 chars in the audit/email.\n\n#### POST /api/privacy/internal/run-retention\n\nRun the retention/auto-purge batch (DPDP §8). Internal-only, shared-secret guarded; defaults to DRY-RUN. Trigger from a host cron or an external pinger.\n\n- **Auth:** internal only — header `x-internal-secret` must match the backend `INTERNAL_SERVICE_SECRET`; otherwise **401** `{\"detail\":\"unauthorized\"}` (also 401 when `INTERNAL_SERVICE_SECRET` is unset).\n- **Path params:** none\n- **Query params:** `dry_run` (string, default `\"true\"`) — only the literal `false` arms the batch; anything else stays a dry-run.\n\nRequest: none\n\nResponse (`enabled: true`):\n```json\n{\n \"enabled\": true,\n \"retention_days\": \"int\",\n \"dry_run\": \"boolean\",\n \"eligible\": \"int\",\n \"anonymised\": \"int\",\n \"errors\": \"int (optional)\",\n \"sample\": [\"string (optional)\"]\n}\n```\n\nResponse (disabled — `RETENTION_DAYS <= 0`):\n```json\n{\n \"enabled\": false,\n \"reason\": \"string\",\n \"eligible\": 0,\n \"anonymised\": 0\n}\n```\n\nStatus codes: 200 OK; 401 unauthorized.\n\nNotes: Runs one retention batch — anonymises (in place) applicants whose retention window (`RETENTION_DAYS`) has lapsed and who aren't mid-pipeline, reusing the erasure anonymise path; bounded by `RETENTION_MAX_PER_RUN` (default 500). No-op (returns the disabled shape) when `RETENTION_DAYS <= 0`. Delegates to `app.jobs.retention.run_retention`, also runnable as a CLI (`python -m app.jobs.retention`). New config (`app/config.py`): `RETENTION_DAYS` (default 0 = disabled), `RETENTION_MAX_PER_RUN` (default 500).\n\n#### GET /api/privacy/admin/requests\n\nList this organisation's data-rights requests (controller visibility), newest first (max 200).\n\n- **Auth:** JWT httpOnly cookie `token` (or Bearer) required via get_current_user; org context via get_active_org_id.\n- **Path params:** none\n- **Query params:** none\n\nRequest: none\n\nResponse: JSON array of:\n```json\n[\n {\n \"request_id\": \"string (UUID)\",\n \"subject_email\": \"string\",\n \"request_type\": \"access_export | erasure | rectification\",\n \"status\": \"string — DSARStatus\",\n \"scope\": \"company | platform\",\n \"due_at\": \"datetime ISO8601 | null\",\n \"created_at\": \"datetime ISO8601 | null\",\n \"fulfilled_at\": \"datetime ISO8601 | null\",\n \"overdue\": \"boolean\"\n }\n]\n```\n\nStatus codes: 200 OK; 401 Not authenticated.\n\nNotes: Filtered to `organisation_id == active_org_id`, ordered by `created_at` desc, limit 200. `overdue` = `due_at` is set AND status is not `fulfilled`/`cancelled`/`expired` AND `due_at < now`.\n\n#### GET /api/privacy/admin/requests/{rid}\n\nOne request + its audit trail (scoped to the caller's organisation).\n\n- **Auth:** JWT httpOnly cookie `token` (or Bearer) required via get_current_user; org context via get_active_org_id.\n- **Path params:** `rid` (UUID, required)\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{\n \"request\": {\n \"request_id\": \"string (UUID)\",\n \"subject_email\": \"string\",\n \"request_type\": \"access_export | erasure | rectification\",\n \"status\": \"string — DSARStatus\",\n \"scope\": \"company | platform\",\n \"due_at\": \"datetime ISO8601 | null\",\n \"created_at\": \"datetime ISO8601 | null\",\n \"fulfilled_at\": \"datetime ISO8601 | null\",\n \"payload\": \"object | null\"\n },\n \"audit_trail\": [\n {\n \"action\": \"string\",\n \"actor_type\": \"string — AuditActorType | null\",\n \"detail\": \"object | null\",\n \"created_at\": \"datetime ISO8601 | null\"\n }\n ]\n}\n```\n\nStatus codes: 200 OK; 401 Not authenticated; 404 — \"Not found\" when `rid` is unknown or the request's `organisation_id != active_org_id`.\n\nNotes: Plain dict response. The audit trail is the `compliance_audit_logs` rows for this subject's email (`subject_email`), ordered by `created_at` desc, limit 100.\n\n---\n\n## Interview Engine — Fastify\n\n### `interview-engine/apps/api/src/routes/assistant.routes.ts`\n\n#### POST /api/assistant/chat\n\nConversational IntervieHire help assistant. Takes a chat history (plus optional current-page/topic context), prepends a fixed system instruction, and proxies to DeepSeek for a plain-text reply.\n\n- **Auth:** public (no preHandler, no API-key check). Subject only to the global rate limit: 200 requests / 1 minute.\n- **Path params:** none\n- **Query params:** none\n\nRequest: JSON body, validated by zod\n```\n{\n messages: Array<{\n role: \"user\" | \"assistant\", // required, enum (only these two values)\n content: string // required, min length 1\n }>, // required, .min(1)\n page?: string, // optional\n topic?: string // optional\n}\n```\nThe zod schema permits only \"user\" and \"assistant\" roles; the server injects the system message before forwarding.\n\nResponse: 200 OK, application/json\n```\n{\n answer: string // DeepSeek's plain-text reply (choices[0].message.content trimmed; \"\" if nothing)\n}\n```\n\nStatus codes: 200 OK; 400 (Fastify default on thrown ZodError — invalid/missing body); 429 Too Many Requests (global rate limit exceeded); 500 Internal Server Error if DeepSeek not configured ('DeepSeek is not configured. Set DEEPSEEK_API_KEY to enable LLM calls.') or upstream call fails ('DeepSeek failed: ').\n\nNotes: Registered via `app.register(assistantRoutes, { prefix: '/api/assistant' })`; route `app.post('/chat', ...)`. Validation done manually with zod (no Fastify schema). System instruction is fixed lines plus `Current page: ${page}.` / `User topic: ${topic}.` when present; model returns plain text only. Forwarded array: [{role:'system', content: systemInstruction}, ...body.messages]. DeepSeek config: model DEEPSEEK_MODEL (default 'deepseek-v4-flash'), baseUrl DEEPSEEK_BASE_URL (default 'https://api.deepseek.com/chat/completions'). No deterministic fallback — errors with no key.\n\n### `interview-engine/apps/api/src/routes/company.routes.ts`\n\n#### GET /api/company/dashboard/{companyId}\n\nReturns the full company dashboard payload: the company record, all candidates (newest first), all job roles with their questions, and all interview sessions (newest first) with candidate, jobRole and proctoringLogs included.\n\n- **Auth:** none (globally rate-limited 200/min; CORS origin:true credentials:true).\n- **Path params:** `companyId`:string — Prisma Company.id\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```\n// 200 OK — no Fastify response schema; raw Prisma query result:\n{\n company: Company | null, // findUnique; null if not found\n candidates: Candidate[], // findMany, orderBy createdAt desc\n roles: JobRole[], // findMany, include: { questions: Question[] }\n sessions: InterviewSession[] // findMany, orderBy createdAt desc,\n // include: { candidate, jobRole, proctoringLogs }\n}\n// Each entry is the full Prisma model row (field names/types from prisma/schema.prisma).\n```\n\nStatus codes: 200 OK (always, even when company is null); 500 on Prisma/DB error.\n\nNotes: No validation on companyId. company is null (not 404) when id does not exist. No DTO mapping. Globally rate limited (429 if exceeded).\n\n#### POST /api/company/candidates\n\nUpserts a candidate for a company (by companyId+email), computes a deterministic ATS screening score against the target JobRole, persists the score/breakdown, and creates a new SCHEDULED interview session for the candidate against that role.\n\n- **Auth:** none (globally rate-limited 200/min).\n- **Path params:** none\n- **Query params:** none\n\nRequest: validated with zod (throws → 500)\n```\n{\n companyId: string, // required\n fullName: string, // required\n email: string, // required, valid email\n phone?: string, // optional\n parsedResume?: Record, // optional, default {}\n resumeText?: string, // optional\n jobRoleId: string // required (JobRole must exist → findUniqueOrThrow)\n}\n```\n\nResponse:\n```\n// 200 OK — no Fastify response schema; raw object:\n{\n candidate: Candidate, // upserted row: companyId, fullName, email, phone, parsedResume,\n // resumeText, atsScore (Float), atsBreakdown (Json)\n ats: {\n score: number, // 0..100, rounded to 1 decimal\n breakdown: {\n primary: number, // 0..1 normalized term match vs role.primaryCriteria\n secondary: number, // 0..1 vs role.secondaryCriteria\n education: number, // 0..1\n experience: number, // 0..1 (yearsOfExperience | experienceYears / 8)\n communication: number, // 0..1\n weights: { primary: number, secondary: number, education: number, experience: number, communication: number }\n }\n },\n session: InterviewSession // newly created: { companyId, candidateId, jobRoleId, status: 'SCHEDULED', ... }\n}\n```\n\nStatus codes: 200 OK; 500 on zod validation failure, jobRoleId not found (P2025), or any DB error; 429 if rate limited.\n\nNotes: Upsert key is the composite unique companyId_email. On existing candidate updates fullName/phone/parsedResume/resumeText/atsScore/atsBreakdown (not email). scoreCandidate is fully deterministic (no AI). Always creates a fresh SCHEDULED InterviewSession.\n\n#### POST /api/company/questions/generate\n\nGenerates interview questions for a job role using DeepSeek (falls back to a single deterministic question if the AI call fails), persists up to the first 10, and returns the created Question rows.\n\n- **Auth:** none (globally rate-limited 200/min).\n- **Path params:** none\n- **Query params:** none\n\nRequest: validated with zod (throws → 500)\n```\n{\n companyId: string, // required\n jobRoleId: string, // required\n jobDescription: string, // required\n roleType: string, // required (roleApplicability enum value & competency map key)\n companyName: string, // required\n jobTitle?: string // optional\n}\n```\n\nResponse:\n```\n// 200 OK — no Fastify response schema:\n{\n questions: Question[] // up to 10 created Question rows. Each created with:\n // {\n // companyId, jobRoleId, text,\n // difficulty: 'EASY' | 'MEDIUM' | 'HARD' (default 'MEDIUM'),\n // topicCategories: string[],\n // roleApplicability: [roleType],\n // aiEvaluationGuidance: string // JSON string: { questionType, modelAnswer,\n // rubric: { requiredPoints:[{id,description,keywords[],weight}], secondaryPoints:[...],\n // excellentAnswerSignals:[...], redFlags:[{id,description,severity:'low'|'medium'|'high'|'critical'}] } }\n // // plus model defaults (id, createdAt, etc.)\n // }\n}\n```\n\nStatus codes: 200 OK; 500 on zod validation failure or DB/transaction error; 429 if rate limited.\n\nNotes: generateQuestions calls DeepSeek; on any error returns ONE deterministic fallback question (so this route can succeed with a single question even without an API key). Only the first 10 generated questions persisted (slice(0,10)) inside a prisma.$transaction. aiEvaluationGuidance stored as a stringified JSON blob.\n\n#### PUT /api/company/questions/{id}\n\nUpdates a single Question by id with the raw request body and returns the updated row.\n\n- **Auth:** none (globally rate-limited 200/min).\n- **Path params:** `id`:string — Prisma Question.id to update\n- **Query params:** none\n\nRequest: NO zod validation — req.body passed directly as Prisma update `data`.\n```\n{\n text?: string,\n difficulty?: 'EASY' | 'MEDIUM' | 'HARD',\n topicCategories?: string[],\n roleApplicability?: string[],\n aiEvaluationGuidance?: string,\n // ...any other writable Question column (passed through unfiltered)\n}\n```\n\nResponse:\n```\n// 200 OK — updated Question row verbatim:\nQuestion // { id, companyId, jobRoleId, text, difficulty, topicCategories[], roleApplicability[], aiEvaluationGuidance, createdAt, ... }\n```\n\nStatus codes: 200 OK; 500 if id not found (P2025) or body contains an invalid/unknown column; 429 if rate limited.\n\nNotes: Unsafe/unvalidated — entire body forwarded to Prisma update with no allowlist. No auth, so any caller who knows a question id can mutate it. Throws (500) rather than 404 when id does not exist.\n\n### `interview-engine/apps/api/src/routes/interview.routes.ts`\n\n#### GET /api/interview/demo-session\n\nIdempotently self-seeds a demo company (slug 'demo-junior-sde'), a 'Junior Software Development Engineer' job role, 4 seeded junior SDE questions, a demo candidate (Aarav Sharma), and a SCHEDULED interview session; returns IDs for bootstrapping a zero-config demo interview.\n\n- **Auth:** Public (no preHandler, no api key). Global rate limit (200/min).\n- **Path params:** none\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```\n200 OK (application/json)\n{\n sessionId: string, // InterviewSession.id (cuid)\n companyId: string, // Company.id\n roleId: string, // JobRole.id\n candidateId: string // Candidate.id\n}\n```\n\nStatus codes: 200 OK; 429 rate limited; 500 on Prisma/upsert failure.\n\nNotes: No Fastify schema. Upserts company by slug; finds-or-creates role/questions/candidate/session. Side-effecting GET (writes to DB).\n\n#### GET /api/interview/sessions/:id\n\nFetches a single interview session with related company, candidate, jobRole (incl. its questions), and proctoringLogs.\n\n- **Auth:** Public. Global rate limit. Per-candidate invite enforcement when the session is token-bound (see Query params).\n- **Path params:** `id`:string — InterviewSession.id (cuid)\n- **Query params:** `token`:string (optional) — the invite token. If the session has a non-null `inviteToken` and `token` does not match → 403. Token-free sessions ignore this param.\n\nRequest: none\n\nResponse:\n```\n200 OK (application/json)\nResult of prisma.interviewSession.findUnique({ where:{id}, include:{ company, candidate, jobRole:{ include:{ questions } }, proctoringLogs } })\n\nInterviewSession {\n id, companyId, candidateId, jobRoleId,\n status: 'SCHEDULED'|'IN_PROGRESS'|'COMPLETED'|...,\n scheduledAt: string|null, startedAt: string|null, completedAt: string|null,\n transcript: Json|null, // array of {speaker:'ai'|'candidate', text, timestamp, questionIndex, ...}\n reportUrl: string|null,\n createdAt: string, updatedAt: string,\n company: Company { id, name, slug, description, reportEmail, primaryColor, ... },\n candidate: Candidate { id, companyId, fullName, email, parsedResume:Json, atsScore, atsBreakdown:Json, ... },\n jobRole: JobRole { id, companyId, title, roleType, description, requirements, primaryCriteria:string[], secondaryCriteria:string[], atsScoringWeights:Json, evaluationCriteria:Json, questions: Question[] },\n proctoringLogs: ProctoringLog[]\n}\n// null if not found\n```\n\nStatus codes: 200 OK (body may be null if session not found); 403 `{ \"error\": \"This interview link is invalid or has expired.\", \"code\": \"INVALID_TOKEN\" }` when the session is token-bound and the `token` query param doesn't match; 429 rate limited; 500 on DB error.\n\nNotes: No Fastify schema; field set governed by the Prisma schema. Returns null (not 404) for unknown id. The token guard runs after the findUnique: only sessions with a non-null `inviteToken` enforce it, so legacy/scheduled/demo (token-free) sessions are unaffected.\n\n#### POST /api/interview/sessions/:id/start\n\nMarks the session IN_PROGRESS, sets startedAt (if unset), seeds the first AI question into the transcript if no AI turn exists yet, opens the transcript metadata row (status: recording), and returns the updated session plus the initial question text.\n\n- **Auth:** Public. Global rate limit. Per-candidate invite enforcement when the session is token-bound (see Query params).\n- **Path params:** `id`:string — InterviewSession.id\n- **Query params:** `token`:string (optional) — the invite token. If the session has a non-null `inviteToken` and `token` does not match → 403. Token-free sessions ignore this param.\n\nRequest: none (body ignored)\n\nResponse:\n```\n200 OK (application/json)\n{\n session: InterviewSession, // full updated row (status='IN_PROGRESS', startedAt set, transcript updated)\n initialQuestion: string // first active question text, or fallback 'Tell me about your software engineering background.'\n}\n```\n\nStatus codes: 200 OK; 403 `{ \"error\": \"This interview link is invalid or has expired.\", \"code\": \"INVALID_TOKEN\" }` when the session is token-bound and the `token` query param doesn't match; 403 recruiter-settings gates — `INTERVIEW_DISABLED` (settings.interviewEnabled === false), `NO_REATTEMPT` (settings.allowReattempt === false and status COMPLETED/EVALUATED), `LATE_ATTEMPT` (settings.allowLate === false and now > scheduledAt + 5min grace), `ACCESS_SCHEDULED_ONLY` / `ACCESS_INVITED_ONLY` (settings.accessControl), and `TOO_EARLY` `{ \"error\": \"This interview has not opened yet. Please return at your scheduled time.\", \"code\": \"TOO_EARLY\" }` (scheduledAt is set and now < scheduledAt − 10min early-entry window); 400 `CV_REQUIRED` (settings.requireCv === true and no candidate resume); 404/500 — uses findUniqueOrThrow, so unknown id throws (P2025) surfaced as 500-class; 429 rate limited.\n\nNotes: No Fastify schema. The token guard runs after the findUniqueOrThrow: only sessions with a non-null `inviteToken` enforce it, so token-free sessions are unaffected and the existing settings checks still apply. **Scheduled-slot barrier:** the `TOO_EARLY` guard is UNCONDITIONAL on any session that has a `scheduledAt` (not gated behind a setting) and rejects a start until the early-entry window opens (`scheduledAt − 10min`); `EARLY_ENTRY_MS` here MUST stay in sync with the candidate room's `EARLY_ENTRY_MS` (interviewcandidateroom/page.tsx), which renders a countdown \"waiting room\" lobby until the same instant. Sessions with no `scheduledAt` (plain link / demo) are unaffected. firstQuestion = first isActive question (orderBy createdAt asc) of the jobRole, else fallback. Calls ensureTranscriptMeta(id).\n\n#### GET /api/interview/sessions/:id/vapi-config\n\nBuilds and returns a Vapi voice-assistant configuration object derived from the session's company, job role, questions, and evaluation criteria.\n\n- **Auth:** Public. Global rate limit.\n- **Path params:** `id`:string — InterviewSession.id\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```\n200 OK (application/json)\nVapi assistant config object produced by buildVapiAssistantConfig({\n companyName, companyDescription?, jobRole (title),\n roleRequirements, questions: string[], evaluationCriteria\n})\n// shape defined by services/vapi-config.service.ts (name, model, voice, transcriber, firstMessage, systemPrompt/messages, etc.)\n```\n\nStatus codes: 200 OK; 404/500 — findUniqueOrThrow throws on unknown id; 429 rate limited.\n\nNotes: No Fastify schema. Exact shape is whatever buildVapiAssistantConfig returns (see vapi-config.service.ts).\n\n#### POST /api/interview/sessions/:id/complete\n\nMarks the session COMPLETED, sets completedAt, and best-effort finalizes the transcript (.txt). A finalize failure is logged but does not fail the request.\n\n- **Auth:** Public. Global rate limit. **Per-candidate invite token enforced** (see Notes).\n- **Path params:** `id`:string — InterviewSession.id\n- **Query params:** `token`?:string — optional invite token; required only when the session is token-bound (`inviteToken` set).\n\nRequest: none (body ignored)\n\nResponse:\n```\n200 OK (application/json)\n{\n session: InterviewSession, // updated row (status='COMPLETED', completedAt set)\n transcript: | null // null if finalize failed (logged, non-fatal)\n}\n```\n\nStatus codes: 200 OK; **403 `{ error:'This interview link is invalid or has expired.', code:'INVALID_TOKEN' }`** — session has a non-null `inviteToken` and `?token=` doesn't match (via `blockedByInviteToken`); 404/500 — prisma.update throws on unknown id (P2025); 429 rate limited.\n\nNotes: No Fastify schema. **`blockedByInviteToken(req, reply)` runs first** — a token-bound session only serves the matching `?token=`; token-free sessions pass through unaffected. finalizeTranscript(id) wrapped in .catch → returns null on error. Transcript .txt can also be rebuilt via POST /api/interviews/:id/transcript/finalize.\n\n#### POST /api/interview/sessions/:id/answers\n\nSubmits a candidate's text answer; the conversation director appends the answer to the transcript, decides whether to ask a follow-up / next question / closing line, persists the transcript, auto-captures both turns into the event log, and returns the AI's next utterance.\n\n- **Auth:** Public. Global rate limit. **Per-candidate invite token enforced** (see Notes).\n- **Path params:** `id`:string — InterviewSession.id\n- **Query params:** `token`?:string — optional invite token; required only when the session is token-bound (`inviteToken` set).\n\nRequest: application/json\n```\n{\n text: string, // required, trimmed; empty/missing → 400\n metrics?: Record // optional, default {} (stored on the candidate transcript turn)\n}\n```\n\nResponse:\n```\n200 OK (application/json)\n{\n answer: { text: string }, // echo of the submitted (trimmed) answer text\n ai: { // from handleCandidateTranscript()\n text: string, // AI's next utterance\n interviewPhase: 'questioning' | 'follow_up' | 'closing',\n emotionState: 'curious' | 'encouraging'\n }\n}\n```\n\nStatus codes: **403 `{ error:'This interview link is invalid or has expired.', code:'INVALID_TOKEN' }`** — session has a non-null `inviteToken` and `?token=` doesn't match (via `blockedByInviteToken`, checked before body validation); 400 'Answer text is required' (empty/missing text); 200 OK; 404/500 — handleCandidateTranscript throws 'Interview session not found' for unknown id (surfaced as 500); 429 rate limited.\n\nNotes: No Fastify schema. **`blockedByInviteToken(req, reply)` runs first** — a token-bound session only serves the matching `?token=`; token-free sessions pass through unaffected. Determines active question index from last AI transcript entry; enforces MAX_FOLLOWUPS_PER_QUESTION; updates session.status to IN_PROGRESS. Best-effort recordEventSafe for both turns.\n\n#### POST /api/interview/sessions/:id/transcript-text\n\nIngests a pasted/structured interview transcript (Convai memory paste, {turns:[]}, {interaction:[]}, or raw 'Speaker: text' lines), normalizes it into the evaluator's {speaker,text,questionIndex,timestamp} shape, and overwrites session.transcript.\n\n- **Auth:** Public. Global rate limit.\n- **Path params:** `id`:string — InterviewSession.id\n- **Query params:** none\n\nRequest: application/json (any one of these forms; parsed leniently)\n```\n{\n turns?: Array<{ speaker?: string, role?: string, text?: string, message?: string, content?: string }>,\n interaction?: Array<{ speaker?: string, role?: string, text?: string, message?: string, content?: string }>, // Convai chatHistory shape\n text?: string // raw transcript with 'Speaker: line' prefixes\n}\n// body may also be a raw JSON string (typeof body === 'string')\n```\n\nResponse:\n```\n200 OK (application/json)\n{\n ok: true,\n turns: number, // count of normalized turns\n transcript: Array<{\n speaker: 'ai' | 'candidate',\n text: string,\n questionIndex: number, // >= 0; increments on each ai turn\n timestamp: string (ISO)\n }>\n}\n```\n\nStatus codes: 404 'Session not found'; 400 'Could not parse any interview turns. Paste the conversation with \"Speaker: text\" lines, or send a {turns:[{speaker,text}]} array.' (zero parsed turns); 200 OK; 429 rate limited.\n\nNotes: No Fastify schema. Speaker classified by regex: aiRe=/character|interviewer|assistant|^ai\\b|\\bai\\b|lina|bot/i, candidateRe=/user|candidate|\\byou\\b|\\bme\\b|human|applicant/i; unknown → 'candidate'. Overwrites session.transcript entirely.\n\n#### POST /api/interview/sessions/:id/evaluate\n\nRuns the Aviral rubric-grounded evaluation over the session's transcript and returns the canonical CandidateReport.\n\n- **Auth:** Public. Global rate limit. **Per-candidate invite token enforced** (see Notes).\n- **Path params:** `id`:string — InterviewSession.id\n- **Query params:** `token`?:string — optional invite token; required only when the session is token-bound (`inviteToken` set).\n\nRequest: none (body ignored)\n\nResponse:\n```\n200 OK (application/json)\n{\n evaluation: EvalCandidateReport // canonical CandidateReport from evaluateInterview()\n}\n// EvalCandidateReport (aviral-eval): { overallScore, recommendation, perDimension skill scores, perQuestion breakdown, redFlags, ... } — see aviral-eval/types.ts / @interviehire/shared CandidateReport\n```\n\nStatus codes: 200 OK; **403 `{ error:'This interview link is invalid or has expired.', code:'INVALID_TOKEN' }`** — session has a non-null `inviteToken` and `?token=` doesn't match (via `blockedByInviteToken`); 500 on evaluation error (session not found, missing transcript, evaluator failure); 429 rate limited.\n\nNotes: No Fastify schema. **`blockedByInviteToken(req, reply)` runs first** — a token-bound session only serves the matching `?token=`; token-free sessions pass through unaffected. Response wraps the service return in { evaluation }. Exact fields defined by the dashboard contract and aviral-eval/types.ts. **Exit interviews — verbatim, NOT scored (2026-07-05 pivot):** when the session's `settings.interviewType === \"exit_interview\"` (or `settings.jobType === \"exit\"`), `evaluateInterview` **early-returns `buildExitTranscriptReport(id)`** (`services/exit-report.service.ts`) instead of running the hire evaluator — which previously scored even with no DeepSeek key. In that case `evaluation` is the VERBATIM, NO-SCORE exit report (`interviewType: \"exit_interview\"`, `scored: false`, `exchanges`/`themes`, always-empty `questionBreakdown`; no `overallScore`/`recommendation`/`skillScores`/sentiment) — see the exit-variant block under **GET /api/jobs/applicants/{applicant_id}/functional-report**. The backend `sync_applicant_to_ai` writes both settings keys whenever the parent Job has `job_kind == \"exit\"`.\n\n#### GET /api/interview/sessions/:id/candidate-report\n\nReturns the candidate-facing (redacted/summarized) version of the evaluation report for the session.\n\n- **Auth:** Public. Global rate limit.\n- **Path params:** `id`:string — InterviewSession.id\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```\n200 OK (application/json)\n{\n report: EvalCandidateFacingReport // candidate-facing report from getCandidateFacingReport()\n}\n// shape defined by aviral-eval / evaluation.service.ts (candidate-safe summary subset)\n```\n\nStatus codes: 200 OK; 500 on error; 429 rate limited.\n\nNotes: No Fastify schema. Exact fields per EvalCandidateFacingReport type in evaluation.service.ts / aviral-eval/types.ts.\n\n#### POST /api/interview/sessions/:id/report\n\nGenerates a PDF evaluation report for the session and returns the server filesystem path to the generated file.\n\n- **Auth:** Public. Global rate limit.\n- **Path params:** `id`:string — InterviewSession.id\n- **Query params:** none\n\nRequest: none (body ignored)\n\nResponse:\n```\n200 OK (application/json)\n{\n filePath: string // server-side path to the generated PDF (from generatePdfReport())\n}\n```\n\nStatus codes: 200 OK; 500 on generation error; 429 rate limited.\n\nNotes: No Fastify schema. Returns a server path, not the binary; serve via GET /api/interview/uploads/:file if stored under uploads/.\n\n#### POST /api/interview/sessions/:id/email-report\n\nEmails the interview PDF report (existing reportUrl or freshly generated) as an attachment via SMTP/nodemailer to the company report email (fallback body.to).\n\n- **Auth:** Public. Relies on SMTP_* env. Global rate limit.\n- **Path params:** `id`:string — InterviewSession.id\n- **Query params:** none\n\nRequest: application/json\n```\n{\n to?: string // optional recipient; used only when session.company.reportEmail is falsy\n}\n```\n\nResponse:\n```\n200 OK (application/json)\n{\n sent: true\n}\n```\n\nStatus codes: 200 OK; 500 'Session not found' (thrown) for unknown id; 500 on PDF generation or nodemailer/SMTP send failure; 429 rate limited.\n\nNotes: No Fastify schema. Recipient = session.company.reportEmail || body.to. Uses nodemailer with SMTP_HOST/SMTP_PORT(587)/SMTP_USER/SMTP_PASS, from REPORT_FROM. Attaches '-report.pdf'.\n\n#### POST /api/interview/sessions/:id/transcript\n\nStores or upserts a browser speech-to-text transcript entry (one logical transcriptId) into session.transcript JSON, normalizing each segment to a candidate speech_to_text turn.\n\n- **Auth:** Public. Global rate limit.\n- **Path params:** `id`:string — InterviewSession.id\n- **Query params:** none\n\nRequest: application/json\n```\n{\n transcriptId?: string, // optional; trimmed; default 'browser-speech-recognition' (key for upsert)\n transcript?: Array<{ // segments; non-array → []\n text?: string, // trimmed; empty segments dropped\n timestamp?: string // ISO; default now()\n }>,\n fullText?: string, // optional; default = segments' text joined by '\\n'\n finalized?: boolean, // stored as (finalized === true)\n createdAt?: string // ISO; default now()\n}\n```\n\nResponse:\n```\n200 OK (application/json)\n{\n stored: true,\n entry: {\n type: 'speech_to_text_transcript',\n source: 'speech_to_text',\n text: string, // fullText\n segments: Array<{ speaker:'candidate', text:string, timestamp:string, source:'speech_to_text' }>,\n sessionId: string,\n candidateId: string,\n transcriptId: string,\n finalized: boolean,\n createdAt: string (ISO), // preserved on update\n updatedAt: string (ISO)\n }\n}\n```\n\nStatus codes: 404 'Session not found'; 400 'Transcript must contain at least one text segment' (no non-empty segments); 200 OK; 429 rate limited.\n\nNotes: No Fastify schema. Upsert keyed by (type='speech_to_text_transcript' && transcriptId): existing entry merged (createdAt preserved), else appended. Does not replace other transcript entries.\n\n#### POST /api/interview/sessions/:id/recording\n\nAccepts a multipart audio/video recording upload, saves it under uploads/, attaches a recording metadata entry to session.transcript, and kicks off async transcription + question-fit processing.\n\n- **Auth:** Public. Requires @fastify/multipart. Global rate limit.\n- **Path params:** `id`:string — InterviewSession.id\n- **Query params:** none\n\nRequest: `multipart/form-data`\n```\nfile: // single file part (req.file()); part.filename optional, default 'recording.webm'. Missing → 400.\n```\n\nResponse:\n```\n200 OK (application/json)\n// session found:\n{\n url: string, // '/uploads/-'\n entry: { type:'recording', filename:string, url:string, createdAt: Date }\n}\n// session NOT found (still 200):\n{\n url: string,\n entry: { type:'recording', filename:string, url:string, createdAt: Date },\n note: 'session not found; recording stored but not linked'\n}\n```\n\nStatus codes: 400 'No file uploaded'; 200 OK (both session-found and not-found cases); 429 rate limited.\n\nNotes: No Fastify schema. Saves to /uploads/-. Appends recording entry to transcript; fires processRecordingForSession(id, filename) async (errors logged). Returns 200 even when session is absent.\n\n#### POST /api/interview/sessions/:id/answer-transcription\n\nAccepts a multipart audio answer upload, saves it, transcribes it synchronously, and returns the transcribed text (does not attach to the session).\n\n- **Auth:** Public. Requires @fastify/multipart. Global rate limit.\n- **Path params:** `id`:string — InterviewSession.id (present in path but not used in handler body)\n- **Query params:** none\n\nRequest: `multipart/form-data`\n```\nfile: // single file part (req.file()); part.filename optional, default 'answer.webm'. Missing → 400.\n```\n\nResponse:\n```\n200 OK (application/json)\n{\n text: string, // transcription from transcribeUploadedFile()\n filename: string // '-'\n}\n```\n\nStatus codes: 400 'No file uploaded'; 502 '' or 'Answer transcription failed' on transcription error; 200 OK; 429 rate limited.\n\nNotes: No Fastify schema. Saves to /uploads/. Synchronous transcription; failures → reply.code(502). Does not modify session.transcript.\n\n#### GET /api/interview/uploads/:file\n\nStreams a previously uploaded file (recording/answer/PDF) from the server uploads/ directory.\n\n- **Auth:** Public. Global rate limit.\n- **Path params:** `file`:string — filename within /uploads/\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```\n200 OK (binary stream)\n\n\n404 Not Found (application/json)\n{ error: 'Not found' }\n```\n\nStatus codes: 200 OK (file stream); 404 'Not found' (file missing); 429 rate limited.\n\nNotes: No Fastify schema. Path = path.join(process.cwd(),'uploads',file). No explicit Content-Type set; no path-traversal sanitization on :file.\n\n#### POST /api/interview/consent\n\nRecords one candidate consent decision (grant or decline) for a session into the append-only `ConsentLog` audit table. Backs the candidate room's informed-consent gate (18+, recording+AI, biometric, privacy policy, cookies) — each grant/decline is persisted server-side.\n\n- **Auth:** Public (candidate is unauthenticated; no preHandler, no api key). Subject to the global @fastify/rate-limit (max 200/min).\n- **Path params:** none\n- **Query params:** none\n\nRequest: application/json\n```\n{\n sessionId: string, // required; empty/missing → 400\n consentVersion: string, // required; empty/missing → 400\n action?: string, // optional, default 'granted' — one of 'granted' | 'declined'; any value other than exactly 'declined' is stored as 'granted'\n scopes?: object, // optional, default {} — expected boolean keys: age18Plus, dataProcessing, biometric, privacyPolicy, cookies\n candidateEmail?: string, // optional\n candidateName?: string, // optional\n inviteToken?: string, // optional\n userAgent?: string, // optional — falls back to the request's User-Agent header when omitted\n locale?: string // optional\n}\n// NOTE: ipAddress is NOT accepted from the body — it is captured server-side from the X-Forwarded-For header (first hop) or req.ip.\n```\n\nResponse:\n```\n200 OK (application/json)\n{\n ok: true,\n id: string, // ConsentLog.id (cuid)\n createdAt: string // ISO 8601 datetime\n}\n```\n\nStatus codes: 200 OK; 400 `{ \"error\": \"sessionId and consentVersion are required\" }` when `sessionId` or `consentVersion` is missing/empty; 429 rate limited; 500 on Prisma/DB error.\n\nNotes: No Fastify schema. Creates one `ConsentLog` row (no foreign key to InterviewSession by design, so the log survives session deletion). `action` is normalized to 'granted' unless it is exactly 'declined'. `scopes` defaults to `{}`. `ipAddress` is server-captured (first X-Forwarded-For hop, else `req.ip`) and is never taken from the request body.\n\n#### GET /api/interview/consent/:sessionId\n\nReturns the consent audit trail for a session — the most-recent `ConsentLog` records (newest first, capped at 20).\n\n- **Auth:** Public. Global rate limit (max 200/min).\n- **Path params:** `sessionId`:string — the interview session id\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```\n200 OK (application/json)\n{\n sessionId: string,\n count: number, // number of records returned (≤ 20)\n records: ConsentLog[] // ordered newest-first, max 20\n}\n\nConsentLog {\n id: string, // cuid\n sessionId: string,\n action: string, // 'granted' | 'declined'\n consentVersion: string,\n scopes: object, // e.g. { age18Plus, dataProcessing, biometric, privacyPolicy, cookies }\n candidateEmail: string | null,\n candidateName: string | null,\n inviteToken: string | null,\n userAgent: string | null,\n ipAddress: string | null,\n locale: string | null,\n createdAt: string // ISO 8601 datetime\n}\n```\n\nStatus codes: 200 OK (`records` may be an empty array if no consent logged for the session); 429 rate limited; 500 on Prisma/DB error.\n\nNotes: No Fastify schema. `records` ordered by createdAt desc, take 20. `count` = records.length.\n\n### `interview-engine/apps/api/src/routes/transcript.routes.ts`\n\n#### POST /api/interviews/{sessionId}/transcript/event\n\nIngest one or many live transcript events into the append-only event log. Best-effort: invalid/malformed events are skipped (never 500); returns stored/skipped counts so the client can detect and retry a fully-rejected batch.\n\n- **Auth:** public (no preHandler, no API key, no auth check).\n- **Path params:** `sessionId`:string — the interview session id (cuid)\n- **Query params:** none\n\nRequest: No Fastify schema; body read untyped (req.body ?? {}). Accepts THREE shapes:\n```\n// 1) Single event object:\n{\n speaker: 'candidate' | 'interviewer' // required; any other value -> event skipped\n text: string // required; trimmed/collapsed, empty -> skipped\n timestampMs?: number // optional; finite & >=0 used as-is (rounded); else derived from startedAt or 0\n source?: 'convai' | 'browser_stt' | 'whisper' | 'manual' // optional; invalid -> 'manual'\n isFinal?: boolean // optional; default true\n createdAt?: string (ISO-8601) // optional; default now()\n}\n\n// 2) Batch wrapper:\n{ events: Array } // required non-empty after extraction\n\n// 3) Bare JSON array:\n[ event-object-as-above, ... ]\n```\nIf the resolved event list is empty → 400. Per-event validation in recordEvents/normalizeEventInput; events failing speaker/text validation counted as skipped, not rejected.\n\nResponse: 200 OK (application/json)\n```\n{\n ok: true,\n stored: number, // count of events actually persisted\n skipped: number // rawEvents.length - stored\n}\n```\n\nStatus codes: 200 OK (may have stored:0 if all skipped); 400 Bad Request {\"error\":\"No transcript events provided\"} (empty event list); 404 Not Found {\"error\":\"Interview session not found\"} (unknown session id); 500 Internal Server Error {\"error\":\"Failed to store transcript event\"} (unexpected error).\n\nNotes: Global rate limit (200/min). Calls recordEvents(sessionId, rawEvents) which upserts the InterviewTranscript metadata row (status 'recording'), normalizes each event, bulk-inserts via createMany, increments eventCount. Only the literal message 'Interview session not found' maps to 404.\n\n#### POST /api/interviews/{sessionId}/transcript/audio\n\nUpload an audio recording (one speaker's audio, whole interview or a chunk) and transcribe it server-side (Whisper/Deepgram ASR) into timestamped transcript events. This is how the Convai avatar's voice becomes interviewer transcript lines.\n\n- **Auth:** public. Server-side transcription requires DEEPGRAM_API_KEY or OPENAI_API_KEY; without it returns 503.\n- **Path params:** `sessionId`:string — the interview session id\n- **Query params:** none\n\nRequest: `multipart/form-data` (via @fastify/multipart req.file()). No Fastify schema.\n```\nfile (binary) required — audio file part (saved to /uploads/--); mimetype default 'audio/webm', filename default 'audio.webm'\nspeaker (text) optional — 'candidate' | 'interviewer'; ONLY 'candidate' selects candidate, any other/absent → 'interviewer'\nstartMs (text) optional — number; offset in ms applied to transcribed segment timestamps; non-numeric/absent → 0\n```\n\nResponse: 200 OK (application/json), two success variants:\n```\n// speech transcribed:\n{ ok: true, segments: number, stored: number, skipped: number }\n// ASR returned no segments:\n{ ok: true, stored: 0, note: 'No speech detected in the audio.' }\n```\n(stored/skipped from recordEvents; events recorded with source:'whisper', isFinal:true, timestampMs = segment.startMs)\n\nStatus codes: 200 OK; 400 Bad Request {\"error\":\"No audio file uploaded\"} (no file part); 404 Not Found {\"error\":\"Session not found\"} (unknown sessionId); 502 Bad Gateway {\"error\":, \"stored\":0} (transcription threw); 503 Service Unavailable {\"error\":\"Server-side transcription is not configured. Set DEEPGRAM_API_KEY (or OPENAI_API_KEY) to transcribe interviewer audio.\", \"stored\":0} (asrAvailable()===false).\n\nNotes: Global rate limit + @fastify/multipart default limits. Writes file synchronously to /uploads (created if missing) before transcribing. transcribeAudioSegments(dest, startMs, mimeType) returns segments {text, startMs}; each becomes a transcript event for the resolved speaker.\n\n#### GET /api/interviews/{sessionId}/transcript\n\nRead the current transcript: ordered raw events plus the InterviewTranscript metadata row. With ?text=1 (or text=true) it also returns the rendered .txt body, finalizing on demand if no file exists yet.\n\n- **Auth:** public.\n- **Path params:** `sessionId`:string — the interview session id\n- **Query params:** `text`:string (optional) — when '1' or 'true', include the rendered .txt body under `text`; otherwise omitted.\n\nRequest: none\n\nResponse: 200 OK (application/json)\n```\n{\n sessionId: string,\n events: Array<{\n sessionId: string,\n speaker: 'candidate' | 'interviewer',\n text: string,\n timestampMs: number,\n source: 'convai' | 'browser_stt' | 'whisper' | 'manual',\n isFinal: boolean,\n createdAt: string // ISO-8601\n }>, // ordered by timestampMs asc, then createdAt asc\n meta: { // InterviewTranscript row, or null if none yet\n id: string,\n sessionId: string,\n candidateId: string | null,\n interviewId: string | null,\n transcriptFilePath: string | null,\n startedAt: string | null, // ISO-8601\n endedAt: string | null, // ISO-8601\n status: string, // 'recording' | 'finalized' | 'empty' | 'failed'\n eventCount: number,\n createdAt: string, // ISO-8601\n updatedAt: string // ISO-8601\n } | null,\n text?: string // present only when ?text=1/true AND a body could be produced\n}\n```\n\nStatus codes: 200 OK; 404 Not Found {\"error\":\"Session not found\"} (unknown sessionId).\n\nNotes: Global rate limit. When text requested, reads meta.transcriptFilePath if on disk, else conventional transcriptFilePath(sessionId), else finalizeTranscript(sessionId) then reads. `text` omitted if none yield a file.\n\n#### POST /api/interviews/{sessionId}/transcript/finalize\n\nFinalize the transcript: gather events (event log, falling back to legacy session.transcript JSON only when no events exist), shape into clean ordered lines, write the .txt file, project turns into session.transcript, and update the InterviewTranscript metadata row.\n\n- **Auth:** public.\n- **Path params:** `sessionId`:string — the interview session id\n- **Query params:** none\n\nRequest: none\n\nResponse: 200 OK (application/json) — FinalizeResult spread alongside ok:true\n```\n{\n ok: true,\n status: 'finalized' | 'empty', // 'finalized' if lines produced, else 'empty'\n filePath: string | null, // absolute path to the written .txt\n lineCount: number, // number of rendered transcript lines\n eventCount: number // total events considered (event log + legacy)\n}\n```\n\nStatus codes: 200 OK (status 'finalized' or 'empty'); 404 Not Found {\"error\":\"Session not found\"} (unknown sessionId at the route guard); 500 Internal Server Error {\"error\":\"Transcript finalization failed\", status:'failed', filePath:null, lineCount:0, eventCount:0} (finalizeTranscript returned status 'failed').\n\nNotes: Global rate limit. finalizeTranscript writes a placeholder .txt even for an empty transcript. When lines exist it also overwrites session.transcript with projected turns {speaker:'ai'|'candidate', text, questionIndex, timestamp, source:'stt'} for the evaluator.\n\n#### POST /api/interviews/{sessionId}/report\n\nFinalize the transcript, then generate a CandidateReport by sending the WHOLE transcript to the LLM (suits Convai's dynamic questions). Falls back to the deterministic evaluator when no LLM key or on error so a report always returns.\n\n- **Auth:** public. LLM path requires DEEPSEEK_API_KEY (and != 'replace-me'); otherwise falls through to the deterministic evaluator.\n- **Path params:** `sessionId`:string — the interview session id\n- **Query params:** none\n\nRequest: none\n\nResponse: 200 OK (application/json). `evaluation` is the normalized CandidateReport; `engine` indicates which path produced it.\n```\n{\n engine: 'transcript_llm' | 'deterministic' | 'exit_verbatim', // 'exit_verbatim' = exit session, no-score verbatim report (see Notes)\n evaluation: {\n interviewId: string, // = sessionId\n candidateId: string,\n roleTitle: string,\n interviewType: 'mixed',\n overallScore: number, // 0-100\n recommendation: 'strong_proceed' | 'proceed' | 'hold' | 'reject' | 'needs_human_review',\n recommendationConfidence: 'high' | 'medium' | 'low',\n candidateConfidence: {\n score: number, // 0-100\n level: 'high' | 'medium' | 'low',\n reliability: 'medium',\n summary: string\n },\n summary: string,\n strengths: string[],\n weaknesses: string[],\n redFlags: Array<{ label: string, severity: 'low'|'medium'|'high'|'critical', reason: string }>,\n skillScores: Array<{ skill: string, score: number, evidenceAnswerIds: string[] }>,\n questionBreakdown: Array<{\n answerId: string,\n questionId: string,\n questionText: string,\n questionOrigin: 'predetermined',\n evaluationMode: 'model_answer_based',\n overallScore: number, // 0-100\n modelAnswerComparison: { score: number, alignment: 'partial'|'missing', matchedPoints: string[], missedPoints: string[], notes: string },\n dimensionScores: {},\n strengths: string[],\n weaknesses: string[],\n redFlags: [],\n followUpRecommendations: [],\n evaluationConfidence: 'medium',\n summary: string,\n transcriptOnly: true\n }>,\n suggestedNextSteps: string[],\n transcriptOnly: true,\n futureSignalPlaceholders: { audioAnalysisEnabled: false, videoAnalysisEnabled: false },\n proctoringSummary: { eventCount: number, criticalOrHighCount: number },\n reportEngine: 'transcript_llm'\n }\n}\n```\nWhen engine is 'deterministic', `evaluation` is whatever evaluateInterview(sessionId) returns (the canonical EvalCandidateReport from the Aviral evaluator), conforming to the same CandidateReport contract but not constructed in this module.\n\nStatus codes: 200 OK (engine 'transcript_llm' or 'deterministic'); 404 Not Found {\"error\":\"Session not found\"} (unknown sessionId at the route guard); 500 Internal Server Error {\"error\":} (both LLM report and deterministic fallback threw).\n\nNotes: Global rate limit. Always calls finalizeTranscript(sessionId) first. **Exit interviews — verbatim, NOT scored (2026-07-05 pivot):** when the session's `settings.interviewType === \"exit_interview\"` (or `settings.jobType === \"exit\"`), this route SKIPS both the holistic LLM report AND the structured (aviral) grader and returns `{ evaluation: buildExitTranscriptReport(sessionId), engine: 'exit_verbatim' }` — a VERBATIM, NO-SCORE report (`interviewType: \"exit_interview\"`, `scored: false`, `exchanges`/`themes`; see the exit-variant block under **GET /api/jobs/applicants/{applicant_id}/functional-report**). Otherwise (hiring path): tries generateTranscriptReport (DeepSeek); on any error logs a warning and tries evaluateInterview; only if BOTH fail does it 500. The report path persists the report onto session.evaluation and sets session.status='EVALUATED'.\n\n#### POST /api/interviews/{sessionId}/screening-outcome\n\nRecruiter-screening only: called by the candidate room right after `/report` succeeds. Forwards server-to-server to the FastAPI backend (`POST /api/public/interview-session/{session_id}/screening-outcome`, `X-Webhook-Secret: process.env.ENGINE_WEBHOOK_SECRET`), which reads the evaluation this route's sibling `/report` just persisted, decides fit, and — on a fit — auto-mints + emails the functional-interview invite. This route is a thin relay: it does no DB access itself.\n\n- **Auth:** public (candidate room calls it directly, no token).\n- **Path params:** `sessionId`:string — the interview session id.\n- **Query params:** none\n\nRequest: none\n\nResponse: relays the backend's JSON body and status code verbatim — see `POST /api/public/interview-session/{session_id}/screening-outcome` above for the shape (`{fits, link?, sent?, alreadyAdvanced?, fitLabel?}`).\n\nStatus codes: relayed from the backend (200/403/404/409); 503 `{\"error\": \"Backend not configured for screening outcome routing.\"}` when `BACKEND_URL` is unset; 502 `{\"error\": \"Failed to reach backend for screening outcome.\"}` on a network failure reaching the backend.\n\nNotes: Requires `BACKEND_URL` and `ENGINE_WEBHOOK_SECRET` env vars on the engine (same vars already used by `drive-upload.service.ts` for recording uploads). The candidate room only calls this when the session's `InterviewSession.settings.stage === 'screening'` (stamped by `backend/app/utils/ai_sync.py sync_applicant_to_ai`); functional-interview sessions never call it.\n\n#### GET /api/interviews/{sessionId}/transcript/file\n\nDownload the finalized transcript as a .txt attachment, finalizing on demand if the file does not yet exist.\n\n- **Auth:** public.\n- **Path params:** `sessionId`:string — the interview session id\n- **Query params:** none\n\nRequest: none\n\nResponse: 200 OK — streamed plain-text file (NOT JSON).\n```\nContent-Type: text/plain; charset=utf-8\nContent-Disposition: attachment; filename=\".txt\"\n<.txt body: header (Interview Transcript / Session ID / Candidate ID / Started At / Ended At) followed by lines '[HH:MM:SS] Candidate|Interviewer: text', or '(No transcript was captured for this interview.)'>\n```\n\nStatus codes: 200 OK (file streamed); 404 Not Found {\"error\":\"Transcript not available\"} (file missing and finalize produced no readable file). No explicit session-existence guard — a missing session yields finalize status 'failed' → 404.\n\nNotes: Global rate limit. If transcriptFilePath(sessionId) does not exist it calls finalizeTranscript(sessionId) and serves the produced file; if that path is null/absent returns 404. Streams via fs.createReadStream.\n\n### `interview-engine/apps/api/src/routes/internal.routes.ts`\n\nInternal service-to-service routes (prefix `/internal`), called ONLY by the FastAPI backend — never the browser — and guarded by a shared-secret header. Because the two services share one Postgres, the backend performs all DB anonymise/erase itself (deleting a Candidate cascades its sessions/transcripts/proctoring at the DB level); the engine's sole job here is to unlink its ON-DISK artifacts. Part of the DPDP Act 2023 right-to-erasure flow.\n\n#### POST /internal/data-rights/erase-files\n\nFiles-only erasure: unlink the on-disk transcript `.txt` and recording blobs for a set of engine interview sessions. Best-effort and idempotent (missing files ignored).\n\n- **Auth:** internal only — requires request header `x-internal-secret` matching the engine's `INTERNAL_SERVICE_SECRET` env var. Missing/mismatched secret (or an unset env var) → **401** `{\"error\":\"unauthorized\",\"code\":\"BAD_INTERNAL_SECRET\"}`.\n- **Path params:** none\n- **Query params:** none\n\nRequest: `application/json`\n```\n{\n sessionIds: string[], // engine InterviewSession ids to purge on-disk files for; non-string entries are ignored\n requestId?: string // optional DSAR request id (correlation only)\n}\n```\nMissing/omitted body is treated as `{}` (→ empty `sessionIds`). For each session id it unlinks: (1) the transcript `.txt`, resolved via BOTH transcript-dir helpers (`transcript.service` + `flagcheckTranscription.service`) plus the authoritative path stored on `InterviewTranscript.transcriptFilePath`; and (2) any `type:'recording'` blobs referenced in the session's `transcript` JSON (joined by basename under `uploads/`).\n\nResponse: 200 OK (application/json).\n```\n{\n ok: true,\n count: number, // number of files actually unlinked\n filesUnlinked: string[] // absolute paths of the files that were removed\n}\n```\n\nStatus codes: 200 OK (success, incl. when nothing matched — `count: 0`); 401 Unauthorized (bad/missing `x-internal-secret`).\n\nNotes: Not under the `/api` prefix (registered at `/internal`). Best-effort/idempotent — each unlink is wrapped in try/catch and skips non-existent files, so replaying the same request is safe. Every DB lookup (transcript path, session transcript) is individually guarded, so a missing session or transcript is simply skipped.\n\n### `interview-engine/apps/api/src/server.ts`\n\n#### GET /health\n\nHealth/liveness check. The only route attached directly to the Fastify server instance; all other routes live inside registered route modules and the websocket gateway.\n\n- **Auth:** none (public)\n- **Path params:** none\n- **Query params:** none\n\nRequest: none\n\nResponse:\n```json\n{\n \"ok\": true,\n \"service\": \"interviehire-api\"\n}\n```\n\nStatus codes: 200 OK.\n\nNotes: No global prefix at the server level — `/health` is served at root. Per-module prefixes applied at registration: companyRoutes → /api/company, interviewRoutes → /api/interview, transcriptRoutes → /api/interviews, assistantRoutes → /api/assistant; registerWebsocket(app) adds WS routes. Server-level config: CORS { origin: true, credentials: true }; rate limit { max: 200, timeWindow: '1 minute' }; @fastify/multipart and @fastify/websocket registered. Listens on host 0.0.0.0, port from process.env.PORT or 4000. dotenv loads ../../../.env relative to the compiled server dir. There is NO root \"/\" route.\n\n---\n\n## Dashboard — Next route handlers\n\n### `dashboard/app/api`\n\n#### POST /api/parse-file\n\nParse an uploaded resume/document file (.pdf/.docx/.txt) server-side and return its extracted plain text. Runs on the Node.js runtime (force-dynamic). Max file size 5 MB.\n\n- **Auth:** none (no auth check in handler)\n- **Path params:** none\n- **Query params:** none\n\nRequest: `multipart/form-data`\n```\nFormData fields:\n file: File (required) — extension must end in .pdf, .docx, or .txt (case-insensitive). file.size <= 5 MB (5*1024*1024 bytes).\n```\nNo JSON body. Read via request.formData(); only the `file` field is consulted.\n\nResponse:\n```\n// 200 OK (success)\n{ \"text\": string } // extracted text, trimmed\n\n// 400 no file\n{ \"error\": \"No file provided\" }\n\n// 413 too large\n{ \"error\": \"File too large (max 5 MB)\" }\n\n// 400 unsupported type\n{ \"error\": \"Unsupported file type. Use .pdf, .docx, or .txt\" }\n\n// 500 parse failure\n{ \"error\": \"Failed to parse file\", \"detail\": string|undefined }\n// detail is error.message in non-production; undefined when NODE_ENV === 'production'\n```\n\nStatus codes: 200 OK; 400 no file provided; 400 unsupported file type; 413 file > 5 MB; 500 parse error (catch-all).\n\nNotes: .pdf parsed via pdf-parse (PDFParse class, with @napi-rs/canvas DOM polyfills + pdfjs legacy worker; falls back to default pdf-parse fn). .docx parsed via mammoth.extractRawText. .txt read as utf-8. Only POST is exported. runtime='nodejs', dynamic='force-dynamic'.\n\n#### POST /api/fetch-doc\n\nFetch a remote/shared document by URL (Google Docs/Sheets/Drive links normalized to export/download URLs), download it, and return extracted plain text. Max 8 MB.\n\n- **Auth:** none (no auth check in handler)\n- **Path params:** none\n- **Query params:** none\n\nRequest: `application/json`\n```\n{\n \"url\": string (required) // document URL; must be a non-empty string\n}\n```\nGoogle Docs (/document/d/) → exported as txt; Google Sheets (/spreadsheets/d/) → exported as csv; Google Drive (/file/d/ or ?id=) → uc?export=download; any other URL fetched as-is.\n\nResponse:\n```\n// 200 OK (success)\n{ \"text\": string } // extracted text, whitespace-collapsed and trimmed\n\n// 400 missing/invalid url type\n{ \"error\": \"No url provided\" }\n\n// 400 unparseable url\n{ \"error\": \"Invalid URL\" }\n\n// 502 upstream fetch not ok\n{ \"error\": \"Could not fetch document (HTTP ).\" }\n\n// 413 too large\n{ \"error\": \"Document too large (max 8 MB).\" }\n\n// 422 empty extraction\n{ \"error\": \"No readable text found in the document.\" }\n\n// 500 catch-all (includes private-doc detection)\n{ \"error\": string } // error.message or \"Failed to fetch document\"\n```\n\nStatus codes: 200 OK; 400 no url provided / url not a string; 400 invalid URL (URL constructor throws); 502 remote fetch returned non-2xx; 413 downloaded body > 8 MB; 422 no readable text after extraction; 500 any thrown error (e.g. private Google doc → message 'Document is not publicly accessible — set sharing to \"Anyone with the link\".', or 'PDF parser unavailable').\n\nNotes: Content-type-driven extraction: application/pdf → pdf-parse (with @napi-rs/canvas polyfills); wordprocessingml/msword → mammoth.extractRawText; otherwise utf-8 string. For text/html it strips