§ unswayed-backend
Checkpoint final-review — job-posting contract completion
Checkpoint final-review — job-posting contract completion
Branch: checkpoint/final-review · Date: 2026-07-04 · Memory: repo docs/ai/memory/0041-checkpoint-final-review.md
What this was
A product-owner request list arrived for the job-posting surface — interview rounds, a close-job button, custom shift timing, free-text currency/benefits, an employer-scoped blog list, dashboard status counts. Research had already shown most of it was built long ago. So this unit of work is deliberately unusual: it is mostly a verification pass with teeth, plus the two genuinely missing pieces. The frontend is being wired by a separate agent that can only read /docs — so every touched endpoint's Swagger had to become the whole truth.
The lesson worth keeping: "verify the already-implemented claim" is real engineering. Four of the six "already done" items turned out to hide real defects that only surfaced under adversarial reading — a validation/mapping disagreement, a transaction race, a resource that never echoed two fields, and Swagger schemas that documented empty objects.
The two real gaps (new features)
1. Dashboard overview: closed_jobs + archived_jobs
GET /api/v1/employer/dashboard/overview counted total/active/draft but not the other half of the Phase-15 lifecycle (draft 0 → published 1 → closed 2 → archived 3). The frontend was deriving closed counts client-side — wrongly. The service computes all four counts from one findMany({ select: { id, status } }) and filters in memory, so adding two more was two filter().length lines, not two queries. The four counts now partition total_jobs exactly.
The bigger fix was documentation: the controller had no @ApiOkResponse at all (only @ApiTags). Because this is a @BareResponse() route (no {status, message, data} envelope), the new schema documents the literal nine-field body — a subtlety a docs-only consumer can't guess, so the @ApiOperation description says it outright.
2. GET /api/currencies — the ISO-4217 master-data list
The frontend's currency dropdown was a hardcoded 6-entry array; salary_currency was (and remains) an unvalidated free string. Rather than have the frontend own a giant static list, currencies became a proper master-data endpoint. The design intentionally mirrors the §28 job-reference family (job_shift_timings etc.) exactly: same guards (JwtAuthGuard + AccountStatusGuard), same { message, data: { currencies: [...] } } envelope + global wrapper, same optional ?q= case-insensitive name search, same frozen 'Currencies get successfully.' message grammar.
Two structural decisions worth understanding:
- The canonical data lives in code, the served data lives in the DB. All 14 sibling lists are Prisma tables, so currencies got a table too (
currencies, unique 3-charcode). But the sibling tables are seeded from legacy production CSV dumps; currency data is ours, so it follows the newer subscription-plans pattern instead — a typedCURRENCIESconst (154 active ISO-4217 entries,{code, name, symbol}) driving an idempotentseed:currenciesCLI (upsert-by-code that never re-enables an admin-disabled row). The const also exportsSUPPORTED_CURRENCY_CODES, so future validation can share one source of truth. salary_currencywas NOT tightened. The frozen legacy contract accepts symbols ($,£— they genuinely occur via the external-jobs path), and the frontend hasn't switched to the endpoint yet. Tightening to@IsIn(SUPPORTED_CURRENCY_CODES)is a one-line follow-up after the switchover; today the DTO's Swagger steers new clients to ISO codes. (Live data was checked first: onlyUSD/EUR/NULLstored.)
The list itself makes dated judgment calls (2026-07-04): euro-replaced BGN excluded, XCG over ANG, VES not VEF, SLE/ZWG over their retired ancestors, no metal/fund/test codes.
The defects the "verify" pass found
interview_rounds: the falsy seam
The DTO said @IsOptional @Type(Number) @IsInt — no lower bound — while the service mapped dto.interview_rounds ? … : null. Result: 0 passed validation then silently became null (the layers disagreed about whether 0 is a value), '' coerced to 0 → null, and -3 passed and persisted, flowing into applicant-facing views. Fix: @Min(1) — safe because 0 was never storable (nothing could have depended on it), and it makes the falsy branch unreachable. The PUT semantics (full-replace: omit the field → stored value wiped to null) were real but undocumented; they're now stated in the Swagger description and pinned by unit + e2e tests.
close-job: the pre-check/transaction race
closeOwnedJob() verified "job exists, is mine, is published" before opening its $transaction, and the status flip inside was an unguarded update({ where: { id } }). Two overlapping closes could both pass the pre-check and both commit — duplicate closed_jobs snapshots, duplicate status-history rows, duplicate applicant notifications. (Applications themselves couldn't double-archive; that updateMany was already status-guarded — which is exactly the pattern the fix generalizes.)
The fix makes the flip the first write inside the transaction and self-guarding: updateMany({ where: { id, status: published } }); a count of 0 throws the same 422 'Only a published job can be closed.' a sequential double-close gets, rolling the whole transaction back. The race loser is indistinguishable from a late arrival — no new wire behavior. A unique constraint on originalJobId was considered and rejected: close → reopen → close is a legitimate lifecycle, so repeated snapshots per job are valid data.
Also fixed here: the prompt's claim that close-endpoint Swagger was "fully fleshed out" was true only of the legacy controller; the v1 surface (close / jobs/closed / reopen) documented zero error responses. All three routes now document their bare 200 bodies and exact 401/403/404/422 messages. Gotcha encoded in the docs: @BareResponse skips only the success envelope — error bodies still use {status:'error', message}.
custom_shift_timing / company_benefits: the echo hole
Both fields persisted correctly and appeared on public/applicant job views — but MyJobResource (the employer's own create/update/list shape) omitted them, so an employer edit form could never prefill what it had saved. interview_rounds was echoed, making the omission an inconsistency rather than a policy. Both fields are now on MyJobResource + its Swagger schema (additive; precedent: brief-v2's applicant_user_id). The docs also finally explain the collapse rule a consumer would otherwise need source access to learn: the non-owner employer job-detail view collapses jobShiftTiming?.name ?? customShiftTiming into one field; owner/applicant views emit both.
/blogs/mine: right code, invisible contract
The service-level shape parity claim checked out completely — same mapper, same paginator, correctly-ordered routes. But Swagger documented both lists' 200 as { type: 'object' } — an empty schema. Both endpoints now document every item field (including the full_article legacy alias and the admin-authored rows' omitted user_id) and the current_page/last_page/per_page/total meta; /mine documents its 401/403 and got its first HTTP-level e2e, which doubles as a regression pin on NestJS route ordering (blogs/mine must be declared before blogs/:id or it 404s).
The stale-e2e discovery
Running the full e2e suite exposed that staging was already red: three suites (lexi-resume-settings, hiring-pipeline, subscriptions — 8 tests) had exact-shape assertions never updated when phase-21/brief-v2 added applicant_user_id, max_applicants_per_job/max_jobs, showPersonalDetails, and the 7-template catalogue. git log -S dates the drift precisely — pre-existing, not this branch. The assertions were brought up to the documented contract (assertions changed, src/ untouched), leaving the whole suite green for the first time since 2026-07-01.
Left deliberately unfixed (owner decisions)
- Systemic P2003 → 500: no master-data id on
CreateJobDtois existence-validated —country,job_shift_timing, and all six pivot arrays FK-fault into a 500 on bad ids. Fixing one field would be arbitrary; the admin catalog's P2003→409 pattern is the template for a dedicated pass. - Lifecycle-422 docs convention: the repo documents lifecycle 422s with the keyed
validationErrorschema even though those runtime bodies carry noerrorsmap (an openapi invariant test enforces this). Matched, not forked. main.tsbinds*:3400(no host argument) — only UFW keeps it off the internet, contrary to the box's bind-to-loopback convention. Flagged, untouched.
Gotchas for the next agent
scripts/dump-openapi.tsboots the full AppModule — run it asWORKER_ROLE=api npx ts-node -r tsconfig-paths/register scripts/dump-openapi.tsor the queue workers attach to the real Redis/Postgres and start draining jobs.- Piping test output (
npm run … | tail) eats the exit code — usepipefailor check the summary line, or a red suite reads as green. - e2e suites share one test database (
ralli_test, serial workers,TRUNCATEper suite) — never run two e2e invocations concurrently.