§ unswayed-backend
Frontend brief v2 — templates, external jobs, match_score & more
A 10-task frontend-driven brief (ADR-0053), shipped additively — no existing wire shape changed, one
preferredLanguagemigration. Two tasks were Critical confirmed defects (external jobs never surfaced; Google logins failed opaquely). This page explains how each mechanism works and why, not just what changed.
How it was built
Shared contract first (the User.preferredLanguage migration + six full-User
spec-fixture patches so the new non-null-ish column didn't break existing mocks),
then six parallel disjoint-file TDD subagents — one per module area (resume ·
discovery · google-auth · cover-letters · newsletters · settings) — then the
orchestrator integrated (wired NewslettersModule into AppModule, ran the
lint/typecheck gate), and verified: repo-wide typecheck clean, lint:check clean,
1047 unit tests across the six areas green, app-boot e2e green. This is the repo's
standard "land the shared surface, fan out on disjoint files, integrate once"
model — the win is wall-clock, and the safety is the non-overlapping file areas.
T1 — Template-driven résumés
BuildResumeDto gained six optional fields: template_id, primary_color,
accent_color, font_family, show_photo, section_order. The DTO enforces only
type (@IsString/@IsBoolean/@IsArray). The membership check for
template_id lives in resume.service.ts (renderOptionsFor): it validates
against the seven frontend template names —
classic · modern · executive · creative · minimal · technical · academic —
and throws 422 on anything else, before any render or DB write. Omitted →
defaults to classic.
Why validate in the service, not the DTO? Because the frontend names are a
product contract, not just a type, and the 422 must carry a specific "expected one
of…" message. The pdfkit renderer's ResumeTemplateType union widened to 9
members — the two new ATS-safe single-column layouts are:
writeTechnical— Courier (monospace),// HEADINGcomment-style rules, a prominent two-column Technical Skills block first.writeAcademic— centered Times (serif), Education first, then Publications & Projects, Research & Experience.
RenderOptions (colors, font, show_photo, section_order) now threads through
both build() and rebuild() — previously the render paths ignored it.
section_order reorders body sections (unknown/duplicate keys ignored, unlisted
sections keep their natural order); show_photo prints an ATS-safe [ Photo ]
text placeholder (never an embedded image — embedded images break ATS parsers).
Gotcha: the renderer's 9-member union is deliberately independent of the
Prisma ResumeTemplate enum (which has 7 different persisted values). The two new
layouts are render-time only; persisting technical/academic would be a
separate schema change.
T2 — 422, not 500, on unreadable uploads
Three résumé paths (upload, extractAutoComplete, update) used to throw a
generic InternalServerErrorException (500) when text extraction returned
empty. A 500 tells the client "server broke"; the truth is "your file wasn't
text-extractable." All three now funnel through a shared extractOrThrow(file, stage) that throws UnprocessableEntityException (422) with a
user-actionable message, plus a Logger.error carrying the stage, file name,
extension, MIME, and extractor class — so ops can see why extraction failed.
A one-time Affinda-key startup warn lands at the resume.providers.ts config seam.
T3 (Critical) — why external JSearch jobs never reached applicants
Two bugs stacked:
- The app never sent JSearch a
country. RapidAPI JSearch defaults an omittedcountrytous, so a "Backend Engineer in Lagos" search silently returned US jobs or nothing. Verified live:…&query=Backend Engineer in Lagosreturned empty; adding&country=ngreturned 10. career-jobs.service.tsswallowed every failure — thefetchExternalcatch didlogger.warn(...)thenreturn [], so a provider outage looked identical to "no matches."
The fix: jsearch-http.provider.ts emits a country (iso2) query param when
present; external-jobs.service.ts resolves it from Country.code for the first
selected country (missing iso2 → logger.debug + prior behavior). fetchExternal
now returns { items, external_status } where external_status ∈
ok | unconfigured | error — logged at error level, distinguishing
"provider unconfigured (RAPIDAPI_KEY unset)" from "provider threw." Internal
jobs still flow regardless (external failure never blanks the whole list).
external_status is surfaced on GET /api/applicant/career-jobs; the dedicated
external-jobs controller keeps its 503 (provider down) vs 200-empty (no
matches) distinction.
T4 (Critical) — the opaque "Invalid Google token"
Diagnosed by instrumenting the boundary first (systematic debugging). The
google.verifier.ts catch discarded the underlying jose error and threw a
generic UnauthorizedException('Invalid Google token'), so the real cause was
invisible. Now the catch logger.errors a distinguishable line (jose class +
code + failing claim/reason) before re-throwing the unchanged client message.
Two real root causes were then fixed:
- Clock skew — added
clockTolerance(default 30s,GOOGLE_TOKEN_CLOCK_ TOLERANCE_SEC) tojwtVerify, so a few seconds of server↔Google drift stops rejecting valid tokens. - Stale JWKS —
keys.tsfetched Google's x509 cert set on every login. Now it caches with a 1h TTL + stale-but-good fallback, and logs a distinct kid-not-found line. Without caching, a rotated Google signing key against a freshly-fetched-but-racing resolver could reject every login.
A startup logger.warn fires when FCM_PROJECT_ID is unset. The client-facing
message is deliberately unchanged — internals go only to logs.
T5 — match_score on job resources
CareerJobResource, JobResource, and JobDetailResource gained
match_score: number | null, threaded via an optional trailing mapper param so the
resources stay pure. Scores come from the existing CompatibilityScoringService
(no new scorer): the list path batch-reads persisted CandidateScore for
(applicantId, jobIds) in one query; the detail path calls getLatestScore.
It's computed for applicant viewers only — guests, employers, incomplete
profiles, missing scores, and external career jobs (no EmployerJob id) all get
null, never 0 (0 is a real low score; null means "not applicable/unknown").
ScoringModule is imported into DiscoveryModule (no dependency cycle).
T6 / T9 — new career-jobs filters
exclude_applied=trueaddsapplications: { none: { applicantId } }to the Prismawhere— only when an applicant is resolved (no-op for guests).is_remote— internal jobs match ajobLocationpivot whose name contains "remote" (documented limitation:EmployerJobhas no dedicated remote column); external jobs useExternalJobListItem.is_remote.posted_withinaccepts an enum (today | 3days | week | month) or a raw positive day count, normalized tocreatedAt >= now − N days(internal) / the item'screated_at(external).
T7 — cover letters sign off with the UCN
Anonymous applications must never leak a real name. GenerateCoverLetterResponse
gained ucn (from the already-loaded applicant.ucn). The prompt was rewritten
to sign off with the UCN only and to forbid real name, PII, and [bracket]
placeholders. Belt-and-suspenders: a post-parseLetter sanitizeLetter step runs a
regex net that strips any residual \[...\] tokens and any leaked real-name string,
and guarantees a UCN sign-off. CoverLetterSummary (list rows) is intentionally left
without ucn — that path doesn't load the applicant, and the persisted content
already signs off with the UCN.
T8 — public newsletters
A new src/newsletters/ module exposes two unauthenticated reads (the repo
has no global auth guard, only the throttler, so public endpoints simply omit the
auth guard):
GET /api/newsletters— paginated, newest-first, always the full{current_page, last_page, per_page, total}envelope (never a bare array).GET /api/newsletter/:id— atomically incrementstotalViewsvia Prisma{ increment: 1 }in the same round-trip that returns the row (no read-then-write race); 404 on missing/non-numeric id.
author is exposed as a non-PII {id, name} only. The module reuses the
Newsletter model created for the Phase-15 admin CRUD.
T10 — preferred_language
User.preferredLanguage String? @default("en") (migration 20260701193227). A
GET/PATCH /api/user/settings/language pair mirrors the existing theme-settings
endpoints (same JwtAuthGuard + AccountStatusGuard, legacy {status,message,data}
envelope). The DTO validates against the supported set en / fr / pt / ar via
@IsIn → 422 on anything else. The field is also included in the applicant
profile read. Every read coalesces null → 'en', so the column being nullable never
surfaces a null to the client.
Wire summary (all snake_case under /api, nothing removed)
| Endpoint | Change |
|---|---|
GET /api/applicant/career-jobs |
+params exclude_applied, is_remote, posted_within; +fields external_status, jobs[].match_score |
| internal job-detail | +match_score |
GET /api/applicant/external-jobs |
now sends country (iso2) to JSearch |
| cover-letter generate | +ucn |
GET /api/newsletters, GET /api/newsletter/:id |
new (public) |
GET/PATCH /api/user/settings/language |
new; applicant profile read +preferred_language |
See ADR-0053 for the decision record and memory 0040 for the build log.