§ unswayed-backend · API contract & docs
Lenux AI — settings & feedback (Phase 13, UN-107)
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:
- resolves the company's current flags (
stored ∘ defaults), - overlays only the keys actually present in the body (each must be a real boolean), and
- 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_settings—companyId @id(Employer.id),featureFlagsJSON, timestamps. One row per company; upserted by the PUT; read by both the GET and the orchestrator'sLenuxFeatureGuard.lenux_suggestion_feedback—id,userId,companyId,suggestionType,suggestionRefId,suggestionRefType,ratingenum,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.
- 201 shape, summary delegation + 403), the
- 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-Afterfor 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.