CODEX // oaoisme wiki

§ unswayed-backend

UAT round 2 — graduated, offer currency, shortlist, job-closing automation & resume design

updated 2026-07-09

UAT round 2 — what changed and how it works

A 14-item UAT pass built on branch fix/uat-round-2 (off staging), in lock-step with a frontend agent consuming the Swagger contract in parallel. Three items were schema changes (each its own migration), five were feature slices, four were confirm-only verifications against the live dev backend, and two were audits. ADRs: 0055 (job-closing automation), 0056 (resume design/fonts). Memory: docs/ai/memory/0044-uat-round-2.md.

1 · "Did you graduate" (graduated)

ApplicantEducation.graduated Boolean? is a tri-state answer, deliberately distinct from isContinue (currently-studying): null means never answered. The multipart DTO takes the string "true"/"false" (same convention as is_continue); the wire emits graduated: boolean | null on every education row (list, create/update responses, profile aggregate).

The subtle decision: is_continue omitted on update resets to false (legacy coercion dto.is_continue === 'true'). Mirroring that would have made graduated effectively non-nullable — an unrelated edit would silently overwrite "never answered". So graduated maps omitted → undefined (Prisma skips the column; the stored answer survives) and present → === 'true'. Frontends must send the field explicitly to change it.

2 · Currency on offers and counter-offers

JobOffer.currency String? — free-text ISO-4217 alphabetic code, validated loosely (@IsOptional @IsString, no @IsIn) exactly like EmployerJob.salaryCurrency: the Currency master-data table (GET /currencies) is the vocabulary, deliberately not enforced, for legacy symbol compatibility.

Flow: the employer's offer_letter / counter_offer_letter action persists currency beside salary. When the candidate counters (offer-response type='counter'), a present currency updates the offer; an omitted one inherits — counters rarely change currency, so inherit is the default. GET /application/{id}/offer-detail echoes currency for both viewer roles, and the admin oversight offer serializer got the same field for parity.

3 · Shortlist as a first-class action (+ the "Declined" column)

type: 'shortlist' joins the employer-action enum. It is the one transition allowed from any current status — including terminal ones (archive, offer_decline, interview_decline, position_filled) — because it models an employer re-engaging a previously rejected/declined/filled candidate. It writes the status, a JobApplicationHistory row (application_shortlisted), records the transition, and sends the standard job_application in-app notification. No interview/offer sub-record, no email, no chat card.

The frontend's new "Declined" column needs no new enum values: interview_decline and offer_decline are set per-round by the candidate response endpoints, and this was verified (not assumed) to include counter-offer rounds — declining an employer counter-offer lands offer_decline because each counter is a fresh pending JobOffer row routed through the same OFFER_RESPONSE.decline mapping.

4 · Multi-round interviews (verified, not changed)

EmployerActionService.act() has no status-based guard on the interview action: each type='interview' creates a new pending JobInterview row (own proposed dates slots, own 30-day expiry, own accept/decline) and resets the application to interview_invite. The only real sequencing rule left: offers still 422 until the application has ever had an accepted interview.

5 · Job-closing automation (the big one — ADR-0055)

Three rules, one daily sweep (JobClosingCron, 7 a.m., advisory lock 771005, delegating to JobClosingService.run()):

  1. Reminderpublished jobs whose deadline falls in [now+6d, now+7d) and deadlineReminderSentAt IS NULL get a queued send-job-deadline-reminder-email ("Your job posting closes in 1 week": title, deadline, applicant count — the count computed at send time in the processor). The stamp is written only after a successful enqueue.
  2. Deadline closepublished jobs with deadline < now (strictly past = "the day after") are closed with reason "Automatically closed: application deadline passed".
  3. Filled close — the offer-accept transaction stamps EmployerJob.vacancyFilledAt the first time the position_filled count reaches a positive noOfPositions (that path is the only position_filled writer repo-wide). The sweep closes at min(vacancyFilledAt + 14d, deadline if it falls inside that window), reason "Automatically closed: all positions filled".

Why there's no double-fire: every rule re-fetches the job and re-checks status = published immediately before closing — closeAsSystem on CloseJobService does that internally and no-ops (returns null) if another rule or a human got there first. It reuses the exact manual-close internals (ClosedJob snapshot, bulk-archive of pending applications, applicant notifications, reopen support) via an extracted performClose. Automated closes are recognizable in the closed-jobs list by closed_by === 'system:auto-close'.

6 · Resume design that actually shows up in the PDF (ADR-0056)

Ground truth first: templates and font-size were already structurally threaded, but themeColor was ignored by 5 of 9 layouts (including the default classic) and fontFamily collapsed to two built-in PDF fonts. Now:

  • Fonts: ten SIL-OFL TTFs vendored under resume/fonts/ (Inter, Roboto, Merriweather, Lato, Gelasio standing in for Georgia — Georgia isn't freely embeddable; Gelasio is metric-compatible). Registered per-request; any failure falls back to base-14 — a render can never crash over a font. Shipped in builds via a nest-cli.json assets glob.
  • Theme color: every template paints it in its own idiom (heading text, hairlines, underline segments, accent bars) from one resolveStyle.
  • Type scale: small 10 / medium 11 / large 13 body points through one typeScale map — name blocks included (creative/executive were hard-coded).
  • Bonus fix: multi-column skills blocks (hybrid/technical/executive) used to leave the PDF cursor at the last column, rendering everything after at half-width. Fixed and regression-pinned.

PII gating (incognitoMode / showPersonalDetails) is untouched — the design work is layout/typography/color only. A 16-PDF matrix (7 templates × knob variations) was rendered from the built dist/ and visually inspected.

7 · Smaller items

  • Export dates (#8): the account data-export PDF formats every date MM-DD-YYYY (UTC getters, consistent with the old toISOString semantics); the download filename deliberately stays YYYY-MM-DD.
  • Reason in notifications (#13): reject / interview-decline / offer-decline notifications.send payloads now carry reason (null when omitted) so the in-app UI can show why.
  • Notification types (#14): NOTIFICATION_TYPES now matches reality — added job_application and job_closed, removed never-sent interview/offer, kept application (really sent by Lenux bulk-shortlist). The constants file now carries the audited type → data-keys catalog. Known routing gaps (reported, unfixed): Lexi auto-apply's generic has no entity id; resume_ready/failed reuse the job_id key for the async pipeline job id.

Confirm-only items

  • #6 both save endpoints live and guarded: internal POST /applicant/job-save/:id; external POST /applicant/external-job-save {rapid_job_id} (accepts and strips a rapid- prefix).
  • #7 GET /blog-categories (public) already unions suggestions with distinct in-use categories — proven live with DB-only custom values surfacing.
  • #10 GET /employer/reviews/:id is public, :id is the Employer.id uuid (not the user id), paginated as data.reviews[] + data.pagination{current_page,last_page,per_page,total}.
  • #12 apply-flow resume/document email attachments: file untouched, e2e green.

Gotchas worth remembering

  • An orphaned migration from an abandoned earlier attempt (20260707173012_offer_currency_and_interview_selected_date) was applied to both ralli_dev and ralli_test with all-NULL columns. Repair = delete its _prisma_migrations row + drop its two columns, then migrate deploy — never migrate reset a shared dev DB.
  • A crashed e2e run leaves .env renamed to .env.e2e-shelved (mtime preserved, so it looks old). The harness self-heals on the next run, but a service restart in that window boots without env.
  • Advisory-lock registry by convention: 771001–3 core crons, 771004 auto-apply, 771005 job-closing — next free is 771006.