CODEX // oaoisme wiki

§ unswayed-backend · API contract & docs

Lenux AI — settings & feedback (Phase 13, UN-107)

updated 2026-06-16

Lenux AI — feature settings & suggestion feedback (Phase 13, T-13.6 / UN-107)

This slice is the control plane for the whole Lenux suite. It does two things: (1) it lets a company turn each Lenux feature on or off, and (2) it collects helpful/unhelpful feedback on individual Lenux suggestions for downstream tuning. It lives at src/lenux-ai/settings/ and rides the shared Lenux /api/v1/* surface (bare camelCase JSON, employer-only, per-company / per-user rate limits). It is the sixth slice of Phase 13, built on the common primitives in src/lenux-ai/common/.

What it does

Method & path Limit What it does
GET /api/v1/companies/:companyId/lenux-settings 30/min/company Read the company's six feature flags (merged over defaults)
PUT /api/v1/companies/:companyId/lenux-settings 30/min/company Upsert the flags (partial body merges over current)
POST /api/v1/lenux/feedback 60/min/user Record helpful/unhelpful feedback on a suggestion (201)
GET /api/v1/companies/:companyId/lenux-feedback/summary 30/min/company Aggregate feedback for the company (admin-only)

companyId is the Employer.id. All endpoints are employer-only (@RequireUserType(employer) + the JWT / account-status / user-type guards).

The six feature flags

The flag set is owned by src/lenux-ai/common/lenux-feature.ts (orchestrator land, shared by every slice):

scoring · jdOptimization · questionGeneration · analytics · autoRanking · chat

DEFAULT_LENUX_FEATURE_FLAGS turns all on except autoRanking so the suite works out of the box with no settings row. resolveFeatureFlags(stored) merges a stored (possibly partial / unknown) JSON over those defaults, ignoring non-boolean and unknown keys. The GET endpoint returns exactly resolveFeatureFlags(...); a company with no row gets the defaults.

Why this slice has NO feature guard (the circularity)

Every other Lenux slice is gated by LenuxFeatureGuard, which reads CompanyLenuxSettings.featureFlags[flag] and 403s when the flag is off. This slice is the one that writes that very row — so gating it on a flag would be circular (you could lock yourself out of the switch that unlocks everything). So the settings controller deliberately omits LenuxFeatureGuard from its @UseGuards(...), and none of its handlers carry @RequireLenuxFeature. The feedback endpoints are likewise ungated.

The payoff is the acceptance criterion for the slice: because the feature guard in common/ reads the same company_lenux_settings row this slice upserts, disabling a flag here immediately 403s the matching endpoints in the other slices — no cache, no restart. Set scoring:false via the PUT and the next call to a scoring endpoint is denied.

The partial-PUT merge (the careful bit)

PUT takes { featureFlags: {...partial or full...} }. The contract is: a PUT only changes what it sends, but the row always stores the full resolved map. So the handler:

  1. resolves the company's current flags (stored ∘ defaults),
  2. overlays only the keys actually present in the body (each must be a real boolean), and
  3. upserts the complete six-key map.

So a first PUT {scoring:false} followed by PUT {chat:false} ends with both off and the other four at their defaults — the second PUT preserves the first. The validation is strict: the DTO uses @IsBoolean() without @Type(() => Boolean), so a string like "false" or a number 1 is not coerced — it is rejected with a 422. Unknown keys are stripped by the global whitelist pipe.

Feedback: recording & the summary

POST /api/v1/lenux/feedback has no companyId in the path — any authenticated company member may submit, so the caller's own employer is resolved via requireCompany(user.id). The body links a rating to a specific suggestion:

{ "suggestionType": "scoring", "suggestionRefId": "...", "suggestionRefType": "candidate_score",
  "rating": "helpful", "comment": "optional" }

rating is the LenuxFeedbackRating enum (helpful|unhelpful); an invalid value is a 422. A row is written to lenux_suggestion_feedback and the endpoint returns 201 { id, status:"recorded" }. suggestionType / suggestionRefId / suggestionRefType are free-form strings on purpose — they let any Lenux surface (a candidate score, an interview question, a JD suggestion, a chat reply) attach feedback that downstream tuning can group on.

GET .../lenux-feedback/summary aggregates the company's feedback with a single Prisma groupBy(['suggestionType','rating']), then folds it into:

{ "companyId": "...", "total": 12,
  "byType": [ { "suggestionType": "chat", "helpful": 2, "unhelpful": 0 },
              { "suggestionType": "scoring", "helpful": 7, "unhelpful": 3 } ],
  "helpfulRate": 0.75 }

byType is sorted by suggestionType for a deterministic response; helpfulRate is helpful / total (0 when there is no feedback). The aggregation lives in LenuxFeedbackService so it is unit-testable in isolation.

The "admin-only summary" mapping

UN-107 restricts the summary to the company Admin. This ATS has no multi-user company entity — a company is one Employer (1:1 with a User), so the employer who owns the company is its admin. That makes the existing cross-tenant check the admin check: requireCompany(user.id, companyId) returns the caller's employer and 403s if companyId is not theirs. No separate role table is needed; the mapping is documented here and in the controller.

Persistence

Two tables (both already in the Phase-13 migration; this slice only reads/writes them, it adds no schema):

  • company_lenux_settingscompanyId @id (Employer.id), featureFlags JSON, timestamps. One row per company; upserted by the PUT; read by both the GET and the orchestrator's LenuxFeatureGuard.
  • lenux_suggestion_feedbackid, userId, companyId, suggestionType, suggestionRefId, suggestionRefType, rating enum, comment?, createdAt, indexed [companyId, suggestionType] for the summary group-by.

Rate limiting & the 429 body

Settings are 30/min/company; feedback POST is 60/min/user; the summary is 30/min/company. When a window is exhausted the shared LenuxRateLimitGuard emits the verbatim Jira body and a Retry-After header:

{ "error": "rate_limit_exceeded", "limit": 60, "window": "1m", "retryAfterSeconds": 60 }

The global per-IP throttler is @SkipThrottle()'d off on this controller.

Testing

  • Unit (27 tests) — the controller (defaults-merge on GET, partial-PUT merge over current + full-map upsert, no-row path, cross-tenant 403, feedback record
    • 201 shape, summary delegation + 403), the LenuxFeedbackService (record with / without comment, group-by aggregation, empty summary), and DTO validation (boolean-strict flags with no coercion, enum rating, required fields), plus a module wiring smoke test.
  • e2e (22 tests, test/lenux-settings.e2e-spec.ts) — boots the real app with the slice and drives every endpoint against Postgres: default + merged GET, partial-PUT persistence and the second-PUT-preserves-first merge, the disable-a-flag round-trip (the row the feature guard reads), boolean-strict 422s, the feedback happy path + null comment + enum 422 + missing-field 422, the aggregated summary + empty summary + cross-company isolation, employer-only 403, applicant 403, 401, cross-tenant 403, and the verbatim 429 + Retry-After for both the 60/min/user feedback window and the 30/min/company settings window.

Controller + service are 100% lines/branches/functions; the slice clears the repo's 90% coverage gate on all four metrics.