mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge tickets part 2 into release/v1.42.0
# Conflicts: # src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
commit
9d4a50282e
80 files changed
+3975
-818
No files matched your search
+369
-226
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -180,6 +180,7 @@ const SUMMARY_COUNTS = [
|
||||
"encounterLinks",
|
||||
"vaccinations",
|
||||
"vaccinationLinks",
|
||||
"customVaccines",
|
||||
"measurementReminders",
|
||||
"measurementReminderEvents",
|
||||
"coachConversations",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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:" } },
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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)" : ""}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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) ?? [];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user