mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
cb86b24c0d
commit
0cec8b07bd
3 files changed
+390
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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"],
|
||||
|
||||
Reference in new issue
Block a user