CODEX // oaoisme wiki

§ unswayed-backend · API contract & docs

Lenux AI — recruitment analytics (Phase 13, UN-105)

updated 2026-06-16

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 with conversionFromPrevious. 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 — joins JobApplication.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=pdf returns 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.