Merge tickets part 2 into release/v1.42.0

# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 13:07:02 +02:00
commit 9d4a50282e
80 files changed
+3975 -818

No files matched your search

+369 -226
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -135,6 +135,12 @@ export again writes nothing; a newer export that overlaps an older one
adds only what is new. A value that changed on the phone is updated;
a value you deleted in HealthLog stays deleted.
Heart rate imported per minute ages the way Apple Health heart rate
does: once a minute is older than 90 days, the nightly pass folds its
hour into one hourly mean and removes the minutes. A later import of
an export that still holds those minutes leaves them out, so an hour
is never counted twice.
## Privacy
- The uploaded ZIP and the extracted database are deleted as soon as
+15 -4
View File
@@ -9639,8 +9639,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",
@@ -9772,7 +9772,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": {
@@ -11814,7 +11822,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
@@ -9639,8 +9639,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",
@@ -9772,7 +9772,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": {
@@ -11814,7 +11822,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
@@ -9639,8 +9639,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",
@@ -9772,7 +9772,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": {
@@ -11814,7 +11822,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
@@ -9639,8 +9639,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",
@@ -9772,7 +9772,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": {
@@ -11814,7 +11822,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
@@ -9639,8 +9639,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",
@@ -9772,7 +9772,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": {
@@ -11814,7 +11822,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
@@ -9639,7 +9639,7 @@
},
"form": {
"date": "날짜",
"catalogLabel": "백신 (목록)",
"catalogLabel": "백신",
"identityHint": "목록에서 고르거나 아래에 직접 입력해 주세요. 둘 중 하나면 충분해요.",
"catalogNone": "백신 선택",
"catalogSearch": "백신 검색",
@@ -9772,7 +9772,15 @@
"deleted": "백신을 삭제했습니다",
"deleteConfirm": "이 백신을 삭제할까요? 이미 기록된 접종은 이름이 그대로 남습니다.",
"saveFailed": "백신을 저장하지 못했습니다.",
"nameTaken": "같은 이름의 백신이 이미 있습니다."
"nameTaken": "같은 이름의 백신이 이미 있습니다.",
"editTitle": "직접 추가한 백신 수정",
"description": "목록에 없는 백신도 목록의 백신과 똑같이 계산돼요.",
"componentsHint": "예방하는 질병을 모두 골라 주세요.",
"boosterHint": "추가 접종이 없으면 비워 두세요.",
"protectsAgainst": "{list} 예방",
"deleteTitle": "직접 추가한 백신을 삭제할까요?",
"sectionDescription": "목록에 없어서 직접 추가한 백신이에요.",
"catalogGroup": "목록"
}
},
"illness": {
@@ -11814,7 +11822,10 @@
"symptomEventDelete": "{name}님이 증상 기록을 삭제했습니다",
"symptomDefinitionCreate": "{name}님이 증상을 추가했습니다",
"symptomDefinitionUpdate": "{name}님이 증상을 변경했습니다",
"symptomDefinitionDelete": "{name}님이 증상을 제거했습니다"
"symptomDefinitionDelete": "{name}님이 증상을 제거했습니다",
"customVaccineCreate": "{name} 님이 백신을 직접 추가했어요",
"customVaccineUpdate": "{name} 님이 직접 추가한 백신을 수정했어요",
"customVaccineDelete": "{name} 님이 직접 추가한 백신을 삭제했어요"
},
"section": {
"measurements": {
+15 -4
View File
@@ -9639,8 +9639,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",
@@ -9772,7 +9772,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": {
@@ -11814,7 +11822,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")
@@ -78,7 +78,6 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
* before the release is tagged. It is NOT a place for keys nobody will wire.
*/
const RELEASE_PENDING_PREFIXES: Record<string, string> = {
"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,
});
}
+44 -2
View File
@@ -19,8 +19,9 @@
* would count a step the phone and the watch both saw twice.
* - Heart rate: per-minute samples of the last 90 days as raw rows, older
* ones as hourly means (min and max kept), one app per hour by the vitals
* priority. The Apple Health retention fold only folds Apple Health rows,
* so this import folds its own history as it writes it.
* priority. The nightly retention fold folds the raw samples into the
* same hourly `stats:` rows once they age past 90 days, and a raw sample
* whose hour is already folded is left out (`folded_window`).
* - Sleep: one row per stage (`sleep-stage-map.ts`), a session without
* stages as one asleep row; of two overlapping sessions from different
* apps the higher-ranked app's is kept.
@@ -58,6 +59,12 @@ import {
valuesMatch,
} from "@/lib/measurements/cross-source-merge";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
import {
FOLDED_WINDOW_REASON,
findFoldedWindowDuplicates,
isFoldedWindowCandidate,
loadFoldTimezone,
} from "@/lib/measurements/folded-window";
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import { isModuleEnabled } from "@/lib/modules/gate";
@@ -214,6 +221,8 @@ class ImportRun {
private readonly userId: string;
private readonly tz: string;
private readonly now: Date;
/** The account zone the fold keys its hours in, read once on first need. */
private foldTz: string | undefined;
private readonly apps = new Map<number, string>();
private readonly leftOutApps = new Set<number>();
private readonly devices = new Map<number, string | null>();
@@ -1335,6 +1344,7 @@ class ImportRun {
});
if (sameReadingCheck) batch = await this.withoutOtherSourceTwins(batch);
if (hourlyPulse) batch = await this.withoutRawPulseHours(batch);
else batch = await this.withoutFoldedHours(batch);
if (batch.length === 0) return;
const inserted = await insertNewMeasurementRows(
@@ -1521,6 +1531,38 @@ class ImportRun {
});
}
/**
* A raw sample of a folded type is left out when the nightly fold has
* already turned its hour into a `stats:` mean under this source. The fold
* deletes the samples it summarises, so without this a later import of an
* export that still holds them would put them back beside their own mean.
* Same rule as the Apple Health batch route and ZIP import
* (`folded-window.ts`); rows too young to have been folded cost no query.
*/
private async withoutFoldedHours(rows: PendingRow[]): Promise<PendingRow[]> {
if (rows.length === 0) return rows;
const candidates = rows.map((row) => ({
type: row.type,
source: SOURCE,
externalId: row.externalId,
measuredAt: row.measuredAt,
}));
if (!candidates.some((row) => isFoldedWindowCandidate(row))) return rows;
this.foldTz ??= await loadFoldTimezone(this.prisma, this.userId);
const folded = await findFoldedWindowDuplicates(
this.prisma,
this.userId,
candidates,
{ tz: this.foldTz },
);
if (folded.size === 0) return rows;
return rows.filter((row, index) => {
if (!folded.has(index)) return true;
this.skip(FOLDED_WINDOW_REASON, 1, row.type);
return false;
});
}
/** Called by the collapse when two apps write the same instant. */
noteCollision(type: string): void {
this.skip("same_instant_other_app", 1, type);
+4 -3
View File
@@ -25,6 +25,7 @@ import {
runDenseIntradayRetention,
DENSE_INTRADAY_RETENTION_TYPES,
DENSE_INTRADAY_RETENTION_DAYS,
DENSE_INTRADAY_FOLD_SOURCES,
} from "@/lib/measurements/dense-intraday-retention";
export const DENSE_INTRADAY_RETENTION_QUEUE = "dense-intraday-retention";
@@ -140,8 +141,8 @@ export async function runDenseIntradayRetentionForUser(
/**
* Boot-time discovery. Finds every user holding at least one LIVE
* per-sample dense-tier row (an `APPLE_HEALTH` row of a
* `DENSE_INTRADAY_RETENTION_TYPES` type whose externalId does NOT start
* per-sample dense-tier row (a row of a `DENSE_INTRADAY_FOLD_SOURCES` source
* and a `DENSE_INTRADAY_RETENTION_TYPES` type whose externalId does NOT start
* with the daily-stats prefix, that is not tombstoned, AND whose
* `measuredAt` is older than the retention window) and enqueues one
* retention job per account.
@@ -187,7 +188,7 @@ export async function enqueueBootTimeDenseIntradayRetention(): Promise<{
const users = await prisma.measurement.groupBy({
by: ["userId"],
where: {
source: "APPLE_HEALTH",
source: { in: [...DENSE_INTRADAY_FOLD_SOURCES] },
type: { in: types },
deletedAt: null,
NOT: { externalId: { startsWith: "stats:" } },
+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.",
@@ -84,8 +84,12 @@ function buildPrismaMock(opts: {
};
// Top-level client.
const findMany = vi.fn(async (args: { where: { type: string } }) =>
args.where.type === "PULSE" ? opts.pulseRows : [],
// The seeded rows are Apple Health's; the Health Connect walk finds none.
const findMany = vi.fn(
async (args: { where: { type: string; source: string } }) =>
args.where.type === "PULSE" && args.where.source === "APPLE_HEALTH"
? opts.pulseRows
: [],
);
// Native-resting probe is the only top-level `findFirst` call.
const topFindFirst = vi
@@ -106,8 +106,13 @@ function buildPrismaMock(existingResting: Array<{ source: string }>) {
.mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]),
},
measurement: {
findMany: vi.fn(async (args: { where: { type: string } }) =>
args.where.type === "PULSE" ? pulseRows() : [],
// The seeded rows are Apple Health's; the Health Connect walk
// finds none.
findMany: vi.fn(
async (args: { where: { type: string; source: string } }) =>
args.where.type === "PULSE" && args.where.source === "APPLE_HEALTH"
? pulseRows()
: [],
),
findFirst: probeFindFirst,
upsert,
@@ -62,9 +62,12 @@ function buildPrismaMock(
);
const updateMany = vi.fn().mockResolvedValue({ count: 0 });
const deleteMany = vi.fn().mockResolvedValue({ count: 0 });
// The seeded rows are Apple Health's; the Health Connect walk finds none.
const findManyMeasurement = vi.fn(
async (args: { where: { type: string } }) =>
rowsByType[args.where.type] ?? [],
async (args: { where: { type: string; source: string } }) =>
args.where.source === "APPLE_HEALTH"
? (rowsByType[args.where.type] ?? [])
: [],
);
const tx = {
measurement: {
@@ -140,20 +143,21 @@ describe("runDenseIntradayRetention — retention bound", () => {
}
});
it("scans exactly the dense-tier types, source-scoped to APPLE_HEALTH", async () => {
it("scans exactly the dense-tier types, once per fold source, Apple Health first", async () => {
const { mock, findManyMeasurement } = buildPrismaMock({});
await runDenseIntradayRetention(mock, { log: () => {} });
const scannedTypes = findManyMeasurement.mock.calls.map(
(c) => (c[0] as { where: { type: string } }).where.type,
);
expect(scannedTypes.sort()).toEqual(
Array.from(DENSE_INTRADAY_RETENTION_TYPES).sort(),
);
for (const call of findManyMeasurement.mock.calls) {
const where = (call[0] as unknown as { where: { source: string } }).where;
expect(where.source).toBe("APPLE_HEALTH");
}
const scans = findManyMeasurement.mock.calls.map((c) => {
const where = (c[0] as { where: { type: string; source: string } }).where;
return `${where.source}:${where.type}`;
});
const types = Array.from(DENSE_INTRADAY_RETENTION_TYPES);
// One walk per source, each scoped to its one source: two sources
// never share a mean, and manual or Withings rows are never scanned.
expect(scans).toEqual([
...types.map((type) => `APPLE_HEALTH:${type}`),
...types.map((type) => `HEALTH_CONNECT:${type}`),
]);
});
});
@@ -75,8 +75,13 @@ function buildPrismaMock(opts: { spo2Rows: PerSampleRow[] }) {
},
};
const findMany = vi.fn(async (args: { where: { type: string } }) =>
args.where.type === "OXYGEN_SATURATION" ? opts.spo2Rows : [],
// The seeded rows are Apple Health's; the Health Connect walk finds none.
const findMany = vi.fn(
async (args: { where: { type: string; source: string } }) =>
args.where.type === "OXYGEN_SATURATION" &&
args.where.source === "APPLE_HEALTH"
? opts.spo2Rows
: [],
);
// Native-resting probe + derived-resting upsert: PULSE-only surfaces.
const topFindFirst = vi.fn().mockResolvedValue(null);
@@ -226,3 +226,80 @@ describe("findCompactionTombstones", () => {
expect(found.size).toBe(0);
});
});
/**
* Health Connect folds its raw heart-rate history the way Apple Health does:
* past the 90-day window into one `stats:` row per local hour, under its own
* source. The mean-day rule stays Apple Health only, because the nightly
* mean consolidation never reads another source.
*/
describe("Health Connect", () => {
const old = new Date("2026-03-01T10:15:00.000Z");
const hc = (
type: string,
at: Date,
overrides: Record<string, unknown> = {},
) =>
raw(type, at, {
source: "HEALTH_CONNECT",
externalId: "hc:00000000-0000-0000-0000-000000000001:1",
...overrides,
});
/** A lookup that answers per source, as the database does. */
function sourceAwareClient(
live: Array<{ type: string; source: string; externalId: string }>,
) {
const findMany = vi.fn(async (args: { where: { source: string } }) =>
live.filter((row) => row.source === args.where.source),
);
return {
findMany,
client: {
measurement: { findMany },
} as unknown as Parameters<typeof findFoldedWindowDuplicates>[0],
};
}
it("takes a raw dense sample past the window, and not a mean-type one", () => {
expect(isFoldedWindowCandidate(hc("PULSE", old), NOW)).toBe(true);
expect(
isFoldedWindowCandidate(
hc("PULSE", new Date(NOW.getTime() - 89 * DAY)),
NOW,
),
).toBe(false);
expect(isFoldedWindowCandidate(hc("RESPIRATORY_RATE", old), NOW)).toBe(
false,
);
expect(
isFoldedWindowCandidate(
hc("PULSE", old, {
externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-03-01T10",
}),
NOW,
),
).toBe(false);
});
it("is covered only by a `stats:` row of its own source", async () => {
const hour = "stats:HKQuantityTypeIdentifierHeartRate:2026-03-01T10";
const { client: c, findMany } = sourceAwareClient([
{ type: "PULSE", source: "HEALTH_CONNECT", externalId: hour },
]);
const found = await findFoldedWindowDuplicates(
c,
"u1",
[hc("PULSE", old), raw("PULSE", old)],
{ now: NOW, tz: "UTC" },
);
// The Health Connect sample is a duplicate; the Apple Health sample of
// the same hour is not, because no Apple Health `stats:` row covers it.
expect([...found]).toEqual([0]);
const sources = findMany.mock.calls.map(
(call) =>
(call as unknown as [{ where: { source: string } }])[0].where.source,
);
expect(sources.sort()).toEqual(["APPLE_HEALTH", "HEALTH_CONNECT"]);
});
});
+277 -239
View File
@@ -50,6 +50,7 @@
*/
import { holdAccountAgainstRestore } from "@/lib/export/restore-lock";
import type {
MeasurementSource,
MeasurementType,
Prisma,
PrismaClient,
@@ -154,6 +155,23 @@ export const DENSE_INTRADAY_RETENTION_TYPES: ReadonlySet<MeasurementType> =
*/
export const DENSE_INTRADAY_RETENTION_DAYS = 90;
/**
* The sources whose raw dense samples fold (v1.42). Apple Health from the
* start; Health Connect because its importer writes the samples of the last
* 90 days raw, exactly like an Apple Health sync, and without a fold every
* new export would add another quarter of minute-by-minute heart rate that
* never ages into hourly means. Each source folds into its OWN `stats:` rows,
* so an hour two sources both covered keeps one mean per source, the same
* way the raw samples kept their source.
*/
export const DENSE_INTRADAY_FOLD_SOURCES = [
"APPLE_HEALTH",
"HEALTH_CONNECT",
] as const satisfies readonly MeasurementSource[];
export type DenseIntradayFoldSource =
(typeof DENSE_INTRADAY_FOLD_SOURCES)[number];
/** The stats externalId prefix marks an already-collapsed row. */
const DAILY_STATS_PREFIX = "stats:";
@@ -249,10 +267,13 @@ export async function adoptOrMintHourlyRows(
input: {
userId: string;
type: MeasurementType;
/** The source the folded samples carry; the hourly rows carry it too. */
source?: DenseIntradayFoldSource;
unit: string;
slots: readonly HourlySlot[];
},
): Promise<string[]> {
const source = input.source ?? "APPLE_HEALTH";
if (input.slots.length === 0) return [];
// Every row that either index could put in the way, lowest id first — the
@@ -263,7 +284,7 @@ export async function adoptOrMintHourlyRows(
where: {
userId: input.userId,
type: input.type,
source: "APPLE_HEALTH",
source,
OR: [
{ externalId: { in: input.slots.map((slot) => slot.externalId) } },
{
@@ -338,7 +359,7 @@ export async function adoptOrMintHourlyRows(
type: input.type,
value: slot.value,
unit: input.unit,
source: "APPLE_HEALTH" as const,
source,
measuredAt: slot.anchor,
externalId: slot.externalId,
})),
@@ -538,263 +559,280 @@ export async function runDenseIntradayRetention(
// for the dry-run hourly fan-out estimate (writeDay never runs on dry-run).
let currentTz = DEFAULT_TIMEZONE;
const walk = await runConsolidation<MeasurementType>({
prismaClient,
options: { ...options, cutoffHours },
types: DENSE_INTRADAY_RETENTION_TYPES,
hkIdentifierForType,
dailyStatsExternalId,
statsPrefix: DAILY_STATS_PREFIX,
// The base's per-day reducedValue is the day MEAN; the hourly fold
// recomputes per-hour means itself (same `meanBucketValue` reducer, so
// the two surfaces can never drift), and the day-level value only
// feeds the summary/dry-run reporting.
reduce: meanBucketValue,
// Live per-sample rows for the type, source-scoped to APPLE_HEALTH so
// manual + Withings spot rows survive. The minted hourly stats rows are
// excluded by the NOT-startsWith predicate so they are never re-folded;
// soft-deleted rows are excluded so a re-run converges. `cutoffAt` is
// the retention boundary — rows newer than it (inside the window) are
// NOT scanned, so the dense intra-day shape is preserved.
buildScanWhere: ({ userId, type, cutoffAt, statsPrefix }) => ({
userId,
source: "APPLE_HEALTH",
type,
deletedAt: null,
NOT: { externalId: { startsWith: statsPrefix } },
...(cutoffAt ? { measuredAt: { lt: cutoffAt } } : {}),
}),
scanSelect: {
id: true,
type: true,
value: true,
measuredAt: true,
externalId: true,
unit: true,
},
writeDay: async ({
prismaClient: pc,
userId,
type,
externalId,
canonicalTimestamp,
dayRows,
sourceRowIds,
tz,
dateKey,
}): Promise<DayWriteOutcome> => {
const unit = dayRows[0]?.unit ?? "unknown";
const isPulse = type === "PULSE";
const hkIdentifier = hkIdentifierForType(type);
// Unreachable: the base skips types without an HK identifier before
// scanning; asserted so a future type-set edit fails loud, not by
// minting a malformed externalId.
if (!hkIdentifier) {
throw new Error(`no HK identifier for dense-tier type ${type}`);
}
// Awaited inside, not returned bare: the restore-lock guard finds the
// consolidation passes by their `await runConsolidation`.
const walkSource = async (source: DenseIntradayFoldSource) =>
await runConsolidation<MeasurementType>({
prismaClient,
options: { ...options, cutoffHours },
types: DENSE_INTRADAY_RETENTION_TYPES,
hkIdentifierForType,
dailyStatsExternalId,
statsPrefix: DAILY_STATS_PREFIX,
// The base's per-day reducedValue is the day MEAN; the hourly fold
// recomputes per-hour means itself (same `meanBucketValue` reducer, so
// the two surfaces can never drift), and the day-level value only
// feeds the summary/dry-run reporting.
reduce: meanBucketValue,
// Live per-sample rows for the type, scoped to the one source this walk
// folds so manual + Withings spot rows survive and two sources never
// share a mean. The minted hourly stats rows are
// excluded by the NOT-startsWith predicate so they are never re-folded;
// soft-deleted rows are excluded so a re-run converges. `cutoffAt` is
// the retention boundary — rows newer than it (inside the window) are
// NOT scanned, so the dense intra-day shape is preserved.
buildScanWhere: ({ userId, type, cutoffAt, statsPrefix }) => ({
userId,
source,
type,
deletedAt: null,
NOT: { externalId: { startsWith: statsPrefix } },
...(cutoffAt ? { measuredAt: { lt: cutoffAt } } : {}),
}),
scanSelect: {
id: true,
type: true,
value: true,
measuredAt: true,
externalId: true,
unit: true,
},
writeDay: async ({
prismaClient: pc,
userId,
type,
externalId,
canonicalTimestamp,
dayRows,
sourceRowIds,
tz,
dateKey,
}): Promise<DayWriteOutcome> => {
const unit = dayRows[0]?.unit ?? "unknown";
const isPulse = type === "PULSE";
const hkIdentifier = hkIdentifierForType(type);
// Unreachable: the base skips types without an HK identifier before
// scanning; asserted so a future type-set edit fails loud, not by
// minting a malformed externalId.
if (!hkIdentifier) {
throw new Error(`no HK identifier for dense-tier type ${type}`);
}
// DAY-rollup fidelity — mean AND min/max (all dense-tier types). The
// persistent `MeasurementRollup` DAY bucket aggregates LIVE rows;
// recomputing it AFTER the fold would aggregate the hourly means: the
// min/max collapse to the extreme hourly MEANS (for SpO2 hiding the
// overnight-desaturation nadir; for PULSE the resting floor + workout
// peak) and the mean becomes an unweighted mean-of-hourly-means that
// drifts whenever per-hour sample counts differ. So the DAY bucket is
// recomputed from the RAW rows BEFORE the fold, while they are still
// live, and the post-fold DAY recompute is skipped entirely. The
// WEEK/MONTH/YEAR recomputes this call enqueues run post-commit
// against whatever is live then, as before.
await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp);
// DAY-rollup fidelity — mean AND min/max (all dense-tier types). The
// persistent `MeasurementRollup` DAY bucket aggregates LIVE rows;
// recomputing it AFTER the fold would aggregate the hourly means: the
// min/max collapse to the extreme hourly MEANS (for SpO2 hiding the
// overnight-desaturation nadir; for PULSE the resting floor + workout
// peak) and the mean becomes an unweighted mean-of-hourly-means that
// drifts whenever per-hour sample counts differ. So the DAY bucket is
// recomputed from the RAW rows BEFORE the fold, while they are still
// live, and the post-fold DAY recompute is skipped entirely. The
// WEEK/MONTH/YEAR recomputes this call enqueues run post-commit
// against whatever is live then, as before.
await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp);
// Fold the day into per-LOCAL-HOUR mean rows, anchored at local
// HH:30. Sorted for a deterministic write order across runs.
const byHour = [...bucketRowsByLocalHour(dayRows, tz).entries()].sort(
(a, b) => a[0] - b[0],
);
// Fold the day into per-LOCAL-HOUR mean rows, anchored at local
// HH:30. Sorted for a deterministic write order across runs.
const byHour = [...bucketRowsByLocalHour(dayRows, tz).entries()].sort(
(a, b) => a[0] - b[0],
);
const foldOnce = async (): Promise<{
removed: number;
retiredDaily: boolean;
}> => {
let removed = 0;
let retiredDaily = false;
await pc.$transaction(async (tx) => {
// First, before any reading is touched: see `restore-lock.ts`.
await holdAccountAgainstRestore(tx, userId);
const canonicalRowIds = await adoptOrMintHourlyRows(tx, {
userId,
type,
unit,
slots: byHour.map(([hour, hourRows]) => ({
externalId: hourlyStatsExternalId(hkIdentifier, dateKey, hour),
anchor: canonicalHourlyTimestamp(dateKey, hour, tz),
value: meanBucketValue(hourRows),
})),
});
// Retire a live pre-hourly DAILY `stats:` row for this day (the
// late-sync path: raw samples arriving for a day folded to the
// daily grain before v1.28.31). Same transaction as the hourly
// mint — an AVG-over-live-rows reader must never see the daily
// row and the hourly rows live at once (double count).
const dailyRow = await tx.measurement.findFirst({
where: {
const foldOnce = async (): Promise<{
removed: number;
retiredDaily: boolean;
}> => {
let removed = 0;
let retiredDaily = false;
await pc.$transaction(async (tx) => {
// First, before any reading is touched: see `restore-lock.ts`.
await holdAccountAgainstRestore(tx, userId);
const canonicalRowIds = await adoptOrMintHourlyRows(tx, {
userId,
type,
source: "APPLE_HEALTH",
externalId,
deletedAt: null,
},
select: { id: true },
orderBy: { id: "asc" },
});
if (dailyRow && !canonicalRowIds.includes(dailyRow.id)) {
await tx.measurement.update({
where: { id: dailyRow.id },
data: { deletedAt: new Date() },
source,
unit,
slots: byHour.map(([hour, hourRows]) => ({
externalId: hourlyStatsExternalId(hkIdentifier, dateKey, hour),
anchor: canonicalHourlyTimestamp(dateKey, hour, tz),
value: meanBucketValue(hourRows),
})),
});
retiredDaily = true;
}
// Delete the out-of-window per-sample rows in the same
// transaction. EXCLUDE the adopted canonical rows: a per-sample row
// that happened to fall on an hourly anchor is the row just adopted
// as that hour's mean, so removing it would erase the fold.
//
// v1.42 — deleted outright, like the cumulative drain's rows,
// where they used to be tombstoned for the 75-day retention. No
// client ever pulled these rows, so the tombstone told nobody
// anything; it only held the re-upload of a folded sample off,
// and the `folded_window` ingest guard (`folded-window.ts`) does
// that now from the live hourly row. The tombstones made up most
// of the table and its indexes.
const del = await tx.measurement.deleteMany({
where: {
id: { in: sourceRowIds, notIn: canonicalRowIds },
deletedAt: null,
},
// Retire a live pre-hourly DAILY `stats:` row for this day (the
// late-sync path: raw samples arriving for a day folded to the
// daily grain before v1.28.31). Same transaction as the hourly
// mint — an AVG-over-live-rows reader must never see the daily
// row and the hourly rows live at once (double count).
const dailyRow = await tx.measurement.findFirst({
where: {
userId,
type,
source,
externalId,
deletedAt: null,
},
select: { id: true },
orderBy: { id: "asc" },
});
if (dailyRow && !canonicalRowIds.includes(dailyRow.id)) {
await tx.measurement.update({
where: { id: dailyRow.id },
data: { deletedAt: new Date() },
});
retiredDaily = true;
}
// Delete the out-of-window per-sample rows in the same
// transaction. EXCLUDE the adopted canonical rows: a per-sample row
// that happened to fall on an hourly anchor is the row just adopted
// as that hour's mean, so removing it would erase the fold.
//
// v1.42 — deleted outright, like the cumulative drain's rows,
// where they used to be tombstoned for the 75-day retention. No
// client ever pulled these rows, so the tombstone told nobody
// anything; it only held the re-upload of a folded sample off,
// and the `folded_window` ingest guard (`folded-window.ts`) does
// that now from the live hourly row. The tombstones made up most
// of the table and its indexes.
const del = await tx.measurement.deleteMany({
where: {
id: { in: sourceRowIds, notIn: canonicalRowIds },
deletedAt: null,
},
});
removed = del.count;
});
removed = del.count;
});
return { removed, retiredDaily };
};
return { removed, retiredDaily };
};
let foldResult: { removed: number; retiredDaily: boolean };
try {
foldResult = await foldOnce();
} catch (err) {
if (!isUniqueConstraintViolation(err)) throw err;
// A concurrent writer won a slot mid-transaction. Retry once: the
// deterministic lookup now resolves the winning row and adopts it,
// so this pass cannot create a duplicate.
foldResult = await foldOnce();
}
// Counters mutate OUTSIDE the retryable transaction so a P2002 retry
// cannot double-count.
summary.totals.hourlyRowsUpserted += byHour.length;
if (foldResult.retiredDaily) summary.totals.dailyRowsRetired += 1;
let foldResult: { removed: number; retiredDaily: boolean };
try {
foldResult = await foldOnce();
} catch (err) {
if (!isUniqueConstraintViolation(err)) throw err;
// A concurrent writer won a slot mid-transaction. Retry once: the
// deterministic lookup now resolves the winning row and adopts it,
// so this pass cannot create a duplicate.
foldResult = await foldOnce();
}
// Counters mutate OUTSIDE the retryable transaction so a P2002 retry
// cannot double-count.
summary.totals.hourlyRowsUpserted += byHour.length;
if (foldResult.retiredDaily) summary.totals.dailyRowsRetired += 1;
// iOS#34 — preserve the resting signal for PROXY users. For a user
// with zero native `RESTING_HEART_RATE` rows, the read-path resolver
// derives resting from the 20th-percentile of each day's RAW PULSE; the
// fold has just deleted those rows, so that day's resting figure would
// vanish (the hourly means flatten the low tail the percentile reads).
// Mint one derived `RESTING_HEART_RATE` row from the same percentile,
// sourced `COMPUTED` (distinct from Apple's own resting rows on both
// unique indexes), so the day reads back as the clean resting series
// the resolver prefers. Skipped for users WITH native resting rows
// (the resolver ignores the proxy entirely for them, so a derived row
// would only double-count).
if (isPulse) {
const restingValue = deriveDailyRestingFromPulse(dayRows);
if (restingValue !== null && !(await userHasNativeResting(userId))) {
const restingExternalId = dailyStatsExternalId(
RESTING_HK_IDENTIFIER,
// The fold's canonical timestamp is local-noon of the day; reuse
// its UTC date for the resting row's `stats:` key so a re-run
// upserts the same row instead of minting a sibling.
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: stable stats: externalId key; changing it would mint a sibling row on re-run
canonicalTimestamp.toISOString().slice(0, 10),
);
await pc.measurement.upsert({
where: {
userId_type_source_externalId: {
// iOS#34 — preserve the resting signal for PROXY users. For a user
// with zero native `RESTING_HEART_RATE` rows, the read-path resolver
// derives resting from the 20th-percentile of each day's RAW PULSE; the
// fold has just deleted those rows, so that day's resting figure would
// vanish (the hourly means flatten the low tail the percentile reads).
// Mint one derived `RESTING_HEART_RATE` row from the same percentile,
// sourced `COMPUTED` (distinct from Apple's own resting rows on both
// unique indexes), so the day reads back as the clean resting series
// the resolver prefers. Skipped for users WITH native resting rows
// (the resolver ignores the proxy entirely for them, so a derived row
// would only double-count).
if (isPulse) {
const restingValue = deriveDailyRestingFromPulse(dayRows);
if (restingValue !== null && !(await userHasNativeResting(userId))) {
const restingExternalId = dailyStatsExternalId(
RESTING_HK_IDENTIFIER,
// The fold's canonical timestamp is local-noon of the day; reuse
// its UTC date for the resting row's `stats:` key so a re-run
// upserts the same row instead of minting a sibling.
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: stable stats: externalId key; changing it would mint a sibling row on re-run
canonicalTimestamp.toISOString().slice(0, 10),
);
await pc.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type: "RESTING_HEART_RATE",
source: "COMPUTED",
externalId: restingExternalId,
},
},
create: {
userId,
type: "RESTING_HEART_RATE",
value: restingValue,
unit: "bpm",
source: "COMPUTED",
measuredAt: canonicalTimestamp,
externalId: restingExternalId,
},
},
create: {
update: { value: restingValue, deletedAt: null },
});
summary.totals.derivedRestingRowsUpserted += 1;
// The derived row is its own (type, day); recompute its DAY bucket
// so the rollup tier serves it on a covered read.
await recomputeBucketsForMeasurement(
userId,
type: "RESTING_HEART_RATE",
value: restingValue,
unit: "bpm",
source: "COMPUTED",
measuredAt: canonicalTimestamp,
externalId: restingExternalId,
},
update: { value: restingValue, deletedAt: null },
});
summary.totals.derivedRestingRowsUpserted += 1;
// The derived row is its own (type, day); recompute its DAY bucket
// so the rollup tier serves it on a covered read.
await recomputeBucketsForMeasurement(
userId,
"RESTING_HEART_RATE",
canonicalTimestamp,
);
"RESTING_HEART_RATE",
canonicalTimestamp,
);
}
}
}
// No post-fold DAY recompute for ANY dense-tier type — it would
// overwrite the true raw-derived mean/min/max the pre-fold recompute
// above captured with hourly-mean-derived approximations (see the
// fidelity note there).
// No post-fold DAY recompute for ANY dense-tier type — it would
// overwrite the true raw-derived mean/min/max the pre-fold recompute
// above captured with hourly-mean-derived approximations (see the
// fidelity note there).
return { kind: "written", sourceRowsRemoved: foldResult.removed };
},
recordBucket: ({ dayRows, outcome }) => {
summary.totals.daysConsolidated += 1;
if (outcome === null) {
// Dry-run: writeDay never ran, so estimate the hourly fan-out here
// from the same bucketing the real fold would use.
summary.totals.hourlyRowsUpserted += bucketRowsByLocalHour(
dayRows,
currentTz,
).size;
}
summary.totals.perSampleRowsSoftDeleted +=
outcome?.kind === "written"
? outcome.sourceRowsRemoved
: dayRows.length;
},
onUserStart: ({ tz }) => {
currentTz = tz;
},
onUserComplete: ({ userId, tz, dryRun }) => {
log(
`[dense-intraday-retention] user=${userId} tz=${tz}${dryRun ? " (dry-run)" : ""}`,
);
},
// Per-day failure boundary (v1.18.10 lesson). A day whose fold throws —
// e.g. a residual unique-index collision the adopt-in-place path could
// not absorb, or a derived-resting upsert that races a native row — is
// logged and STEPPED OVER so the global walk keeps draining every other
// user / type / day. The failed day keeps its raw rows live (the fold
// deletes only on a committed transaction), so the next nightly run
// retries it. Without this, one poisoned day aborted the whole walk and
// stranded every later account.
onBucketError: ({ userId, type, dateKey, error }) => {
const reason = error instanceof Error ? error.message : String(error);
log(
`[dense-intraday-retention] user=${userId} type=${type} day=${dateKey} failed — ${reason}; continuing with the next bucket`,
);
},
});
return { kind: "written", sourceRowsRemoved: foldResult.removed };
},
recordBucket: ({ dayRows, outcome }) => {
summary.totals.daysConsolidated += 1;
if (outcome === null) {
// Dry-run: writeDay never ran, so estimate the hourly fan-out here
// from the same bucketing the real fold would use.
summary.totals.hourlyRowsUpserted += bucketRowsByLocalHour(
dayRows,
currentTz,
).size;
}
summary.totals.perSampleRowsSoftDeleted +=
outcome?.kind === "written"
? outcome.sourceRowsRemoved
: dayRows.length;
},
onUserStart: ({ tz }) => {
currentTz = tz;
},
onUserComplete: ({ userId, tz, dryRun }) => {
log(
`[dense-intraday-retention] user=${userId} source=${source} tz=${tz}${dryRun ? " (dry-run)" : ""}`,
);
},
// Per-day failure boundary (v1.18.10 lesson). A day whose fold throws —
// e.g. a residual unique-index collision the adopt-in-place path could
// not absorb, or a derived-resting upsert that races a native row — is
// logged and STEPPED OVER so the global walk keeps draining every other
// user / type / day. The failed day keeps its raw rows live (the fold
// deletes only on a committed transaction), so the next nightly run
// retries it. Without this, one poisoned day aborted the whole walk and
// stranded every later account.
onBucketError: ({ userId, type, dateKey, error }) => {
const reason = error instanceof Error ? error.message : String(error);
log(
`[dense-intraday-retention] user=${userId} source=${source} type=${type} day=${dateKey} failed — ${reason}; continuing with the next bucket`,
);
},
});
summary.totals.usersScanned = walk.usersScanned;
summary.stoppedEarly = walk.stoppedEarly;
// One walk per source, Apple Health first so its behaviour and its order
// are exactly what they were before Health Connect folded too. A walk that
// stopped early stops the pass: the next run resumes where it left off.
for (const source of DENSE_INTRADAY_FOLD_SOURCES) {
const walk = await walkSource(source);
summary.totals.usersScanned = Math.max(
summary.totals.usersScanned,
walk.usersScanned,
);
if (walk.stoppedEarly) {
summary.stoppedEarly = true;
break;
}
}
log(
`[dense-intraday-retention] done — usersScanned=${summary.totals.usersScanned} daysConsolidated=${summary.totals.daysConsolidated} perSampleRowsSoftDeleted=${summary.totals.perSampleRowsSoftDeleted} hourlyRowsUpserted=${summary.totals.hourlyRowsUpserted} dailyRowsRetired=${summary.totals.dailyRowsRetired} derivedRestingRowsUpserted=${summary.totals.derivedRestingRowsUpserted}${options.dryRun ? " (dry-run)" : ""}`,
+71 -34
View File
@@ -1,14 +1,13 @@
/**
* The `folded_window` ingest guard and the compaction-tombstone class (v1.42).
*
* The dense-intraday consolidation folds the raw Apple Health samples of a
* DENSE type into one `stats:` row per local hour once they are older than
* The dense-intraday consolidation folds the raw samples of a DENSE type into one `stats:` row per local hour once they are older than
* the retention window, and a MEAN type into one `stats:` row per local day
* once the consolidation grace has passed. Since v1.42 the folded raw rows are
* deleted outright rather than left as tombstones, so a client that re-uploads
* an old sample after losing its anchor would put it back beside the hour or
* day that already accounts for it. A raw (non-`stats:`) Apple Health value of
* such a type, old enough to have been folded, is therefore a duplicate when a
* day that already accounts for it. A raw (non-`stats:`) value of such a source
* and type, old enough to have been folded, is therefore a duplicate when a
* live `stats:` row covers its hour or day. The batch route and the ZIP import
* apply the same rule.
*
@@ -29,8 +28,12 @@
* the other zone; such a row is then simply not recognised, which is the
* pre-v1.42 behaviour, never a wrong refusal.
*
* Health Connect does not use this: its importer writes the folded shape
* itself.
* Health Connect folds its raw heart-rate history the same way under its own
* source (the importer writes samples of the last 90 days raw, and the
* nightly pass folds them once they age out), so the hour rule covers both
* sources and a covering row is always looked up under the sample's own
* source. The day rule stays Apple Health only: the nightly mean
* consolidation reads no other source.
*/
import type {
MeasurementSource,
@@ -49,6 +52,7 @@ import {
hourOfDayForUserTz,
} from "@/lib/measurements/consolidation-tz";
import {
DENSE_INTRADAY_FOLD_SOURCES,
DENSE_INTRADAY_RETENTION_DAYS,
DENSE_INTRADAY_RETENTION_TYPES,
hourlyStatsExternalId,
@@ -60,15 +64,17 @@ export const FOLDED_WINDOW_REASON: FoldedWindowReason = "folded_window";
const HOUR_MS = 3_600_000;
/** Which types fold into which window, and from what age. */
/** Which sources and types fold into which window, and from what age. */
export const FOLDED_WINDOW_RULES = [
{
window: "hour",
sources: DENSE_INTRADAY_FOLD_SOURCES,
types: DENSE_INTRADAY_RETENTION_TYPES,
foldedAfterMs: DENSE_INTRADAY_RETENTION_DAYS * 24 * HOUR_MS,
},
{
window: "day",
sources: ["APPLE_HEALTH"] as const,
types: HIGH_FREQUENCY_MEAN_TYPES,
foldedAfterMs: CONSOLIDATION_GRACE_CUTOFF_HOURS * HOUR_MS,
},
@@ -98,7 +104,6 @@ export function isFoldedWindowCandidate(
row: FoldedWindowCandidateRow,
now: Date = new Date(),
): boolean {
if (row.source !== "APPLE_HEALTH") return false;
if (row.externalId === null) return false;
if (row.externalId.startsWith("stats:")) return false;
// A collision retirement (`reconcile-external-measurement.ts`) parks the
@@ -106,7 +111,10 @@ export function isFoldedWindowCandidate(
if (row.externalId.startsWith("retired:")) return false;
const age = now.getTime() - row.measuredAt.getTime();
return FOLDED_WINDOW_RULES.some(
(rule) => rule.types.has(row.type) && age > rule.foldedAfterMs,
(rule) =>
(rule.sources as readonly MeasurementSource[]).includes(row.source) &&
rule.types.has(row.type) &&
age > rule.foldedAfterMs,
);
}
@@ -153,8 +161,9 @@ export async function loadFoldTimezone(
}
/**
* Indexes (into `rows`) whose covering `stats:` row is live. One query over
* the unique `(user_id, type, source, external_id)` index for the whole set.
* Indexes (into `rows`) whose covering `stats:` row is live under the row's
* own source. One query per source present, each over the unique
* `(user_id, type, source, external_id)` index for the whole set.
*/
async function coveredIndexes(
client: Pick<LookupClient, "measurement">,
@@ -163,35 +172,49 @@ async function coveredIndexes(
rows: ReadonlyArray<{
index: number;
type: MeasurementType;
source: MeasurementSource;
measuredAt: Date;
}>,
): Promise<Set<number>> {
const covered = new Set<number>();
if (rows.length === 0) return covered;
const keyOf = new Map<number, string>();
const ids = new Set<string>();
const types = new Set<MeasurementType>();
const bySource = new Map<
MeasurementSource,
{
keyOf: Map<number, string>;
ids: Set<string>;
types: Set<MeasurementType>;
}
>();
for (const row of rows) {
const id = coveringStatsExternalId(row, tz);
if (!id) continue;
keyOf.set(row.index, `${row.type}|${id}`);
ids.add(id);
types.add(row.type);
let group = bySource.get(row.source);
if (!group) {
group = { keyOf: new Map(), ids: new Set(), types: new Set() };
bySource.set(row.source, group);
}
group.keyOf.set(row.index, `${row.type}|${id}`);
group.ids.add(id);
group.types.add(row.type);
}
if (ids.size === 0) return covered;
const live = await client.measurement.findMany({
where: {
userId,
type: { in: [...types] },
source: "APPLE_HEALTH",
externalId: { in: [...ids] },
deletedAt: null,
},
select: { type: true, externalId: true },
});
const liveKeys = new Set(live.map((row) => `${row.type}|${row.externalId}`));
for (const [index, key] of keyOf) {
if (liveKeys.has(key)) covered.add(index);
for (const [source, group] of bySource) {
const live = await client.measurement.findMany({
where: {
userId,
type: { in: [...group.types] },
source,
externalId: { in: [...group.ids] },
deletedAt: null,
},
select: { type: true, externalId: true },
});
const liveKeys = new Set(
live.map((row) => `${row.type}|${row.externalId}`),
);
for (const [index, key] of group.keyOf) {
if (liveKeys.has(key)) covered.add(index);
}
}
return covered;
}
@@ -211,7 +234,14 @@ export async function findFoldedWindowDuplicates(
const now = options.now ?? new Date();
const candidates = rows.flatMap((row, index) =>
isFoldedWindowCandidate(row, now)
? [{ index, type: row.type, measuredAt: row.measuredAt }]
? [
{
index,
type: row.type,
source: row.source,
measuredAt: row.measuredAt,
},
]
: [],
);
if (candidates.length === 0) return new Set();
@@ -230,7 +260,7 @@ export interface TombstoneCandidateRow extends FoldedWindowCandidateRow {
/**
* Indexes (into `rows`) of the compaction tombstones ("class A"): soft-deleted
* raw Apple Health samples of a folded type that were already past the fold
* raw samples of a folded source and type that were already past the fold
* threshold when they were deleted, and whose hour or day a live `stats:` row
* covers. Everything else, in particular a person's own deletions of recent
* samples, is class B and keeps the 75-day tombstone retention.
@@ -243,7 +273,14 @@ export async function findCompactionTombstones(
): Promise<Set<number>> {
const candidates = rows.flatMap((row, index) =>
row.deletedAt !== null && isFoldedWindowCandidate(row, row.deletedAt)
? [{ index, type: row.type, measuredAt: row.measuredAt }]
? [
{
index,
type: row.type,
source: row.source,
measuredAt: row.measuredAt,
},
]
: [],
);
return coveredIndexes(client, userId, tz, candidates);
+4 -1
View File
@@ -389,6 +389,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",
@@ -419,7 +423,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
@@ -371,7 +366,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",
});
@@ -377,4 +377,89 @@ describe("runDenseIntradayRetention (real Postgres)", () => {
// Both raw samples survive — the intra-day shape is preserved.
expect(live).toHaveLength(2);
});
it("folds Health Connect raw heart rate past the window into its own hourly rows, beside Apple Health's", async () => {
const prisma = getPrismaClient();
// Berlin UTC+2 on 1 May: 08:xx UTC is local hour 10 — far past 90 days.
const at = (minute: number) =>
new Date(`2026-05-01T08:${String(minute).padStart(2, "0")}:00.000Z`);
await prisma.measurement.createMany({
data: [
...[60, 70, 80].map((value, i) => ({
userId: TEST_USER_ID,
type: "PULSE" as const,
value,
unit: "bpm",
source: "HEALTH_CONNECT" as const,
measuredAt: at(i * 10 + 1),
externalId: `hc:00000000-0000-0000-0000-00000000000a:${i}`,
})),
{
userId: TEST_USER_ID,
type: "PULSE" as const,
value: 100,
unit: "bpm",
source: "APPLE_HEALTH" as const,
measuredAt: at(5),
externalId: "hk-pulse-1",
},
],
});
const summary = await runDenseIntradayRetention(prisma, {
userId: TEST_USER_ID,
// The default 90-day window: the samples are well past it.
log: () => {},
});
expect(summary.totals.perSampleRowsSoftDeleted).toBe(4);
const rows = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "PULSE" },
orderBy: { source: "asc" },
});
// Raw rows of both sources are gone outright, not tombstoned.
expect(rows.every((row) => row.externalId?.startsWith("stats:"))).toBe(
true,
);
expect(rows.every((row) => row.deletedAt === null)).toBe(true);
const id = "stats:HKQuantityTypeIdentifierHeartRate:2026-05-01T10";
// One mean per source: the two sources never share a row.
expect(rows.map((row) => [row.source, row.externalId, row.value])).toEqual([
["APPLE_HEALTH", id, 100],
["HEALTH_CONNECT", id, 70],
]);
// A second run finds nothing left to fold.
const again = await runDenseIntradayRetention(prisma, {
userId: TEST_USER_ID,
log: () => {},
});
expect(again.totals.daysConsolidated).toBe(0);
});
it("keeps Health Connect raw heart rate inside the window raw", async () => {
const prisma = getPrismaClient();
const recent = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "PULSE",
value: 64,
unit: "bpm",
source: "HEALTH_CONNECT",
measuredAt: recent,
externalId: "hc:00000000-0000-0000-0000-00000000000b:1",
},
});
const summary = await runDenseIntradayRetention(prisma, {
userId: TEST_USER_ID,
log: () => {},
});
expect(summary.totals.daysConsolidated).toBe(0);
expect(
await prisma.measurement.count({
where: { userId: TEST_USER_ID, source: "HEALTH_CONNECT" },
}),
).toBe(1);
});
});
@@ -30,6 +30,7 @@ import {
zipHealthConnectDb,
} from "../fixtures/health-connect/build-fixture";
import { importHealthConnectExport } from "@/lib/import/health-connect/import";
import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-retention";
import {
HEALTH_CONNECT_IMPORT_KIND,
HEALTH_CONNECT_IMPORT_QUEUE,
@@ -406,6 +407,53 @@ describe("Health Connect import, heart rate history", () => {
});
});
describe("Health Connect import, folded heart rate", () => {
it("folds raw heart rate once it ages out, and a later import does not put it back", async () => {
const prisma = getPrismaClient();
const user = await createUser("hc-folded");
// A day of minute-by-minute heart rate, 120 days back, imported while it
// was recent so it lands raw.
const { path, endUtc } = fixtureDb("folded", {
days: 1,
endUtc: today - 120 * DAY,
});
const args = {
prisma,
dbPath: path,
userId: user.id,
userTimezone: "Europe/Berlin",
connectedIntegrations: [],
now: new Date(endUtc),
} as const;
await importHealthConnectExport(args);
expect((await countBy(user.id)).PULSE).toBe(24 * 60);
// The nightly fold, at today's clock: the samples are past the window.
await runDenseIntradayRetention(prisma, {
userId: user.id,
log: () => {},
});
const folded = await prisma.measurement.findMany({
where: { userId: user.id, type: "PULSE", source: "HEALTH_CONNECT" },
});
expect(folded).toHaveLength(24);
expect(
folded.every(
(row) =>
row.deletedAt === null &&
row.externalId?.startsWith(
"stats:HKQuantityTypeIdentifierHeartRate:",
),
),
).toBe(true);
// The same export again: every raw sample's hour is folded already.
const again = await importHealthConnectExport(args);
expect(again.skipped["PULSE::folded_window"]).toBe(24 * 60);
expect((await countBy(user.id)).PULSE).toBe(24);
});
});
describe("Health Connect import, worker", () => {
async function stagedJob(name: string, zipBuilder: (zip: string) => void) {
const prisma = getPrismaClient();
@@ -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);
});
});