CODEX // oaoisme wiki

§ unswayed-backend

Phase 21 — frontend brief features

updated 2026-06-29

Phase 21 implemented a frontend product brief (2026-06-28): 8 new features (N1–N8) plus 13 carried-over gaps, built in one phase. The interesting part isn't any single endpoint — it's how a brief this large was delivered, and the handful of places where the brief's assumptions didn't match the actual schema.

How it was built — foundation, fan-out, integrate

The repo's house rule for a multi-part change is: land the shared contracts first, then fan out parallel agents over disjoint file-areas, then integrate. That's exactly what happened:

  1. One migration (phase_21_frontend_brief) added every new column/enum up front — CoverLetter.pdfUrl, ResumeSettings.showPersonalDetails, three new ResumeTemplate values, EmployerJob.extractedSkills/displayLocation/viewsCount, Applicant.profileViews, Message.metadata + a MessageType.action, JobOffer.counterAmount + InterviewOfferStatus.counter, and SubscriptionPlan.maxApplicantsPerJob/maxJobs. A shared SubscriptionLimitsPort interface was also landed so two unrelated slices could depend on it without colliding.
  2. 11 subagents in parallel, each owning one module directory (cover-letters, resume, scoring, discovery, talent, applications, employer-jobs, pipeline+chat, subscriptions, dashboards+feedback), each doing its own red→green TDD against the now- stable schema.
  3. Integration by the orchestrator — wire the modules, fix the cross-slice type seams, run the one gate.

This is the same pattern the earlier roadmap phases used; the lesson it keeps proving is that the schema is the contract. Once the columns exist, disjoint slices compile against a stable surface and never block each other.

What the features do (briefly)

  • Cover letters (N1): the generator now grounds the letter in the applicant's name + the employer's company name, renders it to a PDF (pdfkit) uploaded to storage, and returns a pdfUrl. On apply, a cover_letter_id attaches that PDF to the application.
  • Resume templates (N8): seven ATS-compliant single-column layouts, plus a showPersonalDetails toggle. Before this, the renderer ignored every setting — the fix was as much about threading template/font/theme/PII through the render chain as about drawing the templates.
  • Scoring (N2/N3): a career-areas view that groups jobs by category with each applicant's match score, a standalone "score me against this job" endpoint, and an AI pass that extracts a job's required skills from its description (cached on the job) to enrich the deterministic scoring engine.
  • Unified jobs (N4): a public GET /api/jobs that merges internal postings with external (JSearch) results, using the LLM to extract salary/remote/type/level from each external description (cached 1h) so they can be filtered like internal jobs.
  • Application → chat (N5): every hiring action (interview, offer, rejection, counter-offer) and every applicant response now also drops a structured message into the employer↔applicant DM, carrying metadata the client renders as Accept/Decline buttons.
  • Talent (N6): an employer view of applicants ranked by fit against their jobs.
  • Subscription limits (N7): plans can cap applicants-per-job and jobs-per-plan; the apply and job-create flows enforce them with a 422.

The instructive part — where the brief met reality

A brief written against the frontend's mental model won't always match the backend schema. Recon caught these before coding, and the implementation followed the schema:

  • employerId is a string, not a number. Employer.id is a UUID. The brief said number; sending a number to findUnique({ where: { id } }) would have thrown. Lesson: verify the actual PK type, don't trust a spec's casing/typing.
  • There is no User.name. A person's name lives on Applicant (firstName/ lastName). The personalisation reads from there.
  • There is no additional_documents field and no ApplicantResume.rawText and no LinkedIn column. Each brief feature that assumed one was re-routed to what exists (an extra JobApplicationDocument; the parsed resumeData; an unpopulated optional).
  • User.subscriptionPlan stores the plan name, not a slug. Resolving limits by slug would have silently matched nothing (always "unlimited"). The limits resolver matches by name — the same value the Stripe webhook writes.
  • Three endpoints already existed with a different shape (feedback list, the two dashboards) and the unified-jobs merge already existed as career-jobs. Rather than reshape them (and risk the frozen wire contract), the brief's variants were added as new endpoints beside the originals.

The takeaway for anyone extending this backend: reconcile the request against the schema first. The cheapest bugs are the ones a 20-minute recon pass prevents.

See ADR-0051 and memory entry 0037 for the full decision record and the per-feature file map.