§ unswayed-backend · API contract & docs
Lenux AI — recruitment analytics (Phase 13, UN-105)
What it is
The analytics slice of Lenux AI (src/lenux-ai/analytics/) turns the hiring
pipeline into recruiter-facing metrics: a funnel, time-in-stage, diversity
(privacy-safe), source effectiveness, and a CSV/PDF export. It lives on the
greenfield /api/v1/* surface (bare camelCase bodies, employer-only, custom 429,
per-user limits), gated behind the analytics feature flag.
The data it reads
Everything derives from application_status_history — an append-only log of every
JobApplication.status transition ({applicationId, fromStatus, toStatus, changedBy, changedAt}). The repo writes a row at every status change: a one-line
recordStatusTransition(tx, …) call was added inside the existing apply,
employer-action and applicant-response transactions (so the write is atomic with the
status change), plus the new shortlist path. This is the "not optional — write on every
transition" requirement from the ticket, done structurally rather than by convention.
The endpoints
GET /api/v1/jobs/{jobId}/analytics/funnel— counts per stage withconversionFromPrevious. Stages are applied → shortlisted → interviewed → hired (this ATS has no native screened step, so it's omitted — a documented deviation). "Ever reached" semantics:interview_invite/interview_accept→ interviewed,position_filled→ hired.GET .../analytics/time-in-stage— avg + median days between consecutive transitions, per stage, computed from each application's ordered history.GET /api/v1/companies/{companyId}/analytics/diversity?jobId=— aggregates only, grouped by gender/ethnicity, with k-anonymity ≥ 5: any group (or total) below the threshold returns{ sufficientData: false, message: "Insufficient data to display aggregated results." }. It never returns per-individual rows.GET .../analytics/sources— joinsJobApplication.sourceChannel(direct|job_board|referral|social) with outcomes for a per-channel conversion rate.GET .../analytics/export?format=pdf|csv— the same funnel+sources dataset serialized. CSV is a direct serialization;format=pdfreturns a minimal hand-built valid PDF (Node has no ReportLab) carrying the same data. Heaviest endpoint → 10/hour/user.
The daily aggregation
AnalyticsFunnelDaily is a cache ({employerJobId, day, stage, count}, unique per
trio), refreshed by a daily @Cron(EVERY_DAY_AT_MIDNIGHT) that enqueues a
refresh-analytics-funnel job on the lenux-ai queue (try/catch + Logger, like the
subscription-expiry cron). The funnel endpoint prefers the cache when a full-stage row
set exists, else computes live — so the numbers are always correct even before the first
refresh; the table only speeds up large applicant pools.
Why this shape
Funnel/time-in-stage are pure functions of the history log, so the engine
(analytics-funnel.engine.ts) is a set of pure, exhaustively unit-tested functions; the
service just feeds them DB rows. Diversity's k-anonymity guard is the one piece of real
policy — it's enforced in the engine so it can't be bypassed by a controller change.