CODEX // oaoisme wiki

§ unswayed-backend

Checkpoint final-review — job-posting contract completion

updated 2026-07-04

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-char code). 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 typed CURRENCIES const (154 active ISO-4217 entries, {code, name, symbol}) driving an idempotent seed:currencies CLI (upsert-by-code that never re-enables an admin-disabled row). The const also exports SUPPORTED_CURRENCY_CODES, so future validation can share one source of truth.
  • salary_currency was 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: only USD/EUR/NULL stored.)

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 0null, 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 CreateJobDto is 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 validationError schema even though those runtime bodies carry no errors map (an openapi invariant test enforces this). Matched, not forked.
  • main.ts binds *: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.ts boots the full AppModule — run it as WORKER_ROLE=api npx ts-node -r tsconfig-paths/register scripts/dump-openapi.ts or the queue workers attach to the real Redis/Postgres and start draining jobs.
  • Piping test output (npm run … | tail) eats the exit code — use pipefail or check the summary line, or a red suite reads as green.
  • e2e suites share one test database (ralli_test, serial workers, TRUNCATE per suite) — never run two e2e invocations concurrently.