From 0cec8b07bd6183b5db8a63ea3dcfab19fa89e329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 01:30:28 +0200 Subject: [PATCH] Publish the vaccination booster mint and upload suggestion The iOS tracker was told both endpoints were in the spec; neither was registered. Publish POST /api/vaccinations/{id}/booster (201 mint / 200 re-anchor with the resolved MeasurementReminder DTO) and GET /api/vaccinations/suggest (the server-decided one/many/none verdict), and regenerate the YAML. A structural guard now derives the expected path set from the route files so the next vaccination route cannot ship unpublished. --- docs/api/openapi.yaml | 216 ++++++++++++++++++ .../vaccination-route-publication.test.ts | 58 +++++ src/lib/openapi/routes/vaccinations.ts | 116 ++++++++++ 3 files changed, 390 insertions(+) create mode 100644 src/lib/openapi/__tests__/vaccination-route-publication.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5e4118d0c..54209f810 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -10813,6 +10813,92 @@ paths: "404": *a18 "422": *a3 "429": *a4 + /api/vaccinations/{id}/booster: + post: + tags: + - Records + summary: Plan a booster reminder from a dose + description: "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: + VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the + preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the + existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A + free-text-only dose has no antigen to remind on and is refused with 422. Audits as + `vaccination.booster.planned`." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + intervalMonths: + type: integer + minimum: 1 + maximum: 600 + label: + type: string + minLength: 1 + maxLength: 120 + notifyHour: + type: integer + minimum: 0 + maximum: 23 + required: + - intervalMonths + - label + additionalProperties: false + responses: + "200": + description: Existing booster reminder re-anchored. + content: + application/json: + schema: + $ref: "#/components/schemas/ReanchorVaccinationBoosterEnvelope" + "201": + description: Booster reminder minted. + content: + application/json: + schema: + $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" + "401": *a1 + "403": *a6 + "404": *a18 + "422": *a3 + "429": *a4 + /api/vaccinations/suggest: + get: + tags: + - Records + summary: Suggest the dose a document belongs to + description: Which dose does a document dated `anchor` most plausibly belong to? The document upload review asks this + when a scan is classified `VACCINATION`. The window (±7 days, shared with the visit moment) and the + one-pre-selects / many-offer-a-picker verdict are decided server-side so the browser never re-derives them. + Owner-scoped; the anchor is the only input and it is a date, not an id. + parameters: + - in: query + name: anchor + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: true + responses: + "200": + description: The suggestion verdict. + content: + application/json: + schema: + $ref: "#/components/schemas/VaccinationSuggestEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 /api/vaccinations/{id}/links: post: tags: @@ -36582,6 +36668,136 @@ components: - data - error additionalProperties: false + ReanchorVaccinationBoosterEnvelope: + type: object + properties: + data: + type: object + properties: + reminder: + $ref: "#/components/schemas/MeasurementReminderDTO" + minted: + type: boolean + required: + - reminder + - minted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MintVaccinationBoosterEnvelope: + type: object + properties: + data: + type: object + properties: + reminder: + $ref: "#/components/schemas/MeasurementReminderDTO" + minted: + type: boolean + required: + - reminder + - minted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + VaccinationSuggestEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/VaccinationSuggestResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + VaccinationSuggestResult: + anyOf: + - type: object + properties: + kind: + type: string + const: one + vaccination: + $ref: "#/components/schemas/VaccinationSuggestion" + required: + - kind + - vaccination + additionalProperties: false + - type: object + properties: + kind: + type: string + const: many + vaccinations: + type: array + items: + $ref: "#/components/schemas/VaccinationSuggestion" + required: + - kind + - vaccinations + additionalProperties: false + - type: object + properties: + kind: + type: string + const: none + required: + - kind + additionalProperties: false + description: "The SHAPE of the answer, decided server-side: `one` pre-selects that dose, `many` offers a picker over + `vaccinations` (bounded at 20 candidates), `none` offers nothing. A client renders the verdict and must not + collapse `many` into a silent auto-pick — the rule lives on the server so every client agrees." + VaccinationSuggestion: + type: object + properties: + id: + type: string + occurredAt: + type: string + antigenSlug: + anyOf: + - type: string + - type: "null" + vaccineName: + anyOf: + - type: string + - type: "null" + required: + - id + - occurredAt + - antigenSlug + - vaccineName + additionalProperties: false + description: "One candidate dose, resolved enough to render without a second round trip: `antigenSlug` when the dose + carries a catalogue slug (name it from the catalogue), `vaccineName` when the person's own wording is all the + dose has." LinkVaccinationEnvelope: type: object properties: diff --git a/src/lib/openapi/__tests__/vaccination-route-publication.test.ts b/src/lib/openapi/__tests__/vaccination-route-publication.test.ts new file mode 100644 index 000000000..4ee17e2a6 --- /dev/null +++ b/src/lib/openapi/__tests__/vaccination-route-publication.test.ts @@ -0,0 +1,58 @@ +/** + * Every vaccination route that exists on disk is published in the + * OpenAPI route table. + * + * The iOS tracker was told the booster mint and the upload suggestion + * were in the spec while neither was registered — the one false + * statement to the client. This guard recomputes the expected path set + * from the route files themselves, so the next vaccination route + * cannot ship unpublished either. + * + * Watched red: with the `/api/vaccinations/{id}/booster` and + * `/api/vaccinations/suggest` entries removed from `vaccinationPaths` + * this fails naming both paths — the exact pre-fix state. + */ +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { vaccinationPaths } from "../routes/vaccinations"; + +const ROUTE_ROOT = join(__dirname, "../../../app/api/vaccinations"); + +/** Collect every route.ts under the vaccinations API tree as a spec path. */ +function collectRoutePaths(dir: string, urlPrefix: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + // Next dynamic segment `[id]` publishes as `{id}`. + const segment = entry.replace(/^\[(.+)\]$/, "{$1}"); + found.push(...collectRoutePaths(full, `${urlPrefix}/${segment}`)); + } else if (entry === "route.ts") { + found.push(urlPrefix); + } + } + return found; +} + +describe("vaccination route publication", () => { + it("publishes every vaccination route file", () => { + const expected = collectRoutePaths(ROUTE_ROOT, "/api/vaccinations").sort(); + // Self-check: the walker found the tree (an empty set proves nothing). + expect(expected.length).toBeGreaterThanOrEqual(6); + const published = Object.keys(vaccinationPaths).sort(); + const missing = expected.filter((p) => !published.includes(p)); + expect( + missing, + "vaccination route files without an OpenAPI path entry — register them in src/lib/openapi/routes/vaccinations.ts and run pnpm openapi:generate", + ).toEqual([]); + }); + + it("publishes the booster mint and the upload suggestion with their verbs", () => { + expect( + vaccinationPaths["/api/vaccinations/{id}/booster"]?.post, + ).toBeDefined(); + expect(vaccinationPaths["/api/vaccinations/suggest"]?.get).toBeDefined(); + }); +}); diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index aa9a71c61..0017741fc 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -27,7 +27,10 @@ import { vaccinationListQuerySchema, vaccinationLinkSchema, vaccinationSiteEnum, + vaccinationBoosterSchema, + vaccinationSuggestQuerySchema, } from "@/lib/validations/vaccinations"; +import { measurementReminderDto } from "@/lib/validations/measurement-reminders"; import { dataEnvelope, @@ -54,6 +57,18 @@ vaccinationListQuerySchema.meta({ "Filters for the immunization list: `antigenSlug`, `from` / `to` (ISO-8601 instants) and `limit` (1–500, default 300). The list is full and bounded rather than paged — a vaccination record is a lifetime document but a short one.", }); +vaccinationBoosterSchema.meta({ + id: "VaccinationBoosterRequest", + description: + "The confirm body for a booster reminder — the person's own values, accepted or edited from the catalogue's prefill. `intervalMonths` is bounded at 600 (a decade booster is 120); `label` is composed client-side so its locale is the person's own. There is no antigen field: the server reads the antigen from the dose's catalogue entry, never from the request, so a client cannot key a reminder onto an antigen the dose does not contain.", +}); + +vaccinationSuggestQuerySchema.meta({ + id: "VaccinationSuggestQuery", + description: + "The single input to the upload suggestion: the document's anchor date (ISO-8601 with offset). No ids — the candidate doses come from the caller's own record, narrowed from the session.", +}); + vaccinationLinkSchema.meta({ id: "VaccinationLinkRequest", description: @@ -160,6 +175,37 @@ const vaccinationList = z.object({ vaccinations: z.array(vaccination) }).meta({ "The caller's immunization history, newest dose first, with every entry's series already resolved over the whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever given.", }); +const vaccinationSuggestion = z + .object({ + id: z.string(), + occurredAt: z.string(), + antigenSlug: z.string().nullable(), + vaccineName: z.string().nullable(), + }) + .meta({ + id: "VaccinationSuggestion", + description: + "One candidate dose, resolved enough to render without a second round trip: `antigenSlug` when the dose carries a catalogue slug (name it from the catalogue), `vaccineName` when the person's own wording is all the dose has.", + }); + +const vaccinationSuggestResult = z + .union([ + z.object({ + kind: z.literal("one"), + vaccination: vaccinationSuggestion, + }), + z.object({ + kind: z.literal("many"), + vaccinations: z.array(vaccinationSuggestion), + }), + z.object({ kind: z.literal("none") }), + ]) + .meta({ + id: "VaccinationSuggestResult", + description: + "The SHAPE of the answer, decided server-side: `one` pre-selects that dose, `many` offers a picker over `vaccinations` (bounded at 20 candidates), `none` offers nothing. A client renders the verdict and must not collapse `many` into a silent auto-pick — the rule lives on the server so every client agrees.", + }); + const vaccinationNotFound = { "404": { description: "Vaccination not found (or owned by another user).", @@ -304,6 +350,76 @@ export const vaccinationPaths: NonNullable = { }, }, }, + "/api/vaccinations/{id}/booster": { + post: { + tags: ["Records"], + summary: "Plan a booster reminder from a dose", + description: + "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.", + requestParams: idPath, + requestBody: { + required: true, + content: { "application/json": { schema: vaccinationBoosterSchema } }, + }, + responses: { + ...recordRefusal(), + "200": { + description: "Existing booster reminder re-anchored.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + reminder: measurementReminderDto, + minted: z.boolean(), + }), + "ReanchorVaccinationBoosterEnvelope", + ), + }, + }, + }, + "201": { + description: "Booster reminder minted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + reminder: measurementReminderDto, + minted: z.boolean(), + }), + "MintVaccinationBoosterEnvelope", + ), + }, + }, + }, + ...vaccinationNotFound, + ...stdResponses, + }, + }, + }, + "/api/vaccinations/suggest": { + get: { + tags: ["Records"], + summary: "Suggest the dose a document belongs to", + description: + "Which dose does a document dated `anchor` most plausibly belong to? The document upload review asks this when a scan is classified `VACCINATION`. The window (±7 days, shared with the visit moment) and the one-pre-selects / many-offer-a-picker verdict are decided server-side so the browser never re-derives them. Owner-scoped; the anchor is the only input and it is a date, not an id.", + requestParams: { query: vaccinationSuggestQuerySchema }, + responses: { + ...recordRefusal(), + "200": { + description: "The suggestion verdict.", + content: { + "application/json": { + schema: dataEnvelope( + vaccinationSuggestResult, + "VaccinationSuggestEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/vaccinations/{id}/links": { post: { tags: ["Records"],