Let a record define its own vaccines

A dose can now name a vaccine the catalogue does not list. The person
defines it once (a name, the diseases it protects against from the
catalogue's antigen list, the series length and the booster interval) and
logs doses against it; such a dose counts into each listed antigen's
series, settles a booster reminder keyed to one of them and offers a
booster, the way a catalogue combination does.

One resolver (resolve-vaccine-entry.ts) now answers for every consumer
that asked the catalogue directly: the series, the satisfy matcher, the
booster mint, the dose DTO, the doctor report, document links, the
upload suggestion, MCP search and the client's info sentences and
booster offer. The catalogue wins when a dose carries both.

The custom routes resolve the record through requireRecordAuth (list at
READ, create at WRITE with the actor-keyed write bucket, edit and remove
at MANAGE with the C4 overwrite audit) and join the sharing guard, the
MANAGE matrix and the activity feed. Removing a definition tombstones it
and lets go of its doses, each keeping a name. Definitions travel in the
backup and restore before the doses that name them; the dose form picks
them, adds one without leaving the form, and the page lists them for
editing. With the last contract stub implemented, the 501 helper and the
not_implemented code leave the tree.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 13:00:37 +02:00
1 parent 6ab72aec3e
commit b299904552
68 files changed
+3326 -521

No files matched your search

+369 -226
View File
File diff suppressed because it is too large. Load diff
+15 -4
View File
@@ -9611,8 +9611,8 @@
},
"form": {
"date": "Datum",
"catalogLabel": "Impfstoff (Katalog)",
"identityHint": "Katalog wählen oder unten frei eintragen — eins genügt.",
"catalogLabel": "Impfstoff",
"identityHint": "Aus der Liste wählen oder unten frei eintragen. Eins genügt.",
"catalogNone": "Impfstoff wählen",
"catalogSearch": "Impfstoff suchen",
"catalogNoMatch": "Kein Katalogeintrag gefunden",
@@ -9744,7 +9744,15 @@
"deleted": "Impfstoff entfernt",
"deleteConfirm": "Diesen Impfstoff entfernen? Bereits eingetragene Impfungen behalten ihren Namen.",
"saveFailed": "Der Impfstoff konnte nicht gespeichert werden.",
"nameTaken": "Du hast schon einen Impfstoff mit diesem Namen."
"nameTaken": "Du hast schon einen Impfstoff mit diesem Namen.",
"editTitle": "Eigenen Impfstoff bearbeiten",
"description": "Ein Impfstoff, den der Katalog nicht kennt, gezählt wie jeder Katalogeintrag.",
"componentsHint": "Alle Krankheiten wählen, vor denen er schützt.",
"boosterHint": "Leer lassen, wenn keine Auffrischung nötig ist.",
"protectsAgainst": "Schützt vor {list}.",
"deleteTitle": "Eigenen Impfstoff entfernen?",
"sectionDescription": "Selbst angelegte Impfstoffe, die der Katalog nicht kennt.",
"catalogGroup": "Katalog"
}
},
"illness": {
@@ -11786,7 +11794,10 @@
"symptomEventDelete": "{name} hat einen Symptom-Eintrag gelöscht",
"symptomDefinitionCreate": "{name} hat ein Symptom angelegt",
"symptomDefinitionUpdate": "{name} hat ein Symptom geändert",
"symptomDefinitionDelete": "{name} hat ein Symptom entfernt"
"symptomDefinitionDelete": "{name} hat ein Symptom entfernt",
"customVaccineCreate": "{name} hat einen eigenen Impfstoff angelegt",
"customVaccineUpdate": "{name} hat einen eigenen Impfstoff bearbeitet",
"customVaccineDelete": "{name} hat einen eigenen Impfstoff entfernt"
},
"section": {
"measurements": {
+15 -4
View File
@@ -9611,8 +9611,8 @@
},
"form": {
"date": "Date",
"catalogLabel": "Vaccine (catalogue)",
"identityHint": "Pick from the catalogue or type it in below — either is enough.",
"catalogLabel": "Vaccine",
"identityHint": "Pick a vaccine from the list or type it in below. Either is enough.",
"catalogNone": "Choose a vaccine",
"catalogSearch": "Search vaccines",
"catalogNoMatch": "No catalogue entry found",
@@ -9744,7 +9744,15 @@
"deleted": "Vaccine removed",
"deleteConfirm": "Remove this vaccine? Doses already logged keep their name.",
"saveFailed": "The vaccine could not be saved.",
"nameTaken": "You already have a vaccine with this name."
"nameTaken": "You already have a vaccine with this name.",
"editTitle": "Edit own vaccine",
"description": "A vaccine the catalogue does not list, counted like any catalogue entry.",
"componentsHint": "Choose every disease it protects against.",
"boosterHint": "Leave empty if there is no booster.",
"protectsAgainst": "Protects against {list}.",
"deleteTitle": "Remove own vaccine?",
"sectionDescription": "Vaccines you added yourself because the catalogue does not list them.",
"catalogGroup": "Catalogue"
}
},
"illness": {
@@ -11786,7 +11794,10 @@
"symptomEventDelete": "{name} deleted a symptom entry",
"symptomDefinitionCreate": "{name} added a symptom",
"symptomDefinitionUpdate": "{name} changed a symptom",
"symptomDefinitionDelete": "{name} removed a symptom"
"symptomDefinitionDelete": "{name} removed a symptom",
"customVaccineCreate": "{name} added an own vaccine",
"customVaccineUpdate": "{name} edited an own vaccine",
"customVaccineDelete": "{name} removed an own vaccine"
},
"section": {
"measurements": {
+15 -4
View File
@@ -9611,8 +9611,8 @@
},
"form": {
"date": "Fecha",
"catalogLabel": "Vacuna (catálogo)",
"identityHint": "Elige del catálogo o escríbela abajo: basta con una.",
"catalogLabel": "Vacuna",
"identityHint": "Elige una vacuna de la lista o escríbela abajo. Basta con una.",
"catalogNone": "Elegir una vacuna",
"catalogSearch": "Buscar vacunas",
"catalogNoMatch": "No se encontró ninguna entrada del catálogo",
@@ -9744,7 +9744,15 @@
"deleted": "Vacuna eliminada",
"deleteConfirm": "¿Eliminar esta vacuna? Las dosis ya registradas conservan su nombre.",
"saveFailed": "No se pudo guardar la vacuna.",
"nameTaken": "Ya tienes una vacuna con este nombre."
"nameTaken": "Ya tienes una vacuna con este nombre.",
"editTitle": "Editar vacuna propia",
"description": "Una vacuna que el catálogo no recoge, contada como cualquier entrada del catálogo.",
"componentsHint": "Elige todas las enfermedades de las que protege.",
"boosterHint": "Déjalo vacío si no hay refuerzo.",
"protectsAgainst": "Protege contra {list}.",
"deleteTitle": "¿Quitar la vacuna propia?",
"sectionDescription": "Vacunas que añadiste tú porque el catálogo no las recoge.",
"catalogGroup": "Catálogo"
}
},
"illness": {
@@ -11786,7 +11794,10 @@
"symptomEventDelete": "{name} eliminó una entrada de síntoma",
"symptomDefinitionCreate": "{name} añadió un síntoma",
"symptomDefinitionUpdate": "{name} cambió un síntoma",
"symptomDefinitionDelete": "{name} quitó un síntoma"
"symptomDefinitionDelete": "{name} quitó un síntoma",
"customVaccineCreate": "{name} añadió una vacuna propia",
"customVaccineUpdate": "{name} editó una vacuna propia",
"customVaccineDelete": "{name} quitó una vacuna propia"
},
"section": {
"measurements": {
+15 -4
View File
@@ -9611,8 +9611,8 @@
},
"form": {
"date": "Date",
"catalogLabel": "Vaccin (catalogue)",
"identityHint": "Choisissez dans le catalogue ou saisissez ci-dessous : l'un suffit.",
"catalogLabel": "Vaccin",
"identityHint": "Choisissez un vaccin dans la liste ou saisissez-le ci-dessous. L'un suffit.",
"catalogNone": "Choisir un vaccin",
"catalogSearch": "Rechercher des vaccins",
"catalogNoMatch": "Aucune entrée du catalogue trouvée",
@@ -9744,7 +9744,15 @@
"deleted": "Vaccin supprimé",
"deleteConfirm": "Supprimer ce vaccin ? Les doses déjà saisies gardent leur nom.",
"saveFailed": "Le vaccin n’a pas pu être enregistré.",
"nameTaken": "Tu as déjà un vaccin portant ce nom."
"nameTaken": "Tu as déjà un vaccin portant ce nom.",
"editTitle": "Modifier le vaccin personnel",
"description": "Un vaccin absent du catalogue, compté comme n'importe quelle entrée du catalogue.",
"componentsHint": "Choisissez chaque maladie contre laquelle il protège.",
"boosterHint": "Laissez vide s'il n'y a pas de rappel.",
"protectsAgainst": "Protège contre {list}.",
"deleteTitle": "Retirer ce vaccin personnel ?",
"sectionDescription": "Les vaccins que vous avez ajoutés parce que le catalogue ne les connaît pas.",
"catalogGroup": "Catalogue"
}
},
"illness": {
@@ -11786,7 +11794,10 @@
"symptomEventDelete": "{name} a supprimé une entrée de symptôme",
"symptomDefinitionCreate": "{name} a ajouté un symptôme",
"symptomDefinitionUpdate": "{name} a modifié un symptôme",
"symptomDefinitionDelete": "{name} a retiré un symptôme"
"symptomDefinitionDelete": "{name} a retiré un symptôme",
"customVaccineCreate": "{name} a ajouté un vaccin personnel",
"customVaccineUpdate": "{name} a modifié un vaccin personnel",
"customVaccineDelete": "{name} a retiré un vaccin personnel"
},
"section": {
"measurements": {
+15 -4
View File
@@ -9611,8 +9611,8 @@
},
"form": {
"date": "Data",
"catalogLabel": "Vaccino (catalogo)",
"identityHint": "Scegli dal catalogo o scrivilo sotto: ne basta uno.",
"catalogLabel": "Vaccino",
"identityHint": "Scegli un vaccino dall'elenco o scrivilo sotto. Ne basta uno.",
"catalogNone": "Scegli un vaccino",
"catalogSearch": "Cerca vaccini",
"catalogNoMatch": "Nessuna voce del catalogo trovata",
@@ -9744,7 +9744,15 @@
"deleted": "Vaccino rimosso",
"deleteConfirm": "Rimuovere questo vaccino? Le dosi già registrate mantengono il loro nome.",
"saveFailed": "Impossibile salvare il vaccino.",
"nameTaken": "Hai già un vaccino con questo nome."
"nameTaken": "Hai già un vaccino con questo nome.",
"editTitle": "Modifica vaccino personale",
"description": "Un vaccino assente dal catalogo, contato come qualsiasi voce del catalogo.",
"componentsHint": "Scegli tutte le malattie da cui protegge.",
"boosterHint": "Lascia vuoto se non serve un richiamo.",
"protectsAgainst": "Protegge da {list}.",
"deleteTitle": "Rimuovere il vaccino personale?",
"sectionDescription": "Vaccini aggiunti da te perché il catalogo non li elenca.",
"catalogGroup": "Catalogo"
}
},
"illness": {
@@ -11786,7 +11794,10 @@
"symptomEventDelete": "{name} ha eliminato una voce di sintomo",
"symptomDefinitionCreate": "{name} ha aggiunto un sintomo",
"symptomDefinitionUpdate": "{name} ha modificato un sintomo",
"symptomDefinitionDelete": "{name} ha rimosso un sintomo"
"symptomDefinitionDelete": "{name} ha rimosso un sintomo",
"customVaccineCreate": "{name} ha aggiunto un vaccino personale",
"customVaccineUpdate": "{name} ha modificato un vaccino personale",
"customVaccineDelete": "{name} ha rimosso un vaccino personale"
},
"section": {
"measurements": {
+14 -3
View File
@@ -9611,7 +9611,7 @@
},
"form": {
"date": "날짜",
"catalogLabel": "백신 (목록)",
"catalogLabel": "백신",
"identityHint": "목록에서 고르거나 아래에 직접 입력해 주세요. 둘 중 하나면 충분해요.",
"catalogNone": "백신 선택",
"catalogSearch": "백신 검색",
@@ -9744,7 +9744,15 @@
"deleted": "백신을 삭제했습니다",
"deleteConfirm": "이 백신을 삭제할까요? 이미 기록된 접종은 이름이 그대로 남습니다.",
"saveFailed": "백신을 저장하지 못했습니다.",
"nameTaken": "같은 이름의 백신이 이미 있습니다."
"nameTaken": "같은 이름의 백신이 이미 있습니다.",
"editTitle": "직접 추가한 백신 수정",
"description": "목록에 없는 백신도 목록의 백신과 똑같이 계산돼요.",
"componentsHint": "예방하는 질병을 모두 골라 주세요.",
"boosterHint": "추가 접종이 없으면 비워 두세요.",
"protectsAgainst": "{list} 예방",
"deleteTitle": "직접 추가한 백신을 삭제할까요?",
"sectionDescription": "목록에 없어서 직접 추가한 백신이에요.",
"catalogGroup": "목록"
}
},
"illness": {
@@ -11786,7 +11794,10 @@
"symptomEventDelete": "{name}님이 증상 기록을 삭제했습니다",
"symptomDefinitionCreate": "{name}님이 증상을 추가했습니다",
"symptomDefinitionUpdate": "{name}님이 증상을 변경했습니다",
"symptomDefinitionDelete": "{name}님이 증상을 제거했습니다"
"symptomDefinitionDelete": "{name}님이 증상을 제거했습니다",
"customVaccineCreate": "{name} 님이 백신을 직접 추가했어요",
"customVaccineUpdate": "{name} 님이 직접 추가한 백신을 수정했어요",
"customVaccineDelete": "{name} 님이 직접 추가한 백신을 삭제했어요"
},
"section": {
"measurements": {
+15 -4
View File
@@ -9611,8 +9611,8 @@
},
"form": {
"date": "Data",
"catalogLabel": "Szczepionka (katalog)",
"identityHint": "Wybierz z katalogu lub wpisz poniżej — wystarczy jedno.",
"catalogLabel": "Szczepionka",
"identityHint": "Wybierz szczepionkę z listy lub wpisz ją poniżej. Wystarczy jedno.",
"catalogNone": "Wybierz szczepionkę",
"catalogSearch": "Szukaj szczepionek",
"catalogNoMatch": "Nie znaleziono wpisu w katalogu",
@@ -9744,7 +9744,15 @@
"deleted": "Szczepionka usunięta",
"deleteConfirm": "Usunąć tę szczepionkę? Zapisane już dawki zachowają swoją nazwę.",
"saveFailed": "Nie udało się zapisać szczepionki.",
"nameTaken": "Masz już szczepionkę o tej nazwie."
"nameTaken": "Masz już szczepionkę o tej nazwie.",
"editTitle": "Edytuj własną szczepionkę",
"description": "Szczepionka spoza katalogu, liczona jak każdy wpis z katalogu.",
"componentsHint": "Wybierz wszystkie choroby, przed którymi chroni.",
"boosterHint": "Zostaw puste, jeśli nie ma dawki przypominającej.",
"protectsAgainst": "Chroni przed: {list}.",
"deleteTitle": "Usunąć własną szczepionkę?",
"sectionDescription": "Szczepionki dodane przez Ciebie, bo nie ma ich w katalogu.",
"catalogGroup": "Katalog"
}
},
"illness": {
@@ -11786,7 +11794,10 @@
"symptomEventDelete": "{name} usunął(-ęła) wpis objawu",
"symptomDefinitionCreate": "{name} dodał(a) objaw",
"symptomDefinitionUpdate": "{name} zmienił(a) objaw",
"symptomDefinitionDelete": "{name} usunął(-ęła) objaw"
"symptomDefinitionDelete": "{name} usunął(-ęła) objaw",
"customVaccineCreate": "{name} dodał własną szczepionkę",
"customVaccineUpdate": "{name} zmienił własną szczepionkę",
"customVaccineDelete": "{name} usunął własną szczepionkę"
},
"section": {
"measurements": {
+10 -7
View File
@@ -9330,9 +9330,11 @@ model VaccinationRecord {
/// `lastSatisfiedAt` per reminder, this keeps the first match in catalogue
/// component order.
reminderId String? @map("reminder_id")
/// @pending-consumer(#1005) v1.42 — the user's own vaccine definition when
/// the dose is for a product the catalogue does not list. Catalogue wins
/// when both resolve. SetNull on delete: the dose keeps its `vaccineName`.
/// v1.42 (#1005) — the user's own vaccine definition when the dose is for a
/// product the catalogue does not list, resolved through
/// `src/lib/vaccinations/resolve-vaccine-entry.ts`. Catalogue wins when both
/// resolve. Removing the definition unlinks the dose and leaves it a name
/// (`removeCustomVaccine`); SetNull covers a hard delete the same way.
customVaccineId String? @map("custom_vaccine_id")
/// The one free-text field (AES-256-GCM at rest, the `*Encrypted` Bytes
/// convention). Impfreaktionen live here in v1 rather than in a structured
@@ -9365,13 +9367,14 @@ model VaccinationRecord {
model CustomVaccine {
id String @id @default(cuid())
userId String @map("user_id")
/// @pending-consumer(#1005) Display name, unique per user.
/// Display name, unique per user (the API also refuses a case-only twin).
name String
/// @pending-consumer(#1005) Antigen slugs the product covers.
/// Antigen slugs the product covers, from `ANTIGEN_SLUGS`; storage is
/// tolerant, the resolver skips a slug it does not know.
components String[]
/// @pending-consumer(#1005) Typical primary-series length.
/// Typical primary-series length; the series total for its doses.
typicalSeriesDoses Int? @map("typical_series_doses")
/// @pending-consumer(#1005) Booster interval in months; NULL = no booster.
/// Booster interval in months, the booster offer's prefill; NULL = none.
boosterIntervalMonths Int? @map("booster_interval_months")
/// Soft-delete tombstone. NULL = live.
deletedAt DateTime? @map("deleted_at")
@@ -83,7 +83,6 @@ const RELEASE_PENDING_PREFIXES: Record<string, string> = {
"insights.workouts.manual.editTitle": "v1.42 manual workout edit (#1162)",
"insights.workouts.manual.editAction": "v1.42 manual workout edit (#1162)",
"insights.workouts.manual.updated": "v1.42 manual workout edit (#1162)",
"vaccinations.custom": "v1.42 user-defined vaccines (#1005)",
"labs.convertedFrom": "v1.42 lab unit conversion note (#1095)",
};
@@ -58,6 +58,7 @@ const ON_THE_SHARED_BUCKET: Array<[route: string, openApiPath: string]> = [
],
["src/app/api/cycle/day-logs/route.ts", "/api/cycle/day-logs"],
["src/app/api/vaccinations/route.ts", "/api/vaccinations"],
["src/app/api/vaccinations/custom/route.ts", "/api/vaccinations/custom"],
["src/app/api/allergies/route.ts", "/api/allergies"],
["src/app/api/biomarkers/route.ts", "/api/biomarkers"],
["src/app/api/encounters/route.ts", "/api/encounters"],
+21 -2
View File
@@ -971,6 +971,14 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "profile",
why: "Which of the record's doses a scanned page dated around a given day belongs to. A read over the same rows the immunization list serves, reduced to a verdict — the caller learns nothing it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
},
"app/api/vaccinations/custom/route.ts": {
domain: "profile",
why: "The record's own vaccine definitions: a name, the antigens from the catalogue's closed list, a series length and a booster interval. Part of the immunization history the dose list already shows a `profile` grant, and it names no credential, no integration and no notification channel. Every row is read and written under the resolved user id. The create arm is a delegable write.",
},
"app/api/vaccinations/custom/[id]/route.ts": {
domain: "profile",
why: "One of the record's own vaccine definitions, fetch-then-guard against the resolved user. Reached only through its MANAGE arms; a definition another record holds is a 404 like any foreign id.",
},
"app/api/practitioners/route.ts": {
domain: "profile",
why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.",
@@ -1457,6 +1465,8 @@ const DELEGABLE_WRITE_ROUTES: Record<string, string> = {
"Logging a dose. Every id the body may carry — the practitioner, the visit, the pages to file it against — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's scan to another's dose. The booster reminders it clears are the RECORD's, which is correct: it is the owner's booster plan, and a helper transcribing an Impfpass is doing exactly the work that should settle it.",
"app/api/vaccinations/[id]/booster/route.ts":
"Confirming the booster reminder a dose suggests. The reminder is minted under the RECORD and keyed on the antigen the server reads from the dose's catalogue entry, never from the body, so a delegate cannot point it at an antigen the dose does not contain; a second confirmation re-anchors the one reminder rather than minting another.",
"app/api/vaccinations/custom/route.ts":
"Adding a vaccine definition to the record. It lands under the RECORD, which is correct: it is the owner's Impfpass a helper is transcribing, and the doses logged against it are the owner's. The rate bucket keys on the ACTOR, and a name the record already holds is the ordinary 409 the owner would hit themselves.",
"app/api/medications/[id]/side-effects/route.ts":
"Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.",
"app/api/medications/route.ts":
@@ -1603,6 +1613,11 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: [],
why: "Reopening a deleted dose. The document links survived the tombstone, so it comes back with its page still filed against it.",
},
"app/api/vaccinations/custom/[id]/route.ts": {
domain: "profile",
conditions: ["C4"],
why: "Correcting and removing one vaccine definition. The removal soft-deletes it and lets go of the doses that named it, each keeping a name, so no dose leaves the record; the audit row names the definition and how many doses it let go. C4 on the edit: the antigens, the series length and the booster interval are filed before and after, and a rename is named, never quoted. Neither verb re-runs the booster satisfy matcher, so a delegate cannot move a booster's due date by correcting a definition.",
},
"app/api/illness/episodes/[id]/resolve/route.ts": {
domain: "illness",
conditions: [],
@@ -2010,8 +2025,12 @@ const ACTOR_ROUTES: Record<string, string> = {
* list, the occurrence create on the write literal, and the definition create,
* the definition edit/delete and the occurrence edit/delete on the manage
* literal. 232 -> 242 with both.
*
* v1.42 -- the person's own vaccine definitions (#1005) add four: both
* modules on the record list, the create on the write literal and the
* edit/delete on the manage literal. 242 -> 246.
*/
const FROZEN_ENTRY_COUNT = 242;
const FROZEN_ENTRY_COUNT = 246;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -2772,7 +2791,7 @@ describe("(g) the MANAGE route set is frozen", () => {
it("keeps the admitted mutation inventory complete and discoverable", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(84);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87);
const expected = ADMITTED_MUTATING_HANDLERS.map(
({ handlerModule, action, level }) =>
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
});
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(84);
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(87);
expect(
new Set(
ADMITTED_MUTATING_HANDLERS.map(
@@ -50,6 +50,7 @@ vi.mock("@/lib/db", () => ({
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
customVaccine: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
// same reason as the visit tables above.
@@ -158,6 +159,7 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.customVaccine.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue(
[] as never,
);
@@ -88,6 +88,7 @@ vi.mock("@/lib/db", () => ({
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
customVaccine: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
@@ -42,6 +42,7 @@ vi.mock("@/lib/db", () => ({
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
customVaccine: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
// same reason as the visit tables above.
+31 -3
View File
@@ -34,6 +34,7 @@ import {
VACCINATION_INCLUDE,
applyVaccinationLinks,
loadVaccinationDocuments,
resolveOwnedCustomVaccine,
resolveOwnedEncounter,
resolveOwnedPractitioner,
resolveSeriesFor,
@@ -116,9 +117,13 @@ export const PATCH = apiHandler(
entry.vaccineName !== undefined
? entry.vaccineName
: existing.vaccineName;
if (!nextSlug && !nextName) {
const nextCustom =
entry.customVaccineId !== undefined
? entry.customVaccineId
: existing.customVaccineId;
if (!nextSlug && !nextName && !nextCustom) {
return apiError(
"A dose keeps a name: pick one from the catalogue or type what the record says",
"A dose keeps a name: pick one from the catalogue or your own vaccines, or type what the record says",
422,
{ errorCode: "vaccination.identity-required" },
);
@@ -141,6 +146,14 @@ export const PATCH = apiHandler(
);
if (!owned) return "unknown-encounter" as const;
}
if (entry.customVaccineId) {
const owned = await resolveOwnedCustomVaccine(
tx,
user.id,
entry.customVaccineId,
);
if (!owned) return "unknown-custom-vaccine" as const;
}
const data: Prisma.VaccinationRecordUpdateInput = {};
if (entry.occurredAt !== undefined) {
@@ -165,6 +178,11 @@ export const PATCH = apiHandler(
? { connect: { id: entry.encounterId } }
: { disconnect: true };
}
if (entry.customVaccineId !== undefined) {
data.customVaccine = entry.customVaccineId
? { connect: { id: entry.customVaccineId } }
: { disconnect: true };
}
const updated = await tx.vaccinationRecord.update({
where: { id },
@@ -184,6 +202,11 @@ export const PATCH = apiHandler(
errorCode: "vaccination.encounter-not-found",
});
}
if (outcome === "unknown-custom-vaccine") {
return apiError("Vaccine not found", 404, {
errorCode: "vaccination.custom-vaccine-not-found",
});
}
const row = await prisma.vaccinationRecord.findUniqueOrThrow({
where: { id },
@@ -209,6 +232,7 @@ export const PATCH = apiHandler(
doseNumber: existing.doseNumber,
practitionerId: existing.practitionerId,
encounterId: existing.encounterId,
customVaccineId: existing.customVaccineId,
},
after: {
occurredAt: outcome.updated.occurredAt,
@@ -216,6 +240,7 @@ export const PATCH = apiHandler(
doseNumber: outcome.updated.doseNumber,
practitionerId: outcome.updated.practitionerId,
encounterId: outcome.updated.encounterId,
customVaccineId: outcome.updated.customVaccineId,
},
}),
},
@@ -227,7 +252,10 @@ export const PATCH = apiHandler(
entity_type: "vaccination",
entity_id: id,
},
meta: { antigen_slug: row.antigenSlug ?? "free-text" },
meta: {
antigen_slug:
row.antigenSlug ?? (row.customVaccineId ? "custom" : "free-text"),
},
});
return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents));
+185 -17
View File
@@ -1,22 +1,190 @@
/**
* `PATCH /api/vaccinations/custom/[id]` edits one of the record's own vaccine
* definitions; `DELETE` soft-deletes it, leaving every dose logged against it
* on its `vaccineName` (v1.42, #1005). Record-scoped like the dose log.
* `PATCH /api/vaccinations/custom/{id}` — edit one of the record's own
* vaccine definitions.
* `DELETE /api/vaccinations/custom/{id}` — remove it (v1.42, #1005).
*
* Contract stub: owner-only until implemented, then answers 501. The
* finished routes resolve the record through `requireRecordAuth` like the
* dose log and join the sharing guard's delegable lists with their audit
* rows; a stub cannot carry those, so it does not claim the admission.
* MANAGE, classified `profile`, the same split as the dose edit and delete.
*
* An edit is a correction of the definition and applies to every dose logged
* against it: the series those doses sit in is derived on read, so changing
* the antigens or the series length re-reads them, exactly as a corrected
* catalogue entry would. It deliberately does not re-run the booster satisfy
* matcher — that belongs to the act of logging a dose, which happened once.
*
* The removal soft-deletes the definition and lets go of every dose that
* named it; each dose keeps a name (`removeCustomVaccine`). Owner-scoped by
* fetch-then-guard; `userId` is narrowed from auth and never a body field.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { NextRequest } from "next/server";
export const PATCH = apiHandler(async () => {
await requireAuth();
return notImplemented();
});
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { customVaccineUpdateSchema } from "@/lib/validations/vaccinations";
import { toCustomVaccineDTO } from "@/lib/vaccinations/dto";
import {
CUSTOM_VACCINE_SELECT,
findLiveNameClash,
removeCustomVaccine,
} from "@/lib/vaccinations/custom-vaccines";
import type { Prisma } from "@/generated/prisma/client";
export const DELETE = apiHandler(async () => {
await requireAuth();
return notImplemented();
});
type RouteParams = { params: Promise<{ id: string }> };
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.customVaccine.findUnique({ where: { id } });
if (
!existing ||
existing.userId !== user.id ||
existing.deletedAt !== null
) {
return apiError("Vaccine not found", 404, {
errorCode: "vaccination.custom.not-found",
});
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
});
if (jsonError) return jsonError;
const parsed = customVaccineUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "vaccination.custom.invalid",
});
}
const entry = parsed.data;
const outcome = await prisma.$transaction(async (tx) => {
if (
entry.name !== undefined &&
entry.name !== existing.name &&
(await findLiveNameClash(tx, user.id, entry.name, id))
) {
return "name-taken" as const;
}
// A removed definition may still hold the new name on the unique
// index. It is gone from the person's view, so it gives the name up
// rather than blocking the rename.
if (entry.name !== undefined && entry.name !== existing.name) {
await tx.customVaccine.deleteMany({
where: {
userId: user.id,
name: entry.name,
deletedAt: { not: null },
},
});
}
// Field-by-field — never spread the parsed object whole.
const data: Prisma.CustomVaccineUpdateInput = {};
if (entry.name !== undefined) data.name = entry.name;
if (entry.components !== undefined) data.components = entry.components;
if (entry.typicalSeriesDoses !== undefined) {
data.typicalSeriesDoses = entry.typicalSeriesDoses;
}
if (entry.boosterIntervalMonths !== undefined) {
data.boosterIntervalMonths = entry.boosterIntervalMonths;
}
return tx.customVaccine.update({
where: { id },
data,
select: CUSTOM_VACCINE_SELECT,
});
});
if (outcome === "name-taken") {
return apiError("A vaccine with this name already exists", 409, {
errorCode: "vaccination.custom.name-taken",
});
}
await auditLog("vaccination.custom.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
customVaccineId: id,
// The name is the person's own wording: a rename is named in
// `fields`, never quoted.
...overwriteDetails({
before: {
components: existing.components.join(","),
typicalSeriesDoses: existing.typicalSeriesDoses,
boosterIntervalMonths: existing.boosterIntervalMonths,
},
after: {
components: outcome.components.join(","),
typicalSeriesDoses: outcome.typicalSeriesDoses,
boosterIntervalMonths: outcome.boosterIntervalMonths,
},
redacted: outcome.name !== existing.name ? ["name"] : [],
}),
},
});
annotate({
action: {
name: "vaccination.custom.update",
entity_type: "custom-vaccine",
entity_id: id,
},
});
return apiSuccess(toCustomVaccineDTO(outcome));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("manage", "profile");
const { id } = await params;
const existing = await prisma.customVaccine.findUnique({
where: { id },
select: { id: true, userId: true, name: true, deletedAt: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Vaccine not found", 404, {
errorCode: "vaccination.custom.not-found",
});
}
// Already removed: removing twice is a no-op that still succeeds.
let dosesUnlinked = 0;
if (existing.deletedAt === null) {
dosesUnlinked = await prisma.$transaction((tx) =>
removeCustomVaccine(tx, user.id, existing),
);
}
await auditLog("vaccination.custom.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { customVaccineId: id, dosesUnlinked },
});
annotate({
action: {
name: "vaccination.custom.delete",
entity_type: "custom-vaccine",
entity_id: id,
},
meta: { doses_unlinked: dosesUnlinked },
});
return apiSuccess({ deleted: true });
},
);
+132 -13
View File
@@ -1,21 +1,140 @@
/**
* `GET /api/vaccinations/custom` lists the record's own vaccine definitions;
* `POST` adds one (v1.42, #1005). Record-scoped like the dose log.
* `GET /api/vaccinations/custom` — the record's own vaccine definitions.
* `POST /api/vaccinations/custom` — add one (v1.42, #1005).
*
* Contract stub: owner-only until implemented, then answers 501. The
* finished routes resolve the record through `requireRecordAuth` like the
* dose log and join the sharing guard's delegable lists with their audit
* rows; a stub cannot carry those, so it does not claim the admission.
* A definition is a product the shipped catalogue does not list, described in
* the shape a catalogue entry resolves to: a name, the antigens it protects
* against (from the catalogue's closed antigen list), a typical series length
* and a booster interval. A dose logged against it counts into a series,
* clears a booster and offers one exactly the way a catalogue pick does
* (`src/lib/vaccinations/resolve-vaccine-entry.ts`).
*
* Record-scoped like the dose log and classified `profile` with it: a
* definition is part of the immunization history a delegate granted `profile`
* already sees, names no credential, no integration and no notification
* channel. The list is READ, the create WRITE, the same split as the doses.
*
* `userId` is narrowed from auth and fed to every `where`; it is never a body
* field. The `data` object is built field-by-field.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { customVaccineCreateSchema } from "@/lib/validations/vaccinations";
import { toCustomVaccineDTO } from "@/lib/vaccinations/dto";
import {
CUSTOM_VACCINE_SELECT,
findLiveNameClash,
} from "@/lib/vaccinations/custom-vaccines";
export const GET = apiHandler(async () => {
await requireAuth();
return notImplemented();
const { user } = await requireRecordAuth("read", "profile");
const rows = await prisma.customVaccine.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: [{ name: "asc" }, { id: "asc" }],
select: CUSTOM_VACCINE_SELECT,
});
annotate({
action: { name: "vaccination.custom.list", entity_type: "custom-vaccine" },
meta: { count: rows.length },
});
return apiSuccess(rows.map(toCustomVaccineDTO));
});
export const POST = apiHandler(async () => {
await requireAuth();
return notImplemented();
export const POST = apiHandler(async (request: NextRequest) => {
const { user, actor } = await requireRecordAuth("write", "profile");
// The shared per-account write ceiling, keyed on the ACTOR so a delegate
// spends their own allowance (`checkRecordWriteRateLimit`).
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
});
if (jsonError) return jsonError;
const parsed = customVaccineCreateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "vaccination.custom.invalid",
});
}
const entry = parsed.data;
const outcome = await prisma.$transaction(async (tx) => {
if (await findLiveNameClash(tx, user.id, entry.name)) {
return "name-taken" as const;
}
// Names are unique per record, removed definitions included, because the
// index is. A name the person removed and now adds again brings that row
// back with the new values rather than failing on the index: deleting
// unlinked every dose, so nothing re-attaches on the way back.
const removed = await tx.customVaccine.findFirst({
where: { userId: user.id, name: entry.name, deletedAt: { not: null } },
select: { id: true },
});
const data = {
name: entry.name,
components: entry.components,
typicalSeriesDoses: entry.typicalSeriesDoses ?? null,
boosterIntervalMonths: entry.boosterIntervalMonths ?? null,
};
if (removed) {
return tx.customVaccine.update({
where: { id: removed.id },
data: { ...data, deletedAt: null },
select: CUSTOM_VACCINE_SELECT,
});
}
return tx.customVaccine.create({
data: { userId: user.id, ...data },
select: CUSTOM_VACCINE_SELECT,
});
});
if (outcome === "name-taken") {
return apiError("A vaccine with this name already exists", 409, {
errorCode: "vaccination.custom.name-taken",
});
}
await auditLog("vaccination.custom.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
customVaccineId: outcome.id,
components: outcome.components,
boosterIntervalMonths: outcome.boosterIntervalMonths,
},
});
annotate({
action: {
name: "vaccination.custom.create",
entity_type: "custom-vaccine",
entity_id: outcome.id,
},
meta: { components: outcome.components.length },
});
return apiSuccess(toCustomVaccineDTO(outcome), 201);
});
+28 -3
View File
@@ -43,6 +43,7 @@ import {
import {
VACCINATION_INCLUDE,
applyVaccinationLinks,
resolveOwnedCustomVaccine,
resolveOwnedEncounter,
resolveOwnedPractitioner,
resolveOwnerTimezone,
@@ -194,6 +195,14 @@ async function postVaccination(request: NextRequest): Promise<Response> {
const owned = await resolveOwnedEncounter(tx, user.id, entry.encounterId);
if (!owned) return "unknown-encounter" as const;
}
if (entry.customVaccineId) {
const owned = await resolveOwnedCustomVaccine(
tx,
user.id,
entry.customVaccineId,
);
if (!owned) return "unknown-custom-vaccine" as const;
}
// Field-by-field — never spread the parsed object whole.
const row = await tx.vaccinationRecord.create({
@@ -208,6 +217,7 @@ async function postVaccination(request: NextRequest): Promise<Response> {
site: entry.site ?? null,
practitionerId: entry.practitionerId ?? null,
encounterId: entry.encounterId ?? null,
customVaccineId: entry.customVaccineId ?? null,
noteEncrypted: entry.note ? encryptToBytes(entry.note) : null,
},
select: { id: true },
@@ -223,7 +233,10 @@ async function postVaccination(request: NextRequest): Promise<Response> {
const boosters = await satisfyBoostersForDose(
tx,
user.id,
entry.antigenSlug ?? null,
{
antigenSlug: entry.antigenSlug ?? null,
customVaccineId: entry.customVaccineId ?? null,
},
occurredAt,
timezone,
);
@@ -251,6 +264,11 @@ async function postVaccination(request: NextRequest): Promise<Response> {
errorCode: "vaccination.encounter-not-found",
});
}
if (created === "unknown-custom-vaccine") {
return apiError("Vaccine not found", 404, {
errorCode: "vaccination.custom-vaccine-not-found",
});
}
const series = await resolveSeriesFor(prisma, user.id);
@@ -261,6 +279,7 @@ async function postVaccination(request: NextRequest): Promise<Response> {
vaccinationId: created.id,
occurredAt: created.occurredAt.toISOString(),
antigenSlug: created.antigenSlug,
customVaccineId: created.customVaccineId,
// The note is deliberately absent: it is encrypted PHI and the audit
// table is not a second copy of the record.
},
@@ -273,7 +292,9 @@ async function postVaccination(request: NextRequest): Promise<Response> {
entity_id: created.id,
},
meta: {
antigen_slug: created.antigenSlug ?? "free-text",
antigen_slug:
created.antigenSlug ??
(created.customVaccineId ? "custom" : "free-text"),
linked_documents: entry.documentIds?.length ?? 0,
satisfied_boosters: satisfiedReminderIds.length,
},
@@ -285,7 +306,11 @@ async function postVaccination(request: NextRequest): Promise<Response> {
entity_type: "measurement-reminder",
entity_id: reminderId,
},
meta: { antigen_slug: created.antigenSlug ?? "free-text" },
meta: {
antigen_slug:
created.antigenSlug ??
(created.customVaccineId ? "custom" : "free-text"),
},
});
}
+1 -1
View File
@@ -151,7 +151,7 @@ const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{
},
{
labelKey: "previewVaccinations",
keys: ["vaccinations", "vaccinationLinks"],
keys: ["vaccinations", "vaccinationLinks", "customVaccines"],
},
{
labelKey: "previewReminders",
@@ -0,0 +1,128 @@
/**
* v1.42 (#1005) — the person's own vaccines on the client: the booster offer
* a dose logged against one raises, the name it carries in the list, and the
* body the definition sheet sends.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { VaccinationDTO } from "@/lib/vaccinations/dto";
import { boosterOfferFor } from "../booster-mint-prompt";
import { customVaccineBody } from "../custom-vaccine-sheet";
import { draftHasIdentity, draftToBody, emptyDraft } from "../vaccination-form";
import { VaccinationList } from "../vaccination-list";
const OWN = {
id: "cv-1",
name: "Travel combo",
components: ["typhoid", "hepatitis-a"],
typicalSeriesDoses: 1,
boosterIntervalMonths: 36,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
function dose(over: Partial<VaccinationDTO>): VaccinationDTO {
return {
id: "dose-1",
occurredAt: "2026-02-01T00:00:00.000Z",
antigenSlug: null,
vaccineName: null,
doseNumber: null,
seriesDoses: null,
lotNumber: null,
site: null,
catalogEntry: null,
customVaccineId: OWN.id,
customVaccine: OWN,
series: [
{ antigen: "typhoid", position: 1, total: 1, booster: false },
{ antigen: "hepatitis-a", position: 1, total: 1, booster: false },
],
practitioner: null,
encounter: null,
reminderId: null,
note: null,
createdAt: "2026-02-01T00:00:00.000Z",
updatedAt: "2026-02-01T00:00:00.000Z",
...over,
};
}
describe("the booster offer", () => {
it("is raised for an own vaccine with an interval, under its own name", () => {
expect(boosterOfferFor(dose({}))).toEqual({
intervalMonths: 36,
slug: null,
name: "Travel combo",
});
});
it("is not raised for an own vaccine without an interval, or a removed one", () => {
expect(
boosterOfferFor(
dose({ customVaccine: { ...OWN, boosterIntervalMonths: null } }),
),
).toBeNull();
expect(boosterOfferFor(dose({ customVaccine: null }))).toBeNull();
});
});
describe("the list", () => {
it("files the dose under each antigen, marked with the own vaccine's name", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<VaccinationList records={[dose({})]} />
</I18nProvider>,
);
expect(html).toContain('data-antigen="typhoid"');
expect(html).toContain('data-antigen="hepatitis-a"');
expect(html.match(/· Travel combo/g)).toHaveLength(2);
});
});
describe("the forms", () => {
it("accepts an own vaccine as the dose's only identity and sends it", () => {
const draft = emptyDraft({ customVaccineId: "cv-1" });
expect(draftHasIdentity(draft)).toBe(true);
expect(draftToBody(draft)).toMatchObject({
customVaccineId: "cv-1",
antigenSlug: null,
vaccineName: null,
});
});
it("sends a definition only with a name and a disease, antigens in catalogue order", () => {
expect(
customVaccineBody({
name: " ",
components: ["tetanus"],
typicalSeriesDoses: "",
boosterIntervalMonths: "",
}),
).toBeNull();
expect(
customVaccineBody({
name: "X",
components: [],
typicalSeriesDoses: "",
boosterIntervalMonths: "",
}),
).toBeNull();
expect(
customVaccineBody({
name: " Travel combo ",
components: ["typhoid", "tetanus"],
typicalSeriesDoses: "2",
boosterIntervalMonths: "",
}),
).toEqual({
name: "Travel combo",
components: ["tetanus", "typhoid"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
});
});
});
@@ -93,6 +93,8 @@ describe("vaccination form document links", () => {
lotNumber: null,
site: null,
catalogEntry: null,
customVaccineId: null,
customVaccine: null,
series: [],
practitioner: null,
encounter: null,
@@ -26,6 +26,8 @@ function record(
lotNumber: null,
site: null,
catalogEntry: null,
customVaccineId: null,
customVaccine: null,
practitioner: null,
encounter: null,
reminderId: null,
@@ -34,22 +34,40 @@ import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { useTranslations } from "@/lib/i18n/context";
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
import {
customLookupOf,
resolveVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
import { useBoosterMint, type Vaccination } from "./use-vaccinations";
/**
* Whether a freshly created dose should raise the offer at all. Only a
* catalogue entry that carries a booster interval does; a free-text row and a
* Whether a freshly created dose should raise the offer at all. Only an entry
* that carries a booster interval and an antigen to key it on does — a
* catalogue pick or the person's own definition alike; a free-text row and a
* one-off vaccine never prompt.
*/
export function boosterOfferFor(
record: Vaccination,
): { intervalMonths: number; catalogName: string } | null {
const entry = resolveCatalogEntry(record.antigenSlug);
if (!entry || entry.boosterIntervalMonths === null) return null;
export function boosterOfferFor(record: Vaccination): {
intervalMonths: number;
/** The catalogue slug, or null for the person's own definition. */
slug: string | null;
/** The definition's own name, or null for a catalogue entry. */
name: string | null;
} | null {
const entry = resolveVaccineEntry(
record,
customLookupOf([record.customVaccine]),
);
if (
!entry ||
entry.boosterIntervalMonths === null ||
entry.components.length === 0
) {
return null;
}
return {
intervalMonths: entry.boosterIntervalMonths,
catalogName: entry.slug,
slug: entry.slug,
name: entry.name,
};
}
@@ -71,7 +89,7 @@ export function BoosterMintPrompt({
// the person's own locale — the server never resolves an i18n key.
const defaultLabel =
record && offer
? `${t(`vaccinations.catalog.${record.antigenSlug}`)} ${t("vaccinations.booster.labelSuffix")}`
? `${offer.slug ? t(`vaccinations.catalog.${offer.slug}`) : offer.name} ${t("vaccinations.booster.labelSuffix")}`
: "";
const [label, setLabel] = useState(defaultLabel);
const [months, setMonths] = useState<number>(offer?.intervalMonths ?? 12);
+39 -10
View File
@@ -14,24 +14,40 @@
*
* The per-entry `source` renders verbatim as the footnote — a citation is a
* proper noun, like a unit, and is not translated.
*
* The person's own vaccine definitions (v1.42, #1005) render through the same
* templates, with the antigens they listed first and no footnote: their
* numbers are the person's, not a cited schedule's.
*/
import { useTranslations } from "@/lib/i18n/context";
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
import {
resolveVaccineEntry,
type ResolvedVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
type Translate = ReturnType<typeof useTranslations>["t"];
type TranslateCount = ReturnType<typeof useTranslations>["tCount"];
/** The impersonal sentences that apply to this entry, in reading order. */
function infoSentences(
slug: string | null,
entry: ResolvedVaccineEntry | null,
t: Translate,
tCount: TranslateCount,
): { lines: string[]; source: string } | null {
const entry = resolveCatalogEntry(slug);
): { lines: string[]; source: string | null } | null {
if (!entry) return null;
const lines: string[] = [];
if (entry.kind === "custom" && entry.components.length > 0) {
lines.push(
t("vaccinations.custom.protectsAgainst", {
list: entry.components
.map((antigen) => t(`vaccinations.catalog.${antigen}`))
.join(", "),
}),
);
}
if (entry.typicalSeriesDoses !== null) {
lines.push(
tCount("vaccinations.info.primarySeries", entry.typicalSeriesDoses, {
@@ -70,7 +86,7 @@ function infoSentences(
* empty popover for an entry the templates say nothing about.
*/
export function catalogInfoAvailable(slug: string | null): boolean {
const entry = resolveCatalogEntry(slug);
const entry = resolveVaccineEntry({ antigenSlug: slug });
if (!entry) return false;
return (
entry.typicalSeriesDoses !== null ||
@@ -79,9 +95,20 @@ export function catalogInfoAvailable(slug: string | null): boolean {
);
}
export function CatalogInfo({ slug }: { slug: string | null }) {
export function CatalogInfo({
slug = null,
entry,
}: {
slug?: string | null;
/** An already resolved entry, e.g. one of the person's own definitions. */
entry?: ResolvedVaccineEntry | null;
}) {
const { t, tCount } = useTranslations();
const info = infoSentences(slug, t, tCount);
const info = infoSentences(
entry ?? resolveVaccineEntry({ antigenSlug: slug }),
t,
tCount,
);
if (!info) return null;
return (
@@ -91,9 +118,11 @@ export function CatalogInfo({ slug }: { slug: string | null }) {
<li key={index}>{line}</li>
))}
</ul>
<p className="text-muted-foreground text-xs">
{t("vaccinations.info.sourceLabel", { source: info.source })}
</p>
{info.source ? (
<p className="text-muted-foreground text-xs">
{t("vaccinations.info.sourceLabel", { source: info.source })}
</p>
) : null}
</div>
);
}
+109 -6
View File
@@ -13,9 +13,18 @@
* The list is a static import, so the search is an in-memory filter with no
* round trip and no provider dependency. Grouped by category so a lifetime Pass
* (childhood through travel) reads in the order a person recognises.
*
* v1.42 (#1005) — the person's own vaccine definitions list above the
* catalogue under their own heading, and the popover ends in "Add own
* vaccine" for a product neither list has. Picking a definition sets
* `customVaccineId` and clears the catalogue pick, and the other way round:
* a dose names one entry, and the catalogue would win a tie anyway. The
* caller applies each pick as one change (`onChange` clears the definition,
* `onCustomChange` the slug), so two updates never race over one draft;
* clearing calls `onChange(null)` and empties both.
*/
import { useMemo, useState } from "react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -30,16 +39,33 @@ import {
VACCINE_CATALOG,
type VaccineSeed,
} from "@/lib/vaccinations/vaccine-catalog";
import {
customLookupOf,
resolveVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
import { CatalogInfo } from "./catalog-info";
import type { CustomVaccine } from "./use-vaccinations";
export function CatalogPicker({
value,
onChange,
customValue = null,
customs = [],
onCustomChange,
onAddCustom,
disabled,
}: {
/** The chosen catalogue slug, or null. */
value: string | null;
/** A catalogue pick, or `null` to clear every pick. */
onChange: (slug: string | null) => void;
/** The chosen own definition's id, or null. */
customValue?: string | null;
/** The record's own definitions, listed above the catalogue. */
customs?: readonly CustomVaccine[];
onCustomChange?: (id: string | null) => void;
/** Opens the definition sheet; absent when the caller may not add one. */
onAddCustom?: () => void;
disabled?: boolean;
}) {
const { t } = useTranslations();
@@ -63,9 +89,28 @@ export function CatalogPicker({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [term, t]);
const customMatches = useMemo(() => {
const needle = term.trim().toLowerCase();
return customs.filter(
(custom) => !needle || custom.name.toLowerCase().includes(needle),
);
}, [customs, term]);
const selected: VaccineSeed | undefined = value
? VACCINE_CATALOG.find((entry) => entry.slug === value)
: undefined;
const selectedCustom =
!selected && customValue
? (customs.find((custom) => custom.id === customValue) ?? null)
: null;
const customEntry = selectedCustom
? resolveVaccineEntry(
{ customVaccineId: selectedCustom.id },
customLookupOf([selectedCustom]),
)
: null;
const hasPick = Boolean(selected || selectedCustom);
const nothingMatches = matches.length === 0 && customMatches.length === 0;
return (
<div className="space-y-2">
@@ -89,12 +134,14 @@ export function CatalogPicker({
<span
className={cn(
"truncate",
selected ? "text-foreground" : "text-muted-foreground",
hasPick ? "text-foreground" : "text-muted-foreground",
)}
>
{selected
? name(selected.slug)
: t("vaccinations.form.catalogNone")}
: selectedCustom
? selectedCustom.name
: t("vaccinations.form.catalogNone")}
</span>
<ChevronsUpDown
className="size-4 shrink-0 opacity-50"
@@ -111,7 +158,45 @@ export function CatalogPicker({
data-slot="vaccination-catalog-search"
/>
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto overscroll-contain">
{matches.length === 0 ? (
{customMatches.length > 0 ? (
<>
<p className="text-muted-foreground px-2 pt-1 text-xs font-medium tracking-wide uppercase">
{t("vaccinations.custom.title")}
</p>
{customMatches.map((custom) => (
<button
key={custom.id}
type="button"
data-slot="vaccination-custom-option"
data-custom-vaccine-id={custom.id}
onClick={() => {
onCustomChange?.(custom.id);
setOpen(false);
}}
className="hover:bg-muted/60 flex min-h-11 w-full items-center gap-2 rounded-md px-2 text-left text-sm"
>
<Check
className={cn(
"size-4 shrink-0",
custom.id === customValue && !selected
? "opacity-100"
: "opacity-0",
)}
aria-hidden
/>
<span className="text-foreground min-w-0 flex-1 truncate">
{custom.name}
</span>
</button>
))}
{matches.length > 0 ? (
<p className="text-muted-foreground px-2 pt-2 text-xs font-medium tracking-wide uppercase">
{t("vaccinations.custom.catalogGroup")}
</p>
) : null}
</>
) : null}
{nothingMatches ? (
<p className="text-muted-foreground px-2 py-3 text-sm">
{t("vaccinations.form.catalogNoMatch")}
</p>
@@ -142,10 +227,26 @@ export function CatalogPicker({
))
)}
</div>
{onAddCustom ? (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-1 min-h-11 w-full justify-start"
data-slot="vaccination-custom-add"
onClick={() => {
setOpen(false);
onAddCustom();
}}
>
<Plus className="size-4" aria-hidden />
{t("vaccinations.custom.add")}
</Button>
) : null}
</PopoverContent>
</Popover>
{selected ? (
{hasPick ? (
<Button
type="button"
variant="ghost"
@@ -159,8 +260,10 @@ export function CatalogPicker({
) : null}
</div>
{/* Rung 1: the catalogue's sourced sentences for the chosen entry. */}
{/* Rung 1: the catalogue's sourced sentences for the chosen entry, or
the person's own definition read back in the same words. */}
{selected ? <CatalogInfo slug={selected.slug} /> : null}
{customEntry ? <CatalogInfo entry={customEntry} /> : null}
</div>
);
}
@@ -0,0 +1,121 @@
"use client";
/**
* v1.42 (#1005) — the record's own vaccine definitions, below the dose log.
*
* Rendered only once the record holds at least one: the way in is the dose
* form's picker ("Add own vaccine"), and an empty heading on every Impfpass
* would advertise a feature most people never need. Each row names the
* definition and reads it back in the catalogue's own sentences, so a
* definition and a catalogue entry describe themselves the same way. A row
* opens the definition for someone who may edit it.
*/
import { useState } from "react";
import { Plus, Syringe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { SectionHeading } from "@/components/ui/section-heading";
import { useTranslations } from "@/lib/i18n/context";
import {
customLookupOf,
resolveVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
import { CatalogInfo } from "./catalog-info";
import { CustomVaccineSheet } from "./custom-vaccine-sheet";
import type { CustomVaccine } from "./use-vaccinations";
export function CustomVaccineList({
customs,
canAdd,
canManage,
}: {
customs: readonly CustomVaccine[];
/** WRITE on `profile`: may add a definition. */
canAdd: boolean;
/** MANAGE on `profile`: may edit and remove one. */
canManage: boolean;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<CustomVaccine | null>(null);
const [session, setSession] = useState(0);
if (customs.length === 0) return null;
const openSheet = (row: CustomVaccine | null) => {
setEditing(row);
setSession((n) => n + 1);
setOpen(true);
};
return (
<section className="space-y-4" data-slot="custom-vaccine-section">
<SectionHeading
icon={Syringe}
title={t("vaccinations.custom.title")}
subtitle={t("vaccinations.custom.sectionDescription")}
action={
canAdd ? (
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="custom-vaccine-add"
onClick={() => openSheet(null)}
>
<Plus className="size-4" aria-hidden />
{t("vaccinations.custom.add")}
</Button>
) : null
}
/>
<div className="space-y-2">
{customs.map((custom) => {
const entry = resolveVaccineEntry(
{ customVaccineId: custom.id },
customLookupOf([custom]),
);
const editable = canManage;
return (
<Card
key={custom.id}
className="hover:bg-muted/40 gap-0 transition-colors"
data-slot="custom-vaccine-row"
data-custom-vaccine-id={custom.id}
role={editable ? "button" : undefined}
tabIndex={editable ? 0 : undefined}
onClick={editable ? () => openSheet(custom) : undefined}
onKeyDown={
editable
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openSheet(custom);
}
}
: undefined
}
>
<CardContent className="space-y-1 px-4 py-3">
<p className="text-foreground text-sm font-medium">
{custom.name}
</p>
<CatalogInfo entry={entry} />
</CardContent>
</Card>
);
})}
</div>
<CustomVaccineSheet
key={session}
open={open}
onOpenChange={setOpen}
customVaccine={editing}
canRemove={canManage}
/>
</section>
);
}
@@ -0,0 +1,332 @@
"use client";
/**
* v1.42 (#1005) — define, edit or remove one of the record's own vaccines.
*
* A definition is a product the shipped catalogue does not list: a name, the
* diseases it protects against (the catalogue's own antigen list, because the
* series and the booster match key on it), and optionally the series length
* and the booster interval. A dose logged against it then counts like a
* catalogue pick (`resolve-vaccine-entry.ts`).
*
* Save needs a name and at least one disease; nothing else is required.
* Removing asks first and says what happens to the doses: they stay, under
* the definition's name.
*
* Capability note: the same posture as the dose sheet. Create needs WRITE on
* `profile`, edit and remove need MANAGE; the mounts ask before opening it.
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { ANTIGEN_SLUGS } from "@/lib/vaccinations/vaccine-catalog";
import {
useCustomVaccineMutations,
type CustomVaccine,
type CustomVaccineWriteBody,
} from "./use-vaccinations";
export interface CustomVaccineDraft {
name: string;
components: string[];
typicalSeriesDoses: string;
boosterIntervalMonths: string;
}
export function customVaccineDraft(
row: CustomVaccine | null,
): CustomVaccineDraft {
return {
name: row?.name ?? "",
components: row ? [...row.components] : [],
typicalSeriesDoses: row?.typicalSeriesDoses?.toString() ?? "",
boosterIntervalMonths: row?.boosterIntervalMonths?.toString() ?? "",
};
}
function toInt(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number.parseInt(trimmed, 10);
return Number.isNaN(n) ? null : n;
}
/** The body a draft becomes; null while it cannot be saved. */
export function customVaccineBody(
draft: CustomVaccineDraft,
): CustomVaccineWriteBody | null {
const name = draft.name.trim();
if (!name || draft.components.length === 0) return null;
return {
name,
// Catalogue order, so the same choice always reads the same way.
components: ANTIGEN_SLUGS.filter((slug) => draft.components.includes(slug)),
typicalSeriesDoses: toInt(draft.typicalSeriesDoses),
boosterIntervalMonths: toInt(draft.boosterIntervalMonths),
};
}
export function CustomVaccineSheet({
open,
onOpenChange,
customVaccine,
canRemove = false,
onSaved,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** `null` creates a definition; a row edits it. */
customVaccine: CustomVaccine | null;
/** Offer the removal; the caller asks for MANAGE first. */
canRemove?: boolean;
/** Raised with the saved definition, e.g. to pick it in the dose form. */
onSaved?: (saved: CustomVaccine) => void;
}) {
const { t } = useTranslations();
const { create, update, remove } = useCustomVaccineMutations();
// Seeded once at mount; the caller remounts on every open by varying `key`.
const [draft, setDraft] = useState<CustomVaccineDraft>(() =>
customVaccineDraft(customVaccine),
);
const [error, setError] = useState<string | null>(null);
const [confirmingRemove, setConfirmingRemove] = useState(false);
// The diseases an edit opens with lead the list, so what the definition
// covers is visible without scrolling; the order is fixed at mount so a
// box never jumps away from the pointer that ticked it.
const [antigenOrder] = useState<readonly string[]>(() => {
const chosen = new Set(customVaccine?.components ?? []);
return [
...ANTIGEN_SLUGS.filter((slug) => chosen.has(slug)),
...ANTIGEN_SLUGS.filter((slug) => !chosen.has(slug)),
];
});
const isEdit = customVaccine !== null;
const pending = create.isPending || update.isPending;
const body = customVaccineBody(draft);
const patch = (part: Partial<CustomVaccineDraft>) =>
setDraft((current) => ({ ...current, ...part }));
const toggle = (antigen: string, on: boolean) =>
setDraft((current) => ({
...current,
components: on
? [...current.components.filter((a) => a !== antigen), antigen]
: current.components.filter((a) => a !== antigen),
}));
const submit = async () => {
if (!body) return;
setError(null);
try {
const saved = isEdit
? await update.mutateAsync({ id: customVaccine.id, body })
: await create.mutateAsync(body);
toastWrittenOutcome("success", t("vaccinations.custom.saved"));
onOpenChange(false);
onSaved?.(saved);
} catch (err) {
const message =
err instanceof ApiError &&
err.meta?.errorCode === "vaccination.custom.name-taken"
? t("vaccinations.custom.nameTaken")
: t("vaccinations.custom.saveFailed");
setError(message);
toast.error(message);
}
};
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t(
isEdit ? "vaccinations.custom.editTitle" : "vaccinations.custom.add",
)}
description={t("vaccinations.custom.description")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
className="min-h-11"
data-slot="custom-vaccine-save"
disabled={!body || pending}
onClick={() => void submit()}
>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4" data-slot="custom-vaccine-form">
<FieldGroup
htmlFor="custom-vaccine-name"
label={t("vaccinations.custom.name")}
>
<Input
id="custom-vaccine-name"
value={draft.name}
maxLength={100}
onChange={(event) => patch({ name: event.target.value })}
data-slot="custom-vaccine-name"
/>
</FieldGroup>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("vaccinations.custom.components")}
</legend>
<p className="text-muted-foreground text-xs">
{t("vaccinations.custom.componentsHint")}
</p>
<div
className="grid max-h-56 grid-cols-1 gap-x-3 overflow-y-auto overscroll-contain rounded-md border p-2 sm:grid-cols-2"
data-slot="custom-vaccine-components"
>
{antigenOrder.map((antigen) => {
const id = `custom-vaccine-antigen-${antigen}`;
const checked = draft.components.includes(antigen);
return (
<label
key={antigen}
htmlFor={id}
className="hover:bg-muted/60 flex min-h-11 cursor-pointer items-center gap-2 rounded-md px-2 text-sm"
>
<Checkbox
id={id}
checked={checked}
data-antigen={antigen}
onCheckedChange={(next) => toggle(antigen, next === true)}
/>
<span className="text-foreground min-w-0 flex-1">
{t(`vaccinations.catalog.${antigen}`)}
</span>
</label>
);
})}
</div>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup
htmlFor="custom-vaccine-series"
label={t("vaccinations.custom.seriesDoses")}
>
<Input
id="custom-vaccine-series"
type="number"
inputMode="numeric"
min={1}
max={10}
value={draft.typicalSeriesDoses}
onChange={(event) =>
patch({ typicalSeriesDoses: event.target.value })
}
/>
</FieldGroup>
<FieldGroup
htmlFor="custom-vaccine-booster"
label={t("vaccinations.custom.boosterMonths")}
hint={t("vaccinations.custom.boosterHint")}
>
<Input
id="custom-vaccine-booster"
type="number"
inputMode="numeric"
min={1}
max={600}
value={draft.boosterIntervalMonths}
onChange={(event) =>
patch({ boosterIntervalMonths: event.target.value })
}
/>
</FieldGroup>
</div>
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
{isEdit && canRemove ? (
<AlertDialog
open={confirmingRemove}
onOpenChange={setConfirmingRemove}
>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11"
data-slot="custom-vaccine-delete"
>
<Trash2 className="size-4" aria-hidden />
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("vaccinations.custom.deleteTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("vaccinations.custom.deleteConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
remove.mutate(customVaccine.id, {
onSuccess: () => {
toastWrittenOutcome(
"success",
t("vaccinations.custom.deleted"),
);
onOpenChange(false);
},
onError: () =>
toast.error(t("vaccinations.custom.saveFailed")),
});
}}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</div>
</ResponsiveSheet>
);
}
@@ -23,12 +23,14 @@ import {
vaccinationDependentKeys,
} from "@/lib/query-keys";
import type {
CustomVaccineDTO,
VaccinationDTO,
VaccinationListDTO,
} from "@/lib/vaccinations/dto";
import type { VaccinationSuggestionResult } from "@/lib/vaccinations/document-suggestion";
export type Vaccination = VaccinationDTO;
export type CustomVaccine = CustomVaccineDTO;
/**
* The write body — every field optional but the identity pair and the date.
@@ -44,6 +46,7 @@ export interface VaccinationWriteBody {
site?: string | null;
practitionerId?: string | null;
encounterId?: string | null;
customVaccineId?: string | null;
note?: string | null;
documentIds?: string[];
}
@@ -212,3 +215,59 @@ export function useBoosterMint() {
]),
});
}
/** What a definition write carries; `userId` is never here. */
export interface CustomVaccineWriteBody {
name?: string;
components?: string[];
typicalSeriesDoses?: number | null;
boosterIntervalMonths?: number | null;
}
/** v1.42 (#1005) — the record's own vaccine definitions, by name. */
export function useCustomVaccines(enabled = true) {
return useQuery({
queryKey: queryKeys.customVaccines(),
enabled,
queryFn: () => apiGet<CustomVaccine[]>(`${BASE}/custom`),
});
}
/**
* Add, edit and remove a definition. Every write evicts the whole
* vaccinations root: an edit re-reads the series of every dose that names
* the definition, and a removal lets those doses go and names them, which
* the document sheet's dose chips show too. No reminder moves — a definition
* write never runs the satisfy matcher — so the reminder reads and the daily
* reads they feed are left alone.
*/
export function useCustomVaccineMutations() {
const qc = useQueryClient();
const invalidate = () =>
invalidateKeys(qc, [queryKeys.vaccinations(), queryKeys.documents()]);
const create = useMutation({
mutationKey: queryKeys.customVaccineCreate(),
mutationFn: (body: CustomVaccineWriteBody) =>
apiPost<CustomVaccine>(`${BASE}/custom`, body),
onSuccess: invalidate,
});
const update = useMutation({
mutationKey: queryKeys.customVaccineUpdate(),
mutationFn: ({ id, body }: { id: string; body: CustomVaccineWriteBody }) =>
apiPatch<CustomVaccine>(`${BASE}/custom/${encodeURIComponent(id)}`, body),
onSuccess: invalidate,
});
const remove = useMutation({
mutationKey: queryKeys.customVaccineDelete(),
mutationFn: (id: string) =>
apiDelete<{ deleted: boolean }>(
`${BASE}/custom/${encodeURIComponent(id)}`,
),
onSuccess: invalidate,
});
return { create, update, remove };
}
@@ -10,9 +10,11 @@
* feature exists for. The only thing that can block Save is a missing date or
* neither identity arm.
*
* The two identity arms are independent: picking a catalogue entry never
* The identity arms are independent: picking a catalogue entry never
* overwrites what the person typed, and typing never clears a pick. Either is
* sufficient; both are allowed.
* sufficient; both are allowed. Since v1.42 (#1005) the pick may also be one
* of the record's own vaccine definitions, chosen in the same picker, and a
* new definition can be added from it without leaving the form.
*
* Follows the encounter form's controlled-draft shape rather than a
* react-hook-form instance — the sibling surface this plan cites as its
@@ -32,8 +34,14 @@ import { EncounterSuggestionField } from "@/components/encounters/encounter-sugg
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { Practitioner } from "@/hooks/use-practitioners";
import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations";
import {
useCustomVaccines,
type CustomVaccine,
type Vaccination,
type VaccinationWriteBody,
} from "./use-vaccinations";
import { CatalogPicker } from "./catalog-picker";
import { CustomVaccineSheet } from "./custom-vaccine-sheet";
import { VaccinationDocumentPicker } from "./vaccination-document-picker";
import { dayKeyAsUtcMidnight, isCalendarDateKey } from "@/lib/tz/date-only";
@@ -52,6 +60,8 @@ export interface VaccinationDraft {
/** `YYYY-MM-DD`, the `DateField` contract. */
occurredAt: string;
antigenSlug: string | null;
/** One of the record's own vaccine definitions, or null. */
customVaccineId: string | null;
vaccineName: string;
doseNumber: string;
seriesDoses: string;
@@ -85,6 +95,7 @@ export function emptyDraft(
return {
occurredAt: todayLocal(),
antigenSlug: null,
customVaccineId: null,
vaccineName: "",
doseNumber: "",
seriesDoses: "",
@@ -102,6 +113,8 @@ export function draftFromVaccination(row: Vaccination): VaccinationDraft {
return {
occurredAt: row.occurredAt.slice(0, 10),
antigenSlug: row.antigenSlug,
// A definition removed since stays unpicked: the picker cannot name it.
customVaccineId: row.customVaccine ? row.customVaccineId : null,
vaccineName: row.vaccineName ?? "",
doseNumber: row.doseNumber?.toString() ?? "",
seriesDoses: row.seriesDoses?.toString() ?? "",
@@ -125,7 +138,11 @@ export function draftInstant(day: string): string | null {
/** Does the draft carry at least one identity arm? */
export function draftHasIdentity(draft: VaccinationDraft): boolean {
return draft.antigenSlug !== null || draft.vaccineName.trim().length > 0;
return (
draft.antigenSlug !== null ||
draft.customVaccineId !== null ||
draft.vaccineName.trim().length > 0
);
}
function toInt(value: string): number | null {
@@ -145,6 +162,7 @@ export function draftToBody(
return {
occurredAt,
antigenSlug: draft.antigenSlug,
customVaccineId: draft.customVaccineId,
vaccineName: draft.vaccineName.trim() || null,
doseNumber: toInt(draft.doseNumber),
seriesDoses: toInt(draft.seriesDoses),
@@ -186,6 +204,11 @@ export function VaccinationForm({
const [seriesOpen, setSeriesOpen] = useState(
draft.doseNumber.length > 0 || draft.seriesDoses.length > 0,
);
const customs = useCustomVaccines();
// The definition sheet, opened from the picker. Bumped `session` remounts
// it so a second open starts empty.
const [addingCustom, setAddingCustom] = useState(false);
const [customSession, setCustomSession] = useState(0);
const anchor = draftInstant(draft.occurredAt);
@@ -212,10 +235,32 @@ export function VaccinationForm({
>
<CatalogPicker
value={draft.antigenSlug}
onChange={(slug) => patch({ antigenSlug: slug })}
// One change per pick, so the two arms never race over one draft.
onChange={(slug) =>
patch({ antigenSlug: slug, customVaccineId: null })
}
customValue={draft.customVaccineId}
customs={customs.data ?? []}
onCustomChange={(id) =>
patch({ customVaccineId: id, antigenSlug: null })
}
onAddCustom={() => {
setCustomSession((n) => n + 1);
setAddingCustom(true);
}}
/>
</FieldGroup>
<CustomVaccineSheet
key={customSession}
open={addingCustom}
onOpenChange={setAddingCustom}
customVaccine={null}
onSaved={(saved: CustomVaccine) =>
patch({ customVaccineId: saved.id, antigenSlug: null })
}
/>
<FieldGroup
htmlFor="vaccination-free-text"
label={t("vaccinations.form.freeTextLabel")}
@@ -13,6 +13,10 @@
* duplication: one record, several appearances, and the numbers come straight
* from the DTO's `series` array, never recomputed here.
*
* A dose logged against the person's own vaccine definition groups exactly
* like a catalogue pick, under each antigen the definition lists, with the
* definition's name as its marker.
*
* A free-text-only record, and a record whose slug the catalogue no longer
* resolves, carries an empty `series` and groups under its verbatim
* `vaccineName` instead — the degrade the DTO's `catalogEntry: null` signals,
@@ -72,7 +76,9 @@ interface AntigenGroup {
function recordIdentity(t: Translate, record: Vaccination): string | null {
if (record.catalogEntry)
return t(`vaccinations.catalog.${record.catalogEntry.slug}`);
return record.vaccineName;
// The person's own definition names the dose ahead of its free text, the
// way a catalogue pick does.
return record.customVaccine?.name ?? record.vaccineName;
}
/** The series sentence for one appearance, composed from resolved numbers. */
@@ -128,7 +134,11 @@ function groupByAntigen(records: readonly Vaccination[]): AntigenGroup[] {
// No resolvable antigen: a free-text row or a dead slug. Its verbatim name
// is the heading; the slug is the last-resort key so a nameless dead-slug
// row still lands somewhere rather than colliding with another.
const name = record.vaccineName ?? record.antigenSlug ?? "—";
const name =
record.vaccineName ??
record.customVaccine?.name ??
record.antigenSlug ??
"—";
const key = `free:${name}`;
ensure(key, { key, antigen: null, freeName: name }).rows.push({
record,
@@ -26,7 +26,12 @@ import { useTranslations } from "@/lib/i18n/context";
import { VaccinationList } from "./vaccination-list";
import { VaccinationSheet } from "./vaccination-sheet";
import { BoosterMintPrompt, boosterOfferFor } from "./booster-mint-prompt";
import { useVaccinations, type Vaccination } from "./use-vaccinations";
import { CustomVaccineList } from "./custom-vaccine-list";
import {
useCustomVaccines,
useVaccinations,
type Vaccination,
} from "./use-vaccinations";
export function VaccinationsView() {
const { t } = useTranslations();
@@ -45,6 +50,10 @@ export function VaccinationsView() {
const canPlanBooster = canWriteDomain("measurements");
const { data, isLoading, isError, refetch } = useVaccinations();
const records = data?.vaccinations ?? [];
// v1.42 (#1005) — the record's own definitions, listed below the log once
// there is one. A failed read leaves the section out rather than claiming
// there are none; the picker reads the same query and says the same.
const customs = useCustomVaccines();
// One sheet for create and edit. Bumped `session` remounts it so a second
// open never shows the first's values.
@@ -154,6 +163,12 @@ export function VaccinationsView() {
}
/>
)}
<CustomVaccineList
customs={customs.data ?? []}
canAdd={canAddDose}
canManage={canManageProfile}
/>
</div>
);
}
+14 -1
View File
@@ -12,6 +12,11 @@
*/
import { prisma } from "@/lib/db";
import { deriveSeries } from "@/lib/vaccinations/series";
import {
CUSTOM_VACCINE_RESOLVE_SELECT,
customLookupOf,
resolveVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
import { resolveEffectiveReferenceRange } from "@/lib/labs/reference-range";
import { getEvent } from "@/lib/logging/context";
import { decryptAllergyReaction } from "@/lib/doctor-report-helpers";
@@ -298,24 +303,32 @@ export async function loadImmunizations(
doseNumber: true,
site: true,
practitioner: { select: { name: true } },
// v1.42 (#1005) — the person's own definition: its antigens place the
// dose in a series, and its name stands in when the dose has none.
customVaccineId: true,
customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT },
},
});
if (rows.length === 0) return null;
const customs = customLookupOf(rows.map((row) => row.customVaccine));
const series = deriveSeries(
rows.map((row) => ({
id: row.id,
occurredAt: row.occurredAt,
antigenSlug: row.antigenSlug,
customVaccineId: row.customVaccineId,
doseNumber: row.doseNumber,
seriesDoses: row.seriesDoses,
})),
customs,
);
return rows.map((row) => ({
occurredAt: row.occurredAt.toISOString(),
antigenSlug: row.antigenSlug,
vaccineName: row.vaccineName,
vaccineName:
row.vaccineName ?? resolveVaccineEntry(row, customs)?.name ?? null,
lotNumber: row.lotNumber,
site: row.site,
practitionerName: row.practitioner?.name ?? null,
+18 -5
View File
@@ -16,7 +16,11 @@ import type {
DocumentLinkedProcedureDto,
DocumentVaccinationLinkDto,
} from "@/lib/validations/inbound-documents";
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
import {
CUSTOM_VACCINE_RESOLVE_SELECT,
customLookupOf,
resolveVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
/**
* Load the condition links for a page of documents in ONE grouped query.
@@ -237,20 +241,29 @@ export async function loadDocumentVaccinationLinks(
if (doseIds.length === 0) return map;
const doses = await prisma.vaccinationRecord.findMany({
where: { id: { in: doseIds }, userId },
select: { id: true, antigenSlug: true, vaccineName: true },
select: {
id: true,
antigenSlug: true,
vaccineName: true,
customVaccineId: true,
customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT },
},
});
const doseById = new Map(doses.map((dose) => [dose.id, dose]));
const customs = customLookupOf(doses.map((dose) => dose.customVaccine));
for (const [documentId, targets] of byDocument) {
map.set(
documentId,
targets.map((target) => {
const dose = doseById.get(target.id);
const entry = dose ? resolveVaccineEntry(dose, customs) : null;
return {
vaccinationId: target.id,
occurredAt: target.date,
catalogSlug:
resolveCatalogEntry(dose?.antigenSlug ?? null)?.slug ?? null,
vaccineName: dose?.vaccineName ?? null,
catalogSlug: entry?.slug ?? null,
// A dose logged against the person's own definition and carrying no
// wording of its own is named by that definition.
vaccineName: dose?.vaccineName ?? entry?.name ?? null,
};
}),
);
@@ -413,6 +413,7 @@ function makePrisma() {
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
customVaccine: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
// same reason as the visit tables above.
@@ -290,6 +290,7 @@ function sourceClient() {
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
customVaccine: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
// same reason as the visit tables above.
+1 -2
View File
@@ -378,6 +378,7 @@ export const TWO_ENDED_MODELS = [
// from anywhere else.
"VaccinationRecord",
"VaccinationDocumentLink",
"CustomVaccine",
// The Vorsorge reminders, off the debt register at last (v1.37.20, #223 /
// iOS #68), and the completion ledger carried with them from the release
// that introduces it. The pairing is deliberate: the ledger arriving is
@@ -589,8 +590,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly<Record<string, string>> = {
* rather than by what is cheapest to write.
*/
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
CustomVaccine:
"The person's own vaccine definitions (v1.42, #1005). Until both ends land, a restored dose that pointed at one comes back as its bare `vaccineName`: no antigens, no series position, no booster rule.",
WorkoutRoute:
"GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.",
WorkoutSamples:
+1
View File
@@ -180,6 +180,7 @@ const SUMMARY_COUNTS = [
"encounterLinks",
"vaccinations",
"vaccinationLinks",
"customVaccines",
"measurementReminders",
"measurementReminderEvents",
"coachConversations",
+3
View File
@@ -179,6 +179,7 @@ export interface RestoreResponse {
encounterLinks: number;
vaccinations: number;
vaccinationLinks: number;
customVaccines: number;
measurementReminders: number;
measurementReminderEvents: number;
coachConversations: number;
@@ -2646,6 +2647,7 @@ export async function restoreBackup(
encounterLinks: visitsCleared.encounterLinks,
vaccinations: vaccinationsCleared.vaccinations,
vaccinationLinks: vaccinationsCleared.vaccinationLinks,
customVaccines: vaccinationsCleared.customVaccines,
measurementReminders: remindersCleared.measurementReminders,
measurementReminderEvents: remindersCleared.measurementReminderEvents,
coachConversations: coachCleared.coachConversations,
@@ -2905,6 +2907,7 @@ export async function restoreBackup(
encounterLinks: summary.encounterLinks,
vaccinations: summary.vaccinations,
vaccinationLinks: summary.vaccinationLinks,
customVaccines: summary.customVaccines,
measurementReminders: summary.measurementReminders,
measurementReminderEvents: summary.measurementReminderEvents,
},
+131 -15
View File
@@ -6,8 +6,9 @@
* greps only the restore ROUTE gets a false negative because the route
* delegates.
*
* Two models ride: the dose and the link that keeps the scanned Impfpass page
* beside it. Both are carried rather than owed. `DocumentConditionLink` on the
* Three models ride: the dose, the link that keeps the scanned Impfpass page
* beside it, and since v1.42 (#1005) the person's own vaccine definitions a
* dose may name. Both are carried rather than owed. `DocumentConditionLink` on the
* debt register says what the alternative costs — documents and conditions
* both restore, the filing between them does not — and an immunization history
* is the record with the least chance of being reconstructed from anywhere
@@ -29,6 +30,9 @@
* the same way, and resolves to NULL, with the drop reported, only when
* the file genuinely lacks the reminder — a portable export omits
* tombstoned ones.
* - `customVaccineId` points at a definition restored by this same section
* first. It drops to NULL, with the drop named, when the file lacks the
* definition; the dose then keeps its own `vaccineName`.
* - `antigenSlug` is restored verbatim with no validation against the
* current catalogue. A slug this release no longer resolves must still
* come back; the renderer degrades to the free-text arm, which is the
@@ -76,6 +80,8 @@ export interface VaccinationBackupEntry {
* lacks the reminder.
*/
reminderId: string | null;
/** The person's own definition the dose names (v1.42, #1005). */
customVaccineId: string | null;
/** Base64 ciphertext; disaster-recovery payloads only. */
noteEncrypted?: string | null;
/** The note readable; portable payloads only. */
@@ -85,6 +91,22 @@ export interface VaccinationBackupEntry {
deletedAt?: string | null;
}
/**
* One of the person's own vaccine definitions (v1.42, #1005). `components`
* travels verbatim, never checked against the antigen list this release
* ships, for the same reason a dose's `antigenSlug` does.
*/
export interface CustomVaccineBackupEntry {
id: string;
name: string;
components: string[];
typicalSeriesDoses: number | null;
boosterIntervalMonths: number | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
/** One edge from a dose to the page it was transcribed from. */
export interface VaccinationDocumentLinkBackupEntry {
vaccinationId: string;
@@ -93,11 +115,13 @@ export interface VaccinationDocumentLinkBackupEntry {
}
export interface VaccinationsBackupSection {
customVaccines: CustomVaccineBackupEntry[];
vaccinations: VaccinationBackupEntry[];
vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[];
}
export interface VaccinationsBackupCounts {
customVaccines: number;
vaccinations: number;
vaccinationLinks: number;
}
@@ -119,12 +143,24 @@ const VACCINATION_BACKUP_SELECT = {
practitionerId: true,
encounterId: true,
reminderId: true,
customVaccineId: true,
noteEncrypted: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
} as const;
const CUSTOM_VACCINE_BACKUP_SELECT = {
id: true,
name: true,
components: true,
typicalSeriesDoses: true,
boosterIntervalMonths: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
} as const;
const VACCINATION_DOCUMENT_LINK_BACKUP_SELECT = {
vaccinationId: true,
documentId: true,
@@ -140,26 +176,47 @@ const VACCINATION_DOCUMENT_LINK_BACKUP_SELECT = {
* back as itself.
*/
export async function buildVaccinationsBackupSection(
prisma: Pick<PrismaClient, "vaccinationRecord" | "vaccinationDocumentLink">,
prisma: Pick<
PrismaClient,
"vaccinationRecord" | "vaccinationDocumentLink" | "customVaccine"
>,
userId: string,
options: VaccinationsBackupOptions = {},
): Promise<VaccinationsBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [vaccinationRows, documentLinkRows] = await Promise.all([
prisma.vaccinationRecord.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { occurredAt: "desc" },
select: VACCINATION_BACKUP_SELECT,
}),
prisma.vaccinationDocumentLink.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: VACCINATION_DOCUMENT_LINK_BACKUP_SELECT,
}),
]);
const [customVaccineRows, vaccinationRows, documentLinkRows] =
await Promise.all([
prisma.customVaccine.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: [{ name: "asc" }, { id: "asc" }],
select: CUSTOM_VACCINE_BACKUP_SELECT,
}),
prisma.vaccinationRecord.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { occurredAt: "desc" },
select: VACCINATION_BACKUP_SELECT,
}),
prisma.vaccinationDocumentLink.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: VACCINATION_DOCUMENT_LINK_BACKUP_SELECT,
}),
]);
return {
customVaccines: customVaccineRows.map((row) => ({
id: row.id,
name: row.name,
components: [...row.components],
typicalSeriesDoses: row.typicalSeriesDoses,
boosterIntervalMonths: row.boosterIntervalMonths,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
...(disasterRecovery
? { deletedAt: row.deletedAt?.toISOString() ?? null }
: {}),
})),
vaccinations: vaccinationRows.map((row) => ({
id: row.id,
occurredAt: row.occurredAt.toISOString(),
@@ -172,6 +229,7 @@ export async function buildVaccinationsBackupSection(
practitionerId: row.practitionerId,
encounterId: row.encounterId,
reminderId: row.reminderId,
customVaccineId: row.customVaccineId,
...(disasterRecovery
? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) }
: {
@@ -199,6 +257,7 @@ export function countVaccinationsBackupSection(
section: VaccinationsBackupSection,
): VaccinationsBackupCounts {
return {
customVaccines: section.customVaccines.length,
vaccinations: section.vaccinations.length,
vaccinationLinks: section.vaccinationDocumentLinks.length,
};
@@ -206,6 +265,7 @@ export function countVaccinationsBackupSection(
/** Counts the immunization restore wiped, for the audit trail. */
export interface VaccinationsRestoreCleared {
customVaccines: number;
vaccinations: number;
vaccinationLinks: number;
}
@@ -219,10 +279,22 @@ export interface VaccinationsRestoreCleared {
* times and reports success.
*/
export interface VaccinationsRestoreInput {
customVaccines: RestoredCustomVaccine[];
vaccinations: RestoredVaccination[];
vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[];
}
/** A definition as the parsed file presents it. */
export type RestoredCustomVaccine = Pick<
CustomVaccineBackupEntry,
"id" | "name" | "createdAt" | "updatedAt"
> & {
components?: string[] | undefined;
typicalSeriesDoses?: number | null | undefined;
boosterIntervalMonths?: number | null | undefined;
deletedAt?: string | null | undefined;
};
/**
* What the restore reads, as the parsed file actually presents it — a wider
* set than what this release writes: a portable export omits the tombstone,
@@ -246,6 +318,7 @@ export type RestoredVaccination = Pick<
| "practitionerId"
| "encounterId"
| "reminderId"
| "customVaccineId"
| "noteEncrypted"
| "note"
>
@@ -276,6 +349,32 @@ export async function restoreVaccinationsData(
const clearedVaccinations = await tx.vaccinationRecord.deleteMany({
where: { userId: ownerId },
});
// After the doses that point at them, so neither count reads short.
const clearedCustomVaccines = await tx.customVaccine.deleteMany({
where: { userId: ownerId },
});
// The definitions first: a dose addresses one by id.
if (payload.customVaccines.length > 0) {
await tx.customVaccine.createMany({
data: payload.customVaccines.map((entry) => ({
id: entry.id,
userId: ownerId,
name: entry.name,
// Verbatim, like a dose's slug: an antigen this release does not know
// still says what the person meant, and the resolver skips it.
components: entry.components ?? [],
typicalSeriesDoses: entry.typicalSeriesDoses ?? null,
boosterIntervalMonths: entry.boosterIntervalMonths ?? null,
createdAt: new Date(entry.createdAt),
updatedAt: new Date(entry.updatedAt),
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
})),
});
}
const restoredCustomVaccines = new Set(
payload.customVaccines.map((entry) => entry.id),
);
// All three far sides are restored by other branches of the same
// transaction, so the check is against the database rather than against the
@@ -308,6 +407,7 @@ export async function restoreVaccinationsData(
const droppedPractitioners: string[] = [];
const droppedEncounters: string[] = [];
const droppedReminders: string[] = [];
const droppedCustomVaccines: string[] = [];
// Sealed notes this host's keys do not open, by file path.
const unopened: string[] = [];
@@ -336,6 +436,14 @@ export async function restoreVaccinationsData(
if (entry.reminderId && reminderId === null) {
droppedReminders.push(entry.reminderId);
}
const customVaccineId =
entry.customVaccineId &&
restoredCustomVaccines.has(entry.customVaccineId)
? entry.customVaccineId
: null;
if (entry.customVaccineId && customVaccineId === null) {
droppedCustomVaccines.push(entry.customVaccineId);
}
return {
id: entry.id,
userId: ownerId,
@@ -352,6 +460,7 @@ export async function restoreVaccinationsData(
practitionerId,
encounterId,
reminderId,
customVaccineId,
noteEncrypted: sealBytesForRestore(
entry.noteEncrypted,
entry.note,
@@ -384,6 +493,12 @@ export async function restoreVaccinationsData(
[...new Set(droppedReminders)],
droppedReminders,
);
recordUnknownKeys(
skips,
"vaccinationReference",
[...new Set(droppedCustomVaccines)],
droppedCustomVaccines,
);
recordUnknownKeys(skips, "vaccinationCiphertext", unopened, unopened);
const restoredVaccinations = new Set(
@@ -433,6 +548,7 @@ export async function restoreVaccinationsData(
);
return {
customVaccines: clearedCustomVaccines.count,
vaccinations: clearedVaccinations.count,
vaccinationLinks: clearedLinks.count,
};
-21
View File
@@ -1,21 +0,0 @@
/**
* The answer a route gives while its contract exists and its body does not.
*
* A release built in parallel packages lands every new route as a stub first,
* registered in the OpenAPI table with its final shape, so the route-coverage
* and contract checks hold in every package's worktree from the start. The
* stub authenticates exactly as the finished route will and then answers 501.
*
* Nothing may ship with a stub in it: the error-code catalogue check fails on
* a listed code no route emits, so the code leaves the catalogue, and this
* file with it, once the last stub is implemented.
*/
import { apiError } from "@/lib/api-response";
export const NOT_IMPLEMENTED_ERROR_CODE = "not_implemented";
export function notImplemented() {
return apiError("Not implemented", 501, {
errorCode: NOT_IMPLEMENTED_ERROR_CODE,
});
}
+50 -6
View File
@@ -66,7 +66,11 @@ import { listTargets } from "@/lib/links";
import type { LinkedTarget } from "@/lib/links/link-service";
import { isModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
import {
CUSTOM_VACCINE_RESOLVE_SELECT,
customLookupOf,
resolveVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
// ── Bounds ───────────────────────────────────────────────────────────
@@ -430,8 +434,8 @@ function vaccineCatalogName(
slug: string | null,
locale: Locale,
): string | null {
const entry = resolveCatalogEntry(slug);
if (!entry) return null;
const entry = resolveVaccineEntry({ antigenSlug: slug });
if (!entry?.slug) return null;
return getServerTranslator(locale).t(`vaccinations.catalog.${entry.slug}`);
}
@@ -646,8 +650,27 @@ const VACCINATION_SELECT = {
lotNumber: true,
site: true,
practitioner: PRACTITIONER_SELECT,
// v1.42 (#1005) — the person's own definition, for its name and antigens.
customVaccineId: true,
customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT },
} as const;
/**
* The person's own vaccine definition a dose names, when it is the answer
* (the catalogue wins when the dose also carries a slug). Its name is user
* text and is scrubbed or fenced by the caller.
*/
function ownVaccine(row: {
antigenSlug: string | null;
customVaccineId: string | null;
customVaccine: Parameters<typeof customLookupOf>[0][number];
}): { name: string; components: readonly string[] } | null {
const entry = resolveVaccineEntry(row, customLookupOf([row.customVaccine]));
return entry?.kind === "custom" && entry.name
? { name: entry.name, components: entry.components }
: null;
}
async function conditionCandidates(
userId: string,
owner: Owner,
@@ -858,10 +881,15 @@ async function vaccinationCandidates(
});
return rows.map((row) => {
const practitioner = livePractitioner(row.practitioner);
const entry = resolveCatalogEntry(row.antigenSlug);
const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug });
const own = ownVaccine(row);
const catalogEn = vaccineCatalogName(row.antigenSlug, "en");
const name =
row.vaccineName ?? catalogEn ?? row.antigenSlug ?? "Vaccination";
row.vaccineName ??
catalogEn ??
own?.name ??
row.antigenSlug ??
"Vaccination";
const dose = row.doseNumber ? `dose ${row.doseNumber}, ` : "";
const day = dateOnly(row.occurredAt);
return {
@@ -881,6 +909,10 @@ async function vaccinationCandidates(
? enumWords(row.antigenSlug.replace(/-/g, "_"))
: "",
...(entry?.synonyms ?? []),
own?.name ?? "",
...(own?.components ?? []).map((antigen) =>
enumWords(antigen.replace(/-/g, "_")),
),
].join(" "),
weight: WEIGHT.primary,
},
@@ -1072,6 +1104,8 @@ async function fetchVisit(
vaccineName: true,
antigenSlug: true,
occurredAt: true,
customVaccineId: true,
customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT },
},
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
})
@@ -1087,6 +1121,7 @@ async function fetchVisit(
label: scrubFenceMarkers(
dose.vaccineName ??
vaccineCatalogName(dose.antigenSlug, "en") ??
ownVaccine(dose)?.name ??
dose.antigenSlug ??
"Vaccination",
),
@@ -1377,7 +1412,9 @@ async function fetchVaccination(
]);
const catalog = vaccineCatalogName(row.antigenSlug, "en");
const name = row.vaccineName ?? catalog ?? row.antigenSlug ?? "Vaccination";
const own = ownVaccine(row);
const name =
row.vaccineName ?? catalog ?? own?.name ?? row.antigenSlug ?? "Vaccination";
const practitioner = livePractitioner(row.practitioner);
const day = dateOnly(row.occurredAt);
const dose =
@@ -1388,6 +1425,13 @@ async function fetchVaccination(
const sentences = [
`Vaccination ${fenceUserText(name)} on ${day}, ${dose}.`,
catalog && row.vaccineName ? `Catalogue entry: ${catalog}.` : null,
own
? `Logged against the person's own vaccine ${fenceUserText(own.name)}${
own.components.length > 0
? `, which protects against ${own.components.join(", ")}`
: ""
}.`
: null,
practitioner
? `Given by ${fenceUserText(practitioner.name)}.`
: "No practitioner recorded.",
+4 -1
View File
@@ -380,6 +380,10 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"vaccination.booster-invalid",
"vaccination.booster-no-antigen",
"vaccination.booster-out-of-scope",
"vaccination.custom-vaccine-not-found",
"vaccination.custom.invalid",
"vaccination.custom.name-taken",
"vaccination.custom.not-found",
"vaccination.encounter-not-found",
"vaccination.identity-required",
"vaccination.invalid",
@@ -410,7 +414,6 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"modules_conflict",
"mood_tag_layout_conflict",
"not_configured",
"not_implemented",
"notification_prefs_conflict",
"oidc_only",
"private_origin_not_approved",
+1 -7
View File
@@ -9,12 +9,7 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { inviteCreateSchema } from "@/lib/validations/invite";
import { adminReminderCheckSchema } from "@/lib/validations/notifications";
import {
dataEnvelope,
errorEnvelope,
notImplementedResponse,
stdResponses,
} from "./shared";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification
// subsystem. Mirrors the runtime types in
@@ -310,7 +305,6 @@ export const adminDiagnosticPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
-15
View File
@@ -269,21 +269,6 @@ export const malformedJsonResponse = {
},
};
/**
* The 501 a route answers while its contract is published ahead of its
* implementation on a release branch. Every stub carries it; the owner of the
* route drops it with the stub. No tagged release answers it: the error-code
* catalogue check fails once `not_implemented` is listed and nothing emits
* it, and fails while something still does and it is not listed.
*/
export const notImplementedResponse = {
"501": {
description:
"Not implemented yet: the contract is published ahead of the implementation on a release branch. `meta.errorCode: not_implemented`.",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
export const stdResponses = {
+79 -56
View File
@@ -29,6 +29,8 @@ import {
vaccinationSiteEnum,
vaccinationBoosterSchema,
vaccinationSuggestQuerySchema,
customVaccineCreateSchema,
customVaccineUpdateSchema,
} from "@/lib/validations/vaccinations";
import { measurementReminderDto } from "@/lib/validations/measurement-reminders";
@@ -37,7 +39,6 @@ import {
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
notImplementedResponse,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
@@ -49,13 +50,13 @@ import {
const createVaccinationRequest = 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.",
"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. At least one identity arm is required: `antigenSlug` (a slug the catalogue offers), `customVaccineId` (one of the record's own vaccine definitions, see `/api/vaccinations/custom`; an id the record does not hold live is a 404 `vaccination.custom-vaccine-not-found`) or `vaccineName` (whatever the person's own record says, verbatim, which may be a trade name). When both a slug and a definition are given, the catalogue is the answer. 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, the antigens of an own definition included.",
});
const updateVaccinationRequest = 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.",
"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 no identity arm (slug, own definition or name) is refused. `customVaccineId: null` unlinks the dose from its own definition. A present `documentIds` array replaces the links, empty array included.",
});
const listVaccinationsQuery = vaccinationListQuerySchema.meta({
@@ -150,6 +151,55 @@ const practitioner = z
"The practice that administered the dose, resolved rather than an id. Null when the record names none.",
});
// v1.42 (#1005) — the record's own vaccine definitions.
const customVaccine = z
.object({
id: z.string(),
name: z.string(),
components: z
.array(z.string())
.describe(
"Catalogue antigen slugs the product protects against, only ones this release knows.",
),
typicalSeriesDoses: z.number().int().nullable(),
boosterIntervalMonths: z.number().int().nullable(),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "CustomVaccine",
description:
"A vaccine the record's owner defined for a product the shipped catalogue does not list, in the shape a catalogue entry resolves to. A dose logged against it counts into the series of each listed antigen, settles a booster reminder keyed to one of them and offers one from `boosterIntervalMonths`, exactly as a catalogue pick does. `name` is the person's own wording.",
});
const customVaccineCreateRequest = customVaccineCreateSchema.meta({
id: "CreateCustomVaccineRequest",
description:
"A new definition. `components` lists one to all of the catalogue's antigen slugs, each once — a disease outside that list is logged as a free-text dose instead. `typicalSeriesDoses` (1–10) and `boosterIntervalMonths` (1–600) are optional; the interval prefills the booster offer after a dose.",
});
const customVaccineUpdateRequest = customVaccineUpdateSchema.meta({
id: "UpdateCustomVaccineRequest",
description:
"Partial edit; an omitted key is left untouched and an empty body is a 422. The change applies to every dose logged against the definition, because the series is derived on read; it never re-runs booster satisfaction.",
});
const customVaccineNotFound = {
"404": {
description:
"No live definition with this id on the record (`vaccination.custom.not-found`).",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
const customVaccineNameTaken = {
"409": {
description:
"A live definition on the record already carries this name, compared without case (`vaccination.custom.name-taken`).",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
const vaccination = z
.object({
id: z.string(),
@@ -161,6 +211,14 @@ const vaccination = z
lotNumber: z.string().nullable(),
site: vaccinationSiteEnum.nullable(),
catalogEntry: catalogEntry.nullable(),
customVaccineId: z.string().nullable().meta({
description:
"The record's own vaccine definition the dose names (v1.42), verbatim.",
}),
customVaccine: customVaccine.nullable().meta({
description:
"That definition, resolved, or null when the dose names none or it was removed. When `catalogEntry` is also set, the catalogue is the answer.",
}),
series: z.array(seriesPosition),
practitioner: practitioner.nullable(),
encounter: vaccinationEncounter.nullable(),
@@ -244,43 +302,15 @@ const vaccinationNotFound = {
const idPath = { path: z.object({ id: z.string() }) };
// v1.42 (#1005) — the record's own vaccine definitions.
const customVaccine = z
.object({
id: z.string(),
name: z.string(),
components: z
.array(z.string())
.describe("Catalogue antigen slugs the product covers."),
typicalSeriesDoses: z.number().int().nullable(),
boosterIntervalMonths: z.number().int().nullable(),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({ id: "CustomVaccine" });
const customVaccineCreateRequest = z
.object({
name: z.string(),
components: z.array(z.string()),
typicalSeriesDoses: z.number().int().nullable().optional(),
boosterIntervalMonths: z.number().int().nullable().optional(),
})
.strict()
.meta({ id: "CreateCustomVaccineRequest" });
const customVaccineUpdateRequest = customVaccineCreateRequest
.partial()
.meta({ id: "UpdateCustomVaccineRequest" });
export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/vaccinations/custom": {
get: {
tags: ["Records"],
summary: "List the record's own vaccines",
description:
"Vaccine definitions the record's owner added for products the shipped catalogue does not list. Soft-deleted definitions are excluded.",
"The vaccine definitions the record's owner added for products the shipped catalogue does not list, by name. Removed definitions are excluded. Readable under a `profile` grant like the dose list.",
responses: {
...recordRefusal(),
"200": {
description: "The definitions, by name.",
content: {
@@ -293,19 +323,19 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...notImplementedResponse,
},
},
post: {
tags: ["Records"],
summary: "Add an own vaccine",
description:
"Defines a vaccine in the same shape a catalogue entry resolves to, so series and booster logic treat both alike. Names are unique per record.",
"Defines a vaccine in the same shape a catalogue entry resolves to, so the series and booster logic treat both alike. Names are unique per record, compared without case; adding a name the person removed earlier brings that definition back with the new values (its doses were let go on removal and stay so). Audits as `vaccination.custom.create`.",
requestBody: {
required: true,
content: { "application/json": { schema: customVaccineCreateRequest } },
},
responses: {
...recordRefusal(),
"201": {
description: "Created.",
content: {
@@ -317,13 +347,9 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"409": {
description: "A live definition with this name already exists.",
content: { "application/json": { schema: errorEnvelope } },
},
...customVaccineNameTaken,
...stdResponses,
...recordWriteRateLimitResponse,
...notImplementedResponse,
},
},
},
@@ -331,13 +357,15 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
patch: {
tags: ["Records"],
summary: "Edit an own vaccine",
description: "Changes one definition. Owner-scoped; an unknown id 404s.",
description:
"Changes one definition; every dose logged against it reads the change. Owner-scoped; an unknown or removed id 404s. Audits as `vaccination.custom.update`, naming a rename without quoting it.",
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: customVaccineUpdateRequest } },
},
responses: {
...recordRefusal(),
"200": {
description: "Updated.",
content: {
@@ -349,25 +377,19 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"404": {
description: "No such definition on this record.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description: "A live definition with this name already exists.",
content: { "application/json": { schema: errorEnvelope } },
},
...customVaccineNotFound,
...customVaccineNameTaken,
...stdResponses,
...notImplementedResponse,
},
},
delete: {
tags: ["Records"],
summary: "Remove an own vaccine",
description:
"Soft-deletes one definition. Doses logged against it stay, on their `vaccineName`.",
"Soft-deletes one definition and lets go of every dose that named it. A dose keeps a name: one without wording of its own takes the definition's name as its `vaccineName` first. Removing twice succeeds. Audits as `vaccination.custom.delete` with the number of doses let go.",
requestParams: idPath,
responses: {
...recordRefusal(),
"200": {
description: "Removed.",
content: {
@@ -379,12 +401,8 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"404": {
description: "No such definition on this record.",
content: { "application/json": { schema: errorEnvelope } },
},
...customVaccineNotFound,
...stdResponses,
...notImplementedResponse,
},
},
},
@@ -421,6 +439,11 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
responses: {
...idempotentWrite(),
...recordRefusal(),
"404": {
description:
"A practice, visit or own vaccine the body names is not the record's (`vaccination.practitioner-not-found`, `vaccination.encounter-not-found`, `vaccination.custom-vaccine-not-found`).",
content: { "application/json": { schema: errorEnvelope } },
},
"201": {
description: "Dose logged.",
content: {
+9
View File
@@ -52,4 +52,13 @@ export const vaccinationKeys = {
vaccinationRestore: () => ["vaccinations", "restore"] as const,
vaccinationBooster: () => ["vaccinations", "booster"] as const,
vaccinationLink: () => ["vaccinations", "link"] as const,
/**
* v1.42 (#1005) — the record's own vaccine definitions. Under the
* `["vaccinations"]` root on purpose: editing or removing a definition
* re-reads every dose that names it, and the root eviction repaints both.
*/
customVaccines: () => ["vaccinations", "custom"] as const,
customVaccineCreate: () => ["vaccinations", "custom", "create"] as const,
customVaccineUpdate: () => ["vaccinations", "custom", "update"] as const,
customVaccineDelete: () => ["vaccinations", "custom", "delete"] as const,
} as const;
+6
View File
@@ -78,6 +78,8 @@ export function recordActivityVerbLine(
return t("recordSharing.activityVerb.encounterVisitCreate", { name });
case "vaccination.record.create":
return t("recordSharing.activityVerb.vaccinationRecordCreate", { name });
case "vaccination.custom.create":
return t("recordSharing.activityVerb.customVaccineCreate", { name });
case "practitioner.contact.create":
return t("recordSharing.activityVerb.practitionerContactCreate", {
name,
@@ -218,6 +220,10 @@ export function recordActivityVerbLine(
return t("recordSharing.activityVerb.vaccinationRecordDelete", { name });
case "vaccination.record.restore":
return t("recordSharing.activityVerb.vaccinationRecordRestore", { name });
case "vaccination.custom.update":
return t("recordSharing.activityVerb.customVaccineUpdate", { name });
case "vaccination.custom.delete":
return t("recordSharing.activityVerb.customVaccineDelete", { name });
case "vaccination.booster.planned":
return t("recordSharing.activityVerb.vaccinationBoosterPlanned", {
name,
@@ -23,10 +23,10 @@ import {
ANTIGEN_SLUGS,
VACCINE_CATALOG,
VACCINE_CATALOG_SLUGS,
componentsForSlug,
resolveCatalogEntry,
type AntigenSlug,
} from "@/lib/vaccinations/vaccine-catalog";
import { componentsForDose } from "@/lib/vaccinations/resolve-vaccine-entry";
const MONOVALENT = new Set<string>(ANTIGEN_SLUGS);
@@ -193,8 +193,10 @@ describe("a slug the catalogue no longer knows degrades to the free text", () =>
it("matches no antigen at all rather than guessing from the name", () => {
// The consequence that matters: an unresolvable dose must not clear a
// booster reminder. No components means no match.
expect(componentsForSlug(ORPHANED_RECORD.antigenSlug)).toEqual([]);
expect(componentsForSlug(null)).toEqual([]);
expect(
componentsForDose({ antigenSlug: ORPHANED_RECORD.antigenSlug }),
).toEqual([]);
expect(componentsForDose({ antigenSlug: null })).toEqual([]);
});
it("still resolves a slug it does have, so the check above is not vacuous", () => {
@@ -0,0 +1,138 @@
/**
* v1.42 (#1005) — the one resolver every consumer asks, and what the
* person's own vaccine definitions do through it: count into each listed
* antigen's series, take their own series length, and lose all of that the
* moment the definition is removed, falling back to the dose's name.
*/
import { describe, expect, it } from "vitest";
import {
componentsForDose,
customLookupOf,
knownAntigens,
resolveVaccineEntry,
type CustomVaccineDefinition,
} from "@/lib/vaccinations/resolve-vaccine-entry";
import { deriveSeries } from "@/lib/vaccinations/series";
const MONTH_MS = 30 * 24 * 60 * 60 * 1000;
const monthsAgo = (months: number) => new Date(Date.now() - months * MONTH_MS);
const dtp: CustomVaccineDefinition = {
id: "cv-dtp",
name: "DTP from abroad",
components: ["diphtheria", "tetanus", "pertussis"],
typicalSeriesDoses: 3,
boosterIntervalMonths: 120,
};
const customs = customLookupOf([dtp]);
describe("resolveVaccineEntry", () => {
it("resolves a catalogue slug, with its citation", () => {
const entry = resolveVaccineEntry({ antigenSlug: "tdap" });
expect(entry?.kind).toBe("catalog");
expect(entry?.slug).toBe("tdap");
expect(entry?.source).not.toBeNull();
expect(entry?.components).toEqual(["tetanus", "diphtheria", "pertussis"]);
});
it("resolves the person's own definition in the same shape, uncited", () => {
const entry = resolveVaccineEntry({ customVaccineId: "cv-dtp" }, customs);
expect(entry).toMatchObject({
kind: "custom",
slug: null,
customVaccineId: "cv-dtp",
name: "DTP from abroad",
atc: null,
category: null,
source: null,
typicalSeriesDoses: 3,
boosterIntervalMonths: 120,
});
expect(entry?.components).toEqual(["diphtheria", "tetanus", "pertussis"]);
});
it("lets the catalogue win when a dose carries both", () => {
const entry = resolveVaccineEntry(
{ antigenSlug: "influenza", customVaccineId: "cv-dtp" },
customs,
);
expect(entry?.kind).toBe("catalog");
expect(entry?.components).toEqual(["influenza"]);
});
it("answers null for nothing it knows: no arm, a dead slug, an unknown or removed definition", () => {
expect(resolveVaccineEntry({})).toBeNull();
expect(resolveVaccineEntry({ antigenSlug: "no-such-slug" })).toBeNull();
expect(
resolveVaccineEntry({ customVaccineId: "cv-missing" }, customs),
).toBeNull();
expect(resolveVaccineEntry({ customVaccineId: "cv-dtp" })).toBeNull();
const removed = customLookupOf([{ ...dtp, deletedAt: new Date() }]);
expect(
resolveVaccineEntry({ customVaccineId: "cv-dtp" }, removed),
).toBeNull();
});
it("drops antigens the catalogue does not know, and duplicates, but keeps the rest", () => {
expect(
knownAntigens(["tetanus", "unicorn-pox", "tetanus", "rabies"]),
).toEqual(["tetanus", "rabies"]);
const odd = customLookupOf([
{ ...dtp, id: "cv-odd", components: ["unicorn-pox", "cholera"] },
]);
expect(componentsForDose({ customVaccineId: "cv-odd" }, odd)).toEqual([
"cholera",
]);
});
});
describe("a dose logged against an own definition, in the series", () => {
it("counts into each listed antigen, at that antigen's own position", () => {
const history = [
// Two catalogue tetanus doses first, then the DTP definition.
{
id: "t1",
occurredAt: monthsAgo(48),
antigenSlug: "tetanus",
doseNumber: null,
seriesDoses: null,
},
{
id: "t2",
occurredAt: monthsAgo(36),
antigenSlug: "tetanus",
doseNumber: null,
seriesDoses: null,
},
{
id: "own",
occurredAt: monthsAgo(12),
antigenSlug: null,
customVaccineId: "cv-dtp",
doseNumber: null,
seriesDoses: null,
},
];
const series = deriveSeries(history, customs).get("own");
expect(series).toEqual([
{ antigen: "diphtheria", position: 1, total: 3, booster: false },
{ antigen: "tetanus", position: 3, total: 3, booster: false },
{ antigen: "pertussis", position: 1, total: 3, booster: false },
]);
});
it("places nothing once the definition is gone from the lookup", () => {
const series = deriveSeries([
{
id: "own",
occurredAt: monthsAgo(1),
antigenSlug: null,
customVaccineId: "cv-dtp",
doseNumber: null,
seriesDoses: null,
},
]);
expect(series.get("own")).toEqual([]);
});
});
+19 -5
View File
@@ -28,7 +28,10 @@
import type { Prisma } from "@/generated/prisma/client";
import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling";
import { componentsForSlug } from "@/lib/vaccinations/vaccine-catalog";
import {
componentsForDose,
loadCustomVaccineLookup,
} from "@/lib/vaccinations/resolve-vaccine-entry";
/** What the confirm carries. All three are the user's edited-or-accepted values. */
export interface BoosterMintInput {
@@ -80,14 +83,25 @@ export async function mintOrReanchorBooster(
): Promise<BoosterMintResult> {
const record = await tx.vaccinationRecord.findFirst({
where: { id: input.vaccinationId, userId, deletedAt: null },
select: { id: true, antigenSlug: true, occurredAt: true },
select: {
id: true,
antigenSlug: true,
customVaccineId: true,
occurredAt: true,
},
});
if (!record) return { outcome: "unknown-record" };
// The reminder keys on the dose's PRIMARY component antigen — for a Tdap that
// is tetanus. A person who tracks the components separately can mint further
// reminders by hand; the offer covers the one the interval belongs to.
const [antigen] = componentsForSlug(record.antigenSlug);
// is tetanus, for the person's own definition the first antigen it lists. A
// person who tracks the components separately can mint further reminders by
// hand; the offer covers the one the interval belongs to.
const [antigen] = componentsForDose(
record,
record.customVaccineId
? await loadCustomVaccineLookup(tx, userId)
: undefined,
);
if (!antigen) return { outcome: "no-antigen" };
const notifyHour = input.notifyHour ?? 9;
+69
View File
@@ -0,0 +1,69 @@
/**
* What the custom-vaccine routes share (v1.42, #1005): the columns they
* publish, the name-clash probe and the removal.
*/
import type { Prisma } from "@/generated/prisma/client";
/** The columns a definition is published with. */
export const CUSTOM_VACCINE_SELECT = {
id: true,
name: true,
components: true,
typicalSeriesDoses: true,
boosterIntervalMonths: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.CustomVaccineSelect;
/**
* A live definition on the record whose name equals `name` ignoring case,
* other than `exceptId`. "Dukoral" and "dukoral" are one product to the
* person reading the picker, even though the unique index tells them apart.
*/
export async function findLiveNameClash(
tx: Prisma.TransactionClient,
userId: string,
name: string,
exceptId?: string,
): Promise<boolean> {
const clash = await tx.customVaccine.findFirst({
where: {
userId,
deletedAt: null,
name: { equals: name, mode: "insensitive" },
...(exceptId ? { id: { not: exceptId } } : {}),
},
select: { id: true },
});
return clash !== null;
}
/**
* Remove a definition and let go of every dose that named it.
*
* The definition is tombstoned; the doses are not touched beyond the link.
* A dose that carried no wording of its own takes the definition's name as
* its `vaccineName` first, so it keeps a name — the same thing the foreign
* key's `ON DELETE SET NULL` promises for a hard delete, done by hand because
* a tombstone does not fire it. Runs in the caller's transaction so the name
* and the unlink land together. Returns how many doses were unlinked.
*/
export async function removeCustomVaccine(
tx: Prisma.TransactionClient,
userId: string,
definition: { id: string; name: string },
): Promise<number> {
await tx.vaccinationRecord.updateMany({
where: { userId, customVaccineId: definition.id, vaccineName: null },
data: { vaccineName: definition.name },
});
const unlinked = await tx.vaccinationRecord.updateMany({
where: { userId, customVaccineId: definition.id },
data: { customVaccineId: null },
});
await tx.customVaccine.update({
where: { id: definition.id },
data: { deletedAt: new Date() },
});
return unlinked.count;
}
+8 -1
View File
@@ -89,6 +89,9 @@ export async function suggestVaccinationForDate(
occurredAt: true,
antigenSlug: true,
vaccineName: true,
// v1.42 (#1005) — a dose named only by the person's own definition is
// offered under that definition's name.
customVaccine: { select: { name: true, deletedAt: true } },
},
});
@@ -96,7 +99,11 @@ export async function suggestVaccinationForDate(
id: row.id,
occurredAt: row.occurredAt.toISOString(),
antigenSlug: row.antigenSlug,
vaccineName: row.vaccineName,
vaccineName:
row.vaccineName ??
(row.customVaccine && row.customVaccine.deletedAt === null
? row.customVaccine.name
: null),
}));
if (vaccinations.length === 0) return { kind: "none" };
+61 -5
View File
@@ -19,6 +19,7 @@
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { getEvent } from "@/lib/logging/context";
import type {
CustomVaccine,
Encounter,
Practitioner,
VaccinationRecord,
@@ -28,7 +29,10 @@ import {
toPractitionerDTO,
type PractitionerDTO,
} from "@/lib/practitioners/dto";
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
import {
knownAntigens,
resolveVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
import type { SeriesPosition } from "@/lib/vaccinations/series";
import type { VaccinationRenewalDTO } from "@/lib/vaccinations/renewal";
@@ -56,6 +60,43 @@ export interface VaccinationCatalogDTO {
category: string;
}
/**
* One of the record's own vaccine definitions (v1.42, #1005). `components`
* lists only antigens the catalogue knows, so a client can name each one.
*/
export interface CustomVaccineDTO {
id: string;
name: string;
components: string[];
typicalSeriesDoses: number | null;
boosterIntervalMonths: number | null;
createdAt: string;
updatedAt: string;
}
export type CustomVaccineRow = Pick<
CustomVaccine,
| "id"
| "name"
| "components"
| "typicalSeriesDoses"
| "boosterIntervalMonths"
| "createdAt"
| "updatedAt"
>;
export function toCustomVaccineDTO(row: CustomVaccineRow): CustomVaccineDTO {
return {
id: row.id,
name: row.name,
components: knownAntigens(row.components),
typicalSeriesDoses: row.typicalSeriesDoses,
boosterIntervalMonths: row.boosterIntervalMonths,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/** The visit this dose was given at, enough to recognise it by. */
export interface VaccinationEncounterDTO {
id: string;
@@ -80,6 +121,13 @@ export interface VaccinationDTO {
* not know the slug. Null is the signal to render `vaccineName` instead.
*/
catalogEntry: VaccinationCatalogDTO | null;
/** The person's own definition the dose names, verbatim. */
customVaccineId: string | null;
/**
* That definition, resolved, or `null` when the dose names none or it was
* removed. When `catalogEntry` is also set, the catalogue is the answer.
*/
customVaccine: CustomVaccineDTO | null;
/**
* Where this dose sits in each of its antigen series, resolved server-side.
*
@@ -123,6 +171,7 @@ function decryptNote(value: Uint8Array | null): string | null {
export type VaccinationWithRelations = VaccinationRecord & {
practitioner?: Practitioner | null;
encounter?: Pick<Encounter, "id" | "occurredAt" | "kind"> | null;
customVaccine?: (CustomVaccineRow & Pick<CustomVaccine, "deletedAt">) | null;
};
export function toVaccinationDTO(
@@ -130,7 +179,11 @@ export function toVaccinationDTO(
series: SeriesPosition[],
documents?: VaccinationDocumentDTO[],
): VaccinationDTO {
const entry = resolveCatalogEntry(row.antigenSlug);
const custom =
row.customVaccine && row.customVaccine.deletedAt === null
? row.customVaccine
: null;
const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug });
return {
id: row.id,
occurredAt: row.occurredAt.toISOString(),
@@ -140,9 +193,12 @@ export function toVaccinationDTO(
seriesDoses: row.seriesDoses,
lotNumber: row.lotNumber,
site: row.site,
catalogEntry: entry
? { slug: entry.slug, atc: entry.atc, category: entry.category }
: null,
catalogEntry:
entry?.slug && entry.atc && entry.category
? { slug: entry.slug, atc: entry.atc, category: entry.category }
: null,
customVaccineId: row.customVaccineId,
customVaccine: custom ? toCustomVaccineDTO(custom) : null,
series,
practitioner: row.practitioner ? toPractitionerDTO(row.practitioner) : null,
encounter: row.encounter
@@ -0,0 +1,191 @@
/**
* The one answer to "what was this dose", catalogue or the person's own.
*
* Since v1.42 (#1005) a dose can name a vaccine the shipped catalogue does
* not list: the person defines it once (`CustomVaccine`: a name, the
* antigens it protects against, a series length, a booster interval) and
* logs doses against it. Every consumer that used to ask the catalogue —
* the series derivation, the booster satisfy matcher, the booster mint, the
* DTO, the doctor report, the document links, MCP search, the picker's info
* sentences and the booster offer — asks this module instead, so a custom
* vaccine counts into a series, clears a booster and offers one exactly the
* way a catalogue entry does.
*
* ── Precedence ─────────────────────────────────────────────────────────────
*
* The catalogue wins when both resolve. A catalogue slug is the shipped,
* sourced answer; a definition is the person's own. A dose carries one or
* the other in practice, and the rule only decides the rare row that
* carries both (an edit that picked a catalogue entry without clearing the
* definition).
*
* A definition that is soft-deleted does not resolve, and neither does an
* antigen it lists that the catalogue does not know: storage is tolerant (a
* restore writes what the file held), the answer is not. A dose with nothing
* that resolves falls back to its `vaccineName`, the same degrade a dead
* catalogue slug has always had.
*
* Pure apart from {@link loadCustomVaccineLookup}, which takes the client it
* reads through, so the client components can import the resolver itself.
*/
import type { Prisma } from "@/generated/prisma/client";
import {
ANTIGEN_SLUGS,
resolveCatalogEntry,
type AntigenSlug,
type VaccineCategory,
} from "@/lib/vaccinations/vaccine-catalog";
/** A definition as the resolver reads it. */
export interface CustomVaccineDefinition {
id: string;
name: string;
components: readonly string[];
typicalSeriesDoses: number | null;
boosterIntervalMonths: number | null;
/** Absent on a DTO, which only ever carries live definitions. */
deletedAt?: Date | null;
}
/** The record's definitions, by id. */
export type CustomVaccineLookup = ReadonlyMap<string, CustomVaccineDefinition>;
/** The fields of a dose the resolver reads. */
export interface VaccineIdentity {
antigenSlug?: string | null;
customVaccineId?: string | null;
}
/** The resolved entry, in the shape a catalogue seed has. */
export interface ResolvedVaccineEntry {
kind: "catalog" | "custom";
/** The catalogue slug, or null for a definition. */
slug: string | null;
/** The definition's id, or null for a catalogue entry. */
customVaccineId: string | null;
/**
* The definition's own name. Null for a catalogue entry, whose name
* resolves through i18n (`vaccinations.catalog.<slug>`).
*/
name: string | null;
/** WHO ATC code; a definition has none. */
atc: string | null;
/** The antigens a dose counts into. */
components: readonly AntigenSlug[];
typicalSeriesDoses: number | null;
boosterIntervalMonths: number | null;
/** Picker grouping; a definition has none. */
category: VaccineCategory | null;
/** The citation for the numbers; a definition is the person's, not cited. */
source: string | null;
/** Generic search aliases; a definition has its name and nothing else. */
synonyms: readonly string[];
}
const KNOWN_ANTIGENS: ReadonlySet<string> = new Set(ANTIGEN_SLUGS);
/** The listed antigens the catalogue knows, deduplicated, in stored order. */
export function knownAntigens(raw: readonly string[]): AntigenSlug[] {
const seen = new Set<string>();
const out: AntigenSlug[] = [];
for (const value of raw) {
if (!KNOWN_ANTIGENS.has(value) || seen.has(value)) continue;
seen.add(value);
out.push(value as AntigenSlug);
}
return out;
}
/**
* Resolve a dose to its entry, catalogue first. Null when neither arm
* resolves — the caller then falls back to the dose's `vaccineName`.
*/
export function resolveVaccineEntry(
record: VaccineIdentity,
customs?: CustomVaccineLookup,
): ResolvedVaccineEntry | null {
const catalog = resolveCatalogEntry(record.antigenSlug);
if (catalog) {
return {
kind: "catalog",
slug: catalog.slug,
customVaccineId: null,
name: null,
atc: catalog.atc,
components: catalog.components,
typicalSeriesDoses: catalog.typicalSeriesDoses,
boosterIntervalMonths: catalog.boosterIntervalMonths,
category: catalog.category,
source: catalog.source,
synonyms: catalog.synonyms ?? [],
};
}
if (!record.customVaccineId || !customs) return null;
const custom = customs.get(record.customVaccineId);
if (!custom || custom.deletedAt) return null;
return {
kind: "custom",
slug: null,
customVaccineId: custom.id,
name: custom.name,
atc: null,
components: knownAntigens(custom.components),
typicalSeriesDoses: custom.typicalSeriesDoses,
boosterIntervalMonths: custom.boosterIntervalMonths,
category: null,
source: null,
synonyms: [],
};
}
/**
* The antigens a dose counts into. Empty for a free-text-only dose and for
* one whose arms no longer resolve: a name string is not evidence about
* which antigens were given, and guessing from one is how a booster reminder
* would be cleared by a dose that never contained it.
*/
export function componentsForDose(
record: VaccineIdentity,
customs?: CustomVaccineLookup,
): readonly AntigenSlug[] {
return resolveVaccineEntry(record, customs)?.components ?? [];
}
/** A lookup over the definitions a list of rows carries. */
export function customLookupOf(
definitions: ReadonlyArray<CustomVaccineDefinition | null | undefined>,
): CustomVaccineLookup {
const map = new Map<string, CustomVaccineDefinition>();
for (const definition of definitions) {
if (definition) map.set(definition.id, definition);
}
return map;
}
/** The columns a definition is resolved from. */
export const CUSTOM_VACCINE_RESOLVE_SELECT = {
id: true,
name: true,
components: true,
typicalSeriesDoses: true,
boosterIntervalMonths: true,
deletedAt: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.CustomVaccineSelect;
/**
* The record's live definitions, by id. An Impfpass names a handful at most,
* so the whole set is read rather than the ids a page happens to need.
*/
export async function loadCustomVaccineLookup(
client: Pick<Prisma.TransactionClient, "customVaccine">,
userId: string,
): Promise<CustomVaccineLookup> {
const rows = await client.customVaccine.findMany({
where: { userId, deletedAt: null },
select: CUSTOM_VACCINE_RESOLVE_SELECT,
});
return customLookupOf(rows);
}
+15 -4
View File
@@ -21,7 +21,9 @@
*
* A free-text-only dose matches nothing. Nothing here reads `vaccineName`: a
* name string is not evidence about which antigens were in the syringe, and a
* booster cleared by a guess is worse than a booster not cleared at all.
* booster cleared by a guess is worse than a booster not cleared at all. A
* dose logged against the person's own vaccine definition (v1.42, #1005)
* counts its listed antigens, the same way a catalogue combination does.
*
* ── Cold path only ─────────────────────────────────────────────────────────
*
@@ -50,7 +52,11 @@ import {
satisfyReminder,
type SatisfiableReminder,
} from "@/lib/measurement-reminders/satisfy";
import { componentsForSlug } from "@/lib/vaccinations/vaccine-catalog";
import {
componentsForDose,
loadCustomVaccineLookup,
type VaccineIdentity,
} from "@/lib/vaccinations/resolve-vaccine-entry";
/** What the create route needs back: which reminders moved, and the first. */
export interface BoosterSatisfyResult {
@@ -82,11 +88,16 @@ const EMPTY: BoosterSatisfyResult = {
export async function satisfyBoostersForDose(
tx: Prisma.TransactionClient,
userId: string,
antigenSlug: string | null,
identity: VaccineIdentity,
occurredAt: Date,
timezone: string,
): Promise<BoosterSatisfyResult> {
const components = componentsForSlug(antigenSlug);
const components = componentsForDose(
identity,
identity.customVaccineId
? await loadCustomVaccineLookup(tx, userId)
: undefined,
);
if (components.length === 0) return EMPTY;
const reminders = await tx.measurementReminder.findMany({
+17 -11
View File
@@ -28,11 +28,11 @@
* medical-device boundary starts, and it is a separate venture's territory.
* What this function reports is arithmetic on what a person wrote down.
*/
import type { AntigenSlug } from "@/lib/vaccinations/vaccine-catalog";
import {
componentsForSlug,
resolveCatalogEntry,
type AntigenSlug,
} from "@/lib/vaccinations/vaccine-catalog";
resolveVaccineEntry,
type CustomVaccineLookup,
} from "@/lib/vaccinations/resolve-vaccine-entry";
/** One antigen's view of one dose. */
export interface SeriesPosition {
@@ -46,7 +46,8 @@ export interface SeriesPosition {
position: number;
/**
* How long the series is: the record's own override first, then the
* catalogue's typical value, then null when neither knows. Null is honest —
* catalogue's (or the person's own definition's) typical value, then null
* when neither knows. Null is honest —
* a seasonal vaccine has no "of M".
*/
total: number | null;
@@ -62,6 +63,8 @@ export interface SeriesInputRecord {
id: string;
occurredAt: Date;
antigenSlug: string | null;
/** The person's own definition (v1.42, #1005), resolved after the slug. */
customVaccineId?: string | null;
doseNumber: number | null;
seriesDoses: number | null;
}
@@ -80,6 +83,7 @@ export interface SeriesInputRecord {
*/
export function deriveSeries(
records: readonly SeriesInputRecord[],
customs?: CustomVaccineLookup,
): Map<string, SeriesPosition[]> {
const result = new Map<string, SeriesPosition[]>();
for (const record of records) result.set(record.id, []);
@@ -97,13 +101,14 @@ export function deriveSeries(
for (const record of chronological) {
// A free-text-only record — and a record whose slug the catalogue no
// longer resolves — yields nothing. Guessing an antigen from a name string
// is how a booster gets cleared by a dose that never contained it.
const components = componentsForSlug(record.antigenSlug);
// longer resolves, or whose own definition was removed — yields nothing.
// Guessing an antigen from a name string is how a booster gets cleared by
// a dose that never contained it.
const entry = resolveVaccineEntry(record, customs);
const components = entry?.components ?? [];
if (components.length === 0) continue;
const catalogTotal =
resolveCatalogEntry(record.antigenSlug)?.typicalSeriesDoses ?? null;
const catalogTotal = entry?.typicalSeriesDoses ?? null;
// The record's own override wins: it is what the person's actual schedule
// was, and the catalogue only knows the typical one.
const total = record.seriesDoses ?? catalogTotal;
@@ -140,6 +145,7 @@ export function deriveSeries(
export function seriesForRecord(
recordId: string,
history: readonly SeriesInputRecord[],
customs?: CustomVaccineLookup,
): SeriesPosition[] {
return deriveSeries(history).get(recordId) ?? [];
return deriveSeries(history, customs).get(recordId) ?? [];
}
+30 -2
View File
@@ -17,6 +17,10 @@ import {
deriveSeries,
type SeriesInputRecord,
} from "@/lib/vaccinations/series";
import {
CUSTOM_VACCINE_RESOLVE_SELECT,
loadCustomVaccineLookup,
} from "@/lib/vaccinations/resolve-vaccine-entry";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/** Matches the visits service: the fallback when an account never set one. */
@@ -25,6 +29,9 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const VACCINATION_INCLUDE = {
practitioner: true,
encounter: { select: { id: true, occurredAt: true, kind: true } },
// v1.42 (#1005) — the person's own definition, resolved onto the DTO so no
// client needs a second read to name the dose or count its antigens.
customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT },
} as const satisfies Prisma.VaccinationRecordInclude;
/** What the series derivation needs, and nothing more. */
@@ -32,6 +39,7 @@ export const SERIES_SELECT = {
id: true,
occurredAt: true,
antigenSlug: true,
customVaccineId: true,
doseNumber: true,
seriesDoses: true,
} as const satisfies Prisma.VaccinationRecordSelect;
@@ -56,12 +64,20 @@ export async function loadSeriesHistory(
});
}
/** Every record's resolved series positions, keyed by record id. */
/**
* Every record's resolved series positions, keyed by record id. The record's
* own vaccine definitions are read beside the history, so a dose logged
* against one counts into the antigens it lists.
*/
export async function resolveSeriesFor(
tx: Prisma.TransactionClient,
userId: string,
) {
return deriveSeries(await loadSeriesHistory(tx, userId));
const [history, customs] = await Promise.all([
loadSeriesHistory(tx, userId),
loadCustomVaccineLookup(tx, userId),
]);
return deriveSeries(history, customs);
}
/**
@@ -152,6 +168,18 @@ export async function resolveOwnerTimezone(userId: string): Promise<string> {
return row?.timezone || DEFAULT_TIMEZONE;
}
/** Does the record own a live vaccine definition with this id? */
export async function resolveOwnedCustomVaccine(
tx: Prisma.TransactionClient,
userId: string,
customVaccineId: string,
): Promise<{ id: string } | null> {
return tx.customVaccine.findFirst({
where: { id: customVaccineId, userId, deletedAt: null },
select: { id: true },
});
}
/** Does the caller own a live visit with this id? */
export async function resolveOwnedEncounter(
tx: Prisma.TransactionClient,
+5 -15
View File
@@ -28,8 +28,11 @@
* A record stores `antigenSlug` verbatim, backups restore it without
* validation, and a slug this file has stopped listing must still describe
* what someone was given. {@link resolveCatalogEntry} is the ONLY lookup path
* and it returns `null` rather than throwing; every consumer answers `null` by
* falling back to the record's own `vaccineName`. That degrade is a tested
* into this file and it returns `null` rather than throwing. Consumers do not
* call it directly: since v1.42 (#1005) they ask
* `src/lib/vaccinations/resolve-vaccine-entry.ts`, which tries the catalogue
* first and the person's own vaccine definitions second, and every consumer
* answers `null` by falling back to the record's own `vaccineName`. That degrade is a tested
* guarantee, not an accident — see
* `src/lib/vaccinations/__tests__/catalog-integrity.test.ts`.
*
@@ -546,16 +549,3 @@ export function resolveCatalogEntry(
if (!slug) return null;
return BY_SLUG.get(slug) ?? null;
}
/**
* The antigens a dose with this slug counts into.
*
* Empty for an unknown slug and for a free-text-only record: a name string is
* not evidence about which antigens were given, and guessing from one is how
* a booster reminder would be cleared by a dose that never contained it.
*/
export function componentsForSlug(
slug: string | null | undefined,
): readonly AntigenSlug[] {
return resolveCatalogEntry(slug)?.components ?? [];
}
@@ -183,13 +183,23 @@ describe("an edit names what it changes", () => {
).toBe(true);
});
it("refuses an edit that clears both identity arms at once", () => {
it("refuses an edit that clears every identity arm at once", () => {
expect(
vaccinationUpdateSchema.safeParse({
antigenSlug: null,
vaccineName: null,
customVaccineId: null,
}).success,
).toBe(false);
});
it("leaves clearing the slug and the name to the merged-row check, because an own vaccine may remain", () => {
expect(
vaccinationUpdateSchema.safeParse({
antigenSlug: null,
vaccineName: null,
}).success,
).toBe(false);
).toBe(true);
});
it("allows clearing one arm, because the other may still be there", () => {
+3
View File
@@ -85,6 +85,8 @@ export interface BackupSummary {
vaccinations: number;
/** v1.37.19 (A6-8) — vaccination↔document links. */
vaccinationLinks: number;
/** v1.42 (#1005) — the person's own vaccine definitions. */
customVaccines: number;
/** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */
measurementReminders: number;
/** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */
@@ -176,6 +178,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
payload.encounterConditionLinks.length,
vaccinations: payload.vaccinations.length,
vaccinationLinks: payload.vaccinationDocumentLinks.length,
customVaccines: payload.customVaccines.length,
// v1.37.20 (#223 / iOS #68) — counted from the release that carries them,
// so the admin's "what did I just restore" answer never under-counts a
// file with reminders the way it once did for visits.
+21
View File
@@ -1311,6 +1311,24 @@ const vaccinationBackupSchema = z
})
.passthrough();
/**
* One of the person's own vaccine definitions (v1.42, #1005). `components`
* is a list of free strings rather than the antigen enum, for the reason
* `antigenSlug` is: a file the app wrote must restore.
*/
const customVaccineBackupSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
components: z.array(z.string()).optional(),
typicalSeriesDoses: z.number().int().nullable().optional(),
boosterIntervalMonths: z.number().int().nullable().optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
const vaccinationLinkBackupSchema = z
.object({
vaccinationId: z.string().min(1),
@@ -2081,6 +2099,9 @@ export const backupPayloadSchema = z
// for the same reason as the sections above: a file written before the
// tables existed carries no key, and an account with an empty Impfpass
// writes [].
// v1.42 (#1005) — defaulted: a file written before definitions
// travelled carries no key, and its doses name none.
customVaccines: z.array(customVaccineBackupSchema).default([]),
vaccinations: z.array(vaccinationBackupSchema).default([]),
vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]),
// The Vorsorge reminders and their completion ledger (v1.37.20, #223 /
+66 -6
View File
@@ -25,7 +25,10 @@
*/
import { z } from "zod/v4";
import { VACCINE_CATALOG_SLUGS } from "@/lib/vaccinations/vaccine-catalog";
import {
ANTIGEN_SLUGS,
VACCINE_CATALOG_SLUGS,
} from "@/lib/vaccinations/vaccine-catalog";
const KNOWN_SLUGS: ReadonlySet<string> = new Set(VACCINE_CATALOG_SLUGS);
@@ -99,7 +102,7 @@ const linkIds = z.array(id).max(100).optional();
* rather than leaving the person guessing which of the two the server meant.
*/
const IDENTITY_MESSAGE =
"name the vaccine: pick one from the catalogue or type what the record says";
"name the vaccine: pick one from the catalogue or your own vaccines, or type what the record says";
export const vaccinationCreateSchema = z
.object({
@@ -114,13 +117,18 @@ export const vaccinationCreateSchema = z
site: vaccinationSiteEnum.nullable().optional(),
practitionerId: id.nullable().optional(),
encounterId: id.nullable().optional(),
/**
* v1.42 (#1005) — one of the record's own vaccine definitions, the third
* identity arm. Re-narrowed to the resolved record in the route.
*/
customVaccineId: id.nullable().optional(),
/** Becomes `noteEncrypted`. Impfreaktionen live here. */
note: z.string().max(2000).nullable().optional(),
documentIds: linkIds,
})
.strict()
.superRefine((body, ctx) => {
if (body.antigenSlug || body.vaccineName) return;
if (body.antigenSlug || body.vaccineName || body.customVaccineId) return;
for (const path of ["antigenSlug", "vaccineName"] as const) {
ctx.addIssue({ code: "custom", path: [path], message: IDENTITY_MESSAGE });
}
@@ -151,6 +159,7 @@ export const vaccinationUpdateSchema = z
site: vaccinationSiteEnum.nullable().optional(),
practitionerId: id.nullable().optional(),
encounterId: id.nullable().optional(),
customVaccineId: id.nullable().optional(),
note: z.string().max(2000).nullable().optional(),
documentIds: linkIds,
})
@@ -160,9 +169,13 @@ export const vaccinationUpdateSchema = z
})
.refine(
(body) =>
// Only when the edit clears BOTH arms in one call. Clearing one while
// the other stays is legal and the route checks the merged row.
!(body.antigenSlug === null && body.vaccineName === null),
// Only when the edit clears EVERY arm in one call. Clearing one while
// another stays is legal and the route checks the merged row.
!(
body.antigenSlug === null &&
body.vaccineName === null &&
body.customVaccineId === null
),
{ path: ["antigenSlug"], message: IDENTITY_MESSAGE },
);
@@ -190,6 +203,53 @@ export const vaccinationListQuerySchema = z
export type VaccinationListQuery = z.infer<typeof vaccinationListQuerySchema>;
/* ── the record's own vaccines (v1.42, #1005) ─────────────────────── */
/**
* The antigens a definition protects against: only from the catalogue's
* closed antigen list, because the series derivation and the booster match
* both key on those slugs. A disease the list does not carry is a free-text
* dose, not a definition.
*/
export const customVaccineComponentsSchema = z
.array(z.enum(ANTIGEN_SLUGS))
.min(1)
.max(ANTIGEN_SLUGS.length)
.refine((list) => new Set(list).size === list.length, {
message: "name each antigen once",
});
/** The definition's name: what the person calls the product. */
const customVaccineName = z.string().trim().min(1).max(100);
export const customVaccineCreateSchema = z
.object({
name: customVaccineName,
components: customVaccineComponentsSchema,
typicalSeriesDoses: z.number().int().min(1).max(10).nullable().optional(),
/** Months; a decade booster is 120, bounded like the booster confirm. */
boosterIntervalMonths: z
.number()
.int()
.min(1)
.max(600)
.nullable()
.optional(),
})
.strict();
export type CustomVaccineCreate = z.infer<typeof customVaccineCreateSchema>;
/** Edit a definition; an omitted field is left untouched, an empty body 422s. */
export const customVaccineUpdateSchema = customVaccineCreateSchema
.partial()
.strict()
.refine((body) => Object.keys(body).length > 0, {
message: "name at least one field to change",
});
export type CustomVaccineUpdate = z.infer<typeof customVaccineUpdateSchema>;
/* ── links ────────────────────────────────────────────────────────── */
/**
+25
View File
@@ -457,6 +457,31 @@ export const ADMITTED_MUTATING_HANDLERS = [
"write",
"upsert",
),
// v1.42 (#1005) — the record's own vaccine definitions.
handler(
"app/api/vaccinations/custom/route.ts",
"/api/vaccinations/custom",
"POST",
"profile",
"write",
"create",
),
handler(
"app/api/vaccinations/custom/[id]/route.ts",
"/api/vaccinations/custom/[id]",
"PATCH",
"profile",
"manage",
"update",
),
handler(
"app/api/vaccinations/custom/[id]/route.ts",
"/api/vaccinations/custom/[id]",
"DELETE",
"profile",
"manage",
"delete",
),
handler(
"app/api/illness/episodes/[id]/day-logs/route.ts",
"/api/illness/episodes/[id]/day-logs",
@@ -288,6 +288,7 @@ const COUNT_BACK: Record<
p.vaccinationRecord.count({ where: { userId } }),
VaccinationDocumentLink: (p, userId) =>
p.vaccinationDocumentLink.count({ where: { userId } }),
CustomVaccine: (p, userId) => p.customVaccine.count({ where: { userId } }),
MeasurementReminder: (p, userId) =>
p.measurementReminder.count({ where: { userId } }),
MeasurementReminderEvent: (p, userId) =>
@@ -1284,10 +1285,23 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
// the skip test below). `reminderId` used to be deliberately absent here —
// it was the one reference that could never resolve — and is deliberately
// present since v1.37.20, because resolving is now the behaviour under test.
// v1.42 (#1005) — the person's own vaccine definition the dose also names.
// The catalogue slug still wins on read; the reference is what has to
// survive the trip, and it can only if the definition comes back first.
const customVaccine = await prisma.customVaccine.create({
data: {
userId: OWNER_ID,
name: "Round-trip travel combo",
components: ["typhoid", "hepatitis-a"],
typicalSeriesDoses: 2,
boosterIntervalMonths: 36,
},
});
const vaccination = await prisma.vaccinationRecord.create({
data: {
userId: OWNER_ID,
occurredAt: AT("2026-05-14T00:00:00.000Z"),
customVaccineId: customVaccine.id,
antigenSlug: "tdap",
vaccineName: "Tetanus, diphtheria and pertussis",
doseNumber: 3,
@@ -2097,6 +2111,7 @@ describe("every model the plan claims two-ended survives a real restore", () =>
practitioner: { select: { name: true } },
encounter: { select: { occurredAt: true } },
reminder: { select: { label: true } },
customVaccine: true,
documentLinks: { select: { documentId: true } },
},
});
@@ -2114,6 +2129,14 @@ describe("every model the plan claims two-ended survives a real restore", () =>
// the id survived the round trip instead of dropping to NULL the way it
// had to before the reminders travelled.
reminder: dose.reminder?.label ?? null,
customVaccine: dose.customVaccine
? {
name: dose.customVaccine.name,
components: dose.customVaccine.components,
typicalSeriesDoses: dose.customVaccine.typicalSeriesDoses,
boosterIntervalMonths: dose.customVaccine.boosterIntervalMonths,
}
: null,
links: dose.documentLinks.length,
note: dose.noteEncrypted ? decryptFromBytes(dose.noteEncrypted) : null,
}).toEqual({
@@ -2127,6 +2150,12 @@ describe("every model the plan claims two-ended survives a real restore", () =>
practitioner: "Round-trip practice",
encounterAt: "2026-06-30T08:00:00.000Z",
reminder: "Blutdruck messen",
customVaccine: {
name: "Round-trip travel combo",
components: ["typhoid", "hepatitis-a"],
typicalSeriesDoses: 2,
boosterIntervalMonths: 36,
},
links: 1,
note: "sore arm for a day, nothing else",
});
@@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
describe("the complete MANAGE handler matrix", () => {
it("starts with a non-empty, exact handler inventory", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(84);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87);
});
});
@@ -552,6 +552,12 @@ async function makeVaccination(userId: string) {
});
}
async function makeCustomVaccine(userId: string) {
return getPrismaClient().customVaccine.create({
data: { userId, name: "Travel combo", components: ["typhoid"] },
});
}
async function makeFamilyHistory(userId: string) {
return getPrismaClient().familyHistoryEntry.create({
data: { userId, relationship: "MOTHER", condition: "Hypertension" },
@@ -1794,6 +1800,81 @@ manageContract("POST /api/vaccinations/[id]/restore", {
)?.deletedAt === null,
});
writeEffectContract("POST /api/vaccinations/custom", {
contract: ADMITTED_MUTATING_HANDLERS.find(
(entry) =>
entry.route === "/api/vaccinations/custom" && entry.action === "POST",
)!,
prepare: async (ownerId) => ({ ownerId }),
act: async () => {
const { POST } = await import("@/app/api/vaccinations/custom/route");
return call(POST as Handler, "POST", "/api/vaccinations/custom", {
name: "Cholera oral",
components: ["cholera"],
});
},
ok: 201,
auditAction: "vaccination.custom.create",
applied: async ({ ownerId }) =>
(await getPrismaClient().customVaccine.count({
where: { userId: ownerId },
})) === 1,
});
manageContract("PATCH /api/vaccinations/custom/[id]", {
contract: ADMITTED_MUTATING_HANDLERS.find(
(entry) =>
entry.route === "/api/vaccinations/custom/[id]" &&
entry.action === "PATCH",
)!,
prepare: (ownerId) => makeCustomVaccine(ownerId),
act: async (row) => {
const { PATCH } = await import("@/app/api/vaccinations/custom/[id]/route");
return call(
PATCH as Handler,
"PATCH",
`/api/vaccinations/custom/${row.id}`,
{ boosterIntervalMonths: 36 },
{ id: row.id },
);
},
ok: 200,
auditAction: "vaccination.custom.update",
applied: async (row) =>
(
await getPrismaClient().customVaccine.findUnique({
where: { id: row.id },
})
)?.boosterIntervalMonths === 36,
});
manageContract("DELETE /api/vaccinations/custom/[id]", {
contract: ADMITTED_MUTATING_HANDLERS.find(
(entry) =>
entry.route === "/api/vaccinations/custom/[id]" &&
entry.action === "DELETE",
)!,
prepare: (ownerId) => makeCustomVaccine(ownerId),
act: async (row) => {
const { DELETE } = await import("@/app/api/vaccinations/custom/[id]/route");
return call(
DELETE as Handler,
"DELETE",
`/api/vaccinations/custom/${row.id}`,
undefined,
{ id: row.id },
);
},
ok: 200,
auditAction: "vaccination.custom.delete",
applied: async (row) =>
(
await getPrismaClient().customVaccine.findUnique({
where: { id: row.id },
})
)?.deletedAt !== null,
});
proveWriteRoute("POST /api/vaccinations/[id]/booster", {
route: "/api/vaccinations/[id]/booster",
// The dose the booster is planned against. `makeVaccination` records a
@@ -2765,13 +2846,13 @@ describe("the conditions the admissions were granted on", () => {
describe("the complete MANAGE handler matrix", () => {
it("registers one strict actor-and-effect driver for every admission", () => {
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
expect(STRICT_DRIVER_KEYS.size).toBe(84);
expect(STRICT_DRIVER_KEYS.size).toBe(87);
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
});
it("executes every registered driver through its owned effect and actor audit", () => {
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
expect(REAL_EFFECT_KEYS.size).toBe(84);
expect(REAL_EFFECT_KEYS.size).toBe(87);
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
});
});
@@ -0,0 +1,424 @@
/**
* v1.42 (#1005) — the record's own vaccine definitions, through the real
* routes and a real Postgres.
*
* Pinned here: a definition is created, listed, renamed and removed under the
* resolved record; a dose logged against it counts into every listed
* antigen's series and clears a booster keyed to one of them, the way a
* catalogue combination does; an id another record holds is refused; removing
* the definition leaves every dose standing with a name; the booster mint keys
* on its first antigen; and the sharing levels hold — a READ delegate lists
* but cannot add, a WRITE delegate adds onto the owner's record but cannot
* edit, and a Guardian inside a managed profile writes to the profile.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import {
GET as listCustom,
POST as createCustom,
} from "@/app/api/vaccinations/custom/route";
import {
DELETE as deleteCustom,
PATCH as patchCustom,
} from "@/app/api/vaccinations/custom/[id]/route";
import {
GET as listVaccinations,
POST as createVaccination,
} from "@/app/api/vaccinations/route";
import { POST as planBooster } from "@/app/api/vaccinations/[id]/booster/route";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const DAY_MS = 24 * 60 * 60 * 1000;
const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS);
const daysFromNow = (days: number) => new Date(Date.now() + days * DAY_MS);
let sequence = 0;
interface Person {
id: string;
sessionId: string;
}
async function person(label: string): Promise<Person> {
const suffix = sequence++;
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: `${label}-${suffix}`,
email: `${label}-${suffix}@example.test`,
timezone: "Europe/Berlin",
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: daysFromNow(1) },
});
return { id: user.id, sessionId: session.id };
}
function signIn(who: Person): void {
headerJar.delete("authorization");
cookieJar.set("healthlog_session", who.sessionId);
}
function json(method: string, url: string, body?: unknown): Request {
return new Request(`http://localhost${url}`, {
method,
headers: { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
const params = (id: string) => ({ params: Promise.resolve({ id }) }) as never;
async function addCustom(body: unknown): Promise<Response> {
return createCustom(json("POST", "/api/vaccinations/custom", body) as never);
}
async function getCustoms(): Promise<Response> {
// The GET takes no request; the wrapper still hands one through.
return (listCustom as (request: Request) => Promise<Response>)(
json("GET", "/api/vaccinations/custom"),
);
}
async function editCustom(id: string, body: unknown): Promise<Response> {
return patchCustom(
json("PATCH", `/api/vaccinations/custom/${id}`, body) as never,
params(id),
);
}
async function removeCustom(id: string): Promise<Response> {
return deleteCustom(
json("DELETE", `/api/vaccinations/custom/${id}`) as never,
params(id),
);
}
async function postDose(body: unknown): Promise<Response> {
return createVaccination(json("POST", "/api/vaccinations", body) as never);
}
interface DoseDto {
id: string;
customVaccineId: string | null;
customVaccine: { name: string } | null;
series: { antigen: string; position: number; total: number | null }[];
reminderId: string | null;
}
async function doses(): Promise<DoseDto[]> {
const res = await listVaccinations(json("GET", "/api/vaccinations") as never);
expect(res.status).toBe(200);
return ((await res.json()) as { data: { vaccinations: DoseDto[] } }).data
.vaccinations;
}
const TRAVEL = {
name: "Travel combo",
components: ["typhoid", "hepatitis-a"],
typicalSeriesDoses: 2,
boosterIntervalMonths: 36,
};
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("an owner's own vaccine definitions", () => {
it("creates, lists and refuses a second definition of the same name", async () => {
const owner = await person("owner");
signIn(owner);
const created = await addCustom(TRAVEL);
expect(created.status).toBe(201);
const def = ((await created.json()) as { data: { id: string } }).data;
const listed = await getCustoms();
expect(listed.status).toBe(200);
expect(
((await listed.json()) as { data: { id: string; name: string }[] }).data,
).toEqual([expect.objectContaining({ id: def.id, name: "Travel combo" })]);
// The same product, typed differently, is one product to the reader.
const clash = await addCustom({ ...TRAVEL, name: "travel COMBO" });
expect(clash.status).toBe(409);
expect((await clash.json()).meta?.errorCode).toBe(
"vaccination.custom.name-taken",
);
// An antigen outside the closed list is refused.
const bad = await addCustom({ name: "Other", components: ["unicorn-pox"] });
expect(bad.status).toBe(422);
});
it("counts a dose into every listed antigen and clears a booster keyed to one", async () => {
const owner = await person("owner");
signIn(owner);
const prisma = getPrismaClient();
const def = (
(await (await addCustom(TRAVEL)).json()) as { data: { id: string } }
).data;
// A hepatitis A booster already planned and due.
const reminder = await prisma.measurementReminder.create({
data: {
userId: owner.id,
label: "hepatitis A booster",
intervalDays: 3650,
notifyHour: 9,
vaccinationAntigen: "hepatitis-a",
nextDueAt: daysAgo(1),
},
});
const res = await postDose({
occurredAt: daysAgo(3).toISOString(),
customVaccineId: def.id,
});
expect(res.status, JSON.stringify(await res.clone().json())).toBe(201);
const dto = ((await res.json()) as { data: DoseDto }).data;
expect(dto.customVaccine?.name).toBe("Travel combo");
expect(dto.series).toEqual([
expect.objectContaining({ antigen: "typhoid", position: 1, total: 2 }),
expect.objectContaining({
antigen: "hepatitis-a",
position: 1,
total: 2,
}),
]);
expect(dto.reminderId).toBe(reminder.id);
expect(
(
await prisma.measurementReminder.findUniqueOrThrow({
where: { id: reminder.id },
})
).lastSatisfiedAt,
).not.toBeNull();
// An edit to the definition is read by every dose that names it.
expect((await editCustom(def.id, { components: ["typhoid"] })).status).toBe(
200,
);
expect((await doses())[0].series.map((p) => p.antigen)).toEqual([
"typhoid",
]);
});
it("keys the booster mint on the definition's first antigen", async () => {
const owner = await person("owner");
signIn(owner);
const def = (
(await (await addCustom(TRAVEL)).json()) as { data: { id: string } }
).data;
const dose = (
(await (
await postDose({
occurredAt: daysAgo(3).toISOString(),
customVaccineId: def.id,
})
).json()) as { data: { id: string } }
).data;
const res = await planBooster(
json("POST", `/api/vaccinations/${dose.id}/booster`, {
intervalMonths: 36,
label: "Travel combo booster",
}) as never,
params(dose.id),
);
expect(res.status).toBe(201);
const minted = await getPrismaClient().measurementReminder.findFirstOrThrow(
{ where: { userId: owner.id } },
);
expect(minted.vaccinationAntigen).toBe("typhoid");
});
it("refuses a definition another record holds", async () => {
const stranger = await person("stranger");
signIn(stranger);
const foreign = (
(await (await addCustom(TRAVEL)).json()) as { data: { id: string } }
).data;
const owner = await person("owner");
signIn(owner);
const res = await postDose({
occurredAt: daysAgo(3).toISOString(),
customVaccineId: foreign.id,
});
expect(res.status).toBe(404);
expect((await res.json()).meta?.errorCode).toBe(
"vaccination.custom-vaccine-not-found",
);
expect((await editCustom(foreign.id, { name: "Mine" })).status).toBe(404);
expect((await removeCustom(foreign.id)).status).toBe(404);
expect(
await getPrismaClient().vaccinationRecord.count({
where: { userId: owner.id },
}),
).toBe(0);
});
it("removing a definition keeps every dose, each with a name, and the name can be used again", async () => {
const owner = await person("owner");
signIn(owner);
const prisma = getPrismaClient();
const def = (
(await (await addCustom(TRAVEL)).json()) as { data: { id: string } }
).data;
await postDose({
occurredAt: daysAgo(30).toISOString(),
customVaccineId: def.id,
});
await postDose({
occurredAt: daysAgo(3).toISOString(),
customVaccineId: def.id,
vaccineName: "as the pass says",
});
expect((await removeCustom(def.id)).status).toBe(200);
// Removing twice succeeds.
expect((await removeCustom(def.id)).status).toBe(200);
const rows = await prisma.vaccinationRecord.findMany({
where: { userId: owner.id, deletedAt: null },
orderBy: { occurredAt: "asc" },
});
expect(rows.map((row) => [row.customVaccineId, row.vaccineName])).toEqual([
[null, "Travel combo"],
[null, "as the pass says"],
]);
expect(
((await (await getCustoms()).json()) as { data: unknown[] }).data,
).toEqual([]);
const audit = await prisma.auditLog.findFirstOrThrow({
where: { action: "vaccination.custom.delete" },
orderBy: { createdAt: "asc" },
});
const details =
typeof audit.details === "string"
? JSON.parse(audit.details)
: audit.details;
expect(details).toMatchObject({ dosesUnlinked: 2 });
// The same name again brings the definition back, without its old doses.
const again = await addCustom({ ...TRAVEL, boosterIntervalMonths: 12 });
expect(again.status).toBe(201);
expect(((await again.json()) as { data: { id: string } }).data.id).toBe(
def.id,
);
expect(
await prisma.vaccinationRecord.count({
where: { userId: owner.id, customVaccineId: def.id },
}),
).toBe(0);
});
});
describe("sharing and managed profiles", () => {
async function switchInAs(
ownerId: string,
access: "READ" | "WRITE",
): Promise<Person> {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const delegate = await person("delegate");
const invited = await inviteGrant({
grantorId: ownerId,
granteeId: delegate.id,
access,
scope: ["profile"],
});
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
signIn(delegate);
await switchSessionTo(delegate.sessionId, ownerId);
return delegate;
}
it("lets a READ delegate list the definitions but not add one", async () => {
const owner = await person("owner");
await getPrismaClient().customVaccine.create({
data: { userId: owner.id, name: "Owner's own", components: ["rabies"] },
});
await switchInAs(owner.id, "READ");
const listed = await getCustoms();
expect(listed.status).toBe(200);
expect(
((await listed.json()) as { data: { name: string }[] }).data.map(
(row) => row.name,
),
).toEqual(["Owner's own"]);
const res = await addCustom(TRAVEL);
expect(res.status).toBe(403);
expect((await res.json()).meta?.errorCode).toBe("sharing.access.denied");
});
it("lets a WRITE delegate add onto the owner's record but not edit or remove", async () => {
const owner = await person("owner");
const delegate = await switchInAs(owner.id, "WRITE");
const res = await addCustom(TRAVEL);
expect(res.status).toBe(201);
const def = ((await res.json()) as { data: { id: string } }).data;
const prisma = getPrismaClient();
expect(
(await prisma.customVaccine.findUniqueOrThrow({ where: { id: def.id } }))
.userId,
).toBe(owner.id);
expect(
await prisma.customVaccine.count({ where: { userId: delegate.id } }),
).toBe(0);
expect((await editCustom(def.id, { name: "Renamed" })).status).toBe(403);
expect((await removeCustom(def.id)).status).toBe(403);
});
it("writes a Guardian's definition to the managed profile", async () => {
const { createManagedProfile } =
await import("@/lib/managed-profiles/create");
const guardian = await person("guardian");
const { profile } = await createManagedProfile({
creatorId: guardian.id,
displayName: "Child record",
dateOfBirth: null,
locale: "en",
timezone: "Europe/Berlin",
});
signIn(guardian);
await switchSessionTo(guardian.sessionId, profile.id);
expect((await addCustom(TRAVEL)).status).toBe(201);
const prisma = getPrismaClient();
expect(
await prisma.customVaccine.count({ where: { userId: profile.id } }),
).toBe(1);
expect(
await prisma.customVaccine.count({ where: { userId: guardian.id } }),
).toBe(0);
});
});