§ unswayed-backend
UAT round 2 — graduated, offer currency, shortlist, job-closing automation & resume design
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()):
- Reminder —
publishedjobs whosedeadlinefalls in[now+6d, now+7d)anddeadlineReminderSentAt IS NULLget a queuedsend-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. - Deadline close —
publishedjobs withdeadline < now(strictly past = "the day after") are closed with reason"Automatically closed: application deadline passed". - Filled close — the offer-accept transaction stamps
EmployerJob.vacancyFilledAtthe first time theposition_filledcount reaches a positivenoOfPositions(that path is the onlyposition_filledwriter repo-wide). The sweep closes atmin(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 anest-cli.jsonassets 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 13body points through onetypeScalemap — 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 oldtoISOStringsemantics); the download filename deliberately staysYYYY-MM-DD. - Reason in notifications (#13): reject / interview-decline / offer-decline
notifications.sendpayloads now carryreason(null when omitted) so the in-app UI can show why. - Notification types (#14):
NOTIFICATION_TYPESnow matches reality — addedjob_applicationandjob_closed, removed never-sentinterview/offer, keptapplication(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'sgenerichas no entity id;resume_ready/failedreuse thejob_idkey for the async pipeline job id.
Confirm-only items
- #6 both save endpoints live and guarded: internal
POST /applicant/job-save/:id; externalPOST /applicant/external-job-save{rapid_job_id}(accepts and strips arapid-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/:idis public,:idis the Employer.id uuid (not the user id), paginated asdata.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 bothralli_devandralli_testwith all-NULL columns. Repair = delete its_prisma_migrationsrow + drop its two columns, thenmigrate deploy— nevermigrate reseta shared dev DB. - A crashed e2e run leaves
.envrenamed 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.