feat(vaccinations): wire the query keys, the contract and the consent copy

- a query-key module and a dependent bundle that also evicts the
  preventive-care root, because logging a dose settles a booster and the
  checkups list goes stale in the same tick
- the OpenAPI module publishes the resolved series with the contract
  written into the description: render text from these numbers, never
  recompute them
- the health-background consent sentence now names the immunization
  history in all six languages, so the widening is stated where it is
  agreed to rather than inferred from a code comment
- no server-side cache helper: nothing cached reads a dose, and a sweep
  over caches nobody reads would look like coverage without being it
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 14:43:59 +02:00
1 parent 82982038cb
commit b97988279e
12 files changed
+1309 -40

No files matched your search

+866 -32
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -10031,7 +10031,7 @@
},
"profile": {
"label": "Gesundheitliche Vorgeschichte",
"detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil sowie Arztbesuche und Praxen."
"detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil, Arztbesuche und Praxen sowie deine Impfungen."
},
"illness": {
"label": "Krankheiten",
+1 -1
View File
@@ -10031,7 +10031,7 @@
},
"profile": {
"label": "Health background",
"detail": "Allergies, family history, the facts in your health profile, and your visits and practices."
"detail": "Allergies, family history, the facts in your health profile, your visits and practices, and your vaccination record."
},
"illness": {
"label": "Illness",
+1 -1
View File
@@ -10031,7 +10031,7 @@
},
"profile": {
"label": "Antecedentes de salud",
"detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud y tus visitas médicas y consultas."
"detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud, tus visitas médicas y consultas, y tu cartilla de vacunación."
},
"illness": {
"label": "Enfermedades",
+1 -1
View File
@@ -10031,7 +10031,7 @@
},
"profile": {
"label": "Antécédents de santé",
"detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, ainsi que vos consultations et cabinets."
"detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, vos consultations et cabinets, ainsi que votre carnet de vaccination."
},
"illness": {
"label": "Maladies",
+1 -1
View File
@@ -10031,7 +10031,7 @@
},
"profile": {
"label": "Anamnesi",
"detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario e le tue visite mediche e studi."
"detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario, le tue visite mediche e studi, e il tuo libretto vaccinale."
},
"illness": {
"label": "Malattie",
+1 -1
View File
@@ -10031,7 +10031,7 @@
},
"profile": {
"label": "Historia zdrowia",
"detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego oraz wizyty lekarskie i gabinety."
"detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego, wizyty lekarskie i gabinety oraz Twoja historia szczepień."
},
"illness": {
"label": "Choroby",
+2
View File
@@ -49,6 +49,7 @@ import { exportPaths } from "./export";
import { familyHistoryPaths } from "./family-history";
import { healthRecordPaths } from "./health-record";
import { encounterPaths } from "./encounters";
import { vaccinationPaths } from "./vaccinations";
import { illnessPaths } from "./illness";
import { importPaths } from "./import";
import { insightsPaths } from "./insights";
@@ -104,6 +105,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...ocrPaths,
...biomarkerPaths,
...encounterPaths,
...vaccinationPaths,
...illnessPaths,
...mentalHealthPaths,
...allergyPaths,
+367
View File
@@ -0,0 +1,367 @@
/**
* OpenAPI route table for the immunization log (`/api/vaccinations`).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies
* and queries reuse the runtime Zod schemas so the wire contract stays
* single-source. Response shapes are declared here to mirror the
* server-authoritative DTOs.
*
* The one contract worth reading twice is `series`. "Dose 3 of 3" is a
* property of a person's whole history for an antigen, not of the row it is
* printed beside, so it is derived once on the server and published as
* resolved numbers. A client renders text from those numbers. It must never
* recompute them, and it is not given the input it would need to try.
*
* Note what is NOT here: the reminder contract does not move. Logging a dose
* satisfies the booster reminders it answers, and that happens through the
* ordinary preventive-care engine — same endpoints, same shape, same
* two-member `origin` enum. The match key that connects them is a server-side
* column and reaches no response.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
vaccinationCreateSchema,
vaccinationUpdateSchema,
vaccinationListQuerySchema,
vaccinationLinkSchema,
vaccinationSiteEnum,
} from "@/lib/validations/vaccinations";
import {
dataEnvelope,
errorEnvelope,
recordRefusal,
stdResponses,
} from "./shared";
vaccinationCreateSchema.meta({
id: "CreateVaccinationRequest",
description:
"Log one administered dose. `occurredAt` is required and must not be in the future — a vaccination is recorded after the fact, and a planned booster is a reminder rather than a record. Exactly one identity arm is required: either `antigenSlug` (a slug the catalogue offers) or `vaccineName` (whatever the person's own record says, verbatim, which may be a trade name). Everything else is optional, so a decades-old paper entry with nothing but a date and a name is loggable. `note` is encrypted at rest. `documentIds` pre-links the scanned pages; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save. Logging a dose satisfies any booster reminder keyed to one of its component antigens.",
});
vaccinationUpdateSchema.meta({
id: "UpdateVaccinationRequest",
description:
"Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. `occurredAt` is editable because a transcription typo is the common case — and editing it deliberately does NOT re-run the booster satisfaction, so correcting a date can never move a reminder's due date. An edit that would leave the record with neither identity arm is refused. A present `documentIds` array replaces the links, empty array included.",
});
vaccinationListQuerySchema.meta({
id: "ListVaccinationsQuery",
description:
"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.",
});
vaccinationLinkSchema.meta({
id: "VaccinationLinkRequest",
description:
"Documents to file against, or unfile from, a dose. Idempotent in both directions and capped at 100 ids. Ids naming nothing the caller owns come back in `unknown` rather than failing the request.",
});
const catalogEntry = z
.object({
slug: z.string(),
atc: z.string(),
category: z.string(),
})
.meta({
id: "VaccinationCatalogEntry",
description:
"What the catalogue this release ships knows about the record's `antigenSlug`: the slug itself, its WHO ATC code from the J07 subtree, and its picker grouping. Null when the slug does not resolve — a slug written by an older release, or one restored verbatim from an older backup. Null is the signal to render `vaccineName` instead; it is not an error and not missing data.",
});
const seriesPosition = z
.object({
antigen: z.string(),
position: z.number().int(),
total: z.number().int().nullable(),
booster: z.boolean(),
})
.meta({
id: "VaccinationSeriesPosition",
description:
"Where this dose sits in ONE antigen's series, derived server-side over the whole history. `position` is the person's own recorded dose number when they gave one, otherwise the chronological index among their doses for that antigen. `total` is the record's own series length, then the catalogue's typical one, then null when neither knows — a seasonal vaccine has no 'of M'. `booster` is true once the series is complete and this dose comes after it. Render text from these values: both resolved reads as 'N of M', position alone as 'dose N', `booster` as a booster. Do not recompute them — a client cannot see the whole history and the two would drift.",
});
const vaccinationDocument = z
.object({
id: z.string(),
label: z.string().nullable(),
date: z.string().nullable(),
redacted: z.boolean(),
})
.meta({
id: "VaccinationLinkedDocument",
description:
"One page filed against a dose, resolved. The label and the date are computed server-side from the document itself, so the client never re-derives them. Both go null together, with `redacted: true`, when the caller's grant does not cover the document vault: a dose lives in the health background but points into the vault, and a page's filename is itself the sensitive part. The link is still listed — the caller may know the dose was transcribed from a page — and `redacted` tells a withheld name apart from a page that simply has no date.",
});
const vaccinationEncounter = z
.object({
id: z.string(),
occurredAt: z.string(),
kind: z.string(),
})
.meta({
id: "VaccinationEncounterStub",
description:
"The visit the dose was given at, enough to recognise it by. Null when no visit is linked.",
});
const practitioner = z
.object({
id: z.string(),
name: z.string(),
specialty: z.string().nullable(),
practice: z.string().nullable(),
location: z.string().nullable(),
phone: z.string().nullable(),
note: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "VaccinationPractitioner",
description:
"The practice that administered the dose, resolved rather than an id. Null when the record names none.",
});
const vaccination = z
.object({
id: z.string(),
occurredAt: z.string(),
antigenSlug: z.string().nullable(),
vaccineName: z.string().nullable(),
doseNumber: z.number().int().nullable(),
seriesDoses: z.number().int().nullable(),
lotNumber: z.string().nullable(),
site: vaccinationSiteEnum.nullable(),
catalogEntry: catalogEntry.nullable(),
series: z.array(seriesPosition),
practitioner: practitioner.nullable(),
encounter: vaccinationEncounter.nullable(),
reminderId: z.string().nullable(),
note: z.string().nullable(),
documents: z.array(vaccinationDocument).optional(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "Vaccination",
description:
"One administered dose. `occurredAt` is a day at UTC midnight — the source record carries dates, never times. `series` carries one entry per component antigen, so a combined preparation reports several at once and each may be at a different position: a dose can be a booster for one antigen and a first dose for another in the same act. An empty `series` means the dose has no resolvable antigen (free text only, or a slug this release does not know) and nothing is guessed from the name. `note` is the decrypted free text, or null on a key-rotation gap — fail-soft, never a 500. `reminderId` names the booster reminder this dose settled, when one matched. `documents` is present on the detail response and absent from the list.",
});
const vaccinationList = z.object({ vaccinations: z.array(vaccination) }).meta({
id: "VaccinationList",
description:
"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 vaccinationNotFound = {
"404": {
description: "Vaccination not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
const idPath = { path: z.object({ id: z.string() }) };
export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/vaccinations": {
get: {
tags: ["Records"],
summary: "List vaccinations",
description:
"Returns the caller's live doses, newest first, each with its series resolved per component antigen. Soft-deleted doses are excluded.",
requestParams: { query: vaccinationListQuerySchema },
responses: {
...recordRefusal(),
"200": {
description: "The caller's immunization history.",
content: {
"application/json": {
schema: dataEnvelope(vaccinationList, "ListVaccinationsEnvelope"),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Records"],
summary: "Log a vaccination",
description:
"Records one administered dose and satisfies every live booster reminder keyed to one of its component antigens — so a single combined dose settles each of the antigens it contains. Satisfaction is forward-only: a dose older than a reminder's last satisfaction is a no-op, which is what makes back-entering an old paper record safe. Honours `Idempotency-Key`. Audits as `vaccination.record.create`.",
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationCreateSchema } },
},
responses: {
...recordRefusal(),
"201": {
description: "Dose logged.",
content: {
"application/json": {
schema: dataEnvelope(vaccination, "CreateVaccinationEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/vaccinations/{id}": {
get: {
tags: ["Records"],
summary: "Read a single vaccination",
description:
"Returns the dose with its linked pages resolved. Owner-scoped; a cross-user or tombstoned id 404s.",
requestParams: idPath,
responses: {
...recordRefusal(),
"200": {
description: "The dose.",
content: {
"application/json": {
schema: dataEnvelope(vaccination, "GetVaccinationEnvelope"),
},
},
},
...vaccinationNotFound,
...stdResponses,
},
},
patch: {
tags: ["Records"],
summary: "Edit a vaccination",
description:
"Partial edit; omitted fields are left untouched. Never re-runs booster satisfaction. Audits as `vaccination.record.update`. Owner-scoped.",
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationUpdateSchema } },
},
responses: {
...recordRefusal(),
"200": {
description: "Dose updated.",
content: {
"application/json": {
schema: dataEnvelope(vaccination, "UpdateVaccinationEnvelope"),
},
},
},
...vaccinationNotFound,
...stdResponses,
},
},
delete: {
tags: ["Records"],
summary: "Soft-delete a vaccination",
description:
"Stamps `deletedAt`. A booster reminder the dose once satisfied is deliberately not rewound: the reminder belongs to the preventive-care list and outlives the record, and tidying a transcription is not evidence the dose never happened. Idempotent. Audits as `vaccination.record.delete`.",
requestParams: idPath,
responses: {
...recordRefusal(),
"200": {
description: "Soft-deleted.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"DeleteVaccinationEnvelope",
),
},
},
},
...vaccinationNotFound,
...stdResponses,
},
},
},
"/api/vaccinations/{id}/restore": {
post: {
tags: ["Records"],
summary: "Restore a soft-deleted vaccination",
description:
"Clears the tombstone. The link rows survived the soft delete, so the dose returns with its pages still filed against it. Restoring a live dose is a no-op that still succeeds.",
requestParams: idPath,
responses: {
...recordRefusal(),
"200": {
description: "Dose restored.",
content: {
"application/json": {
schema: dataEnvelope(vaccination, "RestoreVaccinationEnvelope"),
},
},
},
...vaccinationNotFound,
...stdResponses,
},
},
},
"/api/vaccinations/{id}/links": {
post: {
tags: ["Records"],
summary: "File documents against a vaccination",
description:
"Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document and visit link and unlink.",
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationLinkSchema } },
},
responses: {
"200": {
description: "The dose's linked pages after the change.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
documents: z.array(vaccinationDocument),
unknown: z.array(z.string()),
}),
"LinkVaccinationEnvelope",
),
},
},
},
...vaccinationNotFound,
...stdResponses,
},
},
delete: {
tags: ["Records"],
summary: "Unfile documents from a vaccination",
description:
"Removes links. Idempotent, and it still works for a page the account has since deleted — refusing would leave a filing nobody can clear.",
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationLinkSchema } },
},
responses: {
"200": {
description: "The dose's linked pages after the change.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
documents: z.array(vaccinationDocument),
unknown: z.array(z.string()),
}),
"UnlinkVaccinationEnvelope",
),
},
},
},
...vaccinationNotFound,
...stdResponses,
},
},
},
};
+16
View File
@@ -40,6 +40,7 @@ import { recordSettingsKeys } from "./record-settings";
import { profileKeys } from "./profile";
import { settingsKeys } from "./settings";
import { sharingKeys } from "./sharing";
import { vaccinationKeys } from "./vaccinations";
import { workoutKeys } from "./workouts";
export const queryKeys = {
@@ -70,6 +71,7 @@ export const queryKeys = {
...recordSettingsKeys,
...profileKeys,
...sharingKeys,
...vaccinationKeys,
};
export { recordSettingsKeys } from "./record-settings";
@@ -252,6 +254,20 @@ export const encounterDependentKeys = [
queryKeys.practitioners(),
];
/**
* Keys invalidated when a dose changes.
*
* The preventive-care root rides along, and it is the whole reason this is a
* bundle rather than one key: logging a dose satisfies the booster reminders
* it answers, so the checkups list is stale the moment the dose saves.
* Evicting only the immunization list would leave a booster on screen that
* the server had already moved a decade out.
*/
export const vaccinationDependentKeys = [
queryKeys.vaccinations(),
queryKeys.measurementReminders(),
];
/**
* Invalidate every key in the bundle in parallel. Use this from mutation
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
+42
View File
@@ -0,0 +1,42 @@
/**
* Query keys — the immunization log.
* Part of the centralized factory; aggregated in `./index.ts`.
*
* These exist before any client reads them, because the in-repo
* `healthlog/queryKey-factory` rule errors on a bare array: the first
* component that lists doses cannot compile without a slot here. Same key with
* a different `queryFn` shape silently poisons the cache, which is the whole
* reason the factory is the only legal source.
*
* There is deliberately no matching `invalidateUserVaccinations` in
* `src/lib/cache/invalidate.ts`, for the same reason the visits module next
* door has none. That module evicts SERVER-side cached payloads, and no cached
* payload reads a dose: the dashboard snapshot, the insights targets and the
* analytics buckets are computed from measurements, medications and mood.
* Adding a sweep over caches nothing reads would be a placeholder that looks
* like coverage. It belongs in the release that puts doses into one of those
* payloads, alongside the reader that makes it necessary.
*
* The one server-side cache a dose CAN move belongs to somebody else: logging
* one satisfies a booster reminder, which is a preventive-care write. That
* eviction is the reminder module's, and the client bundle below invalidates
* the reminder keys for the same reason.
*/
export const vaccinationKeys = {
/**
* The root prefix every dose write invalidates through
* (`vaccinationDependentKeys` in `./index.ts`). TanStack's hierarchical
* prefix semantics mean evicting `["vaccinations"]` clears the list and
* every open detail in one tick, so a dose edited in a sheet and the list
* behind it never disagree.
*/
vaccinations: () => ["vaccinations"] as const,
/**
* The list. The filter is part of the key: a page showing one antigen's
* history and the page showing everything are two different answers rather
* than one answer that overwrites the other.
*/
vaccinationList: (antigenSlug?: string | null) =>
["vaccinations", "list", antigenSlug ?? null] as const,
vaccination: (id: string) => ["vaccinations", "detail", id] as const,
} as const;
+10 -2
View File
@@ -47,8 +47,8 @@ export const SHARE_DOMAINS = [
/** Lab results and the biomarker catalogue behind them. */
"labs",
/**
* Health background: allergies, family history, anamnesis facts, and visits
* and practitioners.
* Health background: allergies, family history, anamnesis facts, visits and
* practitioners, and the immunization history.
*
* Visits joined this domain rather than getting one of their own, and the
* cost is worth naming: somebody granted `profile` so a relative could see
@@ -56,6 +56,14 @@ export const SHARE_DOMAINS = [
* book behind it. The mitigation is not this comment — it is the consent
* copy, which names visits in all six locales, so the widening is stated
* where it is agreed to rather than inferred from the code.
*
* The immunization log joined on the same argument and carries the same
* cost, stated the same way: a delegate holding this domain for the allergy
* list also sees every dose the person has had, and the consent copy names
* it. Vaccinations are a tracked clinical vertical with their own module
* toggle, which is a separate question from consent — a module the owner
* enabled is not a section the owner shared, and a person granting health
* background is granting the section whether or not the surfaces are on.
*/
"profile",
/** Illness episodes and the day logs under them. */