§ unswayed-backend
Phase 21 — frontend brief features
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:
- One migration (
phase_21_frontend_brief) added every new column/enum up front —CoverLetter.pdfUrl,ResumeSettings.showPersonalDetails, three newResumeTemplatevalues,EmployerJob.extractedSkills/displayLocation/viewsCount,Applicant.profileViews,Message.metadata+ aMessageType.action,JobOffer.counterAmount+InterviewOfferStatus.counter, andSubscriptionPlan.maxApplicantsPerJob/maxJobs. A sharedSubscriptionLimitsPortinterface was also landed so two unrelated slices could depend on it without colliding. - 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.
- 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, acover_letter_idattaches that PDF to the application. - Resume templates (N8): seven ATS-compliant single-column layouts, plus a
showPersonalDetailstoggle. 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/jobsthat 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:
employerIdis a string, not a number.Employer.idis a UUID. The brief saidnumber; sending a number tofindUnique({ 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 onApplicant(firstName/lastName). The personalisation reads from there. - There is no
additional_documentsfield and noApplicantResume.rawTextand no LinkedIn column. Each brief feature that assumed one was re-routed to what exists (an extraJobApplicationDocument; the parsedresumeData; an unpopulated optional). User.subscriptionPlanstores 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.