CODEX // oaoisme wiki

§ unswayed-backend

Frontend brief v2 — templates, external jobs, match_score & more

updated 2026-07-01

A 10-task frontend-driven brief (ADR-0053), shipped additively — no existing wire shape changed, one preferredLanguage migration. 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), // HEADING comment-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:

  1. The app never sent JSearch a country. RapidAPI JSearch defaults an omitted country to us, so a "Backend Engineer in Lagos" search silently returned US jobs or nothing. Verified live: …&query=Backend Engineer in Lagos returned empty; adding &country=ng returned 10.
  2. career-jobs.service.ts swallowed every failure — the fetchExternal catch did logger.warn(...) then return [], 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_statusok | 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) to jwtVerify, so a few seconds of server↔Google drift stops rejecting valid tokens.
  • Stale JWKSkeys.ts fetched 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=true adds applications: { none: { applicantId } } to the Prisma where — only when an applicant is resolved (no-op for guests).
  • is_remote — internal jobs match a jobLocation pivot whose name contains "remote" (documented limitation: EmployerJob has no dedicated remote column); external jobs use ExternalJobListItem.is_remote.
  • posted_within accepts an enum (today | 3days | week | month) or a raw positive day count, normalized to createdAt >= now − N days (internal) / the item's created_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 increments totalViews via 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 @IsIn422 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.