diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e5b232871..096d6ea96 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -11147,13 +11147,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": &a32 - description: "Not implemented yet: the contract is published ahead of the implementation on a release branch. - `meta.errorCode: not_implemented`." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" "503": description: No background worker is bound, so nothing would run it. content: @@ -15061,7 +15054,7 @@ paths: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: - "202": &a37 + "202": &a38 description: Queued for the background worker. Every refusal the route can answer quickly (capability, provider, rate limit, budget, validation) has already been answered; anything that goes wrong from here is reported on the run. @@ -15823,8 +15816,8 @@ paths: tags: - Records summary: List the record's own vaccines - description: Vaccine definitions the record's owner added for products the shipped catalogue does not list. Soft-deleted - definitions are excluded. + description: The vaccine definitions the record's owner added for products the shipped catalogue does not list, by name. + Removed definitions are excluded. Readable under a `profile` grant like the dose list. responses: "200": description: The definitions, by name. @@ -15833,15 +15826,17 @@ paths: schema: $ref: "#/components/schemas/ListCustomVaccinesEnvelope" "401": *a1 + "403": *a11 "422": *a4 "429": *a5 - "501": *a32 post: tags: - Records summary: Add an own vaccine - description: Defines a vaccine in the same shape a catalogue entry resolves to, so series and booster logic treat both - alike. Names are unique per record. + description: Defines a vaccine in the same shape a catalogue entry resolves to, so the series and booster logic treat + both alike. Names are unique per record, compared without case; adding a name the person removed earlier brings + that definition back with the new values (its doses were let go on removal and stay so). Audits as + `vaccination.custom.create`. requestBody: required: true content: @@ -15856,21 +15851,23 @@ paths: schema: $ref: "#/components/schemas/CreateCustomVaccineEnvelope" "401": *a1 - "409": - description: A live definition with this name already exists. + "403": *a11 + "409": &a32 + description: A live definition on the record already carries this name, compared without case + (`vaccination.custom.name-taken`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a12 - "501": *a32 /api/vaccinations/custom/{id}: patch: tags: - Records summary: Edit an own vaccine - description: Changes one definition. Owner-scoped; an unknown id 404s. + description: Changes one definition; every dose logged against it reads the change. Owner-scoped; an unknown or removed + id 404s. Audits as `vaccination.custom.update`, naming a rename without quoting it. parameters: - in: path name: id @@ -15891,26 +15888,23 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomVaccineEnvelope" "401": *a1 - "404": - description: No such definition on this record. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "409": - description: A live definition with this name already exists. + "403": *a11 + "404": &a33 + description: No live definition with this id on the record (`vaccination.custom.not-found`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a32 "422": *a4 "429": *a5 - "501": *a32 delete: tags: - Records summary: Remove an own vaccine - description: Soft-deletes one definition. Doses logged against it stay, on their `vaccineName`. + description: "Soft-deletes one definition and lets go of every dose that named it. A dose keeps a name: one without + wording of its own takes the definition's name as its `vaccineName` first. Removing twice succeeds. Audits as + `vaccination.custom.delete` with the number of doses let go." parameters: - in: path name: id @@ -15925,15 +15919,10 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomVaccineEnvelope" "401": *a1 - "404": - description: No such definition on this record. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a11 + "404": *a33 "422": *a4 "429": *a5 - "501": *a32 /api/vaccinations: get: tags: @@ -16002,6 +15991,13 @@ paths: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 "403": *a11 + "404": + description: A practice, visit or own vaccine the body names is not the record's (`vaccination.practitioner-not-found`, + `vaccination.encounter-not-found`, `vaccination.custom-vaccine-not-found`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 "429": *a12 @@ -16026,7 +16022,7 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a11 - "404": &a33 + "404": &a34 description: Vaccination not found (or owned by another user). content: application/json: @@ -16061,7 +16057,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -16086,7 +16082,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -16111,7 +16107,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -16177,7 +16173,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -16235,7 +16231,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -16264,7 +16260,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a33 + "404": *a34 "422": *a4 "429": *a5 /api/illness/episodes: @@ -16348,7 +16344,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": &a34 + "404": &a35 description: Illness episode not found (or owned by another user). content: application/json: @@ -16383,7 +16379,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -16407,7 +16403,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -16435,7 +16431,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "409": *a7 "422": *a4 "429": *a5 @@ -16468,7 +16464,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -16519,7 +16515,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 post: @@ -16556,7 +16552,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -16583,7 +16579,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/illness/insights: @@ -16799,7 +16795,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a11 - "404": &a35 + "404": &a36 description: Allergy not found (or owned by another user). content: application/json: @@ -16833,7 +16829,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 delete: @@ -16857,7 +16853,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a36 "422": *a4 "429": *a5 /api/family-history: @@ -16931,7 +16927,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": &a36 + "404": &a37 description: Family history entry not found (or owned by another user). content: application/json: @@ -16965,7 +16961,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a37 "422": *a4 "429": *a5 delete: @@ -16989,7 +16985,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": *a36 + "404": *a37 "422": *a4 "429": *a5 /api/documents/inbound: @@ -17614,7 +17610,7 @@ paths: required: true schema: type: string - - &a38 + - &a39 name: Prefer in: header required: false @@ -17639,9 +17635,9 @@ paths: application/json: schema: $ref: "#/components/schemas/InboundExtractEnvelope" - "202": *a37 + "202": *a38 "401": *a1 - "403": &a39 + "403": &a40 description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master @@ -17665,7 +17661,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a40 + "503": &a41 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -17695,7 +17691,7 @@ paths: required: true schema: type: string - - *a38 + - *a39 requestBody: required: false content: @@ -17731,12 +17727,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" - "202": *a37 + "202": *a38 "401": *a1 - "403": *a39 + "403": *a40 "422": *a4 "429": *a5 - "503": *a40 + "503": *a41 /api/documents/inbound/{id}/summary: post: tags: @@ -17786,7 +17782,7 @@ paths: type: boolean default: false description: With `persist=true`, explicitly replace an existing stored summary. - - *a38 + - *a39 requestBody: required: false content: @@ -17822,12 +17818,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" - "202": *a37 + "202": *a38 "401": *a1 - "403": *a39 + "403": *a40 "422": *a4 "429": *a5 - "503": *a40 + "503": *a41 /api/documents/inbound/{id}/index: post: tags: @@ -17853,7 +17849,7 @@ paths: required: true schema: type: string - - *a38 + - *a39 requestBody: required: false content: @@ -17889,12 +17885,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" - "202": *a37 + "202": *a38 "401": *a1 - "403": *a39 + "403": *a40 "422": *a4 "429": *a5 - "503": *a40 + "503": *a41 /api/documents/inbound/{id}/chat: post: tags: @@ -17934,10 +17930,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a39 + "403": *a40 "422": *a4 "429": *a5 - "503": *a40 + "503": *a41 get: tags: - Documents @@ -18000,10 +17996,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a39 + "403": *a40 "422": *a4 "429": *a5 - "503": *a40 + "503": *a41 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -18110,7 +18106,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a42 + "409": &a43 description: A custom metric with this name already exists for the caller. content: application/json: @@ -18139,7 +18135,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a11 - "404": &a41 + "404": &a42 description: Custom metric not found (or owned by another user). content: application/json: @@ -18173,8 +18169,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a41 - "409": *a42 + "404": *a42 + "409": *a43 "422": *a4 "429": *a5 delete: @@ -18197,7 +18193,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a41 + "404": *a42 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -18241,7 +18237,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a11 - "404": *a41 + "404": *a42 "422": *a4 "429": *a5 post: @@ -18270,7 +18266,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a41 + "404": *a42 "409": *a7 "422": *a4 "429": *a12 @@ -18306,7 +18302,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a43 + "404": &a44 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -18339,7 +18335,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a43 + "404": *a44 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -18751,7 +18747,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a44 + "403": &a45 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 @@ -18806,7 +18802,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "422": *a4 "429": *a5 /api/nutrients/water: @@ -18835,7 +18831,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "409": *a7 "422": *a4 "429": *a5 @@ -19337,26 +19333,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a45 + "400": &a46 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a46 + "413": &a47 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a47 + "415": &a48 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a48 + "422": &a49 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -19542,11 +19538,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a45 + "400": *a46 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19689,11 +19685,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a45 + "400": *a46 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19846,11 +19842,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a45 + "400": *a46 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19926,21 +19922,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a49 + "422": &a50 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a50 + "429": &a51 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a51 + "502": &a52 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -19985,11 +19981,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a45 + "400": *a46 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -20065,9 +20061,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a50 + "429": *a51 + "502": *a52 /api/strava/credentials: get: tags: @@ -20103,11 +20099,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a45 + "400": *a46 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -20185,9 +20181,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/status: get: tags: @@ -20223,7 +20219,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a50 + "429": *a51 /api/integrations/fitbit/test: post: tags: @@ -20241,9 +20237,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/google-health/resume: post: tags: @@ -20260,7 +20256,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a50 + "429": *a51 /api/integrations/google-health/test: post: tags: @@ -20293,9 +20289,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/whoop/resume: post: tags: @@ -20312,7 +20308,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a50 + "429": *a51 /api/integrations/whoop/test: post: tags: @@ -20329,9 +20325,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/withings/resume: post: tags: @@ -20349,7 +20345,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a50 + "429": *a51 /api/integrations/withings/test: post: tags: @@ -20367,8 +20363,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 + "422": *a50 + "429": *a51 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -20770,7 +20766,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a52 + "403": &a53 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 @@ -20795,7 +20791,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a52 + "403": *a53 "422": *a4 "429": *a5 post: @@ -20897,7 +20893,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a53 "404": description: No such invitation for this account. content: @@ -20941,7 +20937,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a53 "404": description: No such grant on this account's record. content: @@ -20982,7 +20978,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a53 "404": description: No such grant held by this account. content: @@ -21364,7 +21360,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a52 + "403": *a53 "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 — @@ -21694,7 +21690,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a53 + "403": &a54 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -21727,7 +21723,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -21754,7 +21750,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "422": *a4 "429": *a5 post: @@ -21787,7 +21783,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "413": description: Body exceeds 16 KiB. content: @@ -21830,7 +21826,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a53 + "403": *a54 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -21888,7 +21884,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a54 + - &a55 name: family in: path required: true @@ -21932,7 +21928,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a54 + - *a55 requestBody: required: true content: @@ -22498,13 +22494,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a55 + "403": &a56 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a56 + "404": &a57 description: No stored copy with this id. content: application/json: @@ -22578,8 +22574,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a55 - "404": *a56 + "403": *a56 + "404": *a57 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -22623,7 +22619,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a55 + "403": *a56 /api/admin/backups/restores/{jobId}: get: tags: @@ -22646,7 +22642,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a55 + "403": *a56 "404": description: No restore job with this id. content: @@ -22665,7 +22661,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a57 + security: &a58 - cookieAuth: [] responses: "200": @@ -22675,7 +22671,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a58 + "403": &a59 description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the Documents module is off." @@ -22698,9 +22694,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - &a59 + - &a60 name: system in: path required: true @@ -22739,8 +22735,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a58 - "404": &a60 + "403": *a59 + "404": &a61 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -22759,7 +22755,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a61 + "502": &a62 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -22778,9 +22774,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: Removed, or there was nothing to remove. @@ -22789,7 +22785,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a58 + "403": *a59 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -22800,9 +22796,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: The source answered. @@ -22811,9 +22807,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": &a62 + "403": *a59 + "404": *a61 + "422": &a63 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -22822,7 +22818,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/tags: get: tags: @@ -22830,9 +22826,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: The tags. @@ -22841,11 +22837,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": *a62 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/search: get: tags: @@ -22859,9 +22855,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 - name: q in: query required: false @@ -22902,11 +22898,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": *a62 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/import: post: tags: @@ -22925,9 +22921,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 requestBody: required: true content: @@ -22985,7 +22981,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a58 + "403": *a59 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -23016,7 +23012,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a62 + "422": *a63 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -23024,7 +23020,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a61 + "502": *a62 /api/symptoms/definitions: get: tags: @@ -23048,7 +23044,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a63 + "403": &a64 description: >- `illness.disabled`: the record has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module. @@ -23091,7 +23087,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 + "403": *a64 "409": *a7 "422": *a4 "429": *a5 @@ -23121,8 +23117,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 - "404": &a64 + "403": *a64 + "404": &a65 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -23156,8 +23152,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 - "404": *a64 + "403": *a64 + "404": *a65 "422": *a4 "429": *a5 /api/symptoms/events: @@ -23206,7 +23202,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a63 + "403": *a64 "422": *a4 "429": *a5 post: @@ -23232,8 +23228,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": *a64 + "403": *a64 + "404": *a65 "409": *a7 "422": *a4 "429": *a5 @@ -23263,8 +23259,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": &a65 + "403": *a64 + "404": &a66 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -23291,8 +23287,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": *a65 + "403": *a64 + "404": *a66 "422": *a4 "429": *a5 /api/bugreport: @@ -30236,48 +30232,115 @@ components: properties: name: type: string + minLength: 1 + maxLength: 100 components: + minItems: 1 + maxItems: 26 type: array items: type: string + enum: + - 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 typicalSeriesDoses: anyOf: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 + minimum: 1 + maximum: 10 - type: "null" boosterIntervalMonths: anyOf: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 + minimum: 1 + maximum: 600 - type: "null" required: - name - components additionalProperties: false + description: A new definition. `components` lists one to all of the catalogue's antigen slugs, each once — a disease + outside that list is logged as a free-text dose instead. `typicalSeriesDoses` (1–10) and `boosterIntervalMonths` + (1–600) are optional; the interval prefills the booster offer after a dose. UpdateCustomVaccineRequest: type: object properties: name: type: string + minLength: 1 + maxLength: 100 components: + minItems: 1 + maxItems: 26 type: array items: type: string + enum: + - 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 typicalSeriesDoses: anyOf: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 + minimum: 1 + maximum: 10 - type: "null" boosterIntervalMonths: anyOf: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 + minimum: 1 + maximum: 600 - type: "null" additionalProperties: false + description: Partial edit; an omitted key is left untouched and an empty body is a 422. The change applies to every dose + logged against the definition, because the series is derived on read; it never re-runs booster satisfaction. CreateVaccinationRequest: type: object properties: @@ -30339,6 +30402,12 @@ components: minLength: 1 maxLength: 40 - type: "null" + customVaccineId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" note: anyOf: - type: string @@ -30355,12 +30424,15 @@ components: - occurredAt additionalProperties: false 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." + recorded after the fact, and a planned booster is a reminder rather than a record. At least one identity arm is + required: `antigenSlug` (a slug the catalogue offers), `customVaccineId` (one of the record's own vaccine + definitions, see `/api/vaccinations/custom`; an id the record does not hold live is a 404 + `vaccination.custom-vaccine-not-found`) or `vaccineName` (whatever the person's own record says, verbatim, which + may be a trade name). When both a slug and a definition are given, the catalogue is the answer. Everything else + is optional, so a decades-old paper entry with nothing but a date and a name is loggable. `note` is encrypted at + rest. `documentIds` pre-links the scanned pages; an id naming nothing the caller owns is dropped rather than + refused, so a link never blocks a save. Logging a dose satisfies any booster reminder keyed to one of its + component antigens, the antigens of an own definition included." UpdateVaccinationRequest: type: object properties: @@ -30422,6 +30494,12 @@ components: minLength: 1 maxLength: 40 - type: "null" + customVaccineId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" note: anyOf: - type: string @@ -30435,11 +30513,11 @@ components: minLength: 1 maxLength: 40 additionalProperties: false - description: Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. + 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. + leave the record with no identity arm (slug, own definition or name) is refused. `customVaccineId: null` unlinks + the dose from its own definition. A present `documentIds` array replaces the links, empty array included." VaccinationBoosterRequest: type: object properties: @@ -33817,16 +33895,18 @@ components: symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; tokens — tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid, - vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found, - vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, - vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, - workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) - — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, + vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, + vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found, + vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, + vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — + workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, + workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, + ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, - modules_conflict, mood_tag_layout_conflict, not_configured, not_implemented, - notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable, - rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" + modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, + private_origin_not_approved, private_origin_not_grantable, rate_limited_self, too_many_rows, + unreadable_json, upstream_invalid_json, vapid_not_configured" type: string capability: description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, @@ -39774,6 +39854,51 @@ components: - targetId - createdAt additionalProperties: {} + customVaccines: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + components: + type: array + items: + type: string + typicalSeriesDoses: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + boosterIntervalMonths: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + deletedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + required: + - id + - name + - createdAt + - updatedAt + additionalProperties: {} vaccinations: default: [] type: array @@ -41102,6 +41227,7 @@ components: - encounterDocumentLinks - encounterLabLinks - encounterConditionLinks + - customVaccines - vaccinations - vaccinationDocumentLinks - coachConversations @@ -69218,7 +69344,7 @@ components: type: array items: type: string - description: Catalogue antigen slugs the product covers. + description: Catalogue antigen slugs the product protects against, only ones this release knows. typicalSeriesDoses: anyOf: - type: integer @@ -69248,6 +69374,10 @@ components: - createdAt - updatedAt additionalProperties: false + description: A vaccine the record's owner defined for a product the shipped catalogue does not list, in the shape a + catalogue entry resolves to. A dose logged against it counts into the series of each listed antigen, settles a + booster reminder keyed to one of them and offers one from `boosterIntervalMonths`, exactly as a catalogue pick + does. `name` is the person's own wording. CreateCustomVaccineEnvelope: type: object properties: @@ -69391,6 +69521,17 @@ components: anyOf: - $ref: "#/components/schemas/VaccinationCatalogEntry" - type: "null" + customVaccineId: + anyOf: + - type: string + - type: "null" + description: The record's own vaccine definition the dose names (v1.42), verbatim. + customVaccine: + anyOf: + - $ref: "#/components/schemas/CustomVaccine" + - type: "null" + description: That definition, resolved, or null when the dose names none or it was removed. When `catalogEntry` is also + set, the catalogue is the answer. series: type: array items: @@ -69429,6 +69570,8 @@ components: - lotNumber - site - catalogEntry + - customVaccineId + - customVaccine - series - practitioner - encounter diff --git a/docs/integrations/health-connect.md b/docs/integrations/health-connect.md index 5ebf70738..df370ea98 100644 --- a/docs/integrations/health-connect.md +++ b/docs/integrations/health-connect.md @@ -135,6 +135,12 @@ export again writes nothing; a newer export that overlaps an older one adds only what is new. A value that changed on the phone is updated; a value you deleted in HealthLog stays deleted. +Heart rate imported per minute ages the way Apple Health heart rate +does: once a minute is older than 90 days, the nightly pass folds its +hour into one hourly mean and removes the minutes. A later import of +an export that still holds those minutes leaves them out, so an hour +is never counted twice. + ## Privacy - The uploaded ZIP and the extracted database are deleted as soon as diff --git a/messages/de.json b/messages/de.json index 96f498f5a..9394fc7ba 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9639,8 +9639,8 @@ }, "form": { "date": "Datum", - "catalogLabel": "Impfstoff (Katalog)", - "identityHint": "Katalog wählen oder unten frei eintragen — eins genügt.", + "catalogLabel": "Impfstoff", + "identityHint": "Aus der Liste wählen oder unten frei eintragen. Eins genügt.", "catalogNone": "Impfstoff wählen", "catalogSearch": "Impfstoff suchen", "catalogNoMatch": "Kein Katalogeintrag gefunden", @@ -9772,7 +9772,15 @@ "deleted": "Impfstoff entfernt", "deleteConfirm": "Diesen Impfstoff entfernen? Bereits eingetragene Impfungen behalten ihren Namen.", "saveFailed": "Der Impfstoff konnte nicht gespeichert werden.", - "nameTaken": "Du hast schon einen Impfstoff mit diesem Namen." + "nameTaken": "Du hast schon einen Impfstoff mit diesem Namen.", + "editTitle": "Eigenen Impfstoff bearbeiten", + "description": "Ein Impfstoff, den der Katalog nicht kennt, gezählt wie jeder Katalogeintrag.", + "componentsHint": "Alle Krankheiten wählen, vor denen er schützt.", + "boosterHint": "Leer lassen, wenn keine Auffrischung nötig ist.", + "protectsAgainst": "Schützt vor {list}.", + "deleteTitle": "Eigenen Impfstoff entfernen?", + "sectionDescription": "Selbst angelegte Impfstoffe, die der Katalog nicht kennt.", + "catalogGroup": "Katalog" } }, "illness": { @@ -11814,7 +11822,10 @@ "symptomEventDelete": "{name} hat einen Symptom-Eintrag gelöscht", "symptomDefinitionCreate": "{name} hat ein Symptom angelegt", "symptomDefinitionUpdate": "{name} hat ein Symptom geändert", - "symptomDefinitionDelete": "{name} hat ein Symptom entfernt" + "symptomDefinitionDelete": "{name} hat ein Symptom entfernt", + "customVaccineCreate": "{name} hat einen eigenen Impfstoff angelegt", + "customVaccineUpdate": "{name} hat einen eigenen Impfstoff bearbeitet", + "customVaccineDelete": "{name} hat einen eigenen Impfstoff entfernt" }, "section": { "measurements": { diff --git a/messages/en.json b/messages/en.json index 66d175180..f0f0d0829 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9639,8 +9639,8 @@ }, "form": { "date": "Date", - "catalogLabel": "Vaccine (catalogue)", - "identityHint": "Pick from the catalogue or type it in below — either is enough.", + "catalogLabel": "Vaccine", + "identityHint": "Pick a vaccine from the list or type it in below. Either is enough.", "catalogNone": "Choose a vaccine", "catalogSearch": "Search vaccines", "catalogNoMatch": "No catalogue entry found", @@ -9772,7 +9772,15 @@ "deleted": "Vaccine removed", "deleteConfirm": "Remove this vaccine? Doses already logged keep their name.", "saveFailed": "The vaccine could not be saved.", - "nameTaken": "You already have a vaccine with this name." + "nameTaken": "You already have a vaccine with this name.", + "editTitle": "Edit own vaccine", + "description": "A vaccine the catalogue does not list, counted like any catalogue entry.", + "componentsHint": "Choose every disease it protects against.", + "boosterHint": "Leave empty if there is no booster.", + "protectsAgainst": "Protects against {list}.", + "deleteTitle": "Remove own vaccine?", + "sectionDescription": "Vaccines you added yourself because the catalogue does not list them.", + "catalogGroup": "Catalogue" } }, "illness": { @@ -11814,7 +11822,10 @@ "symptomEventDelete": "{name} deleted a symptom entry", "symptomDefinitionCreate": "{name} added a symptom", "symptomDefinitionUpdate": "{name} changed a symptom", - "symptomDefinitionDelete": "{name} removed a symptom" + "symptomDefinitionDelete": "{name} removed a symptom", + "customVaccineCreate": "{name} added an own vaccine", + "customVaccineUpdate": "{name} edited an own vaccine", + "customVaccineDelete": "{name} removed an own vaccine" }, "section": { "measurements": { diff --git a/messages/es.json b/messages/es.json index 174af5c61..9d3e45689 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9639,8 +9639,8 @@ }, "form": { "date": "Fecha", - "catalogLabel": "Vacuna (catálogo)", - "identityHint": "Elige del catálogo o escríbela abajo: basta con una.", + "catalogLabel": "Vacuna", + "identityHint": "Elige una vacuna de la lista o escríbela abajo. Basta con una.", "catalogNone": "Elegir una vacuna", "catalogSearch": "Buscar vacunas", "catalogNoMatch": "No se encontró ninguna entrada del catálogo", @@ -9772,7 +9772,15 @@ "deleted": "Vacuna eliminada", "deleteConfirm": "¿Eliminar esta vacuna? Las dosis ya registradas conservan su nombre.", "saveFailed": "No se pudo guardar la vacuna.", - "nameTaken": "Ya tienes una vacuna con este nombre." + "nameTaken": "Ya tienes una vacuna con este nombre.", + "editTitle": "Editar vacuna propia", + "description": "Una vacuna que el catálogo no recoge, contada como cualquier entrada del catálogo.", + "componentsHint": "Elige todas las enfermedades de las que protege.", + "boosterHint": "Déjalo vacío si no hay refuerzo.", + "protectsAgainst": "Protege contra {list}.", + "deleteTitle": "¿Quitar la vacuna propia?", + "sectionDescription": "Vacunas que añadiste tú porque el catálogo no las recoge.", + "catalogGroup": "Catálogo" } }, "illness": { @@ -11814,7 +11822,10 @@ "symptomEventDelete": "{name} eliminó una entrada de síntoma", "symptomDefinitionCreate": "{name} añadió un síntoma", "symptomDefinitionUpdate": "{name} cambió un síntoma", - "symptomDefinitionDelete": "{name} quitó un síntoma" + "symptomDefinitionDelete": "{name} quitó un síntoma", + "customVaccineCreate": "{name} añadió una vacuna propia", + "customVaccineUpdate": "{name} editó una vacuna propia", + "customVaccineDelete": "{name} quitó una vacuna propia" }, "section": { "measurements": { diff --git a/messages/fr.json b/messages/fr.json index 88791634c..f55ee1c3c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9639,8 +9639,8 @@ }, "form": { "date": "Date", - "catalogLabel": "Vaccin (catalogue)", - "identityHint": "Choisissez dans le catalogue ou saisissez ci-dessous : l'un suffit.", + "catalogLabel": "Vaccin", + "identityHint": "Choisissez un vaccin dans la liste ou saisissez-le ci-dessous. L'un suffit.", "catalogNone": "Choisir un vaccin", "catalogSearch": "Rechercher des vaccins", "catalogNoMatch": "Aucune entrée du catalogue trouvée", @@ -9772,7 +9772,15 @@ "deleted": "Vaccin supprimé", "deleteConfirm": "Supprimer ce vaccin ? Les doses déjà saisies gardent leur nom.", "saveFailed": "Le vaccin n’a pas pu être enregistré.", - "nameTaken": "Tu as déjà un vaccin portant ce nom." + "nameTaken": "Tu as déjà un vaccin portant ce nom.", + "editTitle": "Modifier le vaccin personnel", + "description": "Un vaccin absent du catalogue, compté comme n'importe quelle entrée du catalogue.", + "componentsHint": "Choisissez chaque maladie contre laquelle il protège.", + "boosterHint": "Laissez vide s'il n'y a pas de rappel.", + "protectsAgainst": "Protège contre {list}.", + "deleteTitle": "Retirer ce vaccin personnel ?", + "sectionDescription": "Les vaccins que vous avez ajoutés parce que le catalogue ne les connaît pas.", + "catalogGroup": "Catalogue" } }, "illness": { @@ -11814,7 +11822,10 @@ "symptomEventDelete": "{name} a supprimé une entrée de symptôme", "symptomDefinitionCreate": "{name} a ajouté un symptôme", "symptomDefinitionUpdate": "{name} a modifié un symptôme", - "symptomDefinitionDelete": "{name} a retiré un symptôme" + "symptomDefinitionDelete": "{name} a retiré un symptôme", + "customVaccineCreate": "{name} a ajouté un vaccin personnel", + "customVaccineUpdate": "{name} a modifié un vaccin personnel", + "customVaccineDelete": "{name} a retiré un vaccin personnel" }, "section": { "measurements": { diff --git a/messages/it.json b/messages/it.json index 5ca435d91..39b7cf5a3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9639,8 +9639,8 @@ }, "form": { "date": "Data", - "catalogLabel": "Vaccino (catalogo)", - "identityHint": "Scegli dal catalogo o scrivilo sotto: ne basta uno.", + "catalogLabel": "Vaccino", + "identityHint": "Scegli un vaccino dall'elenco o scrivilo sotto. Ne basta uno.", "catalogNone": "Scegli un vaccino", "catalogSearch": "Cerca vaccini", "catalogNoMatch": "Nessuna voce del catalogo trovata", @@ -9772,7 +9772,15 @@ "deleted": "Vaccino rimosso", "deleteConfirm": "Rimuovere questo vaccino? Le dosi già registrate mantengono il loro nome.", "saveFailed": "Impossibile salvare il vaccino.", - "nameTaken": "Hai già un vaccino con questo nome." + "nameTaken": "Hai già un vaccino con questo nome.", + "editTitle": "Modifica vaccino personale", + "description": "Un vaccino assente dal catalogo, contato come qualsiasi voce del catalogo.", + "componentsHint": "Scegli tutte le malattie da cui protegge.", + "boosterHint": "Lascia vuoto se non serve un richiamo.", + "protectsAgainst": "Protegge da {list}.", + "deleteTitle": "Rimuovere il vaccino personale?", + "sectionDescription": "Vaccini aggiunti da te perché il catalogo non li elenca.", + "catalogGroup": "Catalogo" } }, "illness": { @@ -11814,7 +11822,10 @@ "symptomEventDelete": "{name} ha eliminato una voce di sintomo", "symptomDefinitionCreate": "{name} ha aggiunto un sintomo", "symptomDefinitionUpdate": "{name} ha modificato un sintomo", - "symptomDefinitionDelete": "{name} ha rimosso un sintomo" + "symptomDefinitionDelete": "{name} ha rimosso un sintomo", + "customVaccineCreate": "{name} ha aggiunto un vaccino personale", + "customVaccineUpdate": "{name} ha modificato un vaccino personale", + "customVaccineDelete": "{name} ha rimosso un vaccino personale" }, "section": { "measurements": { diff --git a/messages/ko.json b/messages/ko.json index 25350e105..b07663267 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9639,7 +9639,7 @@ }, "form": { "date": "날짜", - "catalogLabel": "백신 (목록)", + "catalogLabel": "백신", "identityHint": "목록에서 고르거나 아래에 직접 입력해 주세요. 둘 중 하나면 충분해요.", "catalogNone": "백신 선택", "catalogSearch": "백신 검색", @@ -9772,7 +9772,15 @@ "deleted": "백신을 삭제했습니다", "deleteConfirm": "이 백신을 삭제할까요? 이미 기록된 접종은 이름이 그대로 남습니다.", "saveFailed": "백신을 저장하지 못했습니다.", - "nameTaken": "같은 이름의 백신이 이미 있습니다." + "nameTaken": "같은 이름의 백신이 이미 있습니다.", + "editTitle": "직접 추가한 백신 수정", + "description": "목록에 없는 백신도 목록의 백신과 똑같이 계산돼요.", + "componentsHint": "예방하는 질병을 모두 골라 주세요.", + "boosterHint": "추가 접종이 없으면 비워 두세요.", + "protectsAgainst": "{list} 예방", + "deleteTitle": "직접 추가한 백신을 삭제할까요?", + "sectionDescription": "목록에 없어서 직접 추가한 백신이에요.", + "catalogGroup": "목록" } }, "illness": { @@ -11814,7 +11822,10 @@ "symptomEventDelete": "{name}님이 증상 기록을 삭제했습니다", "symptomDefinitionCreate": "{name}님이 증상을 추가했습니다", "symptomDefinitionUpdate": "{name}님이 증상을 변경했습니다", - "symptomDefinitionDelete": "{name}님이 증상을 제거했습니다" + "symptomDefinitionDelete": "{name}님이 증상을 제거했습니다", + "customVaccineCreate": "{name} 님이 백신을 직접 추가했어요", + "customVaccineUpdate": "{name} 님이 직접 추가한 백신을 수정했어요", + "customVaccineDelete": "{name} 님이 직접 추가한 백신을 삭제했어요" }, "section": { "measurements": { diff --git a/messages/pl.json b/messages/pl.json index 6466d1944..4b4d268c2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9639,8 +9639,8 @@ }, "form": { "date": "Data", - "catalogLabel": "Szczepionka (katalog)", - "identityHint": "Wybierz z katalogu lub wpisz poniżej — wystarczy jedno.", + "catalogLabel": "Szczepionka", + "identityHint": "Wybierz szczepionkę z listy lub wpisz ją poniżej. Wystarczy jedno.", "catalogNone": "Wybierz szczepionkę", "catalogSearch": "Szukaj szczepionek", "catalogNoMatch": "Nie znaleziono wpisu w katalogu", @@ -9772,7 +9772,15 @@ "deleted": "Szczepionka usunięta", "deleteConfirm": "Usunąć tę szczepionkę? Zapisane już dawki zachowają swoją nazwę.", "saveFailed": "Nie udało się zapisać szczepionki.", - "nameTaken": "Masz już szczepionkę o tej nazwie." + "nameTaken": "Masz już szczepionkę o tej nazwie.", + "editTitle": "Edytuj własną szczepionkę", + "description": "Szczepionka spoza katalogu, liczona jak każdy wpis z katalogu.", + "componentsHint": "Wybierz wszystkie choroby, przed którymi chroni.", + "boosterHint": "Zostaw puste, jeśli nie ma dawki przypominającej.", + "protectsAgainst": "Chroni przed: {list}.", + "deleteTitle": "Usunąć własną szczepionkę?", + "sectionDescription": "Szczepionki dodane przez Ciebie, bo nie ma ich w katalogu.", + "catalogGroup": "Katalog" } }, "illness": { @@ -11814,7 +11822,10 @@ "symptomEventDelete": "{name} usunął(-ęła) wpis objawu", "symptomDefinitionCreate": "{name} dodał(a) objaw", "symptomDefinitionUpdate": "{name} zmienił(a) objaw", - "symptomDefinitionDelete": "{name} usunął(-ęła) objaw" + "symptomDefinitionDelete": "{name} usunął(-ęła) objaw", + "customVaccineCreate": "{name} dodał własną szczepionkę", + "customVaccineUpdate": "{name} zmienił własną szczepionkę", + "customVaccineDelete": "{name} usunął własną szczepionkę" }, "section": { "measurements": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 877ce6d27..f0ec538d7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9330,9 +9330,11 @@ model VaccinationRecord { /// `lastSatisfiedAt` per reminder, this keeps the first match in catalogue /// component order. reminderId String? @map("reminder_id") - /// @pending-consumer(#1005) v1.42 — the user's own vaccine definition when - /// the dose is for a product the catalogue does not list. Catalogue wins - /// when both resolve. SetNull on delete: the dose keeps its `vaccineName`. + /// v1.42 (#1005) — the user's own vaccine definition when the dose is for a + /// product the catalogue does not list, resolved through + /// `src/lib/vaccinations/resolve-vaccine-entry.ts`. Catalogue wins when both + /// resolve. Removing the definition unlinks the dose and leaves it a name + /// (`removeCustomVaccine`); SetNull covers a hard delete the same way. customVaccineId String? @map("custom_vaccine_id") /// The one free-text field (AES-256-GCM at rest, the `*Encrypted` Bytes /// convention). Impfreaktionen live here in v1 rather than in a structured @@ -9365,13 +9367,14 @@ model VaccinationRecord { model CustomVaccine { id String @id @default(cuid()) userId String @map("user_id") - /// @pending-consumer(#1005) Display name, unique per user. + /// Display name, unique per user (the API also refuses a case-only twin). name String - /// @pending-consumer(#1005) Antigen slugs the product covers. + /// Antigen slugs the product covers, from `ANTIGEN_SLUGS`; storage is + /// tolerant, the resolver skips a slug it does not know. components String[] - /// @pending-consumer(#1005) Typical primary-series length. + /// Typical primary-series length; the series total for its doses. typicalSeriesDoses Int? @map("typical_series_doses") - /// @pending-consumer(#1005) Booster interval in months; NULL = no booster. + /// Booster interval in months, the booster offer's prefill; NULL = none. boosterIntervalMonths Int? @map("booster_interval_months") /// Soft-delete tombstone. NULL = live. deletedAt DateTime? @map("deleted_at") diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index 27d9efc4e..edbc4eb8e 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -78,7 +78,6 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [ * before the release is tagged. It is NOT a place for keys nobody will wire. */ const RELEASE_PENDING_PREFIXES: Record = { - "vaccinations.custom": "v1.42 user-defined vaccines (#1005)", "labs.convertedFrom": "v1.42 lab unit conversion note (#1095)", }; diff --git a/src/__tests__/record-write-rate-limit-contract.test.ts b/src/__tests__/record-write-rate-limit-contract.test.ts index 55c327147..3330c46c4 100644 --- a/src/__tests__/record-write-rate-limit-contract.test.ts +++ b/src/__tests__/record-write-rate-limit-contract.test.ts @@ -58,6 +58,7 @@ const ON_THE_SHARED_BUCKET: Array<[route: string, openApiPath: string]> = [ ], ["src/app/api/cycle/day-logs/route.ts", "/api/cycle/day-logs"], ["src/app/api/vaccinations/route.ts", "/api/vaccinations"], + ["src/app/api/vaccinations/custom/route.ts", "/api/vaccinations/custom"], ["src/app/api/allergies/route.ts", "/api/allergies"], ["src/app/api/biomarkers/route.ts", "/api/biomarkers"], ["src/app/api/encounters/route.ts", "/api/encounters"], diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index e2177cc3e..d156dffeb 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -971,6 +971,14 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "Which of the record's doses a scanned page dated around a given day belongs to. A read over the same rows the immunization list serves, reduced to a verdict — the caller learns nothing it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.", }, + "app/api/vaccinations/custom/route.ts": { + domain: "profile", + why: "The record's own vaccine definitions: a name, the antigens from the catalogue's closed list, a series length and a booster interval. Part of the immunization history the dose list already shows a `profile` grant, and it names no credential, no integration and no notification channel. Every row is read and written under the resolved user id. The create arm is a delegable write.", + }, + "app/api/vaccinations/custom/[id]/route.ts": { + domain: "profile", + why: "One of the record's own vaccine definitions, fetch-then-guard against the resolved user. Reached only through its MANAGE arms; a definition another record holds is a 404 like any foreign id.", + }, "app/api/practitioners/route.ts": { domain: "profile", why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.", @@ -1457,6 +1465,8 @@ const DELEGABLE_WRITE_ROUTES: Record = { "Logging a dose. Every id the body may carry — the practitioner, the visit, the pages to file it against — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's scan to another's dose. The booster reminders it clears are the RECORD's, which is correct: it is the owner's booster plan, and a helper transcribing an Impfpass is doing exactly the work that should settle it.", "app/api/vaccinations/[id]/booster/route.ts": "Confirming the booster reminder a dose suggests. The reminder is minted under the RECORD and keyed on the antigen the server reads from the dose's catalogue entry, never from the body, so a delegate cannot point it at an antigen the dose does not contain; a second confirmation re-anchors the one reminder rather than minting another.", + "app/api/vaccinations/custom/route.ts": + "Adding a vaccine definition to the record. It lands under the RECORD, which is correct: it is the owner's Impfpass a helper is transcribing, and the doses logged against it are the owner's. The rate bucket keys on the ACTOR, and a name the record already holds is the ordinary 409 the owner would hit themselves.", "app/api/medications/[id]/side-effects/route.ts": "Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.", "app/api/medications/route.ts": @@ -1603,6 +1613,11 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "Reopening a deleted dose. The document links survived the tombstone, so it comes back with its page still filed against it.", }, + "app/api/vaccinations/custom/[id]/route.ts": { + domain: "profile", + conditions: ["C4"], + why: "Correcting and removing one vaccine definition. The removal soft-deletes it and lets go of the doses that named it, each keeping a name, so no dose leaves the record; the audit row names the definition and how many doses it let go. C4 on the edit: the antigens, the series length and the booster interval are filed before and after, and a rename is named, never quoted. Neither verb re-runs the booster satisfy matcher, so a delegate cannot move a booster's due date by correcting a definition.", + }, "app/api/illness/episodes/[id]/resolve/route.ts": { domain: "illness", conditions: [], @@ -2010,8 +2025,12 @@ const ACTOR_ROUTES: Record = { * list, the occurrence create on the write literal, and the definition create, * the definition edit/delete and the occurrence edit/delete on the manage * literal. 232 -> 242 with both. + * + * v1.42 -- the person's own vaccine definitions (#1005) add four: both + * modules on the record list, the create on the write literal and the + * edit/delete on the manage literal. 242 -> 246. */ -const FROZEN_ENTRY_COUNT = 242; +const FROZEN_ENTRY_COUNT = 246; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2772,7 +2791,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(84); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 860b45b45..82ba66592 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(84); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(87); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( 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 392263eff..2d5f00af6 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -50,6 +50,7 @@ vi.mock("@/lib/db", () => ({ encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the // same reason as the visit tables above. @@ -158,6 +159,7 @@ beforeEach(() => { [] as never, ); vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.customVaccine.findMany).mockResolvedValue([] as never); vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue( [] as never, ); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 0e04c6bba..2e859e00a 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -88,6 +88,7 @@ vi.mock("@/lib/db", () => ({ measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 8c01d3a7b..04294c2a3 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -42,6 +42,7 @@ vi.mock("@/lib/db", () => ({ encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the // same reason as the visit tables above. diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts index 1b85c023d..376016267 100644 --- a/src/app/api/vaccinations/[id]/route.ts +++ b/src/app/api/vaccinations/[id]/route.ts @@ -34,6 +34,7 @@ import { VACCINATION_INCLUDE, applyVaccinationLinks, loadVaccinationDocuments, + resolveOwnedCustomVaccine, resolveOwnedEncounter, resolveOwnedPractitioner, resolveSeriesFor, @@ -116,9 +117,13 @@ export const PATCH = apiHandler( entry.vaccineName !== undefined ? entry.vaccineName : existing.vaccineName; - if (!nextSlug && !nextName) { + const nextCustom = + entry.customVaccineId !== undefined + ? entry.customVaccineId + : existing.customVaccineId; + if (!nextSlug && !nextName && !nextCustom) { return apiError( - "A dose keeps a name: pick one from the catalogue or type what the record says", + "A dose keeps a name: pick one from the catalogue or your own vaccines, or type what the record says", 422, { errorCode: "vaccination.identity-required" }, ); @@ -141,6 +146,14 @@ export const PATCH = apiHandler( ); if (!owned) return "unknown-encounter" as const; } + if (entry.customVaccineId) { + const owned = await resolveOwnedCustomVaccine( + tx, + user.id, + entry.customVaccineId, + ); + if (!owned) return "unknown-custom-vaccine" as const; + } const data: Prisma.VaccinationRecordUpdateInput = {}; if (entry.occurredAt !== undefined) { @@ -165,6 +178,11 @@ export const PATCH = apiHandler( ? { connect: { id: entry.encounterId } } : { disconnect: true }; } + if (entry.customVaccineId !== undefined) { + data.customVaccine = entry.customVaccineId + ? { connect: { id: entry.customVaccineId } } + : { disconnect: true }; + } const updated = await tx.vaccinationRecord.update({ where: { id }, @@ -184,6 +202,11 @@ export const PATCH = apiHandler( errorCode: "vaccination.encounter-not-found", }); } + if (outcome === "unknown-custom-vaccine") { + return apiError("Vaccine not found", 404, { + errorCode: "vaccination.custom-vaccine-not-found", + }); + } const row = await prisma.vaccinationRecord.findUniqueOrThrow({ where: { id }, @@ -209,6 +232,7 @@ export const PATCH = apiHandler( doseNumber: existing.doseNumber, practitionerId: existing.practitionerId, encounterId: existing.encounterId, + customVaccineId: existing.customVaccineId, }, after: { occurredAt: outcome.updated.occurredAt, @@ -216,6 +240,7 @@ export const PATCH = apiHandler( doseNumber: outcome.updated.doseNumber, practitionerId: outcome.updated.practitionerId, encounterId: outcome.updated.encounterId, + customVaccineId: outcome.updated.customVaccineId, }, }), }, @@ -227,7 +252,10 @@ export const PATCH = apiHandler( entity_type: "vaccination", entity_id: id, }, - meta: { antigen_slug: row.antigenSlug ?? "free-text" }, + meta: { + antigen_slug: + row.antigenSlug ?? (row.customVaccineId ? "custom" : "free-text"), + }, }); return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents)); diff --git a/src/app/api/vaccinations/custom/[id]/route.ts b/src/app/api/vaccinations/custom/[id]/route.ts index 069ef1d2f..777b61a96 100644 --- a/src/app/api/vaccinations/custom/[id]/route.ts +++ b/src/app/api/vaccinations/custom/[id]/route.ts @@ -1,22 +1,190 @@ /** - * `PATCH /api/vaccinations/custom/[id]` edits one of the record's own vaccine - * definitions; `DELETE` soft-deletes it, leaving every dose logged against it - * on its `vaccineName` (v1.42, #1005). Record-scoped like the dose log. + * `PATCH /api/vaccinations/custom/{id}` — edit one of the record's own + * vaccine definitions. + * `DELETE /api/vaccinations/custom/{id}` — remove it (v1.42, #1005). * - * Contract stub: owner-only until implemented, then answers 501. The - * finished routes resolve the record through `requireRecordAuth` like the - * dose log and join the sharing guard's delegable lists with their audit - * rows; a stub cannot carry those, so it does not claim the admission. + * MANAGE, classified `profile`, the same split as the dose edit and delete. + * + * An edit is a correction of the definition and applies to every dose logged + * against it: the series those doses sit in is derived on read, so changing + * the antigens or the series length re-reads them, exactly as a corrected + * catalogue entry would. It deliberately does not re-run the booster satisfy + * matcher — that belongs to the act of logging a dose, which happened once. + * + * The removal soft-deletes the definition and lets go of every dose that + * named it; each dose keeps a name (`removeCustomVaccine`). Owner-scoped by + * fetch-then-guard; `userId` is narrowed from auth and never a body field. */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { NextRequest } from "next/server"; -export const PATCH = apiHandler(async () => { - await requireAuth(); - return notImplemented(); -}); +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { customVaccineUpdateSchema } from "@/lib/validations/vaccinations"; +import { toCustomVaccineDTO } from "@/lib/vaccinations/dto"; +import { + CUSTOM_VACCINE_SELECT, + findLiveNameClash, + removeCustomVaccine, +} from "@/lib/vaccinations/custom-vaccines"; +import type { Prisma } from "@/generated/prisma/client"; -export const DELETE = apiHandler(async () => { - await requireAuth(); - return notImplemented(); -}); +type RouteParams = { params: Promise<{ id: string }> }; + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.customVaccine.findUnique({ where: { id } }); + if ( + !existing || + existing.userId !== user.id || + existing.deletedAt !== null + ) { + return apiError("Vaccine not found", 404, { + errorCode: "vaccination.custom.not-found", + }); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = customVaccineUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.custom.invalid", + }); + } + const entry = parsed.data; + + const outcome = await prisma.$transaction(async (tx) => { + if ( + entry.name !== undefined && + entry.name !== existing.name && + (await findLiveNameClash(tx, user.id, entry.name, id)) + ) { + return "name-taken" as const; + } + // A removed definition may still hold the new name on the unique + // index. It is gone from the person's view, so it gives the name up + // rather than blocking the rename. + if (entry.name !== undefined && entry.name !== existing.name) { + await tx.customVaccine.deleteMany({ + where: { + userId: user.id, + name: entry.name, + deletedAt: { not: null }, + }, + }); + } + // Field-by-field — never spread the parsed object whole. + const data: Prisma.CustomVaccineUpdateInput = {}; + if (entry.name !== undefined) data.name = entry.name; + if (entry.components !== undefined) data.components = entry.components; + if (entry.typicalSeriesDoses !== undefined) { + data.typicalSeriesDoses = entry.typicalSeriesDoses; + } + if (entry.boosterIntervalMonths !== undefined) { + data.boosterIntervalMonths = entry.boosterIntervalMonths; + } + return tx.customVaccine.update({ + where: { id }, + data, + select: CUSTOM_VACCINE_SELECT, + }); + }); + + if (outcome === "name-taken") { + return apiError("A vaccine with this name already exists", 409, { + errorCode: "vaccination.custom.name-taken", + }); + } + + await auditLog("vaccination.custom.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + customVaccineId: id, + // The name is the person's own wording: a rename is named in + // `fields`, never quoted. + ...overwriteDetails({ + before: { + components: existing.components.join(","), + typicalSeriesDoses: existing.typicalSeriesDoses, + boosterIntervalMonths: existing.boosterIntervalMonths, + }, + after: { + components: outcome.components.join(","), + typicalSeriesDoses: outcome.typicalSeriesDoses, + boosterIntervalMonths: outcome.boosterIntervalMonths, + }, + redacted: outcome.name !== existing.name ? ["name"] : [], + }), + }, + }); + + annotate({ + action: { + name: "vaccination.custom.update", + entity_type: "custom-vaccine", + entity_id: id, + }, + }); + + return apiSuccess(toCustomVaccineDTO(outcome)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.customVaccine.findUnique({ + where: { id }, + select: { id: true, userId: true, name: true, deletedAt: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Vaccine not found", 404, { + errorCode: "vaccination.custom.not-found", + }); + } + + // Already removed: removing twice is a no-op that still succeeds. + let dosesUnlinked = 0; + if (existing.deletedAt === null) { + dosesUnlinked = await prisma.$transaction((tx) => + removeCustomVaccine(tx, user.id, existing), + ); + } + + await auditLog("vaccination.custom.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { customVaccineId: id, dosesUnlinked }, + }); + + annotate({ + action: { + name: "vaccination.custom.delete", + entity_type: "custom-vaccine", + entity_id: id, + }, + meta: { doses_unlinked: dosesUnlinked }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/vaccinations/custom/route.ts b/src/app/api/vaccinations/custom/route.ts index 371792b40..79c745473 100644 --- a/src/app/api/vaccinations/custom/route.ts +++ b/src/app/api/vaccinations/custom/route.ts @@ -1,21 +1,140 @@ /** - * `GET /api/vaccinations/custom` lists the record's own vaccine definitions; - * `POST` adds one (v1.42, #1005). Record-scoped like the dose log. + * `GET /api/vaccinations/custom` — the record's own vaccine definitions. + * `POST /api/vaccinations/custom` — add one (v1.42, #1005). * - * Contract stub: owner-only until implemented, then answers 501. The - * finished routes resolve the record through `requireRecordAuth` like the - * dose log and join the sharing guard's delegable lists with their audit - * rows; a stub cannot carry those, so it does not claim the admission. + * A definition is a product the shipped catalogue does not list, described in + * the shape a catalogue entry resolves to: a name, the antigens it protects + * against (from the catalogue's closed antigen list), a typical series length + * and a booster interval. A dose logged against it counts into a series, + * clears a booster and offers one exactly the way a catalogue pick does + * (`src/lib/vaccinations/resolve-vaccine-entry.ts`). + * + * Record-scoped like the dose log and classified `profile` with it: a + * definition is part of the immunization history a delegate granted `profile` + * already sees, names no credential, no integration and no notification + * channel. The list is READ, the create WRITE, the same split as the doses. + * + * `userId` is narrowed from auth and fed to every `where`; it is never a body + * field. The `data` object is built field-by-field. */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; +import { customVaccineCreateSchema } from "@/lib/validations/vaccinations"; +import { toCustomVaccineDTO } from "@/lib/vaccinations/dto"; +import { + CUSTOM_VACCINE_SELECT, + findLiveNameClash, +} from "@/lib/vaccinations/custom-vaccines"; export const GET = apiHandler(async () => { - await requireAuth(); - return notImplemented(); + const { user } = await requireRecordAuth("read", "profile"); + + const rows = await prisma.customVaccine.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: [{ name: "asc" }, { id: "asc" }], + select: CUSTOM_VACCINE_SELECT, + }); + + annotate({ + action: { name: "vaccination.custom.list", entity_type: "custom-vaccine" }, + meta: { count: rows.length }, + }); + + return apiSuccess(rows.map(toCustomVaccineDTO)); }); -export const POST = apiHandler(async () => { - await requireAuth(); - return notImplemented(); +export const POST = apiHandler(async (request: NextRequest) => { + const { user, actor } = await requireRecordAuth("write", "profile"); + + // The shared per-account write ceiling, keyed on the ACTOR so a delegate + // spends their own allowance (`checkRecordWriteRateLimit`). + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = customVaccineCreateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.custom.invalid", + }); + } + const entry = parsed.data; + + const outcome = await prisma.$transaction(async (tx) => { + if (await findLiveNameClash(tx, user.id, entry.name)) { + return "name-taken" as const; + } + // Names are unique per record, removed definitions included, because the + // index is. A name the person removed and now adds again brings that row + // back with the new values rather than failing on the index: deleting + // unlinked every dose, so nothing re-attaches on the way back. + const removed = await tx.customVaccine.findFirst({ + where: { userId: user.id, name: entry.name, deletedAt: { not: null } }, + select: { id: true }, + }); + const data = { + name: entry.name, + components: entry.components, + typicalSeriesDoses: entry.typicalSeriesDoses ?? null, + boosterIntervalMonths: entry.boosterIntervalMonths ?? null, + }; + if (removed) { + return tx.customVaccine.update({ + where: { id: removed.id }, + data: { ...data, deletedAt: null }, + select: CUSTOM_VACCINE_SELECT, + }); + } + return tx.customVaccine.create({ + data: { userId: user.id, ...data }, + select: CUSTOM_VACCINE_SELECT, + }); + }); + + if (outcome === "name-taken") { + return apiError("A vaccine with this name already exists", 409, { + errorCode: "vaccination.custom.name-taken", + }); + } + + await auditLog("vaccination.custom.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + customVaccineId: outcome.id, + components: outcome.components, + boosterIntervalMonths: outcome.boosterIntervalMonths, + }, + }); + + annotate({ + action: { + name: "vaccination.custom.create", + entity_type: "custom-vaccine", + entity_id: outcome.id, + }, + meta: { components: outcome.components.length }, + }); + + return apiSuccess(toCustomVaccineDTO(outcome), 201); }); diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index beeb81826..f7cda6128 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -43,6 +43,7 @@ import { import { VACCINATION_INCLUDE, applyVaccinationLinks, + resolveOwnedCustomVaccine, resolveOwnedEncounter, resolveOwnedPractitioner, resolveOwnerTimezone, @@ -194,6 +195,14 @@ async function postVaccination(request: NextRequest): Promise { const owned = await resolveOwnedEncounter(tx, user.id, entry.encounterId); if (!owned) return "unknown-encounter" as const; } + if (entry.customVaccineId) { + const owned = await resolveOwnedCustomVaccine( + tx, + user.id, + entry.customVaccineId, + ); + if (!owned) return "unknown-custom-vaccine" as const; + } // Field-by-field — never spread the parsed object whole. const row = await tx.vaccinationRecord.create({ @@ -208,6 +217,7 @@ async function postVaccination(request: NextRequest): Promise { site: entry.site ?? null, practitionerId: entry.practitionerId ?? null, encounterId: entry.encounterId ?? null, + customVaccineId: entry.customVaccineId ?? null, noteEncrypted: entry.note ? encryptToBytes(entry.note) : null, }, select: { id: true }, @@ -223,7 +233,10 @@ async function postVaccination(request: NextRequest): Promise { const boosters = await satisfyBoostersForDose( tx, user.id, - entry.antigenSlug ?? null, + { + antigenSlug: entry.antigenSlug ?? null, + customVaccineId: entry.customVaccineId ?? null, + }, occurredAt, timezone, ); @@ -251,6 +264,11 @@ async function postVaccination(request: NextRequest): Promise { errorCode: "vaccination.encounter-not-found", }); } + if (created === "unknown-custom-vaccine") { + return apiError("Vaccine not found", 404, { + errorCode: "vaccination.custom-vaccine-not-found", + }); + } const series = await resolveSeriesFor(prisma, user.id); @@ -261,6 +279,7 @@ async function postVaccination(request: NextRequest): Promise { vaccinationId: created.id, occurredAt: created.occurredAt.toISOString(), antigenSlug: created.antigenSlug, + customVaccineId: created.customVaccineId, // The note is deliberately absent: it is encrypted PHI and the audit // table is not a second copy of the record. }, @@ -273,7 +292,9 @@ async function postVaccination(request: NextRequest): Promise { entity_id: created.id, }, meta: { - antigen_slug: created.antigenSlug ?? "free-text", + antigen_slug: + created.antigenSlug ?? + (created.customVaccineId ? "custom" : "free-text"), linked_documents: entry.documentIds?.length ?? 0, satisfied_boosters: satisfiedReminderIds.length, }, @@ -285,7 +306,11 @@ async function postVaccination(request: NextRequest): Promise { entity_type: "measurement-reminder", entity_id: reminderId, }, - meta: { antigen_slug: created.antigenSlug ?? "free-text" }, + meta: { + antigen_slug: + created.antigenSlug ?? + (created.customVaccineId ? "custom" : "free-text"), + }, }); } diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index c9c5aa862..e890109ec 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -151,7 +151,7 @@ const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{ }, { labelKey: "previewVaccinations", - keys: ["vaccinations", "vaccinationLinks"], + keys: ["vaccinations", "vaccinationLinks", "customVaccines"], }, { labelKey: "previewReminders", diff --git a/src/components/vaccinations/__tests__/custom-vaccine.test.tsx b/src/components/vaccinations/__tests__/custom-vaccine.test.tsx new file mode 100644 index 000000000..8f79a0581 --- /dev/null +++ b/src/components/vaccinations/__tests__/custom-vaccine.test.tsx @@ -0,0 +1,128 @@ +/** + * v1.42 (#1005) — the person's own vaccines on the client: the booster offer + * a dose logged against one raises, the name it carries in the list, and the + * body the definition sheet sends. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { VaccinationDTO } from "@/lib/vaccinations/dto"; + +import { boosterOfferFor } from "../booster-mint-prompt"; +import { customVaccineBody } from "../custom-vaccine-sheet"; +import { draftHasIdentity, draftToBody, emptyDraft } from "../vaccination-form"; +import { VaccinationList } from "../vaccination-list"; + +const OWN = { + id: "cv-1", + name: "Travel combo", + components: ["typhoid", "hepatitis-a"], + typicalSeriesDoses: 1, + boosterIntervalMonths: 36, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +function dose(over: Partial): VaccinationDTO { + return { + id: "dose-1", + occurredAt: "2026-02-01T00:00:00.000Z", + antigenSlug: null, + vaccineName: null, + doseNumber: null, + seriesDoses: null, + lotNumber: null, + site: null, + catalogEntry: null, + customVaccineId: OWN.id, + customVaccine: OWN, + series: [ + { antigen: "typhoid", position: 1, total: 1, booster: false }, + { antigen: "hepatitis-a", position: 1, total: 1, booster: false }, + ], + practitioner: null, + encounter: null, + reminderId: null, + note: null, + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + ...over, + }; +} + +describe("the booster offer", () => { + it("is raised for an own vaccine with an interval, under its own name", () => { + expect(boosterOfferFor(dose({}))).toEqual({ + intervalMonths: 36, + slug: null, + name: "Travel combo", + }); + }); + + it("is not raised for an own vaccine without an interval, or a removed one", () => { + expect( + boosterOfferFor( + dose({ customVaccine: { ...OWN, boosterIntervalMonths: null } }), + ), + ).toBeNull(); + expect(boosterOfferFor(dose({ customVaccine: null }))).toBeNull(); + }); +}); + +describe("the list", () => { + it("files the dose under each antigen, marked with the own vaccine's name", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain('data-antigen="typhoid"'); + expect(html).toContain('data-antigen="hepatitis-a"'); + expect(html.match(/· Travel combo/g)).toHaveLength(2); + }); +}); + +describe("the forms", () => { + it("accepts an own vaccine as the dose's only identity and sends it", () => { + const draft = emptyDraft({ customVaccineId: "cv-1" }); + expect(draftHasIdentity(draft)).toBe(true); + expect(draftToBody(draft)).toMatchObject({ + customVaccineId: "cv-1", + antigenSlug: null, + vaccineName: null, + }); + }); + + it("sends a definition only with a name and a disease, antigens in catalogue order", () => { + expect( + customVaccineBody({ + name: " ", + components: ["tetanus"], + typicalSeriesDoses: "", + boosterIntervalMonths: "", + }), + ).toBeNull(); + expect( + customVaccineBody({ + name: "X", + components: [], + typicalSeriesDoses: "", + boosterIntervalMonths: "", + }), + ).toBeNull(); + expect( + customVaccineBody({ + name: " Travel combo ", + components: ["typhoid", "tetanus"], + typicalSeriesDoses: "2", + boosterIntervalMonths: "", + }), + ).toEqual({ + name: "Travel combo", + components: ["tetanus", "typhoid"], + typicalSeriesDoses: 2, + boosterIntervalMonths: null, + }); + }); +}); diff --git a/src/components/vaccinations/__tests__/vaccination-form.test.ts b/src/components/vaccinations/__tests__/vaccination-form.test.ts index fac6d1426..65da1e69b 100644 --- a/src/components/vaccinations/__tests__/vaccination-form.test.ts +++ b/src/components/vaccinations/__tests__/vaccination-form.test.ts @@ -93,6 +93,8 @@ describe("vaccination form document links", () => { lotNumber: null, site: null, catalogEntry: null, + customVaccineId: null, + customVaccine: null, series: [], practitioner: null, encounter: null, diff --git a/src/components/vaccinations/__tests__/vaccination-list.test.tsx b/src/components/vaccinations/__tests__/vaccination-list.test.tsx index 0493561ce..24d6a7451 100644 --- a/src/components/vaccinations/__tests__/vaccination-list.test.tsx +++ b/src/components/vaccinations/__tests__/vaccination-list.test.tsx @@ -26,6 +26,8 @@ function record( lotNumber: null, site: null, catalogEntry: null, + customVaccineId: null, + customVaccine: null, practitioner: null, encounter: null, reminderId: null, diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx index 9fa6caa4e..cb80e2982 100644 --- a/src/components/vaccinations/booster-mint-prompt.tsx +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -34,22 +34,40 @@ import { FieldGroup } from "@/components/ui/field-group"; import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; import { useTranslations } from "@/lib/i18n/context"; -import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import { + customLookupOf, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; import { useBoosterMint, type Vaccination } from "./use-vaccinations"; /** - * Whether a freshly created dose should raise the offer at all. Only a - * catalogue entry that carries a booster interval does; a free-text row and a + * Whether a freshly created dose should raise the offer at all. Only an entry + * that carries a booster interval and an antigen to key it on does — a + * catalogue pick or the person's own definition alike; a free-text row and a * one-off vaccine never prompt. */ -export function boosterOfferFor( - record: Vaccination, -): { intervalMonths: number; catalogName: string } | null { - const entry = resolveCatalogEntry(record.antigenSlug); - if (!entry || entry.boosterIntervalMonths === null) return null; +export function boosterOfferFor(record: Vaccination): { + intervalMonths: number; + /** The catalogue slug, or null for the person's own definition. */ + slug: string | null; + /** The definition's own name, or null for a catalogue entry. */ + name: string | null; +} | null { + const entry = resolveVaccineEntry( + record, + customLookupOf([record.customVaccine]), + ); + if ( + !entry || + entry.boosterIntervalMonths === null || + entry.components.length === 0 + ) { + return null; + } return { intervalMonths: entry.boosterIntervalMonths, - catalogName: entry.slug, + slug: entry.slug, + name: entry.name, }; } @@ -71,7 +89,7 @@ export function BoosterMintPrompt({ // the person's own locale — the server never resolves an i18n key. const defaultLabel = record && offer - ? `${t(`vaccinations.catalog.${record.antigenSlug}`)} ${t("vaccinations.booster.labelSuffix")}` + ? `${offer.slug ? t(`vaccinations.catalog.${offer.slug}`) : offer.name} ${t("vaccinations.booster.labelSuffix")}` : ""; const [label, setLabel] = useState(defaultLabel); const [months, setMonths] = useState(offer?.intervalMonths ?? 12); diff --git a/src/components/vaccinations/catalog-info.tsx b/src/components/vaccinations/catalog-info.tsx index 49a434a63..66febcd93 100644 --- a/src/components/vaccinations/catalog-info.tsx +++ b/src/components/vaccinations/catalog-info.tsx @@ -14,24 +14,40 @@ * * The per-entry `source` renders verbatim as the footnote — a citation is a * proper noun, like a unit, and is not translated. + * + * The person's own vaccine definitions (v1.42, #1005) render through the same + * templates, with the antigens they listed first and no footnote: their + * numbers are the person's, not a cited schedule's. */ import { useTranslations } from "@/lib/i18n/context"; -import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import { + resolveVaccineEntry, + type ResolvedVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; type Translate = ReturnType["t"]; type TranslateCount = ReturnType["tCount"]; /** The impersonal sentences that apply to this entry, in reading order. */ function infoSentences( - slug: string | null, + entry: ResolvedVaccineEntry | null, t: Translate, tCount: TranslateCount, -): { lines: string[]; source: string } | null { - const entry = resolveCatalogEntry(slug); +): { lines: string[]; source: string | null } | null { if (!entry) return null; const lines: string[] = []; + if (entry.kind === "custom" && entry.components.length > 0) { + lines.push( + t("vaccinations.custom.protectsAgainst", { + list: entry.components + .map((antigen) => t(`vaccinations.catalog.${antigen}`)) + .join(", "), + }), + ); + } + if (entry.typicalSeriesDoses !== null) { lines.push( tCount("vaccinations.info.primarySeries", entry.typicalSeriesDoses, { @@ -70,7 +86,7 @@ function infoSentences( * empty popover for an entry the templates say nothing about. */ export function catalogInfoAvailable(slug: string | null): boolean { - const entry = resolveCatalogEntry(slug); + const entry = resolveVaccineEntry({ antigenSlug: slug }); if (!entry) return false; return ( entry.typicalSeriesDoses !== null || @@ -79,9 +95,20 @@ export function catalogInfoAvailable(slug: string | null): boolean { ); } -export function CatalogInfo({ slug }: { slug: string | null }) { +export function CatalogInfo({ + slug = null, + entry, +}: { + slug?: string | null; + /** An already resolved entry, e.g. one of the person's own definitions. */ + entry?: ResolvedVaccineEntry | null; +}) { const { t, tCount } = useTranslations(); - const info = infoSentences(slug, t, tCount); + const info = infoSentences( + entry ?? resolveVaccineEntry({ antigenSlug: slug }), + t, + tCount, + ); if (!info) return null; return ( @@ -91,9 +118,11 @@ export function CatalogInfo({ slug }: { slug: string | null }) {
  • {line}
  • ))} -

    - {t("vaccinations.info.sourceLabel", { source: info.source })} -

    + {info.source ? ( +

    + {t("vaccinations.info.sourceLabel", { source: info.source })} +

    + ) : null} ); } diff --git a/src/components/vaccinations/catalog-picker.tsx b/src/components/vaccinations/catalog-picker.tsx index 8c2507f59..5e57d4c4a 100644 --- a/src/components/vaccinations/catalog-picker.tsx +++ b/src/components/vaccinations/catalog-picker.tsx @@ -13,9 +13,18 @@ * The list is a static import, so the search is an in-memory filter with no * round trip and no provider dependency. Grouped by category so a lifetime Pass * (childhood through travel) reads in the order a person recognises. + * + * v1.42 (#1005) — the person's own vaccine definitions list above the + * catalogue under their own heading, and the popover ends in "Add own + * vaccine" for a product neither list has. Picking a definition sets + * `customVaccineId` and clears the catalogue pick, and the other way round: + * a dose names one entry, and the catalogue would win a tie anyway. The + * caller applies each pick as one change (`onChange` clears the definition, + * `onCustomChange` the slug), so two updates never race over one draft; + * clearing calls `onChange(null)` and empties both. */ import { useMemo, useState } from "react"; -import { Check, ChevronsUpDown, X } from "lucide-react"; +import { Check, ChevronsUpDown, Plus, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -30,16 +39,33 @@ import { VACCINE_CATALOG, type VaccineSeed, } from "@/lib/vaccinations/vaccine-catalog"; +import { + customLookupOf, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; import { CatalogInfo } from "./catalog-info"; +import type { CustomVaccine } from "./use-vaccinations"; export function CatalogPicker({ value, onChange, + customValue = null, + customs = [], + onCustomChange, + onAddCustom, disabled, }: { /** The chosen catalogue slug, or null. */ value: string | null; + /** A catalogue pick, or `null` to clear every pick. */ onChange: (slug: string | null) => void; + /** The chosen own definition's id, or null. */ + customValue?: string | null; + /** The record's own definitions, listed above the catalogue. */ + customs?: readonly CustomVaccine[]; + onCustomChange?: (id: string | null) => void; + /** Opens the definition sheet; absent when the caller may not add one. */ + onAddCustom?: () => void; disabled?: boolean; }) { const { t } = useTranslations(); @@ -63,9 +89,28 @@ export function CatalogPicker({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [term, t]); + const customMatches = useMemo(() => { + const needle = term.trim().toLowerCase(); + return customs.filter( + (custom) => !needle || custom.name.toLowerCase().includes(needle), + ); + }, [customs, term]); + const selected: VaccineSeed | undefined = value ? VACCINE_CATALOG.find((entry) => entry.slug === value) : undefined; + const selectedCustom = + !selected && customValue + ? (customs.find((custom) => custom.id === customValue) ?? null) + : null; + const customEntry = selectedCustom + ? resolveVaccineEntry( + { customVaccineId: selectedCustom.id }, + customLookupOf([selectedCustom]), + ) + : null; + const hasPick = Boolean(selected || selectedCustom); + const nothingMatches = matches.length === 0 && customMatches.length === 0; return (
    @@ -89,12 +134,14 @@ export function CatalogPicker({ {selected ? name(selected.slug) - : t("vaccinations.form.catalogNone")} + : selectedCustom + ? selectedCustom.name + : t("vaccinations.form.catalogNone")}
    - {matches.length === 0 ? ( + {customMatches.length > 0 ? ( + <> +

    + {t("vaccinations.custom.title")} +

    + {customMatches.map((custom) => ( + + ))} + {matches.length > 0 ? ( +

    + {t("vaccinations.custom.catalogGroup")} +

    + ) : null} + + ) : null} + {nothingMatches ? (

    {t("vaccinations.form.catalogNoMatch")}

    @@ -142,10 +227,26 @@ export function CatalogPicker({ )) )}
    + {onAddCustom ? ( + + ) : null} - {selected ? ( + {hasPick ? (
    ); } diff --git a/src/components/vaccinations/custom-vaccine-list.tsx b/src/components/vaccinations/custom-vaccine-list.tsx new file mode 100644 index 000000000..48ec2bcdf --- /dev/null +++ b/src/components/vaccinations/custom-vaccine-list.tsx @@ -0,0 +1,121 @@ +"use client"; + +/** + * v1.42 (#1005) — the record's own vaccine definitions, below the dose log. + * + * Rendered only once the record holds at least one: the way in is the dose + * form's picker ("Add own vaccine"), and an empty heading on every Impfpass + * would advertise a feature most people never need. Each row names the + * definition and reads it back in the catalogue's own sentences, so a + * definition and a catalogue entry describe themselves the same way. A row + * opens the definition for someone who may edit it. + */ +import { useState } from "react"; +import { Plus, Syringe } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { SectionHeading } from "@/components/ui/section-heading"; +import { useTranslations } from "@/lib/i18n/context"; +import { + customLookupOf, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; +import { CatalogInfo } from "./catalog-info"; +import { CustomVaccineSheet } from "./custom-vaccine-sheet"; +import type { CustomVaccine } from "./use-vaccinations"; + +export function CustomVaccineList({ + customs, + canAdd, + canManage, +}: { + customs: readonly CustomVaccine[]; + /** WRITE on `profile`: may add a definition. */ + canAdd: boolean; + /** MANAGE on `profile`: may edit and remove one. */ + canManage: boolean; +}) { + const { t } = useTranslations(); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [session, setSession] = useState(0); + + if (customs.length === 0) return null; + + const openSheet = (row: CustomVaccine | null) => { + setEditing(row); + setSession((n) => n + 1); + setOpen(true); + }; + + return ( +
    + openSheet(null)} + > + + {t("vaccinations.custom.add")} + + ) : null + } + /> + +
    + {customs.map((custom) => { + const entry = resolveVaccineEntry( + { customVaccineId: custom.id }, + customLookupOf([custom]), + ); + const editable = canManage; + return ( + openSheet(custom) : undefined} + onKeyDown={ + editable + ? (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openSheet(custom); + } + } + : undefined + } + > + +

    + {custom.name} +

    + +
    +
    + ); + })} +
    + + +
    + ); +} diff --git a/src/components/vaccinations/custom-vaccine-sheet.tsx b/src/components/vaccinations/custom-vaccine-sheet.tsx new file mode 100644 index 000000000..929ac6819 --- /dev/null +++ b/src/components/vaccinations/custom-vaccine-sheet.tsx @@ -0,0 +1,332 @@ +"use client"; + +/** + * v1.42 (#1005) — define, edit or remove one of the record's own vaccines. + * + * A definition is a product the shipped catalogue does not list: a name, the + * diseases it protects against (the catalogue's own antigen list, because the + * series and the booster match key on it), and optionally the series length + * and the booster interval. A dose logged against it then counts like a + * catalogue pick (`resolve-vaccine-entry.ts`). + * + * Save needs a name and at least one disease; nothing else is required. + * Removing asks first and says what happens to the doses: they stay, under + * the definition's name. + * + * Capability note: the same posture as the dose sheet. Create needs WRITE on + * `profile`, edit and remove need MANAGE; the mounts ask before opening it. + */ +import { useState } from "react"; +import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { FieldGroup } from "@/components/ui/field-group"; +import { Input } from "@/components/ui/input"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { ApiError } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { ANTIGEN_SLUGS } from "@/lib/vaccinations/vaccine-catalog"; +import { + useCustomVaccineMutations, + type CustomVaccine, + type CustomVaccineWriteBody, +} from "./use-vaccinations"; + +export interface CustomVaccineDraft { + name: string; + components: string[]; + typicalSeriesDoses: string; + boosterIntervalMonths: string; +} + +export function customVaccineDraft( + row: CustomVaccine | null, +): CustomVaccineDraft { + return { + name: row?.name ?? "", + components: row ? [...row.components] : [], + typicalSeriesDoses: row?.typicalSeriesDoses?.toString() ?? "", + boosterIntervalMonths: row?.boosterIntervalMonths?.toString() ?? "", + }; +} + +function toInt(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const n = Number.parseInt(trimmed, 10); + return Number.isNaN(n) ? null : n; +} + +/** The body a draft becomes; null while it cannot be saved. */ +export function customVaccineBody( + draft: CustomVaccineDraft, +): CustomVaccineWriteBody | null { + const name = draft.name.trim(); + if (!name || draft.components.length === 0) return null; + return { + name, + // Catalogue order, so the same choice always reads the same way. + components: ANTIGEN_SLUGS.filter((slug) => draft.components.includes(slug)), + typicalSeriesDoses: toInt(draft.typicalSeriesDoses), + boosterIntervalMonths: toInt(draft.boosterIntervalMonths), + }; +} + +export function CustomVaccineSheet({ + open, + onOpenChange, + customVaccine, + canRemove = false, + onSaved, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** `null` creates a definition; a row edits it. */ + customVaccine: CustomVaccine | null; + /** Offer the removal; the caller asks for MANAGE first. */ + canRemove?: boolean; + /** Raised with the saved definition, e.g. to pick it in the dose form. */ + onSaved?: (saved: CustomVaccine) => void; +}) { + const { t } = useTranslations(); + const { create, update, remove } = useCustomVaccineMutations(); + // Seeded once at mount; the caller remounts on every open by varying `key`. + const [draft, setDraft] = useState(() => + customVaccineDraft(customVaccine), + ); + const [error, setError] = useState(null); + const [confirmingRemove, setConfirmingRemove] = useState(false); + // The diseases an edit opens with lead the list, so what the definition + // covers is visible without scrolling; the order is fixed at mount so a + // box never jumps away from the pointer that ticked it. + const [antigenOrder] = useState(() => { + const chosen = new Set(customVaccine?.components ?? []); + return [ + ...ANTIGEN_SLUGS.filter((slug) => chosen.has(slug)), + ...ANTIGEN_SLUGS.filter((slug) => !chosen.has(slug)), + ]; + }); + + const isEdit = customVaccine !== null; + const pending = create.isPending || update.isPending; + const body = customVaccineBody(draft); + const patch = (part: Partial) => + setDraft((current) => ({ ...current, ...part })); + + const toggle = (antigen: string, on: boolean) => + setDraft((current) => ({ + ...current, + components: on + ? [...current.components.filter((a) => a !== antigen), antigen] + : current.components.filter((a) => a !== antigen), + })); + + const submit = async () => { + if (!body) return; + setError(null); + try { + const saved = isEdit + ? await update.mutateAsync({ id: customVaccine.id, body }) + : await create.mutateAsync(body); + toastWrittenOutcome("success", t("vaccinations.custom.saved")); + onOpenChange(false); + onSaved?.(saved); + } catch (err) { + const message = + err instanceof ApiError && + err.meta?.errorCode === "vaccination.custom.name-taken" + ? t("vaccinations.custom.nameTaken") + : t("vaccinations.custom.saveFailed"); + setError(message); + toast.error(message); + } + }; + + return ( + + + + + } + > +
    + + patch({ name: event.target.value })} + data-slot="custom-vaccine-name" + /> + + +
    + + {t("vaccinations.custom.components")} + +

    + {t("vaccinations.custom.componentsHint")} +

    +
    + {antigenOrder.map((antigen) => { + const id = `custom-vaccine-antigen-${antigen}`; + const checked = draft.components.includes(antigen); + return ( + + ); + })} +
    +
    + +
    + + + patch({ typicalSeriesDoses: event.target.value }) + } + /> + + + + patch({ boosterIntervalMonths: event.target.value }) + } + /> + +
    + + {error ? ( +

    + {error} +

    + ) : null} + + {isEdit && canRemove ? ( + + + + + + + + {t("vaccinations.custom.deleteTitle")} + + + {t("vaccinations.custom.deleteConfirm")} + + + + {t("common.cancel")} + { + remove.mutate(customVaccine.id, { + onSuccess: () => { + toastWrittenOutcome( + "success", + t("vaccinations.custom.deleted"), + ); + onOpenChange(false); + }, + onError: () => + toast.error(t("vaccinations.custom.saveFailed")), + }); + }} + > + {t("common.delete")} + + + + + ) : null} +
    +
    + ); +} diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index 2834e093e..faa4cfb24 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -23,12 +23,14 @@ import { vaccinationDependentKeys, } from "@/lib/query-keys"; import type { + CustomVaccineDTO, VaccinationDTO, VaccinationListDTO, } from "@/lib/vaccinations/dto"; import type { VaccinationSuggestionResult } from "@/lib/vaccinations/document-suggestion"; export type Vaccination = VaccinationDTO; +export type CustomVaccine = CustomVaccineDTO; /** * The write body — every field optional but the identity pair and the date. @@ -44,6 +46,7 @@ export interface VaccinationWriteBody { site?: string | null; practitionerId?: string | null; encounterId?: string | null; + customVaccineId?: string | null; note?: string | null; documentIds?: string[]; } @@ -212,3 +215,59 @@ export function useBoosterMint() { ]), }); } + +/** What a definition write carries; `userId` is never here. */ +export interface CustomVaccineWriteBody { + name?: string; + components?: string[]; + typicalSeriesDoses?: number | null; + boosterIntervalMonths?: number | null; +} + +/** v1.42 (#1005) — the record's own vaccine definitions, by name. */ +export function useCustomVaccines(enabled = true) { + return useQuery({ + queryKey: queryKeys.customVaccines(), + enabled, + queryFn: () => apiGet(`${BASE}/custom`), + }); +} + +/** + * Add, edit and remove a definition. Every write evicts the whole + * vaccinations root: an edit re-reads the series of every dose that names + * the definition, and a removal lets those doses go and names them, which + * the document sheet's dose chips show too. No reminder moves — a definition + * write never runs the satisfy matcher — so the reminder reads and the daily + * reads they feed are left alone. + */ +export function useCustomVaccineMutations() { + const qc = useQueryClient(); + const invalidate = () => + invalidateKeys(qc, [queryKeys.vaccinations(), queryKeys.documents()]); + + const create = useMutation({ + mutationKey: queryKeys.customVaccineCreate(), + mutationFn: (body: CustomVaccineWriteBody) => + apiPost(`${BASE}/custom`, body), + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationKey: queryKeys.customVaccineUpdate(), + mutationFn: ({ id, body }: { id: string; body: CustomVaccineWriteBody }) => + apiPatch(`${BASE}/custom/${encodeURIComponent(id)}`, body), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationKey: queryKeys.customVaccineDelete(), + mutationFn: (id: string) => + apiDelete<{ deleted: boolean }>( + `${BASE}/custom/${encodeURIComponent(id)}`, + ), + onSuccess: invalidate, + }); + + return { create, update, remove }; +} diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index f65b2ac37..aff06ca44 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -10,9 +10,11 @@ * feature exists for. The only thing that can block Save is a missing date or * neither identity arm. * - * The two identity arms are independent: picking a catalogue entry never + * The identity arms are independent: picking a catalogue entry never * overwrites what the person typed, and typing never clears a pick. Either is - * sufficient; both are allowed. + * sufficient; both are allowed. Since v1.42 (#1005) the pick may also be one + * of the record's own vaccine definitions, chosen in the same picker, and a + * new definition can be added from it without leaving the form. * * Follows the encounter form's controlled-draft shape rather than a * react-hook-form instance — the sibling surface this plan cites as its @@ -32,8 +34,14 @@ import { EncounterSuggestionField } from "@/components/encounters/encounter-sugg import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import type { Practitioner } from "@/hooks/use-practitioners"; -import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations"; +import { + useCustomVaccines, + type CustomVaccine, + type Vaccination, + type VaccinationWriteBody, +} from "./use-vaccinations"; import { CatalogPicker } from "./catalog-picker"; +import { CustomVaccineSheet } from "./custom-vaccine-sheet"; import { VaccinationDocumentPicker } from "./vaccination-document-picker"; import { dayKeyAsUtcMidnight, isCalendarDateKey } from "@/lib/tz/date-only"; @@ -52,6 +60,8 @@ export interface VaccinationDraft { /** `YYYY-MM-DD`, the `DateField` contract. */ occurredAt: string; antigenSlug: string | null; + /** One of the record's own vaccine definitions, or null. */ + customVaccineId: string | null; vaccineName: string; doseNumber: string; seriesDoses: string; @@ -85,6 +95,7 @@ export function emptyDraft( return { occurredAt: todayLocal(), antigenSlug: null, + customVaccineId: null, vaccineName: "", doseNumber: "", seriesDoses: "", @@ -102,6 +113,8 @@ export function draftFromVaccination(row: Vaccination): VaccinationDraft { return { occurredAt: row.occurredAt.slice(0, 10), antigenSlug: row.antigenSlug, + // A definition removed since stays unpicked: the picker cannot name it. + customVaccineId: row.customVaccine ? row.customVaccineId : null, vaccineName: row.vaccineName ?? "", doseNumber: row.doseNumber?.toString() ?? "", seriesDoses: row.seriesDoses?.toString() ?? "", @@ -125,7 +138,11 @@ export function draftInstant(day: string): string | null { /** Does the draft carry at least one identity arm? */ export function draftHasIdentity(draft: VaccinationDraft): boolean { - return draft.antigenSlug !== null || draft.vaccineName.trim().length > 0; + return ( + draft.antigenSlug !== null || + draft.customVaccineId !== null || + draft.vaccineName.trim().length > 0 + ); } function toInt(value: string): number | null { @@ -145,6 +162,7 @@ export function draftToBody( return { occurredAt, antigenSlug: draft.antigenSlug, + customVaccineId: draft.customVaccineId, vaccineName: draft.vaccineName.trim() || null, doseNumber: toInt(draft.doseNumber), seriesDoses: toInt(draft.seriesDoses), @@ -186,6 +204,11 @@ export function VaccinationForm({ const [seriesOpen, setSeriesOpen] = useState( draft.doseNumber.length > 0 || draft.seriesDoses.length > 0, ); + const customs = useCustomVaccines(); + // The definition sheet, opened from the picker. Bumped `session` remounts + // it so a second open starts empty. + const [addingCustom, setAddingCustom] = useState(false); + const [customSession, setCustomSession] = useState(0); const anchor = draftInstant(draft.occurredAt); @@ -212,10 +235,32 @@ export function VaccinationForm({ > patch({ antigenSlug: slug })} + // One change per pick, so the two arms never race over one draft. + onChange={(slug) => + patch({ antigenSlug: slug, customVaccineId: null }) + } + customValue={draft.customVaccineId} + customs={customs.data ?? []} + onCustomChange={(id) => + patch({ customVaccineId: id, antigenSlug: null }) + } + onAddCustom={() => { + setCustomSession((n) => n + 1); + setAddingCustom(true); + }} /> + + patch({ customVaccineId: saved.id, antigenSlug: null }) + } + /> + )} + + ); } diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts index ec08a6bf1..b22921a3f 100644 --- a/src/lib/doctor-report/clinical-records.ts +++ b/src/lib/doctor-report/clinical-records.ts @@ -12,6 +12,11 @@ */ import { prisma } from "@/lib/db"; import { deriveSeries } from "@/lib/vaccinations/series"; +import { + CUSTOM_VACCINE_RESOLVE_SELECT, + customLookupOf, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; import { resolveEffectiveReferenceRange } from "@/lib/labs/reference-range"; import { getEvent } from "@/lib/logging/context"; import { decryptAllergyReaction } from "@/lib/doctor-report-helpers"; @@ -298,24 +303,32 @@ export async function loadImmunizations( doseNumber: true, site: true, practitioner: { select: { name: true } }, + // v1.42 (#1005) — the person's own definition: its antigens place the + // dose in a series, and its name stands in when the dose has none. + customVaccineId: true, + customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT }, }, }); if (rows.length === 0) return null; + const customs = customLookupOf(rows.map((row) => row.customVaccine)); const series = deriveSeries( rows.map((row) => ({ id: row.id, occurredAt: row.occurredAt, antigenSlug: row.antigenSlug, + customVaccineId: row.customVaccineId, doseNumber: row.doseNumber, seriesDoses: row.seriesDoses, })), + customs, ); return rows.map((row) => ({ occurredAt: row.occurredAt.toISOString(), antigenSlug: row.antigenSlug, - vaccineName: row.vaccineName, + vaccineName: + row.vaccineName ?? resolveVaccineEntry(row, customs)?.name ?? null, lotNumber: row.lotNumber, site: row.site, practitionerName: row.practitioner?.name ?? null, diff --git a/src/lib/documents/links.ts b/src/lib/documents/links.ts index 92d26419c..8e5e34538 100644 --- a/src/lib/documents/links.ts +++ b/src/lib/documents/links.ts @@ -16,7 +16,11 @@ import type { DocumentLinkedProcedureDto, DocumentVaccinationLinkDto, } from "@/lib/validations/inbound-documents"; -import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import { + CUSTOM_VACCINE_RESOLVE_SELECT, + customLookupOf, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; /** * Load the condition links for a page of documents in ONE grouped query. @@ -237,20 +241,29 @@ export async function loadDocumentVaccinationLinks( if (doseIds.length === 0) return map; const doses = await prisma.vaccinationRecord.findMany({ where: { id: { in: doseIds }, userId }, - select: { id: true, antigenSlug: true, vaccineName: true }, + select: { + id: true, + antigenSlug: true, + vaccineName: true, + customVaccineId: true, + customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT }, + }, }); const doseById = new Map(doses.map((dose) => [dose.id, dose])); + const customs = customLookupOf(doses.map((dose) => dose.customVaccine)); for (const [documentId, targets] of byDocument) { map.set( documentId, targets.map((target) => { const dose = doseById.get(target.id); + const entry = dose ? resolveVaccineEntry(dose, customs) : null; return { vaccinationId: target.id, occurredAt: target.date, - catalogSlug: - resolveCatalogEntry(dose?.antigenSlug ?? null)?.slug ?? null, - vaccineName: dose?.vaccineName ?? null, + catalogSlug: entry?.slug ?? null, + // A dose logged against the person's own definition and carrying no + // wording of its own is named by that definition. + vaccineName: dose?.vaccineName ?? entry?.name ?? null, }; }), ); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 61402aedb..69c48c449 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -413,6 +413,7 @@ function makePrisma() { encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the // same reason as the visit tables above. diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index 39a42b987..cfa5e8088 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -290,6 +290,7 @@ function sourceClient() { encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the // same reason as the visit tables above. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index bc5f46dab..26b39770e 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -378,6 +378,7 @@ export const TWO_ENDED_MODELS = [ // from anywhere else. "VaccinationRecord", "VaccinationDocumentLink", + "CustomVaccine", // The Vorsorge reminders, off the debt register at last (v1.37.20, #223 / // iOS #68), and the completion ledger carried with them from the release // that introduces it. The pairing is deliberate: the ledger arriving is @@ -589,8 +590,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly> = { * rather than by what is cheapest to write. */ export const COVERAGE_PENDING: Readonly> = { - CustomVaccine: - "The person's own vaccine definitions (v1.42, #1005). Until both ends land, a restored dose that pointed at one comes back as its bare `vaccineName`: no antigens, no series position, no booster rule.", WorkoutRoute: "GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.", WorkoutSamples: diff --git a/src/lib/export/backup-preview.ts b/src/lib/export/backup-preview.ts index 446998d36..1109963f0 100644 --- a/src/lib/export/backup-preview.ts +++ b/src/lib/export/backup-preview.ts @@ -180,6 +180,7 @@ const SUMMARY_COUNTS = [ "encounterLinks", "vaccinations", "vaccinationLinks", + "customVaccines", "measurementReminders", "measurementReminderEvents", "coachConversations", diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 9dc633e95..add18ce1f 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -179,6 +179,7 @@ export interface RestoreResponse { encounterLinks: number; vaccinations: number; vaccinationLinks: number; + customVaccines: number; measurementReminders: number; measurementReminderEvents: number; coachConversations: number; @@ -2646,6 +2647,7 @@ export async function restoreBackup( encounterLinks: visitsCleared.encounterLinks, vaccinations: vaccinationsCleared.vaccinations, vaccinationLinks: vaccinationsCleared.vaccinationLinks, + customVaccines: vaccinationsCleared.customVaccines, measurementReminders: remindersCleared.measurementReminders, measurementReminderEvents: remindersCleared.measurementReminderEvents, coachConversations: coachCleared.coachConversations, @@ -2905,6 +2907,7 @@ export async function restoreBackup( encounterLinks: summary.encounterLinks, vaccinations: summary.vaccinations, vaccinationLinks: summary.vaccinationLinks, + customVaccines: summary.customVaccines, measurementReminders: summary.measurementReminders, measurementReminderEvents: summary.measurementReminderEvents, }, diff --git a/src/lib/export/vaccinations-backup.ts b/src/lib/export/vaccinations-backup.ts index 2e15242b0..ebf3da8c5 100644 --- a/src/lib/export/vaccinations-backup.ts +++ b/src/lib/export/vaccinations-backup.ts @@ -6,8 +6,9 @@ * greps only the restore ROUTE gets a false negative because the route * delegates. * - * Two models ride: the dose and the link that keeps the scanned Impfpass page - * beside it. Both are carried rather than owed. `DocumentConditionLink` on the + * Three models ride: the dose, the link that keeps the scanned Impfpass page + * beside it, and since v1.42 (#1005) the person's own vaccine definitions a + * dose may name. Both are carried rather than owed. `DocumentConditionLink` on the * debt register says what the alternative costs — documents and conditions * both restore, the filing between them does not — and an immunization history * is the record with the least chance of being reconstructed from anywhere @@ -29,6 +30,9 @@ * the same way, and resolves to NULL, with the drop reported, only when * the file genuinely lacks the reminder — a portable export omits * tombstoned ones. + * - `customVaccineId` points at a definition restored by this same section + * first. It drops to NULL, with the drop named, when the file lacks the + * definition; the dose then keeps its own `vaccineName`. * - `antigenSlug` is restored verbatim with no validation against the * current catalogue. A slug this release no longer resolves must still * come back; the renderer degrades to the free-text arm, which is the @@ -76,6 +80,8 @@ export interface VaccinationBackupEntry { * lacks the reminder. */ reminderId: string | null; + /** The person's own definition the dose names (v1.42, #1005). */ + customVaccineId: string | null; /** Base64 ciphertext; disaster-recovery payloads only. */ noteEncrypted?: string | null; /** The note readable; portable payloads only. */ @@ -85,6 +91,22 @@ export interface VaccinationBackupEntry { deletedAt?: string | null; } +/** + * One of the person's own vaccine definitions (v1.42, #1005). `components` + * travels verbatim, never checked against the antigen list this release + * ships, for the same reason a dose's `antigenSlug` does. + */ +export interface CustomVaccineBackupEntry { + id: string; + name: string; + components: string[]; + typicalSeriesDoses: number | null; + boosterIntervalMonths: number | null; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; +} + /** One edge from a dose to the page it was transcribed from. */ export interface VaccinationDocumentLinkBackupEntry { vaccinationId: string; @@ -93,11 +115,13 @@ export interface VaccinationDocumentLinkBackupEntry { } export interface VaccinationsBackupSection { + customVaccines: CustomVaccineBackupEntry[]; vaccinations: VaccinationBackupEntry[]; vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[]; } export interface VaccinationsBackupCounts { + customVaccines: number; vaccinations: number; vaccinationLinks: number; } @@ -119,12 +143,24 @@ const VACCINATION_BACKUP_SELECT = { practitionerId: true, encounterId: true, reminderId: true, + customVaccineId: true, noteEncrypted: true, createdAt: true, updatedAt: true, deletedAt: true, } as const; +const CUSTOM_VACCINE_BACKUP_SELECT = { + id: true, + name: true, + components: true, + typicalSeriesDoses: true, + boosterIntervalMonths: true, + createdAt: true, + updatedAt: true, + deletedAt: true, +} as const; + const VACCINATION_DOCUMENT_LINK_BACKUP_SELECT = { vaccinationId: true, documentId: true, @@ -140,26 +176,47 @@ const VACCINATION_DOCUMENT_LINK_BACKUP_SELECT = { * back as itself. */ export async function buildVaccinationsBackupSection( - prisma: Pick, + prisma: Pick< + PrismaClient, + "vaccinationRecord" | "vaccinationDocumentLink" | "customVaccine" + >, 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, - }), - ]); + const [customVaccineRows, vaccinationRows, documentLinkRows] = + await Promise.all([ + prisma.customVaccine.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + orderBy: [{ name: "asc" }, { id: "asc" }], + select: CUSTOM_VACCINE_BACKUP_SELECT, + }), + prisma.vaccinationRecord.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + orderBy: { occurredAt: "desc" }, + select: VACCINATION_BACKUP_SELECT, + }), + prisma.vaccinationDocumentLink.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: VACCINATION_DOCUMENT_LINK_BACKUP_SELECT, + }), + ]); return { + customVaccines: customVaccineRows.map((row) => ({ + id: row.id, + name: row.name, + components: [...row.components], + typicalSeriesDoses: row.typicalSeriesDoses, + boosterIntervalMonths: row.boosterIntervalMonths, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(disasterRecovery + ? { deletedAt: row.deletedAt?.toISOString() ?? null } + : {}), + })), vaccinations: vaccinationRows.map((row) => ({ id: row.id, occurredAt: row.occurredAt.toISOString(), @@ -172,6 +229,7 @@ export async function buildVaccinationsBackupSection( practitionerId: row.practitionerId, encounterId: row.encounterId, reminderId: row.reminderId, + customVaccineId: row.customVaccineId, ...(disasterRecovery ? { noteEncrypted: encodeSealedBytes(row.noteEncrypted) } : { @@ -199,6 +257,7 @@ export function countVaccinationsBackupSection( section: VaccinationsBackupSection, ): VaccinationsBackupCounts { return { + customVaccines: section.customVaccines.length, vaccinations: section.vaccinations.length, vaccinationLinks: section.vaccinationDocumentLinks.length, }; @@ -206,6 +265,7 @@ export function countVaccinationsBackupSection( /** Counts the immunization restore wiped, for the audit trail. */ export interface VaccinationsRestoreCleared { + customVaccines: number; vaccinations: number; vaccinationLinks: number; } @@ -219,10 +279,22 @@ export interface VaccinationsRestoreCleared { * times and reports success. */ export interface VaccinationsRestoreInput { + customVaccines: RestoredCustomVaccine[]; vaccinations: RestoredVaccination[]; vaccinationDocumentLinks: VaccinationDocumentLinkBackupEntry[]; } +/** A definition as the parsed file presents it. */ +export type RestoredCustomVaccine = Pick< + CustomVaccineBackupEntry, + "id" | "name" | "createdAt" | "updatedAt" +> & { + components?: string[] | undefined; + typicalSeriesDoses?: number | null | undefined; + boosterIntervalMonths?: number | null | undefined; + deletedAt?: string | null | undefined; +}; + /** * What the restore reads, as the parsed file actually presents it — a wider * set than what this release writes: a portable export omits the tombstone, @@ -246,6 +318,7 @@ export type RestoredVaccination = Pick< | "practitionerId" | "encounterId" | "reminderId" + | "customVaccineId" | "noteEncrypted" | "note" > @@ -276,6 +349,32 @@ export async function restoreVaccinationsData( const clearedVaccinations = await tx.vaccinationRecord.deleteMany({ where: { userId: ownerId }, }); + // After the doses that point at them, so neither count reads short. + const clearedCustomVaccines = await tx.customVaccine.deleteMany({ + where: { userId: ownerId }, + }); + + // The definitions first: a dose addresses one by id. + if (payload.customVaccines.length > 0) { + await tx.customVaccine.createMany({ + data: payload.customVaccines.map((entry) => ({ + id: entry.id, + userId: ownerId, + name: entry.name, + // Verbatim, like a dose's slug: an antigen this release does not know + // still says what the person meant, and the resolver skips it. + components: entry.components ?? [], + typicalSeriesDoses: entry.typicalSeriesDoses ?? null, + boosterIntervalMonths: entry.boosterIntervalMonths ?? null, + createdAt: new Date(entry.createdAt), + updatedAt: new Date(entry.updatedAt), + deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + })), + }); + } + const restoredCustomVaccines = new Set( + payload.customVaccines.map((entry) => entry.id), + ); // All three far sides are restored by other branches of the same // transaction, so the check is against the database rather than against the @@ -308,6 +407,7 @@ export async function restoreVaccinationsData( const droppedPractitioners: string[] = []; const droppedEncounters: string[] = []; const droppedReminders: string[] = []; + const droppedCustomVaccines: string[] = []; // Sealed notes this host's keys do not open, by file path. const unopened: string[] = []; @@ -336,6 +436,14 @@ export async function restoreVaccinationsData( if (entry.reminderId && reminderId === null) { droppedReminders.push(entry.reminderId); } + const customVaccineId = + entry.customVaccineId && + restoredCustomVaccines.has(entry.customVaccineId) + ? entry.customVaccineId + : null; + if (entry.customVaccineId && customVaccineId === null) { + droppedCustomVaccines.push(entry.customVaccineId); + } return { id: entry.id, userId: ownerId, @@ -352,6 +460,7 @@ export async function restoreVaccinationsData( practitionerId, encounterId, reminderId, + customVaccineId, noteEncrypted: sealBytesForRestore( entry.noteEncrypted, entry.note, @@ -384,6 +493,12 @@ export async function restoreVaccinationsData( [...new Set(droppedReminders)], droppedReminders, ); + recordUnknownKeys( + skips, + "vaccinationReference", + [...new Set(droppedCustomVaccines)], + droppedCustomVaccines, + ); recordUnknownKeys(skips, "vaccinationCiphertext", unopened, unopened); const restoredVaccinations = new Set( @@ -433,6 +548,7 @@ export async function restoreVaccinationsData( ); return { + customVaccines: clearedCustomVaccines.count, vaccinations: clearedVaccinations.count, vaccinationLinks: clearedLinks.count, }; diff --git a/src/lib/http/not-implemented.ts b/src/lib/http/not-implemented.ts deleted file mode 100644 index 1e4b0bac7..000000000 --- a/src/lib/http/not-implemented.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * The answer a route gives while its contract exists and its body does not. - * - * A release built in parallel packages lands every new route as a stub first, - * registered in the OpenAPI table with its final shape, so the route-coverage - * and contract checks hold in every package's worktree from the start. The - * stub authenticates exactly as the finished route will and then answers 501. - * - * Nothing may ship with a stub in it: the error-code catalogue check fails on - * a listed code no route emits, so the code leaves the catalogue, and this - * file with it, once the last stub is implemented. - */ -import { apiError } from "@/lib/api-response"; - -export const NOT_IMPLEMENTED_ERROR_CODE = "not_implemented"; - -export function notImplemented() { - return apiError("Not implemented", 501, { - errorCode: NOT_IMPLEMENTED_ERROR_CODE, - }); -} diff --git a/src/lib/import/health-connect/import.ts b/src/lib/import/health-connect/import.ts index 320f27745..ec20132fc 100644 --- a/src/lib/import/health-connect/import.ts +++ b/src/lib/import/health-connect/import.ts @@ -19,8 +19,9 @@ * would count a step the phone and the watch both saw twice. * - Heart rate: per-minute samples of the last 90 days as raw rows, older * ones as hourly means (min and max kept), one app per hour by the vitals - * priority. The Apple Health retention fold only folds Apple Health rows, - * so this import folds its own history as it writes it. + * priority. The nightly retention fold folds the raw samples into the + * same hourly `stats:` rows once they age past 90 days, and a raw sample + * whose hour is already folded is left out (`folded_window`). * - Sleep: one row per stage (`sleep-stage-map.ts`), a session without * stages as one asleep row; of two overlapping sessions from different * apps the higher-ranked app's is kept. @@ -58,6 +59,12 @@ import { valuesMatch, } from "@/lib/measurements/cross-source-merge"; import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement"; +import { + FOLDED_WINDOW_REASON, + findFoldedWindowDuplicates, + isFoldedWindowCandidate, + loadFoldTimezone, +} from "@/lib/measurements/folded-window"; import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { validateMeasurementRange } from "@/lib/validations/measurement"; import { isModuleEnabled } from "@/lib/modules/gate"; @@ -214,6 +221,8 @@ class ImportRun { private readonly userId: string; private readonly tz: string; private readonly now: Date; + /** The account zone the fold keys its hours in, read once on first need. */ + private foldTz: string | undefined; private readonly apps = new Map(); private readonly leftOutApps = new Set(); private readonly devices = new Map(); @@ -1335,6 +1344,7 @@ class ImportRun { }); if (sameReadingCheck) batch = await this.withoutOtherSourceTwins(batch); if (hourlyPulse) batch = await this.withoutRawPulseHours(batch); + else batch = await this.withoutFoldedHours(batch); if (batch.length === 0) return; const inserted = await insertNewMeasurementRows( @@ -1521,6 +1531,38 @@ class ImportRun { }); } + /** + * A raw sample of a folded type is left out when the nightly fold has + * already turned its hour into a `stats:` mean under this source. The fold + * deletes the samples it summarises, so without this a later import of an + * export that still holds them would put them back beside their own mean. + * Same rule as the Apple Health batch route and ZIP import + * (`folded-window.ts`); rows too young to have been folded cost no query. + */ + private async withoutFoldedHours(rows: PendingRow[]): Promise { + if (rows.length === 0) return rows; + const candidates = rows.map((row) => ({ + type: row.type, + source: SOURCE, + externalId: row.externalId, + measuredAt: row.measuredAt, + })); + if (!candidates.some((row) => isFoldedWindowCandidate(row))) return rows; + this.foldTz ??= await loadFoldTimezone(this.prisma, this.userId); + const folded = await findFoldedWindowDuplicates( + this.prisma, + this.userId, + candidates, + { tz: this.foldTz }, + ); + if (folded.size === 0) return rows; + return rows.filter((row, index) => { + if (!folded.has(index)) return true; + this.skip(FOLDED_WINDOW_REASON, 1, row.type); + return false; + }); + } + /** Called by the collapse when two apps write the same instant. */ noteCollision(type: string): void { this.skip("same_instant_other_app", 1, type); diff --git a/src/lib/jobs/dense-intraday-retention.ts b/src/lib/jobs/dense-intraday-retention.ts index a0c52a11a..7cbcdfad1 100644 --- a/src/lib/jobs/dense-intraday-retention.ts +++ b/src/lib/jobs/dense-intraday-retention.ts @@ -25,6 +25,7 @@ import { runDenseIntradayRetention, DENSE_INTRADAY_RETENTION_TYPES, DENSE_INTRADAY_RETENTION_DAYS, + DENSE_INTRADAY_FOLD_SOURCES, } from "@/lib/measurements/dense-intraday-retention"; export const DENSE_INTRADAY_RETENTION_QUEUE = "dense-intraday-retention"; @@ -140,8 +141,8 @@ export async function runDenseIntradayRetentionForUser( /** * Boot-time discovery. Finds every user holding at least one LIVE - * per-sample dense-tier row (an `APPLE_HEALTH` row of a - * `DENSE_INTRADAY_RETENTION_TYPES` type whose externalId does NOT start + * per-sample dense-tier row (a row of a `DENSE_INTRADAY_FOLD_SOURCES` source + * and a `DENSE_INTRADAY_RETENTION_TYPES` type whose externalId does NOT start * with the daily-stats prefix, that is not tombstoned, AND whose * `measuredAt` is older than the retention window) and enqueues one * retention job per account. @@ -187,7 +188,7 @@ export async function enqueueBootTimeDenseIntradayRetention(): Promise<{ const users = await prisma.measurement.groupBy({ by: ["userId"], where: { - source: "APPLE_HEALTH", + source: { in: [...DENSE_INTRADAY_FOLD_SOURCES] }, type: { in: types }, deletedAt: null, NOT: { externalId: { startsWith: "stats:" } }, diff --git a/src/lib/mcp/record-search.ts b/src/lib/mcp/record-search.ts index bb1b69ffc..38eaf0870 100644 --- a/src/lib/mcp/record-search.ts +++ b/src/lib/mcp/record-search.ts @@ -66,7 +66,11 @@ import { listTargets } from "@/lib/links"; import type { LinkedTarget } from "@/lib/links/link-service"; import { isModuleEnabled, type ModuleKey } from "@/lib/modules/gate"; import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; -import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import { + CUSTOM_VACCINE_RESOLVE_SELECT, + customLookupOf, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; // ── Bounds ─────────────────────────────────────────────────────────── @@ -430,8 +434,8 @@ function vaccineCatalogName( slug: string | null, locale: Locale, ): string | null { - const entry = resolveCatalogEntry(slug); - if (!entry) return null; + const entry = resolveVaccineEntry({ antigenSlug: slug }); + if (!entry?.slug) return null; return getServerTranslator(locale).t(`vaccinations.catalog.${entry.slug}`); } @@ -646,8 +650,27 @@ const VACCINATION_SELECT = { lotNumber: true, site: true, practitioner: PRACTITIONER_SELECT, + // v1.42 (#1005) — the person's own definition, for its name and antigens. + customVaccineId: true, + customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT }, } as const; +/** + * The person's own vaccine definition a dose names, when it is the answer + * (the catalogue wins when the dose also carries a slug). Its name is user + * text and is scrubbed or fenced by the caller. + */ +function ownVaccine(row: { + antigenSlug: string | null; + customVaccineId: string | null; + customVaccine: Parameters[0][number]; +}): { name: string; components: readonly string[] } | null { + const entry = resolveVaccineEntry(row, customLookupOf([row.customVaccine])); + return entry?.kind === "custom" && entry.name + ? { name: entry.name, components: entry.components } + : null; +} + async function conditionCandidates( userId: string, owner: Owner, @@ -858,10 +881,15 @@ async function vaccinationCandidates( }); return rows.map((row) => { const practitioner = livePractitioner(row.practitioner); - const entry = resolveCatalogEntry(row.antigenSlug); + const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug }); + const own = ownVaccine(row); const catalogEn = vaccineCatalogName(row.antigenSlug, "en"); const name = - row.vaccineName ?? catalogEn ?? row.antigenSlug ?? "Vaccination"; + row.vaccineName ?? + catalogEn ?? + own?.name ?? + row.antigenSlug ?? + "Vaccination"; const dose = row.doseNumber ? `dose ${row.doseNumber}, ` : ""; const day = dateOnly(row.occurredAt); return { @@ -881,6 +909,10 @@ async function vaccinationCandidates( ? enumWords(row.antigenSlug.replace(/-/g, "_")) : "", ...(entry?.synonyms ?? []), + own?.name ?? "", + ...(own?.components ?? []).map((antigen) => + enumWords(antigen.replace(/-/g, "_")), + ), ].join(" "), weight: WEIGHT.primary, }, @@ -1072,6 +1104,8 @@ async function fetchVisit( vaccineName: true, antigenSlug: true, occurredAt: true, + customVaccineId: true, + customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT }, }, orderBy: [{ occurredAt: "asc" }, { id: "asc" }], }) @@ -1087,6 +1121,7 @@ async function fetchVisit( label: scrubFenceMarkers( dose.vaccineName ?? vaccineCatalogName(dose.antigenSlug, "en") ?? + ownVaccine(dose)?.name ?? dose.antigenSlug ?? "Vaccination", ), @@ -1377,7 +1412,9 @@ async function fetchVaccination( ]); const catalog = vaccineCatalogName(row.antigenSlug, "en"); - const name = row.vaccineName ?? catalog ?? row.antigenSlug ?? "Vaccination"; + const own = ownVaccine(row); + const name = + row.vaccineName ?? catalog ?? own?.name ?? row.antigenSlug ?? "Vaccination"; const practitioner = livePractitioner(row.practitioner); const day = dateOnly(row.occurredAt); const dose = @@ -1388,6 +1425,13 @@ async function fetchVaccination( const sentences = [ `Vaccination ${fenceUserText(name)} on ${day}, ${dose}.`, catalog && row.vaccineName ? `Catalogue entry: ${catalog}.` : null, + own + ? `Logged against the person's own vaccine ${fenceUserText(own.name)}${ + own.components.length > 0 + ? `, which protects against ${own.components.join(", ")}` + : "" + }.` + : null, practitioner ? `Given by ${fenceUserText(practitioner.name)}.` : "No practitioner recorded.", diff --git a/src/lib/measurements/__tests__/dense-intraday-pulse-summary.test.ts b/src/lib/measurements/__tests__/dense-intraday-pulse-summary.test.ts index aee9a1c02..714fd0819 100644 --- a/src/lib/measurements/__tests__/dense-intraday-pulse-summary.test.ts +++ b/src/lib/measurements/__tests__/dense-intraday-pulse-summary.test.ts @@ -84,8 +84,12 @@ function buildPrismaMock(opts: { }; // Top-level client. - const findMany = vi.fn(async (args: { where: { type: string } }) => - args.where.type === "PULSE" ? opts.pulseRows : [], + // The seeded rows are Apple Health's; the Health Connect walk finds none. + const findMany = vi.fn( + async (args: { where: { type: string; source: string } }) => + args.where.type === "PULSE" && args.where.source === "APPLE_HEALTH" + ? opts.pulseRows + : [], ); // Native-resting probe is the only top-level `findFirst` call. const topFindFirst = vi diff --git a/src/lib/measurements/__tests__/dense-intraday-resting-mint.test.ts b/src/lib/measurements/__tests__/dense-intraday-resting-mint.test.ts index e72ae5db1..9c9c0ce2a 100644 --- a/src/lib/measurements/__tests__/dense-intraday-resting-mint.test.ts +++ b/src/lib/measurements/__tests__/dense-intraday-resting-mint.test.ts @@ -106,8 +106,13 @@ function buildPrismaMock(existingResting: Array<{ source: string }>) { .mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]), }, measurement: { - findMany: vi.fn(async (args: { where: { type: string } }) => - args.where.type === "PULSE" ? pulseRows() : [], + // The seeded rows are Apple Health's; the Health Connect walk + // finds none. + findMany: vi.fn( + async (args: { where: { type: string; source: string } }) => + args.where.type === "PULSE" && args.where.source === "APPLE_HEALTH" + ? pulseRows() + : [], ), findFirst: probeFindFirst, upsert, diff --git a/src/lib/measurements/__tests__/dense-intraday-retention.test.ts b/src/lib/measurements/__tests__/dense-intraday-retention.test.ts index 740f40d21..e660d5731 100644 --- a/src/lib/measurements/__tests__/dense-intraday-retention.test.ts +++ b/src/lib/measurements/__tests__/dense-intraday-retention.test.ts @@ -62,9 +62,12 @@ function buildPrismaMock( ); const updateMany = vi.fn().mockResolvedValue({ count: 0 }); const deleteMany = vi.fn().mockResolvedValue({ count: 0 }); + // The seeded rows are Apple Health's; the Health Connect walk finds none. const findManyMeasurement = vi.fn( - async (args: { where: { type: string } }) => - rowsByType[args.where.type] ?? [], + async (args: { where: { type: string; source: string } }) => + args.where.source === "APPLE_HEALTH" + ? (rowsByType[args.where.type] ?? []) + : [], ); const tx = { measurement: { @@ -140,20 +143,21 @@ describe("runDenseIntradayRetention — retention bound", () => { } }); - it("scans exactly the dense-tier types, source-scoped to APPLE_HEALTH", async () => { + it("scans exactly the dense-tier types, once per fold source, Apple Health first", async () => { const { mock, findManyMeasurement } = buildPrismaMock({}); await runDenseIntradayRetention(mock, { log: () => {} }); - const scannedTypes = findManyMeasurement.mock.calls.map( - (c) => (c[0] as { where: { type: string } }).where.type, - ); - expect(scannedTypes.sort()).toEqual( - Array.from(DENSE_INTRADAY_RETENTION_TYPES).sort(), - ); - for (const call of findManyMeasurement.mock.calls) { - const where = (call[0] as unknown as { where: { source: string } }).where; - expect(where.source).toBe("APPLE_HEALTH"); - } + const scans = findManyMeasurement.mock.calls.map((c) => { + const where = (c[0] as { where: { type: string; source: string } }).where; + return `${where.source}:${where.type}`; + }); + const types = Array.from(DENSE_INTRADAY_RETENTION_TYPES); + // One walk per source, each scoped to its one source: two sources + // never share a mean, and manual or Withings rows are never scanned. + expect(scans).toEqual([ + ...types.map((type) => `APPLE_HEALTH:${type}`), + ...types.map((type) => `HEALTH_CONNECT:${type}`), + ]); }); }); diff --git a/src/lib/measurements/__tests__/dense-intraday-spo2-summary.test.ts b/src/lib/measurements/__tests__/dense-intraday-spo2-summary.test.ts index 6ae4152e5..23c4e2fa7 100644 --- a/src/lib/measurements/__tests__/dense-intraday-spo2-summary.test.ts +++ b/src/lib/measurements/__tests__/dense-intraday-spo2-summary.test.ts @@ -75,8 +75,13 @@ function buildPrismaMock(opts: { spo2Rows: PerSampleRow[] }) { }, }; - const findMany = vi.fn(async (args: { where: { type: string } }) => - args.where.type === "OXYGEN_SATURATION" ? opts.spo2Rows : [], + // The seeded rows are Apple Health's; the Health Connect walk finds none. + const findMany = vi.fn( + async (args: { where: { type: string; source: string } }) => + args.where.type === "OXYGEN_SATURATION" && + args.where.source === "APPLE_HEALTH" + ? opts.spo2Rows + : [], ); // Native-resting probe + derived-resting upsert: PULSE-only surfaces. const topFindFirst = vi.fn().mockResolvedValue(null); diff --git a/src/lib/measurements/__tests__/folded-window.test.ts b/src/lib/measurements/__tests__/folded-window.test.ts index f75359642..4c9bc9b0d 100644 --- a/src/lib/measurements/__tests__/folded-window.test.ts +++ b/src/lib/measurements/__tests__/folded-window.test.ts @@ -226,3 +226,80 @@ describe("findCompactionTombstones", () => { expect(found.size).toBe(0); }); }); + +/** + * Health Connect folds its raw heart-rate history the way Apple Health does: + * past the 90-day window into one `stats:` row per local hour, under its own + * source. The mean-day rule stays Apple Health only, because the nightly + * mean consolidation never reads another source. + */ +describe("Health Connect", () => { + const old = new Date("2026-03-01T10:15:00.000Z"); + const hc = ( + type: string, + at: Date, + overrides: Record = {}, + ) => + raw(type, at, { + source: "HEALTH_CONNECT", + externalId: "hc:00000000-0000-0000-0000-000000000001:1", + ...overrides, + }); + + /** A lookup that answers per source, as the database does. */ + function sourceAwareClient( + live: Array<{ type: string; source: string; externalId: string }>, + ) { + const findMany = vi.fn(async (args: { where: { source: string } }) => + live.filter((row) => row.source === args.where.source), + ); + return { + findMany, + client: { + measurement: { findMany }, + } as unknown as Parameters[0], + }; + } + + it("takes a raw dense sample past the window, and not a mean-type one", () => { + expect(isFoldedWindowCandidate(hc("PULSE", old), NOW)).toBe(true); + expect( + isFoldedWindowCandidate( + hc("PULSE", new Date(NOW.getTime() - 89 * DAY)), + NOW, + ), + ).toBe(false); + expect(isFoldedWindowCandidate(hc("RESPIRATORY_RATE", old), NOW)).toBe( + false, + ); + expect( + isFoldedWindowCandidate( + hc("PULSE", old, { + externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-03-01T10", + }), + NOW, + ), + ).toBe(false); + }); + + it("is covered only by a `stats:` row of its own source", async () => { + const hour = "stats:HKQuantityTypeIdentifierHeartRate:2026-03-01T10"; + const { client: c, findMany } = sourceAwareClient([ + { type: "PULSE", source: "HEALTH_CONNECT", externalId: hour }, + ]); + const found = await findFoldedWindowDuplicates( + c, + "u1", + [hc("PULSE", old), raw("PULSE", old)], + { now: NOW, tz: "UTC" }, + ); + // The Health Connect sample is a duplicate; the Apple Health sample of + // the same hour is not, because no Apple Health `stats:` row covers it. + expect([...found]).toEqual([0]); + const sources = findMany.mock.calls.map( + (call) => + (call as unknown as [{ where: { source: string } }])[0].where.source, + ); + expect(sources.sort()).toEqual(["APPLE_HEALTH", "HEALTH_CONNECT"]); + }); +}); diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts index f13ec2fb9..1cbf1ab9d 100644 --- a/src/lib/measurements/dense-intraday-retention.ts +++ b/src/lib/measurements/dense-intraday-retention.ts @@ -50,6 +50,7 @@ */ import { holdAccountAgainstRestore } from "@/lib/export/restore-lock"; import type { + MeasurementSource, MeasurementType, Prisma, PrismaClient, @@ -154,6 +155,23 @@ export const DENSE_INTRADAY_RETENTION_TYPES: ReadonlySet = */ export const DENSE_INTRADAY_RETENTION_DAYS = 90; +/** + * The sources whose raw dense samples fold (v1.42). Apple Health from the + * start; Health Connect because its importer writes the samples of the last + * 90 days raw, exactly like an Apple Health sync, and without a fold every + * new export would add another quarter of minute-by-minute heart rate that + * never ages into hourly means. Each source folds into its OWN `stats:` rows, + * so an hour two sources both covered keeps one mean per source, the same + * way the raw samples kept their source. + */ +export const DENSE_INTRADAY_FOLD_SOURCES = [ + "APPLE_HEALTH", + "HEALTH_CONNECT", +] as const satisfies readonly MeasurementSource[]; + +export type DenseIntradayFoldSource = + (typeof DENSE_INTRADAY_FOLD_SOURCES)[number]; + /** The stats externalId prefix marks an already-collapsed row. */ const DAILY_STATS_PREFIX = "stats:"; @@ -249,10 +267,13 @@ export async function adoptOrMintHourlyRows( input: { userId: string; type: MeasurementType; + /** The source the folded samples carry; the hourly rows carry it too. */ + source?: DenseIntradayFoldSource; unit: string; slots: readonly HourlySlot[]; }, ): Promise { + const source = input.source ?? "APPLE_HEALTH"; if (input.slots.length === 0) return []; // Every row that either index could put in the way, lowest id first — the @@ -263,7 +284,7 @@ export async function adoptOrMintHourlyRows( where: { userId: input.userId, type: input.type, - source: "APPLE_HEALTH", + source, OR: [ { externalId: { in: input.slots.map((slot) => slot.externalId) } }, { @@ -338,7 +359,7 @@ export async function adoptOrMintHourlyRows( type: input.type, value: slot.value, unit: input.unit, - source: "APPLE_HEALTH" as const, + source, measuredAt: slot.anchor, externalId: slot.externalId, })), @@ -538,263 +559,280 @@ export async function runDenseIntradayRetention( // for the dry-run hourly fan-out estimate (writeDay never runs on dry-run). let currentTz = DEFAULT_TIMEZONE; - const walk = await runConsolidation({ - prismaClient, - options: { ...options, cutoffHours }, - types: DENSE_INTRADAY_RETENTION_TYPES, - hkIdentifierForType, - dailyStatsExternalId, - statsPrefix: DAILY_STATS_PREFIX, - // The base's per-day reducedValue is the day MEAN; the hourly fold - // recomputes per-hour means itself (same `meanBucketValue` reducer, so - // the two surfaces can never drift), and the day-level value only - // feeds the summary/dry-run reporting. - reduce: meanBucketValue, - // Live per-sample rows for the type, source-scoped to APPLE_HEALTH so - // manual + Withings spot rows survive. The minted hourly stats rows are - // excluded by the NOT-startsWith predicate so they are never re-folded; - // soft-deleted rows are excluded so a re-run converges. `cutoffAt` is - // the retention boundary — rows newer than it (inside the window) are - // NOT scanned, so the dense intra-day shape is preserved. - buildScanWhere: ({ userId, type, cutoffAt, statsPrefix }) => ({ - userId, - source: "APPLE_HEALTH", - type, - deletedAt: null, - NOT: { externalId: { startsWith: statsPrefix } }, - ...(cutoffAt ? { measuredAt: { lt: cutoffAt } } : {}), - }), - scanSelect: { - id: true, - type: true, - value: true, - measuredAt: true, - externalId: true, - unit: true, - }, - writeDay: async ({ - prismaClient: pc, - userId, - type, - externalId, - canonicalTimestamp, - dayRows, - sourceRowIds, - tz, - dateKey, - }): Promise => { - const unit = dayRows[0]?.unit ?? "unknown"; - const isPulse = type === "PULSE"; - const hkIdentifier = hkIdentifierForType(type); - // Unreachable: the base skips types without an HK identifier before - // scanning; asserted so a future type-set edit fails loud, not by - // minting a malformed externalId. - if (!hkIdentifier) { - throw new Error(`no HK identifier for dense-tier type ${type}`); - } + // Awaited inside, not returned bare: the restore-lock guard finds the + // consolidation passes by their `await runConsolidation`. + const walkSource = async (source: DenseIntradayFoldSource) => + await runConsolidation({ + prismaClient, + options: { ...options, cutoffHours }, + types: DENSE_INTRADAY_RETENTION_TYPES, + hkIdentifierForType, + dailyStatsExternalId, + statsPrefix: DAILY_STATS_PREFIX, + // The base's per-day reducedValue is the day MEAN; the hourly fold + // recomputes per-hour means itself (same `meanBucketValue` reducer, so + // the two surfaces can never drift), and the day-level value only + // feeds the summary/dry-run reporting. + reduce: meanBucketValue, + // Live per-sample rows for the type, scoped to the one source this walk + // folds so manual + Withings spot rows survive and two sources never + // share a mean. The minted hourly stats rows are + // excluded by the NOT-startsWith predicate so they are never re-folded; + // soft-deleted rows are excluded so a re-run converges. `cutoffAt` is + // the retention boundary — rows newer than it (inside the window) are + // NOT scanned, so the dense intra-day shape is preserved. + buildScanWhere: ({ userId, type, cutoffAt, statsPrefix }) => ({ + userId, + source, + type, + deletedAt: null, + NOT: { externalId: { startsWith: statsPrefix } }, + ...(cutoffAt ? { measuredAt: { lt: cutoffAt } } : {}), + }), + scanSelect: { + id: true, + type: true, + value: true, + measuredAt: true, + externalId: true, + unit: true, + }, + writeDay: async ({ + prismaClient: pc, + userId, + type, + externalId, + canonicalTimestamp, + dayRows, + sourceRowIds, + tz, + dateKey, + }): Promise => { + const unit = dayRows[0]?.unit ?? "unknown"; + const isPulse = type === "PULSE"; + const hkIdentifier = hkIdentifierForType(type); + // Unreachable: the base skips types without an HK identifier before + // scanning; asserted so a future type-set edit fails loud, not by + // minting a malformed externalId. + if (!hkIdentifier) { + throw new Error(`no HK identifier for dense-tier type ${type}`); + } - // DAY-rollup fidelity — mean AND min/max (all dense-tier types). The - // persistent `MeasurementRollup` DAY bucket aggregates LIVE rows; - // recomputing it AFTER the fold would aggregate the hourly means: the - // min/max collapse to the extreme hourly MEANS (for SpO2 hiding the - // overnight-desaturation nadir; for PULSE the resting floor + workout - // peak) and the mean becomes an unweighted mean-of-hourly-means that - // drifts whenever per-hour sample counts differ. So the DAY bucket is - // recomputed from the RAW rows BEFORE the fold, while they are still - // live, and the post-fold DAY recompute is skipped entirely. The - // WEEK/MONTH/YEAR recomputes this call enqueues run post-commit - // against whatever is live then, as before. - await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp); + // DAY-rollup fidelity — mean AND min/max (all dense-tier types). The + // persistent `MeasurementRollup` DAY bucket aggregates LIVE rows; + // recomputing it AFTER the fold would aggregate the hourly means: the + // min/max collapse to the extreme hourly MEANS (for SpO2 hiding the + // overnight-desaturation nadir; for PULSE the resting floor + workout + // peak) and the mean becomes an unweighted mean-of-hourly-means that + // drifts whenever per-hour sample counts differ. So the DAY bucket is + // recomputed from the RAW rows BEFORE the fold, while they are still + // live, and the post-fold DAY recompute is skipped entirely. The + // WEEK/MONTH/YEAR recomputes this call enqueues run post-commit + // against whatever is live then, as before. + await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp); - // Fold the day into per-LOCAL-HOUR mean rows, anchored at local - // HH:30. Sorted for a deterministic write order across runs. - const byHour = [...bucketRowsByLocalHour(dayRows, tz).entries()].sort( - (a, b) => a[0] - b[0], - ); + // Fold the day into per-LOCAL-HOUR mean rows, anchored at local + // HH:30. Sorted for a deterministic write order across runs. + const byHour = [...bucketRowsByLocalHour(dayRows, tz).entries()].sort( + (a, b) => a[0] - b[0], + ); - const foldOnce = async (): Promise<{ - removed: number; - retiredDaily: boolean; - }> => { - let removed = 0; - let retiredDaily = false; - await pc.$transaction(async (tx) => { - // First, before any reading is touched: see `restore-lock.ts`. - await holdAccountAgainstRestore(tx, userId); - const canonicalRowIds = await adoptOrMintHourlyRows(tx, { - userId, - type, - unit, - slots: byHour.map(([hour, hourRows]) => ({ - externalId: hourlyStatsExternalId(hkIdentifier, dateKey, hour), - anchor: canonicalHourlyTimestamp(dateKey, hour, tz), - value: meanBucketValue(hourRows), - })), - }); - - // Retire a live pre-hourly DAILY `stats:` row for this day (the - // late-sync path: raw samples arriving for a day folded to the - // daily grain before v1.28.31). Same transaction as the hourly - // mint — an AVG-over-live-rows reader must never see the daily - // row and the hourly rows live at once (double count). - const dailyRow = await tx.measurement.findFirst({ - where: { + const foldOnce = async (): Promise<{ + removed: number; + retiredDaily: boolean; + }> => { + let removed = 0; + let retiredDaily = false; + await pc.$transaction(async (tx) => { + // First, before any reading is touched: see `restore-lock.ts`. + await holdAccountAgainstRestore(tx, userId); + const canonicalRowIds = await adoptOrMintHourlyRows(tx, { userId, type, - source: "APPLE_HEALTH", - externalId, - deletedAt: null, - }, - select: { id: true }, - orderBy: { id: "asc" }, - }); - if (dailyRow && !canonicalRowIds.includes(dailyRow.id)) { - await tx.measurement.update({ - where: { id: dailyRow.id }, - data: { deletedAt: new Date() }, + source, + unit, + slots: byHour.map(([hour, hourRows]) => ({ + externalId: hourlyStatsExternalId(hkIdentifier, dateKey, hour), + anchor: canonicalHourlyTimestamp(dateKey, hour, tz), + value: meanBucketValue(hourRows), + })), }); - retiredDaily = true; - } - // Delete the out-of-window per-sample rows in the same - // transaction. EXCLUDE the adopted canonical rows: a per-sample row - // that happened to fall on an hourly anchor is the row just adopted - // as that hour's mean, so removing it would erase the fold. - // - // v1.42 — deleted outright, like the cumulative drain's rows, - // where they used to be tombstoned for the 75-day retention. No - // client ever pulled these rows, so the tombstone told nobody - // anything; it only held the re-upload of a folded sample off, - // and the `folded_window` ingest guard (`folded-window.ts`) does - // that now from the live hourly row. The tombstones made up most - // of the table and its indexes. - const del = await tx.measurement.deleteMany({ - where: { - id: { in: sourceRowIds, notIn: canonicalRowIds }, - deletedAt: null, - }, + // Retire a live pre-hourly DAILY `stats:` row for this day (the + // late-sync path: raw samples arriving for a day folded to the + // daily grain before v1.28.31). Same transaction as the hourly + // mint — an AVG-over-live-rows reader must never see the daily + // row and the hourly rows live at once (double count). + const dailyRow = await tx.measurement.findFirst({ + where: { + userId, + type, + source, + externalId, + deletedAt: null, + }, + select: { id: true }, + orderBy: { id: "asc" }, + }); + if (dailyRow && !canonicalRowIds.includes(dailyRow.id)) { + await tx.measurement.update({ + where: { id: dailyRow.id }, + data: { deletedAt: new Date() }, + }); + retiredDaily = true; + } + + // Delete the out-of-window per-sample rows in the same + // transaction. EXCLUDE the adopted canonical rows: a per-sample row + // that happened to fall on an hourly anchor is the row just adopted + // as that hour's mean, so removing it would erase the fold. + // + // v1.42 — deleted outright, like the cumulative drain's rows, + // where they used to be tombstoned for the 75-day retention. No + // client ever pulled these rows, so the tombstone told nobody + // anything; it only held the re-upload of a folded sample off, + // and the `folded_window` ingest guard (`folded-window.ts`) does + // that now from the live hourly row. The tombstones made up most + // of the table and its indexes. + const del = await tx.measurement.deleteMany({ + where: { + id: { in: sourceRowIds, notIn: canonicalRowIds }, + deletedAt: null, + }, + }); + removed = del.count; }); - removed = del.count; - }); - return { removed, retiredDaily }; - }; + return { removed, retiredDaily }; + }; - let foldResult: { removed: number; retiredDaily: boolean }; - try { - foldResult = await foldOnce(); - } catch (err) { - if (!isUniqueConstraintViolation(err)) throw err; - // A concurrent writer won a slot mid-transaction. Retry once: the - // deterministic lookup now resolves the winning row and adopts it, - // so this pass cannot create a duplicate. - foldResult = await foldOnce(); - } - // Counters mutate OUTSIDE the retryable transaction so a P2002 retry - // cannot double-count. - summary.totals.hourlyRowsUpserted += byHour.length; - if (foldResult.retiredDaily) summary.totals.dailyRowsRetired += 1; + let foldResult: { removed: number; retiredDaily: boolean }; + try { + foldResult = await foldOnce(); + } catch (err) { + if (!isUniqueConstraintViolation(err)) throw err; + // A concurrent writer won a slot mid-transaction. Retry once: the + // deterministic lookup now resolves the winning row and adopts it, + // so this pass cannot create a duplicate. + foldResult = await foldOnce(); + } + // Counters mutate OUTSIDE the retryable transaction so a P2002 retry + // cannot double-count. + summary.totals.hourlyRowsUpserted += byHour.length; + if (foldResult.retiredDaily) summary.totals.dailyRowsRetired += 1; - // iOS#34 — preserve the resting signal for PROXY users. For a user - // with zero native `RESTING_HEART_RATE` rows, the read-path resolver - // derives resting from the 20th-percentile of each day's RAW PULSE; the - // fold has just deleted those rows, so that day's resting figure would - // vanish (the hourly means flatten the low tail the percentile reads). - // Mint one derived `RESTING_HEART_RATE` row from the same percentile, - // sourced `COMPUTED` (distinct from Apple's own resting rows on both - // unique indexes), so the day reads back as the clean resting series - // the resolver prefers. Skipped for users WITH native resting rows - // (the resolver ignores the proxy entirely for them, so a derived row - // would only double-count). - if (isPulse) { - const restingValue = deriveDailyRestingFromPulse(dayRows); - if (restingValue !== null && !(await userHasNativeResting(userId))) { - const restingExternalId = dailyStatsExternalId( - RESTING_HK_IDENTIFIER, - // The fold's canonical timestamp is local-noon of the day; reuse - // its UTC date for the resting row's `stats:` key so a re-run - // upserts the same row instead of minting a sibling. - // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: stable stats: externalId key; changing it would mint a sibling row on re-run - canonicalTimestamp.toISOString().slice(0, 10), - ); - await pc.measurement.upsert({ - where: { - userId_type_source_externalId: { + // iOS#34 — preserve the resting signal for PROXY users. For a user + // with zero native `RESTING_HEART_RATE` rows, the read-path resolver + // derives resting from the 20th-percentile of each day's RAW PULSE; the + // fold has just deleted those rows, so that day's resting figure would + // vanish (the hourly means flatten the low tail the percentile reads). + // Mint one derived `RESTING_HEART_RATE` row from the same percentile, + // sourced `COMPUTED` (distinct from Apple's own resting rows on both + // unique indexes), so the day reads back as the clean resting series + // the resolver prefers. Skipped for users WITH native resting rows + // (the resolver ignores the proxy entirely for them, so a derived row + // would only double-count). + if (isPulse) { + const restingValue = deriveDailyRestingFromPulse(dayRows); + if (restingValue !== null && !(await userHasNativeResting(userId))) { + const restingExternalId = dailyStatsExternalId( + RESTING_HK_IDENTIFIER, + // The fold's canonical timestamp is local-noon of the day; reuse + // its UTC date for the resting row's `stats:` key so a re-run + // upserts the same row instead of minting a sibling. + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: stable stats: externalId key; changing it would mint a sibling row on re-run + canonicalTimestamp.toISOString().slice(0, 10), + ); + await pc.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId, + type: "RESTING_HEART_RATE", + source: "COMPUTED", + externalId: restingExternalId, + }, + }, + create: { userId, type: "RESTING_HEART_RATE", + value: restingValue, + unit: "bpm", source: "COMPUTED", + measuredAt: canonicalTimestamp, externalId: restingExternalId, }, - }, - create: { + update: { value: restingValue, deletedAt: null }, + }); + summary.totals.derivedRestingRowsUpserted += 1; + // The derived row is its own (type, day); recompute its DAY bucket + // so the rollup tier serves it on a covered read. + await recomputeBucketsForMeasurement( userId, - type: "RESTING_HEART_RATE", - value: restingValue, - unit: "bpm", - source: "COMPUTED", - measuredAt: canonicalTimestamp, - externalId: restingExternalId, - }, - update: { value: restingValue, deletedAt: null }, - }); - summary.totals.derivedRestingRowsUpserted += 1; - // The derived row is its own (type, day); recompute its DAY bucket - // so the rollup tier serves it on a covered read. - await recomputeBucketsForMeasurement( - userId, - "RESTING_HEART_RATE", - canonicalTimestamp, - ); + "RESTING_HEART_RATE", + canonicalTimestamp, + ); + } } - } - // No post-fold DAY recompute for ANY dense-tier type — it would - // overwrite the true raw-derived mean/min/max the pre-fold recompute - // above captured with hourly-mean-derived approximations (see the - // fidelity note there). + // No post-fold DAY recompute for ANY dense-tier type — it would + // overwrite the true raw-derived mean/min/max the pre-fold recompute + // above captured with hourly-mean-derived approximations (see the + // fidelity note there). - return { kind: "written", sourceRowsRemoved: foldResult.removed }; - }, - recordBucket: ({ dayRows, outcome }) => { - summary.totals.daysConsolidated += 1; - if (outcome === null) { - // Dry-run: writeDay never ran, so estimate the hourly fan-out here - // from the same bucketing the real fold would use. - summary.totals.hourlyRowsUpserted += bucketRowsByLocalHour( - dayRows, - currentTz, - ).size; - } - summary.totals.perSampleRowsSoftDeleted += - outcome?.kind === "written" - ? outcome.sourceRowsRemoved - : dayRows.length; - }, - onUserStart: ({ tz }) => { - currentTz = tz; - }, - onUserComplete: ({ userId, tz, dryRun }) => { - log( - `[dense-intraday-retention] user=${userId} tz=${tz}${dryRun ? " (dry-run)" : ""}`, - ); - }, - // Per-day failure boundary (v1.18.10 lesson). A day whose fold throws — - // e.g. a residual unique-index collision the adopt-in-place path could - // not absorb, or a derived-resting upsert that races a native row — is - // logged and STEPPED OVER so the global walk keeps draining every other - // user / type / day. The failed day keeps its raw rows live (the fold - // deletes only on a committed transaction), so the next nightly run - // retries it. Without this, one poisoned day aborted the whole walk and - // stranded every later account. - onBucketError: ({ userId, type, dateKey, error }) => { - const reason = error instanceof Error ? error.message : String(error); - log( - `[dense-intraday-retention] user=${userId} type=${type} day=${dateKey} failed — ${reason}; continuing with the next bucket`, - ); - }, - }); + return { kind: "written", sourceRowsRemoved: foldResult.removed }; + }, + recordBucket: ({ dayRows, outcome }) => { + summary.totals.daysConsolidated += 1; + if (outcome === null) { + // Dry-run: writeDay never ran, so estimate the hourly fan-out here + // from the same bucketing the real fold would use. + summary.totals.hourlyRowsUpserted += bucketRowsByLocalHour( + dayRows, + currentTz, + ).size; + } + summary.totals.perSampleRowsSoftDeleted += + outcome?.kind === "written" + ? outcome.sourceRowsRemoved + : dayRows.length; + }, + onUserStart: ({ tz }) => { + currentTz = tz; + }, + onUserComplete: ({ userId, tz, dryRun }) => { + log( + `[dense-intraday-retention] user=${userId} source=${source} tz=${tz}${dryRun ? " (dry-run)" : ""}`, + ); + }, + // Per-day failure boundary (v1.18.10 lesson). A day whose fold throws — + // e.g. a residual unique-index collision the adopt-in-place path could + // not absorb, or a derived-resting upsert that races a native row — is + // logged and STEPPED OVER so the global walk keeps draining every other + // user / type / day. The failed day keeps its raw rows live (the fold + // deletes only on a committed transaction), so the next nightly run + // retries it. Without this, one poisoned day aborted the whole walk and + // stranded every later account. + onBucketError: ({ userId, type, dateKey, error }) => { + const reason = error instanceof Error ? error.message : String(error); + log( + `[dense-intraday-retention] user=${userId} source=${source} type=${type} day=${dateKey} failed — ${reason}; continuing with the next bucket`, + ); + }, + }); - summary.totals.usersScanned = walk.usersScanned; - summary.stoppedEarly = walk.stoppedEarly; + // One walk per source, Apple Health first so its behaviour and its order + // are exactly what they were before Health Connect folded too. A walk that + // stopped early stops the pass: the next run resumes where it left off. + for (const source of DENSE_INTRADAY_FOLD_SOURCES) { + const walk = await walkSource(source); + summary.totals.usersScanned = Math.max( + summary.totals.usersScanned, + walk.usersScanned, + ); + if (walk.stoppedEarly) { + summary.stoppedEarly = true; + break; + } + } log( `[dense-intraday-retention] done — usersScanned=${summary.totals.usersScanned} daysConsolidated=${summary.totals.daysConsolidated} perSampleRowsSoftDeleted=${summary.totals.perSampleRowsSoftDeleted} hourlyRowsUpserted=${summary.totals.hourlyRowsUpserted} dailyRowsRetired=${summary.totals.dailyRowsRetired} derivedRestingRowsUpserted=${summary.totals.derivedRestingRowsUpserted}${options.dryRun ? " (dry-run)" : ""}`, diff --git a/src/lib/measurements/folded-window.ts b/src/lib/measurements/folded-window.ts index 2aeff4167..b6fbdb227 100644 --- a/src/lib/measurements/folded-window.ts +++ b/src/lib/measurements/folded-window.ts @@ -1,14 +1,13 @@ /** * The `folded_window` ingest guard and the compaction-tombstone class (v1.42). * - * The dense-intraday consolidation folds the raw Apple Health samples of a - * DENSE type into one `stats:` row per local hour once they are older than + * The dense-intraday consolidation folds the raw samples of a DENSE type into one `stats:` row per local hour once they are older than * the retention window, and a MEAN type into one `stats:` row per local day * once the consolidation grace has passed. Since v1.42 the folded raw rows are * deleted outright rather than left as tombstones, so a client that re-uploads * an old sample after losing its anchor would put it back beside the hour or - * day that already accounts for it. A raw (non-`stats:`) Apple Health value of - * such a type, old enough to have been folded, is therefore a duplicate when a + * day that already accounts for it. A raw (non-`stats:`) value of such a source + * and type, old enough to have been folded, is therefore a duplicate when a * live `stats:` row covers its hour or day. The batch route and the ZIP import * apply the same rule. * @@ -29,8 +28,12 @@ * the other zone; such a row is then simply not recognised, which is the * pre-v1.42 behaviour, never a wrong refusal. * - * Health Connect does not use this: its importer writes the folded shape - * itself. + * Health Connect folds its raw heart-rate history the same way under its own + * source (the importer writes samples of the last 90 days raw, and the + * nightly pass folds them once they age out), so the hour rule covers both + * sources and a covering row is always looked up under the sample's own + * source. The day rule stays Apple Health only: the nightly mean + * consolidation reads no other source. */ import type { MeasurementSource, @@ -49,6 +52,7 @@ import { hourOfDayForUserTz, } from "@/lib/measurements/consolidation-tz"; import { + DENSE_INTRADAY_FOLD_SOURCES, DENSE_INTRADAY_RETENTION_DAYS, DENSE_INTRADAY_RETENTION_TYPES, hourlyStatsExternalId, @@ -60,15 +64,17 @@ export const FOLDED_WINDOW_REASON: FoldedWindowReason = "folded_window"; const HOUR_MS = 3_600_000; -/** Which types fold into which window, and from what age. */ +/** Which sources and types fold into which window, and from what age. */ export const FOLDED_WINDOW_RULES = [ { window: "hour", + sources: DENSE_INTRADAY_FOLD_SOURCES, types: DENSE_INTRADAY_RETENTION_TYPES, foldedAfterMs: DENSE_INTRADAY_RETENTION_DAYS * 24 * HOUR_MS, }, { window: "day", + sources: ["APPLE_HEALTH"] as const, types: HIGH_FREQUENCY_MEAN_TYPES, foldedAfterMs: CONSOLIDATION_GRACE_CUTOFF_HOURS * HOUR_MS, }, @@ -98,7 +104,6 @@ export function isFoldedWindowCandidate( row: FoldedWindowCandidateRow, now: Date = new Date(), ): boolean { - if (row.source !== "APPLE_HEALTH") return false; if (row.externalId === null) return false; if (row.externalId.startsWith("stats:")) return false; // A collision retirement (`reconcile-external-measurement.ts`) parks the @@ -106,7 +111,10 @@ export function isFoldedWindowCandidate( if (row.externalId.startsWith("retired:")) return false; const age = now.getTime() - row.measuredAt.getTime(); return FOLDED_WINDOW_RULES.some( - (rule) => rule.types.has(row.type) && age > rule.foldedAfterMs, + (rule) => + (rule.sources as readonly MeasurementSource[]).includes(row.source) && + rule.types.has(row.type) && + age > rule.foldedAfterMs, ); } @@ -153,8 +161,9 @@ export async function loadFoldTimezone( } /** - * Indexes (into `rows`) whose covering `stats:` row is live. One query over - * the unique `(user_id, type, source, external_id)` index for the whole set. + * Indexes (into `rows`) whose covering `stats:` row is live under the row's + * own source. One query per source present, each over the unique + * `(user_id, type, source, external_id)` index for the whole set. */ async function coveredIndexes( client: Pick, @@ -163,35 +172,49 @@ async function coveredIndexes( rows: ReadonlyArray<{ index: number; type: MeasurementType; + source: MeasurementSource; measuredAt: Date; }>, ): Promise> { const covered = new Set(); if (rows.length === 0) return covered; - const keyOf = new Map(); - const ids = new Set(); - const types = new Set(); + const bySource = new Map< + MeasurementSource, + { + keyOf: Map; + ids: Set; + types: Set; + } + >(); for (const row of rows) { const id = coveringStatsExternalId(row, tz); if (!id) continue; - keyOf.set(row.index, `${row.type}|${id}`); - ids.add(id); - types.add(row.type); + let group = bySource.get(row.source); + if (!group) { + group = { keyOf: new Map(), ids: new Set(), types: new Set() }; + bySource.set(row.source, group); + } + group.keyOf.set(row.index, `${row.type}|${id}`); + group.ids.add(id); + group.types.add(row.type); } - if (ids.size === 0) return covered; - const live = await client.measurement.findMany({ - where: { - userId, - type: { in: [...types] }, - source: "APPLE_HEALTH", - externalId: { in: [...ids] }, - deletedAt: null, - }, - select: { type: true, externalId: true }, - }); - const liveKeys = new Set(live.map((row) => `${row.type}|${row.externalId}`)); - for (const [index, key] of keyOf) { - if (liveKeys.has(key)) covered.add(index); + for (const [source, group] of bySource) { + const live = await client.measurement.findMany({ + where: { + userId, + type: { in: [...group.types] }, + source, + externalId: { in: [...group.ids] }, + deletedAt: null, + }, + select: { type: true, externalId: true }, + }); + const liveKeys = new Set( + live.map((row) => `${row.type}|${row.externalId}`), + ); + for (const [index, key] of group.keyOf) { + if (liveKeys.has(key)) covered.add(index); + } } return covered; } @@ -211,7 +234,14 @@ export async function findFoldedWindowDuplicates( const now = options.now ?? new Date(); const candidates = rows.flatMap((row, index) => isFoldedWindowCandidate(row, now) - ? [{ index, type: row.type, measuredAt: row.measuredAt }] + ? [ + { + index, + type: row.type, + source: row.source, + measuredAt: row.measuredAt, + }, + ] : [], ); if (candidates.length === 0) return new Set(); @@ -230,7 +260,7 @@ export interface TombstoneCandidateRow extends FoldedWindowCandidateRow { /** * Indexes (into `rows`) of the compaction tombstones ("class A"): soft-deleted - * raw Apple Health samples of a folded type that were already past the fold + * raw samples of a folded source and type that were already past the fold * threshold when they were deleted, and whose hour or day a live `stats:` row * covers. Everything else, in particular a person's own deletions of recent * samples, is class B and keeps the 75-day tombstone retention. @@ -243,7 +273,14 @@ export async function findCompactionTombstones( ): Promise> { const candidates = rows.flatMap((row, index) => row.deletedAt !== null && isFoldedWindowCandidate(row, row.deletedAt) - ? [{ index, type: row.type, measuredAt: row.measuredAt }] + ? [ + { + index, + type: row.type, + source: row.source, + measuredAt: row.measuredAt, + }, + ] : [], ); return coveredIndexes(client, userId, tz, candidates); diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index ce73521b1..43b1f786f 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -389,6 +389,10 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "vaccination.booster-invalid", "vaccination.booster-no-antigen", "vaccination.booster-out-of-scope", + "vaccination.custom-vaccine-not-found", + "vaccination.custom.invalid", + "vaccination.custom.name-taken", + "vaccination.custom.not-found", "vaccination.encounter-not-found", "vaccination.identity-required", "vaccination.invalid", @@ -419,7 +423,6 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "modules_conflict", "mood_tag_layout_conflict", "not_configured", - "not_implemented", "notification_prefs_conflict", "oidc_only", "private_origin_not_approved", diff --git a/src/lib/openapi/routes/admin.ts b/src/lib/openapi/routes/admin.ts index 3c9079df7..df06d2d96 100644 --- a/src/lib/openapi/routes/admin.ts +++ b/src/lib/openapi/routes/admin.ts @@ -9,12 +9,7 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { inviteCreateSchema } from "@/lib/validations/invite"; import { adminReminderCheckSchema } from "@/lib/validations/notifications"; -import { - dataEnvelope, - errorEnvelope, - notImplementedResponse, - stdResponses, -} from "./shared"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; // v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification // subsystem. Mirrors the runtime types in @@ -371,7 +366,6 @@ export const adminDiagnosticPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, - ...notImplementedResponse, }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 1b4599b4d..79e399cca 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -269,21 +269,6 @@ export const malformedJsonResponse = { }, }; -/** - * The 501 a route answers while its contract is published ahead of its - * implementation on a release branch. Every stub carries it; the owner of the - * route drops it with the stub. No tagged release answers it: the error-code - * catalogue check fails once `not_implemented` is listed and nothing emits - * it, and fails while something still does and it is not listed. - */ -export const notImplementedResponse = { - "501": { - description: - "Not implemented yet: the contract is published ahead of the implementation on a release branch. `meta.errorCode: not_implemented`.", - content: { "application/json": { schema: errorEnvelope } }, - }, -} as const; - // ── Standard 401 / 422 / 429 responses ─────────────────────────────── export const stdResponses = { diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index 9eb67bad2..d38b6fec9 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -29,6 +29,8 @@ import { vaccinationSiteEnum, vaccinationBoosterSchema, vaccinationSuggestQuerySchema, + customVaccineCreateSchema, + customVaccineUpdateSchema, } from "@/lib/validations/vaccinations"; import { measurementReminderDto } from "@/lib/validations/measurement-reminders"; @@ -37,7 +39,6 @@ import { errorEnvelope, idempotencyKeyParameter, idempotentWrite, - notImplementedResponse, recordRefusal, recordWriteRateLimitResponse, stdResponses, @@ -49,13 +50,13 @@ import { const createVaccinationRequest = vaccinationCreateSchema.meta({ id: "CreateVaccinationRequest", description: - "Log one administered dose. `occurredAt` is required and must not be in the future — a vaccination is recorded after the fact, and a planned booster is a reminder rather than a record. Exactly one identity arm is required: either `antigenSlug` (a slug the catalogue offers) or `vaccineName` (whatever the person's own record says, verbatim, which may be a trade name). Everything else is optional, so a decades-old paper entry with nothing but a date and a name is loggable. `note` is encrypted at rest. `documentIds` pre-links the scanned pages; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save. Logging a dose satisfies any booster reminder keyed to one of its component antigens.", + "Log one administered dose. `occurredAt` is required and must not be in the future — a vaccination is recorded after the fact, and a planned booster is a reminder rather than a record. At least one identity arm is required: `antigenSlug` (a slug the catalogue offers), `customVaccineId` (one of the record's own vaccine definitions, see `/api/vaccinations/custom`; an id the record does not hold live is a 404 `vaccination.custom-vaccine-not-found`) or `vaccineName` (whatever the person's own record says, verbatim, which may be a trade name). When both a slug and a definition are given, the catalogue is the answer. Everything else is optional, so a decades-old paper entry with nothing but a date and a name is loggable. `note` is encrypted at rest. `documentIds` pre-links the scanned pages; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save. Logging a dose satisfies any booster reminder keyed to one of its component antigens, the antigens of an own definition included.", }); const updateVaccinationRequest = vaccinationUpdateSchema.meta({ id: "UpdateVaccinationRequest", description: - "Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. `occurredAt` is editable because a transcription typo is the common case — and editing it deliberately does NOT re-run the booster satisfaction, so correcting a date can never move a reminder's due date. An edit that would leave the record with neither identity arm is refused. A present `documentIds` array replaces the links, empty array included.", + "Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. `occurredAt` is editable because a transcription typo is the common case — and editing it deliberately does NOT re-run the booster satisfaction, so correcting a date can never move a reminder's due date. An edit that would leave the record with no identity arm (slug, own definition or name) is refused. `customVaccineId: null` unlinks the dose from its own definition. A present `documentIds` array replaces the links, empty array included.", }); const listVaccinationsQuery = vaccinationListQuerySchema.meta({ @@ -150,6 +151,55 @@ const practitioner = z "The practice that administered the dose, resolved rather than an id. Null when the record names none.", }); +// v1.42 (#1005) — the record's own vaccine definitions. +const customVaccine = z + .object({ + id: z.string(), + name: z.string(), + components: z + .array(z.string()) + .describe( + "Catalogue antigen slugs the product protects against, only ones this release knows.", + ), + typicalSeriesDoses: z.number().int().nullable(), + boosterIntervalMonths: z.number().int().nullable(), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "CustomVaccine", + description: + "A vaccine the record's owner defined for a product the shipped catalogue does not list, in the shape a catalogue entry resolves to. A dose logged against it counts into the series of each listed antigen, settles a booster reminder keyed to one of them and offers one from `boosterIntervalMonths`, exactly as a catalogue pick does. `name` is the person's own wording.", + }); + +const customVaccineCreateRequest = customVaccineCreateSchema.meta({ + id: "CreateCustomVaccineRequest", + description: + "A new definition. `components` lists one to all of the catalogue's antigen slugs, each once — a disease outside that list is logged as a free-text dose instead. `typicalSeriesDoses` (1–10) and `boosterIntervalMonths` (1–600) are optional; the interval prefills the booster offer after a dose.", +}); + +const customVaccineUpdateRequest = customVaccineUpdateSchema.meta({ + id: "UpdateCustomVaccineRequest", + description: + "Partial edit; an omitted key is left untouched and an empty body is a 422. The change applies to every dose logged against the definition, because the series is derived on read; it never re-runs booster satisfaction.", +}); + +const customVaccineNotFound = { + "404": { + description: + "No live definition with this id on the record (`vaccination.custom.not-found`).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +const customVaccineNameTaken = { + "409": { + description: + "A live definition on the record already carries this name, compared without case (`vaccination.custom.name-taken`).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + const vaccination = z .object({ id: z.string(), @@ -161,6 +211,14 @@ const vaccination = z lotNumber: z.string().nullable(), site: vaccinationSiteEnum.nullable(), catalogEntry: catalogEntry.nullable(), + customVaccineId: z.string().nullable().meta({ + description: + "The record's own vaccine definition the dose names (v1.42), verbatim.", + }), + customVaccine: customVaccine.nullable().meta({ + description: + "That definition, resolved, or null when the dose names none or it was removed. When `catalogEntry` is also set, the catalogue is the answer.", + }), series: z.array(seriesPosition), practitioner: practitioner.nullable(), encounter: vaccinationEncounter.nullable(), @@ -244,43 +302,15 @@ const vaccinationNotFound = { const idPath = { path: z.object({ id: z.string() }) }; -// v1.42 (#1005) — the record's own vaccine definitions. -const customVaccine = z - .object({ - id: z.string(), - name: z.string(), - components: z - .array(z.string()) - .describe("Catalogue antigen slugs the product covers."), - typicalSeriesDoses: z.number().int().nullable(), - boosterIntervalMonths: z.number().int().nullable(), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), - }) - .meta({ id: "CustomVaccine" }); - -const customVaccineCreateRequest = z - .object({ - name: z.string(), - components: z.array(z.string()), - typicalSeriesDoses: z.number().int().nullable().optional(), - boosterIntervalMonths: z.number().int().nullable().optional(), - }) - .strict() - .meta({ id: "CreateCustomVaccineRequest" }); - -const customVaccineUpdateRequest = customVaccineCreateRequest - .partial() - .meta({ id: "UpdateCustomVaccineRequest" }); - export const vaccinationPaths: NonNullable = { "/api/vaccinations/custom": { get: { tags: ["Records"], summary: "List the record's own vaccines", description: - "Vaccine definitions the record's owner added for products the shipped catalogue does not list. Soft-deleted definitions are excluded.", + "The vaccine definitions the record's owner added for products the shipped catalogue does not list, by name. Removed definitions are excluded. Readable under a `profile` grant like the dose list.", responses: { + ...recordRefusal(), "200": { description: "The definitions, by name.", content: { @@ -293,19 +323,19 @@ export const vaccinationPaths: NonNullable = { }, }, ...stdResponses, - ...notImplementedResponse, }, }, post: { tags: ["Records"], summary: "Add an own vaccine", description: - "Defines a vaccine in the same shape a catalogue entry resolves to, so series and booster logic treat both alike. Names are unique per record.", + "Defines a vaccine in the same shape a catalogue entry resolves to, so the series and booster logic treat both alike. Names are unique per record, compared without case; adding a name the person removed earlier brings that definition back with the new values (its doses were let go on removal and stay so). Audits as `vaccination.custom.create`.", requestBody: { required: true, content: { "application/json": { schema: customVaccineCreateRequest } }, }, responses: { + ...recordRefusal(), "201": { description: "Created.", content: { @@ -317,13 +347,9 @@ export const vaccinationPaths: NonNullable = { }, }, }, - "409": { - description: "A live definition with this name already exists.", - content: { "application/json": { schema: errorEnvelope } }, - }, + ...customVaccineNameTaken, ...stdResponses, ...recordWriteRateLimitResponse, - ...notImplementedResponse, }, }, }, @@ -331,13 +357,15 @@ export const vaccinationPaths: NonNullable = { patch: { tags: ["Records"], summary: "Edit an own vaccine", - description: "Changes one definition. Owner-scoped; an unknown id 404s.", + description: + "Changes one definition; every dose logged against it reads the change. Owner-scoped; an unknown or removed id 404s. Audits as `vaccination.custom.update`, naming a rename without quoting it.", requestParams: idPath, requestBody: { required: true, content: { "application/json": { schema: customVaccineUpdateRequest } }, }, responses: { + ...recordRefusal(), "200": { description: "Updated.", content: { @@ -349,25 +377,19 @@ export const vaccinationPaths: NonNullable = { }, }, }, - "404": { - description: "No such definition on this record.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "409": { - description: "A live definition with this name already exists.", - content: { "application/json": { schema: errorEnvelope } }, - }, + ...customVaccineNotFound, + ...customVaccineNameTaken, ...stdResponses, - ...notImplementedResponse, }, }, delete: { tags: ["Records"], summary: "Remove an own vaccine", description: - "Soft-deletes one definition. Doses logged against it stay, on their `vaccineName`.", + "Soft-deletes one definition and lets go of every dose that named it. A dose keeps a name: one without wording of its own takes the definition's name as its `vaccineName` first. Removing twice succeeds. Audits as `vaccination.custom.delete` with the number of doses let go.", requestParams: idPath, responses: { + ...recordRefusal(), "200": { description: "Removed.", content: { @@ -379,12 +401,8 @@ export const vaccinationPaths: NonNullable = { }, }, }, - "404": { - description: "No such definition on this record.", - content: { "application/json": { schema: errorEnvelope } }, - }, + ...customVaccineNotFound, ...stdResponses, - ...notImplementedResponse, }, }, }, @@ -421,6 +439,11 @@ export const vaccinationPaths: NonNullable = { responses: { ...idempotentWrite(), ...recordRefusal(), + "404": { + description: + "A practice, visit or own vaccine the body names is not the record's (`vaccination.practitioner-not-found`, `vaccination.encounter-not-found`, `vaccination.custom-vaccine-not-found`).", + content: { "application/json": { schema: errorEnvelope } }, + }, "201": { description: "Dose logged.", content: { diff --git a/src/lib/query-keys/vaccinations.ts b/src/lib/query-keys/vaccinations.ts index 6271cc3fc..09d3cbd7d 100644 --- a/src/lib/query-keys/vaccinations.ts +++ b/src/lib/query-keys/vaccinations.ts @@ -52,4 +52,13 @@ export const vaccinationKeys = { vaccinationRestore: () => ["vaccinations", "restore"] as const, vaccinationBooster: () => ["vaccinations", "booster"] as const, vaccinationLink: () => ["vaccinations", "link"] as const, + /** + * v1.42 (#1005) — the record's own vaccine definitions. Under the + * `["vaccinations"]` root on purpose: editing or removing a definition + * re-reads every dose that names it, and the root eviction repaints both. + */ + customVaccines: () => ["vaccinations", "custom"] as const, + customVaccineCreate: () => ["vaccinations", "custom", "create"] as const, + customVaccineUpdate: () => ["vaccinations", "custom", "update"] as const, + customVaccineDelete: () => ["vaccinations", "custom", "delete"] as const, } as const; diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index 15360ca9b..2c310f04a 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -78,6 +78,8 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.encounterVisitCreate", { name }); case "vaccination.record.create": return t("recordSharing.activityVerb.vaccinationRecordCreate", { name }); + case "vaccination.custom.create": + return t("recordSharing.activityVerb.customVaccineCreate", { name }); case "practitioner.contact.create": return t("recordSharing.activityVerb.practitionerContactCreate", { name, @@ -218,6 +220,10 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.vaccinationRecordDelete", { name }); case "vaccination.record.restore": return t("recordSharing.activityVerb.vaccinationRecordRestore", { name }); + case "vaccination.custom.update": + return t("recordSharing.activityVerb.customVaccineUpdate", { name }); + case "vaccination.custom.delete": + return t("recordSharing.activityVerb.customVaccineDelete", { name }); case "vaccination.booster.planned": return t("recordSharing.activityVerb.vaccinationBoosterPlanned", { name, diff --git a/src/lib/vaccinations/__tests__/catalog-integrity.test.ts b/src/lib/vaccinations/__tests__/catalog-integrity.test.ts index 5dc9dfe88..9c147c94b 100644 --- a/src/lib/vaccinations/__tests__/catalog-integrity.test.ts +++ b/src/lib/vaccinations/__tests__/catalog-integrity.test.ts @@ -23,10 +23,10 @@ import { ANTIGEN_SLUGS, VACCINE_CATALOG, VACCINE_CATALOG_SLUGS, - componentsForSlug, resolveCatalogEntry, type AntigenSlug, } from "@/lib/vaccinations/vaccine-catalog"; +import { componentsForDose } from "@/lib/vaccinations/resolve-vaccine-entry"; const MONOVALENT = new Set(ANTIGEN_SLUGS); @@ -193,8 +193,10 @@ describe("a slug the catalogue no longer knows degrades to the free text", () => it("matches no antigen at all rather than guessing from the name", () => { // The consequence that matters: an unresolvable dose must not clear a // booster reminder. No components means no match. - expect(componentsForSlug(ORPHANED_RECORD.antigenSlug)).toEqual([]); - expect(componentsForSlug(null)).toEqual([]); + expect( + componentsForDose({ antigenSlug: ORPHANED_RECORD.antigenSlug }), + ).toEqual([]); + expect(componentsForDose({ antigenSlug: null })).toEqual([]); }); it("still resolves a slug it does have, so the check above is not vacuous", () => { diff --git a/src/lib/vaccinations/__tests__/resolve-vaccine-entry.test.ts b/src/lib/vaccinations/__tests__/resolve-vaccine-entry.test.ts new file mode 100644 index 000000000..faddb0e6a --- /dev/null +++ b/src/lib/vaccinations/__tests__/resolve-vaccine-entry.test.ts @@ -0,0 +1,138 @@ +/** + * v1.42 (#1005) — the one resolver every consumer asks, and what the + * person's own vaccine definitions do through it: count into each listed + * antigen's series, take their own series length, and lose all of that the + * moment the definition is removed, falling back to the dose's name. + */ +import { describe, expect, it } from "vitest"; + +import { + componentsForDose, + customLookupOf, + knownAntigens, + resolveVaccineEntry, + type CustomVaccineDefinition, +} from "@/lib/vaccinations/resolve-vaccine-entry"; +import { deriveSeries } from "@/lib/vaccinations/series"; + +const MONTH_MS = 30 * 24 * 60 * 60 * 1000; +const monthsAgo = (months: number) => new Date(Date.now() - months * MONTH_MS); + +const dtp: CustomVaccineDefinition = { + id: "cv-dtp", + name: "DTP from abroad", + components: ["diphtheria", "tetanus", "pertussis"], + typicalSeriesDoses: 3, + boosterIntervalMonths: 120, +}; +const customs = customLookupOf([dtp]); + +describe("resolveVaccineEntry", () => { + it("resolves a catalogue slug, with its citation", () => { + const entry = resolveVaccineEntry({ antigenSlug: "tdap" }); + expect(entry?.kind).toBe("catalog"); + expect(entry?.slug).toBe("tdap"); + expect(entry?.source).not.toBeNull(); + expect(entry?.components).toEqual(["tetanus", "diphtheria", "pertussis"]); + }); + + it("resolves the person's own definition in the same shape, uncited", () => { + const entry = resolveVaccineEntry({ customVaccineId: "cv-dtp" }, customs); + expect(entry).toMatchObject({ + kind: "custom", + slug: null, + customVaccineId: "cv-dtp", + name: "DTP from abroad", + atc: null, + category: null, + source: null, + typicalSeriesDoses: 3, + boosterIntervalMonths: 120, + }); + expect(entry?.components).toEqual(["diphtheria", "tetanus", "pertussis"]); + }); + + it("lets the catalogue win when a dose carries both", () => { + const entry = resolveVaccineEntry( + { antigenSlug: "influenza", customVaccineId: "cv-dtp" }, + customs, + ); + expect(entry?.kind).toBe("catalog"); + expect(entry?.components).toEqual(["influenza"]); + }); + + it("answers null for nothing it knows: no arm, a dead slug, an unknown or removed definition", () => { + expect(resolveVaccineEntry({})).toBeNull(); + expect(resolveVaccineEntry({ antigenSlug: "no-such-slug" })).toBeNull(); + expect( + resolveVaccineEntry({ customVaccineId: "cv-missing" }, customs), + ).toBeNull(); + expect(resolveVaccineEntry({ customVaccineId: "cv-dtp" })).toBeNull(); + const removed = customLookupOf([{ ...dtp, deletedAt: new Date() }]); + expect( + resolveVaccineEntry({ customVaccineId: "cv-dtp" }, removed), + ).toBeNull(); + }); + + it("drops antigens the catalogue does not know, and duplicates, but keeps the rest", () => { + expect( + knownAntigens(["tetanus", "unicorn-pox", "tetanus", "rabies"]), + ).toEqual(["tetanus", "rabies"]); + const odd = customLookupOf([ + { ...dtp, id: "cv-odd", components: ["unicorn-pox", "cholera"] }, + ]); + expect(componentsForDose({ customVaccineId: "cv-odd" }, odd)).toEqual([ + "cholera", + ]); + }); +}); + +describe("a dose logged against an own definition, in the series", () => { + it("counts into each listed antigen, at that antigen's own position", () => { + const history = [ + // Two catalogue tetanus doses first, then the DTP definition. + { + id: "t1", + occurredAt: monthsAgo(48), + antigenSlug: "tetanus", + doseNumber: null, + seriesDoses: null, + }, + { + id: "t2", + occurredAt: monthsAgo(36), + antigenSlug: "tetanus", + doseNumber: null, + seriesDoses: null, + }, + { + id: "own", + occurredAt: monthsAgo(12), + antigenSlug: null, + customVaccineId: "cv-dtp", + doseNumber: null, + seriesDoses: null, + }, + ]; + const series = deriveSeries(history, customs).get("own"); + expect(series).toEqual([ + { antigen: "diphtheria", position: 1, total: 3, booster: false }, + { antigen: "tetanus", position: 3, total: 3, booster: false }, + { antigen: "pertussis", position: 1, total: 3, booster: false }, + ]); + }); + + it("places nothing once the definition is gone from the lookup", () => { + const series = deriveSeries([ + { + id: "own", + occurredAt: monthsAgo(1), + antigenSlug: null, + customVaccineId: "cv-dtp", + doseNumber: null, + seriesDoses: null, + }, + ]); + expect(series.get("own")).toEqual([]); + }); +}); diff --git a/src/lib/vaccinations/booster-mint.ts b/src/lib/vaccinations/booster-mint.ts index ccd0504ae..871fc68f1 100644 --- a/src/lib/vaccinations/booster-mint.ts +++ b/src/lib/vaccinations/booster-mint.ts @@ -28,7 +28,10 @@ import type { Prisma } from "@/generated/prisma/client"; import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling"; -import { componentsForSlug } from "@/lib/vaccinations/vaccine-catalog"; +import { + componentsForDose, + loadCustomVaccineLookup, +} from "@/lib/vaccinations/resolve-vaccine-entry"; /** What the confirm carries. All three are the user's edited-or-accepted values. */ export interface BoosterMintInput { @@ -80,14 +83,25 @@ export async function mintOrReanchorBooster( ): Promise { const record = await tx.vaccinationRecord.findFirst({ where: { id: input.vaccinationId, userId, deletedAt: null }, - select: { id: true, antigenSlug: true, occurredAt: true }, + select: { + id: true, + antigenSlug: true, + customVaccineId: true, + occurredAt: true, + }, }); if (!record) return { outcome: "unknown-record" }; // The reminder keys on the dose's PRIMARY component antigen — for a Tdap that - // is tetanus. A person who tracks the components separately can mint further - // reminders by hand; the offer covers the one the interval belongs to. - const [antigen] = componentsForSlug(record.antigenSlug); + // is tetanus, for the person's own definition the first antigen it lists. A + // person who tracks the components separately can mint further reminders by + // hand; the offer covers the one the interval belongs to. + const [antigen] = componentsForDose( + record, + record.customVaccineId + ? await loadCustomVaccineLookup(tx, userId) + : undefined, + ); if (!antigen) return { outcome: "no-antigen" }; const notifyHour = input.notifyHour ?? 9; diff --git a/src/lib/vaccinations/custom-vaccines.ts b/src/lib/vaccinations/custom-vaccines.ts new file mode 100644 index 000000000..8d5006f81 --- /dev/null +++ b/src/lib/vaccinations/custom-vaccines.ts @@ -0,0 +1,69 @@ +/** + * What the custom-vaccine routes share (v1.42, #1005): the columns they + * publish, the name-clash probe and the removal. + */ +import type { Prisma } from "@/generated/prisma/client"; + +/** The columns a definition is published with. */ +export const CUSTOM_VACCINE_SELECT = { + id: true, + name: true, + components: true, + typicalSeriesDoses: true, + boosterIntervalMonths: true, + createdAt: true, + updatedAt: true, +} as const satisfies Prisma.CustomVaccineSelect; + +/** + * A live definition on the record whose name equals `name` ignoring case, + * other than `exceptId`. "Dukoral" and "dukoral" are one product to the + * person reading the picker, even though the unique index tells them apart. + */ +export async function findLiveNameClash( + tx: Prisma.TransactionClient, + userId: string, + name: string, + exceptId?: string, +): Promise { + const clash = await tx.customVaccine.findFirst({ + where: { + userId, + deletedAt: null, + name: { equals: name, mode: "insensitive" }, + ...(exceptId ? { id: { not: exceptId } } : {}), + }, + select: { id: true }, + }); + return clash !== null; +} + +/** + * Remove a definition and let go of every dose that named it. + * + * The definition is tombstoned; the doses are not touched beyond the link. + * A dose that carried no wording of its own takes the definition's name as + * its `vaccineName` first, so it keeps a name — the same thing the foreign + * key's `ON DELETE SET NULL` promises for a hard delete, done by hand because + * a tombstone does not fire it. Runs in the caller's transaction so the name + * and the unlink land together. Returns how many doses were unlinked. + */ +export async function removeCustomVaccine( + tx: Prisma.TransactionClient, + userId: string, + definition: { id: string; name: string }, +): Promise { + await tx.vaccinationRecord.updateMany({ + where: { userId, customVaccineId: definition.id, vaccineName: null }, + data: { vaccineName: definition.name }, + }); + const unlinked = await tx.vaccinationRecord.updateMany({ + where: { userId, customVaccineId: definition.id }, + data: { customVaccineId: null }, + }); + await tx.customVaccine.update({ + where: { id: definition.id }, + data: { deletedAt: new Date() }, + }); + return unlinked.count; +} diff --git a/src/lib/vaccinations/document-suggestion.ts b/src/lib/vaccinations/document-suggestion.ts index e123b83c4..58b518dc7 100644 --- a/src/lib/vaccinations/document-suggestion.ts +++ b/src/lib/vaccinations/document-suggestion.ts @@ -89,6 +89,9 @@ export async function suggestVaccinationForDate( occurredAt: true, antigenSlug: true, vaccineName: true, + // v1.42 (#1005) — a dose named only by the person's own definition is + // offered under that definition's name. + customVaccine: { select: { name: true, deletedAt: true } }, }, }); @@ -96,7 +99,11 @@ export async function suggestVaccinationForDate( id: row.id, occurredAt: row.occurredAt.toISOString(), antigenSlug: row.antigenSlug, - vaccineName: row.vaccineName, + vaccineName: + row.vaccineName ?? + (row.customVaccine && row.customVaccine.deletedAt === null + ? row.customVaccine.name + : null), })); if (vaccinations.length === 0) return { kind: "none" }; diff --git a/src/lib/vaccinations/dto.ts b/src/lib/vaccinations/dto.ts index a1a5ac29b..0548432e5 100644 --- a/src/lib/vaccinations/dto.ts +++ b/src/lib/vaccinations/dto.ts @@ -19,6 +19,7 @@ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { getEvent } from "@/lib/logging/context"; import type { + CustomVaccine, Encounter, Practitioner, VaccinationRecord, @@ -28,7 +29,10 @@ import { toPractitionerDTO, type PractitionerDTO, } from "@/lib/practitioners/dto"; -import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import { + knownAntigens, + resolveVaccineEntry, +} from "@/lib/vaccinations/resolve-vaccine-entry"; import type { SeriesPosition } from "@/lib/vaccinations/series"; import type { VaccinationRenewalDTO } from "@/lib/vaccinations/renewal"; @@ -56,6 +60,43 @@ export interface VaccinationCatalogDTO { category: string; } +/** + * One of the record's own vaccine definitions (v1.42, #1005). `components` + * lists only antigens the catalogue knows, so a client can name each one. + */ +export interface CustomVaccineDTO { + id: string; + name: string; + components: string[]; + typicalSeriesDoses: number | null; + boosterIntervalMonths: number | null; + createdAt: string; + updatedAt: string; +} + +export type CustomVaccineRow = Pick< + CustomVaccine, + | "id" + | "name" + | "components" + | "typicalSeriesDoses" + | "boosterIntervalMonths" + | "createdAt" + | "updatedAt" +>; + +export function toCustomVaccineDTO(row: CustomVaccineRow): CustomVaccineDTO { + return { + id: row.id, + name: row.name, + components: knownAntigens(row.components), + typicalSeriesDoses: row.typicalSeriesDoses, + boosterIntervalMonths: row.boosterIntervalMonths, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + /** The visit this dose was given at, enough to recognise it by. */ export interface VaccinationEncounterDTO { id: string; @@ -80,6 +121,13 @@ export interface VaccinationDTO { * not know the slug. Null is the signal to render `vaccineName` instead. */ catalogEntry: VaccinationCatalogDTO | null; + /** The person's own definition the dose names, verbatim. */ + customVaccineId: string | null; + /** + * That definition, resolved, or `null` when the dose names none or it was + * removed. When `catalogEntry` is also set, the catalogue is the answer. + */ + customVaccine: CustomVaccineDTO | null; /** * Where this dose sits in each of its antigen series, resolved server-side. * @@ -123,6 +171,7 @@ function decryptNote(value: Uint8Array | null): string | null { export type VaccinationWithRelations = VaccinationRecord & { practitioner?: Practitioner | null; encounter?: Pick | null; + customVaccine?: (CustomVaccineRow & Pick) | null; }; export function toVaccinationDTO( @@ -130,7 +179,11 @@ export function toVaccinationDTO( series: SeriesPosition[], documents?: VaccinationDocumentDTO[], ): VaccinationDTO { - const entry = resolveCatalogEntry(row.antigenSlug); + const custom = + row.customVaccine && row.customVaccine.deletedAt === null + ? row.customVaccine + : null; + const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug }); return { id: row.id, occurredAt: row.occurredAt.toISOString(), @@ -140,9 +193,12 @@ export function toVaccinationDTO( seriesDoses: row.seriesDoses, lotNumber: row.lotNumber, site: row.site, - catalogEntry: entry - ? { slug: entry.slug, atc: entry.atc, category: entry.category } - : null, + catalogEntry: + entry?.slug && entry.atc && entry.category + ? { slug: entry.slug, atc: entry.atc, category: entry.category } + : null, + customVaccineId: row.customVaccineId, + customVaccine: custom ? toCustomVaccineDTO(custom) : null, series, practitioner: row.practitioner ? toPractitionerDTO(row.practitioner) : null, encounter: row.encounter diff --git a/src/lib/vaccinations/resolve-vaccine-entry.ts b/src/lib/vaccinations/resolve-vaccine-entry.ts new file mode 100644 index 000000000..3def70740 --- /dev/null +++ b/src/lib/vaccinations/resolve-vaccine-entry.ts @@ -0,0 +1,191 @@ +/** + * The one answer to "what was this dose", catalogue or the person's own. + * + * Since v1.42 (#1005) a dose can name a vaccine the shipped catalogue does + * not list: the person defines it once (`CustomVaccine`: a name, the + * antigens it protects against, a series length, a booster interval) and + * logs doses against it. Every consumer that used to ask the catalogue — + * the series derivation, the booster satisfy matcher, the booster mint, the + * DTO, the doctor report, the document links, MCP search, the picker's info + * sentences and the booster offer — asks this module instead, so a custom + * vaccine counts into a series, clears a booster and offers one exactly the + * way a catalogue entry does. + * + * ── Precedence ───────────────────────────────────────────────────────────── + * + * The catalogue wins when both resolve. A catalogue slug is the shipped, + * sourced answer; a definition is the person's own. A dose carries one or + * the other in practice, and the rule only decides the rare row that + * carries both (an edit that picked a catalogue entry without clearing the + * definition). + * + * A definition that is soft-deleted does not resolve, and neither does an + * antigen it lists that the catalogue does not know: storage is tolerant (a + * restore writes what the file held), the answer is not. A dose with nothing + * that resolves falls back to its `vaccineName`, the same degrade a dead + * catalogue slug has always had. + * + * Pure apart from {@link loadCustomVaccineLookup}, which takes the client it + * reads through, so the client components can import the resolver itself. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import { + ANTIGEN_SLUGS, + resolveCatalogEntry, + type AntigenSlug, + type VaccineCategory, +} from "@/lib/vaccinations/vaccine-catalog"; + +/** A definition as the resolver reads it. */ +export interface CustomVaccineDefinition { + id: string; + name: string; + components: readonly string[]; + typicalSeriesDoses: number | null; + boosterIntervalMonths: number | null; + /** Absent on a DTO, which only ever carries live definitions. */ + deletedAt?: Date | null; +} + +/** The record's definitions, by id. */ +export type CustomVaccineLookup = ReadonlyMap; + +/** The fields of a dose the resolver reads. */ +export interface VaccineIdentity { + antigenSlug?: string | null; + customVaccineId?: string | null; +} + +/** The resolved entry, in the shape a catalogue seed has. */ +export interface ResolvedVaccineEntry { + kind: "catalog" | "custom"; + /** The catalogue slug, or null for a definition. */ + slug: string | null; + /** The definition's id, or null for a catalogue entry. */ + customVaccineId: string | null; + /** + * The definition's own name. Null for a catalogue entry, whose name + * resolves through i18n (`vaccinations.catalog.`). + */ + name: string | null; + /** WHO ATC code; a definition has none. */ + atc: string | null; + /** The antigens a dose counts into. */ + components: readonly AntigenSlug[]; + typicalSeriesDoses: number | null; + boosterIntervalMonths: number | null; + /** Picker grouping; a definition has none. */ + category: VaccineCategory | null; + /** The citation for the numbers; a definition is the person's, not cited. */ + source: string | null; + /** Generic search aliases; a definition has its name and nothing else. */ + synonyms: readonly string[]; +} + +const KNOWN_ANTIGENS: ReadonlySet = new Set(ANTIGEN_SLUGS); + +/** The listed antigens the catalogue knows, deduplicated, in stored order. */ +export function knownAntigens(raw: readonly string[]): AntigenSlug[] { + const seen = new Set(); + const out: AntigenSlug[] = []; + for (const value of raw) { + if (!KNOWN_ANTIGENS.has(value) || seen.has(value)) continue; + seen.add(value); + out.push(value as AntigenSlug); + } + return out; +} + +/** + * Resolve a dose to its entry, catalogue first. Null when neither arm + * resolves — the caller then falls back to the dose's `vaccineName`. + */ +export function resolveVaccineEntry( + record: VaccineIdentity, + customs?: CustomVaccineLookup, +): ResolvedVaccineEntry | null { + const catalog = resolveCatalogEntry(record.antigenSlug); + if (catalog) { + return { + kind: "catalog", + slug: catalog.slug, + customVaccineId: null, + name: null, + atc: catalog.atc, + components: catalog.components, + typicalSeriesDoses: catalog.typicalSeriesDoses, + boosterIntervalMonths: catalog.boosterIntervalMonths, + category: catalog.category, + source: catalog.source, + synonyms: catalog.synonyms ?? [], + }; + } + if (!record.customVaccineId || !customs) return null; + const custom = customs.get(record.customVaccineId); + if (!custom || custom.deletedAt) return null; + return { + kind: "custom", + slug: null, + customVaccineId: custom.id, + name: custom.name, + atc: null, + components: knownAntigens(custom.components), + typicalSeriesDoses: custom.typicalSeriesDoses, + boosterIntervalMonths: custom.boosterIntervalMonths, + category: null, + source: null, + synonyms: [], + }; +} + +/** + * The antigens a dose counts into. Empty for a free-text-only dose and for + * one whose arms no longer resolve: a name string is not evidence about + * which antigens were given, and guessing from one is how a booster reminder + * would be cleared by a dose that never contained it. + */ +export function componentsForDose( + record: VaccineIdentity, + customs?: CustomVaccineLookup, +): readonly AntigenSlug[] { + return resolveVaccineEntry(record, customs)?.components ?? []; +} + +/** A lookup over the definitions a list of rows carries. */ +export function customLookupOf( + definitions: ReadonlyArray, +): CustomVaccineLookup { + const map = new Map(); + for (const definition of definitions) { + if (definition) map.set(definition.id, definition); + } + return map; +} + +/** The columns a definition is resolved from. */ +export const CUSTOM_VACCINE_RESOLVE_SELECT = { + id: true, + name: true, + components: true, + typicalSeriesDoses: true, + boosterIntervalMonths: true, + deletedAt: true, + createdAt: true, + updatedAt: true, +} as const satisfies Prisma.CustomVaccineSelect; + +/** + * The record's live definitions, by id. An Impfpass names a handful at most, + * so the whole set is read rather than the ids a page happens to need. + */ +export async function loadCustomVaccineLookup( + client: Pick, + userId: string, +): Promise { + const rows = await client.customVaccine.findMany({ + where: { userId, deletedAt: null }, + select: CUSTOM_VACCINE_RESOLVE_SELECT, + }); + return customLookupOf(rows); +} diff --git a/src/lib/vaccinations/satisfy-matching.ts b/src/lib/vaccinations/satisfy-matching.ts index 7ec77fcac..0b4d89977 100644 --- a/src/lib/vaccinations/satisfy-matching.ts +++ b/src/lib/vaccinations/satisfy-matching.ts @@ -21,7 +21,9 @@ * * A free-text-only dose matches nothing. Nothing here reads `vaccineName`: a * name string is not evidence about which antigens were in the syringe, and a - * booster cleared by a guess is worse than a booster not cleared at all. + * booster cleared by a guess is worse than a booster not cleared at all. A + * dose logged against the person's own vaccine definition (v1.42, #1005) + * counts its listed antigens, the same way a catalogue combination does. * * ── Cold path only ───────────────────────────────────────────────────────── * @@ -50,7 +52,11 @@ import { satisfyReminder, type SatisfiableReminder, } from "@/lib/measurement-reminders/satisfy"; -import { componentsForSlug } from "@/lib/vaccinations/vaccine-catalog"; +import { + componentsForDose, + loadCustomVaccineLookup, + type VaccineIdentity, +} from "@/lib/vaccinations/resolve-vaccine-entry"; /** What the create route needs back: which reminders moved, and the first. */ export interface BoosterSatisfyResult { @@ -82,11 +88,16 @@ const EMPTY: BoosterSatisfyResult = { export async function satisfyBoostersForDose( tx: Prisma.TransactionClient, userId: string, - antigenSlug: string | null, + identity: VaccineIdentity, occurredAt: Date, timezone: string, ): Promise { - const components = componentsForSlug(antigenSlug); + const components = componentsForDose( + identity, + identity.customVaccineId + ? await loadCustomVaccineLookup(tx, userId) + : undefined, + ); if (components.length === 0) return EMPTY; const reminders = await tx.measurementReminder.findMany({ diff --git a/src/lib/vaccinations/series.ts b/src/lib/vaccinations/series.ts index d1749030b..2df8d0865 100644 --- a/src/lib/vaccinations/series.ts +++ b/src/lib/vaccinations/series.ts @@ -28,11 +28,11 @@ * medical-device boundary starts, and it is a separate venture's territory. * What this function reports is arithmetic on what a person wrote down. */ +import type { AntigenSlug } from "@/lib/vaccinations/vaccine-catalog"; import { - componentsForSlug, - resolveCatalogEntry, - type AntigenSlug, -} from "@/lib/vaccinations/vaccine-catalog"; + resolveVaccineEntry, + type CustomVaccineLookup, +} from "@/lib/vaccinations/resolve-vaccine-entry"; /** One antigen's view of one dose. */ export interface SeriesPosition { @@ -46,7 +46,8 @@ export interface SeriesPosition { position: number; /** * How long the series is: the record's own override first, then the - * catalogue's typical value, then null when neither knows. Null is honest — + * catalogue's (or the person's own definition's) typical value, then null + * when neither knows. Null is honest — * a seasonal vaccine has no "of M". */ total: number | null; @@ -62,6 +63,8 @@ export interface SeriesInputRecord { id: string; occurredAt: Date; antigenSlug: string | null; + /** The person's own definition (v1.42, #1005), resolved after the slug. */ + customVaccineId?: string | null; doseNumber: number | null; seriesDoses: number | null; } @@ -80,6 +83,7 @@ export interface SeriesInputRecord { */ export function deriveSeries( records: readonly SeriesInputRecord[], + customs?: CustomVaccineLookup, ): Map { const result = new Map(); for (const record of records) result.set(record.id, []); @@ -97,13 +101,14 @@ export function deriveSeries( for (const record of chronological) { // A free-text-only record — and a record whose slug the catalogue no - // longer resolves — yields nothing. Guessing an antigen from a name string - // is how a booster gets cleared by a dose that never contained it. - const components = componentsForSlug(record.antigenSlug); + // longer resolves, or whose own definition was removed — yields nothing. + // Guessing an antigen from a name string is how a booster gets cleared by + // a dose that never contained it. + const entry = resolveVaccineEntry(record, customs); + const components = entry?.components ?? []; if (components.length === 0) continue; - const catalogTotal = - resolveCatalogEntry(record.antigenSlug)?.typicalSeriesDoses ?? null; + const catalogTotal = entry?.typicalSeriesDoses ?? null; // The record's own override wins: it is what the person's actual schedule // was, and the catalogue only knows the typical one. const total = record.seriesDoses ?? catalogTotal; @@ -140,6 +145,7 @@ export function deriveSeries( export function seriesForRecord( recordId: string, history: readonly SeriesInputRecord[], + customs?: CustomVaccineLookup, ): SeriesPosition[] { - return deriveSeries(history).get(recordId) ?? []; + return deriveSeries(history, customs).get(recordId) ?? []; } diff --git a/src/lib/vaccinations/service.ts b/src/lib/vaccinations/service.ts index 686151f1a..0b3bf2e3e 100644 --- a/src/lib/vaccinations/service.ts +++ b/src/lib/vaccinations/service.ts @@ -17,6 +17,10 @@ import { deriveSeries, type SeriesInputRecord, } from "@/lib/vaccinations/series"; +import { + CUSTOM_VACCINE_RESOLVE_SELECT, + loadCustomVaccineLookup, +} from "@/lib/vaccinations/resolve-vaccine-entry"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** Matches the visits service: the fallback when an account never set one. */ @@ -25,6 +29,9 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const VACCINATION_INCLUDE = { practitioner: true, encounter: { select: { id: true, occurredAt: true, kind: true } }, + // v1.42 (#1005) — the person's own definition, resolved onto the DTO so no + // client needs a second read to name the dose or count its antigens. + customVaccine: { select: CUSTOM_VACCINE_RESOLVE_SELECT }, } as const satisfies Prisma.VaccinationRecordInclude; /** What the series derivation needs, and nothing more. */ @@ -32,6 +39,7 @@ export const SERIES_SELECT = { id: true, occurredAt: true, antigenSlug: true, + customVaccineId: true, doseNumber: true, seriesDoses: true, } as const satisfies Prisma.VaccinationRecordSelect; @@ -56,12 +64,20 @@ export async function loadSeriesHistory( }); } -/** Every record's resolved series positions, keyed by record id. */ +/** + * Every record's resolved series positions, keyed by record id. The record's + * own vaccine definitions are read beside the history, so a dose logged + * against one counts into the antigens it lists. + */ export async function resolveSeriesFor( tx: Prisma.TransactionClient, userId: string, ) { - return deriveSeries(await loadSeriesHistory(tx, userId)); + const [history, customs] = await Promise.all([ + loadSeriesHistory(tx, userId), + loadCustomVaccineLookup(tx, userId), + ]); + return deriveSeries(history, customs); } /** @@ -152,6 +168,18 @@ export async function resolveOwnerTimezone(userId: string): Promise { return row?.timezone || DEFAULT_TIMEZONE; } +/** Does the record own a live vaccine definition with this id? */ +export async function resolveOwnedCustomVaccine( + tx: Prisma.TransactionClient, + userId: string, + customVaccineId: string, +): Promise<{ id: string } | null> { + return tx.customVaccine.findFirst({ + where: { id: customVaccineId, userId, deletedAt: null }, + select: { id: true }, + }); +} + /** Does the caller own a live visit with this id? */ export async function resolveOwnedEncounter( tx: Prisma.TransactionClient, diff --git a/src/lib/vaccinations/vaccine-catalog.ts b/src/lib/vaccinations/vaccine-catalog.ts index a97e98d84..7be67c2a6 100644 --- a/src/lib/vaccinations/vaccine-catalog.ts +++ b/src/lib/vaccinations/vaccine-catalog.ts @@ -28,8 +28,11 @@ * A record stores `antigenSlug` verbatim, backups restore it without * validation, and a slug this file has stopped listing must still describe * what someone was given. {@link resolveCatalogEntry} is the ONLY lookup path - * and it returns `null` rather than throwing; every consumer answers `null` by - * falling back to the record's own `vaccineName`. That degrade is a tested + * into this file and it returns `null` rather than throwing. Consumers do not + * call it directly: since v1.42 (#1005) they ask + * `src/lib/vaccinations/resolve-vaccine-entry.ts`, which tries the catalogue + * first and the person's own vaccine definitions second, and every consumer + * answers `null` by falling back to the record's own `vaccineName`. That degrade is a tested * guarantee, not an accident — see * `src/lib/vaccinations/__tests__/catalog-integrity.test.ts`. * @@ -546,16 +549,3 @@ export function resolveCatalogEntry( if (!slug) return null; return BY_SLUG.get(slug) ?? null; } - -/** - * The antigens a dose with this slug counts into. - * - * Empty for an unknown slug and for a free-text-only record: a name string is - * not evidence about which antigens were given, and guessing from one is how - * a booster reminder would be cleared by a dose that never contained it. - */ -export function componentsForSlug( - slug: string | null | undefined, -): readonly AntigenSlug[] { - return resolveCatalogEntry(slug)?.components ?? []; -} diff --git a/src/lib/validations/__tests__/vaccinations.test.ts b/src/lib/validations/__tests__/vaccinations.test.ts index cba1936ab..bc27fa11a 100644 --- a/src/lib/validations/__tests__/vaccinations.test.ts +++ b/src/lib/validations/__tests__/vaccinations.test.ts @@ -183,13 +183,23 @@ describe("an edit names what it changes", () => { ).toBe(true); }); - it("refuses an edit that clears both identity arms at once", () => { + it("refuses an edit that clears every identity arm at once", () => { + expect( + vaccinationUpdateSchema.safeParse({ + antigenSlug: null, + vaccineName: null, + customVaccineId: null, + }).success, + ).toBe(false); + }); + + it("leaves clearing the slug and the name to the merged-row check, because an own vaccine may remain", () => { expect( vaccinationUpdateSchema.safeParse({ antigenSlug: null, vaccineName: null, }).success, - ).toBe(false); + ).toBe(true); }); it("allows clearing one arm, because the other may still be there", () => { diff --git a/src/lib/validations/backup-summary.ts b/src/lib/validations/backup-summary.ts index cd04d0eae..855f6dea9 100644 --- a/src/lib/validations/backup-summary.ts +++ b/src/lib/validations/backup-summary.ts @@ -85,6 +85,8 @@ export interface BackupSummary { vaccinations: number; /** v1.37.19 (A6-8) — vaccination↔document links. */ vaccinationLinks: number; + /** v1.42 (#1005) — the person's own vaccine definitions. */ + customVaccines: number; /** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */ measurementReminders: number; /** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */ @@ -176,6 +178,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { payload.encounterConditionLinks.length, vaccinations: payload.vaccinations.length, vaccinationLinks: payload.vaccinationDocumentLinks.length, + customVaccines: payload.customVaccines.length, // v1.37.20 (#223 / iOS #68) — counted from the release that carries them, // so the admin's "what did I just restore" answer never under-counts a // file with reminders the way it once did for visits. diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index eb9069d3c..a0694f9ab 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -1311,6 +1311,24 @@ const vaccinationBackupSchema = z }) .passthrough(); +/** + * One of the person's own vaccine definitions (v1.42, #1005). `components` + * is a list of free strings rather than the antigen enum, for the reason + * `antigenSlug` is: a file the app wrote must restore. + */ +const customVaccineBackupSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + components: z.array(z.string()).optional(), + typicalSeriesDoses: z.number().int().nullable().optional(), + boosterIntervalMonths: z.number().int().nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); + const vaccinationLinkBackupSchema = z .object({ vaccinationId: z.string().min(1), @@ -2081,6 +2099,9 @@ export const backupPayloadSchema = z // for the same reason as the sections above: a file written before the // tables existed carries no key, and an account with an empty Impfpass // writes []. + // v1.42 (#1005) — defaulted: a file written before definitions + // travelled carries no key, and its doses name none. + customVaccines: z.array(customVaccineBackupSchema).default([]), vaccinations: z.array(vaccinationBackupSchema).default([]), vaccinationDocumentLinks: z.array(vaccinationLinkBackupSchema).default([]), // The Vorsorge reminders and their completion ledger (v1.37.20, #223 / diff --git a/src/lib/validations/vaccinations.ts b/src/lib/validations/vaccinations.ts index 4de5d2ee4..1299afa87 100644 --- a/src/lib/validations/vaccinations.ts +++ b/src/lib/validations/vaccinations.ts @@ -25,7 +25,10 @@ */ import { z } from "zod/v4"; -import { VACCINE_CATALOG_SLUGS } from "@/lib/vaccinations/vaccine-catalog"; +import { + ANTIGEN_SLUGS, + VACCINE_CATALOG_SLUGS, +} from "@/lib/vaccinations/vaccine-catalog"; const KNOWN_SLUGS: ReadonlySet = new Set(VACCINE_CATALOG_SLUGS); @@ -99,7 +102,7 @@ const linkIds = z.array(id).max(100).optional(); * rather than leaving the person guessing which of the two the server meant. */ const IDENTITY_MESSAGE = - "name the vaccine: pick one from the catalogue or type what the record says"; + "name the vaccine: pick one from the catalogue or your own vaccines, or type what the record says"; export const vaccinationCreateSchema = z .object({ @@ -114,13 +117,18 @@ export const vaccinationCreateSchema = z site: vaccinationSiteEnum.nullable().optional(), practitionerId: id.nullable().optional(), encounterId: id.nullable().optional(), + /** + * v1.42 (#1005) — one of the record's own vaccine definitions, the third + * identity arm. Re-narrowed to the resolved record in the route. + */ + customVaccineId: id.nullable().optional(), /** Becomes `noteEncrypted`. Impfreaktionen live here. */ note: z.string().max(2000).nullable().optional(), documentIds: linkIds, }) .strict() .superRefine((body, ctx) => { - if (body.antigenSlug || body.vaccineName) return; + if (body.antigenSlug || body.vaccineName || body.customVaccineId) return; for (const path of ["antigenSlug", "vaccineName"] as const) { ctx.addIssue({ code: "custom", path: [path], message: IDENTITY_MESSAGE }); } @@ -151,6 +159,7 @@ export const vaccinationUpdateSchema = z site: vaccinationSiteEnum.nullable().optional(), practitionerId: id.nullable().optional(), encounterId: id.nullable().optional(), + customVaccineId: id.nullable().optional(), note: z.string().max(2000).nullable().optional(), documentIds: linkIds, }) @@ -160,9 +169,13 @@ export const vaccinationUpdateSchema = z }) .refine( (body) => - // Only when the edit clears BOTH arms in one call. Clearing one while - // the other stays is legal and the route checks the merged row. - !(body.antigenSlug === null && body.vaccineName === null), + // Only when the edit clears EVERY arm in one call. Clearing one while + // another stays is legal and the route checks the merged row. + !( + body.antigenSlug === null && + body.vaccineName === null && + body.customVaccineId === null + ), { path: ["antigenSlug"], message: IDENTITY_MESSAGE }, ); @@ -190,6 +203,53 @@ export const vaccinationListQuerySchema = z export type VaccinationListQuery = z.infer; +/* ── the record's own vaccines (v1.42, #1005) ─────────────────────── */ + +/** + * The antigens a definition protects against: only from the catalogue's + * closed antigen list, because the series derivation and the booster match + * both key on those slugs. A disease the list does not carry is a free-text + * dose, not a definition. + */ +export const customVaccineComponentsSchema = z + .array(z.enum(ANTIGEN_SLUGS)) + .min(1) + .max(ANTIGEN_SLUGS.length) + .refine((list) => new Set(list).size === list.length, { + message: "name each antigen once", + }); + +/** The definition's name: what the person calls the product. */ +const customVaccineName = z.string().trim().min(1).max(100); + +export const customVaccineCreateSchema = z + .object({ + name: customVaccineName, + components: customVaccineComponentsSchema, + typicalSeriesDoses: z.number().int().min(1).max(10).nullable().optional(), + /** Months; a decade booster is 120, bounded like the booster confirm. */ + boosterIntervalMonths: z + .number() + .int() + .min(1) + .max(600) + .nullable() + .optional(), + }) + .strict(); + +export type CustomVaccineCreate = z.infer; + +/** Edit a definition; an omitted field is left untouched, an empty body 422s. */ +export const customVaccineUpdateSchema = customVaccineCreateSchema + .partial() + .strict() + .refine((body) => Object.keys(body).length > 0, { + message: "name at least one field to change", + }); + +export type CustomVaccineUpdate = z.infer; + /* ── links ────────────────────────────────────────────────────────── */ /** diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index c96600c11..5d70af0c0 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -457,6 +457,31 @@ export const ADMITTED_MUTATING_HANDLERS = [ "write", "upsert", ), + // v1.42 (#1005) — the record's own vaccine definitions. + handler( + "app/api/vaccinations/custom/route.ts", + "/api/vaccinations/custom", + "POST", + "profile", + "write", + "create", + ), + handler( + "app/api/vaccinations/custom/[id]/route.ts", + "/api/vaccinations/custom/[id]", + "PATCH", + "profile", + "manage", + "update", + ), + handler( + "app/api/vaccinations/custom/[id]/route.ts", + "/api/vaccinations/custom/[id]", + "DELETE", + "profile", + "manage", + "delete", + ), handler( "app/api/illness/episodes/[id]/day-logs/route.ts", "/api/illness/episodes/[id]/day-logs", diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 42ee942b2..7bfa62601 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -288,6 +288,7 @@ const COUNT_BACK: Record< p.vaccinationRecord.count({ where: { userId } }), VaccinationDocumentLink: (p, userId) => p.vaccinationDocumentLink.count({ where: { userId } }), + CustomVaccine: (p, userId) => p.customVaccine.count({ where: { userId } }), MeasurementReminder: (p, userId) => p.measurementReminder.count({ where: { userId } }), MeasurementReminderEvent: (p, userId) => @@ -1284,10 +1285,23 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { // the skip test below). `reminderId` used to be deliberately absent here — // it was the one reference that could never resolve — and is deliberately // present since v1.37.20, because resolving is now the behaviour under test. + // v1.42 (#1005) — the person's own vaccine definition the dose also names. + // The catalogue slug still wins on read; the reference is what has to + // survive the trip, and it can only if the definition comes back first. + const customVaccine = await prisma.customVaccine.create({ + data: { + userId: OWNER_ID, + name: "Round-trip travel combo", + components: ["typhoid", "hepatitis-a"], + typicalSeriesDoses: 2, + boosterIntervalMonths: 36, + }, + }); const vaccination = await prisma.vaccinationRecord.create({ data: { userId: OWNER_ID, occurredAt: AT("2026-05-14T00:00:00.000Z"), + customVaccineId: customVaccine.id, antigenSlug: "tdap", vaccineName: "Tetanus, diphtheria and pertussis", doseNumber: 3, @@ -2097,6 +2111,7 @@ describe("every model the plan claims two-ended survives a real restore", () => practitioner: { select: { name: true } }, encounter: { select: { occurredAt: true } }, reminder: { select: { label: true } }, + customVaccine: true, documentLinks: { select: { documentId: true } }, }, }); @@ -2114,6 +2129,14 @@ describe("every model the plan claims two-ended survives a real restore", () => // the id survived the round trip instead of dropping to NULL the way it // had to before the reminders travelled. reminder: dose.reminder?.label ?? null, + customVaccine: dose.customVaccine + ? { + name: dose.customVaccine.name, + components: dose.customVaccine.components, + typicalSeriesDoses: dose.customVaccine.typicalSeriesDoses, + boosterIntervalMonths: dose.customVaccine.boosterIntervalMonths, + } + : null, links: dose.documentLinks.length, note: dose.noteEncrypted ? decryptFromBytes(dose.noteEncrypted) : null, }).toEqual({ @@ -2127,6 +2150,12 @@ describe("every model the plan claims two-ended survives a real restore", () => practitioner: "Round-trip practice", encounterAt: "2026-06-30T08:00:00.000Z", reminder: "Blutdruck messen", + customVaccine: { + name: "Round-trip travel combo", + components: ["typhoid", "hepatitis-a"], + typicalSeriesDoses: 2, + boosterIntervalMonths: 36, + }, links: 1, note: "sore arm for a day, nothing else", }); diff --git a/tests/integration/dense-intraday-retention.test.ts b/tests/integration/dense-intraday-retention.test.ts index db955362e..06a7d6fba 100644 --- a/tests/integration/dense-intraday-retention.test.ts +++ b/tests/integration/dense-intraday-retention.test.ts @@ -377,4 +377,89 @@ describe("runDenseIntradayRetention (real Postgres)", () => { // Both raw samples survive — the intra-day shape is preserved. expect(live).toHaveLength(2); }); + + it("folds Health Connect raw heart rate past the window into its own hourly rows, beside Apple Health's", async () => { + const prisma = getPrismaClient(); + // Berlin UTC+2 on 1 May: 08:xx UTC is local hour 10 — far past 90 days. + const at = (minute: number) => + new Date(`2026-05-01T08:${String(minute).padStart(2, "0")}:00.000Z`); + await prisma.measurement.createMany({ + data: [ + ...[60, 70, 80].map((value, i) => ({ + userId: TEST_USER_ID, + type: "PULSE" as const, + value, + unit: "bpm", + source: "HEALTH_CONNECT" as const, + measuredAt: at(i * 10 + 1), + externalId: `hc:00000000-0000-0000-0000-00000000000a:${i}`, + })), + { + userId: TEST_USER_ID, + type: "PULSE" as const, + value: 100, + unit: "bpm", + source: "APPLE_HEALTH" as const, + measuredAt: at(5), + externalId: "hk-pulse-1", + }, + ], + }); + + const summary = await runDenseIntradayRetention(prisma, { + userId: TEST_USER_ID, + // The default 90-day window: the samples are well past it. + log: () => {}, + }); + expect(summary.totals.perSampleRowsSoftDeleted).toBe(4); + + const rows = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "PULSE" }, + orderBy: { source: "asc" }, + }); + // Raw rows of both sources are gone outright, not tombstoned. + expect(rows.every((row) => row.externalId?.startsWith("stats:"))).toBe( + true, + ); + expect(rows.every((row) => row.deletedAt === null)).toBe(true); + const id = "stats:HKQuantityTypeIdentifierHeartRate:2026-05-01T10"; + // One mean per source: the two sources never share a row. + expect(rows.map((row) => [row.source, row.externalId, row.value])).toEqual([ + ["APPLE_HEALTH", id, 100], + ["HEALTH_CONNECT", id, 70], + ]); + + // A second run finds nothing left to fold. + const again = await runDenseIntradayRetention(prisma, { + userId: TEST_USER_ID, + log: () => {}, + }); + expect(again.totals.daysConsolidated).toBe(0); + }); + + it("keeps Health Connect raw heart rate inside the window raw", async () => { + const prisma = getPrismaClient(); + const recent = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + await prisma.measurement.create({ + data: { + userId: TEST_USER_ID, + type: "PULSE", + value: 64, + unit: "bpm", + source: "HEALTH_CONNECT", + measuredAt: recent, + externalId: "hc:00000000-0000-0000-0000-00000000000b:1", + }, + }); + const summary = await runDenseIntradayRetention(prisma, { + userId: TEST_USER_ID, + log: () => {}, + }); + expect(summary.totals.daysConsolidated).toBe(0); + expect( + await prisma.measurement.count({ + where: { userId: TEST_USER_ID, source: "HEALTH_CONNECT" }, + }), + ).toBe(1); + }); }); diff --git a/tests/integration/health-connect-import.test.ts b/tests/integration/health-connect-import.test.ts index 9cd102bc9..87cd33fd9 100644 --- a/tests/integration/health-connect-import.test.ts +++ b/tests/integration/health-connect-import.test.ts @@ -30,6 +30,7 @@ import { zipHealthConnectDb, } from "../fixtures/health-connect/build-fixture"; import { importHealthConnectExport } from "@/lib/import/health-connect/import"; +import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-retention"; import { HEALTH_CONNECT_IMPORT_KIND, HEALTH_CONNECT_IMPORT_QUEUE, @@ -406,6 +407,53 @@ describe("Health Connect import, heart rate history", () => { }); }); +describe("Health Connect import, folded heart rate", () => { + it("folds raw heart rate once it ages out, and a later import does not put it back", async () => { + const prisma = getPrismaClient(); + const user = await createUser("hc-folded"); + // A day of minute-by-minute heart rate, 120 days back, imported while it + // was recent so it lands raw. + const { path, endUtc } = fixtureDb("folded", { + days: 1, + endUtc: today - 120 * DAY, + }); + const args = { + prisma, + dbPath: path, + userId: user.id, + userTimezone: "Europe/Berlin", + connectedIntegrations: [], + now: new Date(endUtc), + } as const; + await importHealthConnectExport(args); + expect((await countBy(user.id)).PULSE).toBe(24 * 60); + + // The nightly fold, at today's clock: the samples are past the window. + await runDenseIntradayRetention(prisma, { + userId: user.id, + log: () => {}, + }); + const folded = await prisma.measurement.findMany({ + where: { userId: user.id, type: "PULSE", source: "HEALTH_CONNECT" }, + }); + expect(folded).toHaveLength(24); + expect( + folded.every( + (row) => + row.deletedAt === null && + row.externalId?.startsWith( + "stats:HKQuantityTypeIdentifierHeartRate:", + ), + ), + ).toBe(true); + + // The same export again: every raw sample's hour is folded already. + const again = await importHealthConnectExport(args); + expect(again.skipped["PULSE::folded_window"]).toBe(24 * 60); + expect((await countBy(user.id)).PULSE).toBe(24); + }); +}); + describe("Health Connect import, worker", () => { async function stagedJob(name: string, zipBuilder: (zip: string) => void) { const prisma = getPrismaClient(); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 7810307f0..34583f305 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -282,7 +282,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(84); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87); }); }); @@ -552,6 +552,12 @@ async function makeVaccination(userId: string) { }); } +async function makeCustomVaccine(userId: string) { + return getPrismaClient().customVaccine.create({ + data: { userId, name: "Travel combo", components: ["typhoid"] }, + }); +} + async function makeFamilyHistory(userId: string) { return getPrismaClient().familyHistoryEntry.create({ data: { userId, relationship: "MOTHER", condition: "Hypertension" }, @@ -1794,6 +1800,81 @@ manageContract("POST /api/vaccinations/[id]/restore", { )?.deletedAt === null, }); +writeEffectContract("POST /api/vaccinations/custom", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/vaccinations/custom" && entry.action === "POST", + )!, + prepare: async (ownerId) => ({ ownerId }), + act: async () => { + const { POST } = await import("@/app/api/vaccinations/custom/route"); + return call(POST as Handler, "POST", "/api/vaccinations/custom", { + name: "Cholera oral", + components: ["cholera"], + }); + }, + ok: 201, + auditAction: "vaccination.custom.create", + applied: async ({ ownerId }) => + (await getPrismaClient().customVaccine.count({ + where: { userId: ownerId }, + })) === 1, +}); + +manageContract("PATCH /api/vaccinations/custom/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/vaccinations/custom/[id]" && + entry.action === "PATCH", + )!, + prepare: (ownerId) => makeCustomVaccine(ownerId), + act: async (row) => { + const { PATCH } = await import("@/app/api/vaccinations/custom/[id]/route"); + return call( + PATCH as Handler, + "PATCH", + `/api/vaccinations/custom/${row.id}`, + { boosterIntervalMonths: 36 }, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "vaccination.custom.update", + applied: async (row) => + ( + await getPrismaClient().customVaccine.findUnique({ + where: { id: row.id }, + }) + )?.boosterIntervalMonths === 36, +}); + +manageContract("DELETE /api/vaccinations/custom/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/vaccinations/custom/[id]" && + entry.action === "DELETE", + )!, + prepare: (ownerId) => makeCustomVaccine(ownerId), + act: async (row) => { + const { DELETE } = await import("@/app/api/vaccinations/custom/[id]/route"); + return call( + DELETE as Handler, + "DELETE", + `/api/vaccinations/custom/${row.id}`, + undefined, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "vaccination.custom.delete", + applied: async (row) => + ( + await getPrismaClient().customVaccine.findUnique({ + where: { id: row.id }, + }) + )?.deletedAt !== null, +}); + proveWriteRoute("POST /api/vaccinations/[id]/booster", { route: "/api/vaccinations/[id]/booster", // The dose the booster is planned against. `makeVaccination` records a @@ -2765,13 +2846,13 @@ describe("the conditions the admissions were granted on", () => { describe("the complete MANAGE handler matrix", () => { it("registers one strict actor-and-effect driver for every admission", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(STRICT_DRIVER_KEYS.size).toBe(84); + expect(STRICT_DRIVER_KEYS.size).toBe(87); expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected); }); it("executes every registered driver through its owned effect and actor audit", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(REAL_EFFECT_KEYS.size).toBe(84); + expect(REAL_EFFECT_KEYS.size).toBe(87); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); }); }); diff --git a/tests/integration/vaccination-custom.test.ts b/tests/integration/vaccination-custom.test.ts new file mode 100644 index 000000000..1092c0fc8 --- /dev/null +++ b/tests/integration/vaccination-custom.test.ts @@ -0,0 +1,424 @@ +/** + * v1.42 (#1005) — the record's own vaccine definitions, through the real + * routes and a real Postgres. + * + * Pinned here: a definition is created, listed, renamed and removed under the + * resolved record; a dose logged against it counts into every listed + * antigen's series and clears a booster keyed to one of them, the way a + * catalogue combination does; an id another record holds is refused; removing + * the definition leaves every dose standing with a name; the booster mint keys + * on its first antigen; and the sharing levels hold — a READ delegate lists + * but cannot add, a WRITE delegate adds onto the owner's record but cannot + * edit, and a Guardian inside a managed profile writes to the profile. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { + GET as listCustom, + POST as createCustom, +} from "@/app/api/vaccinations/custom/route"; +import { + DELETE as deleteCustom, + PATCH as patchCustom, +} from "@/app/api/vaccinations/custom/[id]/route"; +import { + GET as listVaccinations, + POST as createVaccination, +} from "@/app/api/vaccinations/route"; +import { POST as planBooster } from "@/app/api/vaccinations/[id]/booster/route"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const DAY_MS = 24 * 60 * 60 * 1000; +const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS); +const daysFromNow = (days: number) => new Date(Date.now() + days * DAY_MS); + +let sequence = 0; + +interface Person { + id: string; + sessionId: string; +} + +async function person(label: string): Promise { + const suffix = sequence++; + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: `${label}-${suffix}`, + email: `${label}-${suffix}@example.test`, + timezone: "Europe/Berlin", + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: daysFromNow(1) }, + }); + return { id: user.id, sessionId: session.id }; +} + +function signIn(who: Person): void { + headerJar.delete("authorization"); + cookieJar.set("healthlog_session", who.sessionId); +} + +function json(method: string, url: string, body?: unknown): Request { + return new Request(`http://localhost${url}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +const params = (id: string) => ({ params: Promise.resolve({ id }) }) as never; + +async function addCustom(body: unknown): Promise { + return createCustom(json("POST", "/api/vaccinations/custom", body) as never); +} +async function getCustoms(): Promise { + // The GET takes no request; the wrapper still hands one through. + return (listCustom as (request: Request) => Promise)( + json("GET", "/api/vaccinations/custom"), + ); +} +async function editCustom(id: string, body: unknown): Promise { + return patchCustom( + json("PATCH", `/api/vaccinations/custom/${id}`, body) as never, + params(id), + ); +} +async function removeCustom(id: string): Promise { + return deleteCustom( + json("DELETE", `/api/vaccinations/custom/${id}`) as never, + params(id), + ); +} +async function postDose(body: unknown): Promise { + return createVaccination(json("POST", "/api/vaccinations", body) as never); +} + +interface DoseDto { + id: string; + customVaccineId: string | null; + customVaccine: { name: string } | null; + series: { antigen: string; position: number; total: number | null }[]; + reminderId: string | null; +} + +async function doses(): Promise { + const res = await listVaccinations(json("GET", "/api/vaccinations") as never); + expect(res.status).toBe(200); + return ((await res.json()) as { data: { vaccinations: DoseDto[] } }).data + .vaccinations; +} + +const TRAVEL = { + name: "Travel combo", + components: ["typhoid", "hepatitis-a"], + typicalSeriesDoses: 2, + boosterIntervalMonths: 36, +}; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("an owner's own vaccine definitions", () => { + it("creates, lists and refuses a second definition of the same name", async () => { + const owner = await person("owner"); + signIn(owner); + + const created = await addCustom(TRAVEL); + expect(created.status).toBe(201); + const def = ((await created.json()) as { data: { id: string } }).data; + + const listed = await getCustoms(); + expect(listed.status).toBe(200); + expect( + ((await listed.json()) as { data: { id: string; name: string }[] }).data, + ).toEqual([expect.objectContaining({ id: def.id, name: "Travel combo" })]); + + // The same product, typed differently, is one product to the reader. + const clash = await addCustom({ ...TRAVEL, name: "travel COMBO" }); + expect(clash.status).toBe(409); + expect((await clash.json()).meta?.errorCode).toBe( + "vaccination.custom.name-taken", + ); + + // An antigen outside the closed list is refused. + const bad = await addCustom({ name: "Other", components: ["unicorn-pox"] }); + expect(bad.status).toBe(422); + }); + + it("counts a dose into every listed antigen and clears a booster keyed to one", async () => { + const owner = await person("owner"); + signIn(owner); + const prisma = getPrismaClient(); + const def = ( + (await (await addCustom(TRAVEL)).json()) as { data: { id: string } } + ).data; + // A hepatitis A booster already planned and due. + const reminder = await prisma.measurementReminder.create({ + data: { + userId: owner.id, + label: "hepatitis A booster", + intervalDays: 3650, + notifyHour: 9, + vaccinationAntigen: "hepatitis-a", + nextDueAt: daysAgo(1), + }, + }); + + const res = await postDose({ + occurredAt: daysAgo(3).toISOString(), + customVaccineId: def.id, + }); + expect(res.status, JSON.stringify(await res.clone().json())).toBe(201); + const dto = ((await res.json()) as { data: DoseDto }).data; + expect(dto.customVaccine?.name).toBe("Travel combo"); + expect(dto.series).toEqual([ + expect.objectContaining({ antigen: "typhoid", position: 1, total: 2 }), + expect.objectContaining({ + antigen: "hepatitis-a", + position: 1, + total: 2, + }), + ]); + expect(dto.reminderId).toBe(reminder.id); + expect( + ( + await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: reminder.id }, + }) + ).lastSatisfiedAt, + ).not.toBeNull(); + + // An edit to the definition is read by every dose that names it. + expect((await editCustom(def.id, { components: ["typhoid"] })).status).toBe( + 200, + ); + expect((await doses())[0].series.map((p) => p.antigen)).toEqual([ + "typhoid", + ]); + }); + + it("keys the booster mint on the definition's first antigen", async () => { + const owner = await person("owner"); + signIn(owner); + const def = ( + (await (await addCustom(TRAVEL)).json()) as { data: { id: string } } + ).data; + const dose = ( + (await ( + await postDose({ + occurredAt: daysAgo(3).toISOString(), + customVaccineId: def.id, + }) + ).json()) as { data: { id: string } } + ).data; + + const res = await planBooster( + json("POST", `/api/vaccinations/${dose.id}/booster`, { + intervalMonths: 36, + label: "Travel combo booster", + }) as never, + params(dose.id), + ); + expect(res.status).toBe(201); + const minted = await getPrismaClient().measurementReminder.findFirstOrThrow( + { where: { userId: owner.id } }, + ); + expect(minted.vaccinationAntigen).toBe("typhoid"); + }); + + it("refuses a definition another record holds", async () => { + const stranger = await person("stranger"); + signIn(stranger); + const foreign = ( + (await (await addCustom(TRAVEL)).json()) as { data: { id: string } } + ).data; + + const owner = await person("owner"); + signIn(owner); + const res = await postDose({ + occurredAt: daysAgo(3).toISOString(), + customVaccineId: foreign.id, + }); + expect(res.status).toBe(404); + expect((await res.json()).meta?.errorCode).toBe( + "vaccination.custom-vaccine-not-found", + ); + expect((await editCustom(foreign.id, { name: "Mine" })).status).toBe(404); + expect((await removeCustom(foreign.id)).status).toBe(404); + expect( + await getPrismaClient().vaccinationRecord.count({ + where: { userId: owner.id }, + }), + ).toBe(0); + }); + + it("removing a definition keeps every dose, each with a name, and the name can be used again", async () => { + const owner = await person("owner"); + signIn(owner); + const prisma = getPrismaClient(); + const def = ( + (await (await addCustom(TRAVEL)).json()) as { data: { id: string } } + ).data; + await postDose({ + occurredAt: daysAgo(30).toISOString(), + customVaccineId: def.id, + }); + await postDose({ + occurredAt: daysAgo(3).toISOString(), + customVaccineId: def.id, + vaccineName: "as the pass says", + }); + + expect((await removeCustom(def.id)).status).toBe(200); + // Removing twice succeeds. + expect((await removeCustom(def.id)).status).toBe(200); + + const rows = await prisma.vaccinationRecord.findMany({ + where: { userId: owner.id, deletedAt: null }, + orderBy: { occurredAt: "asc" }, + }); + expect(rows.map((row) => [row.customVaccineId, row.vaccineName])).toEqual([ + [null, "Travel combo"], + [null, "as the pass says"], + ]); + expect( + ((await (await getCustoms()).json()) as { data: unknown[] }).data, + ).toEqual([]); + const audit = await prisma.auditLog.findFirstOrThrow({ + where: { action: "vaccination.custom.delete" }, + orderBy: { createdAt: "asc" }, + }); + const details = + typeof audit.details === "string" + ? JSON.parse(audit.details) + : audit.details; + expect(details).toMatchObject({ dosesUnlinked: 2 }); + + // The same name again brings the definition back, without its old doses. + const again = await addCustom({ ...TRAVEL, boosterIntervalMonths: 12 }); + expect(again.status).toBe(201); + expect(((await again.json()) as { data: { id: string } }).data.id).toBe( + def.id, + ); + expect( + await prisma.vaccinationRecord.count({ + where: { userId: owner.id, customVaccineId: def.id }, + }), + ).toBe(0); + }); +}); + +describe("sharing and managed profiles", () => { + async function switchInAs( + ownerId: string, + access: "READ" | "WRITE", + ): Promise { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const delegate = await person("delegate"); + const invited = await inviteGrant({ + grantorId: ownerId, + granteeId: delegate.id, + access, + scope: ["profile"], + }); + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + signIn(delegate); + await switchSessionTo(delegate.sessionId, ownerId); + return delegate; + } + + it("lets a READ delegate list the definitions but not add one", async () => { + const owner = await person("owner"); + await getPrismaClient().customVaccine.create({ + data: { userId: owner.id, name: "Owner's own", components: ["rabies"] }, + }); + await switchInAs(owner.id, "READ"); + + const listed = await getCustoms(); + expect(listed.status).toBe(200); + expect( + ((await listed.json()) as { data: { name: string }[] }).data.map( + (row) => row.name, + ), + ).toEqual(["Owner's own"]); + + const res = await addCustom(TRAVEL); + expect(res.status).toBe(403); + expect((await res.json()).meta?.errorCode).toBe("sharing.access.denied"); + }); + + it("lets a WRITE delegate add onto the owner's record but not edit or remove", async () => { + const owner = await person("owner"); + const delegate = await switchInAs(owner.id, "WRITE"); + + const res = await addCustom(TRAVEL); + expect(res.status).toBe(201); + const def = ((await res.json()) as { data: { id: string } }).data; + const prisma = getPrismaClient(); + expect( + (await prisma.customVaccine.findUniqueOrThrow({ where: { id: def.id } })) + .userId, + ).toBe(owner.id); + expect( + await prisma.customVaccine.count({ where: { userId: delegate.id } }), + ).toBe(0); + + expect((await editCustom(def.id, { name: "Renamed" })).status).toBe(403); + expect((await removeCustom(def.id)).status).toBe(403); + }); + + it("writes a Guardian's definition to the managed profile", async () => { + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const guardian = await person("guardian"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Child record", + dateOfBirth: null, + locale: "en", + timezone: "Europe/Berlin", + }); + signIn(guardian); + await switchSessionTo(guardian.sessionId, profile.id); + + expect((await addCustom(TRAVEL)).status).toBe(201); + const prisma = getPrismaClient(); + expect( + await prisma.customVaccine.count({ where: { userId: profile.id } }), + ).toBe(1); + expect( + await prisma.customVaccine.count({ where: { userId: guardian.id } }), + ).toBe(0); + }); +});