From b97988279e6157769cfede888790f18838b62c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:43:59 +0200 Subject: [PATCH] feat(vaccinations): wire the query keys, the contract and the consent copy - a query-key module and a dependent bundle that also evicts the preventive-care root, because logging a dose settles a booster and the checkups list goes stale in the same tick - the OpenAPI module publishes the resolved series with the contract written into the description: render text from these numbers, never recompute them - the health-background consent sentence now names the immunization history in all six languages, so the widening is stated where it is agreed to rather than inferred from a code comment - no server-side cache helper: nothing cached reads a dose, and a sweep over caches nobody reads would look like coverage without being it --- docs/api/openapi.yaml | 898 ++++++++++++++++++++++++- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/lib/openapi/routes/index.ts | 2 + src/lib/openapi/routes/vaccinations.ts | 367 ++++++++++ src/lib/query-keys/index.ts | 16 + src/lib/query-keys/vaccinations.ts | 42 ++ src/lib/sharing/scope.ts | 12 +- 12 files changed, 1309 insertions(+), 40 deletions(-) create mode 100644 src/lib/openapi/routes/vaccinations.ts create mode 100644 src/lib/query-keys/vaccinations.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 33fb729bd..9b4beb85e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -10322,6 +10322,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: @@ -10447,7 +10863,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: @@ -10526,7 +10942,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 delete: @@ -10550,7 +10966,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes/{id}/restore: @@ -10577,7 +10993,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes/{id}/resolve: @@ -10614,7 +11030,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes/{id}/day-logs: @@ -10665,7 +11081,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 post: @@ -10745,7 +11161,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/episodes/{id}/correlation: @@ -10772,7 +11188,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a18 + "404": *a19 "422": *a3 "429": *a4 /api/illness/insights: @@ -11045,7 +11461,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: @@ -11128,7 +11544,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 delete: @@ -11152,7 +11568,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a20 "422": *a3 "429": *a4 /api/family-history: @@ -11260,7 +11676,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: @@ -11329,7 +11745,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a20 + "404": *a21 "422": *a3 "429": *a4 delete: @@ -11353,7 +11769,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a20 + "404": *a21 "422": *a3 "429": *a4 /api/documents/inbound: @@ -12473,7 +12889,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: @@ -12502,7 +12918,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: @@ -12566,8 +12982,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a21 - "409": *a22 + "404": *a22 + "409": *a23 "422": *a3 "429": *a4 delete: @@ -12590,7 +13006,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries: @@ -12634,7 +13050,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a6 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 post: @@ -12678,7 +13094,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a21 + "404": *a22 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries/{entryId}: @@ -12728,7 +13144,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: @@ -12760,7 +13176,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a23 + "404": *a24 "422": *a3 "429": *a4 /api/onboarding/disclaimer: @@ -12930,7 +13346,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 @@ -12985,7 +13401,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a24 + "403": *a25 "422": *a3 "429": *a4 /api/nutrients/water: @@ -13012,7 +13428,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a24 + "403": *a25 "422": *a3 "429": *a4 /api/integrations/healthkit: @@ -13350,7 +13766,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 @@ -13375,7 +13791,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "422": *a3 "429": *a4 post: @@ -13470,7 +13886,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "404": description: No such invitation for this account. content: @@ -13514,7 +13930,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "404": description: No such grant on this account's record. content: @@ -13555,7 +13971,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a25 + "403": *a26 "404": description: No such grant held by this account. content: @@ -13854,7 +14270,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 — @@ -15458,6 +15874,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 @@ -25707,6 +26125,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 @@ -25726,6 +26147,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`; @@ -35163,6 +35585,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 13b0a7861..b24de7b7d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10031,7 +10031,7 @@ }, "profile": { "label": "Gesundheitliche Vorgeschichte", - "detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil sowie Arztbesuche und Praxen." + "detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil, Arztbesuche und Praxen sowie deine Impfungen." }, "illness": { "label": "Krankheiten", diff --git a/messages/en.json b/messages/en.json index 585d15d14..72a715e43 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10031,7 +10031,7 @@ }, "profile": { "label": "Health background", - "detail": "Allergies, family history, the facts in your health profile, and your visits and practices." + "detail": "Allergies, family history, the facts in your health profile, your visits and practices, and your vaccination record." }, "illness": { "label": "Illness", diff --git a/messages/es.json b/messages/es.json index a966a48d7..a82f180c6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10031,7 +10031,7 @@ }, "profile": { "label": "Antecedentes de salud", - "detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud y tus visitas médicas y consultas." + "detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud, tus visitas médicas y consultas, y tu cartilla de vacunación." }, "illness": { "label": "Enfermedades", diff --git a/messages/fr.json b/messages/fr.json index e2c24c74b..ff2c3a59e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10031,7 +10031,7 @@ }, "profile": { "label": "Antécédents de santé", - "detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, ainsi que vos consultations et cabinets." + "detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, vos consultations et cabinets, ainsi que votre carnet de vaccination." }, "illness": { "label": "Maladies", diff --git a/messages/it.json b/messages/it.json index 527bda760..6369b1b0f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10031,7 +10031,7 @@ }, "profile": { "label": "Anamnesi", - "detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario e le tue visite mediche e studi." + "detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario, le tue visite mediche e studi, e il tuo libretto vaccinale." }, "illness": { "label": "Malattie", diff --git a/messages/pl.json b/messages/pl.json index f1b6534d1..4e6d99d20 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10031,7 +10031,7 @@ }, "profile": { "label": "Historia zdrowia", - "detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego oraz wizyty lekarskie i gabinety." + "detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego, wizyty lekarskie i gabinety oraz Twoja historia szczepień." }, "illness": { "label": "Choroby", 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 ed88535ff..e4f08f7c6 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"; @@ -252,6 +254,20 @@ export const encounterDependentKeys = [ queryKeys.practitioners(), ]; +/** + * Keys invalidated when a dose changes. + * + * The preventive-care root rides along, and it is the whole reason this is a + * bundle rather than one key: logging a dose satisfies the booster reminders + * it answers, so the checkups list is stale the moment the dose saves. + * Evicting only the immunization list would leave a booster on screen that + * the server had already moved a decade out. + */ +export const vaccinationDependentKeys = [ + queryKeys.vaccinations(), + queryKeys.measurementReminders(), +]; + /** * Invalidate every key in the bundle in parallel. Use this from mutation * `onSuccess` handlers so the call site stays a one-liner instead of repeating diff --git a/src/lib/query-keys/vaccinations.ts b/src/lib/query-keys/vaccinations.ts new file mode 100644 index 000000000..60d8c10b3 --- /dev/null +++ b/src/lib/query-keys/vaccinations.ts @@ -0,0 +1,42 @@ +/** + * Query keys — the immunization log. + * Part of the centralized factory; aggregated in `./index.ts`. + * + * These exist before any client reads them, because the in-repo + * `healthlog/queryKey-factory` rule errors on a bare array: the first + * component that lists doses cannot compile without a slot here. Same key with + * a different `queryFn` shape silently poisons the cache, which is the whole + * reason the factory is the only legal source. + * + * There is deliberately no matching `invalidateUserVaccinations` in + * `src/lib/cache/invalidate.ts`, for the same reason the visits module next + * door has none. That module evicts SERVER-side cached payloads, and no cached + * payload reads a dose: the dashboard snapshot, the insights targets and the + * analytics buckets are computed from measurements, medications and mood. + * Adding a sweep over caches nothing reads would be a placeholder that looks + * like coverage. It belongs in the release that puts doses into one of those + * payloads, alongside the reader that makes it necessary. + * + * The one server-side cache a dose CAN move belongs to somebody else: logging + * one satisfies a booster reminder, which is a preventive-care write. That + * eviction is the reminder module's, and the client bundle below invalidates + * the reminder keys for the same reason. + */ +export const vaccinationKeys = { + /** + * The root prefix every dose write invalidates through + * (`vaccinationDependentKeys` in `./index.ts`). TanStack's hierarchical + * prefix semantics mean evicting `["vaccinations"]` clears the list and + * every open detail in one tick, so a dose edited in a sheet and the list + * behind it never disagree. + */ + vaccinations: () => ["vaccinations"] as const, + /** + * The list. The filter is part of the key: a page showing one antigen's + * history and the page showing everything are two different answers rather + * than one answer that overwrites the other. + */ + vaccinationList: (antigenSlug?: string | null) => + ["vaccinations", "list", antigenSlug ?? null] as const, + vaccination: (id: string) => ["vaccinations", "detail", id] as const, +} as const; 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. */