diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 723112e8b..34d19567b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -10389,6 +10389,422 @@ paths: "404": *a17 "422": *a3 "429": *a4 + /api/vaccinations: + get: + tags: + - Records + summary: List vaccinations + description: Returns the caller's live doses, newest first, each with its series resolved per component antigen. + Soft-deleted doses are excluded. + parameters: + - in: query + name: antigenSlug + schema: + type: string + minLength: 1 + maxLength: 64 + - in: query + name: from + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - in: query + name: to + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 500 + responses: + "200": + description: The caller's immunization history. + content: + application/json: + schema: + $ref: "#/components/schemas/ListVaccinationsEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + post: + tags: + - Records + summary: Log a vaccination + description: "Records one administered dose and satisfies every live booster reminder keyed to one of its component + antigens — so a single combined dose settles each of the antigens it contains. Satisfaction is forward-only: a + dose older than a reminder's last satisfaction is a no-op, which is what makes back-entering an old paper record + safe. Honours `Idempotency-Key`. Audits as `vaccination.record.create`." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + occurredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + antigenSlug: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + vaccineName: + anyOf: + - type: string + minLength: 1 + maxLength: 200 + - type: "null" + doseNumber: + anyOf: + - type: integer + minimum: 1 + maximum: 20 + - type: "null" + seriesDoses: + anyOf: + - type: integer + minimum: 1 + maximum: 10 + - type: "null" + lotNumber: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + site: + anyOf: + - type: string + enum: + - LEFT_ARM + - RIGHT_ARM + - LEFT_THIGH + - RIGHT_THIGH + - ORAL + - NASAL + - OTHER + - type: "null" + practitionerId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + encounterId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + documentIds: + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + required: + - occurredAt + additionalProperties: false + responses: + "201": + description: Dose logged. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateVaccinationEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + /api/vaccinations/{id}: + get: + tags: + - Records + summary: Read a single vaccination + description: Returns the dose with its linked pages resolved. Owner-scoped; a cross-user or tombstoned id 404s. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The dose. + content: + application/json: + schema: + $ref: "#/components/schemas/GetVaccinationEnvelope" + "401": *a1 + "403": *a6 + "404": &a18 + description: Vaccination not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + patch: + tags: + - Records + summary: Edit a vaccination + description: Partial edit; omitted fields are left untouched. Never re-runs booster satisfaction. Audits as + `vaccination.record.update`. Owner-scoped. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + occurredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + antigenSlug: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + vaccineName: + anyOf: + - type: string + minLength: 1 + maxLength: 200 + - type: "null" + doseNumber: + anyOf: + - type: integer + minimum: 1 + maximum: 20 + - type: "null" + seriesDoses: + anyOf: + - type: integer + minimum: 1 + maximum: 10 + - type: "null" + lotNumber: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + site: + anyOf: + - type: string + enum: + - LEFT_ARM + - RIGHT_ARM + - LEFT_THIGH + - RIGHT_THIGH + - ORAL + - NASAL + - OTHER + - type: "null" + practitionerId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + encounterId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + documentIds: + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + additionalProperties: false + responses: + "200": + description: Dose updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateVaccinationEnvelope" + "401": *a1 + "403": *a6 + "404": *a18 + "422": *a3 + "429": *a4 + delete: + tags: + - Records + summary: Soft-delete a vaccination + description: "Stamps `deletedAt`. A booster reminder the dose once satisfied is deliberately not rewound: the reminder + belongs to the preventive-care list and outlives the record, and tidying a transcription is not evidence the + dose never happened. Idempotent. Audits as `vaccination.record.delete`." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Soft-deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteVaccinationEnvelope" + "401": *a1 + "403": *a6 + "404": *a18 + "422": *a3 + "429": *a4 + /api/vaccinations/{id}/restore: + post: + tags: + - Records + summary: Restore a soft-deleted vaccination + description: Clears the tombstone. The link rows survived the soft delete, so the dose returns with its pages still + filed against it. Restoring a live dose is a no-op that still succeeds. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Dose restored. + content: + application/json: + schema: + $ref: "#/components/schemas/RestoreVaccinationEnvelope" + "401": *a1 + "403": *a6 + "404": *a18 + "422": *a3 + "429": *a4 + /api/vaccinations/{id}/links: + post: + tags: + - Records + summary: File documents against a vaccination + description: "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated + only, matching document and visit link and unlink." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + targetKind: + type: string + enum: + - document + targetIds: + minItems: 1 + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + required: + - targetKind + - targetIds + additionalProperties: false + responses: + "200": + description: The dose's linked pages after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/LinkVaccinationEnvelope" + "401": *a1 + "404": *a18 + "422": *a3 + "429": *a4 + delete: + tags: + - Records + summary: Unfile documents from a vaccination + description: Removes links. Idempotent, and it still works for a page the account has since deleted — refusing would + leave a filing nobody can clear. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + targetKind: + type: string + enum: + - document + targetIds: + minItems: 1 + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + required: + - targetKind + - targetIds + additionalProperties: false + responses: + "200": + description: The dose's linked pages after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/UnlinkVaccinationEnvelope" + "401": *a1 + "404": *a18 + "422": *a3 + "429": *a4 /api/illness/episodes: get: tags: @@ -10514,7 +10930,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": &a18 + "404": &a19 description: Illness episode not found (or owned by another user). content: application/json: @@ -10593,7 +11009,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 delete: @@ -10617,7 +11033,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes/{id}/restore: @@ -10644,7 +11060,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes/{id}/resolve: @@ -10681,7 +11097,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes/{id}/day-logs: @@ -10732,7 +11148,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 post: @@ -10812,7 +11228,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes/{id}/correlation: @@ -10839,7 +11255,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/insights: @@ -11112,7 +11528,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a6 - "404": &a19 + "404": &a20 description: Allergy not found (or owned by another user). content: application/json: @@ -11195,7 +11611,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 delete: @@ -11219,7 +11635,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/family-history: @@ -11327,7 +11743,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": &a20 + "404": &a21 description: Family history entry not found (or owned by another user). content: application/json: @@ -11396,7 +11812,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a20 + "404": *a21 "422": *a3 "429": *a4 delete: @@ -11420,7 +11836,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a20 + "404": *a21 "422": *a3 "429": *a4 /api/documents/inbound: @@ -12565,7 +12981,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a22 + "409": &a23 description: A custom metric with this name already exists for the caller. content: application/json: @@ -12594,7 +13010,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a6 - "404": &a21 + "404": &a22 description: Custom metric not found (or owned by another user). content: application/json: @@ -12658,8 +13074,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a21 - "409": *a22 + "404": *a22 + "409": *a23 "422": *a3 "429": *a4 delete: @@ -12682,7 +13098,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries: @@ -12726,7 +13142,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a6 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 post: @@ -12770,7 +13186,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries/{entryId}: @@ -12820,7 +13236,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a23 + "404": &a24 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -12852,7 +13268,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a23 + "404": *a24 "422": *a3 "429": *a4 /api/onboarding/disclaimer: @@ -13022,7 +13438,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a24 + "403": &a25 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -13077,7 +13493,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a24 + "403": *a25 "422": *a3 "429": *a4 /api/nutrients/water: @@ -13104,7 +13520,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a24 + "403": *a25 "422": *a3 "429": *a4 /api/integrations/healthkit: @@ -13442,7 +13858,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a25 + "403": &a26 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -13467,7 +13883,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "422": *a3 "429": *a4 post: @@ -13562,7 +13978,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "404": description: No such invitation for this account. content: @@ -13606,7 +14022,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "404": description: No such grant on this account's record. content: @@ -13647,7 +14063,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "404": description: No such grant held by this account. content: @@ -13946,7 +14362,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -15550,6 +15966,8 @@ components: type: boolean nutrients: type: boolean + vaccinations: + type: boolean baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -25800,6 +26218,9 @@ components: nutrients: type: boolean description: Whether the "nutrients" module is enabled for this account. + vaccinations: + type: boolean + description: Whether the "vaccinations" module is enabled for this account. required: - cycle - mood @@ -25819,6 +26240,7 @@ components: - inboundDocuments - mentalHealth - nutrients + - vaccinations additionalProperties: false description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; @@ -35529,6 +35951,418 @@ components: - data - error additionalProperties: false + ListVaccinationsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/VaccinationList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + VaccinationList: + type: object + properties: + vaccinations: + type: array + items: + $ref: "#/components/schemas/Vaccination" + required: + - vaccinations + additionalProperties: false + description: The caller's immunization history, newest dose first, with every entry's series already resolved over the + whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever + given. + Vaccination: + type: object + properties: + id: + type: string + occurredAt: + type: string + antigenSlug: + anyOf: + - type: string + - type: "null" + vaccineName: + anyOf: + - type: string + - type: "null" + doseNumber: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + seriesDoses: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + lotNumber: + anyOf: + - type: string + - type: "null" + site: + anyOf: + - type: string + enum: + - LEFT_ARM + - RIGHT_ARM + - LEFT_THIGH + - RIGHT_THIGH + - ORAL + - NASAL + - OTHER + - type: "null" + catalogEntry: + anyOf: + - $ref: "#/components/schemas/VaccinationCatalogEntry" + - type: "null" + series: + type: array + items: + $ref: "#/components/schemas/VaccinationSeriesPosition" + practitioner: + anyOf: + - $ref: "#/components/schemas/VaccinationPractitioner" + - type: "null" + encounter: + anyOf: + - $ref: "#/components/schemas/VaccinationEncounterStub" + - type: "null" + reminderId: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "null" + documents: + type: array + items: + $ref: "#/components/schemas/VaccinationLinkedDocument" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - occurredAt + - antigenSlug + - vaccineName + - doseNumber + - seriesDoses + - lotNumber + - site + - catalogEntry + - series + - practitioner + - encounter + - reminderId + - note + - createdAt + - updatedAt + additionalProperties: false + description: "One administered dose. `occurredAt` is a day at UTC midnight — the source record carries dates, never + times. `series` carries one entry per component antigen, so a combined preparation reports several at once and + each may be at a different position: a dose can be a booster for one antigen and a first dose for another in the + same act. An empty `series` means the dose has no resolvable antigen (free text only, or a slug this release + does not know) and nothing is guessed from the name. `note` is the decrypted free text, or null on a + key-rotation gap — fail-soft, never a 500. `reminderId` names the booster reminder this dose settled, when one + matched. `documents` is present on the detail response and absent from the list." + VaccinationCatalogEntry: + type: object + properties: + slug: + type: string + atc: + type: string + category: + type: string + required: + - slug + - atc + - category + additionalProperties: false + description: "What the catalogue this release ships knows about the record's `antigenSlug`: the slug itself, its WHO ATC + code from the J07 subtree, and its picker grouping. Null when the slug does not resolve — a slug written by an + older release, or one restored verbatim from an older backup. Null is the signal to render `vaccineName` + instead; it is not an error and not missing data." + VaccinationSeriesPosition: + type: object + properties: + antigen: + type: string + position: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + total: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + booster: + type: boolean + required: + - antigen + - position + - total + - booster + additionalProperties: false + description: "Where this dose sits in ONE antigen's series, derived server-side over the whole history. `position` is + the person's own recorded dose number when they gave one, otherwise the chronological index among their doses + for that antigen. `total` is the record's own series length, then the catalogue's typical one, then null when + neither knows — a seasonal vaccine has no 'of M'. `booster` is true once the series is complete and this dose + comes after it. Render text from these values: both resolved reads as 'N of M', position alone as 'dose N', + `booster` as a booster. Do not recompute them — a client cannot see the whole history and the two would drift." + VaccinationPractitioner: + type: object + properties: + id: + type: string + name: + type: string + specialty: + anyOf: + - type: string + - type: "null" + practice: + anyOf: + - type: string + - type: "null" + location: + anyOf: + - type: string + - type: "null" + phone: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - name + - specialty + - practice + - location + - phone + - note + - createdAt + - updatedAt + additionalProperties: false + description: The practice that administered the dose, resolved rather than an id. Null when the record names none. + VaccinationEncounterStub: + type: object + properties: + id: + type: string + occurredAt: + type: string + kind: + type: string + required: + - id + - occurredAt + - kind + additionalProperties: false + description: The visit the dose was given at, enough to recognise it by. Null when no visit is linked. + VaccinationLinkedDocument: + type: object + properties: + id: + type: string + label: + anyOf: + - type: string + - type: "null" + date: + anyOf: + - type: string + - type: "null" + redacted: + type: boolean + required: + - id + - label + - date + - redacted + additionalProperties: false + description: "One page filed against a dose, resolved. The label and the date are computed server-side from the document + itself, so the client never re-derives them. Both go null together, with `redacted: true`, when the caller's + grant does not cover the document vault: a dose lives in the health background but points into the vault, and a + page's filename is itself the sensitive part. The link is still listed — the caller may know the dose was + transcribed from a page — and `redacted` tells a withheld name apart from a page that simply has no date." + CreateVaccinationEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Vaccination" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetVaccinationEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Vaccination" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateVaccinationEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Vaccination" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteVaccinationEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + RestoreVaccinationEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Vaccination" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LinkVaccinationEnvelope: + type: object + properties: + data: + type: object + properties: + documents: + type: array + items: + $ref: "#/components/schemas/VaccinationLinkedDocument" + unknown: + type: array + items: + type: string + required: + - documents + - unknown + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UnlinkVaccinationEnvelope: + type: object + properties: + data: + type: object + properties: + documents: + type: array + items: + $ref: "#/components/schemas/VaccinationLinkedDocument" + unknown: + type: array + items: + type: string + required: + - documents + - unknown + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false ListIllnessEpisodesEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index fab1de3ff..22e52f6b7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6857,6 +6857,7 @@ "restoreSkippedMoodFactor": "Stimmungsfaktor", "restoreSkippedMoodTag": "Stimmungs-Tag", "restoreSkippedVisitReference": "Besuchsverknüpfung", + "restoreSkippedVaccinationReference": "Impfverknüpfung", "restoreSkippedCheckupClosure": "Vorsorge-Abschluss", "docsLink": "Dokumentation" }, @@ -8507,6 +8508,10 @@ "nutrients": { "label": "Nährstoffzufuhr", "description": "Tägliche Mikronährstoff-Summen aus Apple Health." + }, + "vaccinations": { + "label": "Impfungen", + "description": "Der Impfpass in strukturierter Form: Dosis, Serie, nächste Auffrischung." } }, "illness": { @@ -9935,6 +9940,10 @@ "encounterVisitUpdate": "{name} hat einen Arztbesuch bearbeitet", "encounterVisitDelete": "{name} hat einen Arztbesuch gelöscht", "encounterVisitRestore": "{name} hat einen Arztbesuch wiederhergestellt", + "vaccinationRecordCreate": "{name} hat eine Impfung eingetragen", + "vaccinationRecordUpdate": "{name} hat eine Impfung bearbeitet", + "vaccinationRecordDelete": "{name} hat eine Impfung gelöscht", + "vaccinationRecordRestore": "{name} hat eine Impfung wiederhergestellt", "practitionerContactCreate": "{name} hat eine Praxis zum Adressbuch hinzugefügt", "practitionerContactUpdate": "{name} hat einen Adressbucheintrag bearbeitet", "practitionerContactDelete": "{name} hat einen Adressbucheintrag gelöscht", @@ -10014,7 +10023,7 @@ }, "profile": { "label": "Gesundheitliche Vorgeschichte", - "detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil sowie Arztbesuche und Praxen." + "detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil, Arztbesuche und Praxen sowie deine Impfungen." }, "illness": { "label": "Krankheiten", diff --git a/messages/en.json b/messages/en.json index f82c27e99..8998e4fad 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6857,6 +6857,7 @@ "restoreSkippedMoodFactor": "Mood factor", "restoreSkippedMoodTag": "Mood tag", "restoreSkippedVisitReference": "Visit reference", + "restoreSkippedVaccinationReference": "Vaccination reference", "restoreSkippedCheckupClosure": "Checkup closure", "docsLink": "Documentation" }, @@ -8507,6 +8508,10 @@ "nutrients": { "label": "Nutrient intake", "description": "Daily micronutrient totals synced from Apple Health." + }, + "vaccinations": { + "label": "Vaccinations", + "description": "The vaccination record, structured: dose, series, next booster." } }, "illness": { @@ -9935,6 +9940,10 @@ "encounterVisitUpdate": "{name} edited a visit", "encounterVisitDelete": "{name} deleted a visit", "encounterVisitRestore": "{name} restored a visit", + "vaccinationRecordCreate": "{name} added a vaccination", + "vaccinationRecordUpdate": "{name} edited a vaccination", + "vaccinationRecordDelete": "{name} deleted a vaccination", + "vaccinationRecordRestore": "{name} restored a vaccination", "practitionerContactCreate": "{name} added a practice to the address book", "practitionerContactUpdate": "{name} edited an address book entry", "practitionerContactDelete": "{name} deleted an address book entry", @@ -10014,7 +10023,7 @@ }, "profile": { "label": "Health background", - "detail": "Allergies, family history, the facts in your health profile, and your visits and practices." + "detail": "Allergies, family history, the facts in your health profile, your visits and practices, and your vaccination record." }, "illness": { "label": "Illness", diff --git a/messages/es.json b/messages/es.json index f9f9f29f2..f8b57316b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6857,6 +6857,7 @@ "restoreSkippedMoodFactor": "Factor de ánimo", "restoreSkippedMoodTag": "Etiqueta de ánimo", "restoreSkippedVisitReference": "Referencia de visita", + "restoreSkippedVaccinationReference": "Referencia de vacunación", "restoreSkippedCheckupClosure": "Cierre de revisión", "docsLink": "Documentación" }, @@ -8507,6 +8508,10 @@ "nutrients": { "label": "Ingesta de nutrientes", "description": "Totales diarios de micronutrientes sincronizados desde Apple Health." + }, + "vaccinations": { + "label": "Vacunas", + "description": "La cartilla de vacunación estructurada: dosis, serie, próximo refuerzo." } }, "illness": { @@ -9935,6 +9940,10 @@ "encounterVisitUpdate": "{name} editó una visita médica", "encounterVisitDelete": "{name} eliminó una visita médica", "encounterVisitRestore": "{name} restauró una visita médica", + "vaccinationRecordCreate": "{name} añadió una vacuna", + "vaccinationRecordUpdate": "{name} editó una vacuna", + "vaccinationRecordDelete": "{name} eliminó una vacuna", + "vaccinationRecordRestore": "{name} restauró una vacuna", "practitionerContactCreate": "{name} añadió una consulta a la agenda", "practitionerContactUpdate": "{name} editó una entrada de la agenda", "practitionerContactDelete": "{name} eliminó una entrada de la agenda", @@ -10014,7 +10023,7 @@ }, "profile": { "label": "Antecedentes de salud", - "detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud y tus visitas médicas y consultas." + "detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud, tus visitas médicas y consultas, y tu cartilla de vacunación." }, "illness": { "label": "Enfermedades", diff --git a/messages/fr.json b/messages/fr.json index 03865270c..7daddbd8f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6857,6 +6857,7 @@ "restoreSkippedMoodFactor": "Facteur d'humeur", "restoreSkippedMoodTag": "Étiquette d'humeur", "restoreSkippedVisitReference": "Référence de visite", + "restoreSkippedVaccinationReference": "Référence de vaccination", "restoreSkippedCheckupClosure": "Clôture d'un examen", "docsLink": "Documentation" }, @@ -8507,6 +8508,10 @@ "nutrients": { "label": "Apports en nutriments", "description": "Totaux quotidiens de micronutriments synchronisés depuis Apple Health." + }, + "vaccinations": { + "label": "Vaccinations", + "description": "Le carnet de vaccination structuré : dose, série, prochain rappel." } }, "illness": { @@ -9935,6 +9940,10 @@ "encounterVisitUpdate": "{name} a modifié une consultation", "encounterVisitDelete": "{name} a supprimé une consultation", "encounterVisitRestore": "{name} a restauré une consultation", + "vaccinationRecordCreate": "{name} a ajouté une vaccination", + "vaccinationRecordUpdate": "{name} a modifié une vaccination", + "vaccinationRecordDelete": "{name} a supprimé une vaccination", + "vaccinationRecordRestore": "{name} a restauré une vaccination", "practitionerContactCreate": "{name} a ajouté un cabinet au carnet d'adresses", "practitionerContactUpdate": "{name} a modifié une entrée du carnet d'adresses", "practitionerContactDelete": "{name} a supprimé une entrée du carnet d'adresses", @@ -10014,7 +10023,7 @@ }, "profile": { "label": "Antécédents de santé", - "detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, ainsi que vos consultations et cabinets." + "detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, vos consultations et cabinets, ainsi que votre carnet de vaccination." }, "illness": { "label": "Maladies", diff --git a/messages/it.json b/messages/it.json index 09c27c2be..471e33147 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6857,6 +6857,7 @@ "restoreSkippedMoodFactor": "Fattore dell'umore", "restoreSkippedMoodTag": "Etichetta dell'umore", "restoreSkippedVisitReference": "Riferimento alla visita", + "restoreSkippedVaccinationReference": "Riferimento alla vaccinazione", "restoreSkippedCheckupClosure": "Chiusura del controllo", "docsLink": "Documentazione" }, @@ -8507,6 +8508,10 @@ "nutrients": { "label": "Assunzione di nutrienti", "description": "Totali giornalieri di micronutrienti sincronizzati da Apple Health." + }, + "vaccinations": { + "label": "Vaccinazioni", + "description": "Il libretto vaccinale strutturato: dose, serie, prossimo richiamo." } }, "illness": { @@ -9935,6 +9940,10 @@ "encounterVisitUpdate": "{name} ha modificato una visita medica", "encounterVisitDelete": "{name} ha eliminato una visita medica", "encounterVisitRestore": "{name} ha ripristinato una visita medica", + "vaccinationRecordCreate": "{name} ha aggiunto una vaccinazione", + "vaccinationRecordUpdate": "{name} ha modificato una vaccinazione", + "vaccinationRecordDelete": "{name} ha eliminato una vaccinazione", + "vaccinationRecordRestore": "{name} ha ripristinato una vaccinazione", "practitionerContactCreate": "{name} ha aggiunto uno studio alla rubrica", "practitionerContactUpdate": "{name} ha modificato una voce della rubrica", "practitionerContactDelete": "{name} ha eliminato una voce della rubrica", @@ -10014,7 +10023,7 @@ }, "profile": { "label": "Anamnesi", - "detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario e le tue visite mediche e studi." + "detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario, le tue visite mediche e studi, e il tuo libretto vaccinale." }, "illness": { "label": "Malattie", diff --git a/messages/pl.json b/messages/pl.json index dc06dc2cc..b4245f672 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6857,6 +6857,7 @@ "restoreSkippedMoodFactor": "Czynnik nastroju", "restoreSkippedMoodTag": "Tag nastroju", "restoreSkippedVisitReference": "Odwołanie wizyty", + "restoreSkippedVaccinationReference": "Odwołanie szczepienia", "restoreSkippedCheckupClosure": "Zamknięcie badania", "docsLink": "Dokumentacja" }, @@ -8507,6 +8508,10 @@ "nutrients": { "label": "Spożycie składników odżywczych", "description": "Dzienne sumy mikroskładników synchronizowane z Apple Health." + }, + "vaccinations": { + "label": "Szczepienia", + "description": "Książeczka szczepień uporządkowana: dawka, seria, następne przypomnienie." } }, "illness": { @@ -9935,6 +9940,10 @@ "encounterVisitUpdate": "{name} zmienił wizytę lekarską", "encounterVisitDelete": "{name} usunął wizytę lekarską", "encounterVisitRestore": "{name} przywrócił wizytę lekarską", + "vaccinationRecordCreate": "{name} dodał szczepienie", + "vaccinationRecordUpdate": "{name} zmienił szczepienie", + "vaccinationRecordDelete": "{name} usunął szczepienie", + "vaccinationRecordRestore": "{name} przywrócił szczepienie", "practitionerContactCreate": "{name} dodał gabinet do książki adresowej", "practitionerContactUpdate": "{name} zmienił wpis w książce adresowej", "practitionerContactDelete": "{name} usunął wpis w książce adresowej", @@ -10014,7 +10023,7 @@ }, "profile": { "label": "Historia zdrowia", - "detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego oraz wizyty lekarskie i gabinety." + "detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego, wizyty lekarskie i gabinety oraz Twoja historia szczepień." }, "illness": { "label": "Choroby", diff --git a/prisma/migrations/0330_vaccination_record/migration.sql b/prisma/migrations/0330_vaccination_record/migration.sql new file mode 100644 index 000000000..62d24aa74 --- /dev/null +++ b/prisma/migrations/0330_vaccination_record/migration.sql @@ -0,0 +1,87 @@ +-- Vaccinations: the immunization log and the page it was transcribed from. +-- +-- Until now a scanned Impfpass could be filed as a document and nothing more — +-- no structured dose existed anywhere in the model. These two tables turn a +-- Pass line into a record: one row per administered dose, and the link that +-- keeps the scanned page beside it. +-- +-- Additive, with one exception that is deliberate and small: the nullable +-- `vaccination_antigen` column on `measurement_reminders`. It is the match key +-- a logged dose uses to clear a booster reminder. No default, no backfill, no +-- read path outside the vaccination create route, and the published reminder +-- contract does not move. + +-- CreateEnum +CREATE TYPE "vaccination_site" AS ENUM ('LEFT_ARM', 'RIGHT_ARM', 'LEFT_THIGH', 'RIGHT_THIGH', 'ORAL', 'NASAL', 'OTHER'); + +-- AlterTable +ALTER TABLE "measurement_reminders" ADD COLUMN "vaccination_antigen" TEXT; + +-- CreateTable +CREATE TABLE "vaccination_records" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "occurred_at" TIMESTAMP(3) NOT NULL, + "antigen_slug" TEXT, + "vaccine_name" TEXT, + "dose_number" INTEGER, + "series_doses" INTEGER, + "lot_number" TEXT, + "site" "vaccination_site", + "practitioner_id" TEXT, + "encounter_id" TEXT, + "reminder_id" TEXT, + "note_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "vaccination_records_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "vaccination_document_links" ( + "id" TEXT NOT NULL, + "vaccination_id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "vaccination_document_links_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "vaccination_records_user_id_deleted_at_occurred_at_idx" ON "vaccination_records"("user_id", "deleted_at", "occurred_at"); + +-- CreateIndex +CREATE INDEX "vaccination_records_user_id_antigen_slug_occurred_at_idx" ON "vaccination_records"("user_id", "antigen_slug", "occurred_at"); + +-- CreateIndex +CREATE INDEX "vaccination_document_links_document_id_idx" ON "vaccination_document_links"("document_id"); + +-- CreateIndex +CREATE INDEX "vaccination_document_links_user_id_idx" ON "vaccination_document_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "vaccination_document_links_vaccination_id_document_id_key" ON "vaccination_document_links"("vaccination_id", "document_id"); + +-- AddForeignKey +ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_practitioner_id_fkey" FOREIGN KEY ("practitioner_id") REFERENCES "practitioners"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_reminder_id_fkey" FOREIGN KEY ("reminder_id") REFERENCES "measurement_reminders"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "vaccination_document_links" ADD CONSTRAINT "vaccination_document_links_vaccination_id_fkey" FOREIGN KEY ("vaccination_id") REFERENCES "vaccination_records"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "vaccination_document_links" ADD CONSTRAINT "vaccination_document_links_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "vaccination_document_links" ADD CONSTRAINT "vaccination_document_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f6bb517dd..a84c51351 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -940,6 +940,11 @@ model User { encounterDocumentLinks EncounterDocumentLink[] encounterLabLinks EncounterLabLink[] encounterConditionLinks EncounterConditionLink[] + // v1.38.0 — the immunization log. One row per administered dose, plus the + // link table that files the scanned Impfpass page beside it. Gated by the + // `vaccinations` module key, which governs the surfaces and not the store. + vaccinationRecords VaccinationRecord[] + vaccinationDocumentLinks VaccinationDocumentLink[] // v1.7.0 profile — optional patient-identity fields surfaced on the // health-record export cover (PDF) and the FHIR `Patient` resource. @@ -5110,61 +5115,82 @@ model Device { /// server-computed + stored (server-authoritative — iOS consumes the resolved /// value, never recomputes). Soft-deleted. model MeasurementReminder { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// User-facing copy: "Blutdruck messen", "Großes Blutbild". - label String + label String /// Auto-resolve target. NULL = free-text / checklist Vorsorge (a blood /// panel, a dermatology check) with no measurement to match against; those /// resolve only on a manual satisfy. Non-NULL = the cron advances /// `lastSatisfiedAt` when a matching Measurement of this type lands. - measurementType MeasurementType? @map("measurement_type") + measurementType MeasurementType? @map("measurement_type") /// Rolling cadence: fire every N days (7 / 30 / 365). Maps to the recurrence /// engine's `rollingIntervalDays`. Mutually exclusive with `rrule` in /// practice; both NULL is a one-shot reminder anchored on `anchorDate`. - intervalDays Int? @map("interval_days") + intervalDays Int? @map("interval_days") /// Optional RFC-5545 recurrence (e.g. `FREQ=YEARLY`). Maps straight through /// the same recurrence engine the medication schedules use. - rrule String? + rrule String? /// First-due anchor; the resolver falls back to `lastSatisfiedAt ?? createdAt` /// when NULL. - anchorDate DateTime? @map("anchor_date") + anchorDate DateTime? @map("anchor_date") /// Optional course-window end. NULL = open-ended cadence (the existing /// behaviour). Non-NULL bounds a finite cadence: the recurrence engine /// (`medication.endsOn` precedent) stops producing occurrences past this /// instant, so a Coach-suggested "measure BP morning+evening for a week" /// self-expires. Additive; every existing reminder keeps `NULL` and stays /// open-ended. - endsOn DateTime? @map("ends_on") + endsOn DateTime? @map("ends_on") /// Who produced this reminder. `VORSORGE` = the user created it through the /// Vorsorge surface (the existing, default behaviour). `COACH` = the AI /// coach minted it from an evidence-based cadence suggestion, posted to the /// SAME `POST /api/measurement-reminders` endpoint — one engine, not two. /// Lets the UI label provenance and lets Coach dedupe its own suggestions. - origin ReminderOrigin @default(VORSORGE) + origin ReminderOrigin @default(VORSORGE) /// Local hour (0–23) to fire the nudge, mirroring the mood-reminder hour. /// DST-safe windowing is applied at the cron via the existing TZ helpers. - notifyHour Int @default(9) @map("notify_hour") + notifyHour Int @default(9) @map("notify_hour") /// Free-text "wo": "Hausarzt", "Labor Dr. X". Optional. - location String? + location String? /// Server-computed canonical next-due instant (server-authoritative). - nextDueAt DateTime? @map("next_due_at") + nextDueAt DateTime? @map("next_due_at") /// When the reminder was last fulfilled (a matching measurement or a manual /// satisfy). Re-anchors the next-due computation. - lastSatisfiedAt DateTime? @map("last_satisfied_at") + lastSatisfiedAt DateTime? @map("last_satisfied_at") /// Master on/off for this reminder. Distinct from soft-delete: a disabled /// reminder is retained + editable but never fires. - enabled Boolean @default(true) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + enabled Boolean @default(true) + /// Booster match key: the antigen slug a logged vaccination clears this + /// reminder for. Written only by the vaccination booster mint (rung 2 — + /// prefilled, user-confirmed) and read only by the create path's satisfy + /// matcher (`src/lib/vaccinations/satisfy-matching.ts`), which resolves a + /// dose's component antigens and satisfies every reminder they match. + /// + /// Deliberately absent from the reminder DTO + /// (`src/lib/measurement-reminders/dto.ts`): this is a match key, not a + /// behaviour, so the published contract does not move — the `origin` enum + /// stays two-armed, the cron still fires by `nextDueAt` regardless of why a + /// row exists, and no Vorsorge surface excludes these rows. A booster + /// belongs on `/checkups`. That is also why a fourth nullable column here + /// is not the signal the appointment arm's warning described: an + /// appointment had to be bent into this engine (one-shot, no cadence, + /// excluded everywhere), whereas a booster is the engine's native case — + /// recurring, satisfiable, cadence-bearing. + vaccinationAntigen String? @map("vaccination_antigen") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Back-relation for the appointment arm: a PLANNED `Encounter` mints /// exactly one ENCOUNTER-origin row and points at it. SetNull on the far /// side, so deleting the reminder leaves the visit intact. - encounters Encounter[] + encounters Encounter[] + /// Back-relation for the booster arm: the doses that recorded satisfying + /// this reminder. SetNull on the far side — deleting the reminder leaves + /// the vaccination record intact. + vaccinations VaccinationRecord[] @@index([userId, deletedAt]) @@index([userId, nextDueAt]) @@ -7476,8 +7502,12 @@ model InboundDocument { /// Soft-delete tombstone. NULL = live; non-NULL = discarded. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - encounterLinks EncounterDocumentLink[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + encounterLinks EncounterDocumentLink[] + /// Vaccination memberships (m:n via `VaccinationDocumentLink`): a scanned + /// Impfpass page carries many doses, and a dose may be evidenced by more + /// than one page. + vaccinationLinks VaccinationDocumentLink[] @@index([userId, createdAt]) // The list always filters `deletedAt: null` and sorts by `documentDate`, so @@ -7784,6 +7814,9 @@ model Encounter { documentLinks EncounterDocumentLink[] labLinks EncounterLabLink[] conditionLinks EncounterConditionLink[] + /// Doses administered at this visit. SetNull on the far side: deleting the + /// visit leaves the immunization history intact. + vaccinations VaccinationRecord[] @@index([userId, deletedAt, occurredAt]) @@index([userId, status, occurredAt]) @@ -7847,8 +7880,9 @@ model Practitioner { /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - encounters Encounter[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + encounters Encounter[] + vaccinations VaccinationRecord[] @@index([userId, deletedAt, name]) @@map("practitioners") @@ -7916,3 +7950,128 @@ model EncounterConditionLink { @@index([userId]) @@map("encounter_condition_links") } + +// ─── Vaccinations ───────────────────────────────────────────── + +/// v1.38.0 — one administered dose, the Impfpass line made structured. +/// +/// The model is deliberately slim, and every absence below is a decision: +/// +/// - The disease, the typical series length and the booster interval are +/// NOT columns. They belong to the antigen, not to the dose, and live on +/// the catalogue entry behind `antigenSlug` +/// (`src/lib/vaccinations/vaccine-catalog.ts`) where they can be corrected +/// in a patch release instead of a migration. +/// - The practice address is not here either; it is on the linked +/// `Practitioner`. The scanned Pass page is not here; it is on the +/// document link. +/// - Either `antigenSlug` or `vaccineName` must be present, and that rule +/// lives in Zod (`src/lib/validations/vaccinations.ts`), not in a DB +/// CHECK. A CHECK would refuse a legacy row on restore, and a backup that +/// cannot round-trip is not a backup. +/// - There is no manufacturer column and no route column. Route follows the +/// antigen and derives from the catalogue at the FHIR boundary. A +/// manufacturer column would pull trade names into shipped content; the +/// only place a trade name legitimately appears is `vaccineName`, which is +/// the person's own transcription of their Pass — user data, not ours. +/// +/// Module gate: `"vaccinations"`, SURFACE-gated like `medications`. The nav +/// entry, the dashboard and report surfaces and the picker hide when the +/// module is off; these data routes stay exempt so a restore or an import +/// keeps working and re-enabling finds every row intact. +model VaccinationRecord { + id String @id @default(cuid()) + userId String @map("user_id") + /// The day the dose was given, at UTC midnight. An Impfpass carries dates, + /// never times, so the time component is not meaningful and is not + /// collected. The API refuses future instants: a vaccination is logged + /// after the fact, and a planned booster is a reminder, not a record. + occurredAt DateTime @map("occurred_at") + /// Catalogue identity. NULL = a free-text-only row, which is the arm that + /// makes a 1987 Pass line loggable at all. + /// + /// Slugs are append-only by convention (the `BIOMARKER_CATALOG` precedent, + /// `src/lib/labs/biomarker-catalog.ts`). Storage is deliberately tolerant + /// where the API is strict: `POST` refuses a slug the catalogue does not + /// resolve, but a restore writes whatever the backup held. A slug that + /// stops resolving degrades to the free-text arm — a tested behaviour, not + /// an accident (`src/lib/vaccinations/__tests__/catalog-integrity.test.ts`). + antigenSlug String? @map("antigen_slug") + /// The Pass's own wording, verbatim. May be a trade name; that is user + /// data. Plaintext and bounded so the list can render and search it. + vaccineName String? @map("vaccine_name") + /// The Pass's claim about which dose this is. Optional — the series + /// derivation falls back to the chronological index per component antigen. + doseNumber Int? @map("dose_number") + /// Per-record override of the catalogue's typical series length, for the + /// schedule a person was actually given. + seriesDoses Int? @map("series_doses") + /// Chargenbezeichnung (IfSG §22). A batch code, not a secret: plaintext and + /// bounded so the list and the report can render it without a decrypt. + lotNumber String? @map("lot_number") + site VaccinationSite? + practitionerId String? @map("practitioner_id") + encounterId String? @map("encounter_id") + /// The booster reminder this dose satisfied, when one matched. The act runs + /// through `satisfyReminder`; this column is the record of it, not the act. + /// One id for possibly several satisfied rows — the engine keeps + /// `lastSatisfiedAt` per reminder, this keeps the first match in catalogue + /// component order. + reminderId String? @map("reminder_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 + /// reaction model nobody has asked for yet. + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull) + encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: SetNull) + reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull) + documentLinks VaccinationDocumentLink[] + + @@index([userId, deletedAt, occurredAt]) + @@index([userId, antigenSlug, occurredAt]) + @@map("vaccination_records") +} + +/// Where the dose went. Small and closed, because the Pass's own vocabulary +/// is small and closed; OTHER absorbs the rest rather than growing the enum. +enum VaccinationSite { + LEFT_ARM + RIGHT_ARM + LEFT_THIGH + RIGHT_THIGH + ORAL + NASAL + OTHER + + @@map("vaccination_site") +} + +/// Dose ↔ stored document, the `DocumentConditionLink` shape. m:n because a +/// scanned Impfpass page carries every dose on it, and a dose may be +/// evidenced by more than one page. Never mandatory: a dose saves without a +/// document, and a document files without a dose. `userId` is denormalised +/// for the owner-scoped list query. This is the only link table the +/// vaccination source owns — practitioner and encounter attach by scalar FK, +/// because a dose is administered once. +model VaccinationDocumentLink { + id String @id @default(cuid()) + vaccinationId String @map("vaccination_id") + documentId String @map("document_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + vaccination VaccinationRecord @relation(fields: [vaccinationId], references: [id], onDelete: Cascade) + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([vaccinationId, documentId]) + @@index([documentId]) + @@index([userId]) + @@map("vaccination_document_links") +} diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 89681a2f7..acfbe3c2a 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -562,6 +562,17 @@ async function main() { ), ); + // ───── v1.38.0 dose free text (Bytes column) ───── + // The note on an administered dose — a reaction, a sore arm. Always + // encrypted on write, so every non-null row rotates. + results.push( + await rotateBytesColumn( + "VaccinationRecord", + "noteEncrypted", + prisma.vaccinationRecord, + ), + ); + // ───── Inbound clinical document (Bytes column, codec-dispatched) ───── // The raw uploaded document. Two layouts recorded per row in // `contentCodec` ("base64v1" string codec | "binary2" binary codec), so diff --git a/src/__tests__/encounter-reminder-exclusion.test.ts b/src/__tests__/encounter-reminder-exclusion.test.ts index 884523d2e..eecb9a461 100644 --- a/src/__tests__/encounter-reminder-exclusion.test.ts +++ b/src/__tests__/encounter-reminder-exclusion.test.ts @@ -95,6 +95,7 @@ function reminderRow( nextDueAt: at, lastSatisfiedAt: null, enabled: true, + vaccinationAntigen: null, createdAt: at, updatedAt: at, deletedAt: null, diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 60fbc51a3..6452d98b6 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -943,6 +943,14 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record 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/route.ts": { + domain: "profile", + why: "The record's immunization history. Health background in the same sense as the allergies and the visits already under this domain: it names no credential, no integration and no notification channel. It carries the same honest cost as those two, which is that a delegate granted `profile` to read an allergy list also sees every dose the person has had; the consent copy names the immunization history for exactly this reason. The create arm is a delegable write.", + }, + "app/api/vaccinations/[id]/route.ts": { + domain: "profile", + why: "One dose of the record, fetch-then-guard against the resolved user. The pages it resolves are read through the link service, which narrows both ends to the same resolved id, and their names are withheld from a grant that does not reach the vault — the filename of a scanned page is itself the sensitive part.", + }, "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.", @@ -1144,6 +1152,10 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "Reopening a deleted address-book entry. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", }, + "app/api/vaccinations/[id]/restore/route.ts": { + domain: "profile", + why: "Reopening a deleted dose with the page it was transcribed from still filed against it. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, "app/api/mood-entries/bulk-delete/route.ts": { domain: "mind", why: "Tombstoning a selection. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", @@ -1387,6 +1399,8 @@ const DELEGABLE_WRITE_ROUTES: Record = { "Filing a visit, or booking one. Every id the body may carry — the practitioner, the checkup it closes, the three link arrays — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's document to another's visit. Booking mints the appointment reminder under the RECORD, which is correct: it is the owner's appointment and the owner's channels that should be nudged about it.", "app/api/practitioners/route.ts": "Adding a doctor or practice to the record's address book. Nothing is unique across accounts here by design, so a delegate adding a practice the owner already has produces a second row rather than a collision with somebody else's namespace.", + "app/api/vaccinations/route.ts": + "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/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": @@ -1513,6 +1527,16 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "Reopening a deleted address-book entry. It does not re-attach the visits whose reference was nulled, because which visits meant this practice is not recoverable.", }, + "app/api/vaccinations/[id]/route.ts": { + domain: "profile", + conditions: ["C4"], + why: "Correcting and removing one dose; the delete soft-deletes and deliberately does not rewind the booster reminder the dose once cleared, because tidying a transcription is not evidence the dose never happened. C4 on the edit: the date, the antigen, the dose number and the two references are filed before and after, and the encrypted note deliberately is not. The edit never re-runs the satisfy matcher, so a delegate cannot move a booster's due date by correcting a typo.", + }, + "app/api/vaccinations/[id]/restore/route.ts": { + domain: "profile", + 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/illness/episodes/[id]/resolve/route.ts": { domain: "illness", conditions: [], @@ -1873,8 +1897,12 @@ const ACTOR_ROUTES: Record = { * read-only and admitted on the whole record for the same reason as the linked * figures, adds one: 213 -> 214. The visit-suggestion read, which serves the * three incidental-linking moments from the same rule, adds one: 214 -> 215. + * + * v1.38.0 -- the immunization log adds six: two reads and one restore on the + * record list, one create on the write literal, and the edit/delete pair plus + * the restore on the manage literal. 215 -> 221. */ -const FROZEN_ENTRY_COUNT = 215; +const FROZEN_ENTRY_COUNT = 221; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2635,7 +2663,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(70); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(74); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index f18f282b5..a21657ddb 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(70); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(74); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index b60133b32..cf730b806 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -140,6 +140,13 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ // phone whose user never opted in cannot land rows server-side. Walking // the tree means a NEW ungated nutrients route fails BY NAME. "src/app/api/nutrients", + // v1.38.0 — the immunization log (`vaccinations` module). SURFACE-gated + // like medications, not data-layer-gated: every `/api/vaccinations/*` route + // is raw CRUD over the person's own doses, so all four are EXEMPT below + // under the same reasoning. Walking the tree means a NEW vaccination route + // must justify itself — gate or documented exemption — rather than + // silently appearing. + "src/app/api/vaccinations", ]; /** @@ -283,6 +290,19 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // rationale as glp1-timeline above: a deterministic detector over weight + // dose history, no AI narrative, medications is core/always-on. "src/app/api/insights/glp1-plateau/route.ts", + // ── DATA LAYER (vaccinations) ───────────────────────────────────── + // v1.38.0 — the immunization log is SURFACE-gated (nav entry, dashboard + // and report leaves, the picker), not data-layer-gated. Every + // `/api/vaccinations/*` route is raw CRUD over the person's own doses and + // the pages they were transcribed from — the same reasoning as mood, labs + // and medications above: the module toggle governs whether vaccinations + // SURFACE, not whether the row store accepts rows. A restore or an import + // must keep working while the module is off, and re-enabling must find + // every dose intact rather than a gap in an Impfpass nobody can rebuild. + "src/app/api/vaccinations/route.ts", + "src/app/api/vaccinations/[id]/route.ts", + "src/app/api/vaccinations/[id]/restore/route.ts", + "src/app/api/vaccinations/[id]/links/route.ts", ]; const MODULE_GATE_NEEDLE = "requireModuleEnabled("; diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index a1b1d2209..4d42c8519 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -290,6 +290,8 @@ function sourceClient() { encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 149213e80..d7fac6681 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -49,6 +49,7 @@ import { restoreProfileData } from "@/lib/export/profile-backup"; import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; import { restoreVisitsData } from "@/lib/export/visits-backup"; +import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -92,6 +93,8 @@ interface RestoreResponse { practitioners: number; encounters: number; encounterLinks: number; + vaccinations: number; + vaccinationLinks: number; }; } @@ -1332,6 +1335,18 @@ const handler = apiHandler( skips, ); + // The immunization log, after the visits: a dose remaps its + // practitioner and its encounter against rows the branch above has + // just written, and its document link needs the documents that + // landed before that. Both ends of this section live in + // `src/lib/export/vaccinations-backup.ts`. + const vaccinationsCleared = await restoreVaccinationsData( + tx, + ownerId, + payload, + skips, + ); + const cleared = { measurements: measurements.count, medications: meds.count, @@ -1361,6 +1376,8 @@ const handler = apiHandler( practitioners: visitsCleared.practitioners, encounters: visitsCleared.encounters, encounterLinks: visitsCleared.encounterLinks, + vaccinations: vaccinationsCleared.vaccinations, + vaccinationLinks: vaccinationsCleared.vaccinationLinks, }; return { cleared, skipped: summarizeRestoreSkips(skips) }; }, diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 16e1c3292..2237b5c1f 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -36,6 +36,8 @@ vi.mock("@/lib/db", () => ({ encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -115,6 +117,10 @@ beforeEach(() => { vi.mocked(prisma.encounterConditionLink.findMany).mockResolvedValue( [] as never, ); + vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue( + [] as never, + ); }); afterEach(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index f1e7b2723..47fc52e16 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -34,6 +34,8 @@ vi.mock("@/lib/db", () => ({ encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/vaccinations/[id]/links/route.ts b/src/app/api/vaccinations/[id]/links/route.ts new file mode 100644 index 000000000..7a9b10442 --- /dev/null +++ b/src/app/api/vaccinations/[id]/links/route.ts @@ -0,0 +1,104 @@ +/** + * `POST /api/vaccinations/{id}/links` — file a scanned page against a dose. + * `DELETE /api/vaccinations/{id}/links` — unfile it. + * + * Deliberately bare `requireAuth()` rather than a delegable declaration, and + * the reason is consistency rather than oversight: document link and unlink + * are not delegable today, and neither is the visit link beside it. Making a + * third answer to the same question would be the drift. The set is revisited + * as one decision when the polymorphic-linking work lands. + * + * Both verbs are idempotent, both are capped, and neither ever blocks a save: + * an id that names nothing this account owns comes back in `unknown` rather + * than failing the request, because a dose must never be un-saveable because + * one page it pointed at has since been deleted. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + apiSuccess, + apiError, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { vaccinationLinkSchema } from "@/lib/validations/vaccinations"; +import { linkTargets, unlinkTargets } from "@/lib/links"; +import { + actingDomainVisibility, + loadVaccinationDocuments, +} from "@/lib/vaccinations/service"; + +type RouteParams = { params: Promise<{ id: string }> }; + +async function handle( + request: NextRequest, + { params }: RouteParams, + verb: "link" | "unlink", +): Promise { + const { user } = await requireAuth(); + + const { id } = await params; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = vaccinationLinkSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.link.invalid", + }); + } + + const apply = verb === "link" ? linkTargets : unlinkTargets; + const result = await apply(prisma, { + userId: user.id, + sourceKind: "vaccination", + sourceId: id, + targetKind: parsed.data.targetKind, + targetIds: parsed.data.targetIds, + }); + + if (result.unknownSource) { + return apiError("Vaccination not found", 404); + } + + annotate({ + action: { + name: `vaccination.link.${verb}`, + entity_type: "vaccination", + entity_id: id, + }, + meta: { + target_kind: parsed.data.targetKind, + changed: result.changed, + unknown: result.unknownTargetIds.length, + }, + }); + + // Bare `requireAuth()` resolves the caller's OWN record — this route is not + // delegable — so there is no narrowed grant to honour and every domain is + // the caller's own. + const visible = await actingDomainVisibility(prisma, null); + const documents = await loadVaccinationDocuments( + prisma, + user.id, + id, + visible, + ); + return apiSuccess({ documents, unknown: result.unknownTargetIds }); +} + +export const POST = apiHandler( + async (request: NextRequest, context: RouteParams) => + handle(request, context, "link"), +); + +export const DELETE = apiHandler( + async (request: NextRequest, context: RouteParams) => + handle(request, context, "unlink"), +); diff --git a/src/app/api/vaccinations/[id]/restore/route.ts b/src/app/api/vaccinations/[id]/restore/route.ts new file mode 100644 index 000000000..685e61657 --- /dev/null +++ b/src/app/api/vaccinations/[id]/restore/route.ts @@ -0,0 +1,75 @@ +/** + * `POST /api/vaccinations/{id}/restore` — clear a dose's tombstone. + * + * MANAGE rather than WRITE: undoing a deletion is a management act when the + * manager is the one who holds delete. + * + * The document links survived the soft delete — they cascade only on a hard + * one — so a restored dose comes back with the page it was transcribed from + * still filed against it. The booster satisfy matcher does not run: restoring + * is not logging a dose, and the reminder it once satisfied was never rewound + * by the deletion either. + */ +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 { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { toVaccinationDTO } from "@/lib/vaccinations/dto"; +import { + VACCINATION_INCLUDE, + actingDomainVisibility, + loadVaccinationDocuments, + resolveSeriesFor, +} from "@/lib/vaccinations/service"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user, grantId } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.vaccinationRecord.findUnique({ + where: { id }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Vaccination not found", 404); + } + + if (existing.deletedAt !== null) { + await prisma.vaccinationRecord.update({ + where: { id }, + data: { deletedAt: null }, + }); + } + + const row = await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id }, + include: VACCINATION_INCLUDE, + }); + const visible = await actingDomainVisibility(prisma, grantId); + const [documents, series] = await Promise.all([ + loadVaccinationDocuments(prisma, user.id, id, visible), + resolveSeriesFor(prisma, user.id), + ]); + + await auditLog("vaccination.record.restore", { + userId: user.id, + ipAddress: getClientIp(request), + details: { vaccinationId: id, occurredAt: row.occurredAt.toISOString() }, + }); + + annotate({ + action: { + name: "vaccination.record.restore", + entity_type: "vaccination", + entity_id: id, + }, + }); + + return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents)); + }, +); diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts new file mode 100644 index 000000000..8a7c77bf2 --- /dev/null +++ b/src/app/api/vaccinations/[id]/route.ts @@ -0,0 +1,282 @@ +/** + * `GET /api/vaccinations/{id}` — one owned, live dose with its links. + * `PATCH /api/vaccinations/{id}` — edit it (partial; field-by-field). + * `DELETE /api/vaccinations/{id}` — soft-delete it. + * + * Owner-scoped by fetch-then-guard. `userId` is narrowed from auth; the body + * never carries it. + * + * PATCH deliberately does NOT re-run the booster satisfy matcher. An edit is a + * correction of a transcription, not a new clinical event, and re-satisfying + * on edit would make `occurredAt` edits side-effectful in a way no other + * clinical model here is: fixing a typo in a date would silently push a + * booster reminder's next due date out. The side effect belongs to the act of + * logging a dose, which happens once. + */ +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 { overwriteDetails } from "@/lib/sharing/audit-details"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { vaccinationUpdateSchema } from "@/lib/validations/vaccinations"; +import { toVaccinationDTO } from "@/lib/vaccinations/dto"; +import { + VACCINATION_INCLUDE, + actingDomainVisibility, + applyVaccinationLinks, + loadVaccinationDocuments, + resolveOwnedEncounter, + resolveOwnedPractitioner, + resolveSeriesFor, +} from "@/lib/vaccinations/service"; +import type { Prisma } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user, grantId } = await requireRecordAuth("read", "profile"); + + const { id } = await params; + const row = await prisma.vaccinationRecord.findUnique({ + where: { id }, + include: VACCINATION_INCLUDE, + }); + if (!row || row.userId !== user.id || row.deletedAt !== null) { + return apiError("Vaccination not found", 404); + } + + const visible = await actingDomainVisibility(prisma, grantId); + const [documents, series] = await Promise.all([ + loadVaccinationDocuments(prisma, user.id, id, visible), + resolveSeriesFor(prisma, user.id), + ]); + + annotate({ + action: { + name: "vaccination.record.read", + entity_type: "vaccination", + entity_id: id, + }, + }); + + return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents)); + }, +); + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user, grantId } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.vaccinationRecord.findUnique({ + where: { id }, + }); + if ( + !existing || + existing.userId !== user.id || + existing.deletedAt !== null + ) { + return apiError("Vaccination not found", 404); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 32 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = vaccinationUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.invalid", + }); + } + + const entry = parsed.data; + + // The identity rule is checked against the MERGED row rather than the + // body, because a PATCH naming only the batch code says nothing about the + // identity and must not be refused for it — while a PATCH that clears the + // one arm a record was saved with would leave a dose with no name at all. + const nextSlug = + entry.antigenSlug !== undefined + ? entry.antigenSlug + : existing.antigenSlug; + const nextName = + entry.vaccineName !== undefined + ? entry.vaccineName + : existing.vaccineName; + if (!nextSlug && !nextName) { + return apiError( + "A dose keeps a name: pick one from the catalogue or type what the record says", + 422, + { errorCode: "vaccination.identity-required" }, + ); + } + + const outcome = await prisma.$transaction(async (tx) => { + if (entry.practitionerId) { + const owned = await resolveOwnedPractitioner( + tx, + user.id, + entry.practitionerId, + ); + if (!owned) return "unknown-practitioner" as const; + } + if (entry.encounterId) { + const owned = await resolveOwnedEncounter( + tx, + user.id, + entry.encounterId, + ); + if (!owned) return "unknown-encounter" as const; + } + + const data: Prisma.VaccinationRecordUpdateInput = {}; + if (entry.occurredAt !== undefined) { + data.occurredAt = new Date(entry.occurredAt); + } + if (entry.antigenSlug !== undefined) data.antigenSlug = entry.antigenSlug; + if (entry.vaccineName !== undefined) data.vaccineName = entry.vaccineName; + if (entry.doseNumber !== undefined) data.doseNumber = entry.doseNumber; + if (entry.seriesDoses !== undefined) data.seriesDoses = entry.seriesDoses; + if (entry.lotNumber !== undefined) data.lotNumber = entry.lotNumber; + if (entry.site !== undefined) data.site = entry.site; + if (entry.note !== undefined) { + data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null; + } + if (entry.practitionerId !== undefined) { + data.practitioner = entry.practitionerId + ? { connect: { id: entry.practitionerId } } + : { disconnect: true }; + } + if (entry.encounterId !== undefined) { + data.encounter = entry.encounterId + ? { connect: { id: entry.encounterId } } + : { disconnect: true }; + } + + const updated = await tx.vaccinationRecord.update({ + where: { id }, + data, + }); + await applyVaccinationLinks(tx, user.id, id, entry.documentIds); + return { updated }; + }); + + if (outcome === "unknown-practitioner") { + return apiError("Practitioner not found", 404, { + errorCode: "vaccination.practitioner-not-found", + }); + } + if (outcome === "unknown-encounter") { + return apiError("Visit not found", 404, { + errorCode: "vaccination.encounter-not-found", + }); + } + + const row = await prisma.vaccinationRecord.findUniqueOrThrow({ + where: { id }, + include: VACCINATION_INCLUDE, + }); + const visible = await actingDomainVisibility(prisma, grantId); + const [documents, series] = await Promise.all([ + loadVaccinationDocuments(prisma, user.id, id, visible), + resolveSeriesFor(prisma, user.id), + ]); + + await auditLog("vaccination.record.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + vaccinationId: id, + // The note is deliberately absent: encrypted PHI, and the audit table + // is not a second copy of the record. + ...overwriteDetails({ + before: { + occurredAt: existing.occurredAt, + antigenSlug: existing.antigenSlug, + doseNumber: existing.doseNumber, + practitionerId: existing.practitionerId, + encounterId: existing.encounterId, + }, + after: { + occurredAt: outcome.updated.occurredAt, + antigenSlug: outcome.updated.antigenSlug, + doseNumber: outcome.updated.doseNumber, + practitionerId: outcome.updated.practitionerId, + encounterId: outcome.updated.encounterId, + }, + }), + }, + }); + + annotate({ + action: { + name: "vaccination.record.update", + entity_type: "vaccination", + entity_id: id, + }, + meta: { antigen_slug: row.antigenSlug ?? "free-text" }, + }); + + return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.vaccinationRecord.findUnique({ + where: { id }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Vaccination not found", 404); + } + + // Already tombstoned: deleting twice is a no-op that still succeeds. + // + // A booster reminder this dose satisfied is deliberately NOT re-opened. + // The reminder belongs to the person's preventive-care list and outlives + // the record; rewinding its due date because a transcription was tidied up + // would turn a correction into a nudge. + if (existing.deletedAt === null) { + await prisma.vaccinationRecord.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + } + + await auditLog("vaccination.record.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + vaccinationId: id, + occurredAt: existing.occurredAt.toISOString(), + antigenSlug: existing.antigenSlug, + }, + }); + + annotate({ + action: { + name: "vaccination.record.delete", + entity_type: "vaccination", + entity_id: id, + }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts new file mode 100644 index 000000000..16d2be8b7 --- /dev/null +++ b/src/app/api/vaccinations/route.ts @@ -0,0 +1,257 @@ +/** + * `GET /api/vaccinations` — the account's immunization log, series resolved. + * `POST /api/vaccinations` — log one dose, optionally pre-linked to the page + * it was transcribed from. + * + * Classified `profile`: an immunization history is health background in the + * same sense as the allergy list and the visit record already under that + * domain. No credential, no integration, no outbound channel. + * + * The module gate governs the SURFACES, not this route. A disabled + * `vaccinations` module hides the nav entry, the dashboard and report leaves + * and the picker; the data routes stay reachable so a restore or an import + * keeps working and re-enabling the module finds every row intact. That is the + * `medications` posture, and the route-gate inventory records it. + * + * `userId` is narrowed from auth and fed to the Prisma `where`; it is never a + * body field. The `data` object is built field-by-field. + */ +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 { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + vaccinationCreateSchema, + vaccinationListQuerySchema, +} from "@/lib/validations/vaccinations"; +import { + toVaccinationDTO, + type VaccinationListDTO, +} from "@/lib/vaccinations/dto"; +import { + VACCINATION_INCLUDE, + applyVaccinationLinks, + resolveOwnedEncounter, + resolveOwnedPractitioner, + resolveOwnerTimezone, + resolveSeriesFor, +} from "@/lib/vaccinations/service"; +import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "profile"); + + const params = new URL(request.url).searchParams; + const parsed = vaccinationListQuerySchema.safeParse({ + antigenSlug: params.get("antigenSlug") ?? undefined, + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + limit: params.get("limit") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.invalid", + }); + } + + const query = parsed.data; + // Full and bounded rather than paged: an Impfpass is a lifetime document but + // a small one, and the default already covers a long life. + const take = query.limit ?? 300; + + const [rows, series] = await Promise.all([ + prisma.vaccinationRecord.findMany({ + where: { + userId: user.id, + deletedAt: null, + ...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}), + ...(query.from || query.to + ? { + occurredAt: { + ...(query.from ? { gte: new Date(query.from) } : {}), + ...(query.to ? { lte: new Date(query.to) } : {}), + }, + } + : {}), + }, + orderBy: { occurredAt: "desc" }, + take, + include: VACCINATION_INCLUDE, + }), + // Over the WHOLE live set, never over the filtered page: a position is a + // count of what came before it, so deriving it from a window would report + // the oldest dose in that window as the first one ever given. + resolveSeriesFor(prisma, user.id), + ]); + + annotate({ + action: { name: "vaccination.record.list", entity_type: "vaccination" }, + meta: { count: rows.length, filtered: Boolean(query.antigenSlug) }, + }); + + const body: VaccinationListDTO = { + vaccinations: rows.map((row) => + toVaccinationDTO(row, series.get(row.id) ?? []), + ), + }; + return apiSuccess(body); +}); + +// A double-tap on "save" must not log the same dose twice. +export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination)); + +async function postVaccination(request: NextRequest): Promise { + const { user } = await requireRecordAuth("write", "profile"); + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 32 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = vaccinationCreateSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "vaccination.record.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.invalid", + }); + } + + const entry = parsed.data; + const occurredAt = new Date(entry.occurredAt); + const timezone = await resolveOwnerTimezone(user.id); + + // Filled inside the transaction, read after it for the wide event: an + // annotation is not worth holding a transaction open for. + let satisfiedReminderIds: string[] = []; + + const created = await prisma.$transaction(async (tx) => { + // Every id the body carries is re-narrowed to the resolved account before + // anything is written. A practice or a visit the caller does not own is a + // refusal rather than a silently dropped field: the person named one and + // would otherwise watch the dose save without it. + if (entry.practitionerId) { + const owned = await resolveOwnedPractitioner( + tx, + user.id, + entry.practitionerId, + ); + if (!owned) return "unknown-practitioner" as const; + } + if (entry.encounterId) { + const owned = await resolveOwnedEncounter(tx, user.id, entry.encounterId); + if (!owned) return "unknown-encounter" as const; + } + + // Field-by-field — never spread the parsed object whole. + const row = await tx.vaccinationRecord.create({ + data: { + userId: user.id, + occurredAt, + antigenSlug: entry.antigenSlug ?? null, + vaccineName: entry.vaccineName ?? null, + doseNumber: entry.doseNumber ?? null, + seriesDoses: entry.seriesDoses ?? null, + lotNumber: entry.lotNumber ?? null, + site: entry.site ?? null, + practitionerId: entry.practitionerId ?? null, + encounterId: entry.encounterId ?? null, + noteEncrypted: entry.note ? encryptToBytes(entry.note) : null, + }, + select: { id: true }, + }); + + await applyVaccinationLinks(tx, user.id, row.id, entry.documentIds); + + // The booster arm, inside the same transaction so a dose that failed to + // save cannot leave a reminder looking settled. It runs on the OWNER's + // reminders, which is correct even when a delegate is transcribing: it is + // the owner's booster plan, and filing their Impfpass is exactly the work + // that should settle it. + const boosters = await satisfyBoostersForDose( + tx, + user.id, + entry.antigenSlug ?? null, + occurredAt, + timezone, + ); + satisfiedReminderIds = boosters.satisfiedReminderIds; + if (boosters.primaryReminderId) { + await tx.vaccinationRecord.update({ + where: { id: row.id }, + data: { reminderId: boosters.primaryReminderId }, + }); + } + + return tx.vaccinationRecord.findUniqueOrThrow({ + where: { id: row.id }, + include: VACCINATION_INCLUDE, + }); + }); + + if (created === "unknown-practitioner") { + return apiError("Practitioner not found", 404, { + errorCode: "vaccination.practitioner-not-found", + }); + } + if (created === "unknown-encounter") { + return apiError("Visit not found", 404, { + errorCode: "vaccination.encounter-not-found", + }); + } + + const series = await resolveSeriesFor(prisma, user.id); + + await auditLog("vaccination.record.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + vaccinationId: created.id, + occurredAt: created.occurredAt.toISOString(), + antigenSlug: created.antigenSlug, + // The note is deliberately absent: it is encrypted PHI and the audit + // table is not a second copy of the record. + }, + }); + + annotate({ + action: { + name: "vaccination.record.create", + entity_type: "vaccination", + entity_id: created.id, + }, + meta: { + antigen_slug: created.antigenSlug ?? "free-text", + linked_documents: entry.documentIds?.length ?? 0, + satisfied_boosters: satisfiedReminderIds.length, + }, + }); + for (const reminderId of satisfiedReminderIds) { + annotate({ + action: { + name: "vaccination.booster.satisfied", + entity_type: "measurement-reminder", + entity_id: reminderId, + }, + meta: { antigen_slug: created.antigenSlug ?? "free-text" }, + }); + } + + return apiSuccess( + toVaccinationDTO(created, series.get(created.id) ?? []), + 201, + ); +} diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index b3b92484d..9b80465ea 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -201,6 +201,9 @@ function catalogueLabel( if (catalogue === "visitReference") { return t("admin.section.backups.restoreSkippedVisitReference"); } + if (catalogue === "vaccinationReference") { + return t("admin.section.backups.restoreSkippedVaccinationReference"); + } if (catalogue === "checkupClosure") { return t("admin.section.backups.restoreSkippedCheckupClosure"); } diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index 606444432..f42a0b95f 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -53,6 +53,7 @@ import { Pill, Smile, Sparkles, + Syringe, TestTube, Thermometer, type LucideIcon, @@ -110,6 +111,8 @@ const MODULE_ICONS: Record = { mentalHealth: Brain, // v1.28 — opt-in micronutrient-intake sync (Apple Health day totals). nutrients: Leaf, + // v1.38.0 — the immunization log. + vaccinations: Syringe, }; export function ModulesSection() { diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 6caf22db1..99afe45c9 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -242,6 +242,13 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { model: "Encounter", field: "outcomeEncrypted", kind: "bytes" }, { model: "Practitioner", field: "noteEncrypted", kind: "bytes" }, + // ───── v1.38.0 dose free text (Bytes column) ───── + // What the person wrote about the dose — a reaction, a sore arm, why the + // schedule went the way it did. The Pass's own structured fields stay + // queryable plaintext (the batch code is a batch code, not a secret), so + // this is the one column on the record that rotates. + { model: "VaccinationRecord", field: "noteEncrypted", kind: "bytes" }, + // ───── Inbound clinical document (Bytes column, codec-dispatched) ───── // The raw uploaded document (the most sensitive blob in the corpus; never // logged, never in wide-event meta). Two layouts, recorded per row in diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index fb6dacf08..73ca46678 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -142,6 +142,7 @@ function moduleMap( inboundDocuments: true, mentalHealth: true, nutrients: true, + vaccinations: true, }; return { ...base, ...overrides }; } diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index e92e08e2f..522296d04 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -97,6 +97,15 @@ export const WIPE_MODELS = [ "EncounterConditionLink", "Encounter", + // ── Vaccinations ──────────────────────────────────────────────────────── + // Same ordering argument as the visit links: the document link cascades + // from the dose AND from the stored document, so it is deleted before + // either or its count reads zero on the audit row. The dose points at a + // practitioner and an encounter with SetNull, which is why it goes here, + // ahead of both. + "VaccinationDocumentLink", + "VaccinationRecord", + // ── Clinical record ───────────────────────────────────────────────────── "MentalHealthAssessment", "LabResult", diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 8380077ea..c7aeccfbd 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -255,6 +255,8 @@ function makePrisma() { encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 031dc3852..d3f42b992 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -142,6 +142,14 @@ export const BACKED_UP_MODELS = [ "EncounterLabLink", "EncounterConditionLink", + // ── Vaccinations ────────────────────────────────────────────────────────── + // An immunization history is the one part of a health record that has no + // other copy: the paper Pass is the original, and once it has been + // transcribed the transcription is what the person relies on. Nothing + // re-syncs it from a provider. + "VaccinationRecord", + "VaccinationDocumentLink", + // ── Coach ───────────────────────────────────────────────────────────────── "CoachConversation", "CoachMessage", @@ -190,6 +198,10 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // relation-shaped proof would be attributed to whichever one a matcher found // first — the `symptomLinks` confusion, avoided by construction. "src/lib/export/visits-backup.ts", + // The immunization log and its document link, same arrangement for the same + // reason: `documentLinks` is a field name on three models now, so each of + // these reads through its OWN delegate here. + "src/lib/export/vaccinations-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -199,6 +211,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/intraday-profile-backup.ts", "src/lib/export/health-score-backup.ts", "src/lib/export/visits-backup.ts", + "src/lib/export/vaccinations-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -262,6 +275,15 @@ export const TWO_ENDED_MODELS = [ "EncounterDocumentLink", "EncounterLabLink", "EncounterConditionLink", + // Doses travel both ways from the release that introduces them, and the + // link with them. `DocumentConditionLink` one list down says what the + // alternative costs — documents and conditions both restore, the filing + // between them does not. Repeating that here would return a restored + // Impfpass scan and a restored dose with nothing between them, which is the + // same regret against a record a person cannot reconstruct from anywhere + // else. + "VaccinationRecord", + "VaccinationDocumentLink", ] as const; /** One model claimed to travel both ways. */ diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index f587439c6..05a9537a7 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -43,6 +43,12 @@ import { type VisitsBackupCounts, type VisitsBackupSection, } from "@/lib/export/visits-backup"; +import { + buildVaccinationsBackupSection, + countVaccinationsBackupSection, + type VaccinationsBackupCounts, + type VaccinationsBackupSection, +} from "@/lib/export/vaccinations-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -56,7 +62,8 @@ export interface FullBackupCounts ProfileBackupCounts, IntradayProfileBackupCounts, HealthScoreBackupCounts, - VisitsBackupCounts { + VisitsBackupCounts, + VaccinationsBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -269,6 +276,7 @@ export async function buildFullBackupPayload( intradayProfiles, healthScoreRecords, visits, + vaccinations, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -402,6 +410,12 @@ export async function buildFullBackupPayload( buildVisitsBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The immunization log and the pages it was transcribed from. Both ends + // live in `src/lib/export/vaccinations-backup.ts` beside each other, the + // same arrangement as the visits above and for the same reason. + buildVaccinationsBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -429,6 +443,7 @@ export async function buildFullBackupPayload( const intradaySection: IntradayProfileBackupSection = intradayProfiles; const healthScoreSection: HealthScoreBackupSection = healthScoreRecords; const visitsSection: VisitsBackupSection = visits; + const vaccinationsSection: VaccinationsBackupSection = vaccinations; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -659,6 +674,7 @@ export async function buildFullBackupPayload( ...intradaySection, ...healthScoreSection, ...visitsSection, + ...vaccinationsSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -687,6 +703,7 @@ export async function buildFullBackupPayload( ...countIntradayProfileBackupSection(intradayProfiles), ...countHealthScoreBackupSection(healthScoreRecords), ...countVisitsBackupSection(visits), + ...countVaccinationsBackupSection(vaccinations), }, }; } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index b13148f07..24fa35348 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -40,7 +40,14 @@ * travels, or a document, lab result or condition episode whose id a portable * export does not carry. * - * The sixth, `checkupClosure`, is not about a restore at all, and it borrows + * The sixth, `vaccinationReference`, is the same kind as `visitReference` and + * is kept separate rather than folded into it so the report says which part of + * the record lost an edge. It names a row an immunization entry pointed at + * that the restore did not put back — a booster reminder, which never travels, + * a practitioner or visit the file referenced without carrying, or a scanned + * page whose id a portable export does not include. + * + * The seventh, `checkupClosure`, is not about a restore at all, and it borrows * this shape deliberately rather than growing a second reporting mechanism * beside it. The situation is the same one: something a write was asked to do * could not be done, the record itself survives, and the person is told which @@ -54,6 +61,7 @@ export type SkippedCatalogue = | "moodFactor" | "moodTag" | "visitReference" + | "vaccinationReference" | "checkupClosure"; /** One key this instance does not know, and the links it cost. */ diff --git a/src/lib/export/vaccinations-backup.ts b/src/lib/export/vaccinations-backup.ts new file mode 100644 index 000000000..e4515747e --- /dev/null +++ b/src/lib/export/vaccinations-backup.ts @@ -0,0 +1,418 @@ +/** + * The immunization log, with both backup ends in one file. + * + * Same arrangement as `visits-backup.ts` and for the same reason: a reader + * asking "is this carried at both ways?" answers it here, and a reader who + * 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 + * 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 + * else, so the same regret is not repeated here. + * + * Each model uses its own named select constant and its own delegate call + * rather than riding a parent's `include`: `documentLinks` is a relation field + * name on three different models now, so a relation-shaped write would be + * attributed to whichever of them a matcher happened to find first. + * + * Three references need care on the way back: + * + * - `practitionerId` and `encounterId` point at models that restore in the + * same run. They remap to the restored rows, and drop to NULL when the + * file references a row it does not carry. + * - `reminderId` points at a `MeasurementReminder`, which is on the + * coverage-pending register and does not come back. It resolves to NULL, + * never to a dangling id, and the drop is reported. + * - `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 + * guarantee `src/lib/vaccinations/__tests__/catalog-integrity.test.ts` + * holds. + */ +import { Buffer } from "node:buffer"; +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import type { VaccinationSite } from "@/generated/prisma/client"; + +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +export interface VaccinationsBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** One administered dose, as the Pass recorded it. */ +export interface VaccinationBackupEntry { + /** Always carried: the document link addresses a dose by it. */ + id: string; + occurredAt: string; + /** + * Carried verbatim, never checked against the catalogue this release ships. + * A slug that stopped resolving still describes what the person was given. + */ + antigenSlug: string | null; + vaccineName: string | null; + doseNumber: number | null; + seriesDoses: number | null; + lotNumber: string | null; + site: VaccinationSite | null; + practitionerId: string | null; + encounterId: string | null; + /** + * Carried so the loss is visible in the file rather than inferred from its + * absence. The restore resolves it to NULL and names the drop: the reminder + * it points at is not in the backup. + */ + reminderId: string | null; + /** Base64 ciphertext, carried verbatim — never decrypted into the file. */ + noteEncrypted: string | 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; + targetId: string; + createdAt: string; +} + +export interface VaccinationsBackupSection { + vaccinations: VaccinationBackupEntry[]; + vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[]; +} + +export interface VaccinationsBackupCounts { + vaccinations: number; + vaccinationLinks: number; +} + +/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */ +function encodeCiphertext(value: Uint8Array | null): string | null { + if (!value || value.byteLength === 0) return null; + return Buffer.from(value).toString("base64"); +} + +/** + * Named select constants, one per model — a structural matcher binds a model + * to the literal beside its delegate call, and an inline object inside a + * `Promise.all` is the shape earlier work here had to refactor away from. + */ +const VACCINATION_BACKUP_SELECT = { + id: true, + occurredAt: true, + antigenSlug: true, + vaccineName: true, + doseNumber: true, + seriesDoses: true, + lotNumber: true, + site: true, + practitionerId: true, + encounterId: true, + reminderId: true, + noteEncrypted: true, + createdAt: true, + updatedAt: true, + deletedAt: true, +} as const; + +const VACCINATION_DOCUMENT_LINK_BACKUP_SELECT = { + vaccinationId: true, + documentId: true, + createdAt: true, +} as const; + +/** + * Build the immunization slice of a user's full backup. + * + * Takes the delegates it uses rather than a whole client, matching the other + * section builders. A portable export omits tombstones so a restore cannot + * resurrect them; a disaster-recovery payload keeps them so the account comes + * back as itself. + */ +export async function buildVaccinationsBackupSection( + prisma: Pick, + userId: string, + options: VaccinationsBackupOptions = {}, +): Promise { + 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, + }), + ]); + + return { + vaccinations: vaccinationRows.map((row) => ({ + id: row.id, + occurredAt: row.occurredAt.toISOString(), + antigenSlug: row.antigenSlug, + vaccineName: row.vaccineName, + doseNumber: row.doseNumber, + seriesDoses: row.seriesDoses, + lotNumber: row.lotNumber, + site: row.site, + practitionerId: row.practitionerId, + encounterId: row.encounterId, + reminderId: row.reminderId, + noteEncrypted: encodeCiphertext(row.noteEncrypted), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(disasterRecovery + ? { deletedAt: row.deletedAt?.toISOString() ?? null } + : {}), + })), + vaccinationDocumentLinks: documentLinkRows.map((row) => ({ + vaccinationId: row.vaccinationId, + targetId: row.documentId, + createdAt: row.createdAt.toISOString(), + })), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countVaccinationsBackupSection( + section: VaccinationsBackupSection, +): VaccinationsBackupCounts { + return { + vaccinations: section.vaccinations.length, + vaccinationLinks: section.vaccinationDocumentLinks.length, + }; +} + +/** Counts the immunization restore wiped, for the audit trail. */ +export interface VaccinationsRestoreCleared { + vaccinations: number; + vaccinationLinks: number; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: the payload schema defaults every key, so + * every caller already satisfies this, and one that stops satisfying it fails + * to compile instead of passing `undefined` into a loop that iterates zero + * times and reports success. + */ +export interface VaccinationsRestoreInput { + vaccinations: RestoredVaccination[]; + vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[]; +} + +/** + * 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, + * and every optional column arrives as `undefined` rather than `null`. + */ +type OptionalNullable = { [K in keyof T]?: T[K] | undefined }; + +export type RestoredVaccination = Pick< + VaccinationBackupEntry, + "id" | "occurredAt" | "createdAt" | "updatedAt" +> & + OptionalNullable< + Pick< + VaccinationBackupEntry, + | "antigenSlug" + | "vaccineName" + | "doseNumber" + | "seriesDoses" + | "lotNumber" + | "site" + | "practitionerId" + | "encounterId" + | "reminderId" + | "noteEncrypted" + > + > & { deletedAt?: string | null }; + +function decodeCiphertext(encoded: string): Uint8Array { + const decoded = Buffer.from(encoded, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; +} + +/** + * Re-create the account's immunization log and its document links. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. The dose first, then the link, because a link addresses both of its + * ends by id. + * + * MUST be called after the practitioners, encounters and documents have been + * restored: the dose remaps two of those references against the database and + * the link is written only when the document actually came back. Calling it + * earlier would drop every reference and be counted as a successful restore of + * a thinner record. + */ +export async function restoreVaccinationsData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: VaccinationsRestoreInput, + skips: RestoreSkipLog, +): Promise { + const clearedLinks = await tx.vaccinationDocumentLink.deleteMany({ + where: { userId: ownerId }, + }); + const clearedVaccinations = await tx.vaccinationRecord.deleteMany({ + where: { userId: ownerId }, + }); + + // Both far sides are restored by other branches of the same transaction, so + // the check is against the database rather than against the payload — this + // is the only place that can see all three sections at once. + const [practitionerIds, encounterIds] = await Promise.all([ + tx.practitioner.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + tx.encounter.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + ]); + const restoredPractitioners = new Set(practitionerIds.map((row) => row.id)); + const restoredEncounters = new Set(encounterIds.map((row) => row.id)); + + // A row the file references but the restore did not bring back cannot be + // invented, and a dangling id would fail the foreign key and roll the whole + // restore back over one dose. The reference drops to NULL instead — the + // dose, its date, its antigen and its batch code all survive. + const droppedPractitioners: string[] = []; + const droppedEncounters: string[] = []; + // The reminder is on the coverage-pending register: it is not in the file at + // all, so every reference to one is a known, reportable drop. + const droppedReminders: string[] = []; + + if (payload.vaccinations.length > 0) { + await tx.vaccinationRecord.createMany({ + data: payload.vaccinations.map((entry) => { + const practitionerId = + entry.practitionerId && + restoredPractitioners.has(entry.practitionerId) + ? entry.practitionerId + : null; + if (entry.practitionerId && practitionerId === null) { + droppedPractitioners.push(entry.practitionerId); + } + const encounterId = + entry.encounterId && restoredEncounters.has(entry.encounterId) + ? entry.encounterId + : null; + if (entry.encounterId && encounterId === null) { + droppedEncounters.push(entry.encounterId); + } + if (entry.reminderId) droppedReminders.push(entry.reminderId); + return { + id: entry.id, + userId: ownerId, + occurredAt: new Date(entry.occurredAt), + // Verbatim, deliberately: a slug the current catalogue no longer + // resolves still describes what was given, and the renderer falls + // back to `vaccineName`. + antigenSlug: entry.antigenSlug ?? null, + vaccineName: entry.vaccineName ?? null, + doseNumber: entry.doseNumber ?? null, + seriesDoses: entry.seriesDoses ?? null, + lotNumber: entry.lotNumber ?? null, + site: entry.site ?? null, + practitionerId, + encounterId, + // Never the id the file carried: the row it named is not restored. + reminderId: null, + noteEncrypted: + entry.noteEncrypted == null + ? null + : decodeCiphertext(entry.noteEncrypted), + createdAt: new Date(entry.createdAt), + updatedAt: new Date(entry.updatedAt), + deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + }; + }), + }); + } + + recordUnknownKeys( + skips, + "vaccinationReference", + [...new Set(droppedPractitioners)], + droppedPractitioners, + ); + recordUnknownKeys( + skips, + "vaccinationReference", + [...new Set(droppedEncounters)], + droppedEncounters, + ); + recordUnknownKeys( + skips, + "vaccinationReference", + [...new Set(droppedReminders)], + droppedReminders, + ); + + const restoredVaccinations = new Set( + payload.vaccinations.map((entry) => entry.id), + ); + const documentIds = await tx.inboundDocument.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }); + const restoredDocuments = new Set(documentIds.map((row) => row.id)); + + const writableLinks: Array<{ + vaccinationId: string; + documentId: string; + createdAt: Date; + }> = []; + const droppedLinks: string[] = []; + for (const entry of payload.vaccinationDocumentLinks) { + if ( + restoredVaccinations.has(entry.vaccinationId) && + restoredDocuments.has(entry.targetId) + ) { + writableLinks.push({ + vaccinationId: entry.vaccinationId, + documentId: entry.targetId, + createdAt: new Date(entry.createdAt), + }); + } else { + droppedLinks.push(entry.targetId); + } + } + if (writableLinks.length > 0) { + await tx.vaccinationDocumentLink.createMany({ + data: writableLinks.map((row) => ({ + userId: ownerId, + vaccinationId: row.vaccinationId, + documentId: row.documentId, + createdAt: row.createdAt, + })), + }); + } + recordUnknownKeys( + skips, + "vaccinationReference", + [...new Set(droppedLinks)], + droppedLinks, + ); + + return { + vaccinations: clearedVaccinations.count, + vaccinationLinks: clearedLinks.count, + }; +} diff --git a/src/lib/links/link-service.ts b/src/lib/links/link-service.ts index 7a2515f15..d58a3b36f 100644 --- a/src/lib/links/link-service.ts +++ b/src/lib/links/link-service.ts @@ -12,7 +12,7 @@ import type { Prisma } from "@/generated/prisma/client"; export const MAX_TARGETS_PER_CALL = 100; /** The record a link hangs off. */ -export type LinkSourceKind = "encounter" | "document"; +export type LinkSourceKind = "encounter" | "document" | "vaccination"; /** The record a link points at. */ export type LinkTargetKind = @@ -138,6 +138,10 @@ const SOURCES: Record< delegate: (tx) => narrow(tx.inboundDocument), relationField: "document", }, + vaccination: { + delegate: (tx) => narrow(tx.vaccinationRecord), + relationField: "vaccination", + }, }; const DOCUMENT_ENDPOINT: EndpointSpec = { @@ -198,6 +202,13 @@ const CONDITION_ENDPOINT: EndpointSpec = { * than a result field. Three is the number of encounter link tables this * design allows; a fourth is the point at which this stops being a small set * of named relations and starts being a general edge store. + * + * That ceiling is per source, and the `vaccination` source owns exactly ONE + * pair. A dose is administered once, so the practitioner and the visit attach + * by scalar FK rather than through a table; the many-to-many argument that + * justifies the lab and condition links does not transfer. A second + * vaccination pair is this becoming the edge store, and is the point to stop + * and raise it rather than to add a row here. */ const LINK_TABLES: Partial< Record<`${LinkSourceKind}:${LinkTargetKind}`, LinkTableSpec> @@ -238,6 +249,12 @@ const LINK_TABLES: Partial< targetColumn: "encounterId", target: ENCOUNTER_ENDPOINT, }, + "vaccination:document": { + delegate: (tx) => narrow(tx.vaccinationDocumentLink), + sourceColumn: "vaccinationId", + targetColumn: "documentId", + target: DOCUMENT_ENDPOINT, + }, }; function tableFor( diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 329254702..7025e129f 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -154,6 +154,25 @@ export const MODULE_KEYS = [ // ingest route refuses (403 `module.disabled`) and the settings card // does not render. The `optIn` marker inverts the per-user default. "nutrients", + // v1.38.0 — the immunization log (`/vaccinations`). A tracked clinical + // vertical a person may not use at all, which is exactly the shape the + // existing opt-out verticals have (`labs`, `illness`, `medications`), so it + // gets a key rather than riding somebody else's. Default-on, no `optIn` + // marker: no egress, no new attack surface — the `mentalHealth` / + // `inboundDocuments` posture. + // + // SURFACE-gated like `medications`: the nav entry, the dashboard and report + // surfaces and the picker hide, and the `/api/vaccinations*` data routes + // stay exempt so a restore / import keeps working and re-enabling finds + // every row intact. + // + // The one subtlety, stated so nobody reads it as a leak: a booster reminder + // the user CONFIRMED does not follow this off-switch. It is an ordinary + // preventive-care row on an engine that is never gated, and a reminder + // somebody agreed to keeps its promise. Turning the module off hides + // surfaces the person made content for; it does not silently retract a + // commitment the app made to them. + "vaccinations", ] as const; export type ModuleKey = (typeof MODULE_KEYS)[number]; @@ -341,6 +360,14 @@ export const MODULE_REGISTRY: Readonly> = // ingest refuses while off (refuse-ingest posture, not surface-only). optIn: true, }, + vaccinations: { + key: "vaccinations", + labelKey: "modules.vaccinations.label", + descriptionKey: "modules.vaccinations.description", + category: "tracking", + // No `optIn`: default-on. Nothing leaves the instance and the surfaces + // are inert until the person logs a dose. + }, }); const MODULE_KEY_SET: ReadonlySet = new Set(MODULE_KEYS); diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index bbd5158f7..1d053c5ee 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -49,6 +49,7 @@ import { exportPaths } from "./export"; import { familyHistoryPaths } from "./family-history"; import { healthRecordPaths } from "./health-record"; import { encounterPaths } from "./encounters"; +import { vaccinationPaths } from "./vaccinations"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; import { insightsPaths } from "./insights"; @@ -104,6 +105,7 @@ export const openApiPaths: NonNullable = { ...ocrPaths, ...biomarkerPaths, ...encounterPaths, + ...vaccinationPaths, ...illnessPaths, ...mentalHealthPaths, ...allergyPaths, diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts new file mode 100644 index 000000000..aa9a71c61 --- /dev/null +++ b/src/lib/openapi/routes/vaccinations.ts @@ -0,0 +1,367 @@ +/** + * OpenAPI route table for the immunization log (`/api/vaccinations`). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies + * and queries reuse the runtime Zod schemas so the wire contract stays + * single-source. Response shapes are declared here to mirror the + * server-authoritative DTOs. + * + * The one contract worth reading twice is `series`. "Dose 3 of 3" is a + * property of a person's whole history for an antigen, not of the row it is + * printed beside, so it is derived once on the server and published as + * resolved numbers. A client renders text from those numbers. It must never + * recompute them, and it is not given the input it would need to try. + * + * Note what is NOT here: the reminder contract does not move. Logging a dose + * satisfies the booster reminders it answers, and that happens through the + * ordinary preventive-care engine — same endpoints, same shape, same + * two-member `origin` enum. The match key that connects them is a server-side + * column and reaches no response. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + vaccinationCreateSchema, + vaccinationUpdateSchema, + vaccinationListQuerySchema, + vaccinationLinkSchema, + vaccinationSiteEnum, +} from "@/lib/validations/vaccinations"; + +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; + +vaccinationCreateSchema.meta({ + id: "CreateVaccinationRequest", + description: + "Log one administered dose. `occurredAt` is required and must not be in the future — a vaccination is recorded after the fact, and a planned booster is a reminder rather than a record. Exactly one identity arm is required: either `antigenSlug` (a slug the catalogue offers) or `vaccineName` (whatever the person's own record says, verbatim, which may be a trade name). Everything else is optional, so a decades-old paper entry with nothing but a date and a name is loggable. `note` is encrypted at rest. `documentIds` pre-links the scanned pages; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save. Logging a dose satisfies any booster reminder keyed to one of its component antigens.", +}); + +vaccinationUpdateSchema.meta({ + id: "UpdateVaccinationRequest", + description: + "Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. `occurredAt` is editable because a transcription typo is the common case — and editing it deliberately does NOT re-run the booster satisfaction, so correcting a date can never move a reminder's due date. An edit that would leave the record with neither identity arm is refused. A present `documentIds` array replaces the links, empty array included.", +}); + +vaccinationListQuerySchema.meta({ + id: "ListVaccinationsQuery", + description: + "Filters for the immunization list: `antigenSlug`, `from` / `to` (ISO-8601 instants) and `limit` (1–500, default 300). The list is full and bounded rather than paged — a vaccination record is a lifetime document but a short one.", +}); + +vaccinationLinkSchema.meta({ + id: "VaccinationLinkRequest", + description: + "Documents to file against, or unfile from, a dose. Idempotent in both directions and capped at 100 ids. Ids naming nothing the caller owns come back in `unknown` rather than failing the request.", +}); + +const catalogEntry = z + .object({ + slug: z.string(), + atc: z.string(), + category: z.string(), + }) + .meta({ + id: "VaccinationCatalogEntry", + description: + "What the catalogue this release ships knows about the record's `antigenSlug`: the slug itself, its WHO ATC code from the J07 subtree, and its picker grouping. Null when the slug does not resolve — a slug written by an older release, or one restored verbatim from an older backup. Null is the signal to render `vaccineName` instead; it is not an error and not missing data.", + }); + +const seriesPosition = z + .object({ + antigen: z.string(), + position: z.number().int(), + total: z.number().int().nullable(), + booster: z.boolean(), + }) + .meta({ + id: "VaccinationSeriesPosition", + description: + "Where this dose sits in ONE antigen's series, derived server-side over the whole history. `position` is the person's own recorded dose number when they gave one, otherwise the chronological index among their doses for that antigen. `total` is the record's own series length, then the catalogue's typical one, then null when neither knows — a seasonal vaccine has no 'of M'. `booster` is true once the series is complete and this dose comes after it. Render text from these values: both resolved reads as 'N of M', position alone as 'dose N', `booster` as a booster. Do not recompute them — a client cannot see the whole history and the two would drift.", + }); + +const vaccinationDocument = z + .object({ + id: z.string(), + label: z.string().nullable(), + date: z.string().nullable(), + redacted: z.boolean(), + }) + .meta({ + id: "VaccinationLinkedDocument", + description: + "One page filed against a dose, resolved. The label and the date are computed server-side from the document itself, so the client never re-derives them. Both go null together, with `redacted: true`, when the caller's grant does not cover the document vault: a dose lives in the health background but points into the vault, and a page's filename is itself the sensitive part. The link is still listed — the caller may know the dose was transcribed from a page — and `redacted` tells a withheld name apart from a page that simply has no date.", + }); + +const vaccinationEncounter = z + .object({ + id: z.string(), + occurredAt: z.string(), + kind: z.string(), + }) + .meta({ + id: "VaccinationEncounterStub", + description: + "The visit the dose was given at, enough to recognise it by. Null when no visit is linked.", + }); + +const practitioner = z + .object({ + id: z.string(), + name: z.string(), + specialty: z.string().nullable(), + practice: z.string().nullable(), + location: z.string().nullable(), + phone: z.string().nullable(), + note: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .meta({ + id: "VaccinationPractitioner", + description: + "The practice that administered the dose, resolved rather than an id. Null when the record names none.", + }); + +const vaccination = z + .object({ + id: z.string(), + occurredAt: z.string(), + antigenSlug: z.string().nullable(), + vaccineName: z.string().nullable(), + doseNumber: z.number().int().nullable(), + seriesDoses: z.number().int().nullable(), + lotNumber: z.string().nullable(), + site: vaccinationSiteEnum.nullable(), + catalogEntry: catalogEntry.nullable(), + series: z.array(seriesPosition), + practitioner: practitioner.nullable(), + encounter: vaccinationEncounter.nullable(), + reminderId: z.string().nullable(), + note: z.string().nullable(), + documents: z.array(vaccinationDocument).optional(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .meta({ + id: "Vaccination", + description: + "One administered dose. `occurredAt` is a day at UTC midnight — the source record carries dates, never times. `series` carries one entry per component antigen, so a combined preparation reports several at once and each may be at a different position: a dose can be a booster for one antigen and a first dose for another in the same act. An empty `series` means the dose has no resolvable antigen (free text only, or a slug this release does not know) and nothing is guessed from the name. `note` is the decrypted free text, or null on a key-rotation gap — fail-soft, never a 500. `reminderId` names the booster reminder this dose settled, when one matched. `documents` is present on the detail response and absent from the list.", + }); + +const vaccinationList = z.object({ vaccinations: z.array(vaccination) }).meta({ + id: "VaccinationList", + description: + "The caller's immunization history, newest dose first, with every entry's series already resolved over the whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever given.", +}); + +const vaccinationNotFound = { + "404": { + description: "Vaccination not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +const idPath = { path: z.object({ id: z.string() }) }; + +export const vaccinationPaths: NonNullable = { + "/api/vaccinations": { + get: { + tags: ["Records"], + summary: "List vaccinations", + description: + "Returns the caller's live doses, newest first, each with its series resolved per component antigen. Soft-deleted doses are excluded.", + requestParams: { query: vaccinationListQuerySchema }, + responses: { + ...recordRefusal(), + "200": { + description: "The caller's immunization history.", + content: { + "application/json": { + schema: dataEnvelope(vaccinationList, "ListVaccinationsEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Records"], + summary: "Log a vaccination", + description: + "Records one administered dose and satisfies every live booster reminder keyed to one of its component antigens — so a single combined dose settles each of the antigens it contains. Satisfaction is forward-only: a dose older than a reminder's last satisfaction is a no-op, which is what makes back-entering an old paper record safe. Honours `Idempotency-Key`. Audits as `vaccination.record.create`.", + requestBody: { + required: true, + content: { "application/json": { schema: vaccinationCreateSchema } }, + }, + responses: { + ...recordRefusal(), + "201": { + description: "Dose logged.", + content: { + "application/json": { + schema: dataEnvelope(vaccination, "CreateVaccinationEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/vaccinations/{id}": { + get: { + tags: ["Records"], + summary: "Read a single vaccination", + description: + "Returns the dose with its linked pages resolved. Owner-scoped; a cross-user or tombstoned id 404s.", + requestParams: idPath, + responses: { + ...recordRefusal(), + "200": { + description: "The dose.", + content: { + "application/json": { + schema: dataEnvelope(vaccination, "GetVaccinationEnvelope"), + }, + }, + }, + ...vaccinationNotFound, + ...stdResponses, + }, + }, + patch: { + tags: ["Records"], + summary: "Edit a vaccination", + description: + "Partial edit; omitted fields are left untouched. Never re-runs booster satisfaction. Audits as `vaccination.record.update`. Owner-scoped.", + requestParams: idPath, + requestBody: { + required: true, + content: { "application/json": { schema: vaccinationUpdateSchema } }, + }, + responses: { + ...recordRefusal(), + "200": { + description: "Dose updated.", + content: { + "application/json": { + schema: dataEnvelope(vaccination, "UpdateVaccinationEnvelope"), + }, + }, + }, + ...vaccinationNotFound, + ...stdResponses, + }, + }, + delete: { + tags: ["Records"], + summary: "Soft-delete a vaccination", + description: + "Stamps `deletedAt`. A booster reminder the dose once satisfied is deliberately not rewound: the reminder belongs to the preventive-care list and outlives the record, and tidying a transcription is not evidence the dose never happened. Idempotent. Audits as `vaccination.record.delete`.", + requestParams: idPath, + responses: { + ...recordRefusal(), + "200": { + description: "Soft-deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.boolean() }), + "DeleteVaccinationEnvelope", + ), + }, + }, + }, + ...vaccinationNotFound, + ...stdResponses, + }, + }, + }, + "/api/vaccinations/{id}/restore": { + post: { + tags: ["Records"], + summary: "Restore a soft-deleted vaccination", + description: + "Clears the tombstone. The link rows survived the soft delete, so the dose returns with its pages still filed against it. Restoring a live dose is a no-op that still succeeds.", + requestParams: idPath, + responses: { + ...recordRefusal(), + "200": { + description: "Dose restored.", + content: { + "application/json": { + schema: dataEnvelope(vaccination, "RestoreVaccinationEnvelope"), + }, + }, + }, + ...vaccinationNotFound, + ...stdResponses, + }, + }, + }, + "/api/vaccinations/{id}/links": { + post: { + tags: ["Records"], + summary: "File documents against a vaccination", + description: + "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document and visit link and unlink.", + requestParams: idPath, + requestBody: { + required: true, + content: { "application/json": { schema: vaccinationLinkSchema } }, + }, + responses: { + "200": { + description: "The dose's linked pages after the change.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + documents: z.array(vaccinationDocument), + unknown: z.array(z.string()), + }), + "LinkVaccinationEnvelope", + ), + }, + }, + }, + ...vaccinationNotFound, + ...stdResponses, + }, + }, + delete: { + tags: ["Records"], + summary: "Unfile documents from a vaccination", + description: + "Removes links. Idempotent, and it still works for a page the account has since deleted — refusing would leave a filing nobody can clear.", + requestParams: idPath, + requestBody: { + required: true, + content: { "application/json": { schema: vaccinationLinkSchema } }, + }, + responses: { + "200": { + description: "The dose's linked pages after the change.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + documents: z.array(vaccinationDocument), + unknown: z.array(z.string()), + }), + "UnlinkVaccinationEnvelope", + ), + }, + }, + }, + ...vaccinationNotFound, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 5d8250051..4ce3b6979 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -40,6 +40,7 @@ import { recordSettingsKeys } from "./record-settings"; import { profileKeys } from "./profile"; import { settingsKeys } from "./settings"; import { sharingKeys } from "./sharing"; +import { vaccinationKeys } from "./vaccinations"; import { workoutKeys } from "./workouts"; export const queryKeys = { @@ -70,6 +71,7 @@ export const queryKeys = { ...recordSettingsKeys, ...profileKeys, ...sharingKeys, + ...vaccinationKeys, }; export { recordSettingsKeys } from "./record-settings"; diff --git a/src/lib/query-keys/vaccinations.ts b/src/lib/query-keys/vaccinations.ts new file mode 100644 index 000000000..3c8ca4ce0 --- /dev/null +++ b/src/lib/query-keys/vaccinations.ts @@ -0,0 +1,45 @@ +/** + * Query keys — the immunization log. + * Part of the centralized factory; aggregated in `./index.ts`. + * + * These exist before any client reads them, because the in-repo + * `healthlog/queryKey-factory` rule errors on a bare array: the first + * component that lists doses cannot compile without a slot here. Same key with + * a different `queryFn` shape silently poisons the cache, which is the whole + * reason the factory is the only legal source. + * + * There is deliberately no matching `invalidateUserVaccinations` in + * `src/lib/cache/invalidate.ts`, for the same reason the visits module next + * door has none. That module evicts SERVER-side cached payloads, and no cached + * payload reads a dose: the dashboard snapshot, the insights targets and the + * analytics buckets are computed from measurements, medications and mood. + * Adding a sweep over caches nothing reads would be a placeholder that looks + * like coverage. It belongs in the release that puts doses into one of those + * payloads, alongside the reader that makes it necessary. + * + * There is no dependent-keys bundle here yet either, and that omission is the + * same rule one layer up: a bundle with no caller is dead code, and the gate + * says so. When the capture surface lands it brings its own, and that bundle + * has to evict the preventive-care root alongside this one — logging a dose + * satisfies the booster reminders it answers, so the checkups list goes stale + * in the same tick. Evicting only the immunization list would leave a booster + * on screen that the server had already moved a decade out. + */ +export const vaccinationKeys = { + /** + * The root prefix every dose write invalidates through + * (`vaccinationDependentKeys` in `./index.ts`). TanStack's hierarchical + * prefix semantics mean evicting `["vaccinations"]` clears the list and + * every open detail in one tick, so a dose edited in a sheet and the list + * behind it never disagree. + */ + vaccinations: () => ["vaccinations"] as const, + /** + * The list. The filter is part of the key: a page showing one antigen's + * history and the page showing everything are two different answers rather + * than one answer that overwrites the other. + */ + vaccinationList: (antigenSlug?: string | null) => + ["vaccinations", "list", antigenSlug ?? null] as const, + vaccination: (id: string) => ["vaccinations", "detail", id] as const, +} as const; diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index e64890544..fd279c055 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -74,6 +74,8 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.illnessEpisodeCreate", { name }); case "encounter.visit.create": return t("recordSharing.activityVerb.encounterVisitCreate", { name }); + case "vaccination.record.create": + return t("recordSharing.activityVerb.vaccinationRecordCreate", { name }); case "practitioner.contact.create": return t("recordSharing.activityVerb.practitionerContactCreate", { name, @@ -187,6 +189,12 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.encounterVisitDelete", { name }); case "encounter.visit.restore": return t("recordSharing.activityVerb.encounterVisitRestore", { name }); + case "vaccination.record.update": + return t("recordSharing.activityVerb.vaccinationRecordUpdate", { name }); + case "vaccination.record.delete": + return t("recordSharing.activityVerb.vaccinationRecordDelete", { name }); + case "vaccination.record.restore": + return t("recordSharing.activityVerb.vaccinationRecordRestore", { name }); case "practitioner.contact.update": return t("recordSharing.activityVerb.practitionerContactUpdate", { name, diff --git a/src/lib/sharing/scope.ts b/src/lib/sharing/scope.ts index a76f96471..784297cf4 100644 --- a/src/lib/sharing/scope.ts +++ b/src/lib/sharing/scope.ts @@ -47,8 +47,8 @@ export const SHARE_DOMAINS = [ /** Lab results and the biomarker catalogue behind them. */ "labs", /** - * Health background: allergies, family history, anamnesis facts, and visits - * and practitioners. + * Health background: allergies, family history, anamnesis facts, visits and + * practitioners, and the immunization history. * * Visits joined this domain rather than getting one of their own, and the * cost is worth naming: somebody granted `profile` so a relative could see @@ -56,6 +56,14 @@ export const SHARE_DOMAINS = [ * book behind it. The mitigation is not this comment — it is the consent * copy, which names visits in all six locales, so the widening is stated * where it is agreed to rather than inferred from the code. + * + * The immunization log joined on the same argument and carries the same + * cost, stated the same way: a delegate holding this domain for the allergy + * list also sees every dose the person has had, and the consent copy names + * it. Vaccinations are a tracked clinical vertical with their own module + * toggle, which is a separate question from consent — a module the owner + * enabled is not a section the owner shared, and a person granting health + * background is granting the section whether or not the surfaces are on. */ "profile", /** Illness episodes and the day logs under them. */ diff --git a/src/lib/vaccinations/__tests__/catalog-integrity.test.ts b/src/lib/vaccinations/__tests__/catalog-integrity.test.ts new file mode 100644 index 000000000..5dc9dfe88 --- /dev/null +++ b/src/lib/vaccinations/__tests__/catalog-integrity.test.ts @@ -0,0 +1,209 @@ +/** + * What the catalogue promises about itself, and the one promise it makes to + * every row that outlives it. + * + * The structural checks below are cheap and mostly hold shapes the type system + * already holds — they exist because the file is edited by hand, entry by + * entry, and a paste that duplicates a slug or points a combination at an + * antigen that no longer exists compiles fine right up until two doses share + * an identity. + * + * The one that matters is the last block: a slug this release does not know + * must degrade to the record's own free text. A record keeps `antigenSlug` + * verbatim, backups restore it without validation, and slugs are append-only + * by convention rather than by constraint — so the day one is dropped or + * renamed, every dose that carried it is still a real dose belonging to a real + * person. The resolver returning `null` is what makes that survivable, and a + * resolver that threw or a renderer that produced an empty label would turn a + * catalogue edit into data loss on screen. + */ +import { describe, expect, it } from "vitest"; + +import { + ANTIGEN_SLUGS, + VACCINE_CATALOG, + VACCINE_CATALOG_SLUGS, + componentsForSlug, + resolveCatalogEntry, + type AntigenSlug, +} from "@/lib/vaccinations/vaccine-catalog"; + +const MONOVALENT = new Set(ANTIGEN_SLUGS); + +describe("the vaccine catalogue holds its own shape", () => { + it("carries a whole life's worth of entries, not a token list", () => { + // The floor is not decoration: an adult-only catalogue was the rejected + // alternative, and it would ship a module that cannot hold a child's + // Pass. A count that collapses is the symptom. + expect(VACCINE_CATALOG.length).toBeGreaterThanOrEqual(30); + expect(MONOVALENT.size).toBeGreaterThanOrEqual(20); + }); + + it("gives every entry a unique slug", () => { + const seen = new Map(); + for (const slug of VACCINE_CATALOG_SLUGS) { + seen.set(slug, (seen.get(slug) ?? 0) + 1); + } + const duplicated = [...seen.entries()] + .filter(([, count]) => count > 1) + .map(([slug]) => slug); + expect( + duplicated, + "two entries under one slug means two doses share an identity", + ).toEqual([]); + }); + + it("offers every monovalent antigen as an entry of its own", () => { + // An older Pass line records a component on its own, so every antigen a + // combination can contain must also be loggable by itself. + const missing = ANTIGEN_SLUGS.filter( + (slug) => !VACCINE_CATALOG_SLUGS.includes(slug), + ); + expect(missing).toEqual([]); + }); + + it("resolves every combination's components to real monovalent antigens", () => { + const foreign: string[] = []; + for (const entry of VACCINE_CATALOG) { + for (const component of entry.components) { + if (!MONOVALENT.has(component)) + foreign.push(`${entry.slug}/${component}`); + } + } + expect( + foreign, + "a component that is not an antigen breaks the series axis silently", + ).toEqual([]); + }); + + it("makes every monovalent entry its own single component", () => { + const wrong = VACCINE_CATALOG.filter( + (entry) => + MONOVALENT.has(entry.slug) && + (entry.components.length !== 1 || entry.components[0] !== entry.slug), + ).map((entry) => entry.slug); + expect( + wrong, + "a monovalent that does not count into its own series counts into none", + ).toEqual([]); + }); + + it("gives every entry an ATC code in the J07 subtree", () => { + const wrong = VACCINE_CATALOG.filter( + (entry) => !/^J07[A-Z]{2}\d{2}$/.test(entry.atc), + ).map((entry) => `${entry.slug}: ${entry.atc}`); + expect(wrong).toEqual([]); + }); + + it("cites a source for every entry's numbers", () => { + // Rendered numbers need a rendered citation. A blank or one-word source + // is how a footnote stops being checkable. + const thin = VACCINE_CATALOG.filter( + (entry) => entry.source.trim().length < 8, + ).map((entry) => entry.slug); + expect(thin).toEqual([]); + }); + + it("keeps the numbers inside the range a schedule can mean", () => { + const wrong: string[] = []; + for (const entry of VACCINE_CATALOG) { + const { typicalSeriesDoses: doses, boosterIntervalMonths: months } = + entry; + if (doses !== null && (doses < 1 || doses > 10)) { + wrong.push(`${entry.slug}: ${doses} doses`); + } + if (months !== null && (months < 1 || months > 240)) { + wrong.push(`${entry.slug}: every ${months} months`); + } + } + expect(wrong).toEqual([]); + }); + + it("keeps every synonym generic, lowercase and distinct", () => { + // The vendor-name rule cannot be fully held by a matcher — a trade name + // is a proper noun and nothing here knows the register. What a test CAN + // hold is the shape a trade name would have to break: synonyms are + // lowercase generic wording, never blank, never repeated. + const wrong: string[] = []; + const seen = new Set(); + for (const entry of VACCINE_CATALOG) { + for (const synonym of entry.synonyms ?? []) { + if (synonym !== synonym.toLowerCase() || synonym.trim().length === 0) { + wrong.push(`${entry.slug}: ${synonym}`); + } + if (seen.has(synonym)) wrong.push(`${entry.slug}: repeats ${synonym}`); + seen.add(synonym); + } + } + expect(wrong).toEqual([]); + }); +}); + +describe("a slug the catalogue no longer knows degrades to the free text", () => { + /** + * A record as the app would hold it after the catalogue moved on: a slug + * written by an older release, and the person's own transcription beside it. + */ + const ORPHANED_RECORD = { + antigenSlug: "an-antigen-this-release-does-not-list", + vaccineName: "Tetanus, as the 1987 Pass wrote it", + }; + + /** + * What every consumer of the catalogue does with a record: ask the resolver, + * take the catalogue's name when it answers, fall back to the person's own + * wording when it does not. Written here rather than imported so the + * guarantee is checked as a contract on the resolver, which is what every + * caller depends on. + */ + function displayIdentity(record: { + antigenSlug: string | null; + vaccineName: string | null; + }): string { + const entry = resolveCatalogEntry(record.antigenSlug); + return entry ? entry.slug : (record.vaccineName ?? ""); + } + + it("returns null instead of throwing for a slug it does not have", () => { + expect(() => + resolveCatalogEntry(ORPHANED_RECORD.antigenSlug), + ).not.toThrow(); + expect(resolveCatalogEntry(ORPHANED_RECORD.antigenSlug)).toBeNull(); + }); + + it("renders the person's own wording rather than an empty label", () => { + const label = displayIdentity(ORPHANED_RECORD); + expect( + label, + "an unresolvable slug must degrade to the free-text arm; an empty " + + "label turns a catalogue edit into data loss on screen", + ).toBe("Tetanus, as the 1987 Pass wrote it"); + expect(label.length).toBeGreaterThan(0); + }); + + it("degrades the same way for a null slug and an empty one", () => { + expect(resolveCatalogEntry(null)).toBeNull(); + expect(resolveCatalogEntry(undefined)).toBeNull(); + expect(resolveCatalogEntry("")).toBeNull(); + expect( + displayIdentity({ antigenSlug: null, vaccineName: "Free text only" }), + ).toBe("Free text only"); + }); + + 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([]); + }); + + it("still resolves a slug it does have, so the check above is not vacuous", () => { + const tdap = resolveCatalogEntry("tdap"); + expect(tdap).not.toBeNull(); + expect(tdap?.components).toEqual([ + "tetanus", + "diphtheria", + "pertussis", + ]); + }); +}); diff --git a/src/lib/vaccinations/__tests__/series.test.ts b/src/lib/vaccinations/__tests__/series.test.ts new file mode 100644 index 000000000..fc5d0938a --- /dev/null +++ b/src/lib/vaccinations/__tests__/series.test.ts @@ -0,0 +1,263 @@ +/** + * The series derivation, case by case. + * + * Every fixture date is relative to now. A fixed calendar date in a test about + * ordering rots the moment the window it sat inside slides past it, and this + * repository has shipped that defect before: the dates here are "eleven years + * ago" and "last month", never "2024-03-01". + */ +import { describe, expect, it } from "vitest"; + +import { + deriveSeries, + seriesForRecord, + type SeriesInputRecord, +} from "@/lib/vaccinations/series"; + +const MONTH_MS = 30 * 24 * 60 * 60 * 1000; + +/** `monthsAgo(132)` — eleven years back from whenever the suite runs. */ +function monthsAgo(months: number): Date { + return new Date(Date.now() - months * MONTH_MS); +} + +function record( + id: string, + monthsBack: number, + antigenSlug: string | null, + extra: Partial = {}, +): SeriesInputRecord { + return { + id, + occurredAt: monthsAgo(monthsBack), + antigenSlug, + doseNumber: null, + seriesDoses: null, + ...extra, + }; +} + +describe("a monovalent dose counts into its own series", () => { + it("places three tetanus doses at positions one, two and three", () => { + const history = [ + record("t1", 300, "tetanus"), + record("t2", 296, "tetanus"), + record("t3", 288, "tetanus"), + ]; + const series = deriveSeries(history); + expect(series.get("t1")).toEqual([ + { antigen: "tetanus", position: 1, total: 3, booster: false }, + ]); + expect(series.get("t2")).toEqual([ + { antigen: "tetanus", position: 2, total: 3, booster: false }, + ]); + expect(series.get("t3")).toEqual([ + { antigen: "tetanus", position: 3, total: 3, booster: false }, + ]); + }); + + it("orders by date rather than by the order the query returned them", () => { + // Deliberately handed to the function newest-first. + const series = deriveSeries([ + record("newest", 12, "tetanus"), + record("oldest", 240, "tetanus"), + ]); + expect(series.get("oldest")?.[0].position).toBe(1); + expect(series.get("newest")?.[0].position).toBe(2); + }); +}); + +describe("a combination counts into every component series", () => { + it("puts one Tdap dose at three different positions at once", () => { + // The case the whole component axis exists for: two plain Td doses first, + // so tetanus and diphtheria are already at two while pertussis is at none. + const history = [ + record("td-1", 240, "td"), + record("td-2", 120, "td"), + record("tdap", 6, "tdap"), + ]; + const series = deriveSeries(history); + expect(series.get("tdap")).toEqual([ + { antigen: "tetanus", position: 3, total: 3, booster: false }, + { antigen: "diphtheria", position: 3, total: 3, booster: false }, + { antigen: "pertussis", position: 1, total: 3, booster: false }, + ]); + }); + + it("counts a six-antigen infant dose into all six", () => { + const series = deriveSeries([record("hex", 400, "hexavalent")]); + expect(series.get("hex")?.map((entry) => entry.antigen)).toEqual([ + "diphtheria", + "tetanus", + "pertussis", + "polio", + "hib", + "hepatitis-b", + ]); + expect( + series.get("hex")?.every((entry) => entry.position === 1), + "the first dose of a combination is the first of each of its series", + ).toBe(true); + }); + + it("lets a monovalent dose continue a series a combination started", () => { + const series = deriveSeries([ + record("mmr", 400, "mmr"), + record("measles-only", 396, "measles"), + ]); + expect(series.get("measles-only")).toEqual([ + { antigen: "measles", position: 2, total: 2, booster: false }, + ]); + }); +}); + +describe("position and total prefer what the record itself says", () => { + it("takes the Pass's own dose number over the chronological count", () => { + // Someone transcribing only the last line of a page they never entered + // the rest of: they are right about their history, the count is not. + const series = deriveSeries([ + record("only-entry", 6, "hepatitis-b", { doseNumber: 3 }), + ]); + expect(series.get("only-entry")).toEqual([ + { antigen: "hepatitis-b", position: 3, total: 3, booster: false }, + ]); + }); + + it("takes the record's own series length over the catalogue's", () => { + // HPV is two doses when the course starts early and three when it starts + // later; the override is for the person whose Pass says three. + const series = deriveSeries([ + record("hpv-1", 24, "hpv", { seriesDoses: 3 }), + record("hpv-2", 22, "hpv", { seriesDoses: 3 }), + record("hpv-3", 18, "hpv", { seriesDoses: 3 }), + ]); + expect(series.get("hpv-3")).toEqual([ + { antigen: "hpv", position: 3, total: 3, booster: false }, + ]); + }); + + it("reports a null total for a seasonal dose that is not part of a series", () => { + // Influenza has no series length, so there is no "of M" to report and + // nothing to be past the end of. Five annual doses are five first doses + // of five different years, never four boosters. + const series = deriveSeries([ + record("flu-1", 39, "influenza"), + record("flu-2", 27, "influenza"), + record("flu-3", 15, "influenza"), + record("flu-4", 3, "influenza"), + ]); + expect(series.get("flu-4")).toEqual([ + { antigen: "influenza", position: 4, total: null, booster: false }, + ]); + expect( + [1, 2, 3, 4].every((n) => series.get(`flu-${n}`)?.[0].booster === false), + "a null total means nothing can be past the end of it", + ).toBe(true); + }); +}); + +describe("a dose past the end of its series reads as a booster", () => { + it("marks the fourth hepatitis B dose after a completed three-dose primary", () => { + const series = deriveSeries([ + record("hb-1", 300, "hepatitis-b"), + record("hb-2", 299, "hepatitis-b"), + record("hb-3", 294, "hepatitis-b"), + record("hb-later", 60, "hepatitis-b"), + ]); + expect(series.get("hb-3")?.[0].booster).toBe(false); + expect(series.get("hb-later")).toEqual([ + { antigen: "hepatitis-b", position: 4, total: 3, booster: true }, + ]); + }); + + it("marks every tetanus dose after the primary series, not just the first", () => { + const series = deriveSeries([ + record("t1", 480, "tetanus"), + record("t2", 476, "tetanus"), + record("t3", 468, "tetanus"), + record("t4", 348, "tetanus"), + record("t5", 228, "tetanus"), + record("t6", 108, "tetanus"), + ]); + expect(["t4", "t5", "t6"].map((id) => series.get(id)?.[0].booster)).toEqual( + [true, true, true], + ); + }); + + it("marks a booster per component, so a combination can be both at once", () => { + // Tetanus is long finished; pertussis has never been given. One Tdap dose + // is a tetanus booster and a first pertussis dose in the same act. + const history = [ + record("t1", 480, "tetanus"), + record("t2", 476, "tetanus"), + record("t3", 468, "tetanus"), + record("tdap", 2, "tdap"), + ]; + const series = deriveSeries(history); + const tdap = series.get("tdap") ?? []; + expect(tdap.find((e) => e.antigen === "tetanus")).toEqual({ + antigen: "tetanus", + position: 4, + total: 3, + booster: true, + }); + expect(tdap.find((e) => e.antigen === "pertussis")).toEqual({ + antigen: "pertussis", + position: 1, + total: 3, + booster: false, + }); + }); +}); + +describe("a record with no resolvable antigen yields nothing", () => { + it("returns an empty array for a free-text-only row", () => { + const series = deriveSeries([record("free-text", 12, null)]); + expect( + series.get("free-text"), + "a name string is not evidence about which antigens were given", + ).toEqual([]); + }); + + it("returns an empty array for a slug the catalogue no longer lists", () => { + const series = deriveSeries([record("orphan", 400, "retired-slug")]); + expect(series.get("orphan")).toEqual([]); + }); + + it("keeps a key for every input, resolvable or not", () => { + // Total in the mathematical sense: a caller reading a missing key as "no + // series" and one reading it as "not computed" would disagree about the + // same dose. + const history = [ + record("a", 12, "tetanus"), + record("b", 6, null), + record("c", 3, "retired-slug"), + ]; + const series = deriveSeries(history); + expect([...series.keys()].sort()).toEqual(["a", "b", "c"]); + }); + + it("does not let an unresolvable row shift the positions around it", () => { + const series = deriveSeries([ + record("t1", 300, "tetanus"), + record("free", 200, null), + record("t2", 100, "tetanus"), + ]); + expect(series.get("t2")?.[0].position).toBe(2); + }); +}); + +describe("the single-record helper agrees with the whole-set derivation", () => { + it("returns the same entry the full derivation would", () => { + const history = [record("td-1", 240, "td"), record("tdap", 6, "tdap")]; + expect(seriesForRecord("tdap", history)).toEqual( + deriveSeries(history).get("tdap"), + ); + }); + + it("returns an empty array for a record the history does not contain", () => { + expect( + seriesForRecord("not-in-history", [record("t1", 12, "tetanus")]), + ).toEqual([]); + }); +}); diff --git a/src/lib/vaccinations/dto.ts b/src/lib/vaccinations/dto.ts new file mode 100644 index 000000000..653b96908 --- /dev/null +++ b/src/lib/vaccinations/dto.ts @@ -0,0 +1,165 @@ +/** + * Vaccination row → wire DTO. + * + * Everything is published resolved. The practitioner comes back as an object + * and never as a bare id, the visit as a stub with its date and kind, the + * document links with a label and a date, the catalogue entry as an object or + * an explicit `null`, and — the one that matters — the SERIES already derived. + * + * "Dose 3 of 3" is a property of the whole history, not of the row, and it is + * computed once on the server (`src/lib/vaccinations/series.ts`) so no client + * re-derives it. The DTO carries numbers and a boolean, never a localised + * string: six locales must not ride the API. + * + * `catalogEntry: null` is the degrade signal. It means the slug the row + * carries is not one this release knows — a slug a past version wrote, or one + * restored from an older backup — and the client falls back to `vaccineName`. + * It is not an error and it is not empty data. + */ +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; +import type { + Encounter, + Practitioner, + VaccinationRecord, + VaccinationSite, +} from "@/generated/prisma/client"; +import { + toPractitionerDTO, + type PractitionerDTO, +} from "@/lib/practitioners/dto"; +import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import type { SeriesPosition } from "@/lib/vaccinations/series"; + +/** + * One page a dose was transcribed from, as the caller is allowed to see it. + * + * `label` and `date` go null together when the caller's grant does not cover + * the vault. A dose lives in the health background and points into the + * documents domain, and a grant that opened the background was never consent + * for what is on the other side of that seam — the FILENAME of a scanned page + * is itself the sensitive part. `redacted` says which null is a withholding + * and which is simply a page with no date, so a client never has to guess. + */ +export interface VaccinationDocumentDTO { + id: string; + label: string | null; + date: string | null; + redacted: boolean; +} + +/** What the catalogue knows about the pick, when it still knows it. */ +export interface VaccinationCatalogDTO { + slug: string; + atc: string; + category: string; +} + +/** The visit this dose was given at, enough to recognise it by. */ +export interface VaccinationEncounterDTO { + id: string; + occurredAt: string; + kind: string; +} + +export interface VaccinationDTO { + id: string; + /** The day, at UTC midnight. An Impfpass carries dates, never times. */ + occurredAt: string; + /** The stored slug, verbatim — resolvable or not. */ + antigenSlug: string | null; + /** What the person wrote. May be a trade name; it is their transcription. */ + vaccineName: string | null; + doseNumber: number | null; + seriesDoses: number | null; + lotNumber: string | null; + site: VaccinationSite | null; + /** + * The catalogue's view of `antigenSlug`, or `null` when this release does + * not know the slug. Null is the signal to render `vaccineName` instead. + */ + catalogEntry: VaccinationCatalogDTO | null; + /** + * Where this dose sits in each of its antigen series, resolved server-side. + * + * One entry per component antigen, so a combined dose carries several. Empty + * for a free-text-only row and for a slug the catalogue cannot resolve — no + * antigen is guessed from a name string. + * + * The client renders text from these numbers: both `position` and `total` → + * "N of M", only `position` → "dose N", `booster` → "booster". It never + * recomputes them. + */ + series: SeriesPosition[]; + /** Resolved, never just an id. `null` when the dose names no practice. */ + practitioner: PractitionerDTO | null; + /** Resolved stub, never just an id. `null` when no visit is linked. */ + encounter: VaccinationEncounterDTO | null; + /** The booster reminder this dose satisfied, when one matched. */ + reminderId: string | null; + note: string | null; + /** Present on the detail response; omitted from the list. */ + documents?: VaccinationDocumentDTO[]; + createdAt: string; + updatedAt: string; +} + +/** Decrypt the dose note fail-soft: a rotation gap reads as no note, not 500. */ +function decryptNote(value: Uint8Array | null): string | null { + if (!value || value.byteLength === 0) return null; + try { + return decryptFromBytes(value); + } catch (err) { + getEvent()?.addWarning( + `vaccination note decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; + } +} + +export type VaccinationWithRelations = VaccinationRecord & { + practitioner?: Practitioner | null; + encounter?: Pick | null; +}; + +export function toVaccinationDTO( + row: VaccinationWithRelations, + series: SeriesPosition[], + documents?: VaccinationDocumentDTO[], +): VaccinationDTO { + const entry = resolveCatalogEntry(row.antigenSlug); + return { + id: row.id, + occurredAt: row.occurredAt.toISOString(), + antigenSlug: row.antigenSlug, + vaccineName: row.vaccineName, + doseNumber: row.doseNumber, + seriesDoses: row.seriesDoses, + lotNumber: row.lotNumber, + site: row.site, + catalogEntry: entry + ? { slug: entry.slug, atc: entry.atc, category: entry.category } + : null, + series, + practitioner: row.practitioner ? toPractitionerDTO(row.practitioner) : null, + encounter: row.encounter + ? { + id: row.encounter.id, + occurredAt: row.encounter.occurredAt.toISOString(), + kind: row.encounter.kind, + } + : null, + reminderId: row.reminderId, + note: decryptNote(row.noteEncrypted), + ...(documents ? { documents } : {}), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +/** The list response: one array, newest dose first. */ +export interface VaccinationListDTO { + vaccinations: VaccinationDTO[]; +} diff --git a/src/lib/vaccinations/satisfy-matching.ts b/src/lib/vaccinations/satisfy-matching.ts new file mode 100644 index 000000000..8518b622c --- /dev/null +++ b/src/lib/vaccinations/satisfy-matching.ts @@ -0,0 +1,153 @@ +/** + * Logging a dose clears the booster reminders it answers. + * + * This is the reader half of `MeasurementReminder.vaccinationAntigen`, and the + * whole of it. The writer — the booster mint, which is rung 2: prefilled, + * user-confirmed, editable, declinable — lands with the capture surface. The + * column is a match key and nothing else: no cron query changes, no reminder + * DTO field, no new queue, no new notification event type, no new preference, + * and the published `origin` enum still reads `["VORSORGE","COACH"]`. A + * booster reminder is an ordinary preventive-care row that happens to say + * which antigen would settle it. + * + * ── Why components, not the slug ─────────────────────────────────────────── + * + * A reminder is keyed by an ANTIGEN. A dose is recorded as whatever + * preparation the person was given, which is usually a combination. Matching + * the record's own slug against the reminder's key would mean a Tdap dose left + * a tetanus booster standing, which is the named failure mode of this whole + * design. So the dose is expanded to its component antigens first, and every + * reminder any of them matches is satisfied. + * + * 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. + * + * ── Cold path only ───────────────────────────────────────────────────────── + * + * It runs inside the create transaction of `POST /api/vaccinations` and + * nowhere else. Never on a cron, never on an ingest hook, never on an edit — + * the never-nag query in `src/lib/jobs/measurement-reminder.ts` is untouched + * by this feature, and PATCH deliberately does not re-run this, because + * correcting a transcription typo is not a second clinical event. + * + * ── The two properties that come from `satisfyReminder` ──────────────────── + * + * Both are the design rather than an accident, and both have a fixture: + * + * - **Forward-only.** A dose older than the reminder's `lastSatisfiedAt` is + * a no-op. Transcribing a 1987 Pass page can never clear a booster that is + * due today, which is the property that makes bulk back-entry safe. + * - **Re-anchored.** `nextDueAt` recomputes from the satisfy instant, so + * logging a tetanus dose from 2019 against a ten-year reminder moves the + * due date to 2029 — and logging one old enough that the recomputed date + * is already past leaves the reminder due, which is correct. An old dose + * does not silence a real gap. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +import { + satisfyReminder, + type SatisfiableReminder, +} from "@/lib/measurement-reminders/satisfy"; +import { componentsForSlug } from "@/lib/vaccinations/vaccine-catalog"; + +/** What the create route needs back: which reminders moved, and the first. */ +export interface BoosterSatisfyResult { + /** Ids of every reminder this dose advanced, in catalogue component order. */ + satisfiedReminderIds: string[]; + /** + * The id stamped onto `VaccinationRecord.reminderId`. + * + * The first satisfied reminder in catalogue component order, so a Tdap dose + * that clears a tetanus and a pertussis booster always records the same one + * rather than whichever the database returned first. The column records ONE + * act; the engine records them all, in each row's own `lastSatisfiedAt`. + */ + primaryReminderId: string | null; +} + +const EMPTY: BoosterSatisfyResult = { + satisfiedReminderIds: [], + primaryReminderId: null, +}; + +/** + * Satisfy every live booster reminder this dose answers. + * + * Takes the transaction client the create runs in, so the reminder advance and + * the record insert commit or roll back together: a dose that failed to save + * must not leave a booster looking settled. + */ +export async function satisfyBoostersForDose( + tx: Prisma.TransactionClient, + userId: string, + antigenSlug: string | null, + occurredAt: Date, + timezone: string, +): Promise { + const components = componentsForSlug(antigenSlug); + if (components.length === 0) return EMPTY; + + const reminders = await tx.measurementReminder.findMany({ + where: { + userId, + deletedAt: null, + // An ordinary Vorsorge reminder carries NULL here and is never touched: + // the `in` filter cannot match a null, so the fence is structural rather + // than a condition somebody has to remember. + vaccinationAntigen: { in: [...components] }, + }, + select: { + id: true, + vaccinationAntigen: true, + intervalDays: true, + rrule: true, + anchorDate: true, + notifyHour: true, + lastSatisfiedAt: true, + createdAt: true, + }, + }); + if (reminders.length === 0) return EMPTY; + + // Deterministic: catalogue component order, then id, so two reminders for + // the same antigen still resolve to a stable primary. + const rank = new Map(components.map((antigen, index) => [antigen, index])); + const ordered = [...reminders].sort((a, b) => { + const byAntigen = + (rank.get(a.vaccinationAntigen as never) ?? Number.MAX_SAFE_INTEGER) - + (rank.get(b.vaccinationAntigen as never) ?? Number.MAX_SAFE_INTEGER); + return byAntigen !== 0 ? byAntigen : a.id.localeCompare(b.id); + }); + + const satisfiedReminderIds: string[] = []; + for (const reminder of ordered) { + const row: SatisfiableReminder = { + id: reminder.id, + intervalDays: reminder.intervalDays, + rrule: reminder.rrule, + anchorDate: reminder.anchorDate, + notifyHour: reminder.notifyHour, + lastSatisfiedAt: reminder.lastSatisfiedAt, + createdAt: reminder.createdAt, + }; + // `satisfyReminder` types its client as the full `PrismaClient` because + // every other caller hands it one; the only member it touches is + // `measurementReminder.updateMany`, which a transaction client has. The + // cast is confined to this line so the advance stays inside the create + // transaction rather than committing on its own. + const result = await satisfyReminder( + tx as unknown as PrismaClient, + row, + timezone, + occurredAt, + ); + if (result.satisfied) satisfiedReminderIds.push(reminder.id); + } + + return { + satisfiedReminderIds, + primaryReminderId: satisfiedReminderIds[0] ?? null, + }; +} diff --git a/src/lib/vaccinations/series.ts b/src/lib/vaccinations/series.ts new file mode 100644 index 000000000..d1749030b --- /dev/null +++ b/src/lib/vaccinations/series.ts @@ -0,0 +1,145 @@ +/** + * "Dose 3 of 3", derived once, on the server, per component antigen. + * + * The number beside a dose is not a property of that dose. It is a property of + * the person's whole history for the antigen in question, which means it can + * only be computed by something that can see all of it — and it must be + * computed in exactly one place, or the web client, the native client and the + * doctor report each answer the question slightly differently. So this + * function resolves it once and the result rides the DTO as numbers. No client + * re-derives, and no localised string crosses the wire: six locales must not + * ride the API. + * + * ── The component axis ───────────────────────────────────────────────────── + * + * A combined dose counts into EVERY antigen series it contains, at whatever + * position each of those series happens to be at. A history of Td, Td, Tdap + * puts that last dose at tetanus position 3 and pertussis position 1, and both + * are true at once. Grouping by the record's own slug instead of by its + * components is the failure mode this whole design exists to avoid: it would + * report an adult with three tetanus doses as having had one. + * + * ── What it deliberately does not do ─────────────────────────────────────── + * + * It does not adjudicate a schedule. Whether dose 2 came at the right interval + * after dose 1, whether a gap invalidates a series, whether a person is due + * anything at all — none of that is here, and none of it is anywhere in this + * module. Unsolicited due-computation from age, sex and history is where the + * 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 { + componentsForSlug, + resolveCatalogEntry, + type AntigenSlug, +} from "@/lib/vaccinations/vaccine-catalog"; + +/** One antigen's view of one dose. */ +export interface SeriesPosition { + /** The component antigen this entry speaks for. */ + antigen: AntigenSlug; + /** + * Where this dose sits in that antigen's series. Prefers the number the + * person transcribed from their Pass; falls back to the chronological index + * among their own doses for the antigen. + */ + 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 — + * a seasonal vaccine has no "of M". + */ + total: number | null; + /** + * True once the series is complete and this dose comes after it. The client + * renders "booster" rather than a position out of a total it has passed. + */ + booster: boolean; +} + +/** The minimum a record has to carry to be placed in a series. */ +export interface SeriesInputRecord { + id: string; + occurredAt: Date; + antigenSlug: string | null; + doseNumber: number | null; + seriesDoses: number | null; +} + +/** + * Resolve every record's series positions, per component antigen. + * + * Takes the whole live set rather than one record because the answer for any + * single dose depends on the ones around it. Returns a map keyed by record id + * so a caller can attach the result to a DTO without re-scanning. + * + * Total, in the mathematical sense: every input id is a key in the output, and + * a record with no resolvable antigen maps to an empty array rather than being + * absent. A caller that reads a missing key as "no series" and a caller that + * reads it as "not computed yet" would disagree about the same dose. + */ +export function deriveSeries( + records: readonly SeriesInputRecord[], +): Map { + const result = new Map(); + for (const record of records) result.set(record.id, []); + + // Oldest first: a position is a count of what came before it, and ties break + // on id so two doses recorded for the same day are ordered deterministically + // rather than by whatever the query happened to return. + const chronological = [...records].sort((a, b) => { + const byDate = a.occurredAt.getTime() - b.occurredAt.getTime(); + return byDate !== 0 ? byDate : a.id.localeCompare(b.id); + }); + + /** How many doses of each antigen have been seen so far in the walk. */ + const seen = new Map(); + + 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); + if (components.length === 0) continue; + + const catalogTotal = + resolveCatalogEntry(record.antigenSlug)?.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; + + const positions: SeriesPosition[] = []; + for (const antigen of components) { + const index = (seen.get(antigen) ?? 0) + 1; + seen.set(antigen, index); + // The Pass's own claim wins over the count. Someone transcribing + // "3. Impfung" from a page whose earlier lines they never entered is + // right about their own history and this arithmetic is not. + const position = record.doseNumber ?? index; + positions.push({ + antigen, + position, + total, + booster: total !== null && position > total, + }); + } + result.set(record.id, positions); + } + + return result; +} + +/** + * The same answer for one record, against a history. + * + * A convenience over {@link deriveSeries} for the detail route, which holds + * one record and needs the set it belongs to anyway. Returns an empty array + * for a record the history does not contain, rather than deriving a lonely + * position that would contradict the list. + */ +export function seriesForRecord( + recordId: string, + history: readonly SeriesInputRecord[], +): SeriesPosition[] { + return deriveSeries(history).get(recordId) ?? []; +} diff --git a/src/lib/vaccinations/service.ts b/src/lib/vaccinations/service.ts new file mode 100644 index 000000000..8d929ff5f --- /dev/null +++ b/src/lib/vaccinations/service.ts @@ -0,0 +1,189 @@ +/** + * What the vaccination routes share: the query shapes, the ownership probes, + * the domain redaction, and the link application. + * + * Extracted for the same reason `src/lib/encounters/service.ts` is: four route + * files need the same include, the same "does the caller own this practice" + * question and the same seam handling, and four copies of a redaction rule is + * three chances to get it wrong. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import { prisma } from "@/lib/db"; +import { listTargets, replaceTargets } from "@/lib/links"; +import { grantCoversDomain } from "@/lib/sharing/grants"; +import type { ShareDomain } from "@/lib/sharing/scope"; +import type { VaccinationDocumentDTO } from "@/lib/vaccinations/dto"; +import { + deriveSeries, + type SeriesInputRecord, +} from "@/lib/vaccinations/series"; + +/** Matches the visits service: the fallback when an account never set one. */ +const DEFAULT_TIMEZONE = "Europe/Berlin"; + +/** The relations every vaccination response resolves. */ +export const VACCINATION_INCLUDE = { + practitioner: true, + encounter: { select: { id: true, occurredAt: true, kind: true } }, +} as const satisfies Prisma.VaccinationRecordInclude; + +/** What the series derivation needs, and nothing more. */ +export const SERIES_SELECT = { + id: true, + occurredAt: true, + antigenSlug: true, + doseNumber: true, + seriesDoses: true, +} as const satisfies Prisma.VaccinationRecordSelect; + +/** + * Which sections of the record the caller is actually inside. + * + * Returns a predicate rather than a set so the owner path costs nothing: with + * no grant there is nothing to look up and every domain is open. A grant that + * vanished between the auth check and here answers "no" to everything, which + * is the fail-closed direction. Same shape as the visits service, deliberately + * — the two features sit on the same seam and a second spelling of this would + * be a second thing to keep in step. + */ +export async function actingDomainVisibility( + tx: Prisma.TransactionClient, + grantId: string | null, +): Promise<(domain: ShareDomain) => boolean> { + if (grantId === null) return () => true; + const grant = await tx.accountGrant.findUnique({ + where: { id: grantId }, + select: { scopeJson: true }, + }); + if (!grant) return () => false; + return (domain) => grantCoversDomain(grant, domain); +} + +/** + * The whole live history for one account, in the shape the derivation wants. + * + * The series is a property of the set, so every read that publishes a position + * loads the set — including the detail route, which holds one row and cannot + * answer the question from it. The rows are small (five scalar columns) and an + * Impfpass is a short document even after a long life, so this is cheaper than + * the cache that would keep it consistent. + */ +export async function loadSeriesHistory( + tx: Prisma.TransactionClient, + userId: string, +): Promise { + return tx.vaccinationRecord.findMany({ + where: { userId, deletedAt: null }, + orderBy: { occurredAt: "asc" }, + select: SERIES_SELECT, + }); +} + +/** Every record's resolved series positions, keyed by record id. */ +export async function resolveSeriesFor( + tx: Prisma.TransactionClient, + userId: string, +) { + return deriveSeries(await loadSeriesHistory(tx, userId)); +} + +/** + * The linked pages, resolved and redacted against the caller's reach. + * + * A dose lives in the health background and points into the document vault. A + * grant that opened the background was never consent for the vault, and while + * no document CONTENT crosses the seam here, the filename is itself the + * sensitive part: a page called "oncology discharge letter" says most of what + * the person was worried about. + * + * So the label and the date are withheld and the LINK is not: the caller may + * know the dose was transcribed from a page, because the dose is theirs to + * read. `label: null` rather than an invented placeholder, so an absence reads + * as an absence, and `redacted` says which of the two nulls this is. + */ +export async function loadVaccinationDocuments( + tx: Prisma.TransactionClient, + userId: string, + vaccinationId: string, + visible: (domain: ShareDomain) => boolean, +): Promise { + const rows = await listTargets(tx, { + userId, + sourceKind: "vaccination", + sourceId: vaccinationId, + targetKind: "document", + }); + if (visible("documents")) { + return rows.map((row) => ({ ...row, redacted: false })); + } + return rows.map((row) => ({ + id: row.id, + label: null, + date: null, + redacted: true, + })); +} + +/** + * Apply the document array, when the request named one. + * + * An absent array leaves the links alone; a present one replaces them, empty + * array included. Unknown ids are dropped by the link service and never block + * the save — a dose is never refused because a page it pointed at has since + * been deleted. + */ +export async function applyVaccinationLinks( + tx: Prisma.TransactionClient, + userId: string, + vaccinationId: string, + documentIds: string[] | undefined, +): Promise { + if (documentIds === undefined) return; + await replaceTargets(tx, { + userId, + sourceKind: "vaccination", + sourceId: vaccinationId, + targetKind: "document", + targetIds: documentIds, + }); +} + +/** Does the caller own a live practitioner with this id? */ +export async function resolveOwnedPractitioner( + tx: Prisma.TransactionClient, + userId: string, + practitionerId: string, +): Promise<{ id: string } | null> { + return tx.practitioner.findFirst({ + where: { id: practitionerId, userId, deletedAt: null }, + select: { id: true }, + }); +} + +/** + * The RECORD owner's timezone, for the booster reschedule. + * + * The owner's, never the acting delegate's: a reminder fires on the owner's + * phone at the owner's local hour, and a helper transcribing from another + * timezone must not shift when it rings. + */ +export async function resolveOwnerTimezone(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + return row?.timezone || DEFAULT_TIMEZONE; +} + +/** Does the caller own a live visit with this id? */ +export async function resolveOwnedEncounter( + tx: Prisma.TransactionClient, + userId: string, + encounterId: string, +): Promise<{ id: string } | null> { + return tx.encounter.findFirst({ + where: { id: encounterId, userId, deletedAt: null }, + select: { id: true }, + }); +} diff --git a/src/lib/vaccinations/vaccine-catalog.ts b/src/lib/vaccinations/vaccine-catalog.ts new file mode 100644 index 000000000..a97e98d84 --- /dev/null +++ b/src/lib/vaccinations/vaccine-catalog.ts @@ -0,0 +1,561 @@ +/** + * The antigen-level vaccine catalogue: what a dose can be, in one static list. + * + * This is NOT a DB table, for the same reason `BIOMARKER_CATALOG` is not one + * (`src/lib/labs/biomarker-catalog.ts`): the slug is the stable identity, the + * display name resolves through i18n, the numbers are correctable in a patch + * release rather than a migration, and an account that has never opened the + * picker carries no rows for it. + * + * ── What it deliberately is not ──────────────────────────────────────────── + * + * There is no manufacturer column and there is no trade name anywhere in it. + * Entries are named for the antigen or the disease, synonyms are generic or + * colloquial, and the one place a trade name may legitimately appear in this + * feature is the user's own `vaccineName` transcription of their Pass — their + * data, not our content. + * + * The numbers are TYPICAL values for orientation, and `source` names where + * each one comes from so the sentence rendered beside a dose carries its + * citation. They are not an individual schedule: what a particular person + * needs and when depends on their history, their age and their reasons, and + * that adjudication belongs to their doctor. Nothing in this file or in + * anything reading it computes what is due — the module renders what a person + * logged and, at most, offers to set a reminder they confirm themselves. + * + * ── Slugs are append-only ────────────────────────────────────────────────── + * + * 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 + * guarantee, not an accident — see + * `src/lib/vaccinations/__tests__/catalog-integrity.test.ts`. + * + * ── Combinations ─────────────────────────────────────────────────────────── + * + * `components` is the axis everything downstream turns on. A combination lists + * the monovalent antigens it contains, so one Tdap dose counts into the + * tetanus, the diphtheria AND the pertussis series, and clears a booster + * reminder for any of them. A monovalent lists itself. + * + * ATC codes are the WHO J07 subtree at the most specific level that stays + * product-blind. Two entries may legitimately share a code: ATC classifies by + * antigen combination and does not distinguish a full-strength childhood + * preparation from the reduced-antigen adult one. + */ + +/** The monovalent antigens a dose can contain. Combinations reference these. */ +export const ANTIGEN_SLUGS = [ + "tetanus", + "diphtheria", + "pertussis", + "polio", + "measles", + "mumps", + "rubella", + "varicella", + "hib", + "hepatitis-a", + "hepatitis-b", + "influenza", + "pneumococcal", + "herpes-zoster", + "rsv", + "covid-19", + "fsme", + "hpv", + "meningococcal-acwy", + "meningococcal-b", + "rotavirus", + "typhoid", + "rabies", + "yellow-fever", + "japanese-encephalitis", + "cholera", +] as const; + +export type AntigenSlug = (typeof ANTIGEN_SLUGS)[number]; + +/** + * How the picker groups an entry. + * + * `standard` is the general schedule, `standard60` the additions recommended + * from the later decades, `childhood` what a child's Pass fills up with, + * `indication` what a particular exposure or condition calls for, and `travel` + * what a destination does. A person's own Pass mixes all five, which is why + * the catalogue covers a whole life rather than an adult floor. + */ +export type VaccineCategory = + "standard" | "standard60" | "childhood" | "indication" | "travel"; + +export interface VaccineSeed { + /** Stable EN identity; `vaccinations.catalog.` resolves the name. */ + slug: string; + /** WHO ATC code (J07 subtree), most specific level that stays product-blind. */ + atc: string; + /** Component antigens — self for monovalents. The series-derivation axis. */ + components: readonly AntigenSlug[]; + /** Typical primary-series length, or null (seasonal / single-dose). */ + typicalSeriesDoses: number | null; + /** Typical booster interval in months, or null. 12 = yearly / seasonal. */ + boosterIntervalMonths: number | null; + /** Picker grouping. */ + category: VaccineCategory; + /** + * The document the numbers on this entry come from, rendered as the + * footnote beside them. A citation is a proper noun, like a unit: literal, + * not translated. + */ + source: string; + /** Generic search aliases only — never trade names. */ + synonyms?: readonly string[]; +} + +/** The German standing-committee schedule, for everything on it. */ +const STIKO = "STIKO Epid Bull 4/2026"; + +export const VACCINE_CATALOG: readonly VaccineSeed[] = [ + // ── Monovalent: the general schedule ──────────────────────────────────── + { + // Primary series in infancy, then a lifelong ten-year rhythm. The + // ten-year figure is the one number in this file most people already + // know, and the one a reminder is most often set from. + slug: "tetanus", + atc: "J07AM01", + components: ["tetanus"], + typicalSeriesDoses: 3, + boosterIntervalMonths: 120, + category: "standard", + source: STIKO, + synonyms: ["lockjaw"], + }, + { + // Travels with tetanus in practice; the schedule and the interval match, + // which is why the combined preparation is the usual one. + slug: "diphtheria", + atc: "J07AF01", + components: ["diphtheria"], + typicalSeriesDoses: 3, + boosterIntervalMonths: 120, + category: "standard", + source: STIKO, + }, + { + // No fixed adult rhythm: refreshed once in adulthood, and again in each + // pregnancy, neither of which is an interval. Null rather than a number + // invented to fill the field. + slug: "pertussis", + atc: "J07AJ02", + components: ["pertussis"], + typicalSeriesDoses: 3, + boosterIntervalMonths: null, + category: "standard", + source: STIKO, + synonyms: ["whooping cough"], + }, + { + // Four doses in childhood, one refresher in adolescence. Adults refresh + // only for travel to a region where it still circulates, which is a + // destination question rather than an interval. + slug: "polio", + atc: "J07BF03", + components: ["polio"], + typicalSeriesDoses: 4, + boosterIntervalMonths: null, + category: "standard", + source: STIKO, + synonyms: ["poliomyelitis", "ipv"], + }, + { + // Three doses in infancy; no routine adult booster. + slug: "hepatitis-b", + atc: "J07BC01", + components: ["hepatitis-b"], + typicalSeriesDoses: 3, + boosterIntervalMonths: null, + category: "standard", + source: STIKO, + synonyms: ["hep b"], + }, + { + // Two doses, then variant-adapted refreshers whose cadence is a moving + // recommendation rather than a fixed schedule. Twelve months is the + // rhythm the seasonal refresher has settled into. + slug: "covid-19", + atc: "J07BN01", + components: ["covid-19"], + typicalSeriesDoses: 2, + boosterIntervalMonths: 12, + category: "standard", + source: STIKO, + synonyms: ["coronavirus", "sars-cov-2"], + }, + + // ── Monovalent: added in the later decades ────────────────────────────── + { + // Seasonal: one dose per year against that year's strains, so there is no + // series to be part of. Null rather than 1 — every autumn's dose would + // otherwise read as a booster on a one-dose series it had already passed. + slug: "influenza", + atc: "J07BB02", + components: ["influenza"], + typicalSeriesDoses: null, + boosterIntervalMonths: 12, + category: "standard60", + source: STIKO, + synonyms: ["flu", "seasonal flu"], + }, + { + slug: "pneumococcal", + atc: "J07AL02", + components: ["pneumococcal"], + typicalSeriesDoses: 1, + boosterIntervalMonths: null, + category: "standard60", + source: STIKO, + synonyms: ["pneumonia"], + }, + { + // Two doses, two to six months apart; no booster after them. + slug: "herpes-zoster", + atc: "J07BK03", + components: ["herpes-zoster"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + category: "standard60", + source: STIKO, + synonyms: ["shingles", "zoster"], + }, + { + slug: "rsv", + atc: "J07BX05", + components: ["rsv"], + typicalSeriesDoses: 1, + boosterIntervalMonths: null, + category: "standard60", + source: STIKO, + synonyms: ["respiratory syncytial virus"], + }, + + // ── Monovalent: the childhood Pass ────────────────────────────────────── + { + // Given as part of a combined preparation in practice; the monovalent + // entry exists because an older Pass line may record it on its own. + slug: "measles", + atc: "J07BD01", + components: ["measles"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + category: "childhood", + source: STIKO, + }, + { + slug: "mumps", + atc: "J07BE01", + components: ["mumps"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + category: "childhood", + source: STIKO, + }, + { + slug: "rubella", + atc: "J07BJ01", + components: ["rubella"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + category: "childhood", + source: STIKO, + synonyms: ["german measles"], + }, + { + slug: "varicella", + atc: "J07BK01", + components: ["varicella"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + category: "childhood", + source: STIKO, + synonyms: ["chickenpox"], + }, + { + slug: "hib", + atc: "J07AG01", + components: ["hib"], + typicalSeriesDoses: 3, + boosterIntervalMonths: null, + category: "childhood", + source: STIKO, + synonyms: ["haemophilus influenzae type b"], + }, + { + // Oral, and the series is finished early — the whole course sits inside + // the first months of life. + slug: "rotavirus", + atc: "J07BH01", + components: ["rotavirus"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + category: "childhood", + source: STIKO, + }, + { + // Two doses when the course starts before fifteen, three when it starts + // later. Two is the typical value; the record's own `seriesDoses` + // override is there for the person whose Pass says three. + slug: "hpv", + atc: "J07BM03", + components: ["hpv"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + category: "childhood", + source: STIKO, + synonyms: ["human papillomavirus"], + }, + + // ── Monovalent: by indication ─────────────────────────────────────────── + { + // Three doses, then a refresher every three to five years while the + // exposure lasts. Sixty months is the longer end of that window. + slug: "fsme", + atc: "J07BA02", + components: ["fsme"], + typicalSeriesDoses: 3, + boosterIntervalMonths: 60, + category: "indication", + source: STIKO, + synonyms: ["tick-borne encephalitis", "tbe"], + }, + { + slug: "hepatitis-a", + atc: "J07BC02", + components: ["hepatitis-a"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + category: "indication", + source: STIKO, + synonyms: ["hep a"], + }, + { + slug: "meningococcal-acwy", + atc: "J07AH08", + components: ["meningococcal-acwy"], + typicalSeriesDoses: 1, + boosterIntervalMonths: null, + category: "indication", + source: STIKO, + synonyms: ["meningococcus acwy", "quadrivalent meningococcal"], + }, + { + slug: "meningococcal-b", + atc: "J07AH09", + components: ["meningococcal-b"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + category: "indication", + source: STIKO, + synonyms: ["meningococcus b"], + }, + { + // The pre-exposure course. What follows an actual exposure is treatment + // and is not a schedule this catalogue has an opinion about. + slug: "rabies", + atc: "J07BG01", + components: ["rabies"], + typicalSeriesDoses: 3, + boosterIntervalMonths: null, + category: "indication", + source: STIKO, + synonyms: ["lyssa"], + }, + + // ── Monovalent: by destination ────────────────────────────────────────── + // Not on the standing-committee schedule, so each cites the WHO position + // paper for its own antigen rather than borrowing a citation from a + // document that does not cover it. + { + slug: "typhoid", + atc: "J07AP03", + components: ["typhoid"], + typicalSeriesDoses: 1, + boosterIntervalMonths: 36, + category: "travel", + source: "WHO position paper on typhoid vaccines", + synonyms: ["typhoid fever"], + }, + { + // One dose is treated as lifelong protection; the certificate that used + // to expire after ten years no longer does. + slug: "yellow-fever", + atc: "J07BL01", + components: ["yellow-fever"], + typicalSeriesDoses: 1, + boosterIntervalMonths: null, + category: "travel", + source: "WHO position paper on yellow fever vaccines", + }, + { + slug: "japanese-encephalitis", + atc: "J07BA01", + components: ["japanese-encephalitis"], + typicalSeriesDoses: 2, + boosterIntervalMonths: 12, + category: "travel", + source: "WHO position paper on Japanese encephalitis vaccines", + }, + { + // Oral, and the only entry here whose route is not an injection — which + // is why route derives from the antigen at the export boundary rather + // than being asked of the person filling in the form. + slug: "cholera", + atc: "J07AE01", + components: ["cholera"], + typicalSeriesDoses: 2, + boosterIntervalMonths: 24, + category: "travel", + source: "WHO position paper on cholera vaccines", + }, + + // ── Combinations ──────────────────────────────────────────────────────── + { + // The adult refresher most Passes are full of. + slug: "td", + atc: "J07AM51", + components: ["tetanus", "diphtheria"], + typicalSeriesDoses: 3, + boosterIntervalMonths: 120, + category: "standard", + source: STIKO, + synonyms: ["tetanus diphtheria"], + }, + { + slug: "tdap", + atc: "J07AJ52", + components: ["tetanus", "diphtheria", "pertussis"], + typicalSeriesDoses: 3, + boosterIntervalMonths: 120, + category: "standard", + source: STIKO, + synonyms: ["tetanus diphtheria pertussis"], + }, + { + slug: "tdap-ipv", + atc: "J07CA02", + components: ["tetanus", "diphtheria", "pertussis", "polio"], + typicalSeriesDoses: 3, + boosterIntervalMonths: 120, + category: "standard", + source: STIKO, + }, + { + // The childhood preparation. Same ATC code as the adult one above: ATC + // classifies the antigen combination and does not distinguish the + // full-strength form from the reduced-antigen one. + slug: "dtap-ipv", + atc: "J07CA02", + components: ["diphtheria", "tetanus", "pertussis", "polio"], + typicalSeriesDoses: 4, + boosterIntervalMonths: null, + category: "childhood", + source: STIKO, + }, + { + // Six antigens in one injection, which is what makes an infant Pass page + // short and this catalogue's component axis necessary: one line here + // counts into six separate series. + slug: "hexavalent", + atc: "J07CA09", + components: [ + "diphtheria", + "tetanus", + "pertussis", + "polio", + "hib", + "hepatitis-b", + ], + typicalSeriesDoses: 3, + boosterIntervalMonths: null, + category: "childhood", + source: STIKO, + synonyms: ["six-in-one"], + }, + { + slug: "mmr", + atc: "J07BD52", + components: ["measles", "mumps", "rubella"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + category: "childhood", + source: STIKO, + synonyms: ["measles mumps rubella"], + }, + { + slug: "mmrv", + atc: "J07BD54", + components: ["measles", "mumps", "rubella", "varicella"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + category: "childhood", + source: STIKO, + synonyms: ["measles mumps rubella varicella"], + }, + { + slug: "hepa-hepb", + atc: "J07BC20", + components: ["hepatitis-a", "hepatitis-b"], + typicalSeriesDoses: 3, + boosterIntervalMonths: null, + category: "travel", + source: STIKO, + synonyms: ["hepatitis a and b"], + }, + { + slug: "hepa-typhoid", + atc: "J07CA10", + components: ["hepatitis-a", "typhoid"], + typicalSeriesDoses: 1, + boosterIntervalMonths: null, + category: "travel", + source: "WHO position paper on typhoid vaccines", + }, +]; + +/** Every slug the catalogue currently offers, combinations included. */ +export const VACCINE_CATALOG_SLUGS: readonly string[] = VACCINE_CATALOG.map( + (entry) => entry.slug, +); + +const BY_SLUG = new Map( + VACCINE_CATALOG.map((entry) => [entry.slug, entry]), +); + +/** + * The one lookup path into the catalogue. + * + * Returns `null` for anything it does not know — an empty slug, a slug a past + * release wrote and this one no longer lists, a slug restored verbatim from a + * backup written by an older version. It never throws, because every one of + * those is a real row belonging to a real person, and the caller's answer is + * to fall back to the record's own `vaccineName` rather than to fail. + */ +export function resolveCatalogEntry( + slug: string | null | undefined, +): VaccineSeed | null { + 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 ?? []; +} diff --git a/src/lib/validations/__tests__/vaccinations.test.ts b/src/lib/validations/__tests__/vaccinations.test.ts new file mode 100644 index 000000000..cba1936ab --- /dev/null +++ b/src/lib/validations/__tests__/vaccinations.test.ts @@ -0,0 +1,250 @@ +/** + * The vaccination wire contract, at the boundary. + * + * Four properties are worth holding here rather than only at the route: the + * either-arm rule (the one thing that IS mandatory beyond the date), the + * future-date refusal (the split from the visit schema next door), the + * catalogue check on write (the split from what storage accepts), and the + * absence of `userId` (which is narrowed from auth in every route and must + * never be something a body can name). + */ +import { describe, expect, it } from "vitest"; + +import { + vaccinationCreateSchema, + vaccinationLinkSchema, + vaccinationListQuerySchema, + vaccinationUpdateSchema, +} from "@/lib/validations/vaccinations"; + +const YESTERDAY = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); +const NEXT_YEAR = new Date( + Date.now() + 365 * 24 * 60 * 60 * 1000, +).toISOString(); + +function pathsOf(result: { success: false; error: { issues: unknown[] } }) { + return (result.error.issues as Array<{ path: (string | number)[] }>).map( + (issue) => issue.path.join("."), + ); +} + +describe("a dose needs a date and one way of naming what it was", () => { + it("accepts a catalogue pick with nothing else", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: "tdap", + }); + expect(parsed.success).toBe(true); + }); + + it("accepts free text with nothing else, so a 1987 Pass line is loggable", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + vaccineName: "Tetanol, as the Pass wrote it", + }); + expect(parsed.success).toBe(true); + }); + + it("refuses a body with neither, naming BOTH fields", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + // Both, so a client highlighting one field per issue highlights the pick + // and the text box rather than leaving the person to guess which was + // meant. + expect(pathsOf(parsed).sort()).toEqual(["antigenSlug", "vaccineName"]); + }); + + it("returns every issue at once rather than the first", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: "not-a-date", + doseNumber: 99, + lotNumber: "x".repeat(200), + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.length).toBeGreaterThanOrEqual(3); + }); +}); + +describe("a dose is logged after the fact", () => { + it("refuses a date in the future", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: NEXT_YEAR, + antigenSlug: "tetanus", + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(pathsOf(parsed)).toContain("occurredAt"); + expect(JSON.stringify(parsed.error.issues)).toMatch( + /reminder, not a record/, + ); + }); + + it("allows a day's worth of clock skew, so a client east of here works", () => { + const inSixHours = new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(); + expect( + vaccinationCreateSchema.safeParse({ + occurredAt: inSixHours, + antigenSlug: "tetanus", + }).success, + ).toBe(true); + }); + + it("refuses a date before there were records to keep", () => { + expect( + vaccinationCreateSchema.safeParse({ + occurredAt: "1802-04-01T00:00:00.000Z", + antigenSlug: "tetanus", + }).success, + ).toBe(false); + }); +}); + +describe("the write path is stricter than the column", () => { + it("refuses a slug the catalogue does not offer", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: "not-a-real-antigen", + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(pathsOf(parsed)).toContain("antigenSlug"); + }); + + it("accepts every slug the catalogue does offer", () => { + for (const slug of ["tetanus", "tdap", "hexavalent", "mmrv", "cholera"]) { + expect( + vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: slug, + }).success, + slug, + ).toBe(true); + } + }); +}); + +describe("no body may name the account it writes to", () => { + it("refuses a userId field on create", () => { + const parsed = vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: "tetanus", + userId: "somebody-else", + }); + expect( + parsed.success, + "userId is narrowed from auth; a body that can name it is a body that " + + "can write into another account", + ).toBe(false); + }); + + it("refuses a userId field on update", () => { + expect( + vaccinationUpdateSchema.safeParse({ + lotNumber: "AB-1", + userId: "somebody-else", + }).success, + ).toBe(false); + }); + + it("refuses an unknown field rather than ignoring it", () => { + expect( + vaccinationCreateSchema.safeParse({ + occurredAt: YESTERDAY, + antigenSlug: "tetanus", + manufacturer: "a trade name", + }).success, + ).toBe(false); + }); +}); + +describe("an edit names what it changes", () => { + it("refuses a body that names nothing", () => { + const parsed = vaccinationUpdateSchema.safeParse({}); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(JSON.stringify(parsed.error.issues)).toMatch( + /name at least one field/, + ); + }); + + it("allows one field on its own", () => { + expect( + vaccinationUpdateSchema.safeParse({ lotNumber: "AB-1" }).success, + ).toBe(true); + }); + + it("allows the date to be corrected", () => { + expect( + vaccinationUpdateSchema.safeParse({ occurredAt: YESTERDAY }).success, + ).toBe(true); + }); + + it("refuses an edit that clears both identity arms at once", () => { + expect( + vaccinationUpdateSchema.safeParse({ + antigenSlug: null, + vaccineName: null, + }).success, + ).toBe(false); + }); + + it("allows clearing one arm, because the other may still be there", () => { + expect( + vaccinationUpdateSchema.safeParse({ antigenSlug: null }).success, + ).toBe(true); + }); +}); + +describe("the list query and the link body", () => { + it("refuses a window that ends before it starts", () => { + const parsed = vaccinationListQuerySchema.safeParse({ + from: "2026-07-01T00:00:00.000Z", + to: "2026-06-01T00:00:00.000Z", + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(pathsOf(parsed)).toContain("to"); + }); + + it("coerces a limit from the query string and bounds it", () => { + expect(vaccinationListQuerySchema.safeParse({ limit: "50" })).toMatchObject( + { + success: true, + data: { limit: 50 }, + }, + ); + expect( + vaccinationListQuerySchema.safeParse({ limit: "5000" }).success, + ).toBe(false); + }); + + it("bounds a link call at the facade's own cap", () => { + const ids = (count: number) => + Array.from({ length: count }, (_, index) => `doc-${index}`); + expect( + vaccinationLinkSchema.safeParse({ + targetKind: "document", + targetIds: ids(100), + }).success, + ).toBe(true); + expect( + vaccinationLinkSchema.safeParse({ + targetKind: "document", + targetIds: ids(101), + }).success, + ).toBe(false); + }); + + it("knows exactly one link family", () => { + expect( + vaccinationLinkSchema.safeParse({ + targetKind: "labResult", + targetIds: ["lab-1"], + }).success, + ).toBe(false); + }); +}); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 9a198e7dd..a9fdb0522 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -57,6 +57,7 @@ import { RhythmClassification, SecondarySymptom, SleepStage, + VaccinationSite, } from "@/generated/prisma/enums"; import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS, @@ -884,6 +885,44 @@ const encounterLinkBackupSchema = z }) .passthrough(); +/** + * One administered dose. + * + * `antigenSlug` is a free string here rather than an enum of the catalogue + * this release ships, deliberately: a slug the catalogue has since dropped + * must still restore, and the renderer degrades to `vaccineName`. Validating + * it at the restore boundary would refuse a file the app itself wrote. + */ +const vaccinationBackupSchema = z + .object({ + id: z.string().min(1), + occurredAt: isoDateTime, + antigenSlug: z.string().nullable().optional(), + vaccineName: z.string().nullable().optional(), + doseNumber: z.number().int().nullable().optional(), + seriesDoses: z.number().int().nullable().optional(), + lotNumber: z.string().nullable().optional(), + site: z.enum(VaccinationSite).nullable().optional(), + practitionerId: z.string().nullable().optional(), + encounterId: z.string().nullable().optional(), + // Carried so the loss is visible in the file. The restore resolves it to + // NULL and names the drop: the reminder it points at does not travel. + reminderId: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); + +const vaccinationLinkBackupSchema = z + .object({ + vaccinationId: z.string().min(1), + targetId: z.string().min(1), + createdAt: isoDateTime, + }) + .passthrough(); + const allergyBackupSchema = z .object({ id: z.string().min(1), @@ -1036,6 +1075,12 @@ export const backupPayloadSchema = z encounterDocumentLinks: z.array(encounterLinkBackupSchema).default([]), encounterLabLinks: z.array(encounterLinkBackupSchema).default([]), encounterConditionLinks: z.array(encounterLinkBackupSchema).default([]), + // The immunization log and the pages it was transcribed from. Defaulted + // 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 []. + vaccinations: z.array(vaccinationBackupSchema).default([]), + vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), }) .passthrough() diff --git a/src/lib/validations/vaccinations.ts b/src/lib/validations/vaccinations.ts new file mode 100644 index 000000000..0ae186dee --- /dev/null +++ b/src/lib/validations/vaccinations.ts @@ -0,0 +1,211 @@ +/** + * Vaccination request/response validation. + * + * Source of truth for the `/api/vaccinations*` wire contract; the OpenAPI + * registry reuses these schemas so the spec stays single-source. `userId` is + * NEVER a body field — it is narrowed from the session or Bearer in every + * route and fed to the Prisma `where`. + * + * Nothing is mandatory except the date and ONE identity arm. A dose saves with + * a day and either a catalogue pick or the person's own wording, and nothing + * else: no dose number, no batch code, no arm, no practice, no document. A + * required catalogue pick would make a 1987 Pass line unloggable, which is the + * whole population this feature exists for. + * + * Two places where the API is deliberately stricter than the column: + * + * - `occurredAt` refuses a future instant. A vaccination is logged after the + * fact; a planned booster is a reminder, not a record. The visit schema + * next door allows the future because a booked appointment genuinely is + * one; a dose that has not been given is not a dose. + * - `antigenSlug` must resolve in the catalogue this release ships. Storage + * stays tolerant so a backup written by an older version restores whole + * and its slug degrades to the free-text arm on screen; a write is the one + * moment the catalogue is authoritative, because the picker produced it. + */ +import { z } from "zod/v4"; + +import { VACCINE_CATALOG_SLUGS } from "@/lib/vaccinations/vaccine-catalog"; + +const KNOWN_SLUGS: ReadonlySet = new Set(VACCINE_CATALOG_SLUGS); + +/** + * A day a dose can plausibly carry. + * + * Bounded on both sides: not before 1900, and not in the future beyond the + * clock skew a client's own "today" can plausibly show. The skew allowance is + * a day rather than minutes because the value is a DAY — a client in a + * timezone ahead of the server sends its own local date, and refusing that + * would make the form unusable east of here. + */ +const DAY_SKEW_MS = 24 * 60 * 60 * 1000; + +const doseInstant = z.iso.datetime({ offset: true }).refine( + (value) => { + const at = new Date(value).getTime(); + if (Number.isNaN(at)) return false; + if (at < Date.UTC(1900, 0, 1)) return false; + return at <= Date.now() + DAY_SKEW_MS; + }, + { + message: + "must be a day a dose was actually given (not before 1900, not in the future — a planned booster is a reminder, not a record)", + }, +); + +/* ── enum (mirrors the Prisma enum) ───────────────────────────────── */ + +export const vaccinationSiteEnum = z.enum([ + "LEFT_ARM", + "RIGHT_ARM", + "LEFT_THIGH", + "RIGHT_THIGH", + "ORAL", + "NASAL", + "OTHER", +]); + +export type VaccinationSiteInput = z.infer; + +const id = z.string().min(1).max(40); + +/** + * A slug the catalogue this release ships actually has. + * + * The message names the field rather than listing thirty-five slugs: the + * picker is the source of a legitimate value, so a slug arriving here that is + * not in the set is a client defect or a hand-written request, and neither is + * helped by an enumeration. + */ +const antigenSlug = z + .string() + .min(1) + .max(64) + .refine((value) => KNOWN_SLUGS.has(value), { + message: "must be a slug the vaccine catalogue offers", + }); + +/** Bounded at the link service's own cap, so an over-long list is a 422. */ +const linkIds = z.array(id).max(100).optional(); + +/* ── vaccination CRUD ─────────────────────────────────────────────── */ + +/** + * The either-arm rule, applied to whichever schema needs it. + * + * Written as a helper because create and update both need it and they need it + * to REPORT the same way: the issue is raised against both fields, so a client + * highlighting one field per issue highlights the pick and the free-text box + * 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"; + +export const vaccinationCreateSchema = z + .object({ + occurredAt: doseInstant, + antigenSlug: antigenSlug.nullable().optional(), + /** The Pass's own wording, verbatim. May be a trade name — user data. */ + vaccineName: z.string().min(1).max(200).nullable().optional(), + doseNumber: z.number().int().min(1).max(20).nullable().optional(), + seriesDoses: z.number().int().min(1).max(10).nullable().optional(), + /** Chargenbezeichnung. A batch code, not a secret. */ + lotNumber: z.string().min(1).max(64).nullable().optional(), + site: vaccinationSiteEnum.nullable().optional(), + practitionerId: id.nullable().optional(), + encounterId: 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; + for (const path of ["antigenSlug", "vaccineName"] as const) { + ctx.addIssue({ code: "custom", path: [path], message: IDENTITY_MESSAGE }); + } + }); + +export type VaccinationCreate = z.infer; + +/** + * Edit a dose. Every field optional; an omitted field is left untouched. + * + * A body naming nothing is a 422 rather than a silent success: a PATCH that + * changed nothing and answered 200 is indistinguishable from one that worked. + * + * `occurredAt` is editable — a transcription typo is the common case, and the + * date is the field most likely to carry one. The identity rule is checked + * against the merged row in the route rather than here, because a PATCH that + * names only `lotNumber` says nothing about the identity and must not be + * refused for it. + */ +export const vaccinationUpdateSchema = z + .object({ + occurredAt: doseInstant.optional(), + antigenSlug: antigenSlug.nullable().optional(), + vaccineName: z.string().min(1).max(200).nullable().optional(), + doseNumber: z.number().int().min(1).max(20).nullable().optional(), + seriesDoses: z.number().int().min(1).max(10).nullable().optional(), + lotNumber: z.string().min(1).max(64).nullable().optional(), + site: vaccinationSiteEnum.nullable().optional(), + practitionerId: id.nullable().optional(), + encounterId: id.nullable().optional(), + note: z.string().max(2000).nullable().optional(), + documentIds: linkIds, + }) + .strict() + .refine((body) => Object.keys(body).length > 0, { + message: "name at least one field to change", + }) + .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), + { path: ["antigenSlug"], message: IDENTITY_MESSAGE }, + ); + +export type VaccinationUpdate = z.infer; + +/** + * List query. + * + * An Impfpass is a lifetime document but a small one, so the list is full and + * bounded rather than paged: the default `take` already covers a long life, + * and a client that wants less says so. + */ +export const vaccinationListQuerySchema = z + .object({ + antigenSlug: z.string().min(1).max(64).optional(), + from: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + limit: z.coerce.number().int().min(1).max(500).optional(), + }) + .strict() + .refine((q) => !(q.from && q.to) || q.from <= q.to, { + path: ["to"], + message: "to must be on or after from", + }); + +export type VaccinationListQuery = z.infer; + +/* ── links ────────────────────────────────────────────────────────── */ + +/** + * The one link family a dose has. + * + * A single-member enum rather than a bare constant, so the wire shape matches + * the encounter link route's and a second family — if this venture ever loses + * the argument against one — is an added member rather than a changed shape. + */ +export const vaccinationLinkTargetEnum = z.enum(["document"]); + +export const vaccinationLinkSchema = z + .object({ + targetKind: vaccinationLinkTargetEnum, + targetIds: z.array(id).min(1).max(100), + }) + .strict(); + +export type VaccinationLinkInput = z.infer; diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 929e2f76e..2eeeeb2dd 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -400,6 +400,39 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "restore", ), + // v1.38.0 — the immunization log. + handler( + "app/api/vaccinations/route.ts", + "/api/vaccinations", + "POST", + "profile", + "write", + "create", + ), + handler( + "app/api/vaccinations/[id]/route.ts", + "/api/vaccinations/[id]", + "PATCH", + "profile", + "manage", + "update", + ), + handler( + "app/api/vaccinations/[id]/route.ts", + "/api/vaccinations/[id]", + "DELETE", + "profile", + "manage", + "delete", + ), + handler( + "app/api/vaccinations/[id]/restore/route.ts", + "/api/vaccinations/[id]/restore", + "POST", + "profile", + "manage", + "restore", + ), handler( "app/api/illness/episodes/[id]/day-logs/route.ts", "/api/illness/episodes/[id]/day-logs", diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 8de0ebd8c..966919fd2 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -46,7 +46,7 @@ process.env.ENCRYPTION_KEY ??= import type { PrismaClient } from "@/generated/prisma/client"; import { encrypt, encryptBytes } from "@/lib/crypto"; -import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { readNote } from "@/lib/crypto/note-cipher"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan"; @@ -149,6 +149,10 @@ const COUNT_BACK: Record< p.encounterLabLink.count({ where: { userId } }), EncounterConditionLink: (p, userId) => p.encounterConditionLink.count({ where: { userId } }), + VaccinationRecord: (p, userId) => + p.vaccinationRecord.count({ where: { userId } }), + VaccinationDocumentLink: (p, userId) => + p.vaccinationDocumentLink.count({ where: { userId } }), }; /** @@ -468,6 +472,40 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { episodeId: episode.id, }, }); + + // One dose with every optional column filled, and the page it was + // transcribed from. Fat on purpose, against this file's own minimal-fixture + // rule: a count-back proves the row returned, and the assertion after the + // restore reads each column back so it also proves the row returned WHOLE. + // The practitioner and the encounter are the ones seeded above, because both + // are remapped on the way back and a reference to something the restore did + // not put back is a different case (covered by the skip test below). + // + // `reminderId` is deliberately NOT set here: it is the one reference that + // can never resolve, so setting it would make this test's "nothing was + // skipped" assertion false for a reason that is correct behaviour. + const vaccination = await prisma.vaccinationRecord.create({ + data: { + userId: OWNER_ID, + occurredAt: AT("2026-05-14T00:00:00.000Z"), + antigenSlug: "tdap", + vaccineName: "Tetanus, diphtheria and pertussis", + doseNumber: 3, + seriesDoses: 3, + lotNumber: "RT-4471", + site: "LEFT_ARM", + practitionerId: practitioner.id, + encounterId: encounter.id, + noteEncrypted: encryptToBytes("sore arm for a day, nothing else"), + }, + }); + await prisma.vaccinationDocumentLink.create({ + data: { + userId: OWNER_ID, + vaccinationId: vaccination.id, + documentId: document.id, + }, + }); } async function createOwner(prisma: PrismaClient) { @@ -596,5 +634,131 @@ describe("every model the plan claims two-ended survives a real restore", () => expect(sideEffect.notes, "plaintext must not come back in the column").toBe( null, ); + + // The dose, read back column by column. The count above says a row + // returned; a restore that wrote one row with every optional column + // defaulted would satisfy it and hand back an Impfpass line with no + // antigen, no batch code and no arm. The two remapped references are + // asserted as ids that exist rather than as the ids the file carried, + // because that is the property the remap owes. + const dose = await prisma.vaccinationRecord.findFirstOrThrow({ + where: { userId: OWNER_ID }, + include: { + practitioner: { select: { name: true } }, + encounter: { select: { occurredAt: true } }, + documentLinks: { select: { documentId: true } }, + }, + }); + expect({ + occurredAt: dose.occurredAt.toISOString(), + antigenSlug: dose.antigenSlug, + vaccineName: dose.vaccineName, + doseNumber: dose.doseNumber, + seriesDoses: dose.seriesDoses, + lotNumber: dose.lotNumber, + site: dose.site, + practitioner: dose.practitioner?.name ?? null, + encounterAt: dose.encounter?.occurredAt.toISOString() ?? null, + links: dose.documentLinks.length, + note: dose.noteEncrypted ? decryptFromBytes(dose.noteEncrypted) : null, + }).toEqual({ + occurredAt: "2026-05-14T00:00:00.000Z", + antigenSlug: "tdap", + vaccineName: "Tetanus, diphtheria and pertussis", + doseNumber: 3, + seriesDoses: 3, + lotNumber: "RT-4471", + site: "LEFT_ARM", + practitioner: "Round-trip practice", + encounterAt: "2026-06-30T08:00:00.000Z", + links: 1, + note: "sore arm for a day, nothing else", + }); + }); + + /** + * The two references a dose can carry that the restore cannot honour, and + * the slug it must not judge. + * + * Separated from the round trip above because both are correct behaviour + * that shows up as a reported skip, and that file asserts nothing was + * skipped. Reported is the whole point: a reference dropped in silence is + * the same defect as a row dropped in silence, one field smaller. + */ + it("reports the reminder it cannot restore and keeps a slug it cannot resolve", async () => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + + const reminder = await prisma.measurementReminder.create({ + data: { + userId: OWNER_ID, + label: "Tetanus booster", + intervalDays: 3650, + vaccinationAntigen: "tetanus", + }, + }); + await prisma.vaccinationRecord.create({ + data: { + userId: OWNER_ID, + occurredAt: AT("1987-09-02T00:00:00.000Z"), + // A slug no catalogue this release ships resolves. It must come back + // exactly as written: the renderer degrades to `vaccineName`, and a + // restore that dropped or rewrote it would lose what a person was + // actually given. + antigenSlug: "retired-antigen-from-an-older-release", + vaccineName: "Whatever the Pass called it in 1987", + reminderId: reminder.id, + }, + }); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + + const reported = body.data.skipped.catalogueKeys as Array<{ + catalogue: string; + key: string; + }>; + expect( + reported.filter((entry) => entry.catalogue === "vaccinationReference"), + "the reminder the dose pointed at is not in the backup at all, so " + + "dropping the reference is correct and saying nothing about it is not", + ).toEqual([ + { catalogue: "vaccinationReference", key: reminder.id, links: 1 }, + ]); + + const dose = await prisma.vaccinationRecord.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect({ + antigenSlug: dose.antigenSlug, + vaccineName: dose.vaccineName, + reminderId: dose.reminderId, + }).toEqual({ + antigenSlug: "retired-antigen-from-an-older-release", + vaccineName: "Whatever the Pass called it in 1987", + reminderId: null, + }); }); }); diff --git a/tests/integration/doctor-report-dense-data.test.ts b/tests/integration/doctor-report-dense-data.test.ts index 6e52a4563..fac0f7a13 100644 --- a/tests/integration/doctor-report-dense-data.test.ts +++ b/tests/integration/doctor-report-dense-data.test.ts @@ -31,6 +31,7 @@ const MODULES = { inboundDocuments: true, mentalHealth: true, nutrients: true, + vaccinations: true, } as const; /** The dense path is about pulse and glucose; nothing else is in scope here. */ diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index 7529ad80a..a034efd03 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -335,6 +335,7 @@ describe("doctor-report — module enable/disable gating", () => { inboundDocuments: true, mentalHealth: true, nutrients: true, + vaccinations: true, } as const; it("includes every module's data when all modules are enabled", async () => { diff --git a/tests/integration/link-service.test.ts b/tests/integration/link-service.test.ts index 19b12f81f..94dc0dd1d 100644 --- a/tests/integration/link-service.test.ts +++ b/tests/integration/link-service.test.ts @@ -74,7 +74,14 @@ async function seedAccount(id: string) { onsetAt: new Date("2026-06-20T00:00:00.000Z"), }, }); - return { encounter, document, labResult, episode }; + const vaccination = await prisma.vaccinationRecord.create({ + data: { + userId: id, + occurredAt: new Date("2026-05-14T00:00:00.000Z"), + antigenSlug: "tdap", + }, + }); + return { encounter, document, labResult, episode, vaccination }; } let owner: Awaited>; @@ -461,3 +468,180 @@ describe("link service — resolved reads", () => { expect(listed).toEqual([]); }); }); + +/** + * The `vaccination:document` pair, one case per invariant the facade claims. + * + * The pair is new; the invariants are not. Repeating them here rather than + * trusting the encounter cases is the point — every one of them is a `where` + * clause or an index on a DIFFERENT table, and a pair wired to the wrong + * column or the wrong ownership probe would pass every test above while + * exposing this one. + */ +describe("link service — the vaccination pair", () => { + it("links a dose to the page it was transcribed from", async () => { + const prisma = getPrismaClient(); + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + + expect(result.changed).toBe(1); + expect(result.unknownTargetIds).toEqual([]); + expect(await prisma.vaccinationDocumentLink.count()).toBe(1); + // Written against the right columns, not merely written: a pair wired to + // the wrong column would still increment the count above. + const row = await prisma.vaccinationDocumentLink.findFirstOrThrow(); + expect({ + vaccinationId: row.vaccinationId, + documentId: row.documentId, + userId: row.userId, + }).toEqual({ + vaccinationId: owner.vaccination.id, + documentId: owner.document.id, + userId: OWNER, + }); + }); + + it("refuses a document another account owns", async () => { + const prisma = getPrismaClient(); + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "document", + targetIds: [stranger.document.id], + }); + + expect(result.changed).toBe(0); + expect(result.unknownTargetIds).toEqual([stranger.document.id]); + expect(await prisma.vaccinationDocumentLink.count()).toBe(0); + }); + + it("refuses a dose another account owns", async () => { + const prisma = getPrismaClient(); + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: stranger.vaccination.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + + expect(result.unknownSource).toBe(true); + expect(await prisma.vaccinationDocumentLink.count()).toBe(0); + }); + + it("links the same pair twice without writing a second row", async () => { + const prisma = getPrismaClient(); + const request = { + userId: OWNER, + sourceKind: "vaccination" as const, + sourceId: owner.vaccination.id, + targetKind: "document" as const, + targetIds: [owner.document.id], + }; + expect((await linkTargets(prisma, request)).changed).toBe(1); + expect((await linkTargets(prisma, request)).changed).toBe(0); + expect(await prisma.vaccinationDocumentLink.count()).toBe(1); + }); + + it("refuses a call naming more targets than the cap", async () => { + const prisma = getPrismaClient(); + await expect( + linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "document", + targetIds: Array.from( + { length: MAX_TARGETS_PER_CALL + 1 }, + (_, index) => `doc-${index}`, + ), + }), + ).rejects.toThrow(/cap/); + expect(await prisma.vaccinationDocumentLink.count()).toBe(0); + }); + + it("will not link onto a dose the account has deleted", async () => { + const prisma = getPrismaClient(); + await prisma.vaccinationRecord.update({ + where: { id: owner.vaccination.id }, + data: { deletedAt: new Date() }, + }); + + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + expect(result.unknownSource).toBe(true); + expect(await prisma.vaccinationDocumentLink.count()).toBe(0); + }); + + it("resolves the linked page to a label and a date, never a bare id", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + + const listed = await listTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "document", + }); + expect(listed).toEqual([ + { + id: owner.document.id, + label: `${OWNER} report`, + date: "2026-06-30T00:00:00.000Z", + }, + ]); + }); + + it("does not leak another account's dose links into a read", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: STRANGER, + sourceKind: "vaccination", + sourceId: stranger.vaccination.id, + targetKind: "document", + targetIds: [stranger.document.id], + }); + + expect( + await listTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: stranger.vaccination.id, + targetKind: "document", + }), + ).toEqual([]); + }); + + it("has no table joining a dose to a lab result", async () => { + // The ceiling, asserted rather than only commented: the vaccination + // source owns exactly one pair, and asking for a second is a programming + // error the facade refuses loudly instead of inventing a table for. + const prisma = getPrismaClient(); + await expect( + linkTargets(prisma, { + userId: OWNER, + sourceKind: "vaccination", + sourceId: owner.vaccination.id, + targetKind: "labResult", + targetIds: [owner.labResult.id], + }), + ).rejects.toThrow(/No link table joins a vaccination to a labResult/); + }); +}); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index b687abd98..4d4261960 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -281,7 +281,7 @@ const ROUTE_IMPORTS = import.meta.glob>( describe("the complete MANAGE handler matrix", () => { it("starts with a non-empty, exact handler inventory", () => { - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(70); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(74); }); }); @@ -537,6 +537,20 @@ async function makeVisit(userId: string) { }); } +/** + * One logged dose, catalogue-identified. + * + * `tetanus` rather than a combination on purpose: the drivers below assert one + * effect each, and a combination would satisfy several booster reminders at + * once if the fixture ever grew one. There is none here, so no reminder moves + * and the edit and delete verbs say only what they are here to say. + */ +async function makeVaccination(userId: string) { + return getPrismaClient().vaccinationRecord.create({ + data: { userId, occurredAt: new Date(ISO), antigenSlug: "tetanus" }, + }); +} + async function makeFamilyHistory(userId: string) { return getPrismaClient().familyHistoryEntry.create({ data: { userId, relationship: "MOTHER", condition: "Hypertension" }, @@ -1439,6 +1453,109 @@ manageContract("POST /api/practitioners/[id]/restore", { ?.deletedAt === null, }); +writeEffectContract("POST /api/vaccinations", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => entry.route === "/api/vaccinations" && entry.action === "POST", + )!, + prepare: async (ownerId) => ({ ownerId }), + act: async () => { + const { POST } = await import("@/app/api/vaccinations/route"); + return call(POST as Handler, "POST", "/api/vaccinations", { + occurredAt: ISO, + antigenSlug: "tetanus", + }); + }, + ok: 201, + auditAction: "vaccination.record.create", + applied: async ({ ownerId }) => + (await getPrismaClient().vaccinationRecord.count({ + where: { userId: ownerId }, + })) === 1, +}); + +manageContract("PATCH /api/vaccinations/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/vaccinations/[id]" && entry.action === "PATCH", + )!, + prepare: (ownerId) => makeVaccination(ownerId), + act: async (row) => { + const { PATCH } = await import("@/app/api/vaccinations/[id]/route"); + return call( + PATCH as Handler, + "PATCH", + `/api/vaccinations/${row.id}`, + { lotNumber: "MX-2211" }, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "vaccination.record.update", + applied: async (row) => + ( + await getPrismaClient().vaccinationRecord.findUnique({ + where: { id: row.id }, + }) + )?.lotNumber === "MX-2211", +}); + +manageContract("DELETE /api/vaccinations/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/vaccinations/[id]" && entry.action === "DELETE", + )!, + prepare: (ownerId) => makeVaccination(ownerId), + act: async (row) => { + const { DELETE } = await import("@/app/api/vaccinations/[id]/route"); + return call( + DELETE as Handler, + "DELETE", + `/api/vaccinations/${row.id}`, + undefined, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "vaccination.record.delete", + applied: async (row) => + ( + await getPrismaClient().vaccinationRecord.findUnique({ + where: { id: row.id }, + }) + )?.deletedAt !== null, +}); + +manageContract("POST /api/vaccinations/[id]/restore", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => entry.route === "/api/vaccinations/[id]/restore", + )!, + prepare: async (ownerId) => { + const row = await makeVaccination(ownerId); + return getPrismaClient().vaccinationRecord.update({ + where: { id: row.id }, + data: { deletedAt: new Date() }, + }); + }, + act: async (row) => { + const { POST } = await import("@/app/api/vaccinations/[id]/restore/route"); + return call( + POST as Handler, + "POST", + `/api/vaccinations/${row.id}/restore`, + {}, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "vaccination.record.restore", + applied: async (row) => + ( + await getPrismaClient().vaccinationRecord.findUnique({ + where: { id: row.id }, + }) + )?.deletedAt === null, +}); + manageContract("POST /api/illness/episodes/[id]/day-logs", { contract: ADMITTED_MUTATING_HANDLERS.find( (entry) => @@ -2379,13 +2496,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(70); + expect(STRICT_DRIVER_KEYS.size).toBe(74); 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(70); + expect(REAL_EFFECT_KEYS.size).toBe(74); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); }); }); diff --git a/tests/integration/vaccination-booster-satisfy.test.ts b/tests/integration/vaccination-booster-satisfy.test.ts new file mode 100644 index 000000000..8de0be3ef --- /dev/null +++ b/tests/integration/vaccination-booster-satisfy.test.ts @@ -0,0 +1,362 @@ +/** + * Logging a dose clears the boosters it answers — through the real route, real + * Postgres, real reminder engine. + * + * Every property here is about ROWS: which `lastSatisfiedAt` moved, which did + * not, and whether a reminder nobody keyed to an antigen was touched. None of + * them can be answered by a mocked client, and the one that matters most is a + * negative: a fake that ignored the `where` would report the ordinary Vorsorge + * reminder as untouched while having satisfied it. + * + * The combination case is the reason this file exists. A Tdap dose has to + * clear a tetanus booster AND a pertussis booster in one call, because a + * reminder is keyed by an antigen and a dose is recorded as whatever + * preparation the person was given. Matching the dose's own slug would leave + * the tetanus booster standing. + * + * Fixture dates are relative. A fixed calendar date in a test about "older + * than" and "moves forward" rots the moment it slides out of the window it + * was written inside. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { POST as createVaccination } from "@/app/api/vaccinations/route"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, 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 OWNER_ID = "booster-owner"; +const DAY_MS = 24 * 60 * 60 * 1000; + +function daysAgo(days: number): Date { + return new Date(Date.now() - days * DAY_MS); +} + +function daysFromNow(days: number): Date { + return new Date(Date.now() + days * DAY_MS); +} + +async function seedSession() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "booster-owner", + email: "booster-owner@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: daysFromNow(7), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +/** + * A booster reminder as the mint will write it: an ordinary ten-year VORSORGE + * row that names the antigen a dose would settle. + */ +async function seedBooster( + antigen: string | null, + overrides: { lastSatisfiedAt?: Date | null; label?: string } = {}, +) { + return getPrismaClient().measurementReminder.create({ + data: { + userId: OWNER_ID, + label: overrides.label ?? `${antigen ?? "generic"} booster`, + intervalDays: 3650, + notifyHour: 9, + vaccinationAntigen: antigen, + lastSatisfiedAt: overrides.lastSatisfiedAt ?? null, + nextDueAt: daysAgo(1), + }, + }); +} + +function post(body: unknown): Promise { + return createVaccination( + new Request("http://localhost/api/vaccinations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as never, + ); +} + +async function reminderById(id: string) { + return getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedSession(); +}); + +describe("a dose satisfies the boosters it answers", () => { + it("clears a monovalent antigen's own booster and re-anchors it", async () => { + const reminder = await seedBooster("tetanus"); + const occurredAt = daysAgo(2); + + const res = await post({ + occurredAt: occurredAt.toISOString(), + antigenSlug: "tetanus", + }); + expect(res.status, JSON.stringify(await res.clone().json())).toBe(201); + + const after = await reminderById(reminder.id); + expect(after.lastSatisfiedAt?.toISOString()).toBe(occurredAt.toISOString()); + // Re-anchored from the satisfy instant, so the next one lands roughly ten + // years out rather than staying on the date it was already overdue on. + expect(after.nextDueAt).not.toBeNull(); + expect(after.nextDueAt!.getTime()).toBeGreaterThan(Date.now()); + }); + + it("records the satisfied reminder on the dose", async () => { + const reminder = await seedBooster("tetanus"); + const res = await post({ + occurredAt: daysAgo(2).toISOString(), + antigenSlug: "tetanus", + }); + const payload = (await res.json()) as { + data: { id: string; reminderId: string | null }; + }; + expect(payload.data.reminderId).toBe(reminder.id); + }); + + it("clears a tetanus AND a pertussis booster from one Tdap dose", async () => { + // The property the whole component axis exists for. A matcher keyed on the + // record's own slug would find neither of these. + const tetanus = await seedBooster("tetanus"); + const pertussis = await seedBooster("pertussis"); + const occurredAt = daysAgo(3); + + const res = await post({ + occurredAt: occurredAt.toISOString(), + antigenSlug: "tdap", + }); + expect(res.status).toBe(201); + + expect((await reminderById(tetanus.id)).lastSatisfiedAt).not.toBeNull(); + expect((await reminderById(pertussis.id)).lastSatisfiedAt).not.toBeNull(); + }); + + it("clears every component of a six-antigen infant dose", async () => { + const reminders = await Promise.all( + ["diphtheria", "tetanus", "pertussis", "polio", "hib", "hepatitis-b"].map( + (antigen) => seedBooster(antigen), + ), + ); + + const res = await post({ + occurredAt: daysAgo(1).toISOString(), + antigenSlug: "hexavalent", + }); + expect(res.status).toBe(201); + + const after = await Promise.all( + reminders.map((reminder) => reminderById(reminder.id)), + ); + expect(after.filter((row) => row.lastSatisfiedAt !== null)).toHaveLength(6); + }); +}); + +describe("what a dose must never touch", () => { + it("leaves an ordinary Vorsorge reminder alone", async () => { + // No antigen key at all — the blood-panel reminder somebody set years ago. + // A matcher whose `where` did not hold would clear this and nothing in the + // response would say so. + const ordinary = await seedBooster(null, { label: "Blood panel" }); + + const res = await post({ + occurredAt: daysAgo(1).toISOString(), + antigenSlug: "tetanus", + }); + expect(res.status).toBe(201); + + const after = await reminderById(ordinary.id); + expect(after.lastSatisfiedAt).toBeNull(); + expect(after.nextDueAt?.toISOString()).toBe( + ordinary.nextDueAt?.toISOString(), + ); + }); + + it("leaves a booster for an antigen the dose does not contain", async () => { + const measles = await seedBooster("measles"); + + const res = await post({ + occurredAt: daysAgo(1).toISOString(), + antigenSlug: "tdap", + }); + expect(res.status).toBe(201); + expect((await reminderById(measles.id)).lastSatisfiedAt).toBeNull(); + }); + + it("matches nothing at all for a free-text-only dose", async () => { + // A name string is not evidence about which antigens were in the syringe. + const tetanus = await seedBooster("tetanus"); + + const res = await post({ + occurredAt: daysAgo(1).toISOString(), + vaccineName: "Tetanus, as the 1987 Pass wrote it", + }); + expect(res.status).toBe(201); + + const payload = (await res.json()) as { + data: { reminderId: string | null }; + }; + expect(payload.data.reminderId).toBeNull(); + expect((await reminderById(tetanus.id)).lastSatisfiedAt).toBeNull(); + }); + + it("leaves another account's booster alone", async () => { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: "booster-stranger", + username: "booster-stranger", + email: "booster-stranger@example.test", + }, + }); + const foreign = await prisma.measurementReminder.create({ + data: { + userId: "booster-stranger", + label: "tetanus booster", + intervalDays: 3650, + vaccinationAntigen: "tetanus", + nextDueAt: daysAgo(1), + }, + }); + + const res = await post({ + occurredAt: daysAgo(1).toISOString(), + antigenSlug: "tetanus", + }); + expect(res.status).toBe(201); + expect((await reminderById(foreign.id)).lastSatisfiedAt).toBeNull(); + }); + + it("leaves a soft-deleted booster alone", async () => { + const deleted = await seedBooster("tetanus"); + await getPrismaClient().measurementReminder.update({ + where: { id: deleted.id }, + data: { deletedAt: new Date() }, + }); + + const res = await post({ + occurredAt: daysAgo(1).toISOString(), + antigenSlug: "tetanus", + }); + expect(res.status).toBe(201); + expect((await reminderById(deleted.id)).lastSatisfiedAt).toBeNull(); + }); +}); + +describe("the two properties the engine brings with it", () => { + it("is forward-only: a backdated transcription never clears a due booster", async () => { + // The property that makes bulk back-entry of an old Pass safe. The + // reminder was satisfied a month ago; a page from 1987 must not move it. + const satisfiedAt = daysAgo(30); + const reminder = await seedBooster("tetanus", { + lastSatisfiedAt: satisfiedAt, + }); + + const res = await post({ + occurredAt: daysAgo(14000).toISOString(), + antigenSlug: "tetanus", + }); + expect(res.status).toBe(201); + + const after = await reminderById(reminder.id); + expect(after.lastSatisfiedAt?.toISOString()).toBe( + satisfiedAt.toISOString(), + ); + // And the record says so: no reminder was satisfied, so none is recorded. + const payload = (await res.json()) as { + data: { reminderId: string | null }; + }; + expect(payload.data.reminderId).toBeNull(); + }); + + it("is idempotent: logging the same dose twice moves nothing the second time", async () => { + const reminder = await seedBooster("tetanus"); + const occurredAt = daysAgo(5); + const bodyIn = { + occurredAt: occurredAt.toISOString(), + antigenSlug: "tetanus", + }; + + expect((await post(bodyIn)).status).toBe(201); + const afterFirst = await reminderById(reminder.id); + + // A duplicate entry is still a valid dose — two doses on one day happen — + // so it returns 201. What must not happen is the reminder advancing again. + const second = await post(bodyIn); + expect(second.status).toBe(201); + + const afterSecond = await reminderById(reminder.id); + expect(afterSecond.lastSatisfiedAt?.toISOString()).toBe( + afterFirst.lastSatisfiedAt?.toISOString(), + ); + expect(afterSecond.nextDueAt?.toISOString()).toBe( + afterFirst.nextDueAt?.toISOString(), + ); + const payload = (await second.json()) as { + data: { reminderId: string | null }; + }; + expect( + payload.data.reminderId, + "the second dose satisfied nothing, so it records nothing", + ).toBeNull(); + }); + + it("leaves a reminder due when the dose is too old to have settled it", async () => { + // Re-anchoring from an old instant can land the next due date in the past, + // and that is correct: an old dose does not silence a real gap. + const reminder = await seedBooster("tetanus"); + + const res = await post({ + occurredAt: daysAgo(5000).toISOString(), + antigenSlug: "tetanus", + }); + expect(res.status).toBe(201); + + const after = await reminderById(reminder.id); + expect(after.lastSatisfiedAt).not.toBeNull(); + expect( + after.nextDueAt!.getTime(), + "a dose from fourteen years ago on a ten-year cadence is still overdue", + ).toBeLessThan(Date.now()); + }); +});