diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 70132f280..33fb729bd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9678,6 +9678,650 @@ paths: "404": *a14 "422": *a3 "429": *a4 + /api/encounters: + get: + tags: + - Records + summary: List visits + description: Returns the caller's live visits, split into `upcoming` and `past`. Bounded and windowed; soft-deleted + visits are excluded. + parameters: + - in: query + name: from + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - in: query + name: to + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - in: query + name: status + schema: + type: string + enum: + - PLANNED + - DONE + - CANCELLED + - NO_SHOW + - in: query + name: practitionerId + schema: + type: string + minLength: 1 + maxLength: 40 + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 200 + responses: + "200": + description: The caller's visits. + content: + application/json: + schema: + $ref: "#/components/schemas/ListEncountersEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + post: + tags: + - Records + summary: File or book a visit + description: "Creates one visit. A future instant with `status: PLANNED` books an appointment and mints its reminder; a + past instant with `status: DONE` and a `reminderId` closes that checkup. Claiming both is a 422 — a visit that + has not happened cannot have closed anything. Honours `Idempotency-Key`. Audits as `encounter.visit.create`." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + occurredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + status: + default: DONE + type: string + enum: + - PLANNED + - DONE + - CANCELLED + - NO_SHOW + kind: + default: OTHER + type: string + enum: + - ROUTINE + - ACUTE + - SPECIALIST + - PREVENTIVE + - EMERGENCY + - HOSPITAL + - THERAPY + - OTHER + practitionerId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + reason: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + outcome: + anyOf: + - type: string + maxLength: 4000 + - type: "null" + reminderId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + documentIds: + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + labResultIds: + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + episodeIds: + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + required: + - occurredAt + additionalProperties: false + responses: + "201": + description: Visit filed. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateEncounterEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + /api/encounters/{id}: + get: + tags: + - Records + summary: Read a single visit + description: Returns the visit with its three link families resolved. Owner-scoped; a cross-user or tombstoned id 404s. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The visit. + content: + application/json: + schema: + $ref: "#/components/schemas/GetEncounterEnvelope" + "401": *a1 + "403": *a6 + "404": &a16 + description: Visit not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + patch: + tags: + - Records + summary: Edit a visit + description: Partial edit; omitted fields are left untouched. Audits as `encounter.visit.update`. Owner-scoped. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + occurredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + status: + type: string + enum: + - PLANNED + - DONE + - CANCELLED + - NO_SHOW + kind: + type: string + enum: + - ROUTINE + - ACUTE + - SPECIALIST + - PREVENTIVE + - EMERGENCY + - HOSPITAL + - THERAPY + - OTHER + practitionerId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + reason: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + outcome: + anyOf: + - type: string + maxLength: 4000 + - type: "null" + reminderId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + documentIds: + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + labResultIds: + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + episodeIds: + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + additionalProperties: false + responses: + "200": + description: Visit updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateEncounterEnvelope" + "401": *a1 + "403": *a6 + "404": *a16 + "422": *a3 + "429": *a4 + delete: + tags: + - Records + summary: Soft-delete a visit + description: Stamps `deletedAt` and retires the visit's own appointment reminder with it — never a checkup the visit + closed, which belongs to the preventive-care list and outlives it. Idempotent. Audits as + `encounter.visit.delete`. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Soft-deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteEncounterEnvelope" + "401": *a1 + "403": *a6 + "404": *a16 + "422": *a3 + "429": *a4 + /api/encounters/{id}/restore: + post: + tags: + - Records + summary: Restore a soft-deleted visit + description: Clears the tombstone. The link rows survived the soft delete, so the visit returns with its documents, labs + and conditions still filed against it. Its appointment reminder returns only if the appointment is still in the + future. Restoring a live visit is a no-op that still succeeds. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Visit restored. + content: + application/json: + schema: + $ref: "#/components/schemas/RestoreEncounterEnvelope" + "401": *a1 + "403": *a6 + "404": *a16 + "422": *a3 + "429": *a4 + /api/encounters/{id}/links: + post: + tags: + - Records + summary: File targets against a visit + description: "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated + only, matching document link and unlink." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + targetKind: + type: string + enum: + - document + - labResult + - conditionEpisode + targetIds: + minItems: 1 + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + required: + - targetKind + - targetIds + additionalProperties: false + responses: + "200": + description: The visit's links after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/LinkEncounterEnvelope" + "401": *a1 + "404": *a16 + "422": *a3 + "429": *a4 + delete: + tags: + - Records + summary: Unfile targets from a visit + description: Removes links. Idempotent, and it still works for a target the account has since deleted — refusing would + leave a filing nobody can clear. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + targetKind: + type: string + enum: + - document + - labResult + - conditionEpisode + targetIds: + minItems: 1 + maxItems: 100 + type: array + items: + type: string + minLength: 1 + maxLength: 40 + required: + - targetKind + - targetIds + additionalProperties: false + responses: + "200": + description: The visit's links after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/UnlinkEncounterEnvelope" + "401": *a1 + "404": *a16 + "422": *a3 + "429": *a4 + /api/practitioners: + get: + tags: + - Records + summary: List practitioners + description: Returns the caller's address book, name-ordered, with an optional case-insensitive name search. + parameters: + - in: query + name: q + schema: + type: string + minLength: 1 + maxLength: 160 + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 200 + responses: + "200": + description: The caller's address book. + content: + application/json: + schema: + $ref: "#/components/schemas/ListPractitionersEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + post: + tags: + - Records + summary: Add a practitioner + description: Creates one address-book entry. Honours `Idempotency-Key`. Audits as `practitioner.contact.create`. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 160 + specialty: + anyOf: + - type: string + maxLength: 120 + - type: "null" + practice: + anyOf: + - type: string + maxLength: 160 + - type: "null" + location: + anyOf: + - type: string + maxLength: 300 + - type: "null" + phone: + anyOf: + - type: string + maxLength: 60 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + required: + - name + additionalProperties: false + responses: + "201": + description: Practitioner added. + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePractitionerEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 + /api/practitioners/{id}: + get: + tags: + - Records + summary: Read a single practitioner + description: Returns the entry including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The practitioner. + content: + application/json: + schema: + $ref: "#/components/schemas/GetPractitionerEnvelope" + "401": *a1 + "403": *a6 + "404": &a17 + description: Practitioner not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + patch: + tags: + - Records + summary: Edit a practitioner + description: Partial edit; omitted fields are left untouched. Audits as `practitioner.contact.update`. Owner-scoped. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 160 + specialty: + anyOf: + - type: string + maxLength: 120 + - type: "null" + practice: + anyOf: + - type: string + maxLength: 160 + - type: "null" + location: + anyOf: + - type: string + maxLength: 300 + - type: "null" + phone: + anyOf: + - type: string + maxLength: 60 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + additionalProperties: false + responses: + "200": + description: Practitioner updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdatePractitionerEnvelope" + "401": *a1 + "403": *a6 + "404": *a17 + "422": *a3 + "429": *a4 + delete: + tags: + - Records + summary: Soft-delete a practitioner + description: Stamps `deletedAt`. Every visit that named this entry survives with no practice attached, because the + reference nulls rather than cascades. Idempotent. Audits as `practitioner.contact.delete`. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Soft-deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeletePractitionerEnvelope" + "401": *a1 + "403": *a6 + "404": *a17 + "422": *a3 + "429": *a4 + /api/practitioners/{id}/restore: + post: + tags: + - Records + summary: Restore a soft-deleted practitioner + description: Clears the tombstone. It does NOT re-attach the visits whose reference was nulled on delete — which visits + meant this practice is not recoverable, and guessing would be worse than the gap. Restoring a live entry is a + no-op that still succeeds. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Practitioner restored. + content: + application/json: + schema: + $ref: "#/components/schemas/RestorePractitionerEnvelope" + "401": *a1 + "403": *a6 + "404": *a17 + "422": *a3 + "429": *a4 /api/illness/episodes: get: tags: @@ -9803,7 +10447,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": &a16 + "404": &a18 description: Illness episode not found (or owned by another user). content: application/json: @@ -9882,7 +10526,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a16 + "404": *a18 "422": *a3 "429": *a4 delete: @@ -9906,7 +10550,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a16 + "404": *a18 "422": *a3 "429": *a4 /api/illness/episodes/{id}/restore: @@ -9933,7 +10577,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a16 + "404": *a18 "422": *a3 "429": *a4 /api/illness/episodes/{id}/resolve: @@ -9970,7 +10614,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a6 - "404": *a16 + "404": *a18 "422": *a3 "429": *a4 /api/illness/episodes/{id}/day-logs: @@ -10021,7 +10665,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a6 - "404": *a16 + "404": *a18 "422": *a3 "429": *a4 post: @@ -10101,7 +10745,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a6 - "404": *a16 + "404": *a18 "422": *a3 "429": *a4 /api/illness/episodes/{id}/correlation: @@ -10128,7 +10772,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a16 + "404": *a18 "422": *a3 "429": *a4 /api/illness/insights: @@ -10401,7 +11045,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a6 - "404": &a17 + "404": &a19 description: Allergy not found (or owned by another user). content: application/json: @@ -10484,7 +11128,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a17 + "404": *a19 "422": *a3 "429": *a4 delete: @@ -10508,7 +11152,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a6 - "404": *a17 + "404": *a19 "422": *a3 "429": *a4 /api/family-history: @@ -10616,7 +11260,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": &a18 + "404": &a20 description: Family history entry not found (or owned by another user). content: application/json: @@ -10685,7 +11329,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a20 "422": *a3 "429": *a4 delete: @@ -10709,7 +11353,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a6 - "404": *a18 + "404": *a20 "422": *a3 "429": *a4 /api/documents/inbound: @@ -11829,7 +12473,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a20 + "409": &a22 description: A custom metric with this name already exists for the caller. content: application/json: @@ -11858,7 +12502,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a6 - "404": &a19 + "404": &a21 description: Custom metric not found (or owned by another user). content: application/json: @@ -11922,8 +12566,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a19 - "409": *a20 + "404": *a21 + "409": *a22 "422": *a3 "429": *a4 delete: @@ -11946,7 +12590,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a19 + "404": *a21 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries: @@ -11990,7 +12634,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a6 - "404": *a19 + "404": *a21 "422": *a3 "429": *a4 post: @@ -12034,7 +12678,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a19 + "404": *a21 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries/{entryId}: @@ -12084,7 +12728,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a21 + "404": &a23 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -12116,7 +12760,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a21 + "404": *a23 "422": *a3 "429": *a4 /api/onboarding/disclaimer: @@ -12286,7 +12930,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a22 + "403": &a24 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 @@ -12341,7 +12985,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a22 + "403": *a24 "422": *a3 "429": *a4 /api/nutrients/water: @@ -12368,7 +13012,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a22 + "403": *a24 "422": *a3 "429": *a4 /api/integrations/healthkit: @@ -12706,7 +13350,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a23 + "403": &a25 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 @@ -12731,7 +13375,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a23 + "403": *a25 "422": *a3 "429": *a4 post: @@ -12826,7 +13470,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a23 + "403": *a25 "404": description: No such invitation for this account. content: @@ -12870,7 +13514,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a23 + "403": *a25 "404": description: No such grant on this account's record. content: @@ -12911,7 +13555,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a23 + "403": *a25 "404": description: No such grant held by this account. content: @@ -13210,7 +13854,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a23 + "403": *a25 "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 — @@ -34038,6 +34682,487 @@ components: - data - error additionalProperties: false + ListEncountersEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/EncounterList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + EncounterList: + type: object + properties: + upcoming: + type: array + items: + $ref: "#/components/schemas/Encounter" + past: + type: array + items: + $ref: "#/components/schemas/Encounter" + required: + - upcoming + - past + additionalProperties: false + description: Visits split server-side rather than client-side. `upcoming` reads soonest-first and `past` reads + newest-first — opposite directions, resolved once here so the ordering lives in one place. + Encounter: + type: object + properties: + id: + type: string + occurredAt: + type: string + status: + type: string + enum: + - PLANNED + - DONE + - CANCELLED + - NO_SHOW + kind: + type: string + enum: + - ROUTINE + - ACUTE + - SPECIALIST + - PREVENTIVE + - EMERGENCY + - HOSPITAL + - THERAPY + - OTHER + practitioner: + anyOf: + - $ref: "#/components/schemas/Practitioner" + - type: "null" + reason: + anyOf: + - type: string + - type: "null" + outcome: + anyOf: + - type: string + - type: "null" + reminderNextDueAt: + anyOf: + - type: string + - type: "null" + links: + $ref: "#/components/schemas/EncounterLinks" + skipped: + $ref: "#/components/schemas/EncounterSkipReport" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - occurredAt + - status + - kind + - practitioner + - reason + - outcome + - reminderNextDueAt + - createdAt + - updatedAt + additionalProperties: false + description: "One contact with the healthcare system: a visit that happened, or one that is booked. `practitioner` is + resolved rather than an id. `reason` and `outcome` are the decrypted free text (null on a key-rotation gap). + `reminderNextDueAt` is the server-computed instant a booked appointment next nudges, and is null for a visit + that already happened or whose one-shot reminder is spent." + Practitioner: + type: object + properties: + id: + type: string + name: + type: string + specialty: + anyOf: + - type: string + - type: "null" + practice: + anyOf: + - type: string + - type: "null" + location: + anyOf: + - type: string + - type: "null" + phone: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - name + - specialty + - practice + - location + - phone + - note + - createdAt + - updatedAt + additionalProperties: false + description: One entry in the caller's address book. `note` is the decrypted free text, or null on a key-rotation gap — + fail-soft, never a 500. + EncounterLinks: + type: object + properties: + documents: + type: array + items: + $ref: "#/components/schemas/EncounterLinkedTarget" + labResults: + type: array + items: + $ref: "#/components/schemas/EncounterLinkedTarget" + conditions: + type: array + items: + $ref: "#/components/schemas/EncounterLinkedTarget" + required: + - documents + - labResults + - conditions + additionalProperties: false + description: The three link families of one visit, each ordered by when it was filed. Present on the detail response, + absent from the list. + EncounterLinkedTarget: + type: object + properties: + id: + type: string + label: + anyOf: + - type: string + - type: "null" + date: + anyOf: + - type: string + - type: "null" + redacted: + type: boolean + required: + - id + - label + - date + - redacted + additionalProperties: false + description: "One thing filed against a visit, resolved. The label and date are computed server-side from the target + itself — a document's title and filing date, a lab result's panel and analyte and draw date, a condition's label + and onset — so the client never re-derives them and the two cannot drift. Both go null together, with `redacted: + true`, when the caller's grant does not cover the target's own domain: a visit lives in the health background + but points at the vault, the labs and the illness journal, and a grant that opened only the first was never + consent for the names in the other three. The link itself is still listed — the caller may know the visit + produced a lab result — and `redacted` is what tells a withheld name apart from a target that simply has no + date." + EncounterSkipReport: + type: object + properties: + catalogueKeys: + type: array + items: + type: object + properties: + catalogue: + type: string + key: + type: string + links: + type: number + required: + - catalogue + - key + - links + additionalProperties: false + links: + type: number + required: + - catalogueKeys + - links + additionalProperties: false + description: "What the write could not do, named. `links: 0` with an empty list is the ordinary answer. Anything else is + a side effect the caller's grant did not reach — today only `checkupClosure`, the closing of a preventive-care + checkup, which is a measurements-domain write a health-background delegate cannot perform. The visit still + saves; the checkup stays due and the response says so, because somebody who believes a checkup was closed will + not look at it again." + CreateEncounterEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Encounter" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetEncounterEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Encounter" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateEncounterEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Encounter" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteEncounterEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + RestoreEncounterEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Encounter" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LinkEncounterEnvelope: + type: object + properties: + data: + type: object + properties: + links: + $ref: "#/components/schemas/EncounterLinks" + unknown: + type: array + items: + type: string + required: + - links + - unknown + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UnlinkEncounterEnvelope: + type: object + properties: + data: + type: object + properties: + links: + $ref: "#/components/schemas/EncounterLinks" + unknown: + type: array + items: + type: string + required: + - links + - unknown + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ListPractitionersEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Practitioner" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CreatePractitionerEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Practitioner" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetPractitionerEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Practitioner" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdatePractitionerEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Practitioner" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeletePractitionerEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + RestorePractitionerEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Practitioner" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false ListIllnessEpisodesEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 0b4dc8816..22e0b9015 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6872,6 +6872,8 @@ "restoreSkippedIllnessSymptom": "Krankheitssymptom", "restoreSkippedMoodFactor": "Stimmungsfaktor", "restoreSkippedMoodTag": "Stimmungs-Tag", + "restoreSkippedVisitReference": "Besuchsverknüpfung", + "restoreSkippedCheckupClosure": "Vorsorge-Abschluss", "docsLink": "Dokumentation" }, "danger-zone": { @@ -9937,6 +9939,14 @@ "illnessEpisodeDelete": "{name} hat einen Krankheitseintrag gelöscht", "illnessEpisodeResolve": "{name} hat eine Erkrankung als beendet markiert", "illnessEpisodeRestore": "{name} hat einen Krankheitseintrag wiederhergestellt", + "encounterVisitCreate": "{name} hat einen Arztbesuch angelegt", + "encounterVisitUpdate": "{name} hat einen Arztbesuch bearbeitet", + "encounterVisitDelete": "{name} hat einen Arztbesuch gelöscht", + "encounterVisitRestore": "{name} hat einen Arztbesuch wiederhergestellt", + "practitionerContactCreate": "{name} hat eine Praxis zum Adressbuch hinzugefügt", + "practitionerContactUpdate": "{name} hat einen Adressbucheintrag bearbeitet", + "practitionerContactDelete": "{name} hat einen Adressbucheintrag gelöscht", + "practitionerContactRestore": "{name} hat einen Adressbucheintrag wiederhergestellt", "illnessDayLogUpsert": "{name} hat einen Krankheitstag erfasst", "medicationUpdate": "{name} hat ein Medikament geändert", "medicationDelete": "{name} hat ein Medikament gelöscht", @@ -10012,7 +10022,7 @@ }, "profile": { "label": "Gesundheitliche Vorgeschichte", - "detail": "Allergien, Familienanamnese und die Angaben in deinem Gesundheitsprofil." + "detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil sowie Arztbesuche und Praxen." }, "illness": { "label": "Krankheiten", @@ -10052,5 +10062,17 @@ "anamnesis": { "relativeNote": "Ein Eintrag zur Familienanamnese beschreibt die Angehörigen dieser Person. Trage ihn nur ein, wenn sie das hier festgehalten haben möchte." } + }, + "encounters": { + "kind": { + "routine": "Routinetermin", + "acute": "Akuttermin", + "specialist": "Facharzttermin", + "preventive": "Vorsorgetermin", + "emergency": "Notfall", + "hospital": "Krankenhaus", + "therapy": "Therapietermin", + "other": "Arzttermin" + } } } diff --git a/messages/en.json b/messages/en.json index f18373297..afacc42d2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6872,6 +6872,8 @@ "restoreSkippedIllnessSymptom": "Illness symptom", "restoreSkippedMoodFactor": "Mood factor", "restoreSkippedMoodTag": "Mood tag", + "restoreSkippedVisitReference": "Visit reference", + "restoreSkippedCheckupClosure": "Checkup closure", "docsLink": "Documentation" }, "danger-zone": { @@ -9937,6 +9939,14 @@ "illnessEpisodeDelete": "{name} removed an illness entry", "illnessEpisodeResolve": "{name} marked an illness as over", "illnessEpisodeRestore": "{name} restored an illness entry", + "encounterVisitCreate": "{name} added a visit", + "encounterVisitUpdate": "{name} edited a visit", + "encounterVisitDelete": "{name} deleted a visit", + "encounterVisitRestore": "{name} restored a visit", + "practitionerContactCreate": "{name} added a practice to the address book", + "practitionerContactUpdate": "{name} edited an address book entry", + "practitionerContactDelete": "{name} deleted an address book entry", + "practitionerContactRestore": "{name} restored an address book entry", "illnessDayLogUpsert": "{name} recorded an illness day", "medicationUpdate": "{name} changed a medication", "medicationDelete": "{name} removed a medication", @@ -10012,7 +10022,7 @@ }, "profile": { "label": "Health background", - "detail": "Allergies, family history and the facts in your health profile." + "detail": "Allergies, family history, the facts in your health profile, and your visits and practices." }, "illness": { "label": "Illness", @@ -10052,5 +10062,17 @@ "anamnesis": { "relativeNote": "A family history entry describes this person's relatives. Add it only when they would want it recorded here." } + }, + "encounters": { + "kind": { + "routine": "Routine visit", + "acute": "Acute visit", + "specialist": "Specialist visit", + "preventive": "Preventive check", + "emergency": "Emergency", + "hospital": "Hospital", + "therapy": "Therapy session", + "other": "Appointment" + } } } diff --git a/messages/es.json b/messages/es.json index 7b3227215..7cec1ba45 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6872,6 +6872,8 @@ "restoreSkippedIllnessSymptom": "Síntoma de enfermedad", "restoreSkippedMoodFactor": "Factor de ánimo", "restoreSkippedMoodTag": "Etiqueta de ánimo", + "restoreSkippedVisitReference": "Referencia de visita", + "restoreSkippedCheckupClosure": "Cierre de revisión", "docsLink": "Documentación" }, "danger-zone": { @@ -9937,6 +9939,14 @@ "illnessEpisodeDelete": "{name} eliminó una enfermedad", "illnessEpisodeResolve": "{name} marcó una enfermedad como terminada", "illnessEpisodeRestore": "{name} restauró una enfermedad", + "encounterVisitCreate": "{name} añadió una visita médica", + "encounterVisitUpdate": "{name} editó una visita médica", + "encounterVisitDelete": "{name} eliminó una visita médica", + "encounterVisitRestore": "{name} restauró una visita médica", + "practitionerContactCreate": "{name} añadió una consulta a la agenda", + "practitionerContactUpdate": "{name} editó una entrada de la agenda", + "practitionerContactDelete": "{name} eliminó una entrada de la agenda", + "practitionerContactRestore": "{name} restauró una entrada de la agenda", "illnessDayLogUpsert": "{name} registró un día de enfermedad", "medicationUpdate": "{name} cambió un medicamento", "medicationDelete": "{name} eliminó un medicamento", @@ -10012,7 +10022,7 @@ }, "profile": { "label": "Antecedentes de salud", - "detail": "Alergias, antecedentes familiares y los datos de tu perfil de salud." + "detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud y tus visitas médicas y consultas." }, "illness": { "label": "Enfermedades", @@ -10052,5 +10062,17 @@ "anamnesis": { "relativeNote": "Una entrada de antecedentes familiares describe a los familiares de esta persona. Añádela solo si querría que quedara registrada aquí." } + }, + "encounters": { + "kind": { + "routine": "Visita de rutina", + "acute": "Visita por urgencia", + "specialist": "Visita al especialista", + "preventive": "Revisión preventiva", + "emergency": "Urgencia", + "hospital": "Hospital", + "therapy": "Sesión de terapia", + "other": "Cita médica" + } } } diff --git a/messages/fr.json b/messages/fr.json index 219ed0de9..d91f2cf3d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6872,6 +6872,8 @@ "restoreSkippedIllnessSymptom": "Symptôme de maladie", "restoreSkippedMoodFactor": "Facteur d'humeur", "restoreSkippedMoodTag": "Étiquette d'humeur", + "restoreSkippedVisitReference": "Référence de visite", + "restoreSkippedCheckupClosure": "Clôture d'un examen", "docsLink": "Documentation" }, "danger-zone": { @@ -9937,6 +9939,14 @@ "illnessEpisodeDelete": "{name} a supprimé une maladie", "illnessEpisodeResolve": "{name} a marqué une maladie comme terminée", "illnessEpisodeRestore": "{name} a restauré une maladie", + "encounterVisitCreate": "{name} a ajouté une consultation", + "encounterVisitUpdate": "{name} a modifié une consultation", + "encounterVisitDelete": "{name} a supprimé une consultation", + "encounterVisitRestore": "{name} a restauré une consultation", + "practitionerContactCreate": "{name} a ajouté un cabinet au carnet d'adresses", + "practitionerContactUpdate": "{name} a modifié une entrée du carnet d'adresses", + "practitionerContactDelete": "{name} a supprimé une entrée du carnet d'adresses", + "practitionerContactRestore": "{name} a restauré une entrée du carnet d'adresses", "illnessDayLogUpsert": "{name} a enregistré une journée de maladie", "medicationUpdate": "{name} a modifié un médicament", "medicationDelete": "{name} a supprimé un médicament", @@ -10012,7 +10022,7 @@ }, "profile": { "label": "Antécédents de santé", - "detail": "Allergies, antécédents familiaux et les informations de votre profil de santé." + "detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, ainsi que vos consultations et cabinets." }, "illness": { "label": "Maladies", @@ -10052,5 +10062,17 @@ "anamnesis": { "relativeNote": "Un antécédent familial décrit les proches de cette personne. Ne l'ajoutez que si elle voudrait le voir consigné ici." } + }, + "encounters": { + "kind": { + "routine": "Consultation de routine", + "acute": "Consultation urgente", + "specialist": "Consultation spécialisée", + "preventive": "Examen de prévention", + "emergency": "Urgence", + "hospital": "Hôpital", + "therapy": "Séance de thérapie", + "other": "Rendez-vous médical" + } } } diff --git a/messages/it.json b/messages/it.json index e3f206ded..60bfd0bf4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6872,6 +6872,8 @@ "restoreSkippedIllnessSymptom": "Sintomo di malattia", "restoreSkippedMoodFactor": "Fattore dell'umore", "restoreSkippedMoodTag": "Etichetta dell'umore", + "restoreSkippedVisitReference": "Riferimento alla visita", + "restoreSkippedCheckupClosure": "Chiusura del controllo", "docsLink": "Documentazione" }, "danger-zone": { @@ -9937,6 +9939,14 @@ "illnessEpisodeDelete": "{name} ha eliminato una malattia", "illnessEpisodeResolve": "{name} ha segnato una malattia come conclusa", "illnessEpisodeRestore": "{name} ha ripristinato una malattia", + "encounterVisitCreate": "{name} ha aggiunto una visita medica", + "encounterVisitUpdate": "{name} ha modificato una visita medica", + "encounterVisitDelete": "{name} ha eliminato una visita medica", + "encounterVisitRestore": "{name} ha ripristinato una visita medica", + "practitionerContactCreate": "{name} ha aggiunto uno studio alla rubrica", + "practitionerContactUpdate": "{name} ha modificato una voce della rubrica", + "practitionerContactDelete": "{name} ha eliminato una voce della rubrica", + "practitionerContactRestore": "{name} ha ripristinato una voce della rubrica", "illnessDayLogUpsert": "{name} ha registrato una giornata di malattia", "medicationUpdate": "{name} ha modificato un farmaco", "medicationDelete": "{name} ha eliminato un farmaco", @@ -10012,7 +10022,7 @@ }, "profile": { "label": "Anamnesi", - "detail": "Allergie, storia familiare e i dati del tuo profilo di salute." + "detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario e le tue visite mediche e studi." }, "illness": { "label": "Malattie", @@ -10052,5 +10062,17 @@ "anamnesis": { "relativeNote": "Una voce di anamnesi familiare descrive i parenti di questa persona. Aggiungila solo se vorrebbe vederla registrata qui." } + }, + "encounters": { + "kind": { + "routine": "Visita di routine", + "acute": "Visita urgente", + "specialist": "Visita specialistica", + "preventive": "Controllo preventivo", + "emergency": "Emergenza", + "hospital": "Ospedale", + "therapy": "Seduta di terapia", + "other": "Appuntamento medico" + } } } diff --git a/messages/pl.json b/messages/pl.json index 80efa43b8..cd1f4c71e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6872,6 +6872,8 @@ "restoreSkippedIllnessSymptom": "Objaw choroby", "restoreSkippedMoodFactor": "Czynnik nastroju", "restoreSkippedMoodTag": "Tag nastroju", + "restoreSkippedVisitReference": "Odwołanie wizyty", + "restoreSkippedCheckupClosure": "Zamknięcie badania", "docsLink": "Dokumentacja" }, "danger-zone": { @@ -9937,6 +9939,14 @@ "illnessEpisodeDelete": "{name} usunął wpis o chorobie", "illnessEpisodeResolve": "{name} oznaczył chorobę jako zakończoną", "illnessEpisodeRestore": "{name} przywrócił wpis o chorobie", + "encounterVisitCreate": "{name} dodał wizytę lekarską", + "encounterVisitUpdate": "{name} zmienił wizytę lekarską", + "encounterVisitDelete": "{name} usunął wizytę lekarską", + "encounterVisitRestore": "{name} przywrócił wizytę lekarską", + "practitionerContactCreate": "{name} dodał gabinet do książki adresowej", + "practitionerContactUpdate": "{name} zmienił wpis w książce adresowej", + "practitionerContactDelete": "{name} usunął wpis w książce adresowej", + "practitionerContactRestore": "{name} przywrócił wpis w książce adresowej", "illnessDayLogUpsert": "{name} zapisał dzień choroby", "medicationUpdate": "{name} zmienił lek", "medicationDelete": "{name} usunął lek", @@ -10012,7 +10022,7 @@ }, "profile": { "label": "Historia zdrowia", - "detail": "Alergie, wywiad rodzinny i dane z Twojego profilu zdrowia." + "detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego oraz wizyty lekarskie i gabinety." }, "illness": { "label": "Choroby", @@ -10052,5 +10062,17 @@ "anamnesis": { "relativeNote": "Wpis w wywiadzie rodzinnym opisuje bliskich tej osoby. Dodaj go tylko wtedy, gdy chciałaby, żeby został tu zapisany." } + }, + "encounters": { + "kind": { + "routine": "Wizyta kontrolna", + "acute": "Wizyta pilna", + "specialist": "Wizyta u specjalisty", + "preventive": "Badanie profilaktyczne", + "emergency": "Nagły przypadek", + "hospital": "Szpital", + "therapy": "Sesja terapeutyczna", + "other": "Wizyta lekarska" + } } } diff --git a/prisma/migrations/0320_encounter_practitioner/migration.sql b/prisma/migrations/0320_encounter_practitioner/migration.sql new file mode 100644 index 000000000..7c8d7b35f --- /dev/null +++ b/prisma/migrations/0320_encounter_practitioner/migration.sql @@ -0,0 +1,169 @@ +-- Visits: the encounter record and the address book it points at. +-- +-- The whole of the previous "which doctor" model was one free-text column on a +-- reminder. A person typed the practice name once per reminder and could reach +-- nothing else that happened there. These five tables turn that string into a +-- record: a visit that happened or is booked, the practitioners it points at, +-- and three links to the documents, lab results and conditions it produced. +-- +-- Additive only. No existing table is altered, nothing is backfilled, and the +-- new enum value on `reminder_origin` is not used by any statement here, so the +-- ADD VALUE is safe inside the migration transaction. + +-- CreateEnum +CREATE TYPE "encounter_status" AS ENUM ('PLANNED', 'DONE', 'CANCELLED', 'NO_SHOW'); + +-- CreateEnum +CREATE TYPE "encounter_kind" AS ENUM ('ROUTINE', 'ACUTE', 'SPECIALIST', 'PREVENTIVE', 'EMERGENCY', 'HOSPITAL', 'THERAPY', 'OTHER'); + +-- AlterEnum +-- A planned visit mints one one-shot reminder on the existing engine rather +-- than starting a second one. ENCOUNTER rows are excluded from every Vorsorge +-- read surface; the published `origin` enum stays at two members. +ALTER TYPE "reminder_origin" ADD VALUE IF NOT EXISTS 'ENCOUNTER'; + +-- CreateTable +CREATE TABLE "encounters" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "occurred_at" TIMESTAMP(3) NOT NULL, + "status" "encounter_status" NOT NULL DEFAULT 'DONE', + "kind" "encounter_kind" NOT NULL DEFAULT 'OTHER', + "practitioner_id" TEXT, + "reason_encrypted" BYTEA, + "outcome_encrypted" BYTEA, + "reminder_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "encounters_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "practitioners" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "specialty" TEXT, + "practice" TEXT, + "location" TEXT, + "phone" TEXT, + "note_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "practitioners_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encounter_document_links" ( + "id" TEXT NOT NULL, + "encounter_id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "encounter_document_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encounter_lab_links" ( + "id" TEXT NOT NULL, + "encounter_id" TEXT NOT NULL, + "lab_result_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "encounter_lab_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "encounter_condition_links" ( + "id" TEXT NOT NULL, + "encounter_id" TEXT NOT NULL, + "episode_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "encounter_condition_links_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "encounters_user_id_deleted_at_occurred_at_idx" ON "encounters"("user_id", "deleted_at", "occurred_at"); + +-- CreateIndex +CREATE INDEX "encounters_user_id_status_occurred_at_idx" ON "encounters"("user_id", "status", "occurred_at"); + +-- CreateIndex +CREATE INDEX "encounters_practitioner_id_idx" ON "encounters"("practitioner_id"); + +-- CreateIndex +CREATE INDEX "practitioners_user_id_deleted_at_name_idx" ON "practitioners"("user_id", "deleted_at", "name"); + +-- CreateIndex +CREATE INDEX "encounter_document_links_document_id_idx" ON "encounter_document_links"("document_id"); + +-- CreateIndex +CREATE INDEX "encounter_document_links_user_id_idx" ON "encounter_document_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "encounter_document_links_encounter_id_document_id_key" ON "encounter_document_links"("encounter_id", "document_id"); + +-- CreateIndex +CREATE INDEX "encounter_lab_links_lab_result_id_idx" ON "encounter_lab_links"("lab_result_id"); + +-- CreateIndex +CREATE INDEX "encounter_lab_links_user_id_idx" ON "encounter_lab_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "encounter_lab_links_encounter_id_lab_result_id_key" ON "encounter_lab_links"("encounter_id", "lab_result_id"); + +-- CreateIndex +CREATE INDEX "encounter_condition_links_episode_id_idx" ON "encounter_condition_links"("episode_id"); + +-- CreateIndex +CREATE INDEX "encounter_condition_links_user_id_idx" ON "encounter_condition_links"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "encounter_condition_links_encounter_id_episode_id_key" ON "encounter_condition_links"("encounter_id", "episode_id"); + +-- AddForeignKey +ALTER TABLE "encounters" ADD CONSTRAINT "encounters_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounters" ADD CONSTRAINT "encounters_practitioner_id_fkey" FOREIGN KEY ("practitioner_id") REFERENCES "practitioners"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounters" ADD CONSTRAINT "encounters_reminder_id_fkey" FOREIGN KEY ("reminder_id") REFERENCES "measurement_reminders"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "practitioners" ADD CONSTRAINT "practitioners_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_document_links" ADD CONSTRAINT "encounter_document_links_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_document_links" ADD CONSTRAINT "encounter_document_links_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_document_links" ADD CONSTRAINT "encounter_document_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_lab_links" ADD CONSTRAINT "encounter_lab_links_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_lab_links" ADD CONSTRAINT "encounter_lab_links_lab_result_id_fkey" FOREIGN KEY ("lab_result_id") REFERENCES "lab_results"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_lab_links" ADD CONSTRAINT "encounter_lab_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_condition_links" ADD CONSTRAINT "encounter_condition_links_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_condition_links" ADD CONSTRAINT "encounter_condition_links_episode_id_fkey" FOREIGN KEY ("episode_id") REFERENCES "illness_episodes"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "encounter_condition_links" ADD CONSTRAINT "encounter_condition_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 92cb5c569..3116419c1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -716,108 +716,108 @@ model User { passkeyUpgradeNudgeDismissed Boolean @default(false) @map("passkey_upgrade_nudge_dismissed") // Relations - passkeys Passkey[] - sessions Session[] @relation("SessionOwner") + passkeys Passkey[] + sessions Session[] @relation("SessionOwner") // v1.36.0 — browser sessions belonging to OTHER accounts that are currently // acting on this one. Named so the delete cascade has somewhere to point. - sessionsActingOnMe Session[] @relation("SessionActingAs") - challenges AuthChallenge[] + sessionsActingOnMe Session[] @relation("SessionActingAs") + challenges AuthChallenge[] // v1.23 — second-factor stores (kept separate from `passkeys`, which is the // passwordless-primary credential set; an MFA credential is meaningless // without the password). - mfaChallenges MfaChallenge[] - mfaRecoveryCodes MfaRecoveryCode[] - webauthnMfaCredentials WebauthnMfaCredential[] - knownDevices UserKnownDevice[] + mfaChallenges MfaChallenge[] + mfaRecoveryCodes MfaRecoveryCode[] + webauthnMfaCredentials WebauthnMfaCredential[] + knownDevices UserKnownDevice[] // v1.23 — "remember this device" trusted-device tokens. A trusted device // skips the second factor (factor 2 only) within the window; the password // is still required. Revoked when the second factor is removed. - trustedDevices TrustedDevice[] + trustedDevices TrustedDevice[] // v1.30.34 — short-lived, token-bound step-up elevations. The Bearer-transport // equivalent of `Session.mfaVerifiedAt`: a token client re-proves a factor and // receives one single-use elevation good for one MFA-management call. - stepUpElevations StepUpElevation[] - measurements Measurement[] - medications Medication[] - intakeEvents MedicationIntakeEvent[] - medicationPauseEras MedicationPauseEra[] - apiTokens ApiToken[] + stepUpElevations StepUpElevation[] + measurements Measurement[] + medications Medication[] + intakeEvents MedicationIntakeEvent[] + medicationPauseEras MedicationPauseEra[] + apiTokens ApiToken[] // v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted // authorization for an MCP client, audience-bound to health:read/health:write. - mcpOAuthConnections McpOAuthConnection[] - refreshTokens RefreshToken[] + mcpOAuthConnections McpOAuthConnection[] + refreshTokens RefreshToken[] // v1.30.x — one-time native OIDC SSO handoff codes. Each row bridges the // browser-context OIDC callback to the cookie-less native token exchange. - oidcNativeHandoffs OidcNativeHandoff[] - auditLogs AuditLog[] - withingsConnection WithingsConnection? + oidcNativeHandoffs OidcNativeHandoff[] + auditLogs AuditLog[] + withingsConnection WithingsConnection? // v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the // Withings OAuth state check. The connect route mints a row with a // 10-minute TTL; the callback resolves the user via this relation // instead of parsing the cookie value, closing the L-1 audit gap // where the legacy `${user.id}:${nonce}` shape leaked the user id // into request logs / network captures. - withingsOAuthStates WithingsOAuthState[] + withingsOAuthStates WithingsOAuthState[] // v1.11.0 — WHOOP integration. Mirrors the Withings connection (1:1) + // OAuth-state ledger (1:N) relations. - whoopConnection WhoopConnection? - whoopOAuthStates WhoopOAuthState[] - whoopConnectTickets WhoopConnectTicket[] + whoopConnection WhoopConnection? + whoopOAuthStates WhoopOAuthState[] + whoopConnectTickets WhoopConnectTicket[] // v1.12.0 — Fitbit/Pixel integration. Mirrors the WHOOP connection (1:1) + // OAuth-state ledger (1:N) relations. - fitbitConnection FitbitConnection? - fitbitOAuthStates FitbitOAuthState[] + fitbitConnection FitbitConnection? + fitbitOAuthStates FitbitOAuthState[] // v1.27.0 — Google Health API integration (separate provider from the classic // Fitbit Web API above). Mirrors the connection (1:1) + OAuth-state ledger // (1:N) relations. - googleHealthConnection GoogleHealthConnection? - googleHealthOAuthStates GoogleHealthOAuthState[] - moodEntries MoodEntry[] - moodContexts MoodContext[] - mentalHealthAssessments MentalHealthAssessment[] - moodTagsCustom MoodTag[] @relation("UserMoodTags") - moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories") - moodTagsHidden MoodTagHidden[] - notificationChannels NotificationChannel[] - pushSubscriptions PushSubscription[] - dataBackups DataBackup[] - achievements UserAchievement[] - recommendationFeedback RecommendationFeedback[] - idempotencyKeys IdempotencyKey[] - devices Device[] - integrationStatuses IntegrationStatus[] - coachConversations CoachConversation[] - coachFacts CoachFact[] - coachPlans CoachPlan[] - coachReminders CoachReminder[] - coachUsage CoachUsage[] + googleHealthConnection GoogleHealthConnection? + googleHealthOAuthStates GoogleHealthOAuthState[] + moodEntries MoodEntry[] + moodContexts MoodContext[] + mentalHealthAssessments MentalHealthAssessment[] + moodTagsCustom MoodTag[] @relation("UserMoodTags") + moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories") + moodTagsHidden MoodTagHidden[] + notificationChannels NotificationChannel[] + pushSubscriptions PushSubscription[] + dataBackups DataBackup[] + achievements UserAchievement[] + recommendationFeedback RecommendationFeedback[] + idempotencyKeys IdempotencyKey[] + devices Device[] + integrationStatuses IntegrationStatus[] + coachConversations CoachConversation[] + coachFacts CoachFact[] + coachPlans CoachPlan[] + coachReminders CoachReminder[] + coachUsage CoachUsage[] // v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings // getworkouts / future Strava). Schema lands now so the v1.5 iOS // session can build against a stable DTO. Routes hang off Workout // (1:1), not User — the User → WorkoutRoute path is two hops by // design. - workouts Workout[] + workouts Workout[] // v1.4.25 W8d — personal records (schema only; detection worker // lands in a later release). The GET /api/personal-records route // ships with v1.4.25 so the iOS app can build its query path // against a stable contract. - personalRecords PersonalRecord[] + personalRecords PersonalRecord[] // Today rail dismiss ledger — observational PriorityItem kinds only. - dismissedPriorityItems DismissedPriorityItem[] + dismissedPriorityItems DismissedPriorityItem[] // v1.4.25 W19b — per-pen / per-vial inventory entities, the // companion to the running-sum MedicationInventoryEvent ledger. // Carries the EMA §6.3 30-day in-use clock per opened pen. - inventoryItems MedicationInventoryItem[] + inventoryItems MedicationInventoryItem[] // v1.4.25 W19d — GLP-1 side-effect logbook. 21-entry × 5-category // EMA EPAR §4.8-derived taxonomy with 1-5 Likert severity. Daily // capture (not per-shot) so delayed-onset symptoms map to their // actual occurrence time. - medicationSideEffects MedicationSideEffect[] + medicationSideEffects MedicationSideEffect[] // v1.4.34 — Apple Health `export.zip` import jobs. Mirrors the // pg-boss job state so the polling endpoint can read live per- // phase progress without round-tripping through pg-boss internals. - importJobs ImportJob[] - medicationIntakeImportJobs MedicationIntakeImportJob[] @relation("MedicationIntakeImportRecord") + importJobs ImportJob[] + medicationIntakeImportJobs MedicationIntakeImportJob[] @relation("MedicationIntakeImportRecord") // v1.5.0 — persistent measurement rollup table. Carries the // count/min/max/mean/sd/slope/r2 stats per (type, granularity, // bucketStart) so the analytics + comprehensive read paths bypass @@ -825,134 +825,143 @@ model User { // multi-instance fan-out. Populated synchronously on the DAY // granularity from the measurement write hooks; WEEK/MONTH/YEAR // are enqueued through pg-boss for the worker to fold. - measurementRollups MeasurementRollup[] + measurementRollups MeasurementRollup[] // v1.34.0 — per-(user, type, local day) cumulative hourly profile. The // shape the nightly cumulative drain folds out of the per-sample rows // immediately before deleting them, so a "typical by 21:00" comparison // has a substrate for days older than the 36-hour grace window. - intradayCumulativeProfiles IntradayCumulativeProfile[] + intradayCumulativeProfiles IntradayCumulativeProfile[] // v0.5.4 ios-coord — per-day dedup ledger for MOOD_REMINDER pushes. // One row per (user, local date) the worker has nudged. Prevents // a second push when the cron rechecks the same hour later in the // 22:00 window (the cron fires every 15 min — without the ledger // a user could see three pushes back-to-back). - moodReminderDispatches MoodReminderDispatch[] + moodReminderDispatches MoodReminderDispatch[] // v1.4.39 W-MOOD — per-(user, granularity, bucket) mood rollup. New // tier behind the mood-analytics read path; per-write hooks fold // MoodEntry mutations into the DAY granularity inline so the next // read serves from this table instead of the unbounded // MoodEntry.findMany walk. - moodEntryRollups MoodEntryRollup[] + moodEntryRollups MoodEntryRollup[] // v1.4.39 W-MED — per-(user, medication, day) compliance rollup. // Per-write hook on MedicationIntakeEvent mutations + the scheduler // keep the row in sync so `/api/medications/intake?scope=compliance` // and `computeUserHealthScoreFastPath` skip the per-event findMany. - medicationComplianceRollups MedicationComplianceRollup[] + medicationComplianceRollups MedicationComplianceRollup[] // v1.4.40 SB-10 — AI consent receipts the iOS app POSTs when the user // grants AI processing during onboarding. Persisted audit trail // covers App-Store Guideline 5.1.2(i) ("data-collection consent must // be verifiable") plus the operator's GDPR Art. 7 documentation // burden. Append-only — a user revoking + re-granting consent mints // a fresh row so the timeline reads chronologically. - consentReceipts ConsentReceipt[] + consentReceipts ConsentReceipt[] // v1.4.49 — per-attempt push-delivery ledger. Every sender (APNS, // WEB_PUSH, TELEGRAM, NTFY) writes one fire-and-forget row per // dispatch so the admin diagnostic endpoint can return the trailing // 20 attempts ordered by recency. Daily prune at 03:35 Europe/Berlin // retires rows older than 90 days (see `handlePushAttemptCleanup` in // `reminder-worker.ts`). - pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser") - pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser") - notificationEvents NotificationEvent[] + pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser") + pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser") + notificationEvents NotificationEvent[] notificationEgressAuthorizationsForRecord NotificationEgressAuthorization[] @relation("NotificationEgressAuthorizationRecordUser") notificationEgressAuthorizationsReceived NotificationEgressAuthorization[] @relation("NotificationEgressAuthorizationRecipientUser") - telegramReminderMessages TelegramReminderMessage[] + telegramReminderMessages TelegramReminderMessage[] // v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total // TRIMP + running EWMA reference) so the nightly Strain engine anchors // the 0–100 map to the user's own training load without re-integrating // the 42-day chronic window of HR series each night. - strainTrimpCache StrainTrimpCache[] + strainTrimpCache StrainTrimpCache[] // v1.11.0 — owner-minted clinician share links. Each row grants a // scoped, time-limited, revocable read view of the health record to a // clinician holding the raw `hls_` token (stored only as an HMAC hash). - clinicianShareLinks ClinicianShareLink[] + clinicianShareLinks ClinicianShareLink[] // v1.36.0 — account sharing. Two relations because a person is on both sides // of the arrangement: `accountGrantsGiven` are the grants this account made // over its OWN record, `accountGrantsReceived` are the records this account // may open. Both cascade on delete of this row — a grant naming a user who // no longer exists is a dangling authorization. - accountGrantsGiven AccountGrant[] @relation("AccountGrantsGiven") - accountGrantsReceived AccountGrant[] @relation("AccountGrantsReceived") + accountGrantsGiven AccountGrant[] @relation("AccountGrantsGiven") + accountGrantsReceived AccountGrant[] @relation("AccountGrantsReceived") // v1.11.0 W1 — durable per-provider AI-health ledger (one row per // provider chain entry) so a known-bad credential is negatively cached // across workers instead of re-burning a 401 every generation. - providerHealth ProviderHealth[] + providerHealth ProviderHealth[] // v1.11.0 W3 — one durable period-narrative row per (period, locale). // Delete/regenerate-clean; the generated prose is AES-256-GCM at rest. - insightNarratives InsightNarrative[] + insightNarratives InsightNarrative[] // v1.31.0 — the data-arrival spine's durable per-day reaction marker. One // row per (kind, local day); the unique index IS the once-per-kind-per-day // claim every reaction surface reads. Retained 14 days. - arrivalReactions ArrivalReaction[] - workoutInsights WorkoutInsight[] - workoutInsightGenerationClaims WorkoutInsightGenerationClaim[] + arrivalReactions ArrivalReaction[] + workoutInsights WorkoutInsight[] + workoutInsightGenerationClaims WorkoutInsightGenerationClaim[] // v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row // per user-created reminder ("BP every 7 days", "annual blood panel"). // Server computes the canonical `nextDueAt`; the cron fires through the // existing notification dispatcher. Soft-deleted. - measurementReminders MeasurementReminder[] + measurementReminders MeasurementReminder[] // v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …) // that pairs with the Vorsorge "annual blood panel" reminder so a result // has somewhere to land. Soft-deleted. - labResults LabResult[] + labResults LabResult[] // v1.19.0 — user-scoped biomarker catalog (canonical name/unit/range per // marker); LabResult rows link to a Biomarker for the resolved range/unit. - biomarkers Biomarker[] + biomarkers Biomarker[] // v1.25.5 — user-defined custom metrics. A separate generic store // (catalog + values), not synced and not in FHIR. Correlation discovery // reads a metric only after the account explicitly opts it in. - customMetrics CustomMetric[] - customMetricEntries CustomMetricEntry[] - correlationPatterns CorrelationPattern[] + customMetrics CustomMetric[] + customMetricEntries CustomMetricEntry[] + correlationPatterns CorrelationPattern[] // v1.35.0 — one row per local day holding the health score as it was shown // that day. Append-only: a later recomputation of the same day never // rewrites the row it finds. - healthScoreRecords HealthScoreRecord[] + healthScoreRecords HealthScoreRecord[] // v1.19.0 — illness / condition journal (born-gated by the module system). // IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the // per-day timeline mirroring the CycleDayLog shape. - illnessEpisodes IllnessEpisode[] - illnessDayLogs IllnessDayLog[] + illnessEpisodes IllnessEpisode[] + illnessDayLogs IllnessDayLog[] // v1.25 (W-RECORDS) — structured health-record entries. Allergies map to // FHIR AllergyIntolerance; family-history entries map to FHIR // FamilyMemberHistory. Reference records (not time-series signals); the // sensitive free-text fields are encrypted at rest. - allergies Allergy[] - familyHistory FamilyHistoryEntry[] + allergies Allergy[] + familyHistory FamilyHistoryEntry[] // v1.25 (W-ENV) — opt-in environmental-context module. `EnvironmentContext` // is the per-day weather/daylight observation keyed to the resolved location; // `EnvironmentTravelLocation` holds the manual date-range travel overrides // that win over the home location for the days they cover. - environmentContexts EnvironmentContext[] - environmentTravelLocations EnvironmentTravelLocation[] + environmentContexts EnvironmentContext[] + environmentTravelLocations EnvironmentTravelLocation[] // v1.28 — micronutrient intake daily totals synced from Apple Health // (vitamins, minerals, water, caffeine). Opt-in `nutrients` module, off // by default; the ingest route refuses while the module is off. - nutrientIntakeDays NutrientIntakeDay[] + nutrientIntakeDays NutrientIntakeDay[] // v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports / // discharge letters) the user uploads. The raw document is encrypted at // rest; extracted facts land in a review-then-confirm staging area // (ExtractedFact) and only reach the structured stores on explicit // approval. Opt-in module, off by default. - inboundDocuments InboundDocument[] - extractedFacts ExtractedFact[] - documentConditionLinks DocumentConditionLink[] - documentContentIndexes DocumentContentIndex[] - documentThumbnails DocumentThumbnail[] + inboundDocuments InboundDocument[] + extractedFacts ExtractedFact[] + documentConditionLinks DocumentConditionLink[] + documentContentIndexes DocumentContentIndex[] + documentThumbnails DocumentThumbnail[] // v1.19.0 — full Withings ECG waveform recordings (encrypted at rest). // The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION // Measurement EVENT row (v1.18.11); this holds the raw signal samples. - ecgRecordings EcgRecording[] + ecgRecordings EcgRecording[] + // v1.38.0 — visits. `Encounter` is one contact with the healthcare + // system (happened or booked), `Practitioner` the account's own address + // book, and the three link tables are what the visit produced. No + // module gate: a visit is core, like the checkups page it lives on. + encounters Encounter[] + practitioners Practitioner[] + encounterDocumentLinks EncounterDocumentLink[] + encounterLabLinks EncounterLabLink[] + encounterConditionLinks EncounterConditionLink[] // v1.7.0 profile — optional patient-identity fields surfaced on the // health-record export cover (PDF) and the FHIR `Patient` resource. @@ -991,16 +1000,16 @@ model User { // v1.15.20 — user-authored "about me" context for the AI surfaces // (1:1, encrypted free text). See `UserHealthProfile`. - healthProfile UserHealthProfile? + healthProfile UserHealthProfile? healthProfileFactRevisions HealthProfileFactRevision[] // v1.15.20 — registration invite tokens (closed-registration flow). // `InviteCreator` = admin who minted the invite; `InviteConsumer` = // the account that registered through it (last consumer wins on a // multi-use invite). - invitesCreated InviteToken[] @relation("InviteCreator") - invitesUsed InviteToken[] @relation("InviteConsumer") + invitesCreated InviteToken[] @relation("InviteCreator") + invitesUsed InviteToken[] @relation("InviteConsumer") // v1.16.0 — per-signup invite redemption ledger (multi-use history). - inviteRedemptions InviteRedemption[] @relation("InviteRedemptions") + inviteRedemptions InviteRedemption[] @relation("InviteRedemptions") // One account per IdP identity — the (issuer, sub) pair is the durable // OIDC key (both NULL on accounts that never signed in via SSO; Postgres @@ -5111,7 +5120,11 @@ model MeasurementReminder { /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// Back-relation for the appointment arm: a PLANNED `Encounter` mints + /// exactly one ENCOUNTER-origin row and points at it. SetNull on the far + /// side, so deleting the reminder leaves the visit intact. + encounters Encounter[] @@index([userId, deletedAt]) @@index([userId, nextDueAt]) @@ -5125,10 +5138,22 @@ model MeasurementReminder { /// Provenance of a MeasurementReminder. `VORSORGE` = user-created through the /// Vorsorge surface (default); `COACH` = minted by the AI coach from a cadence -/// suggestion. Both feed the same engine / endpoint / cron. +/// suggestion; `ENCOUNTER` = minted by, and owned by, a planned `Encounter`. +/// All three feed the same engine / endpoint / cron — an appointment is a +/// one-shot row on this engine, not a second engine. +/// +/// `ENCOUNTER` rows are excluded from every Vorsorge READ surface, or the +/// checkup list would fill with appointments: the reminder list route, the +/// daily digest, the insights preventive-care block and the MCP +/// `get_preventive_care` tool each carry the exclusion, and +/// `src/__tests__/encounter-reminder-exclusion.test.ts` proves all four plus +/// the DTO mapper's refusal. The published `origin` enum therefore stays at +/// two members. Firing and satisfying deliberately do NOT exclude — that is +/// the whole point of the row. enum ReminderOrigin { VORSORGE COACH + ENCOUNTER @@map("reminder_origin") } @@ -5143,8 +5168,8 @@ enum ReminderOrigin { /// charting wiring lands in sibling work — this model only owns the shape. /// Soft-deleted. model LabResult { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// Optional grouping label, e.g. "Großes Blutbild". NULL = standalone value. panel String? /// The biomarker name, e.g. "LDL", "HbA1c", "Ferritin". @@ -5156,15 +5181,15 @@ model LabResult { /// v1.18.9 — qualitative result as free text, e.g. "negativ" / "positiv" / /// "grenzwertig" / "nicht nachweisbar". NULL for a numeric reading. The /// reference bounds + range verdict are meaningless for a qualitative row. - valueText String? @map("value_text") + valueText String? @map("value_text") unit String /// Reference-range bounds stamped onto the row at write time. Despite the /// name these are NOT the lab's printed range: since v1.18.1 every write /// path copies the linked `Biomarker`'s `lowerBound` / `upperBound` here as /// historical truth, so a later catalog edit does not silently rewrite a /// past reading. The range the LAB printed lives in `sourceReference*`. - referenceLow Float? @map("reference_low") - referenceHigh Float? @map("reference_high") + referenceLow Float? @map("reference_low") + referenceHigh Float? @map("reference_high") /// The reference range as PRINTED ON THE SOURCE REPORT for this one reading. /// @@ -5190,12 +5215,12 @@ model LabResult { /// the actual clinical RESULT ("positiv") in the clear, so encrypting the /// window that result is judged against would protect strictly less while /// making the column unreadable to the SQL the read paths run. - sourceReferenceLow Float? @map("source_reference_low") - sourceReferenceHigh Float? @map("source_reference_high") - sourceReferenceText String? @map("source_reference_text") + sourceReferenceLow Float? @map("source_reference_low") + sourceReferenceHigh Float? @map("source_reference_high") + sourceReferenceText String? @map("source_reference_text") /// When the sample was taken (UTC instant; wall-clock anchoring is the /// reader's concern per the schema's UTC-only convention). - takenAt DateTime @map("taken_at") + takenAt DateTime @map("taken_at") /// v1.19.0 — optional link to the user-scoped Biomarker catalog. NULL on /// every existing row; a backfill job grouping by `lower(analyte)` will /// populate it later (this migration only adds the column + relation). Once @@ -5203,19 +5228,20 @@ model LabResult { /// the Biomarker, ending the per-row free-text divergence ("LDL" vs "ldl" /// vs "LDL-C", `mg/dL` vs `mg/dl`). The legacy `analyte` / `unit` / /// `reference*` columns stay as historical truth for one release. - biomarkerId String? @map("biomarker_id") + biomarkerId String? @map("biomarker_id") /// Provenance: "MANUAL" today; import paths set their own source string. - source String @default("MANUAL") + source String @default("MANUAL") /// Optional free-text note, AES-256-GCM encrypted at rest via /// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per convention). - noteEncrypted Bytes? @map("note_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull) + encounterLinks EncounterLabLink[] @@index([userId, analyte, takenAt]) @@index([userId, deletedAt]) @@ -5287,27 +5313,27 @@ model Biomarker { /// search). Unique per user + name so there is never a second definition. /// Soft-deleted. model CustomMetric { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// Free-text display name, e.g. "Morning grip strength". - name String + name String /// Free-text unit label, e.g. "kg", "reps", "score". - unit String + unit String /// Optional target window. Both independently optional; when both present /// the low bound must not exceed the high bound (charted as a reference band). - targetLow Float? @map("target_low") - targetHigh Float? @map("target_high") + targetLow Float? @map("target_low") + targetHigh Float? @map("target_high") /// Optional fixed decimal places for display (null = sensible default). - decimals Int? + decimals Int? /// Optional free-text description ("what this measures"). Plaintext. - description String? + description String? /// Explicit opt-in to expose this metric as a bounded behaviour channel in /// correlation discovery. False keeps the metric isolated. correlationEnabled Boolean @default(false) @map("correlation_enabled") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) entries CustomMetricEntry[] @@ -5413,12 +5439,12 @@ enum HealthScoreBand { /// reading the live score; this table is read only where a past day is the /// question. model HealthScoreRecord { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// The LOCAL calendar day (`YYYY-MM-DD`) the score was computed for, cut in /// `timezone`. Lexicographically ordered, so a trailing-window read is a /// range scan. - dayKey String @map("day_key") @db.VarChar(10) + dayKey String @map("day_key") @db.VarChar(10) /// The IANA zone the day was cut in. An account that moves zones leaves /// behind days cut on the old clock; carrying the zone is what lets a /// reader see that rather than silently compare days of different lengths. @@ -5426,27 +5452,27 @@ model HealthScoreRecord { /// The composite 0-100 as shown. Integer, matching the composite's own /// rounding. - composite Int + composite Int /// The band as shown. Stored rather than re-derived from `composite`, /// because the worst pillar can pull the band below the one the mean alone /// would give, so the number does not determine the band. - band HealthScoreBand + band HealthScoreBand /// Algorithm identity the row was computed under. - scoreVersion Int @map("score_version") + scoreVersion Int @map("score_version") /// Registry-ordered pillar ids that produced the composite. Part of the /// number's identity: the same value under a different composition is a /// different measurement. - composition String[] + composition String[] /// Each counted pillar's own 0-100 score, keyed by pillar id. The composite /// breaks across a composition change; these stay comparable through one, /// which is the whole reason they are stored beside it rather than folded /// away. - pillarScores Json @map("pillar_scores") + pillarScores Json @map("pillar_scores") /// SHA-256 over exactly what the stored composite and band depend on: the /// score version, the composition, and every counted pillar's score with /// its scoring-mode identity. Two rows with the same fingerprint were /// computed from the same standing; two rows that differ, were not. - inputFingerprint String @db.VarChar(64) @map("input_fingerprint") + inputFingerprint String @map("input_fingerprint") @db.VarChar(64) /// The account's health-score configuration version in force when the row /// was written, and when that configuration last changed. Supplied on every @@ -5459,7 +5485,7 @@ model HealthScoreRecord { /// join the composite line across it, and keeps the per-pillar rows running /// through it. Nullable only because the columns predate the writer by one /// release; nothing written since supplies null. - configVersion Int? @map("config_version") + configVersion Int? @map("config_version") configChangedAt DateTime? @map("config_changed_at") @db.Timestamptz(3) /// When the score was computed. Distinct from `dayKey`: the day says which @@ -5831,30 +5857,30 @@ model CoachReminder { // must never land in the table as plaintext. A cleared profile keeps the // row with a NULL payload so `updatedAt` documents the deletion instant. model UserHealthProfile { - id String @id @default(cuid()) - userId String @unique @map("user_id") + id String @id @default(cuid()) + userId String @unique @map("user_id") // Free text, hard-capped at 4 000 chars by the route's Zod schema // BEFORE encryption. NULL = cleared / never written. - aboutMeEncrypted Bytes? @map("about_me_encrypted") + aboutMeEncrypted Bytes? @map("about_me_encrypted") // v1.16.0 — structured self-context companions to the free text. // Short answers (each capped at 500 chars BEFORE encryption). // Age/gender deliberately do NOT live here — they come from the User // profile and are merged into the prompt block at assembly time so // the data is never duplicated. - conditionsEncrypted Bytes? @map("conditions_encrypted") - allergiesEncrypted Bytes? @map("allergies_encrypted") - coachFocusEncrypted Bytes? @map("coach_focus_encrypted") + conditionsEncrypted Bytes? @map("conditions_encrypted") + allergiesEncrypted Bytes? @map("allergies_encrypted") + coachFocusEncrypted Bytes? @map("coach_focus_encrypted") // v1.16.0 — up to 3 clarifying questions the server derives after a // save (AI-generated when a provider + budget allow, deterministic // hints otherwise). Encrypted JSON string[]; the Coach composer // renders them as tappable suggestion chips. NULL = none pending. - pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted") + pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted") // Closed inclusion list for self-context sent to Coach and briefing prompts. // The static default preserves every pre-existing prompt section for legacy // rows without automatically opting future enum members in. - aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -5901,18 +5927,18 @@ enum HealthProfileFactProvenance { /// user and fact kind. Corrections close the current row and mint a replacement /// rather than overwriting encrypted history. model HealthProfileFactRevision { - id String @id @default(cuid()) - userId String @map("user_id") - kind HealthProfileFactKind - valueEncrypted Bytes @map("value_encrypted") - validFrom DateTime @map("valid_from") @db.Timestamptz(6) - validUntil DateTime? @map("valid_until") @db.Timestamptz(6) - provenance HealthProfileFactProvenance @default(USER_REPORTED) - supersededByRevisionId String? @map("superseded_by_revision_id") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + id String @id @default(cuid()) + userId String @map("user_id") + kind HealthProfileFactKind + valueEncrypted Bytes @map("value_encrypted") + validFrom DateTime @map("valid_from") @db.Timestamptz(6) + validUntil DateTime? @map("valid_until") @db.Timestamptz(6) + provenance HealthProfileFactProvenance @default(USER_REPORTED) + supersededByRevisionId String? @map("superseded_by_revision_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - supersededBy HealthProfileFactRevision? @relation("HealthProfileFactSupersession", fields: [supersededByRevisionId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + supersededBy HealthProfileFactRevision? @relation("HealthProfileFactSupersession", fields: [supersededByRevisionId], references: [id], onDelete: SetNull) supersedes HealthProfileFactRevision[] @relation("HealthProfileFactSupersession") @@unique([userId, kind, validFrom]) @@ -6039,12 +6065,12 @@ model NotificationEvent { // credentials, or event deduplication keys are retained here. Completion is // observational only; authorization and retry decisions never read it. model NotificationEgressAuthorization { - id String @id @default(cuid()) - recordUserId String @map("record_user_id") - recipientUserId String @map("recipient_user_id") + id String @id @default(cuid()) + recordUserId String @map("record_user_id") + recipientUserId String @map("recipient_user_id") channel String - eventType String @map("event_type") - authorizedAt DateTime @default(now()) @map("authorized_at") + eventType String @map("event_type") + authorizedAt DateTime @default(now()) @map("authorized_at") completedAt DateTime? @map("completed_at") outcome String? @@ -6946,12 +6972,13 @@ model IllnessEpisode { /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Parent condition for a flare/recurrence (self-relation). - parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) - flares IllnessEpisode[] @relation("IllnessFlares") - dayLogs IllnessDayLog[] - documentLinks DocumentConditionLink[] + parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) + flares IllnessEpisode[] @relation("IllnessFlares") + dayLogs IllnessDayLog[] + documentLinks DocumentConditionLink[] + encounterLinks EncounterConditionLink[] @@index([userId, deletedAt]) @@index([userId, onsetAt]) @@ -7409,7 +7436,8 @@ model InboundDocument { /// Soft-delete tombstone. NULL = live; non-NULL = discarded. deletedAt DateTime? @map("deleted_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + encounterLinks EncounterDocumentLink[] @@index([userId, createdAt]) // The list always filters `deletedAt: null` and sorts by `documentDate`, so @@ -7636,3 +7664,215 @@ model NutrientIntakeDay { @@index([userId, nutrient, day(sort: Desc)]) @@map("nutrient_intake_days") } + +// ─── Visits: encounters and practitioners (v1.38.0) ──────────────── +// +// The whole of the previous "which doctor" model was one free-text +// column (`MeasurementReminder.location`). A person already typed the +// practice name, once per reminder, with no way to reach anything else +// that happened at that practice. These five models turn that string +// into a record: a visit that happened or is booked, the address book +// it points at, and three links to the things the visit produced. + +/// A single contact with the healthcare system: one visit that happened, +/// or one that is booked. +/// +/// Slim by decision. The diagnosis lives on the linked `IllnessEpisode`, +/// the address on the linked `Practitioner`, and everything a person +/// would otherwise retype is a link rather than a column. A free-text +/// `diagnosis` beside the first-class condition entity would be a +/// second, weaker copy of the same fact and would diverge within a +/// release. +/// +/// No module gate, deliberately, and the absence is a decision rather +/// than an oversight. The 18 `ModuleKey` entries have three shapes: an +/// opt-out data vertical, an egress surface, and one output format. A +/// visit is none of them — it is not a tracked signal, it egresses +/// nothing, and it is not an output. `/checkups` sets the precedent and +/// states the reasoning (`src/components/layout/nav-model.ts`): gating +/// the entry would orphan reminders the user can still create. One gate +/// over an encounter would also contradict its own members, since an +/// encounter links to documents, labs and conditions — three separately +/// toggleable modules with no truthful shared off-state. The gate is +/// relocated to the link pickers, which follow the modules that own the +/// things they point at. +model Encounter { + id String @id @default(cuid()) + userId String @map("user_id") + /// The visit instant (UTC; wall-clock anchoring is the reader's + /// concern, per the schema's UTC-only convention). Future instants are + /// legal — that is the whole appointment half. + occurredAt DateTime @map("occurred_at") + /// Where in its lifecycle. PLANNED is a future appointment; DONE is a + /// visit that happened; CANCELLED and NO_SHOW are terminal and keep + /// the row so the history stays honest. + /// + /// The FHIR mapping happens at the export boundary, not here: + /// PLANNED → `Appointment.booked`, DONE → `Encounter.finished`, + /// CANCELLED → `cancelled`, NO_SHOW → `Appointment.noshow`. FHIR + /// splits the future from the past across two resource types, which is + /// a serialisation concern and not a storage one. No FHIR resource + /// type is added for visits in this release and + /// `FHIR_REST_RESOURCE_TYPES` (`src/lib/fhir/rest.ts`) is deliberately + /// untouched — a capability statement that names a type with no route + /// module lies. + status EncounterStatus @default(DONE) + kind EncounterKind @default(OTHER) + practitionerId String? @map("practitioner_id") + /// The person's own framing of why they went or are going. Encrypted + /// at rest (AES-256-GCM, the `*Encrypted` Bytes convention). + reasonEncrypted Bytes? @map("reason_encrypted") + /// What came out of it — plan, follow-up, what to watch. Same codec. + /// One field rather than a treatment/follow-up pair whose boundary + /// nobody can state: two columns would be two chances to put the text + /// in the wrong one, and the actionable half of a follow-up is not + /// prose at all, it is the next appointment. + outcomeEncrypted Bytes? @map("outcome_encrypted") + /// Optional link to the reminder this visit closes. Set when the user + /// files the visit from a due checkup, or minted here when the visit + /// is PLANNED. Satisfying the reminder is the side effect, not this + /// column. + reminderId String? @map("reminder_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull) + reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull) + documentLinks EncounterDocumentLink[] + labLinks EncounterLabLink[] + conditionLinks EncounterConditionLink[] + + @@index([userId, deletedAt, occurredAt]) + @@index([userId, status, occurredAt]) + @@index([practitionerId]) + @@map("encounters") +} + +/// Where a visit sits in its lifecycle. Terminal states keep the row. +enum EncounterStatus { + PLANNED + DONE + CANCELLED + NO_SHOW + + @@map("encounter_status") +} + +/// What kind of contact it was. Defaulted to OTHER so it is never a +/// required pick, and small enough to stay readable at a glance in a +/// list. Maps to FHIR `Encounter.class` at the export boundary. +enum EncounterKind { + ROUTINE + ACUTE + SPECIALIST + PREVENTIVE + EMERGENCY + HOSPITAL + THERAPY + OTHER + + @@map("encounter_kind") +} + +/// The account's own address book of doctors and practices. +/// +/// User-scoped, and the scoping is the point: a global registry with an +/// instance-wide unique practice name leaks one household member's +/// cardiologist into another's picker and makes two self-hosters on one +/// instance share a namespace. There is no uniqueness across accounts of +/// any kind here. +model Practitioner { + id String @id @default(cuid()) + userId String @map("user_id") + /// "Dr. med. …" or a practice name — whichever the person thinks of + /// first. Plaintext and bounded, so the picker can ILIKE-search and + /// ORDER BY in SQL (the `InboundDocument.title` tradeoff). + name String + /// Free-text specialty, NOT an enum: German practice signage does not + /// fit a closed list, and an enum here would force a migration per + /// specialty for no reader that needs the closed set. + specialty String? + /// Practice or clinic name when it differs from `name`. + practice String? + /// Free-text address / "wo", the `MeasurementReminder.location` shape. + location String? + phone String? + /// Free-text note (encrypted): parking, which entrance, who to ask for. + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + encounters Encounter[] + + @@index([userId, deletedAt, name]) + @@map("practitioners") +} + +/// Visit ↔ stored document. Never mandatory: a visit saves without one, +/// and a document files without a visit. +model EncounterDocumentLink { + id String @id @default(cuid()) + encounterId String @map("encounter_id") + documentId String @map("document_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade) + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([encounterId, documentId]) + @@index([documentId]) + @@index([userId]) + @@map("encounter_document_links") +} + +/// Visit ↔ lab result. +/// +/// m:n rather than a `LabResult.encounterId` scalar for two reasons: a +/// re-run panel breaks the 1:many the scalar assumes, and a scalar would +/// put a write on the hot lab-import path. `relevanceNote` is +/// deliberately omitted — a per-link annotation is the first step from a +/// link table towards a general edge store, which this venture refuses. +model EncounterLabLink { + id String @id @default(cuid()) + encounterId String @map("encounter_id") + labResultId String @map("lab_result_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade) + labResult LabResult @relation(fields: [labResultId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([encounterId, labResultId]) + @@index([labResultId]) + @@index([userId]) + @@map("encounter_lab_links") +} + +/// Visit ↔ condition episode. A visit legitimately touches two +/// conditions, exactly as a discharge letter does, which is why this is +/// a link table and not a scalar FK (the `DocumentConditionLink` shape). +model EncounterConditionLink { + id String @id @default(cuid()) + encounterId String @map("encounter_id") + episodeId String @map("episode_id") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade) + episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([encounterId, episodeId]) + @@index([episodeId]) + @@index([userId]) + @@map("encounter_condition_links") +} diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 7c4c2ddd1..89681a2f7 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -545,6 +545,23 @@ async function main() { ), ); + // ───── v1.38.0 visit free text (Bytes columns) ───── + // Why the person went, what came out of it, and the practice note. Always + // encrypted on write, so every non-null row rotates. + results.push( + await rotateBytesColumn("Encounter", "reasonEncrypted", prisma.encounter), + ); + results.push( + await rotateBytesColumn("Encounter", "outcomeEncrypted", prisma.encounter), + ); + results.push( + await rotateBytesColumn( + "Practitioner", + "noteEncrypted", + prisma.practitioner, + ), + ); + // ───── Inbound clinical document (Bytes column, codec-dispatched) ───── // The raw uploaded document. Two layouts recorded per row in // `contentCodec` ("base64v1" string codec | "binary2" binary codec), so diff --git a/src/__tests__/encounter-reminder-exclusion.test.ts b/src/__tests__/encounter-reminder-exclusion.test.ts new file mode 100644 index 000000000..884523d2e --- /dev/null +++ b/src/__tests__/encounter-reminder-exclusion.test.ts @@ -0,0 +1,249 @@ +/** + * A booked visit's reminder appears on no preventive-care surface. + * + * A planned encounter mints one `MeasurementReminder` with `origin: + * ENCOUNTER`, because an appointment is a reminder with a date and a second + * engine would be a second cron, a second queue and a second set of + * preferences. The cost of that decision is this: every READ that means "the + * checkups this person keeps" now has to say so, or the checkup list fills up + * with appointments. + * + * The research that proposed the feature called this "a one-line `where`". It + * is four, and the fourth is in a different subsystem, which is exactly why it + * is worth a guard rather than a comment. + * + * Two halves, deliberately: + * + * 1. **By source text** — each of the four read sites contains an origin + * exclusion. Whitespace-tolerant, and it ASSERTS A NON-ZERO MATCH COUNT. + * A matcher that matches nothing is green for the wrong reason; the + * Bearer-scope guard in this directory stayed blind for months because + * its regex demanded a literal a two-line call never produced, and it + * passed by finding nothing rather than by finding everything. + * 2. **By behaviour** — the DTO mapper refuses an `ENCOUNTER` row. Source + * text cannot tell a filter that runs from one that is written, and the + * refusal is what turns a lost filter into a loud failure instead of an + * appointment relabelled as a checkup. + * + * What this file does NOT prove: that the four queries run. That is what the + * mapper's throw is for, and what an integration test against real rows would + * add. Written down so the limit is visible rather than assumed away. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import type { MeasurementReminder } from "@/generated/prisma/client"; + +const REPO_ROOT = resolve(__dirname, "../.."); + +function read(relativePath: string): string { + return readFileSync(resolve(REPO_ROOT, relativePath), "utf8"); +} + +/** + * Every read that answers "what preventive care is due", and what each one + * feeds. Named rather than discovered: a discovery sweep would have to guess + * which reminder reads are Vorsorge surfaces and which are the engine itself, + * and the engine's two reads must NOT carry the exclusion — firing the + * appointment and satisfying it is the whole point of the row. + */ +const VORSORGE_READ_SITES: ReadonlyArray<{ file: string; surface: string }> = [ + { + file: "src/app/api/measurement-reminders/route.ts", + surface: "the reminder list the checkups page renders", + }, + { + file: "src/lib/daily/load-digest.ts", + surface: "the daily digest's due list", + }, + { + file: "src/lib/insights/feature-blocks.ts", + surface: "the insights preventive-care block", + }, + { + file: "src/lib/mcp/tools.ts", + surface: "the MCP get_preventive_care tool", + }, +]; + +/** + * `origin: { not: "ENCOUNTER" }`, tolerant of any formatting Prettier might + * choose — line breaks between the key and the object, single or double + * quotes, a trailing comma. + */ +const EXCLUSION = /origin\s*:\s*\{\s*not\s*:\s*["']ENCOUNTER["']\s*,?\s*\}/g; + +function reminderRow( + origin: MeasurementReminder["origin"], +): MeasurementReminder { + const at = new Date("2026-07-01T09:00:00.000Z"); + return { + id: "reminder-1", + userId: "user-1", + label: "Dr. Example", + measurementType: null, + intervalDays: null, + rrule: null, + anchorDate: at, + endsOn: null, + origin, + notifyHour: 9, + location: null, + nextDueAt: at, + lastSatisfiedAt: null, + enabled: true, + createdAt: at, + updatedAt: at, + deletedAt: null, + }; +} + +/** + * The BY-ID family: the published measurement-reminder surface. + * + * The list reads above answer "what preventive care is due". These answer + * "this reminder", by id, and they were the larger hole. An appointment row is + * not addressable here at all — it belongs to a visit, is managed through the + * visit routes, and is classified under a different sharing domain — so each + * of these treats one as not found rather than filtering it out of a list. + * + * What reaching them actually cost, before the fix: + * + * - `complete` and `satisfy` committed the mutation and THEN threw in the + * DTO mapper, answering 500 after the write had already landed. `complete` + * is the documented flow behind the iOS notification's Done button, so + * every appointment push a person acknowledged ended in an error. + * - `PATCH` could give a one-shot a cadence, turning a spent appointment + * into an eternal re-nag that appears in no list, or flip `enabled` back + * on for a cancelled one. + * - `DELETE` hard-deletes, so a delegate holding only the measurements + * domain could destroy a reminder belonging to a visit in the profile + * domain — a cross-domain reach the sharing model refuses everywhere else. + * + * The count per file is part of the expectation: three of these live in one + * module, and a matcher that only asked "does this file mention the exclusion" + * would stay green after two of the three lost it. + */ +const BY_ID_SITES: ReadonlyArray<{ + file: string; + sites: number; + surface: string; +}> = [ + { + file: "src/app/api/measurement-reminders/[id]/route.ts", + sites: 3, + surface: "the by-id read, edit and delete verbs", + }, + { + file: "src/app/api/measurement-reminders/[id]/complete/route.ts", + sites: 1, + surface: + "the explicit complete action behind the notification's Done button", + }, + { + file: "src/app/api/measurement-reminders/[id]/satisfy/route.ts", + sites: 1, + surface: "the manual satisfy action", + }, + { + file: "src/lib/telegram-webhook-handlers.ts", + sites: 2, + surface: "the chat buttons that mark a reminder done or postpone it", + }, +]; + +describe("an appointment reminder is not addressable by id", () => { + it("names a by-id set that is not empty (no vacuous pass)", () => { + expect(BY_ID_SITES.length).toBe(4); + expect(BY_ID_SITES.reduce((total, entry) => total + entry.sites, 0)).toBe( + 7, + ); + }); + + for (const { file, sites, surface } of BY_ID_SITES) { + it(`refuses an ENCOUNTER row on ${surface}`, () => { + const matches = read(file).match(EXCLUSION) ?? []; + expect( + matches.length, + `${file} owns ${sites} by-id lookup(s) for ${surface}; ` + + `${matches.length} of them exclude ENCOUNTER-origin rows. An ` + + "appointment reminder reaching this family commits a write it should " + + "have refused, or 500s after committing one.", + ).toBe(sites); + }); + } +}); + +describe("appointment reminders are excluded from every Vorsorge read", () => { + it("names a read set that is not empty (no vacuous pass)", () => { + expect(VORSORGE_READ_SITES.length).toBe(4); + }); + + for (const { file, surface } of VORSORGE_READ_SITES) { + it(`excludes ENCOUNTER rows from ${surface}`, () => { + const source = read(file); + const matches = source.match(EXCLUSION) ?? []; + expect( + matches.length, + `${file} feeds ${surface} and no longer excludes ENCOUNTER-origin ` + + "rows, so a booked appointment will surface there as a checkup", + ).toBeGreaterThan(0); + }); + } + + it("leaves the engine's own reads alone", () => { + // The cron fires these rows and the satisfy primitive resolves them. An + // exclusion in either would mean a booked appointment that never nudges, + // which is the opposite failure and just as silent. + for (const file of [ + "src/lib/jobs/measurement-reminder.ts", + "src/lib/measurement-reminders/satisfy.ts", + ]) { + const matches = read(file).match(EXCLUSION) ?? []; + expect( + matches.length, + `${file} is the engine, not a Vorsorge surface — excluding ENCOUNTER ` + + "rows here would stop appointments firing at all", + ).toBe(0); + } + }); + + it("the Coach dedup read needs nothing, because it is already narrow", () => { + // It filters to `origin: "COACH"`, so an ENCOUNTER row cannot reach it. + // Asserted rather than assumed: if that narrowing ever widened, this file + // is where the consequence should surface. + expect(read("src/lib/ai/coach/suggest-gate.ts")).toMatch( + /origin\s*:\s*["']COACH["']/, + ); + }); +}); + +describe("the reminder DTO refuses an appointment row", () => { + it("maps a VORSORGE row normally", () => { + expect(toMeasurementReminderDto(reminderRow("VORSORGE")).origin).toBe( + "VORSORGE", + ); + }); + + it("maps a COACH row normally", () => { + expect(toMeasurementReminderDto(reminderRow("COACH")).origin).toBe("COACH"); + }); + + it("throws on an ENCOUNTER row rather than relabelling it", () => { + // The invariant: every query feeding this mapper excludes them, so one + // arriving means a read site lost its filter. Failing loudly is the only + // honest answer — the alternative is an appointment shown as a checkup. + expect(() => toMeasurementReminderDto(reminderRow("ENCOUNTER"))).toThrow( + /ENCOUNTER-origin reminder/, + ); + }); + + it("names the guard file in the message, so the next reader finds the proof", () => { + expect(() => toMeasurementReminderDto(reminderRow("ENCOUNTER"))).toThrow( + /encounter-reminder-exclusion/, + ); + }); +}); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 27a40aa5a..4af6c2557 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -927,6 +927,22 @@ const DELEGABLE_ROUTES: Record = { domain: "measurements", why: "One nutrient's daily series over the record, scoped by the resolved user id.", }, + "app/api/encounters/route.ts": { + domain: "profile", + why: "The record's visits — what happened at a practice and what is booked. Record content in the same sense as the allergies and family history already under this domain: it names no credential, no integration and no notification channel. Carries the honest cost of that classification, which is that a delegate granted `profile` for allergies also sees the visit list; the consent copy names visits for exactly this reason. The create arm is a delegable write.", + }, + "app/api/encounters/[id]/route.ts": { + domain: "profile", + why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved 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.", + }, + "app/api/practitioners/[id]/route.ts": { + domain: "profile", + why: "One practitioner of the record, fetch-then-guard against the resolved user.", + }, "app/api/illness/episodes/route.ts": { domain: "illness", why: "The record's illness episodes. Module-gated on the record on both arms, so a delegate gets the surface only where the owner switched it on; the create arm is a delegable write.", @@ -1112,6 +1128,14 @@ const DELEGABLE_ROUTES: Record = { domain: "illness", why: "Reopening a deleted episode with its day logs intact. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", }, + "app/api/encounters/[id]/restore/route.ts": { + domain: "profile", + why: "Reopening a deleted visit with its links intact. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/practitioners/[id]/restore/route.ts": { + domain: "profile", + why: "Reopening a deleted address-book entry. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, "app/api/mood-entries/bulk-delete/route.ts": { domain: "mind", why: "Tombstoning a selection. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", @@ -1351,6 +1375,10 @@ const DELEGABLE_WRITE_ROUTES: Record = { "Adding an analyte to the record's catalogue. A name the record already tracks is the ordinary 409 from the same `(userId, name)` uniqueness the owner would hit themselves.", "app/api/illness/episodes/route.ts": "Opening an illness episode. The module gate runs against the RECORD before the write, so a delegate cannot create an episode inside a record whose owner switched the module off.", + "app/api/encounters/route.ts": + "Filing a visit, or booking one. Every id the body may carry — the practitioner, the checkup it closes, the three link arrays — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's document to another's visit. Booking mints the appointment reminder under the RECORD, which is correct: it is the owner's appointment and the owner's channels that should be nudged about it.", + "app/api/practitioners/route.ts": + "Adding a doctor or practice to the record's address book. Nothing is unique across accounts here by design, so a delegate adding a practice the owner already has produces a second row rather than a collision with somebody else's namespace.", "app/api/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": @@ -1457,6 +1485,26 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: ["C4"], why: "Editing and deleting an episode; the delete soft-deletes. C4 on the edit.", }, + "app/api/encounters/[id]/route.ts": { + domain: "profile", + conditions: ["C4"], + why: "Editing and deleting a visit; the delete soft-deletes and retires the visit's own appointment reminder with it, never a checkup the visit closed. C4 on the edit: the date, the status, the kind and the practitioner are filed before and after, and the encrypted reason and outcome deliberately are not.", + }, + "app/api/encounters/[id]/restore/route.ts": { + domain: "profile", + conditions: [], + why: "Reopening a deleted visit with its links intact.", + }, + "app/api/practitioners/[id]/route.ts": { + domain: "profile", + conditions: ["C4"], + why: "Correcting and removing one address-book entry; the delete soft-deletes and leaves every visit that named it, because the reference nulls rather than cascades. C4 on the edit, over the plaintext fields only.", + }, + "app/api/practitioners/[id]/restore/route.ts": { + domain: "profile", + conditions: [], + why: "Reopening a deleted address-book entry. It does not re-attach the visits whose reference was nulled, because which visits meant this practice is not recoverable.", + }, "app/api/illness/episodes/[id]/resolve/route.ts": { domain: "illness", conditions: [], @@ -1814,7 +1862,7 @@ const ACTOR_ROUTES: Record = { * The two initial Cycle reads follow as separate, scoped admissions: 198 → 200. * The mood day's linked figures, read-only, add one: 200 → 201. */ -const FROZEN_ENTRY_COUNT = 201; +const FROZEN_ENTRY_COUNT = 213; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2575,7 +2623,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(62); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(70); 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 6575f2228..f18f282b5 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(62); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(70); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 88bc30426..a1b1d2209 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -282,6 +282,14 @@ function sourceClient() { }, ]), }, + // The visit tables. Empty here: this file asserts the sections it seeds, + // and an account with no visits is the ordinary case the builder must + // still answer for. + practitioner: { findMany: vi.fn().mockResolvedValue([]) }, + encounter: { findMany: vi.fn().mockResolvedValue([]) }, + encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, customMetric: { findMany: vi.fn().mockResolvedValue([ { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 431bb15a2..149213e80 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -48,6 +48,7 @@ import { import { restoreProfileData } from "@/lib/export/profile-backup"; import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; +import { restoreVisitsData } from "@/lib/export/visits-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -88,6 +89,9 @@ interface RestoreResponse { correlationPatterns: number; intradayProfiles: number; healthScoreRecords: number; + practitioners: number; + encounters: number; + encounterLinks: number; }; } @@ -1315,6 +1319,19 @@ const handler = apiHandler( }); } + // Visits, the address book and the three link tables. Last on + // purpose: a link is written only when both of its ends exist, and + // the documents above are the final far side to land. Both ends of + // this section live in `src/lib/export/visits-backup.ts`, so a grep + // of THIS file alone will not find them — the same delegation as the + // cycle, profile, intraday and score restores above. + const visitsCleared = await restoreVisitsData( + tx, + ownerId, + payload, + skips, + ); + const cleared = { measurements: measurements.count, medications: meds.count, @@ -1341,6 +1358,9 @@ const handler = apiHandler( correlationPatterns: profileCleared.correlationPatterns, intradayProfiles: intradayCleared.intradayProfiles, healthScoreRecords: healthScoreCleared.healthScoreRecords, + practitioners: visitsCleared.practitioners, + encounters: visitsCleared.encounters, + encounterLinks: visitsCleared.encounterLinks, }; return { cleared, skipped: summarizeRestoreSkips(skips) }; }, diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts index ecd92e89e..6adcd7b5b 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({ prisma: { inboundDocument: { findFirst: vi.fn(), + findMany: vi.fn(), update: vi.fn(), findFirstOrThrow: vi.fn(), }, @@ -131,6 +132,11 @@ beforeEach(() => { vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue( [] as never, ); + // The link service re-narrows both ends before it writes: the document it is + // filing, and the episodes it is filing against. + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + { id: "doc-1" }, + ] as never); vi.mocked(prisma.documentConditionLink.deleteMany).mockResolvedValue({ count: 0, } as never); diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts index 078e5ec00..4b4f21999 100644 --- a/src/app/api/documents/inbound/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -14,16 +14,27 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const { txQueryRaw, txCreate, txCreateMany } = vi.hoisted(() => ({ +const { + txQueryRaw, + txCreate, + txCreateMany, + txDocumentFindMany, + txEpisodeFindMany, +} = vi.hoisted(() => ({ txQueryRaw: vi.fn(), txCreate: vi.fn(), txCreateMany: vi.fn(), + // The link service re-narrows BOTH ends inside the transaction before it + // writes, so the transaction double has to answer for them. + txDocumentFindMany: vi.fn(), + txEpisodeFindMany: vi.fn(), })); vi.mock("@/lib/db", () => { const tx = { $queryRaw: txQueryRaw, - inboundDocument: { create: txCreate }, + inboundDocument: { create: txCreate, findMany: txDocumentFindMany }, + illnessEpisode: { findMany: txEpisodeFindMany }, documentConditionLink: { createMany: txCreateMany }, }; return { @@ -186,6 +197,8 @@ beforeEach(() => { txQueryRaw.mockResolvedValue([{ used: BigInt(0) }]); txCreate.mockResolvedValue(docRow() as never); txCreateMany.mockResolvedValue({ count: 0 } as never); + txDocumentFindMany.mockResolvedValue([{ id: "doc-1" }] as never); + txEpisodeFindMany.mockResolvedValue([] as never); }); describe("POST /api/documents/inbound (vault upload)", () => { @@ -294,10 +307,14 @@ describe("POST /api/documents/inbound (vault upload)", () => { vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([ { id: "ep-1" }, ] as never); + txEpisodeFindMany.mockResolvedValue([{ id: "ep-1" }] as never); let res = await post(mkUpload({ episodeIds: ["ep-1"] })); expect(res.status).toBe(201); expect(txCreateMany).toHaveBeenCalledWith({ data: [{ documentId: "doc-1", episodeId: "ep-1", userId: "user-1" }], + // The link service is idempotent by construction, so a retried upload + // that reaches the same pair succeeds instead of hitting the unique index. + skipDuplicates: true, }); // Foreign/unknown episode id → 404-shaped refusal, nothing persisted. @@ -433,12 +450,13 @@ describe("GET /api/documents/inbound (list)", () => { { documentId: "d2", status: "APPROVED", _count: { _all: 1 } }, { documentId: "d2", status: "REJECTED", _count: { _all: 3 } }, ] as never); + // The link read resolves labels in a second query rather than through a + // relation join, so both halves are mocked. vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue([ - { - documentId: "d1", - episodeId: "ep-1", - episode: { label: "Knie" }, - }, + { documentId: "d1", episodeId: "ep-1" }, + ] as never); + vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([ + { id: "ep-1", label: "Knie", onsetAt: new Date("2026-06-01T00:00:00Z") }, ] as never); const res = await get( diff --git a/src/app/api/documents/inbound/bulk/route.ts b/src/app/api/documents/inbound/bulk/route.ts index 0a9fe10b9..35c141934 100644 --- a/src/app/api/documents/inbound/bulk/route.ts +++ b/src/app/api/documents/inbound/bulk/route.ts @@ -20,6 +20,7 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; +import { linkTargets, unlinkTargets } from "@/lib/links"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { isP2002 } from "@/lib/prisma-errors"; @@ -116,15 +117,22 @@ export const POST = apiHandler(async (request: NextRequest) => { results.push({ id, ok: false, error: "notFound" }); continue; } - await prisma.documentConditionLink.createMany({ - data: [{ documentId: id, episodeId: episodeId!, userId: user.id }], - skipDuplicates: true, + await linkTargets(prisma, { + userId: user.id, + sourceKind: "document", + sourceId: id, + targetKind: "conditionEpisode", + targetIds: [episodeId!], }); break; } case "unlinkEpisode": { - await prisma.documentConditionLink.deleteMany({ - where: { documentId: id, episodeId: episodeId!, userId: user.id }, + await unlinkTargets(prisma, { + userId: user.id, + sourceKind: "document", + sourceId: id, + targetKind: "conditionEpisode", + targetIds: [episodeId!], }); break; } diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 864c20229..1a743ad68 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -56,6 +56,7 @@ import { } from "@/lib/documents/upload-policy"; import { withIdempotency } from "@/lib/idempotency"; import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload"; +import { linkTargets } from "@/lib/links"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { isP2002 } from "@/lib/prisma-errors"; @@ -337,12 +338,12 @@ async function processUpload( }); if (episodeIds.length > 0) { - await tx.documentConditionLink.createMany({ - data: episodeIds.map((episodeId) => ({ - documentId: created.id, - episodeId, - userId: user.id, - })), + await linkTargets(tx, { + userId: user.id, + sourceKind: "document", + sourceId: created.id, + targetKind: "conditionEpisode", + targetIds: episodeIds, }); } return created; diff --git a/src/app/api/documents/inbound/usage/route.ts b/src/app/api/documents/inbound/usage/route.ts index 0b4a57c66..5aee8c7ba 100644 --- a/src/app/api/documents/inbound/usage/route.ts +++ b/src/app/api/documents/inbound/usage/route.ts @@ -15,6 +15,7 @@ import { resolveDocumentLimits, } from "@/lib/documents/upload-policy"; import { resolveOcrCapability } from "@/lib/labs/ocr-capability"; +import { listDistinctTargets } from "@/lib/links"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import type { DocumentUsageDto } from "@/lib/validations/inbound-documents"; @@ -50,11 +51,10 @@ export const GET = apiHandler(async () => { // bar's condition chips. Sourced here (not from the loaded corpus) so // a chip exists even when its documents sit pages deep in the // timeline; one indexed grouped query, no blobs. - prisma.documentConditionLink.findMany({ - where: { userId: user.id, document: { deletedAt: null } }, - select: { episodeId: true, episode: { select: { label: true } } }, - distinct: ["episodeId"], - orderBy: { episodeId: "asc" }, + listDistinctTargets(prisma, { + userId: user.id, + sourceKind: "document", + targetKind: "conditionEpisode", }), // Whether an AI action can run for this caller (assist + indexing share // the same provider precondition). Honest availability so the UI never @@ -85,8 +85,8 @@ export const GET = apiHandler(async () => { maxFileBytes: limits.maxFileBytes, acceptedExtensions: [...DOCUMENT_ACCEPTED_EXTENSIONS], linkedEpisodes: linkRows.map((row) => ({ - episodeId: row.episodeId, - name: row.episode.label, + episodeId: row.id, + name: row.label, })), assistAvailable: capability.available, contentIndex: { diff --git a/src/app/api/encounters/[id]/links/route.ts b/src/app/api/encounters/[id]/links/route.ts new file mode 100644 index 000000000..076ec4547 --- /dev/null +++ b/src/app/api/encounters/[id]/links/route.ts @@ -0,0 +1,100 @@ +/** + * `POST /api/encounters/{id}/links` — file things against a visit. + * `DELETE /api/encounters/{id}/links` — unfile them. + * + * Deliberately bare `requireAuth()` rather than a delegable declaration, and + * the reason is consistency rather than oversight: document link and unlink are + * not delegable today — only the vault reads are — and an encounter link is the + * same verb one layer up. Making one delegable and not the other would mean two + * answers to the same question. The pair is revisited as one decision when the + * polymorphic-linking work lands. + * + * Both verbs are idempotent, both are capped, and neither ever blocks a save: + * an id that names nothing this account owns comes back in `unknown` rather + * than failing the request, because a visit must never be un-saveable because + * one document it pointed at has since been deleted. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + apiSuccess, + apiError, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { encounterLinkSchema } from "@/lib/validations/encounters"; +import { linkTargets, unlinkTargets } from "@/lib/links"; +import { + actingDomainVisibility, + loadEncounterLinks, +} from "@/lib/encounters/service"; + +type RouteParams = { params: Promise<{ id: string }> }; + +async function handle( + request: NextRequest, + { params }: RouteParams, + verb: "link" | "unlink", +): Promise { + const { user } = await requireAuth(); + + const { id } = await params; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = encounterLinkSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "encounter.link.invalid", + }); + } + + const apply = verb === "link" ? linkTargets : unlinkTargets; + const result = await apply(prisma, { + userId: user.id, + sourceKind: "encounter", + sourceId: id, + targetKind: parsed.data.targetKind, + targetIds: parsed.data.targetIds, + }); + + if (result.unknownSource) { + return apiError("Visit not found", 404); + } + + annotate({ + action: { + name: `encounter.link.${verb}`, + entity_type: "encounter", + entity_id: id, + }, + meta: { + target_kind: parsed.data.targetKind, + changed: result.changed, + unknown: result.unknownTargetIds.length, + }, + }); + + // Bare `requireAuth()` resolves the caller's OWN record — this route is + // not delegable — so there is no narrowed grant to honour and every + // domain is the caller's own. + const visible = await actingDomainVisibility(prisma, null); + const links = await loadEncounterLinks(prisma, user.id, id, visible); + return apiSuccess({ links, unknown: result.unknownTargetIds }); +} + +export const POST = apiHandler( + async (request: NextRequest, context: RouteParams) => + handle(request, context, "link"), +); + +export const DELETE = apiHandler( + async (request: NextRequest, context: RouteParams) => + handle(request, context, "unlink"), +); diff --git a/src/app/api/encounters/[id]/restore/route.ts b/src/app/api/encounters/[id]/restore/route.ts new file mode 100644 index 000000000..5da6b3242 --- /dev/null +++ b/src/app/api/encounters/[id]/restore/route.ts @@ -0,0 +1,105 @@ +/** + * `POST /api/encounters/{id}/restore` — clear a visit's tombstone. + * + * MANAGE rather than WRITE: undoing a deletion is a management act when the + * manager is the one who holds delete. + * + * The link rows survived the soft delete — they cascade only on a hard one — + * so a restored visit comes back with its documents, labs and conditions still + * filed against it. Its appointment reminder comes back too, but only if the + * appointment is still in the future: restoring a visit whose date has passed + * must not produce a nudge for something that already did or did not happen. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { encounterKindLabel } from "@/lib/encounters/kind-label"; +import { toEncounterDTO } from "@/lib/encounters/dto"; +import { + ENCOUNTER_INCLUDE, + actingDomainVisibility, + loadEncounterLinks, + resolveOwnerNotificationContext, + shouldHaveReminder, +} from "@/lib/encounters/service"; +import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user, grantId } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.encounter.findUnique({ + where: { id }, + include: { practitioner: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Visit not found", 404); + } + + if (existing.deletedAt !== null) { + const { timezone, locale } = await resolveOwnerNotificationContext( + user.id, + ); + const now = new Date(); + await prisma.$transaction(async (tx) => { + await tx.encounter.update({ + where: { id }, + data: { deletedAt: null }, + }); + if ( + existing.reminderId && + shouldHaveReminder(existing.status, existing.occurredAt, now) + ) { + const reminder = await tx.measurementReminder.findUnique({ + where: { id: existing.reminderId }, + select: { id: true, origin: true }, + }); + if (reminder?.origin === "ENCOUNTER") { + await reanchorAppointmentReminder( + tx, + reminder.id, + { + userId: user.id, + occurredAt: existing.occurredAt, + practitionerName: existing.practitioner?.name ?? null, + practitionerLocation: existing.practitioner?.location ?? null, + kindLabel: encounterKindLabel(existing.kind, locale), + }, + timezone, + ); + } + } + }); + } + + const row = await prisma.encounter.findUniqueOrThrow({ + where: { id }, + include: ENCOUNTER_INCLUDE, + }); + const visible = await actingDomainVisibility(prisma, grantId); + const links = await loadEncounterLinks(prisma, user.id, id, visible); + + await auditLog("encounter.visit.restore", { + userId: user.id, + ipAddress: getClientIp(request), + details: { encounterId: id, occurredAt: row.occurredAt.toISOString() }, + }); + + annotate({ + action: { + name: "encounter.visit.restore", + entity_type: "encounter", + entity_id: id, + }, + }); + + return apiSuccess(toEncounterDTO(row, links)); + }, +); diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts new file mode 100644 index 000000000..24840de07 --- /dev/null +++ b/src/app/api/encounters/[id]/route.ts @@ -0,0 +1,405 @@ +/** + * `GET /api/encounters/{id}` — one owned, live visit with its links resolved. + * `PATCH /api/encounters/{id}` — edit it (partial; field-by-field). + * `DELETE /api/encounters/{id}` — soft-delete it, and retire its appointment + * reminder with it. + * + * Owner-scoped by fetch-then-guard. `userId` is narrowed from auth; the body + * never carries it. + * + * The status transitions are where the appointment arm lives. Moving a visit to + * DONE closes the checkup it was filed against — on the transition, so re-saving + * a completed visit does not push that checkup's next due date out again. + * Moving it to CANCELLED or NO_SHOW stops its reminder without deleting the + * row, so the history stays honest. Moving its date re-anchors the reminder it + * already owns and never mints a second. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { encounterUpdateSchema } from "@/lib/validations/encounters"; +import { encounterKindLabel } from "@/lib/encounters/kind-label"; +import { toEncounterDTO } from "@/lib/encounters/dto"; +import { + ENCOUNTER_INCLUDE, + actingDomainVisibility, + applyEncounterLinks, + closeCheckupForVisit, + findOwnAppointmentReminderId, + loadEncounterLinks, + resolveClosableReminder, + resolveOwnedPractitioner, + resolveOwnerNotificationContext, + retireAppointmentReminderForVisit, + retireOrphanedAppointmentReminders, + shouldHaveReminder, + syncAppointmentReminder, +} from "@/lib/encounters/service"; +import { + recordUnknownKeys, + summarizeRestoreSkips, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; +import type { Prisma } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user, grantId } = await requireRecordAuth("read", "profile"); + + const { id } = await params; + const row = await prisma.encounter.findUnique({ + where: { id }, + include: ENCOUNTER_INCLUDE, + }); + if (!row || row.userId !== user.id || row.deletedAt !== null) { + return apiError("Visit not found", 404); + } + + const visible = await actingDomainVisibility(prisma, grantId); + const links = await loadEncounterLinks(prisma, user.id, id, visible); + + annotate({ + action: { + name: "encounter.visit.read", + entity_type: "encounter", + entity_id: id, + }, + }); + + return apiSuccess(toEncounterDTO(row, links)); + }, +); + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user, grantId } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.encounter.findUnique({ where: { id } }); + if ( + !existing || + existing.userId !== user.id || + existing.deletedAt !== null + ) { + return apiError("Visit not found", 404); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 32 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = encounterUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "encounter.invalid", + }); + } + + const entry = parsed.data; + const { timezone, locale } = await resolveOwnerNotificationContext(user.id); + const now = new Date(); + + // The state the visit will be in once this edit lands. Both the reminder + // decision and the checkup decision read from here rather than from the + // body, so an edit that moves only the date is judged against the status + // the visit already has. + const nextOccurredAt = entry.occurredAt + ? new Date(entry.occurredAt) + : existing.occurredAt; + const nextStatus = entry.status ?? existing.status; + + const skips: RestoreSkipLog = []; + + const outcome = await prisma.$transaction(async (tx) => { + const visible = await actingDomainVisibility(tx, grantId); + let practitionerId = existing.practitionerId; + let practitionerName: string | null = null; + let practitionerLocation: string | null = null; + + if (entry.practitionerId !== undefined) { + if (entry.practitionerId === null) { + practitionerId = null; + } else { + const resolved = await resolveOwnedPractitioner( + tx, + user.id, + entry.practitionerId, + ); + if (!resolved) return "unknown-practitioner" as const; + practitionerId = resolved.id; + practitionerName = resolved.name; + practitionerLocation = resolved.location; + } + } else if (practitionerId) { + const resolved = await resolveOwnedPractitioner( + tx, + user.id, + practitionerId, + ); + practitionerName = resolved?.name ?? null; + practitionerLocation = resolved?.location ?? null; + } + + const data: Prisma.EncounterUpdateInput = {}; + if (entry.occurredAt !== undefined) data.occurredAt = nextOccurredAt; + if (entry.status !== undefined) data.status = entry.status; + if (entry.kind !== undefined) data.kind = entry.kind; + if (entry.reason !== undefined) { + data.reasonEncrypted = entry.reason + ? encryptToBytes(entry.reason) + : null; + } + if (entry.outcome !== undefined) { + data.outcomeEncrypted = entry.outcome + ? encryptToBytes(entry.outcome) + : null; + } + if (entry.practitionerId !== undefined) { + data.practitioner = practitionerId + ? { connect: { id: practitionerId } } + : { disconnect: true }; + } + + // The appointment reminder this visit OWNS, resolved through the engine + // table rather than by reading the visit's own foreign key. Reading the + // key answers a different question — what the visit points at — and the + // two stop agreeing the moment the key is re-pointed at a checkup. + const ownAppointmentReminderId = await findOwnAppointmentReminderId( + tx, + user.id, + id, + ); + + let reminderId = existing.reminderId; + + if (shouldHaveReminder(nextStatus, nextOccurredAt, now)) { + // The same contradiction the create arm refuses, and it has to be + // refused here too: a body that books a visit AND names the checkup it + // closed would walk the foreign key off the visit's own reminder, + // leaving it live, armed and unreachable. + if (entry.reminderId) return "contradictory-reminder" as const; + reminderId = await syncAppointmentReminder( + tx, + { + userId: user.id, + occurredAt: nextOccurredAt, + practitionerName, + practitionerLocation, + kindLabel: encounterKindLabel(entry.kind ?? existing.kind, locale), + status: nextStatus, + existingReminderId: ownAppointmentReminderId, + }, + timezone, + now, + ); + } else if (ownAppointmentReminderId) { + // No longer a future appointment: the row stays, switched off. + reminderId = await syncAppointmentReminder( + tx, + { + userId: user.id, + occurredAt: nextOccurredAt, + practitionerName, + practitionerLocation, + kindLabel: encounterKindLabel(entry.kind ?? existing.kind, locale), + status: nextStatus, + existingReminderId: ownAppointmentReminderId, + }, + timezone, + now, + ); + } + + // A body may re-point the visit at the checkup it closes. + if (entry.reminderId !== undefined) { + if (entry.reminderId === null) { + reminderId = ownAppointmentReminderId; + } else { + const checkup = await resolveClosableReminder( + tx, + user.id, + entry.reminderId, + ); + if (!checkup) return "unknown-reminder" as const; + reminderId = checkup.id; + } + } + + if (reminderId !== existing.reminderId) { + data.reminder = reminderId + ? { connect: { id: reminderId } } + : { disconnect: true }; + } + + const updated = await tx.encounter.update({ where: { id }, data }); + + await applyEncounterLinks(tx, user.id, id, entry); + + // Filing the visit as done closes the checkup it names — on the + // TRANSITION, not on every edit of a visit that is already done. + // + // `satisfyReminder` is forward-only, which means an older instant is a + // no-op and a NEWER one always advances. So re-saving a completed visit + // would push the checkup's next due date out again, as if the person had + // been a second time. Gating on the transition is what makes re-filing + // genuinely idempotent rather than merely non-erroring. + const closesCheckupNow = + nextStatus === "DONE" && existing.status !== "DONE"; + if (closesCheckupNow && reminderId) { + const checkup = await resolveClosableReminder(tx, user.id, reminderId); + if (checkup) { + // Same domain fence as the create arm: closing a checkup is a + // measurements write, and a health-background delegate may edit the + // visit without being able to perform it. + if (visible("measurements")) { + await closeCheckupForVisit( + tx, + checkup, + timezone, + nextOccurredAt, + now, + ); + } else { + recordUnknownKeys( + skips, + "checkupClosure", + [checkup.id], + [checkup.id], + ); + } + } + } + + // Nothing this account owns may be left armed for a visit that does not + // point at it. Runs after the write so the row just minted, whose key is + // set one statement earlier, is never mistaken for a stray. + await retireOrphanedAppointmentReminders(tx, user.id, now); + + return { updated }; + }); + + if (outcome === "contradictory-reminder") { + return apiError( + "A booked visit cannot also close a checkup — it has not happened yet", + 422, + { errorCode: "encounter.reminder-conflict" }, + ); + } + if (outcome === "unknown-practitioner") { + return apiError("Practitioner not found", 404, { + errorCode: "encounter.practitioner-not-found", + }); + } + if (outcome === "unknown-reminder") { + return apiError("Checkup not found", 404, { + errorCode: "encounter.reminder-not-found", + }); + } + + const row = await prisma.encounter.findUniqueOrThrow({ + where: { id }, + include: ENCOUNTER_INCLUDE, + }); + const visible = await actingDomainVisibility(prisma, grantId); + const links = await loadEncounterLinks(prisma, user.id, id, visible); + + await auditLog("encounter.visit.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + encounterId: id, + // The free text is deliberately absent: it is encrypted PHI and the + // audit table is not a second copy of the record. + ...overwriteDetails({ + before: { + occurredAt: existing.occurredAt, + status: existing.status, + kind: existing.kind, + practitionerId: existing.practitionerId, + }, + after: { + occurredAt: outcome.updated.occurredAt, + status: outcome.updated.status, + kind: outcome.updated.kind, + practitionerId: outcome.updated.practitionerId, + }, + }), + }, + }); + + annotate({ + action: { + name: "encounter.visit.update", + entity_type: "encounter", + entity_id: id, + }, + meta: { status: row.status }, + }); + + return apiSuccess(toEncounterDTO(row, links, summarizeRestoreSkips(skips))); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.encounter.findUnique({ where: { id } }); + if (!existing || existing.userId !== user.id) { + return apiError("Visit not found", 404); + } + + // Already tombstoned: deleting twice is a no-op that still succeeds. + if (existing.deletedAt === null) { + const at = new Date(); + await prisma.$transaction(async (tx) => { + // Only the visit's OWN appointment reminder goes with it, and it is + // found by the relation rather than by the foreign key: a visit whose + // key points at a checkup still owns its appointment row, and reading + // the key would leave that row armed for a visit that no longer exists. + // Retired BEFORE the tombstone, while the relation still resolves. + await retireAppointmentReminderForVisit(tx, user.id, id, at); + await tx.encounter.update({ where: { id }, data: { deletedAt: at } }); + // A checkup the visit closed belongs to the person's preventive-care + // list and outlives it; the sweep below only reaps appointment rows. + await retireOrphanedAppointmentReminders(tx, user.id, at); + }); + } + + await auditLog("encounter.visit.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + encounterId: id, + occurredAt: existing.occurredAt.toISOString(), + status: existing.status, + }, + }); + + annotate({ + action: { + name: "encounter.visit.delete", + entity_type: "encounter", + entity_id: id, + }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts new file mode 100644 index 000000000..ab5067df5 --- /dev/null +++ b/src/app/api/encounters/route.ts @@ -0,0 +1,296 @@ +/** + * `GET /api/encounters` — the account's visits, split into upcoming and past. + * `POST /api/encounters` — file one, optionally pre-linked and optionally + * booked as a future appointment. + * + * No module gate: a visit is core. The reasoning is written at the `Encounter` + * model in `prisma/schema.prisma`, and the gate is relocated to the link + * pickers, which follow the modules that own what they point at. + * + * `userId` is narrowed from auth and fed to the Prisma `where`; it is never a + * body field. The `data` object is built field-by-field. Nothing is mandatory + * except the date: a visit saves with an instant and nothing else. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + encounterCreateSchema, + encounterListQuerySchema, +} from "@/lib/validations/encounters"; +import { encounterKindLabel } from "@/lib/encounters/kind-label"; +import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto"; +import { + ENCOUNTER_INCLUDE, + actingDomainVisibility, + applyEncounterLinks, + closeCheckupForVisit, + resolveClosableReminder, + resolveOwnedPractitioner, + resolveOwnerNotificationContext, + shouldHaveReminder, + syncAppointmentReminder, +} from "@/lib/encounters/service"; +import { + recordUnknownKeys, + summarizeRestoreSkips, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "profile"); + + const params = new URL(request.url).searchParams; + const parsed = encounterListQuerySchema.safeParse({ + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + status: params.get("status") ?? undefined, + practitionerId: params.get("practitionerId") ?? undefined, + limit: params.get("limit") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "encounter.invalid", + }); + } + + const query = parsed.data; + const take = query.limit ?? 100; + const now = new Date(); + + const where = { + userId: user.id, + deletedAt: null, + ...(query.status ? { status: query.status } : {}), + ...(query.practitionerId ? { practitionerId: query.practitionerId } : {}), + ...(query.from || query.to + ? { + occurredAt: { + ...(query.from ? { gte: new Date(query.from) } : {}), + ...(query.to ? { lte: new Date(query.to) } : {}), + }, + } + : {}), + }; + + // The past arm's own upper bound is `now`, and the caller may have asked for + // an earlier one. Both have to hold, so they COMBINE to the tighter of the + // two rather than one overwriting the other — spreading the caller's + // `occurredAt` and then restating `lte` silently widened every window that + // ended before today back out to "everything up to now". + const requestedTo = query.to ? new Date(query.to) : null; + const pastCeiling = + requestedTo && requestedTo.getTime() < now.getTime() ? requestedTo : now; + + // Two queries rather than one sorted list. Upcoming reads soonest-first and + // past reads newest-first, which is the opposite direction; resolving both + // here means the client renders two lists instead of splitting and + // re-sorting one, and the ordering lives in one place. + const [upcoming, past] = await Promise.all([ + prisma.encounter.findMany({ + // `gt` and `gte` are different keys, so the caller's lower bound and this + // arm's floor both survive the spread; only the `lte` pair collided. + where: { ...where, occurredAt: { ...where.occurredAt, gt: now } }, + orderBy: { occurredAt: "asc" }, + take, + include: ENCOUNTER_INCLUDE, + }), + prisma.encounter.findMany({ + where: { + ...where, + occurredAt: { ...where.occurredAt, lte: pastCeiling }, + }, + orderBy: { occurredAt: "desc" }, + take, + include: ENCOUNTER_INCLUDE, + }), + ]); + + annotate({ + action: { name: "encounter.visit.list", entity_type: "encounter" }, + meta: { upcoming: upcoming.length, past: past.length }, + }); + + const body: EncounterListDTO = { + upcoming: upcoming.map((row) => toEncounterDTO(row)), + past: past.map((row) => toEncounterDTO(row)), + }; + return apiSuccess(body); +}); + +// A double-tap on "save" must not file the same visit twice. +export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter)); + +async function postEncounter(request: NextRequest): Promise { + const { user, grantId } = await requireRecordAuth("write", "profile"); + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 32 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = encounterCreateSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "encounter.visit.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "encounter.invalid", + }); + } + + const entry = parsed.data; + const occurredAt = new Date(entry.occurredAt); + const { timezone, locale } = await resolveOwnerNotificationContext(user.id); + const now = new Date(); + + const skips: RestoreSkipLog = []; + + const created = await prisma.$transaction(async (tx) => { + const visible = await actingDomainVisibility(tx, grantId); + // A practitioner the caller does not own is a refusal rather than a + // silently dropped field: the person named a practice and would otherwise + // see the visit save without it. + let practitioner: + { id: string; name: string; location: string | null } | undefined; + if (entry.practitionerId) { + practitioner = await resolveOwnedPractitioner( + tx, + user.id, + entry.practitionerId, + ); + if (!practitioner) return null; + } + + // Field-by-field — never spread the parsed object whole. + const row = await tx.encounter.create({ + data: { + userId: user.id, + occurredAt, + status: entry.status, + kind: entry.kind, + practitionerId: practitioner?.id ?? null, + reasonEncrypted: entry.reason ? encryptToBytes(entry.reason) : null, + outcomeEncrypted: entry.outcome ? encryptToBytes(entry.outcome) : null, + }, + select: { id: true }, + }); + + await applyEncounterLinks(tx, user.id, row.id, entry); + + // `Encounter.reminderId` carries one of two things, never both, and which + // one follows from the visit's own state. A booked appointment owns a + // reminder this code mints; a completed visit may instead close a checkup + // the person already had. A body claiming both is contradictory — a visit + // that has not happened cannot have closed anything — and is refused. + let reminderId: string | null = null; + + if (shouldHaveReminder(entry.status, occurredAt, now)) { + if (entry.reminderId) return "contradictory-reminder" as const; + reminderId = await syncAppointmentReminder( + tx, + { + userId: user.id, + occurredAt, + practitionerName: practitioner?.name ?? null, + practitionerLocation: practitioner?.location ?? null, + kindLabel: encounterKindLabel(entry.kind, locale), + status: entry.status, + existingReminderId: null, + }, + timezone, + now, + ); + } else if (entry.reminderId) { + const checkup = await resolveClosableReminder( + tx, + user.id, + entry.reminderId, + ); + if (!checkup) return "unknown-reminder" as const; + reminderId = checkup.id; + // Filing a visit as done against a due checkup closes it — but closing + // one re-anchors a preventive-care cadence, which is a write in the + // measurements domain. A delegate holding only the health background may + // file the visit and may not perform that side effect, so the visit + // saves and the closure is reported as skipped rather than done in + // silence: somebody who believes a checkup was closed will not look at + // it again. + if (entry.status === "DONE") { + if (visible("measurements")) { + await closeCheckupForVisit(tx, checkup, timezone, occurredAt, now); + } else { + recordUnknownKeys( + skips, + "checkupClosure", + [checkup.id], + [checkup.id], + ); + } + } + } + + if (reminderId) { + await tx.encounter.update({ + where: { id: row.id }, + data: { reminderId }, + }); + } + + return tx.encounter.findUniqueOrThrow({ + where: { id: row.id }, + include: ENCOUNTER_INCLUDE, + }); + }); + + if (created === null) { + return apiError("Practitioner not found", 404, { + errorCode: "encounter.practitioner-not-found", + }); + } + if (created === "unknown-reminder") { + return apiError("Checkup not found", 404, { + errorCode: "encounter.reminder-not-found", + }); + } + if (created === "contradictory-reminder") { + return apiError( + "A planned visit cannot also close a checkup — it has not happened yet", + 422, + { errorCode: "encounter.reminder-conflict" }, + ); + } + + await auditLog("encounter.visit.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { encounterId: created.id, status: created.status }, + }); + + annotate({ + action: { + name: "encounter.visit.create", + entity_type: "encounter", + entity_id: created.id, + }, + meta: { status: created.status, kind: created.kind }, + }); + + return apiSuccess( + toEncounterDTO(created, undefined, summarizeRestoreSkips(skips)), + 201, + ); +} 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 b1823aecb..16e1c3292 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -28,6 +28,14 @@ vi.mock("@/lib/db", () => ({ // `buildIntradayProfileBackupSection`. intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, + // The visit tables. Empty here: this file asserts the sections it seeds, + // and an account with no visits is the ordinary case the builder must + // still answer for. + practitioner: { findMany: vi.fn().mockResolvedValue([]) }, + encounter: { findMany: vi.fn().mockResolvedValue([]) }, + encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -98,6 +106,15 @@ beforeEach(() => { [] as never, ); vi.mocked(prisma.healthScoreRecord.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.practitioner.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounterLabLink.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.encounterConditionLink.findMany).mockResolvedValue( + [] as never, + ); }); afterEach(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 4ac966527..f1e7b2723 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -26,6 +26,14 @@ vi.mock("@/lib/db", () => ({ // `buildIntradayProfileBackupSection`. intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, + // The visit tables. Empty here: this file asserts the sections it seeds, + // and an account with no visits is the ordinary case the builder must + // still answer for. + practitioner: { findMany: vi.fn().mockResolvedValue([]) }, + encounter: { findMany: vi.fn().mockResolvedValue([]) }, + encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/measurement-reminders/[id]/complete/route.ts b/src/app/api/measurement-reminders/[id]/complete/route.ts index 2a4225f52..ce3a8d1fb 100644 --- a/src/app/api/measurement-reminders/[id]/complete/route.ts +++ b/src/app/api/measurement-reminders/[id]/complete/route.ts @@ -41,8 +41,13 @@ export const POST = apiHandler( const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; + // An appointment reminder is not addressable here. It belongs to a visit, + // is managed through the visit routes, and sits in a different sharing + // domain — so this family treats one as not found rather than acting on + // it. Filtering in the lookup rather than after it is what keeps a write + // from committing before the refusal. const existing = await prisma.measurementReminder.findFirst({ - where: { id, deletedAt: null }, + where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } }, }); if (!existing || existing.userId !== user.id) { return apiError("Measurement reminder not found", 404); diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index b68acd296..a8785b2ae 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -48,8 +48,13 @@ export const GET = apiHandler( const { user } = await requireRecordAuth("read", "measurements"); const { id } = await params; + // An appointment reminder is not addressable here. It belongs to a visit, + // is managed through the visit routes, and sits in a different sharing + // domain — so this family treats one as not found rather than acting on + // it. Filtering in the lookup rather than after it is what keeps a write + // from committing before the refusal. const reminder = await prisma.measurementReminder.findFirst({ - where: { id, deletedAt: null }, + where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } }, }); if (!reminder || reminder.userId !== user.id) { return apiError("Measurement reminder not found", 404); @@ -71,8 +76,13 @@ export const PATCH = apiHandler( const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; + // An appointment reminder is not addressable here. It belongs to a visit, + // is managed through the visit routes, and sits in a different sharing + // domain — so this family treats one as not found rather than acting on + // it. Filtering in the lookup rather than after it is what keeps a write + // from committing before the refusal. const existing = await prisma.measurementReminder.findFirst({ - where: { id, deletedAt: null }, + where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } }, }); if (!existing || existing.userId !== user.id) { return apiError("Measurement reminder not found", 404); @@ -234,8 +244,15 @@ export const DELETE = apiHandler( const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; - const existing = await prisma.measurementReminder.findUnique({ - where: { id }, + // An appointment reminder is not addressable here. It belongs to a visit, + // is managed through the visit routes, and sits in a different sharing + // domain — so this family treats one as not found rather than acting on + // it. Filtering in the lookup rather than after it is what keeps a write + // from committing before the refusal. + // `findFirst` rather than `findUnique`: the origin is not part of any + // unique key, so the predicate cannot ride a unique lookup. + const existing = await prisma.measurementReminder.findFirst({ + where: { id, origin: { not: "ENCOUNTER" } }, }); if (!existing || existing.userId !== user.id) { return apiError("Measurement reminder not found", 404); diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts index f877d3b87..dc24728fb 100644 --- a/src/app/api/measurement-reminders/[id]/satisfy/route.ts +++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts @@ -27,8 +27,13 @@ export const POST = apiHandler( const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; + // An appointment reminder is not addressable here. It belongs to a visit, + // is managed through the visit routes, and sits in a different sharing + // domain — so this family treats one as not found rather than acting on + // it. Filtering in the lookup rather than after it is what keeps a write + // from committing before the refusal. const existing = await prisma.measurementReminder.findFirst({ - where: { id, deletedAt: null }, + where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } }, }); if (!existing || existing.userId !== user.id) { return apiError("Measurement reminder not found", 404); diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts index 8b22c5721..7e4d0b2e8 100644 --- a/src/app/api/measurement-reminders/route.ts +++ b/src/app/api/measurement-reminders/route.ts @@ -39,7 +39,13 @@ export const GET = apiHandler(async () => { const { user } = await requireRecordAuth("read", "measurements"); const reminders = await prisma.measurementReminder.findMany({ - where: { userId: user.id, deletedAt: null }, + // A booked visit's one-shot reminder rides this same engine with + // `origin: ENCOUNTER`. It is not a checkup and must not appear on a + // Vorsorge surface, or the list fills with appointments. Four read + // sites carry this exclusion; `encounter-reminder-exclusion.test.ts` + // proves every one of them, and the DTO mapper refuses such a row + // outright so a site that lost its filter fails loudly. + where: { userId: user.id, deletedAt: null, origin: { not: "ENCOUNTER" } }, // Most-urgent first; a null next-due (uncomputable / disabled) sinks // to the end so the actionable items float to the top. orderBy: [ diff --git a/src/app/api/practitioners/[id]/restore/route.ts b/src/app/api/practitioners/[id]/restore/route.ts new file mode 100644 index 000000000..8a974ec03 --- /dev/null +++ b/src/app/api/practitioners/[id]/restore/route.ts @@ -0,0 +1,58 @@ +/** + * `POST /api/practitioners/{id}/restore` — clear a practitioner's tombstone. + * + * MANAGE rather than WRITE: undoing a deletion is a management act when the + * manager is the one who holds delete. + * + * Restoring the address-book entry does NOT re-attach it to the visits that + * named it. Those references were nulled when the row was deleted, and + * inventing them back would be guessing at which visits meant this practice. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { toPractitionerDTO } from "@/lib/practitioners/dto"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.practitioner.findUnique({ where: { id } }); + if (!existing || existing.userId !== user.id) { + return apiError("Practitioner not found", 404); + } + + // Restoring a live row is a no-op that still succeeds, so a retry after a + // dropped response does not answer 404 for work that already happened. + const restored = + existing.deletedAt === null + ? existing + : await prisma.practitioner.update({ + where: { id }, + data: { deletedAt: null }, + }); + + await auditLog("practitioner.contact.restore", { + userId: user.id, + ipAddress: getClientIp(request), + details: { practitionerId: id, name: restored.name }, + }); + + annotate({ + action: { + name: "practitioner.contact.restore", + entity_type: "practitioner", + entity_id: id, + }, + }); + + return apiSuccess(toPractitionerDTO(restored)); + }, +); diff --git a/src/app/api/practitioners/[id]/route.ts b/src/app/api/practitioners/[id]/route.ts new file mode 100644 index 000000000..5d8de3fbb --- /dev/null +++ b/src/app/api/practitioners/[id]/route.ts @@ -0,0 +1,174 @@ +/** + * `GET /api/practitioners/{id}` — one owned, live practitioner. + * `PATCH /api/practitioners/{id}` — edit it (partial; field-by-field). + * `DELETE /api/practitioners/{id}` — soft-delete it (idempotent). + * + * Owner-scoped by fetch-then-guard, the episode-detail pattern: the row is + * read by id and then checked against the resolved user, so a foreign id and a + * missing id give the same 404 and probing learns nothing. + * + * Deleting a practitioner keeps every visit that named it. The foreign key is + * SetNull, so the visits survive with no practice attached rather than + * disappearing with the address-book entry. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { practitionerUpdateSchema } from "@/lib/validations/practitioners"; +import { toPractitionerDTO } from "@/lib/practitioners/dto"; +import type { Prisma } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("read", "profile"); + + const { id } = await params; + const row = await prisma.practitioner.findUnique({ where: { id } }); + if (!row || row.userId !== user.id || row.deletedAt !== null) { + return apiError("Practitioner not found", 404); + } + + annotate({ + action: { + name: "practitioner.contact.read", + entity_type: "practitioner", + entity_id: id, + }, + }); + + return apiSuccess(toPractitionerDTO(row)); + }, +); + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.practitioner.findUnique({ where: { id } }); + if ( + !existing || + existing.userId !== user.id || + existing.deletedAt !== null + ) { + return apiError("Practitioner not found", 404); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = practitionerUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "practitioner.invalid", + }); + } + + const entry = parsed.data; + + // Field-by-field, and only the keys the body actually named: an absent key + // leaves the column alone, an explicit null clears it. + const data: Prisma.PractitionerUpdateInput = {}; + if (entry.name !== undefined) data.name = entry.name; + if (entry.specialty !== undefined) data.specialty = entry.specialty; + if (entry.practice !== undefined) data.practice = entry.practice; + if (entry.location !== undefined) data.location = entry.location; + if (entry.phone !== undefined) data.phone = entry.phone; + if (entry.note !== undefined) { + data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null; + } + + const updated = await prisma.practitioner.update({ where: { id }, data }); + + await auditLog("practitioner.contact.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + practitionerId: id, + // A manager may edit somebody else's address book, so the owner's + // activity feed has to say what changed rather than only that + // something did. The note is deliberately absent: it is encrypted PHI + // and the audit table is not a second copy of the record. + ...overwriteDetails({ + before: { + name: existing.name, + specialty: existing.specialty, + practice: existing.practice, + location: existing.location, + phone: existing.phone, + }, + after: { + name: updated.name, + specialty: updated.specialty, + practice: updated.practice, + location: updated.location, + phone: updated.phone, + }, + }), + }, + }); + + annotate({ + action: { + name: "practitioner.contact.update", + entity_type: "practitioner", + entity_id: id, + }, + }); + + return apiSuccess(toPractitionerDTO(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.practitioner.findUnique({ where: { id } }); + if (!existing || existing.userId !== user.id) { + return apiError("Practitioner not found", 404); + } + // Already tombstoned: deleting twice is a no-op that still succeeds. + if (existing.deletedAt === null) { + await prisma.practitioner.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + } + + await auditLog("practitioner.contact.delete", { + userId: user.id, + ipAddress: getClientIp(request), + // Soft delete: the row and its name survive, so the id is enough to + // reconstruct what went missing from the list. + details: { practitionerId: id, name: existing.name }, + }); + + annotate({ + action: { + name: "practitioner.contact.delete", + entity_type: "practitioner", + entity_id: id, + }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/practitioners/route.ts b/src/app/api/practitioners/route.ts new file mode 100644 index 000000000..04a495b5a --- /dev/null +++ b/src/app/api/practitioners/route.ts @@ -0,0 +1,123 @@ +/** + * `GET /api/practitioners` — the account's own address book of doctors and + * practices, name-ordered and searchable. + * `POST /api/practitioners` — add one. + * + * No module gate: an address book is core, like the checkups page the visits + * that reference it live on. The reasoning is written at the `Practitioner` + * model in `prisma/schema.prisma`. + * + * `userId` is narrowed from auth and fed to the Prisma `where`; it is never a + * body field. The `data` object is built field-by-field, never spread from the + * parsed body. `note` is encrypted at rest; `name` stays plaintext so the + * picker can search and sort it in SQL. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + practitionerCreateSchema, + practitionerListQuerySchema, +} from "@/lib/validations/practitioners"; +import { toPractitionerDTO } from "@/lib/practitioners/dto"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "profile"); + + const params = new URL(request.url).searchParams; + const parsed = practitionerListQuerySchema.safeParse({ + q: params.get("q") ?? undefined, + limit: params.get("limit") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "practitioner.invalid", + }); + } + + const rows = await prisma.practitioner.findMany({ + where: { + userId: user.id, + deletedAt: null, + ...(parsed.data.q + ? { name: { contains: parsed.data.q, mode: "insensitive" as const } } + : {}), + }, + orderBy: { name: "asc" }, + take: parsed.data.limit ?? 100, + }); + + annotate({ + action: { name: "practitioner.contact.list", entity_type: "practitioner" }, + meta: { count: rows.length }, + }); + + return apiSuccess(rows.map(toPractitionerDTO)); +}); + +// A double-tap on "save" must not mint a second copy of the same practice. +export const POST = apiHandler( + withIdempotency<[NextRequest]>(postPractitioner), +); + +async function postPractitioner(request: NextRequest): Promise { + const { user } = await requireRecordAuth("write", "profile"); + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = practitionerCreateSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "practitioner.contact.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "practitioner.invalid", + }); + } + + const entry = parsed.data; + + // Field-by-field — never spread the parsed object whole. + const created = await prisma.practitioner.create({ + data: { + userId: user.id, + name: entry.name, + specialty: entry.specialty ?? null, + practice: entry.practice ?? null, + location: entry.location ?? null, + phone: entry.phone ?? null, + noteEncrypted: entry.note ? encryptToBytes(entry.note) : null, + }, + }); + + await auditLog("practitioner.contact.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { practitionerId: created.id }, + }); + + annotate({ + action: { + name: "practitioner.contact.create", + entity_type: "practitioner", + entity_id: created.id, + }, + }); + + return apiSuccess(toPractitionerDTO(created), 201); +} diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 74707883b..b3b92484d 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -198,6 +198,12 @@ function catalogueLabel( if (catalogue === "moodTag") { return t("admin.section.backups.restoreSkippedMoodTag"); } + if (catalogue === "visitReference") { + return t("admin.section.backups.restoreSkippedVisitReference"); + } + if (catalogue === "checkupClosure") { + return t("admin.section.backups.restoreSkippedCheckupClosure"); + } return t("admin.section.backups.restoreSkippedMoodFactor"); } diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index de88ef822..6caf22db1 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -233,6 +233,15 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { model: "Allergy", field: "notesEncrypted", kind: "bytes" }, { model: "FamilyHistoryEntry", field: "notesEncrypted", kind: "bytes" }, + // ───── v1.38.0 visit free text (Bytes columns) ───── + // Why the person went, what came out of it, and the practice note. The + // structured columns a picker sorts and searches on — the practitioner's + // name, the kind, the status — stay queryable plaintext, the same split the + // structured health records above make. + { model: "Encounter", field: "reasonEncrypted", kind: "bytes" }, + { model: "Encounter", field: "outcomeEncrypted", kind: "bytes" }, + { model: "Practitioner", field: "noteEncrypted", kind: "bytes" }, + // ───── Inbound clinical document (Bytes column, codec-dispatched) ───── // The raw uploaded document (the most sensitive blob in the corpus; never // logged, never in wide-event meta). Two layouts, recorded per row in diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 7c60aa662..bb90bd9dd 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -364,10 +364,17 @@ export async function loadDailyDigest( orderBy: { integration: "asc" }, }), prisma.measurementReminder.findMany({ + // A booked visit's one-shot reminder rides this same engine with + // `origin: ENCOUNTER`. It is not a checkup and must not appear on a + // Vorsorge surface, or the list fills with appointments. Four read + // sites carry this exclusion; `encounter-reminder-exclusion.test.ts` + // proves every one of them, and the DTO mapper refuses such a row + // outright so a site that lost its filter fails loudly. where: { userId: user.id, enabled: true, deletedAt: null, + origin: { not: "ENCOUNTER" }, nextDueAt: { not: null, lte: now }, }, select: { label: true }, diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 0d8441dac..98092efe2 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -80,6 +80,17 @@ export const WIPE_MODELS = [ "MoodReminderDispatch", "MoodTagHidden", + // ── Visits ────────────────────────────────────────────────────────────── + // The three link tables cascade from the encounter AND from the far side + // they point at — a lab result, a document, a condition episode — so they + // are deleted before any of those, or their per-model counts read zero on an + // audit row that claims to say what was removed. The encounter follows its + // own links; the address book it points at is a parent and goes last. + "EncounterDocumentLink", + "EncounterLabLink", + "EncounterConditionLink", + "Encounter", + // ── Clinical record ───────────────────────────────────────────────────── "MentalHealthAssessment", "LabResult", @@ -192,6 +203,9 @@ export const WIPE_MODELS = [ "IllnessEpisode", "Workout", "InboundDocument", + // Encounters point here with SetNull, so the address book outlives the + // visits it is attached to and is removed after them. + "Practitioner", "ApiToken", ] as const; diff --git a/src/lib/documents/links.ts b/src/lib/documents/links.ts index 92515b6eb..dfd9ed170 100644 --- a/src/lib/documents/links.ts +++ b/src/lib/documents/links.ts @@ -5,7 +5,9 @@ * id a client sends is re-narrowed against the caller's live episodes before * it can land in `document_condition_links`. */ +import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { listTargetsBySource, replaceTargets } from "@/lib/links"; import type { DocumentConditionLinkDto } from "@/lib/validations/inbound-documents"; /** @@ -18,19 +20,21 @@ export async function loadConditionLinks( ): Promise> { const map = new Map(); if (documentIds.length === 0) return map; - const links = await prisma.documentConditionLink.findMany({ - where: { userId, documentId: { in: documentIds } }, - select: { - documentId: true, - episodeId: true, - episode: { select: { label: true } }, - }, - orderBy: { createdAt: "asc" }, + // `includeDeletedTargets` keeps the long-standing behaviour: the chip has + // always come off a relation join with no tombstone filter, so a document + // filed against a condition the person later deleted still shows it. + const byDocument = await listTargetsBySource(prisma, { + userId, + sourceKind: "document", + sourceIds: documentIds, + targetKind: "conditionEpisode", + includeDeletedTargets: true, }); - for (const link of links) { - const entry = map.get(link.documentId) ?? []; - entry.push({ episodeId: link.episodeId, name: link.episode.label }); - map.set(link.documentId, entry); + for (const [documentId, targets] of byDocument) { + map.set( + documentId, + targets.map((target) => ({ episodeId: target.id, name: target.label })), + ); } return map; } @@ -59,25 +63,23 @@ export async function narrowOwnedEpisodeIds( /** * Replace-set a document's condition links inside the given transaction-ish * client: delete links no longer in the set, insert the missing ones. - * `episodeIds` MUST already be owner-narrowed (`narrowOwnedEpisodeIds`). + * + * `episodeIds` should already be owner-narrowed (`narrowOwnedEpisodeIds`), so + * the route can answer a foreign id with the 404-shaped refusal it does today. + * The link service narrows them again regardless — the routes decide the reply, + * the service decides what reaches the table. */ export async function replaceConditionLinks( - tx: Pick, + tx: Prisma.TransactionClient, userId: string, documentId: string, episodeIds: string[], ): Promise { - await tx.documentConditionLink.deleteMany({ - where: { userId, documentId, episodeId: { notIn: episodeIds } }, + await replaceTargets(tx, { + userId, + sourceKind: "document", + sourceId: documentId, + targetKind: "conditionEpisode", + targetIds: episodeIds, }); - if (episodeIds.length > 0) { - await tx.documentConditionLink.createMany({ - data: episodeIds.map((episodeId) => ({ - documentId, - episodeId, - userId, - })), - skipDuplicates: true, - }); - } } diff --git a/src/lib/encounters/__tests__/appointment-reminder.test.ts b/src/lib/encounters/__tests__/appointment-reminder.test.ts new file mode 100644 index 000000000..6c3d881d2 --- /dev/null +++ b/src/lib/encounters/__tests__/appointment-reminder.test.ts @@ -0,0 +1,86 @@ +/** + * The never-nag property, proven rather than asserted in prose. + * + * A booked visit's reminder is a one-shot: both cadence columns are NULL, which + * the schema documents as "anchored on `anchorDate`". The claim that follows — + * that it can fire once and never again — is a claim about the SAME function + * the cron calls to advance a fired reminder. If that function returned an + * instant here, the row would re-arm every tick and nag forever about an + * appointment that already happened, and nothing else in the system would + * notice: the row is excluded from every list a person can see. + * + * The module said this test existed. It did not. That is the reason it does + * now: a comment claiming a proof is worse than no comment, because it stops + * the next reader looking. + */ +import { describe, expect, it } from "vitest"; + +import { + appointmentNextDueAfterFiring, + appointmentNotifyHour, +} from "@/lib/encounters/appointment-reminder"; + +const TZ = "Europe/Berlin"; +const APPOINTMENT = new Date("2026-09-14T07:40:00.000Z"); + +/** The row shape a minted appointment reminder actually carries. */ +const oneShot = { + intervalDays: null, + rrule: null, + anchorDate: APPOINTMENT, + notifyHour: 9, + createdAt: new Date("2026-08-01T00:00:00.000Z"), +}; + +describe("a fired appointment reminder can never re-arm", () => { + it("has no next occurrence once its instant has passed", () => { + const afterItFired = new Date("2026-09-14T08:00:00.000Z"); + + expect(appointmentNextDueAfterFiring(oneShot, TZ, afterItFired)).toBeNull(); + }); + + it("still has none a year later", () => { + // The failure this rules out is a cadence quietly inferred from the anchor, + // which would look correct on the day and nag every year after it. + expect( + appointmentNextDueAfterFiring( + oneShot, + TZ, + new Date("2027-10-01T00:00:00.000Z"), + ), + ).toBeNull(); + }); + + it("is not vacuous: a cadenced reminder DOES get a next occurrence", () => { + // Without this, "returns null" would also pass for a function that returns + // null unconditionally, which is the shape of green this repository keeps + // being bitten by. + const cadenced = { ...oneShot, intervalDays: 30 }; + + expect( + appointmentNextDueAfterFiring( + cadenced, + TZ, + new Date("2026-09-14T08:00:00.000Z"), + ), + ).not.toBeNull(); + }); +}); + +describe("the hour the nudge fires", () => { + it("is the appointment's own local hour, not the server's", () => { + // 07:40 UTC is 09:40 in Berlin, so the nudge belongs at 9. + expect(appointmentNotifyHour(APPOINTMENT, TZ)).toBe(9); + }); + + it("reads midnight as hour zero rather than twenty-four", () => { + // Some locales format midnight as "24"; the column is 0–23. + expect( + appointmentNotifyHour(new Date("2026-09-14T22:00:00.000Z"), TZ), + ).toBe(0); + }); + + it("follows the record's zone, so the same instant differs by zone", () => { + expect(appointmentNotifyHour(APPOINTMENT, "UTC")).toBe(7); + }); +}); diff --git a/src/lib/encounters/appointment-reminder.ts b/src/lib/encounters/appointment-reminder.ts new file mode 100644 index 000000000..4386b55e4 --- /dev/null +++ b/src/lib/encounters/appointment-reminder.ts @@ -0,0 +1,180 @@ +/** + * A booked visit's reminder — one row on the EXISTING engine, not a second one. + * + * A planned encounter mints exactly one `MeasurementReminder` with + * `origin = ENCOUNTER`. No new queue, no new cron, no new notification event + * type, no new preference key: the enum's own docblock states the rule, and an + * appointment is a reminder with a date like every other reminder with a date. + * + * ── Why the row is shaped the way it is ───────────────────────────────────── + * + * `measurementType` is NULL, which is the engine's free-text arm: there is no + * measurement that could satisfy "you have an appointment", so it resolves only + * on an explicit satisfy. `intervalDays` and `rrule` are both NULL, which the + * schema documents as a one-shot anchored on `anchorDate`. + * + * That last part carries a consequence worth stating rather than discovering: + * `computeReminderNextDueAt` returns NULL when there is no cadence, so a + * one-shot's first `nextDueAt` cannot come from it and is stamped here + * directly. The same fact is what makes the never-nag property STRUCTURAL — + * after the cron fires the row it calls that function to advance the slot, gets + * NULL, and the reminder can never fire again. There is no guard enforcing + * this and there should not be one; it is a property of having no cadence. + * + * ── One row per visit, ever ───────────────────────────────────────────────── + * + * Rescheduling re-anchors the existing row. It never mints a second, which is + * why `Encounter.reminderId` is a single column and not a list. Deliberately NO + * partial unique index enforces this: the COACH one is `WHERE origin = 'COACH'` + * so ENCOUNTER rows do not inherit it, and a similar index here would break + * anyone who books two appointments. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "@/lib/measurement-reminders/scheduling"; + +/** What the reminder needs to know about the visit that owns it. */ +export interface AppointmentReminderInput { + userId: string; + occurredAt: Date; + /** Used as the label when present; the kind label is the fallback. */ + practitionerName: string | null; + /** Copied onto the reminder's free-text `location`. */ + practitionerLocation: string | null; + /** The fallback label when the visit names no practice. */ + kindLabel: string; +} + +/** The local hour the nudge fires, read off the appointment in the user's zone. */ +export function appointmentNotifyHour( + occurredAt: Date, + timezone: string, +): number { + const hour = new Intl.DateTimeFormat("en-GB", { + timeZone: timezone, + hour: "2-digit", + hour12: false, + }).format(occurredAt); + const parsed = Number.parseInt(hour, 10); + // `24` is a legal formatter output for midnight in some locales; the column + // is 0–23 and midnight is 0. + if (!Number.isFinite(parsed)) return 9; + return parsed % 24; +} + +function reminderLabel(input: AppointmentReminderInput): string { + return input.practitionerName?.trim() || input.kindLabel; +} + +/** + * Mint the one reminder a planned visit owns, inside the caller's transaction. + * + * Returns the row's id for `Encounter.reminderId`. + */ +export async function mintAppointmentReminder( + tx: Prisma.TransactionClient, + input: AppointmentReminderInput, + timezone: string, +): Promise { + const created = await tx.measurementReminder.create({ + data: { + userId: input.userId, + label: reminderLabel(input), + // The free-text arm: nothing a person could measure satisfies this. + measurementType: null, + // Both NULL — a one-shot anchored on `anchorDate`. + intervalDays: null, + rrule: null, + anchorDate: input.occurredAt, + origin: "ENCOUNTER", + notifyHour: appointmentNotifyHour(input.occurredAt, timezone), + location: input.practitionerLocation, + // Stamped directly: see the note at the top of this file on why the + // cadence helper cannot produce a one-shot's first slot. + nextDueAt: input.occurredAt, + }, + select: { id: true }, + }); + return created.id; +} + +/** + * Move an existing appointment reminder to a new instant. + * + * Re-anchors and re-stamps the same row rather than minting a second, and + * re-enables it: a visit that was cancelled and then rebooked should nudge + * again, which is the whole reason the person moved the date. + */ +export async function reanchorAppointmentReminder( + tx: Prisma.TransactionClient, + reminderId: string, + input: AppointmentReminderInput, + timezone: string, +): Promise { + await tx.measurementReminder.update({ + where: { id: reminderId }, + data: { + label: reminderLabel(input), + anchorDate: input.occurredAt, + notifyHour: appointmentNotifyHour(input.occurredAt, timezone), + location: input.practitionerLocation, + nextDueAt: input.occurredAt, + enabled: true, + deletedAt: null, + }, + }); +} + +/** + * Stop an appointment reminder without deleting it. + * + * A cancelled or missed appointment keeps its row so the history stays honest, + * but must never nudge again. `enabled: false` is the engine's own master + * switch and is distinct from the tombstone. + */ +export async function disableAppointmentReminder( + tx: Prisma.TransactionClient, + reminderId: string, +): Promise { + await tx.measurementReminder.update({ + where: { id: reminderId }, + data: { enabled: false, nextDueAt: null }, + }); +} + +/** Soft-delete the reminder alongside the visit that owns it. */ +export async function deleteAppointmentReminder( + tx: Prisma.TransactionClient, + reminderId: string, + at: Date, +): Promise { + await tx.measurementReminder.update({ + where: { id: reminderId }, + data: { deletedAt: at, enabled: false, nextDueAt: null }, + }); +} + +/** + * What a one-shot appointment reminder's next slot becomes once it has fired. + * + * Exported for `__tests__/appointment-reminder.test.ts`, which proves the + * never-nag property rather than asserting it in prose: the answer must be + * NULL, and it must come from the same function the cron uses to advance a + * reminder it has just fired. + */ +export function appointmentNextDueAfterFiring( + reminder: Pick< + ReminderScheduleInput, + "intervalDays" | "rrule" | "anchorDate" | "notifyHour" | "createdAt" + >, + timezone: string, + now: Date, +): Date | null { + return computeReminderNextDueAt( + { ...reminder, lastSatisfiedAt: null }, + timezone, + now, + ); +} diff --git a/src/lib/encounters/dto.ts b/src/lib/encounters/dto.ts new file mode 100644 index 000000000..9361d2591 --- /dev/null +++ b/src/lib/encounters/dto.ts @@ -0,0 +1,138 @@ +/** + * Encounter row → wire DTO. + * + * Everything is published resolved. The practitioner comes back as an object + * and never as a bare id, the links come back with a label and a date, and the + * appointment's next-due instant comes back computed. A client that had to + * re-derive any of them would drift from the server the first time one of the + * derivations changed on one side only. + * + * The two free-text columns are `Bytes` ciphertext and decrypt fail-soft: a + * key-rotation gap on one row reads as a missing reason, not as a 500 on the + * whole list. + */ +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; +import type { + Encounter, + EncounterKind, + EncounterStatus, + MeasurementReminder, + Practitioner, +} from "@/generated/prisma/client"; +import type { RestoreSkipSummary } from "@/lib/export/restore-skips"; +import { + toPractitionerDTO, + type PractitionerDTO, +} from "@/lib/practitioners/dto"; + +/** + * One thing a visit produced, as the caller is allowed to see it. + * + * `label` and `date` go null together when the caller's grant does not cover + * the target's own domain — a health-background delegate may know the visit + * produced a lab result without being told which analyte. `redacted` says + * which of those nulls is a withholding and which is simply a target with no + * date, so a client never has to guess. + */ +export interface EncounterLinkDTO { + id: string; + label: string | null; + date: string | null; + redacted: boolean; +} + +/** The three link families a visit can carry, each already resolved. */ +export interface EncounterLinksDTO { + documents: EncounterLinkDTO[]; + labResults: EncounterLinkDTO[]; + conditions: EncounterLinkDTO[]; +} + +export interface EncounterDTO { + id: string; + occurredAt: string; + status: EncounterStatus; + kind: EncounterKind; + /** Resolved, never just an id. `null` when the visit names no practice. */ + practitioner: PractitionerDTO | null; + reason: string | null; + outcome: string | null; + /** + * The server-computed instant the appointment reminder next fires, when this + * visit minted one and it has not fired yet. `null` for a visit that already + * happened, or once the one-shot reminder is spent. + */ + reminderNextDueAt: string | null; + /** Present on the detail response; omitted from the list. */ + links?: EncounterLinksDTO; + /** + * What the write could not do, named. + * + * Present on create and edit. `links: 0` is the ordinary answer and says so. + * Anything else is a side effect the caller's grant did not reach — today + * only the closing of a checkup, which is a measurements-domain write that a + * health-background delegate cannot perform. Reported rather than skipped in + * silence, because a person who believes a checkup was closed will not look + * at it again. + */ + skipped?: RestoreSkipSummary; + createdAt: string; + updatedAt: string; +} + +function decryptField( + value: Uint8Array | null, + field: "reason" | "outcome", +): string | null { + if (!value || value.byteLength === 0) return null; + try { + return decryptFromBytes(value); + } catch (err) { + getEvent()?.addWarning( + `encounter ${field} decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; + } +} + +export type EncounterWithRelations = Encounter & { + practitioner?: Practitioner | null; + reminder?: Pick | null; +}; + +export function toEncounterDTO( + row: EncounterWithRelations, + links?: EncounterLinksDTO, + skipped?: RestoreSkipSummary, +): EncounterDTO { + return { + id: row.id, + occurredAt: row.occurredAt.toISOString(), + status: row.status, + kind: row.kind, + practitioner: row.practitioner ? toPractitionerDTO(row.practitioner) : null, + reason: decryptField(row.reasonEncrypted, "reason"), + outcome: decryptField(row.outcomeEncrypted, "outcome"), + reminderNextDueAt: row.reminder?.nextDueAt?.toISOString() ?? null, + ...(links ? { links } : {}), + ...(skipped ? { skipped } : {}), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +/** + * The list response. + * + * Two arrays rather than one sorted list, resolved server-side: "what is + * coming up" and "what happened" are read in opposite directions, and making + * the client split and re-sort one array would put that decision in two + * places. + */ +export interface EncounterListDTO { + upcoming: EncounterDTO[]; + past: EncounterDTO[]; +} diff --git a/src/lib/encounters/kind-label.ts b/src/lib/encounters/kind-label.ts new file mode 100644 index 000000000..ed31a78d2 --- /dev/null +++ b/src/lib/encounters/kind-label.ts @@ -0,0 +1,47 @@ +/** + * The human name of a visit kind, in the record owner's language. + * + * Spelled out with literal `t()` calls rather than an interpolated key, for the + * same reason the restore-skip catalogue labels are: an interpolated key is + * invisible to `i18n-call-site-coverage`, so a renamed or missing kind would + * ship as raw dot notation instead of failing CI. + * + * This is not decoration. The value becomes a `MeasurementReminder.label`, and + * that label IS the push body — so the fallback for a visit with no practice + * attached is read off a lock screen. A raw enum constant there is the schema + * leaking into the product. + */ +import type { EncounterKind } from "@/generated/prisma/client"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; + +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + +export function encounterKindLabel( + kind: EncounterKind, + locale: string | null | undefined, +): string { + const { t } = getServerTranslator(resolveLocale(locale)); + switch (kind) { + case "ROUTINE": + return t("encounters.kind.routine"); + case "ACUTE": + return t("encounters.kind.acute"); + case "SPECIALIST": + return t("encounters.kind.specialist"); + case "PREVENTIVE": + return t("encounters.kind.preventive"); + case "EMERGENCY": + return t("encounters.kind.emergency"); + case "HOSPITAL": + return t("encounters.kind.hospital"); + case "THERAPY": + return t("encounters.kind.therapy"); + default: + return t("encounters.kind.other"); + } +} diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts new file mode 100644 index 000000000..b1078ff8f --- /dev/null +++ b/src/lib/encounters/service.ts @@ -0,0 +1,439 @@ +/** + * The work the encounter routes share: resolving a visit, wiring its links, + * and keeping its appointment reminder in step with its status. + * + * Lives beside the routes rather than inside one of them because create and + * edit both do most of it, and a copy in each is how the two drift. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +import { prisma } from "@/lib/db"; +import { + satisfyReminder, + type SatisfiableReminder, +} from "@/lib/measurement-reminders/satisfy"; +import { listTargets, replaceTargets, type LinkTargetKind } from "@/lib/links"; +import { grantCoversDomain } from "@/lib/sharing/grants"; +import type { ShareDomain } from "@/lib/sharing/scope"; +import type { EncounterLinksDTO } from "@/lib/encounters/dto"; +import { + deleteAppointmentReminder, + disableAppointmentReminder, + mintAppointmentReminder, + reanchorAppointmentReminder, + type AppointmentReminderInput, +} from "@/lib/encounters/appointment-reminder"; + +const DEFAULT_TIMEZONE = "Europe/Berlin"; + +export async function resolveTimezone(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + return row?.timezone || DEFAULT_TIMEZONE; +} + +/** + * The record owner's zone AND language, read together. + * + * Both are properties of the RECORD rather than of whoever is acting on it: a + * delegate booking an appointment in somebody else's record produces a nudge + * that rings on the owner's phone, at the owner's hour, in the owner's + * language. Reading the actor's locale here would localise a stranger's + * notification into the helper's tongue. + */ +export async function resolveOwnerNotificationContext( + userId: string, +): Promise<{ timezone: string; locale: string | null }> { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true, locale: true }, + }); + return { + timezone: row?.timezone || DEFAULT_TIMEZONE, + locale: row?.locale ?? null, + }; +} + +/** The relations the detail and list responses resolve. */ +export const ENCOUNTER_INCLUDE = { + practitioner: true, + reminder: { select: { nextDueAt: true } }, +} as const satisfies Prisma.EncounterInclude; + +/** + * Which sections of the record the caller is actually inside. + * + * Returns a predicate rather than a set so the owner path costs nothing: with + * no grant there is nothing to look up and every domain is open. A grant that + * vanished between the auth check and here answers "no" to everything, which + * is the fail-closed direction. + */ +export async function actingDomainVisibility( + tx: Prisma.TransactionClient, + grantId: string | null, +): Promise<(domain: ShareDomain) => boolean> { + if (grantId === null) return () => true; + const grant = await tx.accountGrant.findUnique({ + where: { id: grantId }, + select: { scopeJson: true }, + }); + if (!grant) return () => false; + return (domain) => grantCoversDomain(grant, domain); +} + +/** Which section of the record each link family's TARGET belongs to. */ +const TARGET_DOMAIN: Record = { + document: "documents", + labResult: "labs", + conditionEpisode: "illness", +}; + +/** + * The three link families, resolved for one visit and redacted per family. + * + * A visit is a seam between four domains: it lives in the health background + * and points at the vault, the lab results and the illness journal. A grant + * that opened only the health background was never consent for what is on the + * other side of that seam — and while the VALUES never crossed it (the list + * carries a label and a date, not a document body or a result), the names are + * themselves the sensitive part. "Oncology discharge letter" and "Ferritin, + * 30 June" say most of what the person was worried about. + * + * So the name and the date are withheld per family, and the LINK is not: the + * caller may know the visit produced a lab result, because the visit is theirs + * to read. `label: null` rather than an invented placeholder, so an absence + * reads as an absence; the array an entry sits in already carries the kind, so + * a client renders "a linked lab result" from its own bundle without the + * server shipping prose down the wire. + * + * Owners and entire-record delegates see everything, so the ordinary case is + * untouched. + */ +export async function loadEncounterLinks( + tx: Prisma.TransactionClient, + userId: string, + encounterId: string, + visible: (domain: ShareDomain) => boolean, +): Promise { + const read = async (targetKind: LinkTargetKind) => { + const rows = await listTargets(tx, { + userId, + sourceKind: "encounter", + sourceId: encounterId, + targetKind, + }); + if (visible(TARGET_DOMAIN[targetKind])) { + return rows.map((row) => ({ ...row, redacted: false })); + } + return rows.map((row) => ({ + id: row.id, + label: null, + date: null, + redacted: true, + })); + }; + const [documents, labResults, conditions] = await Promise.all([ + read("document"), + read("labResult"), + read("conditionEpisode"), + ]); + return { documents, labResults, conditions }; +} + +/** + * Apply whichever of the three link arrays the request actually named. + * + * An absent array leaves that family alone; a present one replaces it, empty + * array included. Unknown ids are dropped by the link service and never block + * the save — a visit is never refused because one document it pointed at has + * since been deleted. + */ +export async function applyEncounterLinks( + tx: Prisma.TransactionClient, + userId: string, + encounterId: string, + arrays: { + documentIds?: string[]; + labResultIds?: string[]; + episodeIds?: string[]; + }, +): Promise { + const families: Array<[LinkTargetKind, string[] | undefined]> = [ + ["document", arrays.documentIds], + ["labResult", arrays.labResultIds], + ["conditionEpisode", arrays.episodeIds], + ]; + for (const [targetKind, targetIds] of families) { + if (targetIds === undefined) continue; + await replaceTargets(tx, { + userId, + sourceKind: "encounter", + sourceId: encounterId, + targetKind, + targetIds, + }); + } +} + +/** + * Whether a visit in this state, at this instant, should be nudging. + * + * Only a PLANNED visit in the future does. A visit that already happened needs + * no reminder, and a terminal one must not produce a nudge for an appointment + * that is not going to occur. + */ +export function shouldHaveReminder( + status: string, + occurredAt: Date, + now: Date, +): boolean { + return status === "PLANNED" && occurredAt.getTime() > now.getTime(); +} + +export interface ReminderSyncInput extends AppointmentReminderInput { + status: string; + /** + * The APPOINTMENT reminder this visit already owns, or null. + * + * Never a checkup the visit closes. `Encounter.reminderId` carries either + * one, and the two are kept apart by their origin: an appointment reminder + * is `ENCOUNTER`, a checkup is not. Confusing them would re-anchor a + * person's own Vorsorge cadence onto an appointment date, which is why the + * routes resolve the origin before calling this. + */ + existingReminderId: string | null; +} + +/** + * Bring the visit's appointment reminder in line with the visit. + * + * Returns the id to store on `Encounter.reminderId` — the existing one, a + * freshly minted one, or the existing one left in place but switched off. + * Never mints a second row for a visit that already has one. + */ +export async function syncAppointmentReminder( + tx: Prisma.TransactionClient, + input: ReminderSyncInput, + timezone: string, + now: Date, +): Promise { + const wanted = shouldHaveReminder(input.status, input.occurredAt, now); + + if (!wanted) { + // The row stays — the history of a cancelled appointment is worth keeping — + // but it stops nudging. + if (input.existingReminderId) { + await disableAppointmentReminder(tx, input.existingReminderId); + } + return input.existingReminderId; + } + + if (input.existingReminderId) { + await reanchorAppointmentReminder( + tx, + input.existingReminderId, + input, + timezone, + ); + return input.existingReminderId; + } + + return mintAppointmentReminder(tx, input, timezone); +} + +/** + * The checkup a visit says it closes, re-narrowed to the caller. + * + * Refuses an `ENCOUNTER`-origin row outright. Those belong to a booked visit + * and are not a checkup anybody can close by filing one; accepting it here + * would let a client re-point one visit's appointment reminder at another + * visit and re-anchor it on the wrong date. + * + * Returns `undefined` when the id names nothing closeable. + */ +export async function resolveClosableReminder( + tx: Prisma.TransactionClient, + userId: string, + reminderId: string, +): Promise { + const row = await tx.measurementReminder.findUnique({ + where: { id: reminderId }, + select: { + id: true, + userId: true, + origin: true, + deletedAt: true, + intervalDays: true, + rrule: true, + anchorDate: true, + notifyHour: true, + lastSatisfiedAt: true, + createdAt: true, + }, + }); + if (!row || row.userId !== userId || row.deletedAt !== null) return undefined; + if (row.origin === "ENCOUNTER") return undefined; + return { + id: row.id, + intervalDays: row.intervalDays, + rrule: row.rrule, + anchorDate: row.anchorDate, + notifyHour: row.notifyHour, + lastSatisfiedAt: row.lastSatisfiedAt, + createdAt: row.createdAt, + }; +} + +/** + * Close the checkup a completed visit was filed against. + * + * `satisfyReminder` is the one shared primitive the cron, the ingest worker + * and the explicit user action all route through. There is no second + * mechanism; the only decision here is WHICH INSTANT to satisfy at. + * + * It is the visit's own instant, bounded by now — not `now`. The plan said + * `now`, and two things it did not anticipate say otherwise: + * + * - **Anchoring.** A yearly panel drawn thirty days ago is next due 335 days + * from today, not 365. Satisfying at `now` moves every backdated entry's + * next due date forward by however long the person took to record it, so + * the more honest they are about the date, the more wrong the cadence gets. + * - **Re-transitions.** Correcting a mis-filed visit — DONE, then NO_SHOW, + * then DONE again — is a transition each way, so the status gate alone + * does not stop a second satisfy. At `now` the second one is strictly + * later and advances the cadence again; at the visit instant it is not + * strictly after the first, and the primitive's forward-only guard makes + * it the no-op it should always have been. + * + * Bounded by `now` because a visit can be booked into the future: a + * PLANNED-to-DONE correction on tomorrow's appointment must not stamp a + * satisfy the engine would read as ahead of its own clock. + * + * This also matches how the rest of the engine is fed — the cron and the + * ingest worker pass the EVENT instant, not the moment they happened to run. + */ +export async function closeCheckupForVisit( + tx: Prisma.TransactionClient, + reminder: SatisfiableReminder, + timezone: string, + occurredAt: Date, + now: Date, +): Promise { + const satisfiedAt = occurredAt.getTime() < now.getTime() ? occurredAt : now; + await satisfyReminder( + tx as unknown as PrismaClient, + reminder, + timezone, + satisfiedAt, + ); +} + +/** + * The live appointment reminder this visit owns, found by the relation rather + * than by reading the visit's foreign key. + * + * The distinction is the whole of the orphan defect. Reading `reminderId` and + * checking its origin answers "what does this visit point at", which stops + * being the same question the moment the column is re-pointed at a checkup: + * the appointment row is still live, still armed, and now invisible to the + * code that owns it, so the next reschedule mints a second one. Asking the + * engine table which of ITS rows this visit owns cannot drift that way, and it + * filters the origin and the tombstone in SQL instead of in a branch. + */ +export async function findOwnAppointmentReminderId( + tx: Prisma.TransactionClient, + userId: string, + encounterId: string, +): Promise { + const row = await tx.measurementReminder.findFirst({ + where: { + userId, + origin: "ENCOUNTER", + deletedAt: null, + encounters: { some: { id: encounterId } }, + }, + select: { id: true }, + }); + return row?.id ?? null; +} + +/** + * Retire every live appointment reminder this account owns that no visit + * points at. + * + * A reminder nobody points at still fires — the cron reads the engine table, + * not the visit list — so an orphan pushes notifications for an appointment + * the person can no longer see, edit or cancel. The refusal in the edit route + * stops the one sequence that produced them; this makes the state itself + * unreachable, so a future path that finds a new way to strand one cannot + * leave it armed. + * + * A soft-deleted VISIT still points at its reminder, so this never reaps a + * deleted visit's row — that retirement belongs to the delete route, which + * knows it is deliberate. + */ +export async function retireOrphanedAppointmentReminders( + tx: Prisma.TransactionClient, + userId: string, + at: Date, +): Promise { + const result = await tx.measurementReminder.updateMany({ + where: { + userId, + origin: "ENCOUNTER", + deletedAt: null, + encounters: { none: {} }, + }, + data: { deletedAt: at, enabled: false, nextDueAt: null }, + }); + return result.count; +} + +/** + * Retire the appointment reminder a visit owns, alongside the visit. + * + * Takes the visit rather than a reminder id, and resolves the row by the + * relation: deleting a visit must reach the reminder it owns even if its + * foreign key is pointing somewhere else. + */ +export async function retireAppointmentReminderForVisit( + tx: Prisma.TransactionClient, + userId: string, + encounterId: string, + at: Date, +): Promise { + const reminderId = await findOwnAppointmentReminderId( + tx, + userId, + encounterId, + ); + if (!reminderId) return; + await deleteAppointmentReminder(tx, reminderId, at); +} + +/** + * The practitioner a visit names, re-narrowed to the caller. + * + * Returns `undefined` when the id names nothing this account owns, which the + * routes turn into a 404 — the same refusal shape a foreign encounter id gets. + */ +export async function resolveOwnedPractitioner( + tx: Prisma.TransactionClient, + userId: string, + practitionerId: string, +): Promise<{ id: string; name: string; location: string | null } | undefined> { + const row = await tx.practitioner.findUnique({ + where: { id: practitionerId }, + select: { + id: true, + name: true, + location: true, + userId: true, + deletedAt: true, + }, + }); + if (!row || row.userId !== userId || row.deletedAt !== null) return undefined; + return { id: row.id, name: row.name, location: row.location }; +} diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index e55bf7b69..8380077ea 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -247,6 +247,14 @@ function makePrisma() { ]), }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, + // The visit tables. Empty here: this file asserts the sections it seeds, + // and an account with no visits is the ordinary case the builder must + // still answer for. + practitioner: { findMany: vi.fn().mockResolvedValue([]) }, + encounter: { findMany: vi.fn().mockResolvedValue([]) }, + encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, + encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 84bc08a66..ac3e0cdc5 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -135,6 +135,13 @@ export const BACKED_UP_MODELS = [ "DocumentConditionLink", "ExtractedFact", + // ── Visits ──────────────────────────────────────────────────────────────── + "Practitioner", + "Encounter", + "EncounterDocumentLink", + "EncounterLabLink", + "EncounterConditionLink", + // ── Coach ───────────────────────────────────────────────────────────────── "CoachConversation", "CoachMessage", @@ -177,6 +184,12 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ "src/lib/export/profile-backup.ts", "src/lib/export/intraday-profile-backup.ts", "src/lib/export/health-score-backup.ts", + // Visits, the address book and the three link tables. Each model reads + // through its OWN delegate here rather than riding a relation, because + // `documentLinks` is a field name on two different models and a + // relation-shaped proof would be attributed to whichever one a matcher found + // first — the `symptomLinks` confusion, avoided by construction. + "src/lib/export/visits-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -185,6 +198,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/profile-backup.ts", "src/lib/export/intraday-profile-backup.ts", "src/lib/export/health-score-backup.ts", + "src/lib/export/visits-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -237,6 +251,17 @@ export const TWO_ENDED_MODELS = [ "Workout", "NutrientIntakeDay", "InboundDocument", + // Visits travel both ways from the release that introduces them. Two + // COVERAGE_PENDING neighbours already say what the alternative costs: + // reminders restore silent, and the filing between documents and conditions + // is lost. Leaving the visit links pending would return visits and documents + // with nothing between them — the same loss, one layer deeper — so the links + // land carried rather than owed. + "Practitioner", + "Encounter", + "EncounterDocumentLink", + "EncounterLabLink", + "EncounterConditionLink", ] as const; /** One model claimed to travel both ways. */ diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 8a64e91ef..f587439c6 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -37,6 +37,12 @@ import { type HealthScoreBackupCounts, type HealthScoreBackupSection, } from "@/lib/export/health-score-backup"; +import { + buildVisitsBackupSection, + countVisitsBackupSection, + type VisitsBackupCounts, + type VisitsBackupSection, +} from "@/lib/export/visits-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -49,7 +55,8 @@ export interface FullBackupCounts RecordsBackupCounts, ProfileBackupCounts, IntradayProfileBackupCounts, - HealthScoreBackupCounts { + HealthScoreBackupCounts, + VisitsBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -261,6 +268,7 @@ export async function buildFullBackupPayload( profile, intradayProfiles, healthScoreRecords, + visits, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -388,6 +396,12 @@ export async function buildFullBackupPayload( buildHealthScoreBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // Visits, the address book behind them, and the three link tables. Both + // ends live in `src/lib/export/visits-backup.ts` beside each other, so a + // grep of the restore route alone will not find them. + buildVisitsBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -414,6 +428,7 @@ export async function buildFullBackupPayload( const profileSection: ProfileBackupSection = profile; const intradaySection: IntradayProfileBackupSection = intradayProfiles; const healthScoreSection: HealthScoreBackupSection = healthScoreRecords; + const visitsSection: VisitsBackupSection = visits; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -643,6 +658,7 @@ export async function buildFullBackupPayload( ...profileSection, ...intradaySection, ...healthScoreSection, + ...visitsSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -670,6 +686,7 @@ export async function buildFullBackupPayload( ...countProfileBackupSection(profile), ...countIntradayProfileBackupSection(intradayProfiles), ...countHealthScoreBackupSection(healthScoreRecords), + ...countVisitsBackupSection(visits), }, }; } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 326ea093b..b13148f07 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -28,9 +28,33 @@ * edge rather than a record. */ -/** Which seeded catalogue a key failed to resolve against. */ +/** + * Which lookup a key failed to resolve against. + * + * Four are seeded catalogues, as described above. The fifth, `visitReference`, + * is a different kind of unresolvable and is here rather than in its own + * mechanism because the cost and the honest response are identical: an edge is + * lost, the record it hangs off survives, and the operator is told which one. + * What it names is a row the file pointed at that the restore did not put + * back — a reminder, which is on the coverage-pending register and never + * travels, or a document, lab result or condition episode whose id a portable + * export does not carry. + * + * The sixth, `checkupClosure`, is not about a restore at all, and it borrows + * this shape deliberately rather than growing a second reporting mechanism + * beside it. The situation is the same one: something a write was asked to do + * could not be done, the record itself survives, and the person is told which + * one rather than left to assume it worked. It is filed when a delegate files + * a visit against a preventive-care checkup their grant does not reach — the + * visit saves, the checkup stays due, and the response says so. + */ export type SkippedCatalogue = - "cycleSymptom" | "illnessSymptom" | "moodFactor" | "moodTag"; + | "cycleSymptom" + | "illnessSymptom" + | "moodFactor" + | "moodTag" + | "visitReference" + | "checkupClosure"; /** One key this instance does not know, and the links it cost. */ export interface SkippedCatalogueKey { diff --git a/src/lib/export/visits-backup.ts b/src/lib/export/visits-backup.ts new file mode 100644 index 000000000..4b5eae712 --- /dev/null +++ b/src/lib/export/visits-backup.ts @@ -0,0 +1,573 @@ +/** + * Visits and the address book behind them, with both backup ends in one file. + * + * The builder and the restore live together deliberately, the way + * `health-score-backup.ts` and `src/lib/cycle/backup.ts` do: a reader asking + * "is this carried at both ends?" answers it here, and a reader who greps only + * the restore ROUTE gets a false negative, because the route delegates. + * + * Five models ride: the practitioners, the encounters, and the three link + * tables between an encounter and a document, a lab result or a condition + * episode. All five are carried rather than owed. Two neighbours on the debt + * register already say what the alternative costs — reminders restore silent, + * and the filing between documents and conditions is lost — and a restore that + * returned visits and documents with nothing between them would be the same + * loss one layer deeper. + * + * Every read is scoped to the owner. Each model uses its own named select + * constant and its own delegate call rather than riding a parent's `include`: + * `documentLinks` is a relation field name on two different models, so a + * relation-shaped write here would be attributed to whichever of them a + * matcher happened to find first. Delegate calls cannot be confused that way. + * + * Two references deliberately do not survive a restore, and both are reported + * rather than dropped: + * + * - `Encounter.reminderId` points at a `MeasurementReminder`, which is on the + * coverage-pending register and does not come back. The column resolves to + * NULL, never to a dangling id. + * - A link whose far side was not restored. A portable export carries no lab + * result ids, so a lab link in one cannot resolve; a disaster-recovery + * payload carries every id and they all do. + */ +import { Buffer } from "node:buffer"; +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; +import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client"; + +import { + recordUnknownKeys, + type RestoreSkipLog, +} from "@/lib/export/restore-skips"; + +export interface VisitsBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** One entry in the account's own address book of doctors and practices. */ +export interface PractitionerBackupEntry { + /** Always carried: an encounter references a practitioner by id and nothing + * else about the row is unique enough to rebuild the reference from. */ + id: string; + name: string; + specialty: string | null; + practice: string | null; + location: string | null; + phone: string | null; + /** Base64 ciphertext, carried verbatim — never decrypted into the file. */ + noteEncrypted: string | null; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; +} + +/** One contact with the healthcare system: happened, or booked. */ +export interface EncounterBackupEntry { + /** Always carried, for the same reason as the practitioner id above: the + * three link tables address an encounter by it. */ + id: string; + occurredAt: string; + status: EncounterStatus; + kind: EncounterKind; + practitionerId: string | null; + reasonEncrypted: string | null; + outcomeEncrypted: string | null; + /** + * Carried so the loss is visible in the file rather than inferred from its + * absence. The restore resolves it to NULL and names the drop: the reminder + * it points at is not in the backup. + */ + reminderId: string | null; + createdAt: string; + updatedAt: string; + deletedAt?: string | null; +} + +/** One edge from a visit to something it produced. */ +export interface EncounterLinkBackupEntry { + encounterId: string; + targetId: string; + createdAt: string; +} + +export interface VisitsBackupSection { + practitioners: PractitionerBackupEntry[]; + encounters: EncounterBackupEntry[]; + encounterDocumentLinks: EncounterLinkBackupEntry[]; + encounterLabLinks: EncounterLinkBackupEntry[]; + encounterConditionLinks: EncounterLinkBackupEntry[]; +} + +export interface VisitsBackupCounts { + practitioners: number; + encounters: number; + encounterLinks: number; +} + +/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */ +function encodeCiphertext(value: Uint8Array | null): string | null { + if (!value || value.byteLength === 0) return null; + return Buffer.from(value).toString("base64"); +} + +/** + * Named select constants, one per model. + * + * Named rather than inline because a structural matcher binds a model to the + * literal beside its delegate call, and an inline object inside a `Promise.all` + * is a shape earlier work in this repository had to refactor away from for + * exactly that reason. + */ +const PRACTITIONER_BACKUP_SELECT = { + id: true, + name: true, + specialty: true, + practice: true, + location: true, + phone: true, + noteEncrypted: true, + createdAt: true, + updatedAt: true, + deletedAt: true, +} as const; + +const ENCOUNTER_BACKUP_SELECT = { + id: true, + occurredAt: true, + status: true, + kind: true, + practitionerId: true, + reasonEncrypted: true, + outcomeEncrypted: true, + reminderId: true, + createdAt: true, + updatedAt: true, + deletedAt: true, +} as const; + +const ENCOUNTER_DOCUMENT_LINK_BACKUP_SELECT = { + encounterId: true, + documentId: true, + createdAt: true, +} as const; + +const ENCOUNTER_LAB_LINK_BACKUP_SELECT = { + encounterId: true, + labResultId: true, + createdAt: true, +} as const; + +const ENCOUNTER_CONDITION_LINK_BACKUP_SELECT = { + encounterId: true, + episodeId: true, + createdAt: true, +} as const; + +/** + * Build the visits slice of a user's full backup. + * + * Takes the delegates it uses rather than a whole client, matching the other + * section builders. A portable export omits tombstones so a restore cannot + * resurrect them; a disaster-recovery payload keeps them so the account comes + * back as itself. + */ +export async function buildVisitsBackupSection( + prisma: Pick< + PrismaClient, + | "practitioner" + | "encounter" + | "encounterDocumentLink" + | "encounterLabLink" + | "encounterConditionLink" + >, + userId: string, + options: VisitsBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + const [ + practitionerRows, + encounterRows, + documentLinkRows, + labLinkRows, + conditionLinkRows, + ] = await Promise.all([ + prisma.practitioner.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + orderBy: { name: "asc" }, + select: PRACTITIONER_BACKUP_SELECT, + }), + prisma.encounter.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + orderBy: { occurredAt: "desc" }, + select: ENCOUNTER_BACKUP_SELECT, + }), + prisma.encounterDocumentLink.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: ENCOUNTER_DOCUMENT_LINK_BACKUP_SELECT, + }), + prisma.encounterLabLink.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: ENCOUNTER_LAB_LINK_BACKUP_SELECT, + }), + prisma.encounterConditionLink.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: ENCOUNTER_CONDITION_LINK_BACKUP_SELECT, + }), + ]); + + return { + practitioners: practitionerRows.map((row) => ({ + id: row.id, + name: row.name, + specialty: row.specialty, + practice: row.practice, + location: row.location, + phone: row.phone, + noteEncrypted: encodeCiphertext(row.noteEncrypted), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(disasterRecovery + ? { deletedAt: row.deletedAt?.toISOString() ?? null } + : {}), + })), + encounters: encounterRows.map((row) => ({ + id: row.id, + occurredAt: row.occurredAt.toISOString(), + status: row.status, + kind: row.kind, + practitionerId: row.practitionerId, + reasonEncrypted: encodeCiphertext(row.reasonEncrypted), + outcomeEncrypted: encodeCiphertext(row.outcomeEncrypted), + reminderId: row.reminderId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(disasterRecovery + ? { deletedAt: row.deletedAt?.toISOString() ?? null } + : {}), + })), + encounterDocumentLinks: documentLinkRows.map((row) => ({ + encounterId: row.encounterId, + targetId: row.documentId, + createdAt: row.createdAt.toISOString(), + })), + encounterLabLinks: labLinkRows.map((row) => ({ + encounterId: row.encounterId, + targetId: row.labResultId, + createdAt: row.createdAt.toISOString(), + })), + encounterConditionLinks: conditionLinkRows.map((row) => ({ + encounterId: row.encounterId, + targetId: row.episodeId, + createdAt: row.createdAt.toISOString(), + })), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countVisitsBackupSection( + section: VisitsBackupSection, +): VisitsBackupCounts { + return { + practitioners: section.practitioners.length, + encounters: section.encounters.length, + encounterLinks: + section.encounterDocumentLinks.length + + section.encounterLabLinks.length + + section.encounterConditionLinks.length, + }; +} + +/** Counts the visit restore wiped, for the audit trail. */ +export interface VisitsRestoreCleared { + practitioners: number; + encounters: number; + encounterLinks: number; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: the payload schema defaults every key, so + * every caller already satisfies this, and one that stops satisfying it fails + * to compile instead of passing `undefined` into a loop that iterates zero + * times and reports success. + */ +export interface VisitsRestoreInput { + practitioners: RestoredPractitioner[]; + encounters: RestoredEncounter[]; + encounterDocumentLinks: EncounterLinkBackupEntry[]; + encounterLabLinks: EncounterLinkBackupEntry[]; + encounterConditionLinks: EncounterLinkBackupEntry[]; +} + +/** + * What the restore reads, as the parsed file actually presents it. + * + * The builder's entry types above describe what this release WRITES. These + * describe what a restore may be handed, which is a wider set: a portable + * export omits the tombstone, and every optional column in the payload schema + * arrives as `undefined` rather than `null`. Modelling the read side as the + * write side would compile only because the two happen to agree today, and + * would break the first time an older file reached it. + */ +type OptionalNullable = { [K in keyof T]?: T[K] | undefined }; + +export type RestoredPractitioner = Pick< + PractitionerBackupEntry, + "id" | "name" | "createdAt" | "updatedAt" +> & + OptionalNullable< + Pick< + PractitionerBackupEntry, + "specialty" | "practice" | "location" | "phone" | "noteEncrypted" + > + > & { deletedAt?: string | null }; + +export type RestoredEncounter = Pick< + EncounterBackupEntry, + "id" | "occurredAt" | "status" | "kind" | "createdAt" | "updatedAt" +> & + OptionalNullable< + Pick< + EncounterBackupEntry, + "practitionerId" | "reasonEncrypted" | "outcomeEncrypted" | "reminderId" + > + > & { deletedAt?: string | null }; + +function decodeCiphertext(encoded: string): Uint8Array { + const decoded = Buffer.from(encoded, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; +} + +/** + * Re-create the account's visits, address book and link rows. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. Parents first — practitioners, then encounters, then the links — + * because a link addresses both of its ends by id. + * + * MUST be called after the documents, lab results and condition episodes have + * been restored: a link is written only when both of its ends exist, and the + * existence check is what decides. Calling it earlier would report every link + * as unresolvable and be counted as a successful restore of nothing. + */ +export async function restoreVisitsData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: VisitsRestoreInput, + skips: RestoreSkipLog, +): Promise { + const clearedLinks = + (await tx.encounterDocumentLink.deleteMany({ where: { userId: ownerId } })) + .count + + (await tx.encounterLabLink.deleteMany({ where: { userId: ownerId } })) + .count + + (await tx.encounterConditionLink.deleteMany({ where: { userId: ownerId } })) + .count; + const clearedEncounters = await tx.encounter.deleteMany({ + where: { userId: ownerId }, + }); + const clearedPractitioners = await tx.practitioner.deleteMany({ + where: { userId: ownerId }, + }); + + if (payload.practitioners.length > 0) { + await tx.practitioner.createMany({ + data: payload.practitioners.map((entry) => ({ + id: entry.id, + userId: ownerId, + name: entry.name, + specialty: entry.specialty ?? null, + practice: entry.practice ?? null, + location: entry.location ?? null, + phone: entry.phone ?? null, + noteEncrypted: + entry.noteEncrypted == null + ? null + : decodeCiphertext(entry.noteEncrypted), + createdAt: new Date(entry.createdAt), + updatedAt: new Date(entry.updatedAt), + deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + })), + }); + } + + // A practitioner the file references but does not carry cannot be invented, + // and a dangling id would fail the foreign key and roll the whole restore + // back over one visit. The reference is dropped to NULL instead — the visit + // itself, its date, its reason and its outcome all survive. + const restoredPractitioners = new Set( + payload.practitioners.map((entry) => entry.id), + ); + const droppedPractitioners: string[] = []; + + // The reminder is on the coverage-pending register: it is not in the file at + // all, so every reference to one is a known, reportable drop. + const droppedReminders: string[] = []; + + if (payload.encounters.length > 0) { + await tx.encounter.createMany({ + data: payload.encounters.map((entry) => { + const practitionerId = + entry.practitionerId && + restoredPractitioners.has(entry.practitionerId) + ? entry.practitionerId + : null; + if (entry.practitionerId && practitionerId === null) { + droppedPractitioners.push(entry.practitionerId); + } + if (entry.reminderId) droppedReminders.push(entry.reminderId); + return { + id: entry.id, + userId: ownerId, + occurredAt: new Date(entry.occurredAt), + status: entry.status, + kind: entry.kind, + practitionerId, + reasonEncrypted: + entry.reasonEncrypted == null + ? null + : decodeCiphertext(entry.reasonEncrypted), + outcomeEncrypted: + entry.outcomeEncrypted == null + ? null + : decodeCiphertext(entry.outcomeEncrypted), + // Never the id the file carried: the row it named is not restored. + reminderId: null, + createdAt: new Date(entry.createdAt), + updatedAt: new Date(entry.updatedAt), + deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + }; + }), + }); + } + + recordUnknownKeys( + skips, + "visitReference", + [...new Set(droppedPractitioners)], + droppedPractitioners, + ); + recordUnknownKeys( + skips, + "visitReference", + [...new Set(droppedReminders)], + droppedReminders, + ); + + const restoredEncounters = new Set(payload.encounters.map((e) => e.id)); + + /** + * Write the links whose far side actually came back, and name the rest. + * + * The far side is checked against the database rather than against the + * payload, because the document, the lab result and the episode are restored + * by other branches in the same transaction and this is the only place that + * can see all three. + */ + async function restoreLinks( + entries: EncounterLinkBackupEntry[], + existingTargetIds: Set, + write: ( + rows: Array<{ + encounterId: string; + targetId: string; + createdAt: Date; + }>, + ) => Promise, + ): Promise { + const writable: Array<{ + encounterId: string; + targetId: string; + createdAt: Date; + }> = []; + const dropped: string[] = []; + for (const entry of entries) { + if ( + restoredEncounters.has(entry.encounterId) && + existingTargetIds.has(entry.targetId) + ) { + writable.push({ + encounterId: entry.encounterId, + targetId: entry.targetId, + createdAt: new Date(entry.createdAt), + }); + } else { + dropped.push(entry.targetId); + } + } + if (writable.length > 0) await write(writable); + recordUnknownKeys(skips, "visitReference", [...new Set(dropped)], dropped); + } + + const [documentIds, labResultIds, episodeIds] = await Promise.all([ + tx.inboundDocument.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + tx.labResult.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + tx.illnessEpisode.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + ]); + + await restoreLinks( + payload.encounterDocumentLinks, + new Set(documentIds.map((row) => row.id)), + async (rows) => { + await tx.encounterDocumentLink.createMany({ + data: rows.map((row) => ({ + userId: ownerId, + encounterId: row.encounterId, + documentId: row.targetId, + createdAt: row.createdAt, + })), + }); + }, + ); + + await restoreLinks( + payload.encounterLabLinks, + new Set(labResultIds.map((row) => row.id)), + async (rows) => { + await tx.encounterLabLink.createMany({ + data: rows.map((row) => ({ + userId: ownerId, + encounterId: row.encounterId, + labResultId: row.targetId, + createdAt: row.createdAt, + })), + }); + }, + ); + + await restoreLinks( + payload.encounterConditionLinks, + new Set(episodeIds.map((row) => row.id)), + async (rows) => { + await tx.encounterConditionLink.createMany({ + data: rows.map((row) => ({ + userId: ownerId, + encounterId: row.encounterId, + episodeId: row.targetId, + createdAt: row.createdAt, + })), + }); + }, + ); + + return { + practitioners: clearedPractitioners.count, + encounters: clearedEncounters.count, + encounterLinks: clearedLinks, + }; +} diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index ba63d860c..062d5b5ef 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -271,10 +271,17 @@ export async function readPreventiveCareBlock( ): Promise { const horizon = new Date(now + PREVENTIVE_DUE_HORIZON_DAYS * MS_PER_DAY); const rows = await prisma.measurementReminder.findMany({ + // A booked visit's one-shot reminder rides this same engine with + // `origin: ENCOUNTER`. It is not a checkup and must not appear on a + // Vorsorge surface, or the list fills with appointments. Four read + // sites carry this exclusion; `encounter-reminder-exclusion.test.ts` + // proves every one of them, and the DTO mapper refuses such a row + // outright so a site that lost its filter fails loudly. where: { userId, deletedAt: null, enabled: true, + origin: { not: "ENCOUNTER" }, nextDueAt: { not: null, lte: horizon }, }, orderBy: { nextDueAt: "asc" }, diff --git a/src/lib/links/index.ts b/src/lib/links/index.ts new file mode 100644 index 000000000..3de5c1e27 --- /dev/null +++ b/src/lib/links/index.ts @@ -0,0 +1,49 @@ +/** + * One service for every link between two of an account's own records. + * + * Two families live behind it today: a visit's links to the documents, lab + * results and conditions it produced, and a document's links to the conditions + * it is filed against. They are separate tables and one call signature. + * + * The reason for the shape rather than four more call sites: the standing + * lesson from the polymorphic-linking work is "one shared service", and this + * release adds three narrow link tables, which is a bounded step the other way. + * Bounded by this module. Every caller speaks one vocabulary — a source, a + * target kind and a list of target ids — so replacing the storage underneath + * later is a change here and not a sweep across every route that files + * something. + * + * Rules the module owns, so no call site re-implements them: + * + * - **Ownership is verified here.** Both ends are re-read scoped by `userId` + * before a row is written. Passing another account's document id links + * nothing; the id comes back in `unknownTargetIds` and the caller decides + * whether that is a refusal or a silent no-op. + * - **Idempotent.** Linking a pair that already exists succeeds and changes + * nothing, matching the document bulk route's behaviour today. + * - **Bounded.** At most {@link MAX_TARGETS_PER_CALL} ids per call. + * - **Soft-deleted rows are not linkable**, on either end. A tombstoned + * document cannot be filed against a visit, and a deleted visit cannot + * collect new links. + * + * One documented exemption: `src/lib/export/visits-backup.ts` writes the link + * tables directly. A restore is rebuilding an account rather than filing + * something in one — it preserves ids and creation instants, and it must not + * re-run an ownership narrowing against rows it is in the middle of creating. + * It is the same exemption the wipe plan and the key rotation hold, for the + * same reason, and it is the only one. + */ +export { + MAX_TARGETS_PER_CALL, + linkTargets, + unlinkTargets, + replaceTargets, + listTargets, + listTargetsBySource, + listDistinctTargets, + type LinkSourceKind, + type LinkTargetKind, + type LinkRequest, + type LinkResult, + type LinkedTarget, +} from "@/lib/links/link-service"; diff --git a/src/lib/links/link-service.ts b/src/lib/links/link-service.ts new file mode 100644 index 000000000..e52c226d3 --- /dev/null +++ b/src/lib/links/link-service.ts @@ -0,0 +1,561 @@ +/** + * The link service itself. See `index.ts` for why it exists and what it + * guarantees; this file is the implementation and the table of link kinds. + */ +import type { Prisma } from "@/generated/prisma/client"; + +/** + * A bound matching the document bulk route's own cap. A link call is a + * user-driven filing action, not an import path, so a request naming more ids + * than this is a client defect rather than a large but legitimate batch. + */ +export const MAX_TARGETS_PER_CALL = 100; + +/** The record a link hangs off. */ +export type LinkSourceKind = "encounter" | "document"; + +/** The record a link points at. */ +export type LinkTargetKind = "document" | "labResult" | "conditionEpisode"; + +export interface LinkRequest { + userId: string; + sourceKind: LinkSourceKind; + sourceId: string; + targetKind: LinkTargetKind; + targetIds: string[]; +} + +export interface LinkResult { + /** + * Rows actually written or removed. + * + * Zero is a success, not a failure: linking a pair that already exists, or + * unlinking one that never did, is the idempotent no-op the callers rely on. + */ + changed: number; + /** + * Ids that named nothing this account owns, or a row it has deleted. + * + * Returned rather than thrown. A bulk filing action treats one unknown id as + * one failed row and keeps going; an upload treats it as a refusal of the + * whole request. Both are legitimate and neither belongs in here. + */ + unknownTargetIds: string[]; + /** True when the source itself is unknown, foreign or deleted. */ + unknownSource: boolean; +} + +/** One resolved link target, ready for a DTO. Never just an id. */ +export interface LinkedTarget { + id: string; + label: string; + /** The date that makes the target sortable and recognisable, ISO-8601. */ + date: string | null; +} + +/** + * Prisma's generated delegates are structurally compatible with the narrow + * shapes below, but each model's argument types are nominally distinct, so a + * table keyed by link kind cannot be written without one narrowing per + * accessor. The casts are confined to {@link LINK_TABLES} and every call in + * this file goes through it. + */ +interface LinkRowDelegate { + createMany(args: { + data: Array>; + skipDuplicates?: boolean; + }): Promise<{ count: number }>; + deleteMany(args: { + where: Record; + }): Promise<{ count: number }>; + findMany(args: { + where: Record; + select: Record>; + orderBy?: Record; + }): Promise>>; +} + +interface OwnedRowDelegate { + findMany(args: { + where: Record; + select: Record; + }): Promise>>; +} + +interface EndpointSpec { + /** The owning model's delegate, for the ownership re-read. */ + delegate: (tx: Prisma.TransactionClient) => OwnedRowDelegate; + /** Columns the label and date are derived from. */ + select: Record; + label: (row: Record) => string; + date: (row: Record) => string | null; +} + +interface LinkTableSpec { + delegate: (tx: Prisma.TransactionClient) => LinkRowDelegate; + sourceColumn: string; + targetColumn: string; + target: EndpointSpec; +} + +/** + * The one narrowing point. + * + * Prisma generates a distinct argument type per model, so a table keyed by + * link kind cannot hold them without erasing the difference. Going through + * `unknown` here rather than at each accessor keeps the unsafety countable: + * every delegate in this file is narrowed by this function and nowhere else, + * and the column names it is narrowed FOR are declared beside it in + * {@link LINK_TABLES}. + */ +function narrow(delegate: unknown): T { + return delegate as T; +} + +function asDate(value: unknown): string | null { + return value instanceof Date ? value.toISOString() : null; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** Where a source record lives, and how to prove the caller owns a live one. */ +const SOURCES: Record< + LinkSourceKind, + { + delegate: (tx: Prisma.TransactionClient) => OwnedRowDelegate; + /** The link row's relation field back to the source, for a joined filter. */ + relationField: string; + } +> = { + encounter: { + delegate: (tx) => narrow(tx.encounter), + relationField: "encounter", + }, + document: { + delegate: (tx) => narrow(tx.inboundDocument), + relationField: "document", + }, +}; + +const DOCUMENT_ENDPOINT: EndpointSpec = { + delegate: (tx) => narrow(tx.inboundDocument), + select: { + id: true, + title: true, + filename: true, + documentDate: true, + reportDate: true, + createdAt: true, + }, + // A vault document may carry neither a title nor a filename; the id is the + // last honest thing left rather than an invented placeholder. + label: (row) => + asString(row.title) ?? asString(row.filename) ?? String(row.id), + date: (row) => + asDate(row.documentDate) ?? asDate(row.reportDate) ?? asDate(row.createdAt), +}; + +const LAB_RESULT_ENDPOINT: EndpointSpec = { + delegate: (tx) => narrow(tx.labResult), + select: { id: true, analyte: true, panel: true, takenAt: true }, + label: (row) => { + const panel = asString(row.panel); + const analyte = String(row.analyte); + return panel ? `${panel} · ${analyte}` : analyte; + }, + date: (row) => asDate(row.takenAt), +}; + +const CONDITION_ENDPOINT: EndpointSpec = { + delegate: (tx) => narrow(tx.illnessEpisode), + select: { id: true, label: true, onsetAt: true }, + label: (row) => String(row.label), + date: (row) => asDate(row.onsetAt), +}; + +/** + * Every legal (source, target) pair and the table behind it. + * + * A pair absent from here is not linkable, and asking for one is a programming + * error rather than a user error — hence the throw in {@link tableFor} rather + * than a result field. Three is the number of encounter link tables this + * design allows; a fourth is the point at which this stops being a small set + * of named relations and starts being a general edge store. + */ +const LINK_TABLES: Partial< + Record<`${LinkSourceKind}:${LinkTargetKind}`, LinkTableSpec> +> = { + "encounter:document": { + delegate: (tx) => narrow(tx.encounterDocumentLink), + sourceColumn: "encounterId", + targetColumn: "documentId", + target: DOCUMENT_ENDPOINT, + }, + "encounter:labResult": { + delegate: (tx) => narrow(tx.encounterLabLink), + sourceColumn: "encounterId", + targetColumn: "labResultId", + target: LAB_RESULT_ENDPOINT, + }, + "encounter:conditionEpisode": { + delegate: (tx) => narrow(tx.encounterConditionLink), + sourceColumn: "encounterId", + targetColumn: "episodeId", + target: CONDITION_ENDPOINT, + }, + "document:conditionEpisode": { + delegate: (tx) => narrow(tx.documentConditionLink), + sourceColumn: "documentId", + targetColumn: "episodeId", + target: CONDITION_ENDPOINT, + }, +}; + +function tableFor( + sourceKind: LinkSourceKind, + targetKind: LinkTargetKind, +): LinkTableSpec { + const spec = LINK_TABLES[`${sourceKind}:${targetKind}`]; + if (!spec) { + throw new Error( + `No link table joins a ${sourceKind} to a ${targetKind}. The legal pairs ` + + `are declared in src/lib/links/link-service.ts; adding one is a schema ` + + `decision, not a call-site decision.`, + ); + } + return spec; +} + +function assertWithinCap(targetIds: string[]): void { + if (targetIds.length > MAX_TARGETS_PER_CALL) { + throw new Error( + `A link call names ${targetIds.length} targets, above the cap of ` + + `${MAX_TARGETS_PER_CALL}.`, + ); + } +} + +/** Does the caller own a LIVE source record with this id? */ +async function ownsSource( + tx: Prisma.TransactionClient, + { + userId, + sourceKind, + sourceId, + }: Omit, +): Promise { + const rows = await SOURCES[sourceKind].delegate(tx).findMany({ + where: { id: sourceId, userId, deletedAt: null }, + select: { id: true }, + }); + return rows.length === 1; +} + +/** The subset of `targetIds` the caller owns and has not deleted. */ +async function ownedTargetIds( + tx: Prisma.TransactionClient, + userId: string, + spec: LinkTableSpec, + targetIds: string[], +): Promise> { + if (targetIds.length === 0) return new Set(); + const rows = await spec.target.delegate(tx).findMany({ + where: { id: { in: targetIds }, userId, deletedAt: null }, + select: { id: true }, + }); + return new Set(rows.map((row) => String(row.id))); +} + +/** + * Split a requested id list into the ones that may be written and the ones + * that named nothing. Deduplicated first: a list naming the same document + * twice is one link, and reporting it as two would overstate what happened. + */ +async function resolveTargets( + tx: Prisma.TransactionClient, + request: LinkRequest, + spec: LinkTableSpec, +): Promise<{ owned: string[]; unknown: string[] }> { + const unique = [...new Set(request.targetIds)]; + const owned = await ownedTargetIds(tx, request.userId, spec, unique); + return { + owned: unique.filter((id) => owned.has(id)), + unknown: unique.filter((id) => !owned.has(id)), + }; +} + +/** + * Add links, leaving any that already exist untouched. + * + * `skipDuplicates` is what makes a re-post a success that changed nothing, + * which is the contract the document bulk route already offers and the one a + * client retrying a request depends on. + */ +export async function linkTargets( + tx: Prisma.TransactionClient, + request: LinkRequest, +): Promise { + assertWithinCap(request.targetIds); + const spec = tableFor(request.sourceKind, request.targetKind); + + if (!(await ownsSource(tx, request))) { + return { changed: 0, unknownTargetIds: [], unknownSource: true }; + } + const { owned, unknown } = await resolveTargets(tx, request, spec); + if (owned.length === 0) { + return { changed: 0, unknownTargetIds: unknown, unknownSource: false }; + } + + const created = await spec.delegate(tx).createMany({ + data: owned.map((targetId) => ({ + [spec.sourceColumn]: request.sourceId, + [spec.targetColumn]: targetId, + userId: request.userId, + })), + skipDuplicates: true, + }); + return { + changed: created.count, + unknownTargetIds: unknown, + unknownSource: false, + }; +} + +/** + * Remove links. + * + * Ownership of the TARGET is deliberately not required here. A document the + * account has since deleted still has a link row, and refusing to unlink it + * would leave a filing nobody can clear. The link rows are scoped by `userId` + * regardless, so nothing another account owns is reachable. + */ +export async function unlinkTargets( + tx: Prisma.TransactionClient, + request: LinkRequest, +): Promise { + assertWithinCap(request.targetIds); + const spec = tableFor(request.sourceKind, request.targetKind); + const unique = [...new Set(request.targetIds)]; + if (unique.length === 0) { + return { changed: 0, unknownTargetIds: [], unknownSource: false }; + } + + const removed = await spec.delegate(tx).deleteMany({ + where: { + userId: request.userId, + [spec.sourceColumn]: request.sourceId, + [spec.targetColumn]: { in: unique }, + }, + }); + return { + changed: removed.count, + unknownTargetIds: [], + unknownSource: false, + }; +} + +/** + * Make the link set exactly `targetIds`: drop what is no longer named, add + * what is missing. + * + * The delete runs before the ownership check on purpose. Clearing a link is + * always allowed (see {@link unlinkTargets}), and doing it first means a + * request naming one unknown id still applies the part of itself that was + * valid, rather than leaving the set in a state the caller did not ask for. + */ +export async function replaceTargets( + tx: Prisma.TransactionClient, + request: LinkRequest, +): Promise { + assertWithinCap(request.targetIds); + const spec = tableFor(request.sourceKind, request.targetKind); + + if (!(await ownsSource(tx, request))) { + return { changed: 0, unknownTargetIds: [], unknownSource: true }; + } + const { owned, unknown } = await resolveTargets(tx, request, spec); + + const removed = await spec.delegate(tx).deleteMany({ + where: { + userId: request.userId, + [spec.sourceColumn]: request.sourceId, + [spec.targetColumn]: { notIn: owned }, + }, + }); + + let created = 0; + if (owned.length > 0) { + const result = await spec.delegate(tx).createMany({ + data: owned.map((targetId) => ({ + [spec.sourceColumn]: request.sourceId, + [spec.targetColumn]: targetId, + userId: request.userId, + })), + skipDuplicates: true, + }); + created = result.count; + } + + return { + changed: removed.count + created, + unknownTargetIds: unknown, + unknownSource: false, + }; +} + +/** + * The resolved targets currently linked to one source. + * + * Returns labels and dates, never bare ids: a client that had to fetch each + * target to render a list would be re-deriving something the server already + * knows, and the two would drift. + */ +export async function listTargets( + tx: Prisma.TransactionClient, + request: Omit, +): Promise { + const spec = tableFor(request.sourceKind, request.targetKind); + + const links = await spec.delegate(tx).findMany({ + where: { + userId: request.userId, + [spec.sourceColumn]: request.sourceId, + }, + select: { [spec.targetColumn]: true }, + orderBy: { createdAt: "asc" }, + }); + const targetIds = links.map((link) => String(link[spec.targetColumn])); + if (targetIds.length === 0) return []; + + // Re-read the targets rather than joining through the link's relation: the + // soft-delete filter belongs on the target, and a tombstoned row must drop + // out of the list even though its link row survives the soft delete. + const rows = await spec.target.delegate(tx).findMany({ + where: { id: { in: targetIds }, userId: request.userId, deletedAt: null }, + select: spec.target.select, + }); + const byId = new Map(rows.map((row) => [String(row.id), row])); + + // Ordered by the link's own creation, so the list reads in the order the + // person filed things rather than in whatever order the id lookup returned. + return targetIds.flatMap((id) => { + const row = byId.get(id); + if (!row) return []; + return [{ id, label: spec.target.label(row), date: spec.target.date(row) }]; + }); +} + +/** + * The resolved targets for MANY sources at once, as a map keyed by source id. + * + * One grouped query for the links and one for the targets, so a page of + * documents costs two round trips rather than two per row. Sources with no + * links are absent from the map rather than present with an empty array. + * + * `includeDeletedTargets` exists for one caller and is off by default. The + * document vault has always rendered a condition chip through a relation join + * with no tombstone filter, so a document filed against a condition the person + * later deleted still shows it. Whether that is right is a product question + * this refactor does not answer; changing it silently while moving the query + * would be the wrong place to answer it. + */ +export async function listTargetsBySource( + tx: Prisma.TransactionClient, + request: { + userId: string; + sourceKind: LinkSourceKind; + sourceIds: string[]; + targetKind: LinkTargetKind; + includeDeletedTargets?: boolean; + }, +): Promise> { + const map = new Map(); + const sourceIds = [...new Set(request.sourceIds)]; + if (sourceIds.length === 0) return map; + + const spec = tableFor(request.sourceKind, request.targetKind); + const links = await spec.delegate(tx).findMany({ + where: { + userId: request.userId, + [spec.sourceColumn]: { in: sourceIds }, + }, + select: { [spec.sourceColumn]: true, [spec.targetColumn]: true }, + orderBy: { createdAt: "asc" }, + }); + if (links.length === 0) return map; + + const targetIds = [ + ...new Set(links.map((link) => String(link[spec.targetColumn]))), + ]; + const rows = await spec.target.delegate(tx).findMany({ + where: { + id: { in: targetIds }, + userId: request.userId, + ...(request.includeDeletedTargets ? {} : { deletedAt: null }), + }, + select: spec.target.select, + }); + const byId = new Map(rows.map((row) => [String(row.id), row])); + + for (const link of links) { + const targetId = String(link[spec.targetColumn]); + const row = byId.get(targetId); + if (!row) continue; + const sourceId = String(link[spec.sourceColumn]); + const entry = map.get(sourceId) ?? []; + entry.push({ + id: targetId, + label: spec.target.label(row), + date: spec.target.date(row), + }); + map.set(sourceId, entry); + } + return map; +} + +/** + * Every distinct target this account has linked at least one LIVE source to. + * + * What a filter bar needs: the chips exist because something is filed under + * them, whether or not that something is on the page currently loaded. The + * source's tombstone is what "live" means here — a condition whose only + * documents were all deleted is not offered as a filter. + */ +export async function listDistinctTargets( + tx: Prisma.TransactionClient, + request: { + userId: string; + sourceKind: LinkSourceKind; + targetKind: LinkTargetKind; + }, +): Promise { + const spec = tableFor(request.sourceKind, request.targetKind); + const sourceRelation = SOURCES[request.sourceKind].relationField; + + const links = await spec.delegate(tx).findMany({ + where: { + userId: request.userId, + [sourceRelation]: { deletedAt: null }, + }, + select: { [spec.targetColumn]: true }, + orderBy: { [spec.targetColumn]: "asc" }, + }); + const targetIds = [ + ...new Set(links.map((link) => String(link[spec.targetColumn]))), + ]; + if (targetIds.length === 0) return []; + + const rows = await spec.target.delegate(tx).findMany({ + where: { id: { in: targetIds }, userId: request.userId }, + select: spec.target.select, + }); + const byId = new Map(rows.map((row) => [String(row.id), row])); + + return targetIds.flatMap((id) => { + const row = byId.get(id); + if (!row) return []; + return [{ id, label: spec.target.label(row), date: spec.target.date(row) }]; + }); +} diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 358b6ac1d..52d635481 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -667,7 +667,14 @@ describe("get_preventive_care", () => { }; expect(prisma.measurementReminder.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { userId: "user-1", deletedAt: null, enabled: true }, + where: { + userId: "user-1", + deletedAt: null, + enabled: true, + // A booked visit's reminder rides the same engine and is not a + // checkup; the tool must not offer one as preventive care. + origin: { not: "ENCOUNTER" }, + }, }), ); expect(result.present).toBe(true); diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index b0d9ee897..e8e886fa4 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -1552,7 +1552,18 @@ export const MCP_TOOLS: McpToolDefinition[] = [ outputShape: getPreventiveCareOutput, async run(ctx) { const reminders = await prisma.measurementReminder.findMany({ - where: { userId: ctx.userId, deletedAt: null, enabled: true }, + // A booked visit's one-shot reminder rides this same engine with + // `origin: ENCOUNTER`. It is not a checkup and must not appear on a + // Vorsorge surface, or the list fills with appointments. Four read + // sites carry this exclusion; `encounter-reminder-exclusion.test.ts` + // proves every one of them, and the DTO mapper refuses such a row + // outright so a site that lost its filter fails loudly. + where: { + userId: ctx.userId, + deletedAt: null, + enabled: true, + origin: { not: "ENCOUNTER" }, + }, // Most-urgent first; a null next-due (uncomputable) sinks to the end. orderBy: [ { nextDueAt: { sort: "asc", nulls: "last" } }, diff --git a/src/lib/measurement-reminders/__tests__/resolve.test.ts b/src/lib/measurement-reminders/__tests__/resolve.test.ts index c6f2b1553..13c665ef7 100644 --- a/src/lib/measurement-reminders/__tests__/resolve.test.ts +++ b/src/lib/measurement-reminders/__tests__/resolve.test.ts @@ -112,4 +112,43 @@ describe("findSatisfyingEvent", () => { }); expect(at).toBeNull(); }); + + it("never auto-satisfies a booked visit, whatever landed", async () => { + // An appointment reminder is free-text by construction (there is nothing a + // person could measure that means "you went"), and the free-text arm above + // resolves from ANY lab result. Left alone, a routine blood panel would + // mark next month's cardiology appointment as attended and silence it. + // + // Going is the only thing that satisfies a visit, and the visit routes are + // where that is recorded, so this matcher must refuse the row outright + // rather than look for evidence it cannot have. + const takenAt = new Date("2026-07-04T09:00:00Z"); + const prisma = makePrisma({ lab: { takenAt } }); + + const at = await findSatisfyingEvent(prisma as never, "u1", { + ...base, + measurementType: null, + origin: "ENCOUNTER", + }); + + expect(at).toBeNull(); + // Refused before the query, not filtered after it: an appointment is not a + // question this matcher should be asking the database. + expect(prisma.labResult.findFirst).not.toHaveBeenCalled(); + expect(prisma.measurement.findFirst).not.toHaveBeenCalled(); + }); + + it("still auto-satisfies an ordinary free-text checkup", async () => { + // The counterpart, so the refusal above cannot be "never resolve anything". + const takenAt = new Date("2026-07-04T09:00:00Z"); + const prisma = makePrisma({ lab: { takenAt } }); + + const at = await findSatisfyingEvent(prisma as never, "u1", { + ...base, + measurementType: null, + origin: "VORSORGE", + }); + + expect(at).toEqual(takenAt); + }); }); diff --git a/src/lib/measurement-reminders/dto.ts b/src/lib/measurement-reminders/dto.ts index d07453f53..0890cfd8f 100644 --- a/src/lib/measurement-reminders/dto.ts +++ b/src/lib/measurement-reminders/dto.ts @@ -24,6 +24,13 @@ export interface MeasurementReminderDtoShape { /** * v1.18.1 — provenance: `VORSORGE` (user-created) or `COACH` (minted from * a Coach cadence suggestion). The UI labels the source. + * + * Deliberately two members, while `ReminderOrigin` in the schema has three. + * The third, `ENCOUNTER`, belongs to a booked visit and does not appear on + * this surface: every lookup that feeds this mapper excludes it, and the + * published enum stays closed at these two so the reminder contract does not + * move. Widening this union is the wrong repair for a type error here — the + * right one is finding the lookup that lost its exclusion. */ origin: "VORSORGE" | "COACH"; notifyHour: number; @@ -35,9 +42,34 @@ export interface MeasurementReminderDtoShape { updatedAt: string; } +/** + * The invariant this mapper enforces: an `ENCOUNTER`-origin row is a booked + * visit's one-shot reminder and is not part of the measurement-reminder + * surface at all. Nine lookups keep it out — four list reads (the reminder + * list route, the daily digest, the insights preventive-care block and the MCP + * preventive-care tool) and five by-id lookups (read, edit, delete, complete + * and satisfy), which answer 404 rather than filtering, because an appointment + * is not addressable by that family. + * + * Reaching here means one of the nine lost its filter. The failure is loud on + * purpose, but note what it cannot undo: on `complete` and `satisfy` the throw + * used to land AFTER the write, so the caller got a 500 for a mutation that + * had already happened. Those two now refuse in the lookup, which is why the + * exclusion belongs in the query and not in a check after it. + * + * Proven by `src/__tests__/encounter-reminder-exclusion.test.ts`. + */ export function toMeasurementReminderDto( row: MeasurementReminder, ): MeasurementReminderDtoShape { + if (row.origin === "ENCOUNTER") { + throw new Error( + `toMeasurementReminderDto received an ENCOUNTER-origin reminder (${row.id}). ` + + "Appointment reminders are excluded from every Vorsorge read surface; " + + "a read site has lost its origin filter. See " + + "src/__tests__/encounter-reminder-exclusion.test.ts.", + ); + } return { id: row.id, label: row.label, diff --git a/src/lib/measurement-reminders/resolve.ts b/src/lib/measurement-reminders/resolve.ts index cc04cc7f3..8af993000 100644 --- a/src/lib/measurement-reminders/resolve.ts +++ b/src/lib/measurement-reminders/resolve.ts @@ -27,6 +27,12 @@ export interface ResolvableReminder { anchorDate: Date | null; lastSatisfiedAt: Date | null; createdAt: Date; + /** + * Optional so the many callers that construct this shape by hand keep + * compiling; absent means "an ordinary reminder", which is the safe reading. + * Only `ENCOUNTER` changes the answer. + */ + origin?: import("@/generated/prisma/client").ReminderOrigin; } /** @@ -50,6 +56,17 @@ export async function findSatisfyingEvent( userId: string, reminder: ResolvableReminder, ): Promise { + // An appointment is not satisfied by a measurement, and refusing it here is + // not a filter — it is the absence of a question worth asking. + // + // An appointment reminder is free-text by construction: there is no reading + // that means "you went". The free-text arm below resolves from ANY lab + // result, so without this a routine blood panel would mark next month's + // cardiology appointment as attended and silence its nudge. Going is the only + // thing that satisfies a visit, and that is recorded by moving the visit to + // DONE through the encounter routes. + if (reminder.origin === "ENCOUNTER") return null; + const floor = satisfactionFloor(reminder); if (reminder.measurementType !== null) { diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts new file mode 100644 index 000000000..15a860b41 --- /dev/null +++ b/src/lib/openapi/routes/encounters.ts @@ -0,0 +1,523 @@ +/** + * OpenAPI route table for visits (`/api/encounters`) and the address book + * behind them (`/api/practitioners`). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies + * and queries reuse the runtime Zod schemas so the wire contract stays + * single-source. Response shapes are declared here to mirror the + * server-authoritative DTOs: the practitioner arrives resolved rather than as + * an id, the links arrive with a label and a date, and the appointment's next + * due instant arrives computed. A client renders these, it never rebuilds them. + * + * Note what is NOT here: the reminder `origin` enum is unchanged. A booked + * visit mints a reminder with a third origin value internally, and that value + * reaches no response, because every preventive-care read excludes it. The + * published contract stays at two members deliberately. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + encounterCreateSchema, + encounterUpdateSchema, + encounterListQuerySchema, + encounterLinkSchema, + encounterStatusEnum, + encounterKindEnum, +} from "@/lib/validations/encounters"; +import { + practitionerCreateSchema, + practitionerUpdateSchema, + practitionerListQuerySchema, +} from "@/lib/validations/practitioners"; + +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; + +encounterCreateSchema.meta({ + id: "CreateEncounterRequest", + description: + "File a visit, or book one. `occurredAt` is the only required field — a visit saves with a date and nothing else. A future instant with `status: PLANNED` books an appointment and mints exactly one reminder for it. `reason` and `outcome` are encrypted at rest. The three id arrays pre-link documents, lab results and condition episodes; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save.", +}); + +encounterUpdateSchema.meta({ + id: "UpdateEncounterRequest", + description: + "Partial edit of a visit; an omitted key leaves the column untouched, and a body naming nothing is a 422. Moving `occurredAt` re-anchors the appointment reminder the visit already owns and never mints a second. Moving `status` to DONE closes the checkup named by `reminderId`, on the transition only. CANCELLED and NO_SHOW stop the reminder without deleting the row. A present id array replaces that link family, empty array included.", +}); + +encounterListQuerySchema.meta({ + id: "ListEncountersQuery", + description: + "Window and filters for the visit list: `from` / `to` (ISO-8601 instants), `status`, `practitionerId`, and `limit` (1–200, default 100).", +}); + +encounterLinkSchema.meta({ + id: "EncounterLinkRequest", + description: + "Targets to file against, or unfile from, a visit. Idempotent in both directions and capped at 100 ids. Ids naming nothing the caller owns come back in `unknown` rather than failing the request.", +}); + +practitionerCreateSchema.meta({ + id: "CreatePractitionerRequest", + description: + "Add a doctor or practice to the caller's own address book. `name` is the only required field and is stored as queryable plaintext so the picker can search and sort it; `note` is encrypted at rest. `specialty` is free text rather than an enum. Nothing is unique across accounts.", +}); + +practitionerUpdateSchema.meta({ + id: "UpdatePractitionerRequest", + description: + "Partial edit; an omitted key leaves the column untouched, an explicit null clears it, and a body naming nothing is a 422.", +}); + +practitionerListQuerySchema.meta({ + id: "ListPractitionersQuery", + description: + "Query params for the address book: `q` (case-insensitive substring of the name) and `limit` (1–200, default 100). Name-ordered.", +}); + +const practitioner = z + .object({ + id: z.string(), + name: z.string(), + specialty: z.string().nullable(), + practice: z.string().nullable(), + location: z.string().nullable(), + phone: z.string().nullable(), + note: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .meta({ + id: "Practitioner", + description: + "One entry in the caller's address book. `note` is the decrypted free text, or null on a key-rotation gap — fail-soft, never a 500.", + }); + +const linkedTarget = z + .object({ + id: z.string(), + label: z.string().nullable(), + date: z.string().nullable(), + redacted: z.boolean(), + }) + .meta({ + id: "EncounterLinkedTarget", + description: + "One thing filed against a visit, resolved. The label and date are computed server-side from the target itself — a document's title and filing date, a lab result's panel and analyte and draw date, a condition's label and onset — so the client never re-derives them and the two cannot drift. Both go null together, with `redacted: true`, when the caller's grant does not cover the target's own domain: a visit lives in the health background but points at the vault, the labs and the illness journal, and a grant that opened only the first was never consent for the names in the other three. The link itself is still listed — the caller may know the visit produced a lab result — and `redacted` is what tells a withheld name apart from a target that simply has no date.", + }); + +const encounterLinks = z + .object({ + documents: z.array(linkedTarget), + labResults: z.array(linkedTarget), + conditions: z.array(linkedTarget), + }) + .meta({ + id: "EncounterLinks", + description: + "The three link families of one visit, each ordered by when it was filed. Present on the detail response, absent from the list.", + }); + +const skipReport = z + .object({ + catalogueKeys: z.array( + z.object({ + catalogue: z.string(), + key: z.string(), + links: z.number(), + }), + ), + links: z.number(), + }) + .meta({ + id: "EncounterSkipReport", + description: + "What the write could not do, named. `links: 0` with an empty list is the ordinary answer. Anything else is a side effect the caller's grant did not reach — today only `checkupClosure`, the closing of a preventive-care checkup, which is a measurements-domain write a health-background delegate cannot perform. The visit still saves; the checkup stays due and the response says so, because somebody who believes a checkup was closed will not look at it again.", + }); + +const encounter = z + .object({ + id: z.string(), + occurredAt: z.string(), + status: encounterStatusEnum, + kind: encounterKindEnum, + practitioner: practitioner.nullable(), + reason: z.string().nullable(), + outcome: z.string().nullable(), + reminderNextDueAt: z.string().nullable(), + links: encounterLinks.optional(), + skipped: skipReport.optional(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .meta({ + id: "Encounter", + description: + "One contact with the healthcare system: a visit that happened, or one that is booked. `practitioner` is resolved rather than an id. `reason` and `outcome` are the decrypted free text (null on a key-rotation gap). `reminderNextDueAt` is the server-computed instant a booked appointment next nudges, and is null for a visit that already happened or whose one-shot reminder is spent.", + }); + +const encounterList = z + .object({ + upcoming: z.array(encounter), + past: z.array(encounter), + }) + .meta({ + id: "EncounterList", + description: + "Visits split server-side rather than client-side. `upcoming` reads soonest-first and `past` reads newest-first — opposite directions, resolved once here so the ordering lives in one place.", + }); + +const encounterNotFound = { + "404": { + description: "Visit not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +const practitionerNotFound = { + "404": { + description: "Practitioner not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +const idPath = { path: z.object({ id: z.string() }) }; + +export const encounterPaths: NonNullable = { + "/api/encounters": { + get: { + tags: ["Records"], + summary: "List visits", + description: + "Returns the caller's live visits, split into `upcoming` and `past`. Bounded and windowed; soft-deleted visits are excluded.", + requestParams: { query: encounterListQuerySchema }, + responses: { + ...recordRefusal(), + "200": { + description: "The caller's visits.", + content: { + "application/json": { + schema: dataEnvelope(encounterList, "ListEncountersEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Records"], + summary: "File or book a visit", + description: + "Creates one visit. A future instant with `status: PLANNED` books an appointment and mints its reminder; a past instant with `status: DONE` and a `reminderId` closes that checkup. Claiming both is a 422 — a visit that has not happened cannot have closed anything. Honours `Idempotency-Key`. Audits as `encounter.visit.create`.", + requestBody: { + required: true, + content: { "application/json": { schema: encounterCreateSchema } }, + }, + responses: { + ...recordRefusal(), + "201": { + description: "Visit filed.", + content: { + "application/json": { + schema: dataEnvelope(encounter, "CreateEncounterEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/encounters/{id}": { + get: { + tags: ["Records"], + summary: "Read a single visit", + description: + "Returns the visit with its three link families resolved. Owner-scoped; a cross-user or tombstoned id 404s.", + requestParams: idPath, + responses: { + ...recordRefusal(), + "200": { + description: "The visit.", + content: { + "application/json": { + schema: dataEnvelope(encounter, "GetEncounterEnvelope"), + }, + }, + }, + ...encounterNotFound, + ...stdResponses, + }, + }, + patch: { + tags: ["Records"], + summary: "Edit a visit", + description: + "Partial edit; omitted fields are left untouched. Audits as `encounter.visit.update`. Owner-scoped.", + requestParams: idPath, + requestBody: { + required: true, + content: { "application/json": { schema: encounterUpdateSchema } }, + }, + responses: { + ...recordRefusal(), + "200": { + description: "Visit updated.", + content: { + "application/json": { + schema: dataEnvelope(encounter, "UpdateEncounterEnvelope"), + }, + }, + }, + ...encounterNotFound, + ...stdResponses, + }, + }, + delete: { + tags: ["Records"], + summary: "Soft-delete a visit", + description: + "Stamps `deletedAt` and retires the visit's own appointment reminder with it — never a checkup the visit closed, which belongs to the preventive-care list and outlives it. Idempotent. Audits as `encounter.visit.delete`.", + requestParams: idPath, + responses: { + ...recordRefusal(), + "200": { + description: "Soft-deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.boolean() }), + "DeleteEncounterEnvelope", + ), + }, + }, + }, + ...encounterNotFound, + ...stdResponses, + }, + }, + }, + "/api/encounters/{id}/restore": { + post: { + tags: ["Records"], + summary: "Restore a soft-deleted visit", + description: + "Clears the tombstone. The link rows survived the soft delete, so the visit returns with its documents, labs and conditions still filed against it. Its appointment reminder returns only if the appointment is still in the future. Restoring a live visit is a no-op that still succeeds.", + requestParams: idPath, + responses: { + ...recordRefusal(), + "200": { + description: "Visit restored.", + content: { + "application/json": { + schema: dataEnvelope(encounter, "RestoreEncounterEnvelope"), + }, + }, + }, + ...encounterNotFound, + ...stdResponses, + }, + }, + }, + "/api/encounters/{id}/links": { + post: { + tags: ["Records"], + summary: "File targets against a visit", + description: + "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document link and unlink.", + requestParams: idPath, + requestBody: { + required: true, + content: { "application/json": { schema: encounterLinkSchema } }, + }, + responses: { + "200": { + description: "The visit's links after the change.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + links: encounterLinks, + unknown: z.array(z.string()), + }), + "LinkEncounterEnvelope", + ), + }, + }, + }, + ...encounterNotFound, + ...stdResponses, + }, + }, + delete: { + tags: ["Records"], + summary: "Unfile targets from a visit", + description: + "Removes links. Idempotent, and it still works for a target the account has since deleted — refusing would leave a filing nobody can clear.", + requestParams: idPath, + requestBody: { + required: true, + content: { "application/json": { schema: encounterLinkSchema } }, + }, + responses: { + "200": { + description: "The visit's links after the change.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + links: encounterLinks, + unknown: z.array(z.string()), + }), + "UnlinkEncounterEnvelope", + ), + }, + }, + }, + ...encounterNotFound, + ...stdResponses, + }, + }, + }, + "/api/practitioners": { + get: { + tags: ["Records"], + summary: "List practitioners", + description: + "Returns the caller's address book, name-ordered, with an optional case-insensitive name search.", + requestParams: { query: practitionerListQuerySchema }, + responses: { + ...recordRefusal(), + "200": { + description: "The caller's address book.", + content: { + "application/json": { + schema: dataEnvelope( + z.array(practitioner), + "ListPractitionersEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Records"], + summary: "Add a practitioner", + description: + "Creates one address-book entry. Honours `Idempotency-Key`. Audits as `practitioner.contact.create`.", + requestBody: { + required: true, + content: { "application/json": { schema: practitionerCreateSchema } }, + }, + responses: { + ...recordRefusal(), + "201": { + description: "Practitioner added.", + content: { + "application/json": { + schema: dataEnvelope(practitioner, "CreatePractitionerEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/practitioners/{id}": { + get: { + tags: ["Records"], + summary: "Read a single practitioner", + description: + "Returns the entry including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s.", + requestParams: idPath, + responses: { + ...recordRefusal(), + "200": { + description: "The practitioner.", + content: { + "application/json": { + schema: dataEnvelope(practitioner, "GetPractitionerEnvelope"), + }, + }, + }, + ...practitionerNotFound, + ...stdResponses, + }, + }, + patch: { + tags: ["Records"], + summary: "Edit a practitioner", + description: + "Partial edit; omitted fields are left untouched. Audits as `practitioner.contact.update`. Owner-scoped.", + requestParams: idPath, + requestBody: { + required: true, + content: { "application/json": { schema: practitionerUpdateSchema } }, + }, + responses: { + ...recordRefusal(), + "200": { + description: "Practitioner updated.", + content: { + "application/json": { + schema: dataEnvelope(practitioner, "UpdatePractitionerEnvelope"), + }, + }, + }, + ...practitionerNotFound, + ...stdResponses, + }, + }, + delete: { + tags: ["Records"], + summary: "Soft-delete a practitioner", + description: + "Stamps `deletedAt`. Every visit that named this entry survives with no practice attached, because the reference nulls rather than cascades. Idempotent. Audits as `practitioner.contact.delete`.", + requestParams: idPath, + responses: { + ...recordRefusal(), + "200": { + description: "Soft-deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.boolean() }), + "DeletePractitionerEnvelope", + ), + }, + }, + }, + ...practitionerNotFound, + ...stdResponses, + }, + }, + }, + "/api/practitioners/{id}/restore": { + post: { + tags: ["Records"], + summary: "Restore a soft-deleted practitioner", + description: + "Clears the tombstone. It does NOT re-attach the visits whose reference was nulled on delete — which visits meant this practice is not recoverable, and guessing would be worse than the gap. Restoring a live entry is a no-op that still succeeds.", + requestParams: idPath, + responses: { + ...recordRefusal(), + "200": { + description: "Practitioner restored.", + content: { + "application/json": { + schema: dataEnvelope(practitioner, "RestorePractitionerEnvelope"), + }, + }, + }, + ...practitionerNotFound, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 514f958fa..bbd5158f7 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -48,6 +48,7 @@ import { devicePaths } from "./devices"; import { exportPaths } from "./export"; import { familyHistoryPaths } from "./family-history"; import { healthRecordPaths } from "./health-record"; +import { encounterPaths } from "./encounters"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; import { insightsPaths } from "./insights"; @@ -102,6 +103,7 @@ export const openApiPaths: NonNullable = { ...labsPaths, ...ocrPaths, ...biomarkerPaths, + ...encounterPaths, ...illnessPaths, ...mentalHealthPaths, ...allergyPaths, diff --git a/src/lib/practitioners/dto.ts b/src/lib/practitioners/dto.ts new file mode 100644 index 000000000..d20e35560 --- /dev/null +++ b/src/lib/practitioners/dto.ts @@ -0,0 +1,54 @@ +/** + * Practitioner row → wire DTO. + * + * Server-authoritative: the client renders these values, it never rebuilds + * them. The free-text note lives in `noteEncrypted` (Bytes) and is decrypted + * fail-soft on read, so a key-rotation gap on one row reads as a missing note + * rather than 500-ing the address book. + */ +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; +import type { Practitioner } from "@/generated/prisma/client"; + +export interface PractitionerDTO { + id: string; + name: string; + specialty: string | null; + practice: string | null; + location: string | null; + phone: string | null; + note: string | null; + createdAt: string; + updatedAt: string; +} + +/** Decrypt a Bytes note fail-soft (null on missing / undecryptable). */ +export function decryptPractitionerNote( + noteEncrypted: Uint8Array | null, +): string | null { + if (!noteEncrypted || noteEncrypted.byteLength === 0) return null; + try { + return decryptFromBytes(noteEncrypted); + } catch (err) { + getEvent()?.addWarning( + `practitioner note decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return null; + } +} + +export function toPractitionerDTO(row: Practitioner): PractitionerDTO { + return { + id: row.id, + name: row.name, + specialty: row.specialty, + practice: row.practice, + location: row.location, + phone: row.phone, + note: decryptPractitionerNote(row.noteEncrypted), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts new file mode 100644 index 000000000..d59d9d591 --- /dev/null +++ b/src/lib/query-keys/encounters.ts @@ -0,0 +1,46 @@ +/** + * Query keys — visits and the address book behind them. + * Part of the centralized factory; aggregated in `./index.ts`. + * + * These exist before any client reads them, because the in-repo + * `healthlog/queryKey-factory` rule errors on a bare array: the first + * component that lists visits cannot compile without a slot here. Same key + * with a different `queryFn` shape silently poisons the cache, which is the + * whole reason the factory is the only legal source. + * + * There is deliberately no matching `invalidateUserVisits` in + * `src/lib/cache/invalidate.ts`. That module evicts SERVER-side cached + * payloads, and no cached payload reads a visit: the dashboard snapshot, the + * insights targets and the analytics buckets are all computed from + * measurements, medications and mood. Adding a sweep over caches nothing reads + * would be a placeholder that looks like coverage. It belongs in the release + * that puts visits into one of those payloads, alongside the reader that makes + * it necessary. + */ +export const encounterKeys = { + /** + * The root prefix every visit write invalidates through + * (`encounterDependentKeys` in `./index.ts`). TanStack's hierarchical + * prefix semantics mean evicting `["encounters"]` clears the windowed list + * and every open detail in one tick, so a visit edited in a sheet and the + * list behind it never disagree. + */ + encounters: () => ["encounters"] as const, + /** + * The windowed list. The window is part of the key: the page can ask for a + * year at a time, and two windows are two different answers rather than one + * answer that overwrites the other. + */ + encounterList: (from: string | null, to: string | null, status?: string) => + ["encounters", "list", from, to, status ?? null] as const, + encounter: (id: string) => ["encounters", "detail", id] as const, + /** + * The address book. Under its own root rather than nested beneath + * `["encounters"]`: a practitioner outlives the visits that name it, and a + * visit write should not evict a list that did not change. + */ + practitioners: () => ["practitioners"] as const, + practitionerList: (q?: string) => + ["practitioners", "list", q ?? null] as const, + practitioner: (id: string) => ["practitioners", "detail", id] as const, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 578df7a9d..ed88535ff 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -25,6 +25,7 @@ import { dailyKeys } from "./daily"; import { dashboardKeys } from "./dashboard"; import { environmentKeys } from "./environment"; import { documentKeys } from "./documents"; +import { encounterKeys } from "./encounters"; import { illnessKeys } from "./illness"; import { insightsKeys } from "./insights"; import { integrationKeys } from "./integrations"; @@ -63,6 +64,7 @@ export const queryKeys = { ...familyHistoryKeys, ...environmentKeys, ...documentKeys, + ...encounterKeys, ...customMetrics, ...nutrientKeys, ...recordSettingsKeys, @@ -236,6 +238,20 @@ export const medicationDependentKeys = [ */ export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()]; +/** + * Keys invalidated when a visit or an address-book entry changes. + * + * Both roots ride together in one bundle rather than two, because the two + * writes reach each other: renaming a practice changes the label every visit + * that names it renders, and deleting one nulls the reference on visits that + * stay. A bundle that evicted only the surface being written would leave the + * other showing the pre-write name. + */ +export const encounterDependentKeys = [ + queryKeys.encounters(), + queryKeys.practitioners(), +]; + /** * Invalidate every key in the bundle in parallel. Use this from mutation * `onSuccess` handlers so the call site stays a one-liner instead of repeating diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index 3d4b1f334..e64890544 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -72,6 +72,12 @@ export function recordActivityVerbLine( return t("recordSharing.activityVerb.familyHistoryCreate", { name }); case "illness.episode.create": return t("recordSharing.activityVerb.illnessEpisodeCreate", { name }); + case "encounter.visit.create": + return t("recordSharing.activityVerb.encounterVisitCreate", { name }); + case "practitioner.contact.create": + return t("recordSharing.activityVerb.practitionerContactCreate", { + name, + }); case "biomarker.create": return t("recordSharing.activityVerb.biomarkerCreate", { name }); case "customMetricEntry.create": @@ -175,6 +181,25 @@ export function recordActivityVerbLine( case "illness.day-log.upsert": return t("recordSharing.activityVerb.illnessDayLogUpsert", { name }); + case "encounter.visit.update": + return t("recordSharing.activityVerb.encounterVisitUpdate", { name }); + case "encounter.visit.delete": + return t("recordSharing.activityVerb.encounterVisitDelete", { name }); + case "encounter.visit.restore": + return t("recordSharing.activityVerb.encounterVisitRestore", { name }); + case "practitioner.contact.update": + return t("recordSharing.activityVerb.practitionerContactUpdate", { + name, + }); + case "practitioner.contact.delete": + return t("recordSharing.activityVerb.practitionerContactDelete", { + name, + }); + case "practitioner.contact.restore": + return t("recordSharing.activityVerb.practitionerContactRestore", { + name, + }); + case "medication.update": return t("recordSharing.activityVerb.medicationUpdate", { name }); case "medication.delete": diff --git a/src/lib/sharing/scope.ts b/src/lib/sharing/scope.ts index ca7093c26..a76f96471 100644 --- a/src/lib/sharing/scope.ts +++ b/src/lib/sharing/scope.ts @@ -46,7 +46,17 @@ export const SHARE_DOMAINS = [ "medications", /** Lab results and the biomarker catalogue behind them. */ "labs", - /** Health background: allergies, family history, anamnesis facts. */ + /** + * Health background: allergies, family history, anamnesis facts, and visits + * and practitioners. + * + * Visits joined this domain rather than getting one of their own, and the + * cost is worth naming: somebody granted `profile` so a relative could see + * an allergy list also gains the record of every appointment and the address + * book behind it. The mitigation is not this comment — it is the consent + * copy, which names visits in all six locales, so the widening is stated + * where it is agreed to rather than inferred from the code. + */ "profile", /** Illness episodes and the day logs under them. */ "illness", diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 245c002e6..822b20460 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -823,8 +823,19 @@ export async function handleCallback(update: TelegramUpdate) { // v1.19.0 — mark a Vorsorge/measurement reminder done; satisfy the // cadence via the shared primitive. const reminderId = data.slice("measure_done:".length).trim(); + // An appointment reminder is not addressable here, the same refusal the + // by-id HTTP family makes: it belongs to a visit, is managed through the + // visit routes, and sits in a different sharing domain. The lookup answers + // "not found" rather than acting on it — satisfying one would mark an + // appointment attended from a chat button, and postponing one would move a + // date the visit record still believes it owns. const reminder = await prisma.measurementReminder.findFirst({ - where: { id: reminderId, userId: user.id, deletedAt: null }, + where: { + id: reminderId, + userId: user.id, + deletedAt: null, + origin: { not: "ENCOUNTER" }, + }, select: { id: true, // v1.19.2 — the target metric; drives the optional numeric capture. @@ -912,8 +923,19 @@ export async function handleCallback(update: TelegramUpdate) { ); return; } + // An appointment reminder is not addressable here, the same refusal the + // by-id HTTP family makes: it belongs to a visit, is managed through the + // visit routes, and sits in a different sharing domain. The lookup answers + // "not found" rather than acting on it — satisfying one would mark an + // appointment attended from a chat button, and postponing one would move a + // date the visit record still believes it owns. const reminder = await prisma.measurementReminder.findFirst({ - where: { id: reminderId, userId: user.id, deletedAt: null }, + where: { + id: reminderId, + userId: user.id, + deletedAt: null, + origin: { not: "ENCOUNTER" }, + }, select: { id: true }, }); if (!reminder) { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 1f18b39fa..9a198e7dd 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -32,6 +32,8 @@ import { ContraceptiveKind, CycleTrackingGoal, DocumentSummaryState, + EncounterKind, + EncounterStatus, FamilyRelationship, FlowLevel, GlucoseContext, @@ -832,6 +834,56 @@ const illnessEpisodeBackupSchema = z }) .passthrough(); +/** + * A practitioner, an encounter, and the edges between an encounter and the + * things it produced. + * + * `id` is required on both records, unlike the lab result above: a link + * addresses an encounter and its far side by id and nothing else on either row + * is unique enough to rebuild the reference from. The ciphertext columns ride + * verbatim as base64 — a visit note is never decrypted into the file. + */ +const practitionerBackupSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + specialty: z.string().nullable().optional(), + practice: z.string().nullable().optional(), + location: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); + +const encounterBackupSchema = z + .object({ + id: z.string().min(1), + occurredAt: isoDateTime, + status: z.enum(EncounterStatus), + kind: z.enum(EncounterKind), + practitionerId: z.string().nullable().optional(), + reasonEncrypted: base64BytesSchema.nullable().optional(), + outcomeEncrypted: base64BytesSchema.nullable().optional(), + // Carried so the loss is visible in the file. The restore resolves it to + // NULL and names the drop: the reminder it points at does not travel. + reminderId: z.string().nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); + +const encounterLinkBackupSchema = z + .object({ + encounterId: z.string().min(1), + targetId: z.string().min(1), + createdAt: isoDateTime, + }) + .passthrough(); + const allergyBackupSchema = z .object({ id: z.string().min(1), @@ -975,6 +1027,15 @@ export const backupPayloadSchema = z // the sections above: a file written before the table existed carries no // key, and an account whose score never resolved writes []. healthScoreRecords: z.array(healthScoreRecordBackupSchema).default([]), + // Visits, the address book behind them, and the three link tables. + // Defaulted for the same reason as the sections above: a file written + // before the tables existed carries no key, and an account that has never + // filed a visit writes []. + practitioners: z.array(practitionerBackupSchema).default([]), + encounters: z.array(encounterBackupSchema).default([]), + encounterDocumentLinks: z.array(encounterLinkBackupSchema).default([]), + encounterLabLinks: z.array(encounterLinkBackupSchema).default([]), + encounterConditionLinks: z.array(encounterLinkBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), }) .passthrough() diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts new file mode 100644 index 000000000..2217b53be --- /dev/null +++ b/src/lib/validations/encounters.ts @@ -0,0 +1,166 @@ +/** + * Visit (encounter) request/response validation. + * + * Source of truth for the `/api/encounters*` wire contract; the OpenAPI + * registry reuses these schemas so the spec stays single-source. `userId` is + * NEVER a body field — it is narrowed from the session or Bearer in every + * route and fed to the Prisma `where`. + * + * Nothing here is mandatory except the date. A visit saves with an instant and + * nothing else: no practitioner, no kind, no reason, no link. That is a + * deliberate product constraint and not an oversight — a required field on a + * form somebody fills after a doctor's appointment is the bureaucracy this + * whole feature exists to avoid. + * + * The one exception to "no future instants" that the rest of the record keeps: + * `occurredAt` may be in the future, because a booked appointment IS a visit + * with a future date. It is bounded on both sides regardless, so a typo cannot + * park a row a century out. + */ +import { z } from "zod/v4"; + +/** Ten years out. Long enough for any real appointment, short enough that a + * mistyped year lands as a 422 rather than as a row nobody sees again. */ +const MAX_FUTURE_MS = 10 * 365 * 24 * 60 * 60 * 1000; + +/** + * An instant a visit can plausibly carry. + * + * Deliberately NOT `isPlausibleEntryInstant`, which refuses anything beyond a + * five-minute clock skew. That bound is right for a measurement, which can only + * describe something that already happened, and wrong here. + */ +const visitInstant = z.iso.datetime({ offset: true }).refine( + (value) => { + const at = new Date(value).getTime(); + if (Number.isNaN(at)) return false; + if (at < Date.UTC(1900, 0, 1)) return false; + return at <= Date.now() + MAX_FUTURE_MS; + }, + { + message: + "must be a plausible visit instant (not before 1900, not more than ten years ahead)", + }, +); + +/* ── enums (mirror the Prisma enums) ─────────────────────────────── */ + +export const encounterStatusEnum = z.enum([ + "PLANNED", + "DONE", + "CANCELLED", + "NO_SHOW", +]); + +export const encounterKindEnum = z.enum([ + "ROUTINE", + "ACUTE", + "SPECIALIST", + "PREVENTIVE", + "EMERGENCY", + "HOSPITAL", + "THERAPY", + "OTHER", +]); + +export type EncounterStatusInput = z.infer; +export type EncounterKindInput = z.infer; + +const id = z.string().min(1).max(40); + +/** + * The three optional pre-link arrays. + * + * Bounded at the link service's own cap so an over-long list is a 422 with a + * field path rather than a thrown error from two layers down. + */ +const linkIds = z.array(id).max(100).optional(); + +/* ── encounter CRUD ───────────────────────────────────────────────── */ + +export const encounterCreateSchema = z + .object({ + occurredAt: visitInstant, + status: encounterStatusEnum.optional().default("DONE"), + kind: encounterKindEnum.optional().default("OTHER"), + practitionerId: id.nullable().optional(), + /** Becomes `reasonEncrypted`. */ + reason: z.string().max(2000).nullable().optional(), + /** Becomes `outcomeEncrypted`. */ + outcome: z.string().max(4000).nullable().optional(), + /** The checkup this visit closes, when it was filed from a due one. */ + reminderId: id.nullable().optional(), + documentIds: linkIds, + labResultIds: linkIds, + episodeIds: linkIds, + }) + .strict(); + +export type EncounterCreate = z.infer; + +/** + * Edit a visit. Every field optional; an omitted field is left untouched. + * + * A body naming nothing is a 422 rather than a silent success: a PATCH that + * changed nothing and answered 200 is indistinguishable from one that worked, + * which is how a client bug survives a release. + */ +export const encounterUpdateSchema = z + .object({ + occurredAt: visitInstant.optional(), + status: encounterStatusEnum.optional(), + kind: encounterKindEnum.optional(), + practitionerId: id.nullable().optional(), + reason: z.string().max(2000).nullable().optional(), + outcome: z.string().max(4000).nullable().optional(), + reminderId: id.nullable().optional(), + documentIds: linkIds, + labResultIds: linkIds, + episodeIds: linkIds, + }) + .strict() + .refine((body) => Object.keys(body).length > 0, { + message: "name at least one field to change", + }); + +export type EncounterUpdate = z.infer; + +/** + * List query. + * + * Windowed and bounded. The response splits past from upcoming server-side, so + * there is no `order` parameter: the client renders two lists rather than + * sorting one. + */ +export const encounterListQuerySchema = z + .object({ + from: z.iso.datetime({ offset: true }).optional(), + to: z.iso.datetime({ offset: true }).optional(), + status: encounterStatusEnum.optional(), + practitionerId: id.optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + }) + .strict() + .refine((q) => !(q.from && q.to) || q.from <= q.to, { + path: ["to"], + message: "to must be on or after from", + }); + +export type EncounterListQuery = z.infer; + +/* ── links ────────────────────────────────────────────────────────── */ + +export const encounterLinkTargetEnum = z.enum([ + "document", + "labResult", + "conditionEpisode", +]); + +export const encounterLinkSchema = z + .object({ + targetKind: encounterLinkTargetEnum, + targetIds: z.array(id).min(1).max(100), + }) + .strict(); + +export type EncounterLinkInput = z.infer; diff --git a/src/lib/validations/practitioners.ts b/src/lib/validations/practitioners.ts new file mode 100644 index 000000000..3bfc283d1 --- /dev/null +++ b/src/lib/validations/practitioners.ts @@ -0,0 +1,56 @@ +/** + * Practitioner (address-book) request/response validation. + * + * Source of truth for the `/api/practitioners*` wire contract; the OpenAPI + * registry reuses these schemas. `userId` is NEVER a body field. + * + * `name` is the only required field, and it is plaintext rather than encrypted + * so the picker can search and sort it in SQL — the same tradeoff the document + * title makes, stated there too. `note` is the encrypted one. + */ +import { z } from "zod/v4"; + +export const practitionerCreateSchema = z + .object({ + name: z.string().min(1).max(160), + /** Free text, not an enum: practice signage does not fit a closed list. */ + specialty: z.string().max(120).nullable().optional(), + practice: z.string().max(160).nullable().optional(), + location: z.string().max(300).nullable().optional(), + phone: z.string().max(60).nullable().optional(), + /** Becomes `noteEncrypted`. */ + note: z.string().max(2000).nullable().optional(), + }) + .strict(); + +export type PractitionerCreate = z.infer; + +/** + * Edit a practitioner. A body naming nothing is a 422, for the same reason as + * the visit edit: a no-op that answers 200 hides a client defect. + */ +export const practitionerUpdateSchema = z + .object({ + name: z.string().min(1).max(160).optional(), + specialty: z.string().max(120).nullable().optional(), + practice: z.string().max(160).nullable().optional(), + location: z.string().max(300).nullable().optional(), + phone: z.string().max(60).nullable().optional(), + note: z.string().max(2000).nullable().optional(), + }) + .strict() + .refine((body) => Object.keys(body).length > 0, { + message: "name at least one field to change", + }); + +export type PractitionerUpdate = z.infer; + +/** List query — a substring search over the plaintext name, bounded. */ +export const practitionerListQuerySchema = z + .object({ + q: z.string().min(1).max(160).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + }) + .strict(); + +export type PractitionerListQuery = z.infer; diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 5c8b566c9..929e2f76e 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -120,6 +120,22 @@ export const ADMITTED_MUTATING_HANDLERS = [ "write", "create", ), + handler( + "app/api/encounters/route.ts", + "/api/encounters", + "POST", + "profile", + "write", + "create", + ), + handler( + "app/api/practitioners/route.ts", + "/api/practitioners", + "POST", + "profile", + "write", + "create", + ), handler( "app/api/medications/[id]/side-effects/route.ts", "/api/medications/[id]/side-effects", @@ -336,6 +352,54 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "restore", ), + handler( + "app/api/encounters/[id]/route.ts", + "/api/encounters/[id]", + "PATCH", + "profile", + "manage", + "update", + ), + handler( + "app/api/encounters/[id]/route.ts", + "/api/encounters/[id]", + "DELETE", + "profile", + "manage", + "delete", + ), + handler( + "app/api/encounters/[id]/restore/route.ts", + "/api/encounters/[id]/restore", + "POST", + "profile", + "manage", + "restore", + ), + handler( + "app/api/practitioners/[id]/route.ts", + "/api/practitioners/[id]", + "PATCH", + "profile", + "manage", + "update", + ), + handler( + "app/api/practitioners/[id]/route.ts", + "/api/practitioners/[id]", + "DELETE", + "profile", + "manage", + "delete", + ), + handler( + "app/api/practitioners/[id]/restore/route.ts", + "/api/practitioners/[id]/restore", + "POST", + "profile", + "manage", + "restore", + ), handler( "app/api/illness/episodes/[id]/day-logs/route.ts", "/api/illness/episodes/[id]/day-logs", diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6eabbb543..8de0ebd8c 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -141,6 +141,14 @@ const COUNT_BACK: Record< p.nutrientIntakeDay.count({ where: { userId } }), InboundDocument: (p, userId) => p.inboundDocument.count({ where: { userId } }), + Practitioner: (p, userId) => p.practitioner.count({ where: { userId } }), + Encounter: (p, userId) => p.encounter.count({ where: { userId } }), + EncounterDocumentLink: (p, userId) => + p.encounterDocumentLink.count({ where: { userId } }), + EncounterLabLink: (p, userId) => + p.encounterLabLink.count({ where: { userId } }), + EncounterConditionLink: (p, userId) => + p.encounterConditionLink.count({ where: { userId } }), }; /** @@ -248,7 +256,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { const biomarker = await prisma.biomarker.create({ data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" }, }); - await prisma.labResult.create({ + const labResult = await prisma.labResult.create({ data: { userId: OWNER_ID, biomarkerId: biomarker.id, @@ -399,7 +407,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { new ArrayBuffer(documentBytes.byteLength), ); contentEncrypted.set(documentBytes); - await prisma.inboundDocument.create({ + const document = await prisma.inboundDocument.create({ data: { userId: OWNER_ID, kind: "LAB_RESULT", @@ -411,6 +419,55 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { contentCodec: "binary2", }, }); + + // One visit, the practice it was at, and one link of each of the three + // kinds. The links reach the document, the lab result and the condition + // episode seeded above rather than fresh rows, because a link to something + // the restore did not put back is the exact case the restore reports as a + // skip — seeding it against a row that does come back is what makes a + // returning count mean the filing survived. + const practitioner = await prisma.practitioner.create({ + data: { + userId: OWNER_ID, + name: "Round-trip practice", + specialty: "Internal medicine", + noteEncrypted: encryptToBytes("ring the upper bell"), + }, + }); + const encounter = await prisma.encounter.create({ + data: { + userId: OWNER_ID, + occurredAt: AT("2026-06-30T08:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + practitionerId: practitioner.id, + reasonEncrypted: encryptToBytes("annual check"), + outcomeEncrypted: encryptToBytes( + "ferritin back in range, recheck in a year", + ), + }, + }); + await prisma.encounterDocumentLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + documentId: document.id, + }, + }); + await prisma.encounterLabLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + labResultId: labResult.id, + }, + }); + await prisma.encounterConditionLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + episodeId: episode.id, + }, + }); } async function createOwner(prisma: PrismaClient) { diff --git a/tests/integration/encounter-appointment-reminder.test.ts b/tests/integration/encounter-appointment-reminder.test.ts new file mode 100644 index 000000000..656c62859 --- /dev/null +++ b/tests/integration/encounter-appointment-reminder.test.ts @@ -0,0 +1,844 @@ +/** + * The appointment arm against real Postgres, through the real routes. + * + * Every property here is about ROWS — how many exist, which instant they carry, + * whether a second call wrote anything — and none of them can be answered by a + * mocked client. The one that matters most is a count: a reschedule must leave + * the reminder count at one, and a service that minted a second on every edit + * would pass any assertion phrased as "the reminder moved". + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { POST as createEncounter } from "@/app/api/encounters/route"; +import { + PATCH as patchEncounter, + DELETE as deleteEncounter, +} from "@/app/api/encounters/[id]/route"; +import { GET as listReminders } from "@/app/api/measurement-reminders/route"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "appointment-owner"; + +/** Far enough out that the appointment is still in the future mid-run. */ +function daysFromNow(days: number): Date { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000); +} + +async function seedSession() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: OWNER_ID, + username: "appointment-owner", + email: "appointment-owner@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await prisma.session.create({ + data: { + userId: OWNER_ID, + expiresAt: daysFromNow(7), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function post(body: unknown): Promise { + return createEncounter( + new Request("http://localhost/api/encounters", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as never, + ); +} + +function patch(id: string, body: unknown): Promise { + return patchEncounter( + new Request(`http://localhost/api/encounters/${id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as never, + { params: Promise.resolve({ id }) } as never, + ); +} + +async function body(res: Response) { + return (await res.json()) as { data: Record }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedSession(); +}); + +describe("booking a visit", () => { + it("mints exactly one reminder, anchored on the appointment", async () => { + const prisma = getPrismaClient(); + const occurredAt = daysFromNow(14); + + const res = await post({ + occurredAt: occurredAt.toISOString(), + status: "PLANNED", + kind: "SPECIALIST", + }); + expect(res.status).toBe(201); + + const reminders = await prisma.measurementReminder.findMany({ + where: { userId: OWNER_ID }, + }); + expect(reminders).toHaveLength(1); + expect(reminders[0]!.origin).toBe("ENCOUNTER"); + // Both cadence columns NULL is the schema's one-shot: it fires once and + // the engine can never compute a second occurrence for it. + expect(reminders[0]!.intervalDays).toBeNull(); + expect(reminders[0]!.rrule).toBeNull(); + expect(reminders[0]!.nextDueAt?.toISOString()).toBe( + occurredAt.toISOString(), + ); + + const created = (await body(res)).data; + expect(created.reminderNextDueAt).toBe(occurredAt.toISOString()); + }); + + it("does not mint one for a visit that already happened", async () => { + const prisma = getPrismaClient(); + const res = await post({ + occurredAt: daysFromNow(-3).toISOString(), + status: "DONE", + }); + expect(res.status).toBe(201); + expect(await prisma.measurementReminder.count()).toBe(0); + }); + + it("saves with a date and nothing else", async () => { + // The product constraint, asserted rather than described: no practitioner, + // no kind, no reason, no link, and the visit still files. + const res = await post({ occurredAt: daysFromNow(-1).toISOString() }); + expect(res.status).toBe(201); + const created = (await body(res)).data; + expect(created.practitioner).toBeNull(); + expect(created.reason).toBeNull(); + expect(created.status).toBe("DONE"); + }); +}); + +describe("what the notification will say", () => { + it("labels a practice-less appointment in the person's own language", async () => { + const prisma = getPrismaClient(); + await prisma.user.update({ + where: { id: OWNER_ID }, + data: { locale: "de" }, + }); + + await post({ + occurredAt: daysFromNow(10).toISOString(), + status: "PLANNED", + kind: "SPECIALIST", + }); + + const reminder = await prisma.measurementReminder.findFirstOrThrow({ + where: { userId: OWNER_ID, origin: "ENCOUNTER" }, + }); + // The label IS the push body. A raw enum constant reaching a lock screen + // is not a fallback, it is a leak of the schema into the product. + expect(reminder.label).not.toBe("SPECIALIST"); + expect(reminder.label).not.toMatch(/^[A-Z_]+$/); + expect(reminder.label).toBe("Facharzttermin"); + }); + + it("prefers the practice name when there is one", async () => { + const prisma = getPrismaClient(); + const practitioner = await prisma.practitioner.create({ + data: { userId: OWNER_ID, name: "Praxis Nord" }, + }); + + await post({ + occurredAt: daysFromNow(10).toISOString(), + status: "PLANNED", + kind: "SPECIALIST", + practitionerId: practitioner.id, + }); + + const reminder = await prisma.measurementReminder.findFirstOrThrow({ + where: { userId: OWNER_ID, origin: "ENCOUNTER" }, + }); + expect(reminder.label).toBe("Praxis Nord"); + }); +}); + +describe("rescheduling", () => { + it("moves the same row and never mints a second", async () => { + const prisma = getPrismaClient(); + const created = ( + await body( + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }), + ) + ).data; + + const moved = daysFromNow(21); + const res = await patch(created.id as string, { + occurredAt: moved.toISOString(), + }); + expect(res.status).toBe(200); + + const reminders = await prisma.measurementReminder.findMany({ + where: { userId: OWNER_ID }, + }); + // The count is the assertion. "The reminder moved" would also be true of a + // service that left the old row behind and minted a fresh one. + expect(reminders).toHaveLength(1); + expect(reminders[0]!.nextDueAt?.toISOString()).toBe(moved.toISOString()); + }); + + it("stops nudging once the visit is cancelled, without deleting the row", async () => { + const prisma = getPrismaClient(); + const created = ( + await body( + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }), + ) + ).data; + + await patch(created.id as string, { status: "CANCELLED" }); + + const reminders = await prisma.measurementReminder.findMany({ + where: { userId: OWNER_ID }, + }); + expect(reminders).toHaveLength(1); + expect(reminders[0]!.enabled).toBe(false); + expect(reminders[0]!.nextDueAt).toBeNull(); + }); + + it("retires the reminder when the visit is deleted", async () => { + const prisma = getPrismaClient(); + const created = ( + await body( + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }), + ) + ).data; + + const res = await deleteEncounter( + new Request(`http://localhost/api/encounters/${created.id}`, { + method: "DELETE", + }) as never, + { params: Promise.resolve({ id: created.id as string }) } as never, + ); + expect(res.status).toBe(200); + + const reminder = await prisma.measurementReminder.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect(reminder.deletedAt).not.toBeNull(); + expect(reminder.nextDueAt).toBeNull(); + }); +}); + +/** + * The orphan class, replayed as the sequence that produced it. + * + * A reminder nobody points at still fires: the cron reads the engine table, not + * the visit list. So an ENCOUNTER-origin row that no encounter owns pushes + * notifications for an appointment the person can no longer see, edit or + * cancel, and deleting the visit does not reach it. Every assertion below + * counts LIVE ENCOUNTER rows rather than checking that some particular row + * moved, because the defect was an extra row rather than a wrong one. + */ +async function liveEncounterReminders() { + return getPrismaClient().measurementReminder.findMany({ + where: { userId: OWNER_ID, origin: "ENCOUNTER", deletedAt: null }, + }); +} + +describe("an appointment reminder can never be orphaned", () => { + async function seedCheckup() { + return getPrismaClient().measurementReminder.create({ + data: { + userId: OWNER_ID, + label: "Annual blood panel", + intervalDays: 365, + anchorDate: daysFromNow(-370), + notifyHour: 9, + nextDueAt: daysFromNow(-5), + }, + }); + } + + it("refuses to point a still-booked visit at a checkup", async () => { + const created = ( + await body( + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }), + ) + ).data; + const checkup = await seedCheckup(); + + const res = await patch(created.id as string, { reminderId: checkup.id }); + + // The same contradiction the create arm already refuses: a visit that has + // not happened cannot have closed anything. + expect(res.status).toBe(422); + expect(await liveEncounterReminders()).toHaveLength(1); + }); + + it("keeps exactly one live reminder across the sequence that produced the orphan", async () => { + const created = ( + await body( + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }), + ) + ).data; + const checkup = await seedCheckup(); + const first = await liveEncounterReminders(); + expect(first).toHaveLength(1); + + // 1. Re-point at a checkup — refused while the visit is still booked. + await patch(created.id as string, { reminderId: checkup.id }); + expect(await liveEncounterReminders()).toHaveLength(1); + + // 2. Move the date. Before the fix this minted a SECOND reminder, because + // the re-anchor path looked only at the visit's foreign key and that key + // had been walked off its own row by step 1. + const moved = daysFromNow(21); + expect( + (await patch(created.id as string, { occurredAt: moved.toISOString() })) + .status, + ).toBe(200); + + const after = await liveEncounterReminders(); + expect(after).toHaveLength(1); + expect(after[0]!.nextDueAt?.toISOString()).toBe(moved.toISOString()); + }); + + it("leaves nothing live behind when the visit is deleted", async () => { + const created = ( + await body( + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }), + ) + ).data; + const checkup = await seedCheckup(); + await patch(created.id as string, { reminderId: checkup.id }); + await patch(created.id as string, { + occurredAt: daysFromNow(21).toISOString(), + }); + + await deleteEncounter( + new Request(`http://localhost/api/encounters/${created.id}`, { + method: "DELETE", + }) as never, + { params: Promise.resolve({ id: created.id as string }) } as never, + ); + + // Nothing may still be armed for a visit that no longer exists. + expect(await liveEncounterReminders()).toEqual([]); + // The person's own checkup is untouched — it was never this visit's to retire. + expect( + ( + await getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }) + ).deletedAt, + ).toBeNull(); + }); + + it("switching a booked visit to done leaves no live appointment reminder", async () => { + const created = ( + await body( + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }), + ) + ).data; + + await patch(created.id as string, { + status: "DONE", + occurredAt: daysFromNow(-1).toISOString(), + }); + + // The row may survive as history, but it must not still be armed. + const live = await liveEncounterReminders(); + for (const row of live) { + expect(row.enabled).toBe(false); + expect(row.nextDueAt).toBeNull(); + } + }); +}); + +/** + * The orphan sweep, in both directions. + * + * The tests above prove the sequence that produced an orphan no longer does. + * These two pin the sweep itself, because it is the part that makes the state + * unreachable rather than merely unproduced — and because its retention rule + * is subtler than it looks. + * + * It reaps by the RELATION (`encounters: { none: {} }`), which counts visits + * pointing at a reminder without caring whether those visits are tombstoned. + * That asymmetry is deliberate and is the second test: a soft-deleted visit + * still owns its reminder, so restoring the visit finds its nudge intact. A + * sweep that also read the visit's tombstone would quietly destroy that on the + * next unrelated write, and nothing else in the system would notice. + */ +describe("the orphan sweep", () => { + /** A live appointment row with no visit pointing at it — the state itself. */ + async function strandedReminder() { + return getPrismaClient().measurementReminder.create({ + data: { + userId: OWNER_ID, + label: "Stranded appointment", + origin: "ENCOUNTER", + anchorDate: daysFromNow(5), + notifyHour: 9, + nextDueAt: daysFromNow(5), + enabled: true, + }, + }); + } + + /** Any visit write; the sweep rides the transaction rather than a route. */ + async function anyVisitWrite() { + const created = ( + await body( + await post({ + occurredAt: daysFromNow(-4).toISOString(), + status: "DONE", + }), + ) + ).data; + expect( + (await patch(created.id as string, { kind: "ROUTINE" })).status, + ).toBe(200); + } + + it("tombstones a row no visit claims", async () => { + const prisma = getPrismaClient(); + const stranded = await strandedReminder(); + + await anyVisitWrite(); + + const after = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: stranded.id }, + }); + // Disarmed AND tombstoned. Disabling alone would leave a row the engine + // ignores but every future reader still has to reason about. + expect(after.deletedAt).not.toBeNull(); + expect(after.enabled).toBe(false); + expect(after.nextDueAt).toBeNull(); + }); + + it("leaves a row that a SOFT-DELETED visit still claims", async () => { + const prisma = getPrismaClient(); + await post({ + occurredAt: daysFromNow(9).toISOString(), + status: "PLANNED", + }); + const owned = await prisma.measurementReminder.findFirstOrThrow({ + where: { userId: OWNER_ID, origin: "ENCOUNTER" }, + }); + const visit = await prisma.encounter.findFirstOrThrow({ + where: { userId: OWNER_ID, reminderId: owned.id }, + }); + + // Tombstoned directly rather than through the delete route: that route + // retires the reminder on purpose, which would prove nothing about the + // sweep. This is the state a soft-deleted visit is actually in. + await prisma.encounter.update({ + where: { id: visit.id }, + data: { deletedAt: new Date() }, + }); + + await anyVisitWrite(); + + const after = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: owned.id }, + }); + // Still claimed, so still there. Restoring the visit has to find its nudge. + expect(after.deletedAt).toBeNull(); + expect(after.enabled).toBe(true); + }); +}); + +describe("closing a checkup by filing a visit", () => { + async function seedCheckup() { + return getPrismaClient().measurementReminder.create({ + data: { + userId: OWNER_ID, + label: "Annual blood panel", + intervalDays: 365, + anchorDate: daysFromNow(-370), + notifyHour: 9, + nextDueAt: daysFromNow(-5), + }, + }); + } + + it("advances the checkup when the visit is filed as done", async () => { + const prisma = getPrismaClient(); + const checkup = await seedCheckup(); + + const res = await post({ + occurredAt: daysFromNow(-1).toISOString(), + status: "DONE", + reminderId: checkup.id, + }); + expect(res.status).toBe(201); + + const after = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }); + expect(after.lastSatisfiedAt).not.toBeNull(); + expect(after.nextDueAt!.getTime()).toBeGreaterThan(Date.now()); + }); + + it("is idempotent — filing it done twice changes nothing and still succeeds", async () => { + const prisma = getPrismaClient(); + const checkup = await seedCheckup(); + const created = ( + await body( + await post({ + occurredAt: daysFromNow(-1).toISOString(), + status: "DONE", + reminderId: checkup.id, + }), + ) + ).data; + + const first = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }); + + const res = await patch(created.id as string, { status: "DONE" }); + expect(res.status).toBe(200); + + const second = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }); + expect(second.lastSatisfiedAt?.toISOString()).toBe( + first.lastSatisfiedAt?.toISOString(), + ); + expect(second.nextDueAt?.toISOString()).toBe( + first.nextDueAt?.toISOString(), + ); + }); + + it("anchors the checkup on the visit date, not on when it was typed in", async () => { + const prisma = getPrismaClient(); + const checkup = await seedCheckup(); + const visitAt = daysFromNow(-30); + + await post({ + occurredAt: visitAt.toISOString(), + status: "DONE", + reminderId: checkup.id, + }); + + const after = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }); + // A yearly panel drawn thirty days ago is next due 335 days from now, not + // 365. Satisfying at "now" would quietly move every backdated entry's next + // due date forward by however long the person took to record it. + expect(after.lastSatisfiedAt?.toISOString()).toBe(visitAt.toISOString()); + const daysOut = + (after.nextDueAt!.getTime() - Date.now()) / (24 * 60 * 60 * 1000); + expect(daysOut).toBeGreaterThan(330); + expect(daysOut).toBeLessThan(340); + }); + + it("does not advance the checkup again when a visit re-enters DONE", async () => { + const prisma = getPrismaClient(); + const checkup = await seedCheckup(); + const created = ( + await body( + await post({ + occurredAt: daysFromNow(-1).toISOString(), + status: "DONE", + reminderId: checkup.id, + }), + ) + ).data; + const first = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }); + + // Correcting a mis-filed visit is an ordinary thing to do, and the round + // trip is a transition each way — so the status gate alone does not stop + // the second satisfy. Satisfying at the VISIT instant does: it is not + // strictly after the last one, and the primitive is forward-only. + await patch(created.id as string, { status: "NO_SHOW" }); + expect((await patch(created.id as string, { status: "DONE" })).status).toBe( + 200, + ); + + const second = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }); + expect(second.lastSatisfiedAt?.toISOString()).toBe( + first.lastSatisfiedAt?.toISOString(), + ); + expect(second.nextDueAt?.toISOString()).toBe( + first.nextDueAt?.toISOString(), + ); + }); + + it("refuses a planned visit that also claims to close a checkup", async () => { + const checkup = await seedCheckup(); + const res = await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + reminderId: checkup.id, + }); + // A visit that has not happened cannot have closed anything. + expect(res.status).toBe(422); + }); + + it("refuses an appointment reminder as the checkup to close", async () => { + const prisma = getPrismaClient(); + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }); + const appointment = await prisma.measurementReminder.findFirstOrThrow({ + where: { userId: OWNER_ID, origin: "ENCOUNTER" }, + }); + + const res = await post({ + occurredAt: daysFromNow(-1).toISOString(), + status: "DONE", + reminderId: appointment.id, + }); + // Accepting it would let one visit re-point another visit's reminder. + expect(res.status).toBe(404); + }); +}); + +describe("the list window", () => { + it("honours an upper bound that is earlier than now", async () => { + // The past arm bounds itself at `now`, and the user's own `to` used to be + // overwritten by that bound rather than combined with it, so every + // narrower window silently widened to "everything up to today". + await post({ occurredAt: daysFromNow(-2).toISOString(), status: "DONE" }); + + const { GET: listVisits } = await import("@/app/api/encounters/route"); + const res = await listVisits( + new Request( + `http://localhost/api/encounters?to=${daysFromNow(-30).toISOString()}`, + ) as never, + ); + expect(res.status).toBe(200); + const listed = (await res.json()) as { + data: { upcoming: unknown[]; past: unknown[] }; + }; + expect(listed.data.past).toEqual([]); + expect(listed.data.upcoming).toEqual([]); + }); + + it("still returns a visit inside the window", async () => { + // The counterpart, so the fix cannot be "return nothing". + await post({ occurredAt: daysFromNow(-2).toISOString(), status: "DONE" }); + + const { GET: listVisits } = await import("@/app/api/encounters/route"); + const res = await listVisits( + new Request( + `http://localhost/api/encounters?from=${daysFromNow(-5).toISOString()}&to=${daysFromNow(-1).toISOString()}`, + ) as never, + ); + const listed = (await res.json()) as { data: { past: unknown[] } }; + expect(listed.data.past).toHaveLength(1); + }); +}); + +describe("an appointment is not addressable through the reminder API", () => { + async function bookedReminder() { + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }); + return getPrismaClient().measurementReminder.findFirstOrThrow({ + where: { userId: OWNER_ID, origin: "ENCOUNTER" }, + }); + } + + it("404s the read, and does not leak the appointment", async () => { + const reminder = await bookedReminder(); + const { GET } = await import("@/app/api/measurement-reminders/[id]/route"); + const res = await GET( + new Request( + `http://localhost/api/measurement-reminders/${reminder.id}`, + ) as never, + { params: Promise.resolve({ id: reminder.id }) } as never, + ); + expect(res.status).toBe(404); + }); + + it("404s complete WITHOUT committing the satisfy", async () => { + // The shape that mattered most: this is the endpoint behind the Done + // button on an appointment push. It used to write, then 500 on the mapper. + const reminder = await bookedReminder(); + const { POST: complete } = + await import("@/app/api/measurement-reminders/[id]/complete/route"); + const res = await complete( + new Request( + `http://localhost/api/measurement-reminders/${reminder.id}/complete`, + { method: "POST" }, + ) as never, + { params: Promise.resolve({ id: reminder.id }) } as never, + ); + + expect(res.status).toBe(404); + const after = await getPrismaClient().measurementReminder.findUniqueOrThrow( + { where: { id: reminder.id } }, + ); + expect(after.lastSatisfiedAt).toBeNull(); + expect(after.nextDueAt?.toISOString()).toBe( + reminder.nextDueAt?.toISOString(), + ); + }); + + it("404s satisfy WITHOUT committing it", async () => { + const reminder = await bookedReminder(); + const { POST: satisfy } = + await import("@/app/api/measurement-reminders/[id]/satisfy/route"); + const res = await satisfy( + new Request( + `http://localhost/api/measurement-reminders/${reminder.id}/satisfy`, + { method: "POST" }, + ) as never, + { params: Promise.resolve({ id: reminder.id }) } as never, + ); + + expect(res.status).toBe(404); + expect( + ( + await getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id: reminder.id }, + }) + ).lastSatisfiedAt, + ).toBeNull(); + }); + + it("404s the edit, so a one-shot cannot be given a cadence", async () => { + const reminder = await bookedReminder(); + const { PATCH: editReminder } = + await import("@/app/api/measurement-reminders/[id]/route"); + const res = await editReminder( + new Request(`http://localhost/api/measurement-reminders/${reminder.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ intervalDays: 7 }), + }) as never, + { params: Promise.resolve({ id: reminder.id }) } as never, + ); + + expect(res.status).toBe(404); + // A cadence here would turn a spent one-shot into a permanent re-nag that + // shows up in no list the person can reach. + expect( + ( + await getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id: reminder.id }, + }) + ).intervalDays, + ).toBeNull(); + }); + + it("404s the delete, so the row survives for the visit that owns it", async () => { + const reminder = await bookedReminder(); + const { DELETE: destroy } = + await import("@/app/api/measurement-reminders/[id]/route"); + const res = await destroy( + new Request(`http://localhost/api/measurement-reminders/${reminder.id}`, { + method: "DELETE", + }) as never, + { params: Promise.resolve({ id: reminder.id }) } as never, + ); + + expect(res.status).toBe(404); + // This verb hard-deletes. A delegate holding only the measurements domain + // reaching it would destroy a row belonging to the profile domain. + expect( + await getPrismaClient().measurementReminder.count({ + where: { id: reminder.id }, + }), + ).toBe(1); + }); +}); + +describe("the Vorsorge list", () => { + it("does not show a booked appointment as a checkup", async () => { + const prisma = getPrismaClient(); + await prisma.measurementReminder.create({ + data: { + userId: OWNER_ID, + label: "Annual blood panel", + intervalDays: 365, + notifyHour: 9, + nextDueAt: daysFromNow(30), + }, + }); + await post({ + occurredAt: daysFromNow(14).toISOString(), + status: "PLANNED", + }); + + // Two rows exist on the engine; one of them is an appointment. + expect(await prisma.measurementReminder.count()).toBe(2); + + const res = await listReminders(); + expect(res.status).toBe(200); + const listed = (await res.json()) as { + data: Array<{ label: string; origin: string }>; + }; + // Exactly the checkup. Had the exclusion been missing, the mapper would + // have thrown and this would be a 500 rather than a wrong list — which is + // the point of the refusal. + expect(listed.data).toHaveLength(1); + expect(listed.data[0]!.label).toBe("Annual blood panel"); + }); +}); diff --git a/tests/integration/encounter-scoped-delegate.test.ts b/tests/integration/encounter-scoped-delegate.test.ts new file mode 100644 index 000000000..524155ccb --- /dev/null +++ b/tests/integration/encounter-scoped-delegate.test.ts @@ -0,0 +1,351 @@ +/** + * What a NARROWLY scoped delegate may learn, and may do, through a visit. + * + * Visits sit in the health-background domain and link out to three others: the + * document vault, the lab results and the illness journal. That makes a visit a + * seam between domains, and a seam is where a scoped grant leaks. + * + * Two properties, both about somebody holding `profile` and nothing else: + * + * 1. **Names do not flow through the seam.** The values never did — the link + * list carries an id, a label and a date, not the document body or the + * analyte's result. But "Ferritin, 30 June" and "Oncology discharge + * letter" are themselves the sensitive part, and a grant that opened only + * the health background was never consent for them. A delegate without + * the target's domain gets the shape of the link and not its name. + * 2. **Closing a checkup needs the checkup's domain.** Satisfying one + * re-anchors a preventive-care cadence, which is a measurements-domain + * write. A profile-only delegate filing a visit still files it; the + * closure is skipped and SAID, rather than performed silently or refused + * with the whole request. + * + * The owner is checked in every case too, because a fix that reads "hide it + * from everyone" would satisfy half of these and break the product. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import type { ShareDomain } from "@/lib/sharing/scope"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "scoped-visit-owner"; +const DELEGATE_ID = "scoped-visit-delegate"; + +function daysFromNow(days: number): Date { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: daysFromNow(1) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +/** Sign in as the delegate and switch into the owner's record. */ +async function switchInto( + access: "READ" | "WRITE" | "MANAGE", + scope: ShareDomain[] | null, +) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access, + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await signIn(DELEGATE_ID); + await switchSessionTo(session.id, OWNER_ID); +} + +async function seedRecord() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `scoped-visit-${name}`, + email: `scoped-visit-${name}@example.test`, + timezone: "Europe/Berlin", + }, + }); + } + + const document = await prisma.inboundDocument.create({ + data: { + userId: OWNER_ID, + kind: "LAB_RESULT", + title: "Oncology discharge letter", + mimeType: "application/pdf", + byteSize: 10, + contentEncrypted: new Uint8Array([1, 2, 3]), + contentCodec: "binary2", + documentDate: daysFromNow(-3), + }, + }); + const labResult = await prisma.labResult.create({ + data: { + userId: OWNER_ID, + analyte: "Ferritin", + panel: "Iron panel", + value: 91, + unit: "ng/mL", + takenAt: daysFromNow(-3), + }, + }); + const episode = await prisma.illnessEpisode.create({ + data: { + userId: OWNER_ID, + label: "Hashimoto thyroiditis", + type: "AUTOIMMUNE", + onsetAt: daysFromNow(-200), + }, + }); + const encounter = await prisma.encounter.create({ + data: { + userId: OWNER_ID, + occurredAt: daysFromNow(-2), + status: "DONE", + kind: "SPECIALIST", + }, + }); + await prisma.encounterDocumentLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + documentId: document.id, + }, + }); + await prisma.encounterLabLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + labResultId: labResult.id, + }, + }); + await prisma.encounterConditionLink.create({ + data: { + userId: OWNER_ID, + encounterId: encounter.id, + episodeId: episode.id, + }, + }); + return { document, labResult, episode, encounter }; +} + +type Link = { + id: string; + label: string | null; + date: string | null; + redacted: boolean; +}; + +type Links = { + documents: Link[]; + labResults: Link[]; + conditions: Link[]; +}; + +async function readLinks(encounterId: string): Promise { + const { GET } = await import("@/app/api/encounters/[id]/route"); + const res = await GET( + new Request(`http://localhost/api/encounters/${encounterId}`) as never, + { params: Promise.resolve({ id: encounterId }) } as never, + ); + expect(res.status).toBe(200); + const parsed = (await res.json()) as { data: { links: Links } }; + return parsed.data.links; +} + +let seeded: Awaited>; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + seeded = await seedRecord(); +}); + +describe("link labels follow the target's own domain", () => { + it("gives the owner every name", async () => { + await signIn(OWNER_ID); + const links = await readLinks(seeded.encounter.id); + + expect(links.documents[0]!.label).toBe("Oncology discharge letter"); + expect(links.labResults[0]!.label).toBe("Iron panel · Ferritin"); + expect(links.conditions[0]!.label).toBe("Hashimoto thyroiditis"); + expect(links.documents[0]!.redacted).toBe(false); + }); + + it("gives an entire-record delegate every name", async () => { + await switchInto("READ", null); + const links = await readLinks(seeded.encounter.id); + + // The ordinary sharing case must not change. + expect(links.documents[0]!.label).toBe("Oncology discharge letter"); + expect(links.labResults[0]!.label).toBe("Iron panel · Ferritin"); + expect(links.conditions[0]!.label).toBe("Hashimoto thyroiditis"); + }); + + it("withholds all three names from a profile-only delegate", async () => { + await switchInto("READ", ["profile"]); + const links = await readLinks(seeded.encounter.id); + + // The links still exist — the visit produced three things and saying so is + // within the grant. What they are called is not. + expect(links.documents).toHaveLength(1); + expect(links.labResults).toHaveLength(1); + expect(links.conditions).toHaveLength(1); + + // Null rather than an invented placeholder: the server is not telling + // this caller what the thing is called, and inventing a name here would + // make an absence look like a value. The array the entry sits in is the + // kind, so the client still renders "a linked lab result" from its own + // bundle without the server shipping English down the wire. + for (const link of [ + links.documents[0]!, + links.labResults[0]!, + links.conditions[0]!, + ]) { + expect(link.label).toBeNull(); + expect(link.redacted).toBe(true); + // The date is the other half of the identification and goes with the name. + expect(link.date).toBeNull(); + // The id stays: it is already the caller's own link row. + expect(link.id).toBeTruthy(); + } + }); + + it("reveals only the domain the delegate actually holds", async () => { + await switchInto("READ", ["profile", "labs"]); + const links = await readLinks(seeded.encounter.id); + + // The point of doing this per target rather than all-or-nothing. + expect(links.labResults[0]!.label).toBe("Iron panel · Ferritin"); + expect(links.labResults[0]!.redacted).toBe(false); + expect(links.documents[0]!.label).toBeNull(); + expect(links.conditions[0]!.label).toBeNull(); + }); +}); + +describe("closing a checkup needs the checkup's own domain", () => { + async function seedCheckup() { + return getPrismaClient().measurementReminder.create({ + data: { + userId: OWNER_ID, + label: "Annual blood panel", + intervalDays: 365, + anchorDate: daysFromNow(-370), + notifyHour: 9, + nextDueAt: daysFromNow(-5), + }, + }); + } + + async function fileVisit(reminderId: string) { + const { POST } = await import("@/app/api/encounters/route"); + return POST( + new Request("http://localhost/api/encounters", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + occurredAt: daysFromNow(-1).toISOString(), + status: "DONE", + reminderId, + }), + }) as never, + ); + } + + it("closes it for the owner", async () => { + const checkup = await seedCheckup(); + await signIn(OWNER_ID); + + expect((await fileVisit(checkup.id)).status).toBe(201); + expect( + ( + await getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }) + ).lastSatisfiedAt, + ).not.toBeNull(); + }); + + it("files the visit but skips the closure for a profile-only delegate", async () => { + const checkup = await seedCheckup(); + await switchInto("WRITE", ["profile"]); + + const res = await fileVisit(checkup.id); + // The visit is theirs to file; refusing the whole request over a side + // effect they cannot perform would lose the record for no gain. + expect(res.status).toBe(201); + const parsed = (await res.json()) as { + data: { + skipped: { links: number; catalogueKeys: Array<{ key: string }> }; + }; + }; + + // Skipped, and SAID. Silence here is the failure mode: the person would + // believe a checkup was closed that is still due. + expect(parsed.data.skipped.links).toBe(1); + expect(parsed.data.skipped.catalogueKeys[0]!.key).toBe(checkup.id); + + expect( + ( + await getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }) + ).lastSatisfiedAt, + ).toBeNull(); + expect(await getPrismaClient().encounter.count()).toBe(2); + }); + + it("closes it for a delegate who holds measurements too", async () => { + const checkup = await seedCheckup(); + await switchInto("WRITE", ["profile", "measurements"]); + + const res = await fileVisit(checkup.id); + expect(res.status).toBe(201); + const parsed = (await res.json()) as { + data: { skipped: { links: number } }; + }; + expect(parsed.data.skipped.links).toBe(0); + expect( + ( + await getPrismaClient().measurementReminder.findUniqueOrThrow({ + where: { id: checkup.id }, + }) + ).lastSatisfiedAt, + ).not.toBeNull(); + }); +}); diff --git a/tests/integration/link-service.test.ts b/tests/integration/link-service.test.ts new file mode 100644 index 000000000..19b12f81f --- /dev/null +++ b/tests/integration/link-service.test.ts @@ -0,0 +1,463 @@ +/** + * The link service against real Postgres. + * + * Almost nothing asserted here is unit-testable, and that is the reason the + * file exists rather than a mocked sibling. Ownership refusal is a `where` + * clause; the duplicate no-op is a unique index plus `skipDuplicates`; the + * soft-delete exclusion is another `where`. A fake client that ignored `where` + * would report every one of them as working, which is precisely the shape of + * green test this repository has been burned by. + * + * Two accounts are seeded for every case, because "scoped to the owner" is a + * claim about the SECOND account and a single-account fixture cannot see it. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { + MAX_TARGETS_PER_CALL, + linkTargets, + listTargets, + listDistinctTargets, + listTargetsBySource, + replaceTargets, + unlinkTargets, +} from "@/lib/links"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER = "link-owner"; +const STRANGER = "link-stranger"; + +async function seedAccount(id: string) { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { id, username: id, email: `${id}@example.test` }, + }); + const encounter = await prisma.encounter.create({ + data: { + userId: id, + occurredAt: new Date("2026-07-01T09:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + }, + }); + const document = await prisma.inboundDocument.create({ + data: { + userId: id, + kind: "LAB_RESULT", + title: `${id} report`, + mimeType: "application/pdf", + byteSize: 12, + contentEncrypted: new Uint8Array([1, 2, 3]), + contentCodec: "binary2", + documentDate: new Date("2026-06-30T00:00:00.000Z"), + }, + }); + const labResult = await prisma.labResult.create({ + data: { + userId: id, + analyte: "Ferritin", + panel: "Iron panel", + value: 91, + unit: "ng/mL", + takenAt: new Date("2026-06-30T09:00:00.000Z"), + }, + }); + const episode = await prisma.illnessEpisode.create({ + data: { + userId: id, + label: `${id} condition`, + type: "INFECTION", + onsetAt: new Date("2026-06-20T00:00:00.000Z"), + }, + }); + return { encounter, document, labResult, episode }; +} + +let owner: Awaited>; +let stranger: Awaited>; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + owner = await seedAccount(OWNER); + stranger = await seedAccount(STRANGER); +}); + +describe("link service — ownership", () => { + it("refuses a target the caller does not own, and writes nothing", async () => { + const prisma = getPrismaClient(); + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "document", + targetIds: [stranger.document.id], + }); + + expect(result.changed).toBe(0); + expect(result.unknownTargetIds).toEqual([stranger.document.id]); + // The refusal has to be visible in the table, not only in the return + // value: a service that reported zero and wrote the row anyway would + // satisfy the assertion above. + expect(await prisma.encounterDocumentLink.count()).toBe(0); + }); + + it("refuses a source the caller does not own", async () => { + const prisma = getPrismaClient(); + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: stranger.encounter.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + + expect(result.unknownSource).toBe(true); + expect(await prisma.encounterDocumentLink.count()).toBe(0); + }); + + it("links the owned ids and names the foreign ones in the same call", async () => { + const prisma = getPrismaClient(); + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "document", + targetIds: [owner.document.id, stranger.document.id], + }); + + expect(result.changed).toBe(1); + expect(result.unknownTargetIds).toEqual([stranger.document.id]); + expect(await prisma.encounterDocumentLink.count()).toBe(1); + }); +}); + +describe("link service — idempotency", () => { + it("linking the same pair twice succeeds and changes nothing the second time", async () => { + const prisma = getPrismaClient(); + const request = { + userId: OWNER, + sourceKind: "encounter" as const, + sourceId: owner.encounter.id, + targetKind: "labResult" as const, + targetIds: [owner.labResult.id], + }; + + const first = await linkTargets(prisma, request); + const second = await linkTargets(prisma, request); + + expect(first.changed).toBe(1); + expect(second.changed).toBe(0); + expect(await prisma.encounterLabLink.count()).toBe(1); + }); + + it("unlinking a pair that was never linked is a success", async () => { + const prisma = getPrismaClient(); + const result = await unlinkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "labResult", + targetIds: [owner.labResult.id], + }); + expect(result.changed).toBe(0); + }); + + it("counts a duplicated id in one call once", async () => { + const prisma = getPrismaClient(); + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "conditionEpisode", + targetIds: [owner.episode.id, owner.episode.id], + }); + expect(result.changed).toBe(1); + expect(await prisma.encounterConditionLink.count()).toBe(1); + }); +}); + +describe("link service — the cap", () => { + it("refuses a call naming more targets than the cap", async () => { + const prisma = getPrismaClient(); + const tooMany = Array.from( + { length: MAX_TARGETS_PER_CALL + 1 }, + (_, i) => `target-${i}`, + ); + await expect( + linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "document", + targetIds: tooMany, + }), + ).rejects.toThrow(/above the cap/); + }); + + it("accepts a call exactly at the cap", async () => { + const prisma = getPrismaClient(); + const atCap = Array.from( + { length: MAX_TARGETS_PER_CALL }, + (_, i) => `target-${i}`, + ); + // Every id is unknown, so nothing is written — the point is that the cap + // itself does not reject a call sitting on the boundary. + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "document", + targetIds: atCap, + }); + expect(result.unknownTargetIds).toHaveLength(MAX_TARGETS_PER_CALL); + }); +}); + +describe("link service — soft deletes", () => { + it("will not link a tombstoned target", async () => { + const prisma = getPrismaClient(); + await prisma.inboundDocument.update({ + where: { id: owner.document.id }, + data: { deletedAt: new Date() }, + }); + + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + expect(result.unknownTargetIds).toEqual([owner.document.id]); + expect(await prisma.encounterDocumentLink.count()).toBe(0); + }); + + it("will not link onto a tombstoned source", async () => { + const prisma = getPrismaClient(); + await prisma.encounter.update({ + where: { id: owner.encounter.id }, + data: { deletedAt: new Date() }, + }); + + const result = await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + expect(result.unknownSource).toBe(true); + }); + + it("still unlinks a target the account has since deleted", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + await prisma.inboundDocument.update({ + where: { id: owner.document.id }, + data: { deletedAt: new Date() }, + }); + + // Refusing here would leave a filing nobody can clear. + const result = await unlinkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + expect(result.changed).toBe(1); + }); + + it("drops a tombstoned target out of the resolved list", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + await prisma.inboundDocument.update({ + where: { id: owner.document.id }, + data: { deletedAt: new Date() }, + }); + + const listed = await listTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "document", + }); + expect(listed).toEqual([]); + }); +}); + +describe("link service — replace", () => { + it("drops what is no longer named and adds what is missing", async () => { + const prisma = getPrismaClient(); + const second = await prisma.illnessEpisode.create({ + data: { + userId: OWNER, + label: "Second condition", + type: "INFECTION", + onsetAt: new Date("2026-06-25T00:00:00.000Z"), + }, + }); + + await replaceTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "conditionEpisode", + targetIds: [owner.episode.id], + }); + await replaceTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "conditionEpisode", + targetIds: [second.id], + }); + + const linked = await prisma.encounterConditionLink.findMany({ + where: { encounterId: owner.encounter.id }, + select: { episodeId: true }, + }); + expect(linked.map((row) => row.episodeId)).toEqual([second.id]); + }); + + it("clears every link when the set is empty", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "conditionEpisode", + targetIds: [owner.episode.id], + }); + await replaceTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "conditionEpisode", + targetIds: [], + }); + expect(await prisma.encounterConditionLink.count()).toBe(0); + }); +}); + +describe("link service — resolved reads", () => { + it("returns a label and a date, never a bare id", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "labResult", + targetIds: [owner.labResult.id], + }); + + const listed = await listTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "labResult", + }); + expect(listed).toEqual([ + { + id: owner.labResult.id, + label: "Iron panel · Ferritin", + date: "2026-06-30T09:00:00.000Z", + }, + ]); + }); + + it("keys a batched read by source and omits sources with no links", async () => { + const prisma = getPrismaClient(); + const other = await prisma.encounter.create({ + data: { + userId: OWNER, + occurredAt: new Date("2026-07-02T09:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + }, + }); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: owner.encounter.id, + targetKind: "document", + targetIds: [owner.document.id], + }); + + const bySource = await listTargetsBySource(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceIds: [owner.encounter.id, other.id], + targetKind: "document", + }); + expect(bySource.get(owner.encounter.id)).toHaveLength(1); + expect(bySource.has(other.id)).toBe(false); + }); + + it("offers a distinct target only while a live source still points at it", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: OWNER, + sourceKind: "document", + sourceId: owner.document.id, + targetKind: "conditionEpisode", + targetIds: [owner.episode.id], + }); + + expect( + await listDistinctTargets(prisma, { + userId: OWNER, + sourceKind: "document", + targetKind: "conditionEpisode", + }), + ).toHaveLength(1); + + await prisma.inboundDocument.update({ + where: { id: owner.document.id }, + data: { deletedAt: new Date() }, + }); + + expect( + await listDistinctTargets(prisma, { + userId: OWNER, + sourceKind: "document", + targetKind: "conditionEpisode", + }), + ).toEqual([]); + }); + + it("does not leak another account's links into a read", async () => { + const prisma = getPrismaClient(); + await linkTargets(prisma, { + userId: STRANGER, + sourceKind: "encounter", + sourceId: stranger.encounter.id, + targetKind: "document", + targetIds: [stranger.document.id], + }); + + const listed = await listTargets(prisma, { + userId: OWNER, + sourceKind: "encounter", + sourceId: stranger.encounter.id, + targetKind: "document", + }); + expect(listed).toEqual([]); + }); +}); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index e5b4f70b0..b687abd98 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -281,7 +281,7 @@ const ROUTE_IMPORTS = import.meta.glob>( describe("the complete MANAGE handler matrix", () => { it("starts with a non-empty, exact handler inventory", () => { - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(62); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(70); }); }); @@ -512,6 +512,31 @@ async function makeEpisode(userId: string) { }); } +async function makePractitioner(userId: string) { + return getPrismaClient().practitioner.create({ + data: { userId, name: "Praxis Nord", specialty: "Internal medicine" }, + }); +} + +/** + * A visit that already happened. + * + * Past rather than booked on purpose: a PLANNED future visit mints an + * appointment reminder, and the drivers below assert one effect each. Keeping + * the fixture in the past leaves the edit and delete verbs saying only what + * they are here to say. + */ +async function makeVisit(userId: string) { + return getPrismaClient().encounter.create({ + data: { + userId, + occurredAt: new Date(ISO), + status: "DONE", + kind: "ROUTINE", + }, + }); +} + async function makeFamilyHistory(userId: string) { return getPrismaClient().familyHistoryEntry.create({ data: { userId, relationship: "MOTHER", condition: "Hypertension" }, @@ -1227,6 +1252,193 @@ manageContract("POST /api/illness/episodes/[id]/restore", { )?.deletedAt === null, }); +writeEffectContract("POST /api/encounters", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => entry.route === "/api/encounters" && entry.action === "POST", + )!, + prepare: async (ownerId) => ({ ownerId }), + act: async () => { + const { POST } = await import("@/app/api/encounters/route"); + return call(POST as Handler, "POST", "/api/encounters", { + occurredAt: ISO, + status: "DONE", + }); + }, + ok: 201, + auditAction: "encounter.visit.create", + applied: async ({ ownerId }) => + (await getPrismaClient().encounter.count({ + where: { userId: ownerId }, + })) === 1, +}); + +manageContract("PATCH /api/encounters/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/encounters/[id]" && entry.action === "PATCH", + )!, + prepare: (ownerId) => makeVisit(ownerId), + act: async (row) => { + const { PATCH } = await import("@/app/api/encounters/[id]/route"); + return call( + PATCH as Handler, + "PATCH", + `/api/encounters/${row.id}`, + { kind: "SPECIALIST" }, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "encounter.visit.update", + applied: async (row) => + (await getPrismaClient().encounter.findUnique({ where: { id: row.id } })) + ?.kind === "SPECIALIST", +}); + +manageContract("DELETE /api/encounters/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/encounters/[id]" && entry.action === "DELETE", + )!, + prepare: (ownerId) => makeVisit(ownerId), + act: async (row) => { + const { DELETE } = await import("@/app/api/encounters/[id]/route"); + return call( + DELETE as Handler, + "DELETE", + `/api/encounters/${row.id}`, + undefined, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "encounter.visit.delete", + applied: async (row) => + (await getPrismaClient().encounter.findUnique({ where: { id: row.id } })) + ?.deletedAt !== null, +}); + +manageContract("POST /api/encounters/[id]/restore", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => entry.route === "/api/encounters/[id]/restore", + )!, + prepare: async (ownerId) => { + const row = await makeVisit(ownerId); + return getPrismaClient().encounter.update({ + where: { id: row.id }, + data: { deletedAt: new Date() }, + }); + }, + act: async (row) => { + const { POST } = await import("@/app/api/encounters/[id]/restore/route"); + return call( + POST as Handler, + "POST", + `/api/encounters/${row.id}/restore`, + {}, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "encounter.visit.restore", + applied: async (row) => + (await getPrismaClient().encounter.findUnique({ where: { id: row.id } })) + ?.deletedAt === null, +}); + +writeEffectContract("POST /api/practitioners", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => entry.route === "/api/practitioners" && entry.action === "POST", + )!, + prepare: async (ownerId) => ({ ownerId }), + act: async () => { + const { POST } = await import("@/app/api/practitioners/route"); + return call(POST as Handler, "POST", "/api/practitioners", { + name: "Praxis Nord", + }); + }, + ok: 201, + auditAction: "practitioner.contact.create", + applied: async ({ ownerId }) => + (await getPrismaClient().practitioner.count({ + where: { userId: ownerId }, + })) === 1, +}); + +manageContract("PATCH /api/practitioners/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/practitioners/[id]" && entry.action === "PATCH", + )!, + prepare: (ownerId) => makePractitioner(ownerId), + act: async (row) => { + const { PATCH } = await import("@/app/api/practitioners/[id]/route"); + return call( + PATCH as Handler, + "PATCH", + `/api/practitioners/${row.id}`, + { name: "Praxis Süd" }, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "practitioner.contact.update", + applied: async (row) => + (await getPrismaClient().practitioner.findUnique({ where: { id: row.id } })) + ?.name === "Praxis Süd", +}); + +manageContract("DELETE /api/practitioners/[id]", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => + entry.route === "/api/practitioners/[id]" && entry.action === "DELETE", + )!, + prepare: (ownerId) => makePractitioner(ownerId), + act: async (row) => { + const { DELETE } = await import("@/app/api/practitioners/[id]/route"); + return call( + DELETE as Handler, + "DELETE", + `/api/practitioners/${row.id}`, + undefined, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "practitioner.contact.delete", + applied: async (row) => + (await getPrismaClient().practitioner.findUnique({ where: { id: row.id } })) + ?.deletedAt !== null, +}); + +manageContract("POST /api/practitioners/[id]/restore", { + contract: ADMITTED_MUTATING_HANDLERS.find( + (entry) => entry.route === "/api/practitioners/[id]/restore", + )!, + prepare: async (ownerId) => { + const row = await makePractitioner(ownerId); + return getPrismaClient().practitioner.update({ + where: { id: row.id }, + data: { deletedAt: new Date() }, + }); + }, + act: async (row) => { + const { POST } = await import("@/app/api/practitioners/[id]/restore/route"); + return call( + POST as Handler, + "POST", + `/api/practitioners/${row.id}/restore`, + {}, + { id: row.id }, + ); + }, + ok: 200, + auditAction: "practitioner.contact.restore", + applied: async (row) => + (await getPrismaClient().practitioner.findUnique({ where: { id: row.id } })) + ?.deletedAt === null, +}); + manageContract("POST /api/illness/episodes/[id]/day-logs", { contract: ADMITTED_MUTATING_HANDLERS.find( (entry) => @@ -2167,13 +2379,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(62); + expect(STRICT_DRIVER_KEYS.size).toBe(70); 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(62); + expect(REAL_EFFECT_KEYS.size).toBe(70); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); }); });