diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f40c76d79..25431a0bd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5803,7 +5803,8 @@ paths: duplicate. v1.32.25 — a NEW mirror create is refused with 422 (`meta.errorCode = medications.mirror.limit_exceeded`) once the caller already holds 30 medications mirrored from the same external source; an idempotent re-post of a medication already mirrored is unaffected, and native creates are never - gated. + gated. v1.40 — `category` may be the key of one of the caller's own categories; any other `custom:` key is 422 + `medications.category.unknown`. parameters: - *a6 requestBody: @@ -5824,6 +5825,260 @@ paths: "409": *a7 "422": *a4 "429": *a5 + /api/medications/categories: + get: + tags: + - Medications + summary: List the record's own medication categories + description: v1.40 (#1041) — every custom category of the record, hidden ones included, in picker order (`sortOrder`, + then creation), each with the number of medications filed under it. The built-in categories are not listed; they + are the fixed values of `MedicationCategory`. Delegable at READ level over the `medications` section. + responses: + "200": + description: The custom categories. + content: + application/json: + schema: + $ref: "#/components/schemas/ListMedicationCategoriesResponse" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + post: + tags: + - Medications + summary: Create a custom medication category + description: "v1.40 (#1041) — mints a `custom:` key and stores the label encrypted. At most 20 per account, hidden + ones included: one more is 422 `medications.category.limitReached`; an invalid body is 422 + `medications.category.invalid`. Rate-limited 30/min keyed on the actor. Delegable at MANAGE level over the + `medications` section. The label never reaches an audit row." + parameters: + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMedicationCategoryRequest" + responses: + "201": + description: The new category. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMedicationCategoryResponse" + "401": *a1 + "403": *a11 + "409": *a7 + "422": *a4 + "429": *a5 + /api/medications/categories/{key}: + patch: + tags: + - Medications + summary: Rename, reorder or hide a custom medication category + description: "v1.40 (#1041) — field-by-field update of one of the record's own categories. Another account's key, or a + built-in value, is 404. Owner-only: not delegable, refused under a record switch. Rate-limited 30/min." + parameters: + - in: path + name: key + schema: + type: string + description: "`custom:`, the category's key." + required: true + description: "`custom:`, the category's key." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMedicationCategoryRequest" + responses: + "200": + description: The updated category. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMedicationCategoryResponse" + "401": *a1 + "404": + description: Not one of the record's own categories. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + delete: + tags: + - Medications + summary: Delete a custom medication category + description: "v1.40 (#1041) — deletes the category and, in the same transaction, moves every medication filed under it + to OTHER; `movedCount` says how many moved. No medication is left pointing at a key that no longer exists. + Another account's key, or a built-in value, is 404. Owner-only: not delegable, refused under a record switch." + parameters: + - in: path + name: key + schema: + type: string + description: "`custom:`, the category's key." + required: true + description: "`custom:`, the category's key." + responses: + "200": + description: Deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteMedicationCategoryResponse" + "401": *a1 + "404": + description: Not one of the record's own categories. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + /api/medications/{id}/courses: + get: + tags: + - Medications + summary: Read a medication's courses + description: v1.40 (#1024) — the same resolved course fields the list and detail reads carry. Owner-only. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The courses. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationCoursesResponse" + "401": *a1 + "404": + description: Medication not found (or another user's). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + post: + tags: + - Medications + summary: Start a course + description: "v1.40 (#1024) — adds a course and, in the same transaction, rewrites the medication's `startsOn`/`endsOn` + to the latest course, so reminders and `intakeActionable` follow with no further write. Refusals: 422 + `medications.course.overlap`, `medications.course.currentOpen`, `medications.course.oneShot`, + `medications.course.invalidRange`, `medications.course.invalid` (body). Owner-only." + parameters: + - in: path + name: id + schema: + type: string + required: true + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMedicationCourseRequest" + responses: + "201": + description: The new course. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMedicationCourseResponse" + "401": *a1 + "404": + description: Medication not found (or another user's). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": *a7 + "422": *a4 + "429": *a5 + /api/medications/{id}/courses/{courseId}: + patch: + tags: + - Medications + summary: Edit a course + description: v1.40 (#1024) — field by field; the medication's window is re-projected in the same transaction. Same + refusals as the create. Owner-only. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: courseId + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMedicationCourseRequest" + responses: + "200": + description: The edited course. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMedicationCourseResponse" + "401": *a1 + "404": + description: Course not found (`medications.course.notFound`), or another user's. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + delete: + tags: + - Medications + summary: Delete a course + description: v1.40 (#1024) — deletes the course and re-projects the medication's window. Deleting the only course makes + the medication continuous again (both window columns cleared). Intakes are not touched. Owner-only. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: courseId + schema: + type: string + required: true + responses: + "200": + description: Deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteMedicationCourseResponse" + "401": *a1 + "404": + description: Course not found (`medications.course.notFound`), or another user's. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/medications/layout: get: tags: @@ -5935,7 +6190,11 @@ paths: description: Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST - /api/medications`. Audits as `medication.update`. + /api/medications`. Audits as `medication.update`. v1.40 — an absent `category` leaves the stored one untouched + (send it only when the person changed it); a `custom:` key that is not the caller's is 422 + `medications.category.unknown`. v1.40 (#1024) — `startsOn`/`endsOn` now edit the latest course (created when + there is none); the same course refusals apply (422 `medications.course.*`), and clearing both on a medication + with several courses is 422 `medications.course.windowRequired`. parameters: - in: path name: id @@ -24583,22 +24842,28 @@ components: minLength: 1 maxLength: 50 category: - type: string - enum: - - BLOOD_PRESSURE - - VITAMIN - - SUPPLEMENT - - PAIN_RELIEF - - ALLERGY - - DIGESTIVE - - THYROID - - HORMONE - - SKIN - - SLEEP_AID - - DIABETES - - ANTIBIOTIC - - MENTAL_HEALTH - - OTHER + anyOf: + - type: string + enum: + - BLOOD_PRESSURE + - VITAMIN + - SUPPLEMENT + - PAIN_RELIEF + - ALLERGY + - DIGESTIVE + - THYROID + - HORMONE + - SKIN + - SLEEP_AID + - DIABETES + - ANTIBIOTIC + - MENTAL_HEALTH + - OTHER + - type: string + pattern: ^custom:[0-9a-f-]{36}$ + description: A built-in clinical category (BLOOD_PRESSURE, VITAMIN, SUPPLEMENT, PAIN_RELIEF, ALLERGY, DIGESTIVE, + THYROID, HORMONE, SKIN, SLEEP_AID, DIABETES, ANTIBIOTIC, MENTAL_HEALTH, OTHER) or `custom:`, the key + of one of the caller's own categories. treatmentClass: type: string enum: @@ -24870,6 +25135,77 @@ components: description: One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc. + CreateMedicationCategoryRequest: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + required: + - label + description: v1.40 — name a new medication category. The label is trimmed, 1 to 40 characters, and encrypted at rest. + UpdateMedicationCategoryRequest: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + sortOrder: + type: integer + minimum: 0 + maximum: 1000 + isActive: + type: boolean + description: "v1.40 — rename, reorder or hide a custom medication category. At least one field. Hiding (`isActive: + false`) removes it from the picker; medications filed under it keep it." + CreateMedicationCourseRequest: + type: object + properties: + startsOn: + description: First day of the course (ISO `YYYY-MM-DD`, inclusive, the person's calendar). + type: string + format: date + 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])))$ + endsOn: + description: Last day (inclusive). NULL or absent = open; only the latest course may be open. + anyOf: + - type: string + format: date + 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])))$ + - type: "null" + note: + description: Why this course, in the person's words. Encrypted at rest. + anyOf: + - type: string + maxLength: 280 + - type: "null" + required: + - startsOn + description: v1.40 — start a course. Refused when it shares a day with another course (422 + `medications.course.overlap`), when it would sit after a course that has not ended (422 + `medications.course.currentOpen`), on a one-time medication that already has one (422 + `medications.course.oneShot`), or when it ends before it starts (422 `medications.course.invalidRange`). + UpdateMedicationCourseRequest: + type: object + properties: + startsOn: + type: string + format: date + 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])))$ + endsOn: + anyOf: + - type: string + format: date + 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])))$ + - type: "null" + note: + anyOf: + - type: string + maxLength: 280 + - type: "null" + description: v1.40 — edit a course field by field. Same refusals as the create. MedicationListLayoutPutBody: type: object properties: @@ -24920,22 +25256,28 @@ components: minLength: 1 maxLength: 50 category: - type: string - enum: - - BLOOD_PRESSURE - - VITAMIN - - SUPPLEMENT - - PAIN_RELIEF - - ALLERGY - - DIGESTIVE - - THYROID - - HORMONE - - SKIN - - SLEEP_AID - - DIABETES - - ANTIBIOTIC - - MENTAL_HEALTH - - OTHER + anyOf: + - type: string + enum: + - BLOOD_PRESSURE + - VITAMIN + - SUPPLEMENT + - PAIN_RELIEF + - ALLERGY + - DIGESTIVE + - THYROID + - HORMONE + - SKIN + - SLEEP_AID + - DIABETES + - ANTIBIOTIC + - MENTAL_HEALTH + - OTHER + - type: string + pattern: ^custom:[0-9a-f-]{36}$ + description: A built-in clinical category (BLOOD_PRESSURE, VITAMIN, SUPPLEMENT, PAIN_RELIEF, ALLERGY, DIGESTIVE, + THYROID, HORMONE, SKIN, SLEEP_AID, DIABETES, ANTIBIOTIC, MENTAL_HEALTH, OTHER) or `custom:`, the key + of one of the caller's own categories. treatmentClass: type: string enum: @@ -32320,7 +32662,10 @@ components: measurement.batch.too_large, measurement.bulk-delete.invalid, measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large, measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source; medication — - medication.intake.import.invalid_format; medications — + medication.intake.import.invalid_format; medications — medications.category.invalid, + medications.category.limitReached, medications.category.unknown, medications.course.currentOpen, + medications.course.invalid, medications.course.invalidRange, medications.course.notFound, + medications.course.oneShot, medications.course.overlap, medications.course.windowRequired, medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid, medications.intake.bulk.too_large, medications.intake.force_slot.invalid, medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed, @@ -35632,6 +35977,33 @@ components: - validUntil - payload additionalProperties: {} + courses: + type: array + items: + type: object + properties: + startsOn: + type: string + format: date + 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])))$ + endsOn: + anyOf: + - type: string + format: date + 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])))$ + - type: "null" + note: + anyOf: + - type: string + maxLength: 280 + - type: "null" + createdAt: + type: string + minLength: 1 + required: + - startsOn + - endsOn + additionalProperties: {} required: - name - dose @@ -35644,6 +36016,36 @@ components: - efficacyTargets - scheduleRevisions additionalProperties: {} + customMedicationCategories: + default: [] + type: array + items: + type: object + properties: + key: + type: string + pattern: ^custom:[0-9a-f-]{36}$ + label: + type: string + minLength: 1 + maxLength: 40 + sortOrder: + default: 0 + type: integer + minimum: 0 + maximum: 9007199254740991 + isActive: + default: true + type: boolean + createdAt: + type: string + minLength: 1 + required: + - key + - label + - sortOrder + - isActive + additionalProperties: {} intakeEvents: default: [] type: array @@ -39294,6 +39696,7 @@ components: - accountSettings - measurements - medications + - customMedicationCategories - intakeEvents - moodEntries - cycleProfile @@ -45055,6 +45458,34 @@ components: $ref: "#/components/schemas/MedicationScheduleOutput" category: $ref: "#/components/schemas/MedicationCategory" + categoryLabel: + anyOf: + - type: string + - type: "null" + description: v1.40 — the decrypted label of a custom category (`category` = `custom:`); null for a built-in + category, which a client localises from `category`. Read-only. + courses: + type: array + items: + $ref: "#/components/schemas/MedicationCourse" + description: v1.40 — every course, ascending by start. Empty for a medication taken continuously since creation. + Server-resolved; render it, do not recompute it. + courseCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + previousCourseEndedOn: + anyOf: + - type: string + format: date + 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])))$ + - type: "null" + description: v1.40 — the last day of the most recent course that has ended (answers 'when did I last take it'); null + when none has. + canStartCourse: + type: boolean + description: "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, + not one-time, has a course and every course has ended." externalSource: anyOf: - type: string @@ -45147,6 +45578,11 @@ components: - updatedAt - schedules - category + - categoryLabel + - courses + - courseCount + - previousCourseEndedOn + - canStartCourse - externalSource - courseStatus - intakeActionable @@ -45164,22 +45600,56 @@ components: `Medication` component for their semantics. MedicationCategory: type: string - enum: - - BLOOD_PRESSURE - - VITAMIN - - SUPPLEMENT - - PAIN_RELIEF - - ALLERGY - - DIGESTIVE - - THYROID - - HORMONE - - SKIN - - SLEEP_AID - - DIABETES - - ANTIBIOTIC - - MENTAL_HEALTH - - OTHER - description: Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`. + description: Clinical category. One of the built-in values (BLOOD_PRESSURE, VITAMIN, SUPPLEMENT, PAIN_RELIEF, ALLERGY, + DIGESTIVE, THYROID, HORMONE, SKIN, SLEEP_AID, DIABETES, ANTIBIOTIC, MENTAL_HEALTH, OTHER) or, since v1.40, + `custom:`, the key of one of the caller's own categories (see `GET /api/medications/categories`), whose + label rides beside it as `categoryLabel`. A client must decode an unknown value tolerantly and must not write + `category` back unless the person changed it. Orthogonal to `MedicationTreatmentClass`. + MedicationCourse: + type: object + properties: + id: + type: string + startsOn: + type: string + format: date + 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])))$ + description: First day of the course, inclusive (`YYYY-MM-DD`). + endsOn: + anyOf: + - type: string + format: date + 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])))$ + - type: "null" + description: Last day, inclusive. NULL = open (only the latest course). + status: + type: string + enum: + - UPCOMING + - CURRENT + - ENDED + description: Where today, on the person's clock, sits in this course. + takenDoses: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Doses logged as taken whose slot falls on a day of this course. + note: + anyOf: + - type: string + - type: "null" + description: The person's note on this course. Encrypted at rest. + required: + - id + - startsOn + - endsOn + - status + - takenDoses + - note + additionalProperties: false + description: v1.40 (#1024) — one course of a medication. Courses never overlap and every course except the latest has + ended, so the medication's `startsOn`/`endsOn`/`courseStatus`/`intakeActionable` are always those of the latest + course. CreateMedicationResponse: type: object properties: @@ -45341,6 +45811,34 @@ components: $ref: "#/components/schemas/MedicationScheduleOutput" category: $ref: "#/components/schemas/MedicationCategory" + categoryLabel: + anyOf: + - type: string + - type: "null" + description: v1.40 — the decrypted label of a custom category (`category` = `custom:`); null for a built-in + category, which a client localises from `category`. Read-only. + courses: + type: array + items: + $ref: "#/components/schemas/MedicationCourse" + description: v1.40 — every course, ascending by start. Empty for a medication taken continuously since creation. + Server-resolved; render it, do not recompute it. + courseCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + previousCourseEndedOn: + anyOf: + - type: string + format: date + 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])))$ + - type: "null" + description: v1.40 — the last day of the most recent course that has ended (answers 'when did I last take it'); null + when none has. + canStartCourse: + type: boolean + description: "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, + not one-time, has a course and every course has ended." courseStatus: type: string enum: @@ -45401,6 +45899,11 @@ components: - updatedAt - schedules - category + - categoryLabel + - courses + - courseCount + - previousCourseEndedOn + - canStartCourse - courseStatus - intakeActionable - stockUnitsRemaining @@ -45409,6 +45912,262 @@ components: additionalProperties: false description: Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics. + ListMedicationCategoriesResponse: + type: object + properties: + data: + type: object + properties: + categories: + type: array + items: + $ref: "#/components/schemas/MedicationCategoryLabel" + required: + - categories + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationCategoryLabel: + type: object + properties: + key: + type: string + description: "`custom:`, the value a medication's `category` carries when it is filed under this category." + label: + type: string + description: The person's own label, 1 to 40 characters. + sortOrder: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Position in the picker and the filter row, ascending. + isActive: + type: boolean + description: false = hidden from the picker. Medications filed under a hidden category keep it. + medicationCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: How many of the record's medications are filed under it. + required: + - key + - label + - sortOrder + - isActive + - medicationCount + additionalProperties: false + description: v1.40 (#1041) — a medication category the person named themselves, shown next to the built-in ones. At most + 20 per account (hidden ones count). + CreateMedicationCategoryResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationCategoryLabel" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateMedicationCategoryResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationCategoryLabel" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteMedicationCategoryResponse: + type: object + properties: + data: + type: object + properties: + key: + type: string + movedCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Medications moved to OTHER. + required: + - key + - movedCount + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationCoursesResponse: + type: object + properties: + data: + type: object + properties: + courses: + type: array + items: + $ref: "#/components/schemas/MedicationCourse" + courseCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + previousCourseEndedOn: + anyOf: + - type: string + format: date + 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])))$ + - type: "null" + canStartCourse: + type: boolean + required: + - courses + - courseCount + - previousCourseEndedOn + - canStartCourse + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CreateMedicationCourseResponse: + type: object + properties: + data: + type: object + properties: + id: + type: string + startsOn: + type: string + format: date + 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])))$ + endsOn: + anyOf: + - type: string + format: date + 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])))$ + - type: "null" + required: + - id + - startsOn + - endsOn + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateMedicationCourseResponse: + type: object + properties: + data: + type: object + properties: + id: + type: string + startsOn: + type: string + format: date + 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])))$ + endsOn: + anyOf: + - type: string + format: date + 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])))$ + - type: "null" + required: + - id + - startsOn + - endsOn + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteMedicationCourseResponse: + type: object + properties: + data: + type: object + properties: + id: + type: string + deleted: + type: boolean + const: true + required: + - id + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MedicationListLayoutResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index 2e7a699c9..010e91b92 100644 --- a/messages/de.json +++ b/messages/de.json @@ -363,7 +363,9 @@ }, "contacts": "Notfallkontakte", "notes": "Weitere Hinweise" - } + }, + "courseRange": "Einnahmephase {start} bis {end}", + "courseSince": "Einnahmephase seit {start}" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -2292,6 +2294,64 @@ "placeholder": "z. B. 1,5 oder 1 1/2", "invalid": "Gib eine Zahl über 0 und bis 100 ein, zum Beispiel 1,5 oder 1 1/2.", "preview": "Zählt als {value} pro Dosis" + }, + "category": { + "custom": { + "add": "Neue Kategorie", + "label": "Name der Kategorie", + "labelPlaceholder": "z. B. Reiseapotheke", + "create": "Hinzufügen", + "limitReached": "Du hast die Grenze von 20 eigenen Kategorien erreicht.", + "saveFailed": "Das wurde nicht gespeichert. Bitte versuch es noch einmal.", + "bpGateNote": "Die Blutdruck-Auswertung zählt nur Medikamente in der eingebauten Kategorie Blutdruck.", + "manage": "Kategorien verwalten", + "manageDescription": "Deine eigenen Kategorien, neben den eingebauten.", + "hidden": "Ausgeblendet", + "count": "{count} Medikamente", + "rename": "Umbenennen", + "hide": "In der Auswahl ausblenden", + "show": "In der Auswahl zeigen", + "delete": "Löschen", + "deleteTitle": "Diese Kategorie löschen?", + "deleteBody": "{count} Medikamente in dieser Kategorie wandern zu Sonstige. Die Medikamente selbst bleiben.", + "movedToOther": "Kategorie gelöscht. {count} Medikamente sind jetzt unter Sonstige." + }, + "filter": { + "label": "Kategorie", + "all": "Alle Kategorien" + } + }, + "course": { + "endedOn": "Letzte Einnahmephase endete am {date}", + "nth": "Phase {n}", + "startNew": "Neue Einnahmephase starten", + "editTitle": "Einnahmephase bearbeiten", + "startsOn": "Erster Tag", + "endsOn": "Letzter Tag (optional)", + "note": "Notiz (optional)", + "notePlaceholder": "z. B. die Grippe im März", + "title": "Einnahmephasen", + "helper": "Wann du es genommen hast. Einnahmen werden nur innerhalb einer Phase erwartet.", + "none": "Durchgehend genommen, keine einzelnen Phasen.", + "range": "{start} bis {end}", + "since": "Seit {start}", + "meta": "Phase {n}, {status}, {taken} Einnahmen", + "status": { + "current": "läuft", + "upcoming": "geplant", + "ended": "beendet" + }, + "delete": "Phase löschen", + "deleteTitle": "Diese Einnahmephase löschen?", + "deleteBody": "Die Daten verschwinden, deine erfassten Einnahmen bleiben im Verlauf.", + "deleteLastConfirm": "Das ist die einzige Phase. Ohne sie gilt das Medikament als durchgehend genommen, seit du es angelegt hast. Die erfassten Einnahmen bleiben.", + "error": { + "overlap": "Diese Phase überschneidet sich mit einer anderen.", + "currentOpen": "Beende die laufende Phase, bevor du eine danach anlegst.", + "oneShot": "Ein einmaliges Medikament hat nur eine Phase.", + "invalidRange": "Der letzte Tag kann nicht vor dem ersten liegen.", + "generic": "Das wurde nicht gespeichert. Bitte versuch es noch einmal." + } } }, "charts": { @@ -8353,7 +8413,10 @@ "doses": { "title": "Erfasste Dosen", "showing": "Angezeigt werden die {shown} jüngsten von {total} erfassten Dosen." - } + }, + "course": "Phase {n}", + "courseRange": "{start} bis {end}", + "courseSince": "seit {start}" }, "cycle": { "symptomCategory": { @@ -11288,6 +11351,7 @@ "medicationIntakeImportFinished": "Der von {name} gestartete Einnahme-Import ist fertig", "medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen", "vaccinationBoosterPlanned": "{name} hat eine Auffrischung geplant", + "medicationCategoryCreate": "{name} hat eine Medikamentenkategorie angelegt", "symptomEventCreate": "{name} hat ein Symptom erfasst", "symptomEventUpdate": "{name} hat einen Symptom-Eintrag bearbeitet", "symptomEventDelete": "{name} hat einen Symptom-Eintrag gelöscht", diff --git a/messages/en.json b/messages/en.json index 4fb883bc5..b65774c65 100644 --- a/messages/en.json +++ b/messages/en.json @@ -363,7 +363,9 @@ "immunizationsColDate": "Date", "immunizationsColVaccine": "Vaccine", "immunizationsColDose": "Dose", - "immunizationsColLot": "Lot" + "immunizationsColLot": "Lot", + "courseRange": "Course {start} to {end}", + "courseSince": "Course since {start}" }, "errorBoundary": { "title": "Something went wrong", @@ -2292,6 +2294,64 @@ "placeholder": "e.g. 1.5 or 1 1/2", "invalid": "Enter a number above 0 and up to 100, for example 1.5 or 1 1/2.", "preview": "Counts as {value} per dose" + }, + "category": { + "custom": { + "add": "New category", + "label": "Category name", + "labelPlaceholder": "e.g. Travel kit", + "create": "Add", + "limitReached": "You have reached the limit of 20 own categories.", + "saveFailed": "That did not save. Please try again.", + "bpGateNote": "Blood pressure insights only count medications in the built-in Blood pressure category.", + "manage": "Manage categories", + "manageDescription": "Your own categories, shown next to the built-in ones.", + "hidden": "Hidden", + "count": "{count} medications", + "rename": "Rename", + "hide": "Hide from the picker", + "show": "Show in the picker", + "delete": "Delete", + "deleteTitle": "Delete this category?", + "deleteBody": "{count} medications filed under it move to Other. The medications themselves stay.", + "movedToOther": "Category deleted. {count} medications moved to Other." + }, + "filter": { + "label": "Category", + "all": "All categories" + } + }, + "course": { + "endedOn": "Last course ended {date}", + "nth": "Course {n}", + "startNew": "Start a new course", + "editTitle": "Edit course", + "startsOn": "First day", + "endsOn": "Last day (optional)", + "note": "Note (optional)", + "notePlaceholder": "e.g. the flu in March", + "title": "Courses", + "helper": "When you took it. Doses are expected only inside a course.", + "none": "Taken continuously, no separate courses.", + "range": "{start} to {end}", + "since": "Since {start}", + "meta": "Course {n}, {status}, {taken} doses taken", + "status": { + "current": "running", + "upcoming": "upcoming", + "ended": "ended" + }, + "delete": "Delete course", + "deleteTitle": "Delete this course?", + "deleteBody": "The dates go; the doses you logged stay in the history.", + "deleteLastConfirm": "This is the only course. Without it the medication counts as taken continuously since you added it. The logged doses stay.", + "error": { + "overlap": "This course shares days with another one.", + "currentOpen": "End the running course before adding one after it.", + "oneShot": "A one-time medication has a single course.", + "invalidRange": "The last day cannot be before the first.", + "generic": "That did not save. Please try again." + } } }, "charts": { @@ -8353,7 +8413,10 @@ "doses": { "title": "Logged doses", "showing": "Showing the {shown} most recent of {total} logged doses." - } + }, + "course": "Course {n}", + "courseRange": "{start} to {end}", + "courseSince": "since {start}" }, "cycle": { "symptomCategory": { @@ -11288,6 +11351,7 @@ "medicationIntakeImportFinished": "The dose import {name} started has finished", "medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded", "vaccinationBoosterPlanned": "{name} planned a booster", + "medicationCategoryCreate": "{name} added a medication category", "symptomEventCreate": "{name} logged a symptom", "symptomEventUpdate": "{name} edited a symptom entry", "symptomEventDelete": "{name} deleted a symptom entry", diff --git a/messages/es.json b/messages/es.json index 7127934cf..1f9b678f2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -363,7 +363,9 @@ }, "contacts": "Contactos de emergencia", "notes": "Notas adicionales" - } + }, + "courseRange": "Tratamiento del {start} al {end}", + "courseSince": "Tratamiento desde el {start}" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -2292,6 +2294,64 @@ "placeholder": "p. ej., 1,5 o 1 1/2", "invalid": "Introduce un número mayor que 0 y hasta 100, por ejemplo 1,5 o 1 1/2.", "preview": "Cuenta como {value} por dosis" + }, + "category": { + "custom": { + "add": "Nueva categoría", + "label": "Nombre de la categoría", + "labelPlaceholder": "p. ej. Botiquín de viaje", + "create": "Añadir", + "limitReached": "Has alcanzado el límite de 20 categorías propias.", + "saveFailed": "No se ha guardado. Inténtalo de nuevo.", + "bpGateNote": "El análisis de la presión arterial solo cuenta los medicamentos de la categoría integrada Presión arterial.", + "manage": "Gestionar categorías", + "manageDescription": "Tus propias categorías, junto a las integradas.", + "hidden": "Oculta", + "count": "{count} medicamentos", + "rename": "Renombrar", + "hide": "Ocultar en el selector", + "show": "Mostrar en el selector", + "delete": "Eliminar", + "deleteTitle": "¿Eliminar esta categoría?", + "deleteBody": "{count} medicamentos de esta categoría pasan a Otros. Los medicamentos se conservan.", + "movedToOther": "Categoría eliminada. {count} medicamentos pasan a Otros." + }, + "filter": { + "label": "Categoría", + "all": "Todas las categorías" + } + }, + "course": { + "endedOn": "El último tratamiento terminó el {date}", + "nth": "Tratamiento {n}", + "startNew": "Empezar un nuevo tratamiento", + "editTitle": "Editar tratamiento", + "startsOn": "Primer día", + "endsOn": "Último día (opcional)", + "note": "Nota (opcional)", + "notePlaceholder": "p. ej. la gripe de marzo", + "title": "Tratamientos", + "helper": "Cuándo lo tomaste. Solo se esperan tomas dentro de un tratamiento.", + "none": "Tomado de forma continua, sin tratamientos separados.", + "range": "Del {start} al {end}", + "since": "Desde el {start}", + "meta": "Tratamiento {n}, {status}, {taken} tomas", + "status": { + "current": "en curso", + "upcoming": "previsto", + "ended": "terminado" + }, + "delete": "Eliminar tratamiento", + "deleteTitle": "¿Eliminar este tratamiento?", + "deleteBody": "Se eliminan las fechas; las tomas registradas siguen en el historial.", + "deleteLastConfirm": "Es el único tratamiento. Sin él, el medicamento cuenta como tomado de forma continua desde que lo añadiste. Las tomas registradas se conservan.", + "error": { + "overlap": "Este tratamiento coincide en días con otro.", + "currentOpen": "Termina el tratamiento en curso antes de añadir uno posterior.", + "oneShot": "Un medicamento de una sola vez tiene un único tratamiento.", + "invalidRange": "El último día no puede ser anterior al primero.", + "generic": "No se ha guardado. Inténtalo de nuevo." + } } }, "charts": { @@ -8353,7 +8413,10 @@ "doses": { "title": "Dosis registradas", "showing": "Se muestran las {shown} dosis más recientes de {total} registradas." - } + }, + "course": "Tratamiento {n}", + "courseRange": "del {start} al {end}", + "courseSince": "desde el {start}" }, "cycle": { "symptomCategory": { @@ -11288,6 +11351,7 @@ "medicationIntakeImportFinished": "La importación de dosis que inició {name} ha terminado", "medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}", "vaccinationBoosterPlanned": "{name} planificó un refuerzo", + "medicationCategoryCreate": "{name} añadió una categoría de medicamentos", "symptomEventCreate": "{name} registró un síntoma", "symptomEventUpdate": "{name} editó una entrada de síntoma", "symptomEventDelete": "{name} eliminó una entrada de síntoma", diff --git a/messages/fr.json b/messages/fr.json index 1a82ab757..1c76f7992 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -363,7 +363,9 @@ }, "contacts": "Contacts d'urgence", "notes": "Notes complémentaires" - } + }, + "courseRange": "Traitement du {start} au {end}", + "courseSince": "Traitement depuis le {start}" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -2292,6 +2294,64 @@ "placeholder": "p. ex. 1,5 ou 1 1/2", "invalid": "Saisissez un nombre supérieur à 0 et jusqu’à 100, par exemple 1,5 ou 1 1/2.", "preview": "Compte comme {value} par dose" + }, + "category": { + "custom": { + "add": "Nouvelle catégorie", + "label": "Nom de la catégorie", + "labelPlaceholder": "p. ex. Trousse de voyage", + "create": "Ajouter", + "limitReached": "Tu as atteint la limite de 20 catégories personnelles.", + "saveFailed": "L'enregistrement a échoué. Réessaie.", + "bpGateNote": "L'analyse de la tension ne compte que les médicaments de la catégorie intégrée Tension artérielle.", + "manage": "Gérer les catégories", + "manageDescription": "Tes propres catégories, à côté des catégories intégrées.", + "hidden": "Masquée", + "count": "{count} médicaments", + "rename": "Renommer", + "hide": "Masquer dans le sélecteur", + "show": "Afficher dans le sélecteur", + "delete": "Supprimer", + "deleteTitle": "Supprimer cette catégorie ?", + "deleteBody": "{count} médicaments de cette catégorie passent dans Autre. Les médicaments eux-mêmes restent.", + "movedToOther": "Catégorie supprimée. {count} médicaments passent dans Autre." + }, + "filter": { + "label": "Catégorie", + "all": "Toutes les catégories" + } + }, + "course": { + "endedOn": "Dernier traitement terminé le {date}", + "nth": "Traitement {n}", + "startNew": "Commencer un nouveau traitement", + "editTitle": "Modifier le traitement", + "startsOn": "Premier jour", + "endsOn": "Dernier jour (facultatif)", + "note": "Note (facultatif)", + "notePlaceholder": "p. ex. la grippe de mars", + "title": "Traitements", + "helper": "Quand tu l’as pris. Les prises ne sont attendues que pendant un traitement.", + "none": "Pris en continu, sans traitements séparés.", + "range": "Du {start} au {end}", + "since": "Depuis le {start}", + "meta": "Traitement {n}, {status}, {taken} prises", + "status": { + "current": "en cours", + "upcoming": "à venir", + "ended": "terminé" + }, + "delete": "Supprimer le traitement", + "deleteTitle": "Supprimer ce traitement ?", + "deleteBody": "Les dates disparaissent ; les prises enregistrées restent dans l’historique.", + "deleteLastConfirm": "C’est le seul traitement. Sans lui, le médicament compte comme pris en continu depuis son ajout. Les prises enregistrées restent.", + "error": { + "overlap": "Ce traitement chevauche un autre.", + "currentOpen": "Termine le traitement en cours avant d’en ajouter un après.", + "oneShot": "Un médicament ponctuel n’a qu’un seul traitement.", + "invalidRange": "Le dernier jour ne peut pas précéder le premier.", + "generic": "L’enregistrement a échoué. Réessaie." + } } }, "charts": { @@ -8353,7 +8413,10 @@ "doses": { "title": "Doses enregistrées", "showing": "Affichage des {shown} doses les plus récentes sur {total} enregistrées." - } + }, + "course": "Traitement {n}", + "courseRange": "du {start} au {end}", + "courseSince": "depuis le {start}" }, "cycle": { "symptomCategory": { @@ -11288,6 +11351,7 @@ "medicationIntakeImportFinished": "L'import de prises lancé par {name} est terminé", "medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}", "vaccinationBoosterPlanned": "{name} a planifié un rappel", + "medicationCategoryCreate": "{name} a ajouté une catégorie de médicaments", "symptomEventCreate": "{name} a noté un symptôme", "symptomEventUpdate": "{name} a modifié une entrée de symptôme", "symptomEventDelete": "{name} a supprimé une entrée de symptôme", diff --git a/messages/it.json b/messages/it.json index 09464fbf9..61397873a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -363,7 +363,9 @@ }, "contacts": "Contatti di emergenza", "notes": "Note aggiuntive" - } + }, + "courseRange": "Ciclo dal {start} al {end}", + "courseSince": "Ciclo dal {start}" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -2292,6 +2294,64 @@ "placeholder": "ad es. 1,5 o 1 1/2", "invalid": "Inserisci un numero maggiore di 0 e fino a 100, ad esempio 1,5 o 1 1/2.", "preview": "Conta come {value} per dose" + }, + "category": { + "custom": { + "add": "Nuova categoria", + "label": "Nome della categoria", + "labelPlaceholder": "es. Kit da viaggio", + "create": "Aggiungi", + "limitReached": "Hai raggiunto il limite di 20 categorie personali.", + "saveFailed": "Il salvataggio non è riuscito. Riprova.", + "bpGateNote": "L'analisi della pressione conta solo i farmaci nella categoria integrata Pressione sanguigna.", + "manage": "Gestisci categorie", + "manageDescription": "Le tue categorie, accanto a quelle integrate.", + "hidden": "Nascosta", + "count": "{count} farmaci", + "rename": "Rinomina", + "hide": "Nascondi nella selezione", + "show": "Mostra nella selezione", + "delete": "Elimina", + "deleteTitle": "Eliminare questa categoria?", + "deleteBody": "{count} farmaci di questa categoria passano in Altro. I farmaci restano.", + "movedToOther": "Categoria eliminata. {count} farmaci passano in Altro." + }, + "filter": { + "label": "Categoria", + "all": "Tutte le categorie" + } + }, + "course": { + "endedOn": "Ultimo ciclo terminato il {date}", + "nth": "Ciclo {n}", + "startNew": "Inizia un nuovo ciclo", + "editTitle": "Modifica ciclo", + "startsOn": "Primo giorno", + "endsOn": "Ultimo giorno (facoltativo)", + "note": "Nota (facoltativa)", + "notePlaceholder": "es. l’influenza di marzo", + "title": "Cicli", + "helper": "Quando l’hai preso. Le assunzioni sono previste solo dentro un ciclo.", + "none": "Preso in modo continuo, senza cicli separati.", + "range": "Dal {start} al {end}", + "since": "Dal {start}", + "meta": "Ciclo {n}, {status}, {taken} assunzioni", + "status": { + "current": "in corso", + "upcoming": "previsto", + "ended": "concluso" + }, + "delete": "Elimina ciclo", + "deleteTitle": "Eliminare questo ciclo?", + "deleteBody": "Le date vengono rimosse; le assunzioni registrate restano nello storico.", + "deleteLastConfirm": "È l’unico ciclo. Senza di esso il farmaco risulta preso in modo continuo da quando l’hai aggiunto. Le assunzioni registrate restano.", + "error": { + "overlap": "Questo ciclo si sovrappone a un altro.", + "currentOpen": "Concludi il ciclo in corso prima di aggiungerne uno dopo.", + "oneShot": "Un farmaco una tantum ha un solo ciclo.", + "invalidRange": "L’ultimo giorno non può precedere il primo.", + "generic": "Il salvataggio non è riuscito. Riprova." + } } }, "charts": { @@ -8353,7 +8413,10 @@ "doses": { "title": "Dosi registrate", "showing": "Sono mostrate le {shown} dosi più recenti su {total} registrate." - } + }, + "course": "Ciclo {n}", + "courseRange": "dal {start} al {end}", + "courseSince": "dal {start}" }, "cycle": { "symptomCategory": { @@ -11288,6 +11351,7 @@ "medicationIntakeImportFinished": "L'importazione delle dosi avviata da {name} è terminata", "medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}", "vaccinationBoosterPlanned": "{name} ha pianificato un richiamo", + "medicationCategoryCreate": "{name} ha aggiunto una categoria di farmaci", "symptomEventCreate": "{name} ha registrato un sintomo", "symptomEventUpdate": "{name} ha modificato una voce di sintomo", "symptomEventDelete": "{name} ha eliminato una voce di sintomo", diff --git a/messages/ko.json b/messages/ko.json index ab049b462..cdabb7b22 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -363,7 +363,9 @@ "immunizationsColDate": "날짜", "immunizationsColVaccine": "백신", "immunizationsColDose": "차수", - "immunizationsColLot": "로트번호" + "immunizationsColLot": "로트번호", + "courseRange": "복용 기간 {start}부터 {end}까지", + "courseSince": "복용 기간 {start}부터" }, "errorBoundary": { "title": "문제가 생겼어요", @@ -2292,6 +2294,64 @@ "placeholder": "예: 1.5 또는 1 1/2", "invalid": "0보다 크고 100 이하인 숫자를 입력하세요. 예: 1.5 또는 1 1/2", "preview": "1회분당 {value}(으)로 계산돼요" + }, + "category": { + "custom": { + "add": "새 카테고리", + "label": "카테고리 이름", + "labelPlaceholder": "예: 여행용 상비약", + "create": "추가", + "limitReached": "나만의 카테고리는 최대 20개까지 만들 수 있어요.", + "saveFailed": "저장하지 못했어요. 다시 시도해 주세요.", + "bpGateNote": "혈압 분석은 기본 제공되는 혈압 카테고리의 약만 계산해요.", + "manage": "카테고리 관리", + "manageDescription": "기본 카테고리 옆에 보이는 나만의 카테고리예요.", + "hidden": "숨김", + "count": "약 {count}개", + "rename": "이름 바꾸기", + "hide": "선택 목록에서 숨기기", + "show": "선택 목록에 표시", + "delete": "삭제", + "deleteTitle": "이 카테고리를 삭제할까요?", + "deleteBody": "이 카테고리의 약 {count}개가 기타로 옮겨져요. 약 자체는 그대로 남아요.", + "movedToOther": "카테고리를 삭제했어요. 약 {count}개가 기타로 옮겨졌어요." + }, + "filter": { + "label": "카테고리", + "all": "모든 카테고리" + } + }, + "course": { + "endedOn": "마지막 복용 기간이 {date}에 끝났어요", + "nth": "복용 기간 {n}", + "startNew": "새 복용 기간 시작", + "editTitle": "복용 기간 수정", + "startsOn": "첫날", + "endsOn": "마지막 날 (선택)", + "note": "메모 (선택)", + "notePlaceholder": "예: 3월 독감", + "title": "복용 기간", + "helper": "언제 복용했는지예요. 복용은 기간 안에서만 예정돼요.", + "none": "계속 복용 중이고, 따로 나눈 기간은 없어요.", + "range": "{start}부터 {end}까지", + "since": "{start}부터", + "meta": "복용 기간 {n}, {status}, {taken}회 복용", + "status": { + "current": "진행 중", + "upcoming": "예정", + "ended": "종료" + }, + "delete": "복용 기간 삭제", + "deleteTitle": "이 복용 기간을 삭제할까요?", + "deleteBody": "날짜만 삭제되고, 기록한 복용은 기록에 남아요.", + "deleteLastConfirm": "유일한 복용 기간이에요. 삭제하면 이 약은 추가한 날부터 계속 복용한 것으로 봐요. 기록한 복용은 남아요.", + "error": { + "overlap": "이 기간이 다른 기간과 겹쳐요.", + "currentOpen": "진행 중인 기간을 끝낸 뒤에 다음 기간을 추가해 주세요.", + "oneShot": "일회성 약은 복용 기간이 하나뿐이에요.", + "invalidRange": "마지막 날은 첫날보다 앞설 수 없어요.", + "generic": "저장하지 못했어요. 다시 시도해 주세요." + } } }, "charts": { @@ -8353,7 +8413,10 @@ "doses": { "title": "기록된 복용", "showing": "기록된 복용 {total}건 중 최근 {shown}건을 보여줘요." - } + }, + "course": "복용 기간 {n}", + "courseRange": "{start}부터 {end}까지", + "courseSince": "{start}부터" }, "cycle": { "symptomCategory": { @@ -11288,6 +11351,7 @@ "medicationIntakeImportFinished": "{name} 님이 시작한 복용 기록 불러오기가 끝났어요", "medicationOneShotReconciled": "{name} 님이 기록한 복용에 따라 일회성 약이 시작되거나 종료됐어요", "vaccinationBoosterPlanned": "{name} 님이 추가 접종을 계획했어요", + "medicationCategoryCreate": "{name} 님이 약 카테고리를 추가했어요", "symptomEventCreate": "{name}님이 증상을 기록했습니다", "symptomEventUpdate": "{name}님이 증상 기록을 편집했습니다", "symptomEventDelete": "{name}님이 증상 기록을 삭제했습니다", diff --git a/messages/pl.json b/messages/pl.json index 731890d25..1f931f743 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -363,7 +363,9 @@ }, "contacts": "Kontakty w nagłych wypadkach", "notes": "Dodatkowe uwagi" - } + }, + "courseRange": "Kuracja od {start} do {end}", + "courseSince": "Kuracja od {start}" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -2292,6 +2294,64 @@ "placeholder": "np. 1,5 lub 1 1/2", "invalid": "Wpisz liczbę większą od 0 i nie większą niż 100, na przykład 1,5 lub 1 1/2.", "preview": "Liczy się jako {value} na dawkę" + }, + "category": { + "custom": { + "add": "Nowa kategoria", + "label": "Nazwa kategorii", + "labelPlaceholder": "np. Apteczka podróżna", + "create": "Dodaj", + "limitReached": "Osiągnięto limit 20 własnych kategorii.", + "saveFailed": "Nie udało się zapisać. Spróbuj ponownie.", + "bpGateNote": "Analiza ciśnienia uwzględnia tylko leki z wbudowanej kategorii Ciśnienie krwi.", + "manage": "Zarządzaj kategoriami", + "manageDescription": "Twoje własne kategorie, obok wbudowanych.", + "hidden": "Ukryta", + "count": "Leki: {count}", + "rename": "Zmień nazwę", + "hide": "Ukryj w wyborze", + "show": "Pokaż w wyborze", + "delete": "Usuń", + "deleteTitle": "Usunąć tę kategorię?", + "deleteBody": "Leki w tej kategorii ({count}) przejdą do kategorii Inne. Same leki zostaną.", + "movedToOther": "Kategoria usunięta. Leki przeniesione do kategorii Inne: {count}." + }, + "filter": { + "label": "Kategoria", + "all": "Wszystkie kategorie" + } + }, + "course": { + "endedOn": "Ostatnia kuracja zakończyła się {date}", + "nth": "Kuracja {n}", + "startNew": "Rozpocznij nową kurację", + "editTitle": "Edytuj kurację", + "startsOn": "Pierwszy dzień", + "endsOn": "Ostatni dzień (opcjonalnie)", + "note": "Notatka (opcjonalnie)", + "notePlaceholder": "np. grypa w marcu", + "title": "Kuracje", + "helper": "Kiedy go przyjmowano. Dawki są oczekiwane tylko w trakcie kuracji.", + "none": "Przyjmowany stale, bez osobnych kuracji.", + "range": "Od {start} do {end}", + "since": "Od {start}", + "meta": "Kuracja {n}, {status}, przyjęte dawki: {taken}", + "status": { + "current": "trwa", + "upcoming": "zaplanowana", + "ended": "zakończona" + }, + "delete": "Usuń kurację", + "deleteTitle": "Usunąć tę kurację?", + "deleteBody": "Daty zostaną usunięte; zapisane dawki zostaną w historii.", + "deleteLastConfirm": "To jedyna kuracja. Bez niej lek liczy się jako przyjmowany stale od dodania. Zapisane dawki zostaną.", + "error": { + "overlap": "Ta kuracja pokrywa się z inną.", + "currentOpen": "Zakończ trwającą kurację, zanim dodasz kolejną po niej.", + "oneShot": "Lek jednorazowy ma tylko jedną kurację.", + "invalidRange": "Ostatni dzień nie może być przed pierwszym.", + "generic": "Nie udało się zapisać. Spróbuj ponownie." + } } }, "charts": { @@ -8353,7 +8413,10 @@ "doses": { "title": "Zarejestrowane dawki", "showing": "Pokazano {shown} najnowszych z {total} zarejestrowanych dawek." - } + }, + "course": "Kuracja {n}", + "courseRange": "od {start} do {end}", + "courseSince": "od {start}" }, "cycle": { "symptomCategory": { @@ -11288,6 +11351,7 @@ "medicationIntakeImportFinished": "Import przyjęć rozpoczęty przez {name} zakończył się", "medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}", "vaccinationBoosterPlanned": "{name} zaplanował dawkę przypominającą", + "medicationCategoryCreate": "{name} dodał kategorię leków", "symptomEventCreate": "{name} zapisał(a) objaw", "symptomEventUpdate": "{name} edytował(a) wpis objawu", "symptomEventDelete": "{name} usunął(-ęła) wpis objawu", diff --git a/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql new file mode 100644 index 000000000..002628228 --- /dev/null +++ b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql @@ -0,0 +1,125 @@ +-- Medication categories: adopt the side table into the schema. +-- +-- `medication_categories` used to be created by the app at runtime (migration +-- 0004 had dropped it as orphaned and the app recreated it on first use). It +-- is now a Prisma model. The DDL below is identical to what the runtime +-- helper ran, and Postgres gives the unnamed primary key, foreign key and +-- index the same names Prisma expects, so a database that already holds the +-- runtime table keeps it untouched and every row in it; a fresh database gets +-- the same table. Idempotent. +CREATE TABLE IF NOT EXISTS "medication_categories" ( + "medication_id" TEXT NOT NULL, + "category" TEXT NOT NULL DEFAULT 'OTHER', + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "medication_categories_pkey" PRIMARY KEY ("medication_id"), + CONSTRAINT "medication_categories_medication_id_fkey" + FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS "medication_categories_category_idx" + ON "medication_categories"("category"); + +-- Custom medication categories (#1041): the person's own labels, shown next +-- to the built-in ones. A medication filed under one carries its `key` in +-- `medication_categories.category`. Idempotent. +CREATE TABLE IF NOT EXISTS "medication_category_labels" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "label_encrypted" BYTEA NOT NULL, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "medication_category_labels_pkey" PRIMARY KEY ("id"), + CONSTRAINT "medication_category_labels_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS "medication_category_labels_key_key" + ON "medication_category_labels"("key"); +CREATE INDEX IF NOT EXISTS "medication_category_labels_user_id_sort_order_idx" + ON "medication_category_labels"("user_id", "sort_order"); + +-- Several courses per medication (#1024). A course is a calendar span the +-- person took the medication for; the medication's own starts_on / ends_on +-- stay and now project the latest course, so every reader that predates +-- courses keeps working unchanged. Idempotent. +CREATE TABLE IF NOT EXISTS "medication_courses" ( + "id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "starts_on" DATE NOT NULL, + "ends_on" DATE, + "note_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "medication_courses_pkey" PRIMARY KEY ("id"), + CONSTRAINT "medication_courses_medication_id_fkey" + FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "medication_courses_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX IF NOT EXISTS "medication_courses_medication_id_starts_on_idx" + ON "medication_courses"("medication_id", "starts_on"); +CREATE INDEX IF NOT EXISTS "medication_courses_user_id_idx" + ON "medication_courses"("user_id"); + +-- Backfill: one course for every medication that has a window today. +-- +-- * Start and end, in order: the course is that window. +-- * Only a start: an open course from that day. +-- * Only an end: the app has always read it as running since the day the +-- medication was created, on the person's clock, so that is the start; +-- the end day itself when it lies before the creation (a backdated +-- record). The creation day is taken in the account's time zone, the +-- zone every reader of the window uses, falling back to the app default +-- (Europe/Berlin) when the stored zone is unknown to Postgres. +-- * Start after end (a row written before the API refused it): every +-- reader has treated that window as empty and ended, with no dose +-- expected. It becomes a one-day course on the end day, which keeps it +-- ended and never invents a stretch of expected doses a swap would. +-- +-- A medication with neither keeps no course: chronic since creation stays the +-- absence of courses. The medication columns are not touched, so the data is +-- preserved either way. The id is derived from the medication id, which makes +-- a re-run a no-op through ON CONFLICT. +-- +-- Reversal: dropping the table loses nothing while every medication has at +-- most one course (its columns still hold it). Once a second course exists, a +-- rollback keeps the current window on the medication and the earlier +-- courses' intakes in the ledger; only the earlier spans go. +INSERT INTO "medication_courses" + ("id", "medication_id", "user_id", "starts_on", "ends_on", "created_at", "updated_at") +SELECT + 'mc_' || w."id", + w."id", + w."user_id", + CASE + WHEN w."starts_on" IS NOT NULL AND w."ends_on" IS NOT NULL + AND w."starts_on" > w."ends_on" THEN w."ends_on" + WHEN w."starts_on" IS NOT NULL THEN w."starts_on" + ELSE LEAST(w."created_day", w."ends_on") + END, + w."ends_on", + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM ( + SELECT + m."id", + m."user_id", + m."starts_on", + m."ends_on", + (m."created_at" AT TIME ZONE 'UTC' AT TIME ZONE + CASE + WHEN EXISTS (SELECT 1 FROM pg_timezone_names z WHERE z."name" = u."timezone") + THEN u."timezone" + ELSE 'Europe/Berlin' + END + )::date AS "created_day" + FROM "medications" m + JOIN "users" u ON u."id" = m."user_id" + WHERE m."starts_on" IS NOT NULL OR m."ends_on" IS NOT NULL +) w +ON CONFLICT ("id") DO NOTHING; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 840c15b49..6a05a5659 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -720,6 +720,8 @@ model User { medications Medication[] intakeEvents MedicationIntakeEvent[] medicationPauseEras MedicationPauseEra[] + medicationCategoryLabels MedicationCategoryLabel[] + medicationCourses MedicationCourse[] 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. @@ -2755,33 +2757,33 @@ model Medication { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - schedules MedicationSchedule[] - intakeEvents MedicationIntakeEvent[] - intakeImportJobs MedicationIntakeImportJob[] - phaseConfig ReminderPhaseConfig? - telegramMessages TelegramReminderMessage[] - doseChanges MedicationDoseChange[] - inventoryEvents MedicationInventoryEvent[] + schedules MedicationSchedule[] + intakeEvents MedicationIntakeEvent[] + intakeImportJobs MedicationIntakeImportJob[] + phaseConfig ReminderPhaseConfig? + telegramMessages TelegramReminderMessage[] + doseChanges MedicationDoseChange[] + inventoryEvents MedicationInventoryEvent[] /// v1.4.25 W19b — per-pen / per-vial entities (state machine + /// 30-day in-use clock). Complements the running-sum /// MedicationInventoryEvent ledger. - inventoryItems MedicationInventoryItem[] + inventoryItems MedicationInventoryItem[] /// v1.4.25 W19d — structured GLP-1 side-effect logs. Surfaced on /// the medication detail page for GLP-1 treatmentClass only; the /// taxonomy follows EMA EPAR §4.8 with HealthLog's 5-category /// roll-up (GI / METABOLIC / INJECTION_SITE / COGNITIVE / /// GLP1_SPECIFIC) and 1-5 Likert severity per entry. - sideEffects MedicationSideEffect[] + sideEffects MedicationSideEffect[] /// v1.4.39 W-MED — per-(medication, day) compliance rollup. Mutations /// on the linked MedicationIntakeEvent rows refresh the row inline; /// the read paths consume this instead of the per-event findMany. - complianceRollups MedicationComplianceRollup[] + complianceRollups MedicationComplianceRollup[] /// v1.16.3 — archived schedule eras (effective dating). Superseded /// schedule states land here when a wholesale schedule replace changes /// cadence-relevant fields; the band minter segments historical minting /// ranges into eras so past days read against the schedule that was /// live THEN, not the current one. - scheduleRevisions MedicationScheduleRevision[] + scheduleRevisions MedicationScheduleRevision[] /// v1.25 — durable pause intervals. A pause stamps an open era /// (`resumedAt = null`); resume closes the latest open era. The /// compliance engine drops expected slots whose anchor falls inside a @@ -2789,12 +2791,19 @@ model Medication { /// as missed. The `pausedAt` column on this model stays the live /// single-pause marker the reminder/today surfaces read; the era table /// is the additive durable record the rate paths need. - pauseEras MedicationPauseEra[] + pauseEras MedicationPauseEra[] /// v1.28 — user-pinned efficacy target(s) for the "Wirkung" view. Rows /// exist ONLY when the user explicitly overrides the derived (ATC / name) /// target resolution; absence means the resolver derives the target each /// read. Nothing about a class→outcome association is denormalised here. - efficacyTargets MedicationEfficacyTarget[] + efficacyTargets MedicationEfficacyTarget[] + /// v1.40 — the clinical category the person filed this medication under + /// (one row at most; absence reads as OTHER). + categoryAssignment MedicationCategoryAssignment? + /// v1.40 (#1024) — the medication's courses. Empty = chronic since + /// creation. `startsOn`/`endsOn` above project the latest one, written only + /// by `src/lib/medications/courses.ts`. + courses MedicationCourse[] user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2810,6 +2819,85 @@ model Medication { @@map("medications") } +/// v1.40 — the clinical category of a medication (BLOOD_PRESSURE, VITAMIN, +/// MENTAL_HEALTH, ... or a `custom:` key of one of the owner's +/// `MedicationCategoryLabel` rows). The table predates this model: it was a +/// raw-SQL side table the app created at runtime, which migration 0004 had +/// dropped as orphaned. Migration 0368 creates it `IF NOT EXISTS` with the +/// same DDL, so a database that already holds the runtime table converges +/// without drift. The foreign key keeps the runtime table's `NO ACTION` on +/// update. Absence of a row reads as OTHER. +model MedicationCategoryAssignment { + medicationId String @id @map("medication_id") + /// A built-in value from `MEDICATION_CATEGORY_VALUES` or `custom:`. + /// Only `src/lib/medication-category.ts` reads or writes it. + category String @default("OTHER") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([category]) + @@map("medication_categories") +} + +/// v1.40 (#1041) — a medication category the person named themselves, shown +/// next to the built-in ones. A medication filed under it carries the row's +/// `key` (`custom:`) in `MedicationCategoryAssignment.category`; the +/// list and detail DTOs publish the decrypted label as `categoryLabel`. +/// Deleting a label moves its medications to OTHER in the same transaction, +/// so the app never writes a dangling key; one a restore leaves behind reads +/// as OTHER. Capped at 20 per account. +model MedicationCategoryLabel { + id String @id @default(cuid()) + userId String @map("user_id") + /// `custom:`, the handle the category column, the backup and the + /// iPhone app use. Never reused. + key String @unique + /// The person's own label, AES-256-GCM at rest. Never logged or audited. + labelEncrypted Bytes @map("label_encrypted") + /// Position in the picker and the filter row, ascending. + sortOrder Int @default(0) @map("sort_order") + /// false = hidden from the picker; medications filed under it keep it. + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, sortOrder]) + @@map("medication_category_labels") +} + +/// v1.40 (#1024) — one course of a medication: a calendar span the person +/// took it for (Tamiflu for a week now and then). Courses never overlap, and +/// every course but the latest-starting one has ended, so the latest course is +/// always the one `Medication.startsOn/endsOn` project (see +/// `src/lib/medications/course-window.ts`). A course carries no schedule or +/// dose of its own: a dose that changes with a course is a schedule revision +/// dated to its start. A one-time medication has at most one course. +model MedicationCourse { + id String @id @default(cuid()) + medicationId String @map("medication_id") + /// Denormalised owner, so the wipe and the sharing scope address the row + /// directly. Always the medication's owner. + userId String @map("user_id") + /// First day, inclusive, on the person's clock. + startsOn DateTime @map("starts_on") @db.Date + /// Last day, inclusive. NULL = open (only the latest course may be open). + endsOn DateTime? @map("ends_on") @db.Date + /// Why this course, in the person's words. AES-256-GCM at rest. + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([medicationId, startsOn]) + @@index([userId]) + @@map("medication_courses") +} + /// v1.28 — the user's explicit override of a medication's efficacy target, /// the ONE thing the "Wirkung" view persists. Class→outcome resolution /// (ATC-prefix → name inference) is computed each read from the closed @@ -6205,19 +6293,19 @@ model RecommendationFeedback { // the user explicitly opted out of into it. The summarisation helper // in `src/lib/ai/coach/persistence.ts` truncates with an ellipsis. model CoachConversation { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// @internal: legacy plaintext title, read only as a fallback for rows the /// free-text encryption backfill has not reached yet (v1.39.3). Every write /// since then goes to `titleEncrypted` and leaves this NULL; the backfill /// nulls it on the old rows. DROP this column in a later release, once the /// backfill reports zero remaining rows on every instance. - title String? + title String? /// v1.39.3 — AES-256-GCM ciphertext of the title (the first 80 characters of /// the opening Coach message, or the rename). Same Bytes codec as the notes. - titleEncrypted Bytes? @map("title_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + titleEncrypted Bytes? @map("title_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") // v1.11.0 — rolling encrypted summary of the elided older turns, so a long // conversation keeps memory of what came before without re-sending every diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 6e054ba8b..84fc95aef 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -378,6 +378,15 @@ async function main() { prisma.cycleSymptom, ), ); + // v1.40 (#1041) — custom medication categories. A Bytes column, unlike the + // three String label columns above. + results.push( + await rotateBytesColumn( + "MedicationCategoryLabel", + "labelEncrypted", + prisma.medicationCategoryLabel, + ), + ); // ───── NotificationChannel."config" (encrypted JSON) ───── // Channel config (Telegram chat id, ntfy topic, etc.). Skipping these on @@ -675,6 +684,14 @@ async function main() { prisma.medicationDoseChange, ), ); + // v1.40 (#1024) — the note on a medication course. + results.push( + await rotateBytesColumn( + "MedicationCourse", + "noteEncrypted", + prisma.medicationCourse, + ), + ); results.push( await rotateBytesColumn( "MedicationInventoryItem", diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index 99b3390e7..6af03054a 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -508,14 +508,17 @@ describe("tables outside the schema have a backup verdict too", () => { }); } + const CREATE_TABLE = /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+"?(\w+)"?/gi; + + function tablesIn(text: string): string[] { + return [...text.matchAll(CREATE_TABLE)].map((m) => m[1]); + } + function rawTables(): string[] { const names = new Set(); for (const file of sourceFiles(SRC)) { - const text = readFileSync(file, "utf8"); - for (const m of text.matchAll( - /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+"?(\w+)"?/gi, - )) { - names.add(m[1]); + for (const name of tablesIn(readFileSync(file, "utf8"))) { + names.add(name); } } return [...names]; @@ -527,8 +530,30 @@ describe("tables outside the schema have a backup verdict too", () => { ), ); - it("finds the raw tables it is meant to judge (no vacuous pass)", () => { - expect(rawTables()).toContain("medication_categories"); + it("finds a raw table when one is written (no vacuous pass)", () => { + // The source holds none since v1.40, so the matcher is proven against the + // exact DDL the medication category helper used to run at runtime. + expect( + tablesIn(` + CREATE TABLE IF NOT EXISTS medication_categories ( + medication_id TEXT PRIMARY KEY + ); + create table if not exists "quoted_name" (id TEXT); + `), + ).toEqual(["medication_categories", "quoted_name"]); + }); + + it("keeps no verdict for a raw table the source no longer creates", () => { + const present = new Set(rawTables()); + expect(Object.keys(RAW_SQL_TABLES).filter((t) => !present.has(t))).toEqual( + [], + ); + }); + + it("judges the adopted medication category table as a schema model", () => { + expect(mappedTables.has("medication_categories")).toBe(true); + expect(BACKED_UP_MODELS).toContain("MedicationCategoryAssignment"); + expect(TWO_ENDED_MODELS).toContain("MedicationCategoryAssignment"); }); it("gives every raw table that is not a schema model a verdict", () => { diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 14d7e3f44..34f750ae7 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -475,6 +475,21 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "permanent", confirm: ["AlertDialog"], }, + { + file: "components/medications/use-medication-categories.ts", + destroys: + "one of the person's own medication categories; the medications filed under it move to Other, they are not deleted", + triggers: ["components/medications/medication-categories-sheet.tsx"], + recovery: "permanent", + confirm: ["AlertDialog"], + }, + { + file: "components/medications/sections/courses-section.tsx", + destroys: + "one course of a medication (its dates and note); the intakes logged in it stay, and deleting the only course makes the medication continuous again", + recovery: "permanent", + confirm: ["AlertDialog"], + }, { file: "components/medications/use-medication-intake.ts", destroys: "an intake event recorded seconds ago", diff --git a/src/__tests__/recurrence-course-anchor-guard.test.ts b/src/__tests__/recurrence-course-anchor-guard.test.ts new file mode 100644 index 000000000..b6c5c1e69 --- /dev/null +++ b/src/__tests__/recurrence-course-anchor-guard.test.ts @@ -0,0 +1,123 @@ +/** + * v1.40 (#1024) — the course rule for a rolling anchor is opt-in. + * + * An intake whose next rolling dose falls before `startsOn` belongs to an + * earlier medication course and must not anchor the current one. That rule + * is only right where `startsOn` IS a course start. A measurement or booster + * reminder passes its anchor date in the same field, and the rule once made a + * booster fourteen years overdue come due "later today". So the rule applies + * only when the context declares `rollingAnchor: "courseStart"`. + * + * This guard enumerates every place in `src` that builds a + * `RecurrenceContext` literal and holds two lists against it: every + * medication builder declares the course start, every other builder does + * not. A new builder lands in neither list and fails until someone decides + * which it is. The scan asserts a floor on what it found, so a matcher that + * stopped matching fails instead of agreeing with an empty tree. + * + * Limit, stated: a literal passed straight to the engine without the + * `RecurrenceContext` annotation is invisible here. Every medication path + * today goes through one of the annotated builders below. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const SRC = resolve(__dirname, ".."); +const ROOT = resolve(SRC, ".."); + +/** Medication builders: `startsOn` is a course start. */ +const MEDICATION_BUILDERS = [ + "src/lib/analytics/compliance/adapters.ts", + "src/lib/medications/scheduling/cadence.ts", + "src/lib/medications/scheduling/compliance.ts", + "src/lib/medications/scheduling/worker-helpers.ts", +]; + +/** Builders whose `startsOn` is an anchor date, never a course start. */ +const NON_MEDICATION_BUILDERS = ["src/lib/measurement-reminders/scheduling.ts"]; + +const DECLARATION = + /(\)\s*:\s*RecurrenceContext\s*\{|:\s*RecurrenceContext\s*=\s*\{)/g; + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + return entry.name === "generated" || entry.name === "__tests__" + ? [] + : sourceFiles(full); + } + return /\.tsx?$/.test(entry.name) ? [full] : []; + }); +} + +/** The body of the object literal (or function body) opened at `open`. */ +function blockAt(text: string, open: number): string { + let depth = 0; + for (let i = open; i < text.length; i += 1) { + if (text[i] === "{") depth += 1; + if (text[i] === "}") { + depth -= 1; + if (depth === 0) return text.slice(open, i + 1); + } + } + return text.slice(open); +} + +interface Builder { + file: string; + declaresCourseStart: boolean; +} + +function builders(): Builder[] { + const out: Builder[] = []; + for (const file of sourceFiles(SRC)) { + const text = readFileSync(file, "utf8"); + for (const m of text.matchAll(DECLARATION)) { + const open = m.index! + m[0].length - 1; + out.push({ + file: relative(ROOT, file), + declaresCourseStart: /rollingAnchor\s*:\s*"courseStart"/.test( + blockAt(text, open), + ), + }); + } + } + return out; +} + +describe("the rolling course anchor is opt-in", () => { + it("finds the builders it judges (no vacuous pass)", () => { + const found = builders(); + expect(found.length).toBeGreaterThanOrEqual(6); + // The matcher sees an annotated literal and a typed return. + expect([ + ...`const c: RecurrenceContext = { a: 1 };\nfunction f(): RecurrenceContext {\n return {};\n}`.matchAll( + DECLARATION, + ), + ]).toHaveLength(2); + }); + + it("puts every builder on exactly one of the two lists", () => { + const files = [...new Set(builders().map((b) => b.file))].sort(); + expect(files).toEqual( + [...MEDICATION_BUILDERS, ...NON_MEDICATION_BUILDERS].sort(), + ); + }); + + it("has every medication builder declare the course start", () => { + const missing = builders().filter( + (b) => MEDICATION_BUILDERS.includes(b.file) && !b.declaresCourseStart, + ); + expect(missing).toEqual([]); + }); + + it("keeps every other builder on the default (the last intake anchors)", () => { + const declaring = builders().filter( + (b) => NON_MEDICATION_BUILDERS.includes(b.file) && b.declaresCourseStart, + ); + expect(declaring).toEqual([]); + }); +}); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index e13fa70b6..e2177cc3e 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1039,6 +1039,10 @@ const DELEGABLE_ROUTES: Record = { domain: "medications", why: "The record's medication cabinet. The cached list projection keys on the same resolved id it scopes to, so the owner's cell holds the owner's data. Returns no ingest endpoint and no token count — the route that does is refused.", }, + "app/api/medications/categories/route.ts": { + domain: "medications", + why: "The record's own medication category vocabulary, read and added to from the wizard. The labels and the per-category counts are scoped to the resolved id; renaming and deleting live in the owner-only `[key]` sibling.", + }, "app/api/medications/[id]/route.ts": { domain: "medications", why: "One medication of the record, fetch-then-guard against the resolved user before anything is serialised.", @@ -1679,6 +1683,11 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: ["C1"], why: "Adding to the record's own symptom vocabulary. C1.", }, + "app/api/medications/categories/route.ts": { + domain: "medications", + conditions: ["C1"], + why: "Adding to the record's own medication category vocabulary, the cycle symptom precedent. C1.", + }, "app/api/medications/[id]/route.ts": { domain: "medications", conditions: ["C4", "C7"], @@ -1994,12 +2003,15 @@ const ACTOR_ROUTES: Record = { * visits' domain, with conditions joined only under an illness grant. * 231 -> 232. * + * v1.40 -- the custom medication categories add two: the vocabulary read on + * the record list and its create on the manage literal, the cycle symptom + * precedent. Rename and delete stay owner-only. * v1.40 -- person-defined symptoms add eight: four modules on the record * list, the occurrence create on the write literal, and the definition create, * the definition edit/delete and the occurrence edit/delete on the manage - * literal. 232 -> 240. + * literal. 232 -> 242 with both. */ -const FROZEN_ENTRY_COUNT = 240; +const FROZEN_ENTRY_COUNT = 242; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2760,7 +2772,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(83); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(84); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index e27f224c3..927aaf7b6 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -267,6 +267,10 @@ const PINNED_AFFORDANCES: Record< "src/components/medications/sections/inventory-section.tsx": { "toast.success": 1, }, + // v1.40 — the delete names how many medications moved to Other. + "src/components/medications/medication-categories-sheet.tsx": { + "toast.success": 1, + }, "src/components/medications/sections/notifications-section.tsx": { "toast.success": 1, }, diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index f775c3e5d..860b45b45 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(83); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(84); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 627962fe8..349245bea 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -266,6 +266,13 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // schedule + intake history intact. "src/app/api/medications/route.ts", "src/app/api/medications/layout/route.ts", + // v1.40 (#1041) — the person's own category vocabulary: the same row + // store, the same data-layer reasoning. + "src/app/api/medications/categories/route.ts", + "src/app/api/medications/categories/[key]/route.ts", + // v1.40 (#1024) — a medication's courses, same again. + "src/app/api/medications/[id]/courses/route.ts", + "src/app/api/medications/[id]/courses/[courseId]/route.ts", "src/app/api/medications/compliance/route.ts", "src/app/api/medications/intake/route.ts", "src/app/api/medications/intake/bulk/route.ts", 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 70aef1269..392263eff 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -23,6 +23,9 @@ vi.mock("@/lib/db", () => ({ passkey: { count: vi.fn(async () => 0) }, measurement: { findMany: vi.fn() }, medication: { findMany: vi.fn() }, + // v1.40 — the custom medication categories the backup carries. + medicationCategoryLabel: { findMany: vi.fn().mockResolvedValue([]) }, + medicationCategoryAssignment: { findMany: vi.fn().mockResolvedValue([]) }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, moodTag: { findMany: vi.fn() }, @@ -130,6 +133,9 @@ beforeEach(() => { // `resetAllMocks` clears the inline defaults, and the full-backup payload // builder reads these unconditionally. vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationCategoryLabel.findMany).mockResolvedValue( + [] as never, + ); vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue( null as never, ); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 08c17d48f..0e04c6bba 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -22,6 +22,9 @@ vi.mock("@/lib/db", () => ({ $queryRaw: vi.fn(), measurement: { findMany: vi.fn(), count: vi.fn() }, medication: { findMany: vi.fn() }, + // v1.40 — the custom medication categories the backup carries. + medicationCategoryLabel: { findMany: vi.fn().mockResolvedValue([]) }, + medicationCategoryAssignment: { findMany: vi.fn().mockResolvedValue([]) }, medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() }, moodEntry: { findMany: vi.fn(), count: vi.fn() }, moodTag: { findMany: vi.fn() }, diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 7585249ba..8c01d3a7b 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -12,6 +12,9 @@ vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: vi.fn().mockResolvedValue([]) }, medication: { findMany: vi.fn().mockResolvedValue([]) }, + // v1.40 — the custom medication categories the backup carries. + medicationCategoryLabel: { findMany: vi.fn().mockResolvedValue([]) }, + medicationCategoryAssignment: { findMany: vi.fn().mockResolvedValue([]) }, medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, moodTag: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 87cae0237..81ee918fd 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -442,6 +442,7 @@ describe("POST /api/export/health-record — the selection reaches every format" schedules: [], scheduleRevisions: [], pauseEras: [], + courses: [], doseChanges: [], intakeEvents: [], }; diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 61b69805c..b1d56040a 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -28,6 +28,7 @@ import { import { pairByTimestamp } from "@/lib/analytics/correlations"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, calculateCompliance, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, @@ -118,6 +119,8 @@ export const GET = apiHandler(async () => { scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, }), probeRollupCoverage(user.id), diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 3ffce0401..2e9758306 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -97,7 +97,7 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ })); vi.mock("@/lib/medication-category", () => ({ - getMedicationCategories: vi.fn(async () => ({})), + resolveMedicationCategories: vi.fn(async () => ({})), })); // The route no longer gates on the `insights` module (the AI analysis @@ -117,7 +117,7 @@ import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; import { prisma } from "@/lib/db"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { resolveMedicationCategories } from "@/lib/medication-category"; import { __resetAllCachesForTests, caches } from "@/lib/cache/server-cache"; const SESSION_OK = { @@ -922,8 +922,8 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing ( ( prisma.medicationIntakeEvent.findMany as ReturnType ).mockResolvedValue(intakeEvents); - vi.mocked(getMedicationCategories).mockResolvedValue({ - "med-bp": "BLOOD_PRESSURE", + vi.mocked(resolveMedicationCategories).mockResolvedValue({ + "med-bp": { category: "BLOOD_PRESSURE", categoryLabel: null }, }); } @@ -1127,8 +1127,8 @@ describe("GET /api/insights/comprehensive — an unlogged day is not a zero", () skipped: e.skipped, })), ); - vi.mocked(getMedicationCategories).mockResolvedValue({ - "med-bp": "BLOOD_PRESSURE", + vi.mocked(resolveMedicationCategories).mockResolvedValue({ + "med-bp": { category: "BLOOD_PRESSURE", categoryLabel: null }, }); } diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index 6309dfb22..f15b92687 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -17,12 +17,13 @@ import { } from "@/lib/analytics/correlations"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, calculateCompliance, expectsDoses, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { resolveMedicationCategories } from "@/lib/medication-category"; import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; import { @@ -393,6 +394,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) { scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, }); // A medication with no schedule expects no dose, and `calculateCompliance` @@ -401,9 +404,12 @@ export async function buildComprehensiveResponse(user: AuthedUser) { // medication and would otherwise report perfect adherence. const medications = medicationRows.filter(expectsDoses); - const categoryMap = await getMedicationCategories( + const resolvedCategories = await resolveMedicationCategories( medications.map((m) => m.id), ); + const categoryMap = Object.fromEntries( + Object.entries(resolvedCategories).map(([id, r]) => [id, r.category]), + ); const medCompliance = []; const bpMedicationEvents: Array<{ @@ -461,6 +467,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) { name: med.name, dose: med.dose, category: categoryMap[med.id] ?? "OTHER", + // v1.40 (#1041) — the label of a custom category; null for a built-in. + categoryLabel: resolvedCategories[med.id]?.categoryLabel ?? null, compliance7: c7.rate, compliance30: c30.rate, streak: c7.streak, diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 7fb97d866..5545404eb 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -49,10 +49,20 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ dayKeyForScheduledFor: vi.fn(() => "2026-06-10"), recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), })); +// v1.40 (#1024) — the course writer and reader run against their own +// integration suite; here they answer "no course, nothing refused". +vi.mock("@/lib/medications/courses", async (importOriginal) => ({ + ...(await importOriginal()), + resolveCourseFields: vi.fn(async () => new Map()), + checkCurrentWindow: vi.fn(async () => null), + setCurrentWindow: vi.fn(async () => undefined), +})); vi.mock("@/lib/medication-category", () => ({ deleteMedicationCategory: vi.fn().mockResolvedValue(undefined), getMedicationCategories: vi.fn().mockResolvedValue({}), - setMedicationCategory: vi.fn().mockResolvedValue(undefined), + resolveMedicationCategories: vi.fn().mockResolvedValue({}), + isAssignableCategory: vi.fn().mockResolvedValue(true), + setMedicationCategory: vi.fn().mockResolvedValue("OTHER"), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -70,7 +80,16 @@ vi.mock("next/headers", () => ({ import { PUT } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { + resolveCourseFields, + setCurrentWindow, +} from "@/lib/medications/courses"; +import { + getMedicationCategories, + isAssignableCategory, + resolveMedicationCategories, + setMedicationCategory, +} from "@/lib/medication-category"; import { auditLog } from "@/lib/auth/audit"; import { dayKeyForScheduledFor, @@ -95,6 +114,14 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // v1.40 — the category and course helpers' defaults, which + // `resetAllMocks` clears. + vi.mocked(resolveCourseFields).mockResolvedValue(new Map()); + vi.mocked(resolveMedicationCategories).mockResolvedValue({}); + vi.mocked(isAssignableCategory).mockResolvedValue(true); + vi.mocked(setMedicationCategory).mockImplementation( + async (_id, category) => (category ?? "OTHER") as never, + ); vi.mocked(prisma.medication.findUnique).mockResolvedValue({ id: "m1", userId: "user-1", @@ -176,10 +203,16 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => { vi.mocked(auditLog).mockResolvedValue(undefined); const res = await PUT(putReq({ endsOn: "2026-12-31" }), ROUTE_CTX); expect(res.status).toBe(200); + // v1.40 (#1024) — the window goes through the course writer, which + // writes the row and the latest course in one transaction; the plain + // row update no longer carries it. const call = lastUpdateCall(); - expect(call.data.endsOn).toBeInstanceOf(Date); - expect((call.data.endsOn as Date).toISOString()).toBe( - new Date("2026-12-31").toISOString(), + expect(call.data).not.toHaveProperty("endsOn"); + expect(setCurrentWindow).toHaveBeenCalledWith( + expect.objectContaining({ + medicationId: "m1", + endsOn: new Date("2026-12-31"), + }), ); }); @@ -1054,7 +1087,8 @@ describe("PUT /api/medications/[id] — shipped client on a record-only medicati expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); // Everything else in the save applies. expect(data).toMatchObject({ name: "Atorvastatin 20" }); - expect(data).toHaveProperty("startsOn"); + // v1.40 (#1024) — the window rides the course writer, not this update. + expect(setCurrentWindow).toHaveBeenCalled(); // And it is still a record: the stored schedule comes back on record. const body = (await res.json()) as { data: { diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 4831388da..5a11e30ba 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { COURSES_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; @@ -74,6 +75,8 @@ export const GET = apiHandler( scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, }); // Privacy gate — same sealed 404 for "absent" and "not yours". diff --git a/src/app/api/medications/[id]/courses/[courseId]/route.ts b/src/app/api/medications/[id]/courses/[courseId]/route.ts new file mode 100644 index 000000000..e4b99dddc --- /dev/null +++ b/src/app/api/medications/[id]/courses/[courseId]/route.ts @@ -0,0 +1,119 @@ +/** + * v1.40 (#1024) — edit or delete one course. Both re-project the + * medication's window in the same transaction. Deleting the only course + * makes the medication chronic again; the UI asks first. Owner-only. + */ +import { NextRequest } from "next/server"; + +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { annotate } from "@/lib/logging/context"; +import { + CourseWriteError, + courseRefusalResponse, + deleteCourse, + updateCourse, +} from "@/lib/medications/courses"; +import { dayKeyOfDate } from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { updateMedicationCourseSchema } from "@/lib/validations/medication"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string; courseId: string }> }; + +function refused(err: unknown): Response { + if (err instanceof CourseWriteError) { + const r = courseRefusalResponse(err.refusal); + annotate({ meta: { "medication.course.refused": err.refusal } }); + return apiError(r.message, r.status, { errorCode: r.errorCode }); + } + throw err; +} + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id, courseId } = await params; + + const { data: raw, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = updateMedicationCourseSchema.safeParse(raw); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "medications.course.invalid", + }); + } + + try { + const updated = await updateCourse({ + userId: user.id, + medicationId: id, + courseId, + timeZone: user.timezone || DEFAULT_TIMEZONE, + startsOn: parsed.data.startsOn, + endsOn: parsed.data.endsOn, + note: parsed.data.note, + }); + invalidateUserMedications(user.id, { evict: true }); + await auditLog("medication.course.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + courseId, + fields: Object.keys(parsed.data), + }, + }); + annotate({ + action: { name: "medication.course.update" }, + meta: { medicationId: id }, + }); + return apiSuccess({ + id: updated.id, + startsOn: dayKeyOfDate(updated.startsOn), + endsOn: updated.endsOn ? dayKeyOfDate(updated.endsOn) : null, + }); + } catch (err) { + return refused(err); + } + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id, courseId } = await params; + try { + await deleteCourse({ + userId: user.id, + medicationId: id, + courseId, + timeZone: user.timezone || DEFAULT_TIMEZONE, + }); + } catch (err) { + return refused(err); + } + invalidateUserMedications(user.id, { evict: true }); + await auditLog("medication.course.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, courseId }, + }); + annotate({ + action: { name: "medication.course.delete" }, + meta: { medicationId: id }, + }); + return apiSuccess({ id: courseId, deleted: true }); + }, +); diff --git a/src/app/api/medications/[id]/courses/route.ts b/src/app/api/medications/[id]/courses/route.ts new file mode 100644 index 000000000..1af87a726 --- /dev/null +++ b/src/app/api/medications/[id]/courses/route.ts @@ -0,0 +1,125 @@ +/** + * v1.40 (#1024) — a medication's courses. + * + * GET /api/medications/{id}/courses — the resolved course fields, the same + * ones the list and detail reads carry. + * POST /api/medications/{id}/courses — start a course (`{ startsOn, + * endsOn?, note? }`). The medication's `startsOn`/`endsOn` are + * rewritten to the latest course in the same transaction, so reminders + * and the dose actions follow without another write. + * + * Owner-only for now: a delegate edits the current window through the + * medication's own PUT, as before. + */ +import { NextRequest } from "next/server"; + +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; +import { annotate } from "@/lib/logging/context"; +import { + CourseWriteError, + courseRefusalResponse, + createCourse, + resolveCourseFields, +} from "@/lib/medications/courses"; +import { dayKeyOfDate } from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { createMedicationCourseSchema } from "@/lib/validations/medication"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + const medication = await prisma.medication.findFirst({ + where: { id, userId: user.id }, + select: { id: true, active: true, oneShot: true }, + }); + if (!medication) return apiError("Medication not found", 404); + const fields = ( + await resolveCourseFields( + [medication], + new Date(), + user.timezone || DEFAULT_TIMEZONE, + ) + ).get(id); + return apiSuccess(fields); + }, +); + +export const POST = apiHandler( + withIdempotency<[NextRequest, RouteParams]>(postCourse), +); + +async function postCourse( + request: NextRequest, + { params }: RouteParams, +): Promise { + const { user } = await requireAuth(); + const { id } = await params; + + const { data: raw, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = createMedicationCourseSchema.safeParse(raw); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "medications.course.invalid", + }); + } + + try { + const created = await createCourse({ + userId: user.id, + medicationId: id, + timeZone: user.timezone || DEFAULT_TIMEZONE, + startsOn: parsed.data.startsOn, + endsOn: parsed.data.endsOn ?? null, + note: parsed.data.note, + }); + invalidateUserMedications(user.id, { evict: true }); + await auditLog("medication.course.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + courseId: created.id, + startsOn: dayKeyOfDate(created.startsOn), + endsOn: created.endsOn ? dayKeyOfDate(created.endsOn) : null, + }, + }); + annotate({ + action: { name: "medication.course.create" }, + meta: { medicationId: id }, + }); + return apiSuccess( + { + id: created.id, + startsOn: dayKeyOfDate(created.startsOn), + endsOn: created.endsOn ? dayKeyOfDate(created.endsOn) : null, + }, + 201, + ); + } catch (err) { + if (err instanceof CourseWriteError) { + const r = courseRefusalResponse(err.refusal); + annotate({ meta: { "medication.course.refused": err.refusal } }); + return apiError(r.message, r.status, { errorCode: r.errorCode }); + } + throw err; + } +} diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 0159323a1..d0cc378f6 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -14,6 +14,11 @@ * Ownership-scoped via `assertMedicationOwnership`; rate-limited; reads only * `deletedAt: null` rows. */ +import { + complianceMintWindow, + COURSES_COMPLIANCE_SELECT, + slotInsideCourses, +} from "@/lib/analytics/compliance"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -130,6 +135,9 @@ export const GET = apiHandler( // v1.16.3 — archived schedule eras: past days mint against the // schedule that was live THEN. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.40 (#1024) — the courses, so the history shows every course's + // slots, the same ones the compliance rate counts. + courses: COURSES_COMPLIANCE_SELECT, }, }); if (!medication) { @@ -204,10 +212,20 @@ export const GET = apiHandler( }, to.getTime()), ); - const bandMedication: BandMinterMedication = { - id: medication.id, + // v1.40 (#1024) — mint over the span of every course and keep only the + // slots on a course day, the rule the compliance ledger applies, so the + // history lists exactly the misses the rate counts. + const courseCtx = { startsOn: medication.startsOn, endsOn: medication.endsOn, + timeZone: userTz, + courses: medication.courses, + }; + const mintWindow = complianceMintWindow(courseCtx); + const bandMedication: BandMinterMedication = { + id: medication.id, + startsOn: mintWindow.startsOn, + endsOn: mintWindow.endsOn, oneShot: medication.oneShot, createdAt: medication.createdAt, }; @@ -256,7 +274,9 @@ export const GET = apiHandler( let family: BandFamily = "none"; for (const g of groups) { if (g.hasExpectedSlots) { - bands.push(...g.bands); + bands.push( + ...g.bands.filter((b) => slotInsideCourses(courseCtx, b.at)), + ); if (family === "none") family = g.family; } } diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index dbae9fc1a..c30e57891 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -14,10 +14,18 @@ import { updateMedicationSchema } from "@/lib/validations/medication"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { deleteMedicationCategory, - getMedicationCategories, + isAssignableCategory, + resolveMedicationCategories, setMedicationCategory, } from "@/lib/medication-category"; import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; +import { + checkCurrentWindow, + CourseWriteError, + courseRefusalResponse, + resolveCourseFields, + setCurrentWindow, +} from "@/lib/medications/courses"; import { schedulesMateriallyDiffer, toRevisionPayloadEntry, @@ -103,9 +111,11 @@ export const GET = apiHandler( } let category = "OTHER"; + let categoryLabel: string | null = null; try { - const categories = await getMedicationCategories([id]); - category = categories[id] ?? "OTHER"; + const categories = await resolveMedicationCategories([id]); + category = categories[id]?.category ?? "OTHER"; + categoryLabel = categories[id]?.categoryLabel ?? null; } catch { // Category enrichment is optional } @@ -235,6 +245,15 @@ export const GET = apiHandler( Number(medication.unitsPerDose), ); + // v1.40 (#1024) — the courses, resolved on the server. + const courseFields = ( + await resolveCourseFields( + [medication], + now, + user.timezone || DEFAULT_TIMEZONE, + ) + ).get(id); + return apiSuccess({ ...medication, unitsPerDose: Number(medication.unitsPerDose), @@ -242,6 +261,8 @@ export const GET = apiHandler( // intake tracking is off (see `scheduleWireFields`). ...scheduleWireFields(medication.trackIntake, schedulesDto), category, + categoryLabel, + ...courseFields, nextDueAt: display ? display.at.toISOString() : null, nextDueOverdue: display?.overdue ?? false, // v1.39.4 (#1040) — see `resolveIntakeActionability`. @@ -265,7 +286,10 @@ export const PUT = apiHandler( // below excludes every actioned row — and the previous cadence is the one // genuinely unrecoverable part, which is what C7 puts in the audit row. // The DELETE beside it stays refused. - const { user } = await requireRecordAuth("manage", "medications"); + const { user, authMethod } = await requireRecordAuth( + "manage", + "medications", + ); const { id } = await params; // v1.5.5 C-E3-3 — route ownership check through the shared helper @@ -290,6 +314,9 @@ export const PUT = apiHandler( name: true, dose: true, endsOn: true, + // v1.40 (#1024) — the course check below needs the stored window. + startsOn: true, + oneShot: true, _count: { select: { schedules: true } }, }, }); @@ -383,6 +410,16 @@ export const PUT = apiHandler( reminderGraceMinutes: topLevelGraceMinutes, } = input; + // v1.40 (#1041) — a custom category must be one of this record's own. + if ( + category !== undefined && + !(await isAssignableCategory(user.id, category)) + ) { + return apiError("Unknown medication category", 422, { + errorCode: "medications.category.unknown", + }); + } + // v1.39.1 (#1033) — intake tracking transition. Off keeps the schedule // as a record; the live era from here on expects nothing. const wasTracked = existing.trackIntake !== false; @@ -480,6 +517,34 @@ export const PUT = apiHandler( // means the dose is the start date; endsOn auto-matches. const normalisedEndsOn = oneShot === true && startsOn ? startsOn : endsOn; + // v1.40 (#1024) — the window a PUT sets is the latest course. Check it + // against the other courses BEFORE the row changes, so a refused window + // leaves the medication as it was. + const touchesWindow = + startsOn !== undefined || + normalisedEndsOn !== undefined || + oneShot !== undefined; + const nextWindow = { + startsOn: startsOn !== undefined ? startsOn : existing.startsOn, + endsOn: + normalisedEndsOn !== undefined ? normalisedEndsOn : existing.endsOn, + }; + const courseTz = user.timezone || DEFAULT_TIMEZONE; + if (touchesWindow) { + const refusal = await checkCurrentWindow({ + userId: user.id, + medicationId: id, + timeZone: courseTz, + ...nextWindow, + oneShot: oneShot ?? existing.oneShot, + createdAt: existing.createdAt, + }); + if (refusal) { + const r = courseRefusalResponse(refusal); + return apiError(r.message, r.status, { errorCode: r.errorCode }); + } + } + const pausedAtPatch = active === undefined ? {} @@ -893,8 +958,10 @@ export const PUT = apiHandler( // v1.5 scheduling primitives — pass-through when supplied. // `startsOn` / `endsOn` are `Date | null | undefined` (the // schema lets the user clear them explicitly with null). - ...(startsOn !== undefined && { startsOn }), - ...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }), + // v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the + // course writer below writes them together with the course they + // project, in one transaction, so the row and its courses cannot + // disagree if one of the two writes fails. ...(oneShot !== undefined && { oneShot }), // v1.16.11 — as-needed flag, field-by-field (the invariants above // already guaranteed the medication ends schedule-consistent). @@ -988,10 +1055,49 @@ export const PUT = apiHandler( } } - const normalizedCategory = - category !== undefined - ? await setMedicationCategory(id, category) - : ((await getMedicationCategories([id]))[id] ?? "OTHER"); + // v1.40 (#1024) — write the window onto the latest course and project it + // back, then serve the projected row. + if (touchesWindow) { + let projected; + try { + projected = await setCurrentWindow({ + userId: user.id, + medicationId: id, + timeZone: courseTz, + ...nextWindow, + }); + } catch (err) { + // A concurrent course write landed between the check above and this + // transaction. Nothing of the window was written. + if (err instanceof CourseWriteError) { + const r = courseRefusalResponse(err.refusal); + return apiError(r.message, r.status, { errorCode: r.errorCode }); + } + throw err; + } + medication = { ...medication, ...projected }; + } + + // v1.40 (#1041) — an iPhone app build that predates custom categories + // decodes an unknown category as OTHER and sends it back on every edit, + // which would overwrite the person's own category with OTHER. A native + // client (Bearer) sending OTHER over a custom category keeps the custom + // one; moving a custom category back to OTHER stays possible on the web. + let categoryToWrite = category; + if (category === "OTHER" && authMethod === "bearer") { + const current = (await resolveMedicationCategories([id]))[id]; + if (current?.category.startsWith("custom:")) { + categoryToWrite = undefined; + annotate({ + action: { name: "medication.update.custom_category_kept" }, + meta: { entity_id: id }, + }); + } + } + if (categoryToWrite !== undefined) + await setMedicationCategory(id, categoryToWrite); + const resolvedCategory = (await resolveMedicationCategories([id]))[id]; + const normalizedCategory = resolvedCategory?.category ?? "OTHER"; await auditLog("medication.update", { userId: user.id, @@ -1054,6 +1160,11 @@ export const PUT = apiHandler( ), ), category: normalizedCategory, + categoryLabel: resolvedCategory?.categoryLabel ?? null, + // v1.40 (#1024) — the courses, as every medication read carries them. + ...(await resolveCourseFields([medication], new Date(), courseTz)).get( + id, + ), }); }, ); diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 888ef60c7..a60495d7e 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -51,9 +51,19 @@ vi.mock("@/lib/cache/server-cache", () => ({ caches: { medicationsList: {} }, __resetAllCachesForTests: vi.fn(), })); +// v1.40 (#1024) — the course writer and reader run against their own +// integration suite; here they answer "no course, nothing refused". +vi.mock("@/lib/medications/courses", async (importOriginal) => ({ + ...(await importOriginal()), + resolveCourseFields: vi.fn(async () => new Map()), + checkCurrentWindow: vi.fn(async () => null), + setCurrentWindow: vi.fn(async () => undefined), +})); vi.mock("@/lib/medication-category", () => ({ getMedicationCategories: vi.fn().mockResolvedValue({}), - setMedicationCategory: vi.fn().mockResolvedValue(undefined), + resolveMedicationCategories: vi.fn().mockResolvedValue({}), + isAssignableCategory: vi.fn().mockResolvedValue(true), + setMedicationCategory: vi.fn().mockResolvedValue("OTHER"), })); vi.mock("@/lib/tz/local-day", async (importOriginal) => ({ // The schedule engine reads the real local-day helpers; only the today @@ -81,7 +91,13 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { cachedSwr } from "@/lib/cache/server-cache"; import { getUserTodayBounds } from "@/lib/tz/local-day"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { resolveCourseFields } from "@/lib/medications/courses"; +import { + getMedicationCategories, + isAssignableCategory, + resolveMedicationCategories, + setMedicationCategory, +} from "@/lib/medication-category"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -99,6 +115,14 @@ function postReq(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // v1.40 — the category and course helpers' defaults, which + // `resetAllMocks` clears. + vi.mocked(resolveCourseFields).mockResolvedValue(new Map()); + vi.mocked(resolveMedicationCategories).mockResolvedValue({}); + vi.mocked(isAssignableCategory).mockResolvedValue(true); + vi.mocked(setMedicationCategory).mockImplementation( + async (_id, category) => (category ?? "OTHER") as never, + ); // v1.32.25 — default the mirror growth-guard count below the cap so the // pre-existing mirror-create tests hit the create path, not the guard. vi.mocked(prisma.medication.count).mockResolvedValue(0 as never); diff --git a/src/app/api/medications/categories/[key]/route.ts b/src/app/api/medications/categories/[key]/route.ts new file mode 100644 index 000000000..85974a8bc --- /dev/null +++ b/src/app/api/medications/categories/[key]/route.ts @@ -0,0 +1,151 @@ +/** + * v1.40 (#1041) — rename, reorder, hide or delete one of the person's own + * medication categories. + * + * Owner-only, like the cycle symptom vocabulary's edit and delete: a + * delegate may add a category from the wizard (the POST beside this file is + * admitted at MANAGE), but renaming or removing the owner's vocabulary stays + * with the owner. Both handlers resolve the key against the caller's own rows + * only; another account's key, or a built-in value, is a 404. DELETE moves every medication + * filed under the category to OTHER in the same transaction and answers how + * many moved, so the app never leaves a medication pointing at a key that no + * longer exists. The label is never logged. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + decryptCategoryLabel, + deleteMedicationCategoryLabel, + encryptCategoryLabel, +} from "@/lib/medication-category"; +import { + isCustomMedicationCategoryKey, + updateMedicationCategoryLabelSchema, +} from "@/lib/validations/medication"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ key: string }> }; + +async function rateLimited(userId: string): Promise { + const rl = await checkRateLimit( + `medications:category:custom:${userId}`, + 30, + 60_000, + ); + return !rl.allowed; +} + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + if (await rateLimited(user.id)) { + return apiError("Too many requests, try again later", 429); + } + + const { key } = await params; + if (!isCustomMedicationCategoryKey(key)) { + return apiError("Category not found", 404); + } + + const { data: raw, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = updateMedicationCategoryLabelSchema.safeParse(raw); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "medications.category.invalid", + }); + } + + const owned = await prisma.medicationCategoryLabel.findFirst({ + where: { key, userId: user.id }, + select: { id: true }, + }); + if (!owned) return apiError("Category not found", 404); + + const updated = await prisma.medicationCategoryLabel.update({ + where: { id: owned.id }, + data: { + ...(parsed.data.label !== undefined + ? { labelEncrypted: encryptCategoryLabel(parsed.data.label) } + : {}), + ...(parsed.data.sortOrder !== undefined + ? { sortOrder: parsed.data.sortOrder } + : {}), + ...(parsed.data.isActive !== undefined + ? { isActive: parsed.data.isActive } + : {}), + }, + select: { + key: true, + labelEncrypted: true, + sortOrder: true, + isActive: true, + }, + }); + const medicationCount = await prisma.medicationCategoryAssignment.count({ + where: { category: key, medication: { userId: user.id } }, + }); + + await auditLog("medication.category.custom.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { key, fields: Object.keys(parsed.data) }, + }); + annotate({ + action: { name: "medication.category.custom.update" }, + meta: { key }, + }); + + return apiSuccess({ + key: updated.key, + label: decryptCategoryLabel(updated.labelEncrypted), + sortOrder: updated.sortOrder, + isActive: updated.isActive, + medicationCount, + }); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + if (await rateLimited(user.id)) { + return apiError("Too many requests, try again later", 429); + } + + const { key } = await params; + if (!isCustomMedicationCategoryKey(key)) { + return apiError("Category not found", 404); + } + + const result = await deleteMedicationCategoryLabel(user.id, key); + if (!result) return apiError("Category not found", 404); + + await auditLog("medication.category.custom.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { key, movedCount: result.movedCount }, + }); + annotate({ + action: { name: "medication.category.custom.delete" }, + meta: { key, movedCount: result.movedCount }, + }); + + return apiSuccess({ key, movedCount: result.movedCount }); + }, +); diff --git a/src/app/api/medications/categories/route.ts b/src/app/api/medications/categories/route.ts new file mode 100644 index 000000000..313012672 --- /dev/null +++ b/src/app/api/medications/categories/route.ts @@ -0,0 +1,126 @@ +/** + * v1.40 (#1041) — the person's own medication categories. + * + * GET /api/medications/categories — every custom category of the record, + * hidden ones included, in picker order, labels decrypted, each with + * the number of medications filed under it. + * POST /api/medications/categories — create one (`{ label }`). Mints a + * `custom:` key and encrypts the label at rest. Capped per + * account (hidden ones count). + * + * The label is the person's free text, so it never reaches a wide event or an + * audit row; only the key does. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { withIdempotency } from "@/lib/idempotency"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + encryptCategoryLabel, + listMedicationCategoryLabels, + mintCustomMedicationCategoryKey, +} from "@/lib/medication-category"; +import { + MAX_CUSTOM_MEDICATION_CATEGORIES, + createMedicationCategoryLabelSchema, +} from "@/lib/validations/medication"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireRecordAuth("read", "medications"); + const categories = await listMedicationCategoryLabels(user.id); + annotate({ + action: { name: "medication.category.custom.list" }, + meta: { count: categories.length }, + }); + return apiSuccess({ categories }); +}); + +/** + * Wrapped in `withIdempotency`: the key is minted fresh per request and the + * label is ciphertext with a per-write IV, so nothing else would catch a + * replayed create and the person's list would gain a silent duplicate. + */ +export const POST = apiHandler( + withIdempotency<[NextRequest]>(postMedicationCategory), +); + +async function postMedicationCategory(request: NextRequest): Promise { + const { user, actor } = await requireRecordAuth("manage", "medications"); + + // Keyed on the actor, the sharing precedent: a manager spends their own + // allowance and cannot lock the owner out of their own record. + const rl = await checkRateLimit( + `medications:category:custom:${actor.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + + const { data: raw, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = createMedicationCategoryLabelSchema.safeParse(raw); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "medications.category.invalid", + }); + } + + const count = await prisma.medicationCategoryLabel.count({ + where: { userId: user.id }, + }); + if (count >= MAX_CUSTOM_MEDICATION_CATEGORIES) { + return apiError( + `Custom category limit reached (${MAX_CUSTOM_MEDICATION_CATEGORIES})`, + 422, + { errorCode: "medications.category.limitReached" }, + ); + } + + const created = await prisma.medicationCategoryLabel.create({ + data: { + userId: user.id, + key: mintCustomMedicationCategoryKey(), + labelEncrypted: encryptCategoryLabel(parsed.data.label), + sortOrder: count, + }, + select: { key: true, sortOrder: true, isActive: true }, + }); + + await auditLog("medication.category.custom.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { key: created.key }, + }); + annotate({ + action: { name: "medication.category.custom.create" }, + meta: { key: created.key }, + }); + + return apiSuccess( + { + key: created.key, + label: parsed.data.label, + sortOrder: created.sortOrder, + isActive: created.isActive, + medicationCount: 0, + }, + 201, + ); +} diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index eee5e5f97..59d17ee03 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -1,5 +1,6 @@ import pLimit from "p-limit"; +import { COURSES_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -59,6 +60,8 @@ export const GET = apiHandler(async () => { scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, orderBy: { createdAt: "desc" }, }); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 0b148d2e7..4ebc3e955 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -10,13 +10,27 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; -import { createMedicationSchema } from "@/lib/validations/medication"; +import { + courseRefusalResponse, + resolveCourseFields, +} from "@/lib/medications/courses"; +import { + dateOfDayKey, + dayKeyOfDate, + validateCourses, +} from "@/lib/medications/course-window"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; +import { + createMedicationSchema, + isCustomMedicationCategoryKey, +} from "@/lib/validations/medication"; import { unstableExternalIdMeta, unstableExternalIdShape, } from "@/lib/validations/external-id"; import { - getMedicationCategories, + isAssignableCategory, + resolveMedicationCategories, setMedicationCategory, } from "@/lib/medication-category"; import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; @@ -78,9 +92,11 @@ async function respondWithExistingMirror( }, ): Promise { let category = "OTHER"; + let categoryLabel: string | null = null; try { - const categories = await getMedicationCategories([medication.id]); - category = categories[medication.id] ?? "OTHER"; + const categories = await resolveMedicationCategories([medication.id]); + category = categories[medication.id]?.category ?? "OTHER"; + categoryLabel = categories[medication.id]?.categoryLabel ?? null; } catch { getEvent()?.addWarning("Medication categories could not be loaded"); } @@ -107,6 +123,7 @@ async function respondWithExistingMirror( ), ), category, + categoryLabel, }); } @@ -174,6 +191,16 @@ async function postMedication(request: NextRequest): Promise { trackIntake, } = parsed.data; + // v1.40 (#1041) — a custom category must be one of this record's own. + if ( + category !== undefined && + !(await isAssignableCategory(user.id, category)) + ) { + return apiError("Unknown medication category", 422, { + errorCode: "medications.category.unknown", + }); + } + // v1.28 — idempotent mirror create. A mirrored medication (Apple // Health) is keyed by the `(userId, externalSource, externalId)` // unique; a re-post of the same concept id (iOS re-sync, retry after a @@ -266,6 +293,31 @@ async function postMedication(request: NextRequest): Promise { const normalisedEndsOn = oneShot === true && startsOn ? startsOn : (endsOn ?? undefined); + // v1.40 (#1024) — a medication created with a window starts with one + // course; the row projects it. An end without a start has always meant + // "running since creation", so the course starts today (or on the end day, + // when that lies earlier). No window: no course, chronic since creation. + let initialCourse: { startsOn: Date; endsOn: Date | null } | null = null; + if (startsOn || normalisedEndsOn) { + const todayKey = userDayKey(new Date(), user.timezone || DEFAULT_TIMEZONE); + const endKey = normalisedEndsOn ? dayKeyOfDate(normalisedEndsOn) : null; + initialCourse = { + startsOn: + startsOn ?? + dateOfDayKey(endKey && endKey < todayKey ? endKey : todayKey), + endsOn: normalisedEndsOn ?? null, + }; + const refusal = validateCourses( + [initialCourse], + todayKey, + oneShot === true, + ); + if (refusal) { + const r = courseRefusalResponse(refusal); + return apiError(r.message, r.status, { errorCode: r.errorCode }); + } + } + const createMedication = () => prisma.medication.create({ data: { @@ -306,6 +358,17 @@ async function postMedication(request: NextRequest): Promise { // v1.5 scheduling primitives — pass-through when supplied. ...(startsOn !== undefined && { startsOn }), ...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }), + ...(initialCourse && { + startsOn: initialCourse.startsOn, + endsOn: initialCourse.endsOn, + courses: { + create: { + userId: user.id, + startsOn: initialCourse.startsOn, + endsOn: initialCourse.endsOn, + }, + }, + }), ...(oneShot !== undefined && { oneShot }), // v1.16.11 — as-needed flag, field-by-field. An asNeeded create // carries an empty `scheduleInputs`, so the nested create below @@ -413,6 +476,10 @@ async function postMedication(request: NextRequest): Promise { medication.id, category, ); + const categoryLabel = isCustomMedicationCategoryKey(normalizedCategory) + ? ((await resolveMedicationCategories([medication.id]))[medication.id] + ?.categoryLabel ?? null) + : null; await auditLog("medication.create", { userId: user.id, @@ -446,6 +513,15 @@ async function postMedication(request: NextRequest): Promise { ), ), category: normalizedCategory, + categoryLabel, + // v1.40 (#1024) — the courses, as every medication read carries them. + ...( + await resolveCourseFields( + [medication], + new Date(), + user.timezone || DEFAULT_TIMEZONE, + ) + ).get(medication.id), }, 201, ); diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index d66708448..99aa76753 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -24,6 +24,7 @@ import { Glp1SubstanceCurveSummary } from "@/components/insights/glp1-substance- import { SubPageShell } from "@/components/insights/sub-page-shell"; import { TileHeader } from "@/components/insights/tile-header"; import { apiGet } from "@/lib/api/api-fetch"; +import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; /** * v1.4.25 W4 — `/insights/medications`. @@ -44,7 +45,10 @@ interface MedicationEntry { id: string; name: string; dose: string; - category: "BLOOD_PRESSURE" | "VITAMIN" | "OTHER"; + /** A built-in category value or `custom:` (v1.40). */ + category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; compliance7: number; compliance30: number; streak: number; @@ -234,12 +238,11 @@ export default function InsightsMedikamentePage() { // card carried (7d/30d bars, taken/skipped/missed, heatmap, // per-med assistant sentence) stays — only the vertical rhythm // tightens. - const categoryLabel = - med.category === "BLOOD_PRESSURE" - ? t("medications.categoryBloodPressure") - : med.category === "VITAMIN" - ? t("medications.categoryVitamin") - : t("medications.categoryOther"); + const categoryLabel = getMedicationCategoryLabel( + med.category, + t, + med.categoryLabel, + ); return ( diff --git a/src/app/medications/__tests__/category-filter.test.tsx b/src/app/medications/__tests__/category-filter.test.tsx new file mode 100644 index 000000000..45c7785cb --- /dev/null +++ b/src/app/medications/__tests__/category-filter.test.tsx @@ -0,0 +1,140 @@ +/** + * v1.40 (#1041) — the category filter on /medications and the custom + * category badge. The filter appears once the list spans two categories + * (or the record has categories of its own), offers each category in use, + * and the card badge shows a custom category's own label. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), + useSearchParams: () => new URLSearchParams(""), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + isLoading: false, + user: { timezone: "Europe/Berlin" }, + }), +})); + +// The create wizard and the dialogs are closed in every assertion here; +// mock them away so the page render stays scoped to the list surface. +vi.mock("@/components/medications/wizard/medication-wizard-dialog", () => ({ + MedicationWizardDialog: () => null, +})); +vi.mock("@/components/medications/log-intake-dialog", () => ({ + LogIntakeDialog: () => null, +})); + +// v1.30.x — `@/app/medications/page` is now an async RSC prefetch wrapper; the +// interactive surface this test renders is the client leaf it wraps. +import MedicationsPage from "@/app/medications/page-client"; +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { MedicationListLayout } from "@/lib/medication-list-layout"; + +const pastWindow = { + windowStart: "01:00", + windowEnd: "02:00", + label: null, + daysOfWeek: null, + dose: null, +}; + +const KEY = "custom:11111111-1111-4111-8111-111111111111"; + +function med(id: string, name: string, category: string, label?: string) { + return { + id, + name, + dose: "5 mg", + category, + categoryLabel: label ?? null, + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + todayEventCount: 0, + nextDueAt: null, + nextDueOverdue: false, + stockDosesRemaining: null, + schedules: [{ id: `s-${id}`, ...pastWindow }], + }; +} + +function renderPage( + meds: ReturnType[], + customCategories: unknown[] = [], +): string { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + client.setQueryData(queryKeys.medications(), meds); + client.setQueryData(queryKeys.medicationListLayout(), { + version: 1, + view: "cards", + order: [], + } satisfies MedicationListLayout); + client.setQueryData(queryKeys.medicationCategories(), { + categories: customCategories, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-02T12:00:00Z")); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe("/medications — category filter", () => { + it("shows the filter once the list spans two categories, with the custom label on the card", () => { + const html = renderPage([ + med("m1", "Levothyroxine", "THYROID"), + med("m2", "Tamiflu", KEY, "Travel kit"), + ]); + expect(html).toContain('data-slot="filter-bar"'); + expect(html).toContain('data-slot="filter-bar-pill"'); + // The card badge reads the person's own label, not "Other". + expect(html).toContain("Travel kit"); + }); + + it("leaves the filter out when every medication shares one category and none are custom", () => { + const html = renderPage([ + med("m1", "Levothyroxine", "THYROID"), + med("m2", "Liothyronine", "THYROID"), + ]); + expect(html).not.toContain('data-slot="filter-bar"'); + }); + + it("shows the filter, with its manage entry, when the record has categories of its own", () => { + const html = renderPage( + [med("m1", "Levothyroxine", "THYROID")], + [ + { + key: KEY, + label: "Travel kit", + sortOrder: 0, + isActive: true, + medicationCount: 0, + }, + ], + ); + expect(html).toContain('data-slot="filter-bar"'); + expect(html).toContain('data-slot="medication-categories-manage"'); + }); +}); diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index 23df5de21..7d13852ec 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -1,7 +1,8 @@ "use client"; +import type { MedicationCourseFields } from "@/components/medications/course-fields"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -33,6 +34,15 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { EmptyState } from "@/components/ui/empty-state"; +import { FilterBar, FilterBarSelect } from "@/components/ui/filter-bar"; +import { MedicationCategoriesSheet } from "@/components/medications/medication-categories-sheet"; +import { useMedicationCategories } from "@/components/medications/use-medication-categories"; +import { + ALL_MEDICATION_CATEGORIES, + effectiveCategoryFilter as resolveCategoryFilter, + filterMedicationsByCategory, + medicationCategoryOptions, +} from "@/lib/medications/category-filter"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -74,6 +84,8 @@ interface Medication { name: string; dose: string; category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; /** v1.4.25 W4d — Prisma treatment class (GENERIC | GLP1). */ treatmentClass?: string; /** v1.4.25 W4d — doses per pen/vial for inventory math. */ @@ -116,6 +128,11 @@ interface Medication { intakeActionable?: boolean; /** v1.39.4 (#1040) — where today sits in the course. */ courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; + /** v1.40 (#1024) — the server-resolved course fields. */ + courses?: MedicationCourseFields["courses"]; + courseCount?: number; + previousCourseEndedOn?: string | null; + canStartCourse?: boolean; /** v1.9.0 — optional WHO ATC classification code for the FHIR export. */ atcCode?: string | null; /** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */ @@ -169,6 +186,63 @@ function MedicationCardSkeleton() { ); } +/** + * v1.40 (#1041) — the category filter is remembered for the browser session + * only, by design: no preference column, and a new visit starts on All. + */ +const CATEGORY_FILTER_STORAGE_KEY = "healthlog.medications.categoryFilter"; +const ALL_CATEGORIES = ALL_MEDICATION_CATEGORIES; + +const CATEGORY_FILTER_EVENT = "healthlog:medication-category-filter"; + +function readStoredCategoryFilter(): string { + try { + return ( + window.sessionStorage.getItem(CATEGORY_FILTER_STORAGE_KEY) ?? + ALL_CATEGORIES + ); + } catch { + return ALL_CATEGORIES; + } +} + +function storeCategoryFilter(value: string) { + try { + if (value === ALL_CATEGORIES) { + window.sessionStorage.removeItem(CATEGORY_FILTER_STORAGE_KEY); + } else { + window.sessionStorage.setItem(CATEGORY_FILTER_STORAGE_KEY, value); + } + } catch { + // Storage blocked: the pick still applies until the page unmounts. + } + window.dispatchEvent(new Event(CATEGORY_FILTER_EVENT)); +} + +function subscribeCategoryFilter(onChange: () => void): () => void { + window.addEventListener(CATEGORY_FILTER_EVENT, onChange); + return () => window.removeEventListener(CATEGORY_FILTER_EVENT, onChange); +} + +/** + * The stored pick, read through `useSyncExternalStore` so the server render + * (and the first client render) is All and the stored pick follows without a + * hydration mismatch. Blocked storage degrades to a per-view filter. + */ +function useCategoryFilter(): [string, (value: string) => void] { + const [fallback, setFallback] = useState(null); + const stored = useSyncExternalStore( + subscribeCategoryFilter, + readStoredCategoryFilter, + () => ALL_CATEGORIES, + ); + const set = useCallback((value: string) => { + setFallback(value); + storeCategoryFilter(value); + }, []); + return [fallback ?? stored, set]; +} + export default function MedicationsPageClient() { const { user, isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); @@ -213,6 +287,15 @@ export default function MedicationsPageClient() { // v1.16.11 (#316) — "Alle fälligen einnehmen" confirm dialog. The header // button earns its slot only while ≥ 2 medications are currently due. const [takeAllOpen, setTakeAllOpen] = useState(false); + // v1.40 (#1041) — the category filter and the manage sheet it opens. + const [categoryFilter, setCategoryFilter] = useCategoryFilter(); + const [manageCategoriesOpen, setManageCategoriesOpen] = useState(false); + // Renaming, hiding and deleting a category stay with the owner (the edit + // route is owner-only, like the cycle symptom vocabulary's). + const canManageCategories = !inSharedRecord; + const customCategories = useMedicationCategories({ + enabled: isAuthenticated && medicationsEnabled, + }); // v1.16.10 — the persisted list presentation: cards vs table plus the // manual medication order, server-side per user @@ -382,6 +465,19 @@ export default function MedicationsPageClient() { // Defensive against stale service-worker responses or any future API // shape change: only filter when we actually have an array. const medsArray = Array.isArray(medications) ? medications : []; + // v1.40 (#1041) — the categories in use, each once, labelled the way the + // card badge labels it. The filter offers only these, so a pick can never + // empty the list. + const categoriesInUse = medicationCategoryOptions(medsArray, t); + const effectiveCategoryFilter = resolveCategoryFilter( + categoryFilter, + categoriesInUse, + ); + const hasCustomCategories = (customCategories.data ?? []).length > 0; + const showCategoryFilter = + medsArray.length > 0 && + (categoriesInUse.length >= 2 || hasCustomCategories); + // v1.16.10 — the user-defined manual order applies to BOTH views // (cards and table). Medications not in the saved order keep the // alphabetical default, appended after the ordered block. @@ -394,6 +490,16 @@ export default function MedicationsPageClient() { layout.order, ); const tableView = layout.view === "table"; + // The filter narrows what the list shows; the due set and the log-intake + // dialog below keep reading every medication. + const shownActiveMeds = filterMedicationsByCategory( + activeMeds, + effectiveCategoryFilter, + ); + const shownInactiveMeds = filterMedicationsByCategory( + inactiveMeds, + effectiveCategoryFilter, + ); // v1.16.11 (#316) — the currently-due set, derived from the list payload // the page already holds via the SAME pipeline a card's pill runs (band @@ -493,6 +599,34 @@ export default function MedicationsPageClient() { } /> + {showCategoryFilter && !isLoading && !isError && ( + setCategoryFilter(ALL_CATEGORIES)} + > + + {canManageCategories && ( + + )} + + )} + {isLoading || isLayoutLoading ? ( // v1.11.3 C4 — skeletons instead of a bare centred spinner so the // page reserves the loaded layout and does not jump when the @@ -540,17 +674,17 @@ export default function MedicationsPageClient() { // gating, same mutation hooks as the cards; one row per // medication, inactive rows pinned after the active block. ) : (
{/* Active medications */} - {activeMeds.length > 0 && ( + {shownActiveMeds.length > 0 && (
- {activeMeds.map((med) => + {shownActiveMeds.map((med) => // v1.16.11 — an as-needed medication always renders the // generic card: the GLP-1 variant is built around the // rolling injection cadence an as-needed med doesn't have. @@ -577,13 +711,13 @@ export default function MedicationsPageClient() { )} {/* Inactive medications */} - {inactiveMeds.length > 0 && ( + {shownInactiveMeds.length > 0 && (

- {t("common.inactive")} ({inactiveMeds.length}) + {t("common.inactive")} ({shownInactiveMeds.length})

- {inactiveMeds.map((med) => + {shownInactiveMeds.map((med) => med.treatmentClass === "GLP1" && !med.asNeeded ? ( )} + {manageCategoriesOpen && canManageCategories && ( + + )} + {logIntakeOpen && ( {/* ── medications ──────────────────────────────────────── */} - + string; }) { const medications = scope.admits("MEDICATION_LIST") ? (report.medications ?? []) @@ -344,15 +355,33 @@ export function MedicationsSection({ ? `${Math.round((comp.taken / comp.total) * 100)}%` : null; return ( - + + + {/* v1.40 (#1024) — one row per course inside the window. */} + {(med.courses ?? []).map((course, index) => ( + + ))} + ); })} {complianceEntries diff --git a/src/components/medications/card-parts/medication-state-badges.tsx b/src/components/medications/card-parts/medication-state-badges.tsx index 6501006f3..cccd86ea1 100644 --- a/src/components/medications/card-parts/medication-state-badges.tsx +++ b/src/components/medications/card-parts/medication-state-badges.tsx @@ -1,6 +1,7 @@ import { Badge } from "@/components/ui/badge"; import { formatDateTime } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; +import { formatDate } from "@/lib/date-format"; +import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; interface MedicationStateBadgesProps { notificationsEnabled: boolean; @@ -10,6 +11,13 @@ interface MedicationStateBadgesProps { recordOnly?: boolean; /** v1.39.4 (#1040) — the course's end date has passed. */ courseEnded?: boolean; + /** + * v1.40 (#1024) — the server's `previousCourseEndedOn`: an ended course + * names its last day ("Last course ended 16 Jun"). + */ + lastCourseEndedOn?: string | null; + /** v1.40 (#1024) — the running course's number, when there are several. */ + courseNumber?: number | null; } /** @@ -24,8 +32,11 @@ export function MedicationStateBadges({ pausedAt, recordOnly = false, courseEnded = false, + lastCourseEndedOn = null, + courseNumber = null, }: MedicationStateBadgesProps) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); + const dateFormatPref = useDateFormatPreference(); return ( <> @@ -44,7 +55,24 @@ export function MedicationStateBadges({ className="text-xs" data-slot="medication-course-ended-badge" > - {t("medications.courseEndedBadge")} + {lastCourseEndedOn + ? t("medications.course.endedOn", { + date: formatDate( + new Date(lastCourseEndedOn), + dateFormatPref, + locale, + ), + }) + : t("medications.courseEndedBadge")} + + )} + {!courseEnded && courseNumber !== null && ( + + {t("medications.course.nth", { n: courseNumber })} )} {!notificationsEnabled && !recordOnly && !courseEnded && ( diff --git a/src/components/medications/course-fields.ts b/src/components/medications/course-fields.ts new file mode 100644 index 000000000..bf48a5d51 --- /dev/null +++ b/src/components/medications/course-fields.ts @@ -0,0 +1,34 @@ +/** + * v1.40 (#1024) — the course fields every medication read carries, as the + * client consumes them. Server-resolved: render, never recompute. + */ +export interface MedicationCourseWire { + id: string; + /** `YYYY-MM-DD`, inclusive. */ + startsOn: string; + /** `YYYY-MM-DD`, inclusive; null = open. */ + endsOn: string | null; + status: "UPCOMING" | "CURRENT" | "ENDED"; + takenDoses: number; + note: string | null; +} + +export interface MedicationCourseFields { + courses?: MedicationCourseWire[]; + courseCount?: number; + previousCourseEndedOn?: string | null; + canStartCourse?: boolean; +} + +/** + * The 1-based number of the course running or about to run, when the + * medication has more than one; null otherwise (nothing to number). + */ +export function currentCourseNumber( + fields: MedicationCourseFields, +): number | null { + const courses = fields.courses ?? []; + if (courses.length < 2) return null; + const index = courses.findIndex((c) => c.status !== "ENDED"); + return index === -1 ? null : index + 1; +} diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index d86c3c524..63a134774 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -31,6 +31,8 @@ * administration. */ +import type { MedicationCourseWire } from "@/components/medications/course-fields"; +import { CoursesRow } from "@/components/medications/sections/courses-section"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useCallback, @@ -135,6 +137,8 @@ export interface MedicationDetailSnapshot { name: string; dose: string; category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; treatmentClass?: string; deliveryForm?: string; dosesPerUnit?: number | null; @@ -172,6 +176,9 @@ export interface MedicationDetailSnapshot { trackIntake?: boolean; /** v1.39.4 (#1040) — where today sits in the course (server-resolved). */ courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; + /** v1.40 (#1024) — the server-resolved courses. */ + courses?: MedicationCourseWire[]; + canStartCourse?: boolean; recordedSchedules?: ScheduleSnapshot[]; schedules: ScheduleSnapshot[]; } @@ -905,6 +912,16 @@ export function MedicationDetailTabs({ active={medication.active} />
+ {/* v1.40 (#1024) — the courses: when it was taken, and a new + course once the last one has ended. */} +
+ +
{/* The structural editor (the create/edit wizard) — name, class, cadence kind, schedules. Replaces the former hero "Vollständig bearbeiten" button, which read as "edit this diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index eb64b07e7..dcd2bdc92 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -1,5 +1,9 @@ "use client"; +import { + currentCourseNumber, + type MedicationCourseFields, +} from "@/components/medications/course-fields"; import { useEffect, useReducer, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -96,6 +100,8 @@ export interface Glp1Medication { name: string; dose: string; category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; treatmentClass?: string; dosesPerUnit?: number | null; /** v1.6.0 — route of administration (drives the injection-site prompt). */ @@ -142,6 +148,11 @@ export interface Glp1Medication { intakeActionable?: boolean; /** v1.39.4 (#1040) — where today sits in the course. */ courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; + /** v1.40 (#1024) — the server-resolved course fields. */ + courses?: MedicationCourseFields["courses"]; + courseCount?: number; + previousCourseEndedOn?: string | null; + canStartCourse?: boolean; schedules: ScheduleLite[]; } @@ -454,6 +465,8 @@ export function Glp1MedicationCard({ pausedAt={medication.pausedAt} recordOnly={medication.trackIntake === false} courseEnded={medication.courseStatus === "ENDED"} + lastCourseEndedOn={medication.previousCourseEndedOn ?? null} + courseNumber={currentCourseNumber(medication)} /> ); @@ -476,7 +489,11 @@ export function Glp1MedicationCard({ /> ); - const categoryLabel = getMedicationCategoryLabel(medication.category, t); + const categoryLabel = getMedicationCategoryLabel( + medication.category, + t, + medication.categoryLabel, + ); // The upcoming-injection line value. The card owns this VALUE content — // the liked relative-day phrasing ("Samstag 13.7. (in 7 Tagen)") — while diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 577e171a1..1f45a5d9b 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -1,5 +1,9 @@ "use client"; +import { + currentCourseNumber, + type MedicationCourseFields, +} from "@/components/medications/course-fields"; import { useState, useEffect, useReducer } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -63,6 +67,8 @@ interface Medication { name: string; dose: string; category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; /** * v1.4.25 W4d — Prisma treatment-class discriminator. When set to * "GLP1" the parent should render the {@link Glp1MedicationCard} @@ -127,6 +133,11 @@ interface Medication { intakeActionable?: boolean; /** v1.39.4 (#1040) — where today sits in the course. */ courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; + /** v1.40 (#1024) — the server-resolved course fields. */ + courses?: MedicationCourseFields["courses"]; + courseCount?: number; + previousCourseEndedOn?: string | null; + canStartCourse?: boolean; schedules: Schedule[]; } @@ -254,7 +265,11 @@ export function MedicationCard({ // background/border tint. Defaults to "upcoming" (calm) when the display // block is absent (older mocks). const doseStatus = display?.currentDose.status ?? "upcoming"; - const categoryLabel = getMedicationCategoryLabel(medication.category, t); + const categoryLabel = getMedicationCategoryLabel( + medication.category, + t, + medication.categoryLabel, + ); const sortedSchedules = [...medication.schedules].sort( (a, b) => a.windowStart.localeCompare(b.windowStart) || @@ -393,6 +408,8 @@ export function MedicationCard({ pausedAt={medication.pausedAt} recordOnly={medication.trackIntake === false} courseEnded={medication.courseStatus === "ENDED"} + lastCourseEndedOn={medication.previousCourseEndedOn ?? null} + courseNumber={currentCourseNumber(medication)} /> ); diff --git a/src/components/medications/medication-categories-sheet.tsx b/src/components/medications/medication-categories-sheet.tsx new file mode 100644 index 000000000..17c53309f --- /dev/null +++ b/src/components/medications/medication-categories-sheet.tsx @@ -0,0 +1,288 @@ +"use client"; + +/** + * v1.40 (#1041) — manage the person's own medication categories: rename, + * hide from the picker, delete, add. Opens from the category filter on the + * medications list. Rows follow the mood manager's archived-tag row anatomy; + * the delete confirmation names how many medications move to Other. + */ +import { Eye, EyeOff, Loader2, Pencil, Tag, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; + +import { AddMedicationCategoryRow } from "./medication-category-add-row"; +import { + type MedicationCategoryLabelDTO, + useDeleteMedicationCategory, + useMedicationCategories, + useUpdateMedicationCategory, +} from "./use-medication-categories"; + +function CategoryRow({ + category, + onDelete, +}: { + category: MedicationCategoryLabelDTO; + onDelete: (category: MedicationCategoryLabelDTO) => void; +}) { + const { t } = useTranslations(); + const update = useUpdateMedicationCategory(); + const [editing, setEditing] = useState(false); + const [label, setLabel] = useState(category.label); + + async function rename() { + const trimmed = label.trim(); + if (!trimmed || trimmed === category.label) { + setEditing(false); + return; + } + try { + await update.mutateAsync({ key: category.key, label: trimmed }); + setEditing(false); + } catch { + toast.error(t("medications.category.custom.saveFailed")); + } + } + + async function toggleHidden() { + try { + await update.mutateAsync({ + key: category.key, + isActive: !category.isActive, + }); + } catch { + toast.error(t("medications.category.custom.saveFailed")); + } + } + + if (editing) { + return ( +
+ setLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void rename(); + } + }} + /> +
+ + +
+
+ ); + } + + return ( +
+
+ ); +} + +export function MedicationCategoriesSheet({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslations(); + const categories = useMedicationCategories(); + const remove = useDeleteMedicationCategory(); + const [pendingDelete, setPendingDelete] = + useState(null); + + async function confirmDelete(category: MedicationCategoryLabelDTO) { + try { + const result = await remove.mutateAsync(category.key); + toast.success( + t("medications.category.custom.movedToOther", { + count: String(result.movedCount), + }), + ); + } catch { + toast.error(t("medications.category.custom.saveFailed")); + } + } + + return ( + +
+ {categories.isError ? ( + void categories.refetch()} /> + ) : ( + (categories.data ?? []).map((category) => ( + + )) + )} + +

+ {t("medications.category.custom.bpGateNote")} +

+
+ + { + if (!next) setPendingDelete(null); + }} + > + + + + {t("medications.category.custom.deleteTitle")} + + + {t("medications.category.custom.deleteBody", { + count: String(pendingDelete?.medicationCount ?? 0), + })} + + + + {t("common.cancel")} + { + const category = pendingDelete; + setPendingDelete(null); + if (category) void confirmDelete(category); + }} + > + {t("medications.category.custom.delete")} + + + + +
+ ); +} diff --git a/src/components/medications/medication-category-add-row.tsx b/src/components/medications/medication-category-add-row.tsx new file mode 100644 index 000000000..1fad4ed6d --- /dev/null +++ b/src/components/medications/medication-category-add-row.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Loader2, Plus } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useTranslations } from "@/lib/i18n/context"; + +import { + isCategoryLimitError, + useCreateMedicationCategory, +} from "./use-medication-categories"; + +/** + * v1.40 (#1041) — the inline "New category" row: a dashed row (the cycle + * log's add-symptom pattern) that opens into a label field in place, so + * creating a category never leaves the wizard or the manage sheet. The + * caller hears the new key as soon as the category exists. + */ +export function AddMedicationCategoryRow({ + onCreated, +}: { + onCreated?: (key: string) => void; +}) { + const { t } = useTranslations(); + const create = useCreateMedicationCategory(); + const [open, setOpen] = useState(false); + const [label, setLabel] = useState(""); + + function close() { + setOpen(false); + setLabel(""); + create.reset(); + } + + async function submit() { + const trimmed = label.trim(); + if (!trimmed) return; + try { + const created = await create.mutateAsync({ label: trimmed }); + onCreated?.(created.key); + close(); + } catch { + // `create.error` drives the inline message below. + } + } + + if (!open) { + return ( + + ); + } + + return ( +
+ setLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + close(); + } + }} + /> + {create.error ? ( +

+ {isCategoryLimitError(create.error) + ? t("medications.category.custom.limitReached") + : t("medications.category.custom.saveFailed")} +

+ ) : null} +
+ + +
+
+ ); +} diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index 06c07558a..56f11405c 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -1,5 +1,6 @@ "use client"; +import type { MedicationCourseFields } from "@/components/medications/course-fields"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useEffect, useReducer, useState } from "react"; import Link from "next/link"; @@ -33,7 +34,12 @@ import { reduceCurrentWindowStatus } from "@/lib/medications/window-status"; import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label"; import { formatTime } from "@/lib/format"; import { formatTimeWindowRange } from "@/lib/time-window-format"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { formatDate } from "@/lib/date-format"; +import { + useDateFormatPreference, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; import { formatDose } from "@/lib/medications/format-dose"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -104,6 +110,11 @@ export interface TableMedication { */ intakeActionable?: boolean; courseStatus?: "UPCOMING" | "CURRENT" | "ENDED"; + /** v1.40 (#1024) — the server-resolved course fields. */ + courses?: MedicationCourseFields["courses"]; + courseCount?: number; + previousCourseEndedOn?: string | null; + canStartCourse?: boolean; /** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */ stockDosesRemaining?: number | null; /** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */ @@ -412,6 +423,7 @@ function MedicationTableRowItem({ highlighted = false, }: MedicationTableRowItemProps) { const { t, locale } = useTranslations(); + const dateFormatPref = useDateFormatPreference(); const fmt = useFormatters(); const weekdayLabel = useWeekdayLabel(); @@ -542,7 +554,15 @@ function MedicationTableRowItem({ className="text-muted-foreground" data-slot="medication-table-course-ended-marker" > - {t("medications.courseEndedBadge")} + {medication.previousCourseEndedOn + ? t("medications.course.endedOn", { + date: formatDate( + new Date(medication.previousCourseEndedOn), + dateFormatPref, + locale, + ), + }) + : t("medications.courseEndedBadge")} ); } else if (medication.asNeeded) { diff --git a/src/components/medications/sections/courses-section.tsx b/src/components/medications/sections/courses-section.tsx new file mode 100644 index 000000000..56fc71fe7 --- /dev/null +++ b/src/components/medications/sections/courses-section.tsx @@ -0,0 +1,382 @@ +"use client"; + +/** + * v1.40 (#1024) — the courses of a medication, in the detail page's + * Lifecycle group: one row per course (dates, where today sits, doses + * taken), "Start a new course" once every course has ended, and edit / + * delete per course. The rows follow the Lifecycle rows beside them (title + + * muted helper, actions at the trailing edge); the editor is a + * ResponsiveSheet with DateFields and inline refusals. + * + * Every figure is the server's (`courses`, `canStartCourse`); this file + * renders them and computes nothing. + */ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { DateField } from "@/components/ui/date-field"; +import { FieldError } from "@/components/forms/field-error"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import type { MedicationCourseWire } from "@/components/medications/course-fields"; +import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { formatDate } from "@/lib/date-format"; +import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; +import { invalidateMedicationReads } from "@/lib/query-keys"; + +/** The refusal codes the course routes answer, mapped to their copy. */ +const REFUSAL_KEYS: Record = { + "medications.course.overlap": "medications.course.error.overlap", + "medications.course.currentOpen": "medications.course.error.currentOpen", + "medications.course.oneShot": "medications.course.error.oneShot", + "medications.course.invalidRange": "medications.course.error.invalidRange", +}; + +function useCourseDate() { + const { locale } = useTranslations(); + const pref = useDateFormatPreference(); + return (key: string) => formatDate(new Date(key), pref, locale); +} + +function CourseEditor({ + medicationId, + course, + open, + onOpenChange, +}: { + medicationId: string; + /** Absent → start a new course. */ + course?: MedicationCourseWire; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [startsOn, setStartsOn] = useState(course?.startsOn ?? ""); + const [endsOn, setEndsOn] = useState(course?.endsOn ?? ""); + const [note, setNote] = useState(course?.note ?? ""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const rangeInvalid = startsOn !== "" && endsOn !== "" && endsOn < startsOn; + + async function save() { + if (!startsOn || rangeInvalid || saving) return; + setSaving(true); + setError(null); + const body = { + startsOn, + endsOn: endsOn === "" ? null : endsOn, + note: note.trim() === "" ? null : note.trim(), + }; + try { + if (course) { + await apiPatch( + `/api/medications/${medicationId}/courses/${course.id}`, + body, + ); + } else { + await apiPost(`/api/medications/${medicationId}/courses`, body); + } + await invalidateMedicationReads(queryClient); + onOpenChange(false); + } catch (err) { + const code = + err instanceof ApiError + ? (err.meta?.errorCode as string | undefined) + : undefined; + setError( + t( + code && REFUSAL_KEYS[code] + ? REFUSAL_KEYS[code] + : "medications.course.error.generic", + ), + ); + } finally { + setSaving(false); + } + } + + return ( + + + + + } + > +
+
+
+ + +
+
+ + + +
+
+
+ + setNote(e.target.value)} + /> +
+ +
+
+ ); +} + +export function CoursesRow({ + medicationId, + courses, + canStartCourse, + canEdit, +}: { + medicationId: string; + courses: MedicationCourseWire[]; + canStartCourse: boolean; + /** Course writes are the owner's; a shared record shows the list only. */ + canEdit: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const date = useCourseDate(); + const [editing, setEditing] = useState(null); + const [starting, setStarting] = useState(false); + const [deleting, setDeleting] = useState(null); + const [deleteBusy, setDeleteBusy] = useState(false); + + async function confirmDelete(course: MedicationCourseWire) { + setDeleteBusy(true); + try { + await apiDelete(`/api/medications/${medicationId}/courses/${course.id}`); + await invalidateMedicationReads(queryClient); + setDeleting(null); + } catch { + toast.error(t("medications.course.error.generic")); + } finally { + setDeleteBusy(false); + } + } + + const statusLabel = (status: MedicationCourseWire["status"]) => + status === "CURRENT" + ? t("medications.course.status.current") + : status === "UPCOMING" + ? t("medications.course.status.upcoming") + : t("medications.course.status.ended"); + + return ( +
+
+
+

+ {t("medications.course.title")} +

+

+ {t("medications.course.helper")} +

+
+ {canEdit && canStartCourse && ( + + )} +
+ + {courses.length === 0 ? ( +

+ {t("medications.course.none")} +

+ ) : ( +
    + {courses.map((course, index) => ( +
  • +
    +

    + {course.endsOn + ? t("medications.course.range", { + start: date(course.startsOn), + end: date(course.endsOn), + }) + : t("medications.course.since", { + start: date(course.startsOn), + })} +

    +

    + {t("medications.course.meta", { + n: index + 1, + status: statusLabel(course.status), + taken: course.takenDoses, + })} +

    + {course.note && ( +

    {course.note}

    + )} +
    + {canEdit && ( + <> + + + + )} +
  • + ))} +
+ )} + + {starting && ( + + )} + {editing && ( + { + if (!open) setEditing(null); + }} + /> + )} + + { + if (!open) setDeleting(null); + }} + > + + + + {t("medications.course.deleteTitle")} + + + {courses.length === 1 + ? t("medications.course.deleteLastConfirm") + : t("medications.course.deleteBody")} + + + + {t("common.cancel")} + { + e.preventDefault(); + if (deleting) void confirmDelete(deleting); + }} + > + {t("medications.course.delete")} + + + + +
+ ); +} diff --git a/src/components/medications/use-medication-categories.ts b/src/components/medications/use-medication-categories.ts new file mode 100644 index 000000000..57a197ca9 --- /dev/null +++ b/src/components/medications/use-medication-categories.ts @@ -0,0 +1,97 @@ +"use client"; + +/** + * v1.40 (#1041) — the record's own medication categories: read, create, + * rename / hide, delete. A rename or a delete changes what every card and the + * insights medication tiles show (a delete moves medications to Other), so + * both refetch the medication reads and the insights reads. Neither touches a + * dose, so the daily reads the intake bundle refreshes stay as they are. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + ApiError, + apiDelete, + apiGet, + apiPatch, + apiPost, +} from "@/lib/api/api-fetch"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; + +const CATEGORY_DEPENDENT_KEYS = [ + queryKeys.medications(), + queryKeys.insightsRoot(), +] as const; + +export interface MedicationCategoryLabelDTO { + key: string; + label: string; + sortOrder: number; + isActive: boolean; + medicationCount: number; +} + +/** The `errorCode` the create POST answers when the per-account cap is hit. */ +export const MEDICATION_CATEGORY_LIMIT_ERROR_CODE = + "medications.category.limitReached"; + +/** Whether a failed create hit the cap rather than anything transient. */ +export function isCategoryLimitError(error: unknown): boolean { + return ( + error instanceof ApiError && + error.meta?.errorCode === MEDICATION_CATEGORY_LIMIT_ERROR_CODE + ); +} + +export function useMedicationCategories(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + queryKey: queryKeys.medicationCategories(), + queryFn: () => + apiGet<{ categories: MedicationCategoryLabelDTO[] }>( + "/api/medications/categories", + ), + staleTime: 5 * 60_000, + select: (data) => data.categories, + }); +} + +export function useCreateMedicationCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { label: string }) => + apiPost("/api/medications/categories", input), + onSuccess: () => + qc.invalidateQueries({ queryKey: queryKeys.medicationCategories() }), + }); +} + +export function useUpdateMedicationCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { + key: string; + label?: string; + isActive?: boolean; + sortOrder?: number; + }) => { + const { key, ...body } = input; + return apiPatch( + `/api/medications/categories/${encodeURIComponent(key)}`, + body, + ); + }, + onSuccess: () => invalidateKeys(qc, CATEGORY_DEPENDENT_KEYS), + }); +} + +export function useDeleteMedicationCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (key: string) => + apiDelete<{ key: string; movedCount: number }>( + `/api/medications/categories/${encodeURIComponent(key)}`, + ), + onSuccess: () => invalidateKeys(qc, CATEGORY_DEPENDENT_KEYS), + }); +} diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index 9de116879..f8edcbfa3 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -1330,3 +1330,85 @@ describe("category on edit (v1.39.4)", () => { expect(body.category).toBe("SKIN"); }); }); + +describe("custom categories (v1.40, #1041)", () => { + const KEY = "custom:11111111-1111-4111-8111-111111111111"; + const OTHER_KEY = "custom:22222222-2222-4222-8222-222222222222"; + + function hydrate(category: string, treatmentClass = "GENERIC") { + return hydrateWizardPayload({ + id: "m1", + name: "Tamiflu", + dose: "75 mg", + category, + treatmentClass, + notificationsEnabled: true, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [ + { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + }, + ], + }); + } + + it("hydrates a custom category onto the Other row with its key", () => { + const payload = hydrate(KEY); + expect(payload.treatmentRow).toBe("other"); + expect(payload.customCategoryKey).toBe(KEY); + }); + + it("keeps the custom category on an edit that leaves it alone", () => { + const body = buildCreateBody({ ...hydrate(KEY), name: "Renamed" }, "edit"); + expect("category" in body).toBe(false); + }); + + it("sends the new key when the person picks another custom category", () => { + const body = buildCreateBody( + { ...hydrate(KEY), customCategoryKey: OTHER_KEY }, + "edit", + ); + expect(body.category).toBe(OTHER_KEY); + expect(body.treatmentClass).toBe("GENERIC"); + }); + + it("sends the built-in value when the person moves it back to a built-in row", () => { + const body = buildCreateBody( + { ...hydrate(KEY), treatmentRow: "vitamin", customCategoryKey: null }, + "edit", + ); + expect(body.category).toBe("VITAMIN"); + }); + + it("sends the built-in Other when the person picks the Other row itself", () => { + const body = buildCreateBody( + { ...hydrate(KEY), customCategoryKey: null }, + "edit", + ); + expect(body.category).toBe("OTHER"); + }); + + it("files a new medication under a custom category on create", () => { + const body = buildCreateBody( + { + ...hydrate("VITAMIN"), + treatmentRow: "other", + customCategoryKey: KEY, + }, + "create", + ); + expect(body.category).toBe(KEY); + expect(body.treatmentClass).toBe("GENERIC"); + }); + + it("keeps a GLP-1 medication on the GLP-1 row whatever its category", () => { + const payload = hydrate(KEY, "GLP1"); + expect(payload.treatmentRow).toBe("glp1"); + expect(payload.customCategoryKey).toBeNull(); + }); +}); diff --git a/src/components/medications/wizard/steps/step2-class.tsx b/src/components/medications/wizard/steps/step2-class.tsx index 1337cbbbf..f066cbb88 100644 --- a/src/components/medications/wizard/steps/step2-class.tsx +++ b/src/components/medications/wizard/steps/step2-class.tsx @@ -21,8 +21,12 @@ import { } from "lucide-react"; import type { ComponentType } from "react"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; +import { AddMedicationCategoryRow } from "../../medication-category-add-row"; +import { useMedicationCategories } from "../../use-medication-categories"; + import { type WizardTreatmentRow, WIZARD_TREATMENT_ROWS, @@ -48,8 +52,60 @@ const ROW_ICONS: Record> = { other: Tag as unknown as LucideIcon, }; +/** One selectable row: the same anatomy for built-in and custom categories. */ +function ClassRow({ + value, + label, + Icon, + selected, + onSelect, + slot, +}: { + value: string; + label: string; + Icon: ComponentType; + selected: boolean; + onSelect: () => void; + slot: "wizard-class-row" | "wizard-custom-class-row"; +}) { + return ( + + ); +} + export function Step2Class({ payload, applyPartial }: StepProps) { const { t } = useTranslations(); + const categories = useMedicationCategories(); + // Creating a category is admitted at MANAGE in a shared record. + const { canManageDomain } = useRecordCapabilities(); + const customKey = payload.customCategoryKey ?? null; + // Hidden categories leave the picker, except the one this medication is + // already filed under: an edit must be able to show what it has. + const customRows = (categories.data ?? []).filter( + (c) => c.isActive || c.key === customKey, + ); return (
- {WIZARD_TREATMENT_ROWS.map((row) => { - const Icon = ROW_ICONS[row]; - const selected = payload.treatmentRow === row; - const label = t(`medications.wizard.classRow.${row}`); - return ( - - ); - })} + {WIZARD_TREATMENT_ROWS.map((row) => ( + + applyPartial( + // The GLP-1 row is always injected; nudge the + // delivery form so the common case needs no extra + // tap. The user can still flip it on Step 3. + row === "glp1" + ? { + treatmentRow: row, + customCategoryKey: null, + deliveryForm: "INJECTION", + } + : { treatmentRow: row, customCategoryKey: null }, + ) + } + /> + ))} + {customRows.map((c) => ( + + applyPartial({ treatmentRow: "other", customCategoryKey: c.key }) + } + /> + ))} + {canManageDomain("medications") && ( + + applyPartial({ treatmentRow: "other", customCategoryKey: key }) + } + /> + )} +

+ {t("medications.category.custom.bpGateNote")} +

); } diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index 498e7c685..5c08230d1 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -192,6 +192,24 @@ export function rowFromTreatment( } } +/** + * v1.40 (#1041) — the custom category an edit hydrates to. A GLP-1 + * medication is the GLP-1 row whatever its category says, as above. + */ +function customCategoryFields( + treatmentClass: string | undefined, + category: string | undefined, +): { + customCategoryKey: string | null; + initialCustomCategoryKey: string | null; +} { + const key = + treatmentClass !== "GLP1" && category?.startsWith("custom:") + ? category + : null; + return { customCategoryKey: key, initialCustomCategoryKey: key }; +} + // ──────────────────────────────────────────────────────────────────── // Wizard payload + per-schedule draft // ──────────────────────────────────────────────────────────────────── @@ -257,6 +275,15 @@ export interface WizardPayload { * Absent on create. */ initialTreatmentRow?: WizardTreatmentRow | null; + /** + * v1.40 (#1041) — one of the person's own categories, picked from the + * custom rows under the built-in ones. Set together with `treatmentRow: + * "other"` (the treatment class a custom category files under); null when + * a built-in row is picked. + */ + customCategoryKey?: string | null; + /** v1.40 — the custom category the edit hydrated to, for the same reason. */ + initialCustomCategoryKey?: string | null; /** * v1.6.0 — route of administration. Drives the injection-site * rotation preview (shown when `INJECTION`) and, paired with @@ -628,8 +655,12 @@ export function firstInvalidIndex( export interface CreateMedicationBody { name: string; dose: string; - /** Omitted on an edit that left the category row untouched (v1.39.4). */ - category?: MedicationCategoryValue; + /** + * Omitted on an edit that left the category row untouched (v1.39.4). A + * built-in value or, since v1.40, the key of one of the person's own + * categories. + */ + category?: MedicationCategoryValue | `custom:${string}`; treatmentClass?: MedicationTreatmentClass; /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ deliveryForm: MedicationDeliveryForm; @@ -843,16 +874,21 @@ export function buildCreateBody( // v1.39.4 — an edit that did not touch the category row leaves the stored // category (and treatment class) alone rather than re-deriving them from // the row, which cannot represent every stored value. + const customCategoryKey = committed.customCategoryKey ?? null; const keepStoredCategory = forMode === "edit" && committed.initialTreatmentRow !== undefined && - committed.treatmentRow === committed.initialTreatmentRow; + committed.treatmentRow === committed.initialTreatmentRow && + customCategoryKey === (committed.initialCustomCategoryKey ?? null); const body: CreateMedicationBody = { name: committed.name.trim(), dose, ...(!keepStoredCategory && { - category: mapping.category, - treatmentClass: mapping.treatmentClass, + // v1.40 — a custom row files under the person's own category with the + // generic treatment class, like the built-in Other row. + category: (customCategoryKey ?? + mapping.category) as CreateMedicationBody["category"], + treatmentClass: customCategoryKey ? "GENERIC" : mapping.treatmentClass, }), deliveryForm: committed.deliveryForm, ...(Number.isFinite(parsedDosesPerUnit) && @@ -1092,6 +1128,8 @@ export interface MedicationPayload { name: string; dose: string; category: string; + /** v1.40 — the label of a custom category; null for a built-in one. */ + categoryLabel?: string | null; treatmentClass?: string; /** v1.6.0 — route of administration. Defaults to ORAL when absent. */ deliveryForm?: string; @@ -1239,6 +1277,7 @@ export function hydrateWizardPayload( initial.treatmentClass, initial.category, ), + ...customCategoryFields(initial.treatmentClass, initial.category), deliveryForm: normaliseDeliveryForm(initial.deliveryForm), dosesPerUnit: typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1 diff --git a/src/lib/__tests__/medication-category.test.ts b/src/lib/__tests__/medication-category.test.ts new file mode 100644 index 000000000..6d49fd106 --- /dev/null +++ b/src/lib/__tests__/medication-category.test.ts @@ -0,0 +1,179 @@ +/** + * v1.40 (#1041) — the one helper that resolves and writes a medication's + * category. Prisma is stubbed; the real-database contract lives in + * `tests/integration/medication-custom-categories.test.ts`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + assignmentFindMany: vi.fn(), + assignmentUpsert: vi.fn(), + assignmentUpdateMany: vi.fn(), + labelFindMany: vi.fn(), + labelFindFirst: vi.fn(), + labelDelete: vi.fn(), + medicationFindUnique: vi.fn(), + annotate: vi.fn(), + order: [] as string[], +})); + +vi.mock("@/lib/db", () => { + const client = { + medicationCategoryAssignment: { + findMany: mocks.assignmentFindMany, + upsert: mocks.assignmentUpsert, + updateMany: mocks.assignmentUpdateMany, + }, + medicationCategoryLabel: { + findMany: mocks.labelFindMany, + findFirst: mocks.labelFindFirst, + delete: mocks.labelDelete, + }, + medication: { findUnique: mocks.medicationFindUnique }, + }; + return { + prisma: { + ...client, + $transaction: async (fn: (tx: typeof client) => unknown) => fn(client), + }, + }; +}); +vi.mock("@/lib/logging/context", () => ({ annotate: mocks.annotate })); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (s: string) => new TextEncoder().encode(`sealed:${s}`), + decryptFromBytes: (b: Uint8Array) => + new TextDecoder().decode(b).replace(/^sealed:/, ""), +})); + +import { + deleteMedicationCategoryLabel, + isAssignableCategory, + resolveMedicationCategories, + setMedicationCategory, +} from "@/lib/medication-category"; + +const OWN = "custom:11111111-1111-4111-8111-111111111111"; +const FOREIGN = "custom:22222222-2222-4222-8222-222222222222"; +const GONE = "custom:33333333-3333-4333-8333-333333333333"; +const sealed = (s: string) => new TextEncoder().encode(`sealed:${s}`); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.order.length = 0; +}); + +describe("resolveMedicationCategories", () => { + it("resolves built-ins, the owner's own key with its label, and nothing else", async () => { + mocks.assignmentFindMany.mockResolvedValue([ + { medicationId: "m1", category: "THYROID", medication: { userId: "u1" } }, + { medicationId: "m2", category: OWN, medication: { userId: "u1" } }, + { medicationId: "m3", category: FOREIGN, medication: { userId: "u1" } }, + { medicationId: "m4", category: GONE, medication: { userId: "u1" } }, + { + medicationId: "m5", + category: "NOT_A_VALUE", + medication: { userId: "u1" }, + }, + ]); + mocks.labelFindMany.mockResolvedValue([ + { key: OWN, userId: "u1", labelEncrypted: sealed("Travel kit") }, + { key: FOREIGN, userId: "u2", labelEncrypted: sealed("Theirs") }, + ]); + + const result = await resolveMedicationCategories([ + "m1", + "m2", + "m3", + "m4", + "m5", + "m6", + ]); + + expect(result).toEqual({ + m1: { category: "THYROID", categoryLabel: null }, + m2: { category: OWN, categoryLabel: "Travel kit" }, + // Another account's label never resolves, even by its exact key. + m3: { category: "OTHER", categoryLabel: null }, + m4: { category: "OTHER", categoryLabel: null }, + m5: { category: "OTHER", categoryLabel: null }, + // No row at all reads as OTHER. + m6: { category: "OTHER", categoryLabel: null }, + }); + // The two custom keys that resolved to nothing are counted, not thrown. + expect(mocks.annotate).toHaveBeenCalledWith({ + meta: { "medication.category.dangling": 2 }, + }); + }); + + it("skips the label read when no custom key is in play", async () => { + mocks.assignmentFindMany.mockResolvedValue([ + { medicationId: "m1", category: "VITAMIN", medication: { userId: "u1" } }, + ]); + await resolveMedicationCategories(["m1"]); + expect(mocks.labelFindMany).not.toHaveBeenCalled(); + expect(mocks.annotate).not.toHaveBeenCalled(); + }); +}); + +describe("isAssignableCategory / setMedicationCategory", () => { + it("admits built-ins and the owner's own key only", async () => { + mocks.labelFindFirst.mockImplementation( + async ({ where }: { where: { key: string; userId: string } }) => + where.key === OWN && where.userId === "u1" ? { key: OWN } : null, + ); + expect(await isAssignableCategory("u1", "MENTAL_HEALTH")).toBe(true); + expect(await isAssignableCategory("u1", OWN)).toBe(true); + expect(await isAssignableCategory("u1", FOREIGN)).toBe(false); + expect(await isAssignableCategory("u1", "custom")).toBe(false); + expect(await isAssignableCategory("u1", "PIZZA")).toBe(false); + }); + + it("writes OTHER for a key the medication's owner does not hold", async () => { + mocks.medicationFindUnique.mockResolvedValue({ userId: "u1" }); + mocks.labelFindFirst.mockResolvedValue(null); + const written = await setMedicationCategory("m1", FOREIGN); + expect(written).toBe("OTHER"); + expect(mocks.assignmentUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: { medicationId: "m1", category: "OTHER" }, + update: { category: "OTHER" }, + }), + ); + }); + + it("writes the owner's own key", async () => { + mocks.medicationFindUnique.mockResolvedValue({ userId: "u1" }); + mocks.labelFindFirst.mockResolvedValue({ key: OWN }); + expect(await setMedicationCategory("m1", OWN)).toBe(OWN); + }); +}); + +describe("deleteMedicationCategoryLabel", () => { + it("moves the category's medications to OTHER before the label goes", async () => { + mocks.labelFindFirst.mockResolvedValue({ id: "label-1" }); + mocks.assignmentUpdateMany.mockImplementation(async () => { + mocks.order.push("move"); + return { count: 3 }; + }); + mocks.labelDelete.mockImplementation(async () => { + mocks.order.push("delete"); + return {}; + }); + + const result = await deleteMedicationCategoryLabel("u1", OWN); + + expect(result).toEqual({ movedCount: 3 }); + expect(mocks.assignmentUpdateMany).toHaveBeenCalledWith({ + where: { category: OWN, medication: { userId: "u1" } }, + data: { category: "OTHER" }, + }); + expect(mocks.order).toEqual(["move", "delete"]); + }); + + it("answers null and touches nothing for a key that is not the caller's", async () => { + mocks.labelFindFirst.mockResolvedValue(null); + expect(await deleteMedicationCategoryLabel("u1", FOREIGN)).toBeNull(); + expect(mocks.assignmentUpdateMany).not.toHaveBeenCalled(); + expect(mocks.labelDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/ai/coach/snapshot-blocks/compliance-block.ts b/src/lib/ai/coach/snapshot-blocks/compliance-block.ts index 763822efc..ebccd6a2e 100644 --- a/src/lib/ai/coach/snapshot-blocks/compliance-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/compliance-block.ts @@ -24,6 +24,7 @@ import { } from "@/lib/analytics/compliance"; import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import { annotate } from "@/lib/logging/context"; +import { courseStatusOn } from "@/lib/medications/course-window"; import { isoWeekKey, tzDayKey, tzWeekday } from "../snapshot-series"; import type { CoachProvenance, @@ -55,6 +56,22 @@ interface ComplianceBlockContext { registerBlock: (key: string, source: CoachScopeSource) => void; } +function courseSummary( + meds: readonly ComplianceBlockMedication[], + now: Date, + userTz: string, +): { courses?: { takenInCourses: number; noCourseRunning: number } } { + const todayKey = tzDayKey(now, userTz); + const several = meds.filter((m) => (m.courses?.length ?? 0) >= 2); + if (several.length === 0) return {}; + const idle = several.filter((m) => + (m.courses ?? []).every((c) => courseStatusOn(c, todayKey) !== "CURRENT"), + ).length; + return { + courses: { takenInCourses: several.length, noCourseRunning: idle }, + }; +} + export function buildComplianceBlock( ctx: Readonly, ): void { @@ -183,6 +200,11 @@ export function buildComplianceBlock( ? Math.round((complianceTaken / complianceDenominator) * 100) : null, timeline: { recent: recentRows, weekly: weeklyRows }, + // v1.40 (#1024) — how many medications are taken in separate courses, + // and how many of those have no course running today. Their days + // between courses expect no dose, so a quiet stretch there is not a + // lapse; the rate above already leaves those days out. + ...courseSummary(complianceMeds, now, userTz), }; metrics.add("compliance"); counts.compliance = countable.length; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 666ae143c..d3115a4e7 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -56,7 +56,10 @@ import type { ReferenceMetric } from "@/lib/reference-ranges"; import { isCycleAvailableForUser } from "@/lib/cycle/gate"; import { resolveModuleMap } from "@/lib/modules/gate"; import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate"; -import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; +import { + COURSES_COMPLIANCE_SELECT, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; import type { BaselineProfile } from "@/lib/insights/derived"; import { toProfileSex } from "@/lib/profile/sex"; import { @@ -650,6 +653,8 @@ async function buildCoachSnapshotImpl( }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, intakeEvents: { // Tombstoned intake rows must never reach the Coach snapshot. where: { deletedAt: null, scheduledFor: { gte: cutoff } }, diff --git a/src/lib/analytics/__tests__/compliance-courses.test.ts b/src/lib/analytics/__tests__/compliance-courses.test.ts new file mode 100644 index 000000000..16602da2f --- /dev/null +++ b/src/lib/analytics/__tests__/compliance-courses.test.ts @@ -0,0 +1,160 @@ +/** + * v1.40 (#1024) — a medication taken in two courses with a gap between them + * expects doses only inside the courses. Every dose of both courses is taken, + * so the honest rate is 100 %; a minter that expanded across the gap would + * read the gap's days as missed and report far less. + */ +import { describe, expect, it } from "vitest"; + +import { + buildComplianceMedicationContext, + calculateCompliance, + expectedSlotsBetween, + type ComplianceSchedule, + type IntakeEvent, +} from "@/lib/analytics/compliance"; +import { dateOfDayKey } from "@/lib/medications/course-window"; + +const TZ = "Europe/Berlin"; +const NOW = new Date("2026-06-20T18:00:00.000Z"); +const DAILY: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + }, +]; +const COURSES = [ + { startsOn: dateOfDayKey("2026-04-01"), endsOn: dateOfDayKey("2026-04-07") }, + { startsOn: dateOfDayKey("2026-06-10"), endsOn: dateOfDayKey("2026-06-16") }, +]; + +function dayKeys(from: string, to: string): string[] { + const out: string[] = []; + for ( + let d = dateOfDayKey(from); + d.getTime() <= dateOfDayKey(to).getTime(); + d = new Date(d.getTime() + 86_400_000) + ) { + out.push(d.toISOString().slice(0, 10)); + } + return out; +} + +// 08:00 Berlin (CEST) is 06:00 UTC; taken five minutes in. +const EVENTS: IntakeEvent[] = [ + ...dayKeys("2026-04-01", "2026-04-07"), + ...dayKeys("2026-06-10", "2026-06-16"), +].map((day) => ({ + scheduledFor: new Date(`${day}T06:00:00.000Z`), + takenAt: new Date(`${day}T06:05:00.000Z`), + skipped: false, +})); + +const MEDICATION = { + // The row projects the latest course. + startsOn: COURSES[1].startsOn, + endsOn: COURSES[1].endsOn, + oneShot: false, + createdAt: new Date("2026-03-15T10:00:00.000Z"), +}; + +describe("compliance across two courses", () => { + it("expects a dose on every course day and none in the gap", () => { + const ctx = buildComplianceMedicationContext( + { ...MEDICATION, courses: COURSES }, + EVENTS[EVENTS.length - 1].takenAt, + TZ, + ); + const slots = expectedSlotsBetween( + DAILY, + new Date("2026-03-22T00:00:00.000Z"), + NOW, + ctx, + EVENTS, + ); + expect(slots).toHaveLength(14); + }); + + it("reports the rate of the two courses alone over a 90-day window", () => { + const ctx = buildComplianceMedicationContext( + { ...MEDICATION, courses: COURSES }, + EVENTS[EVENTS.length - 1].takenAt, + TZ, + ); + const result = calculateCompliance( + EVENTS, + DAILY, + 90, + MEDICATION.createdAt, + { + now: NOW, + medicationContext: ctx, + }, + ); + expect(result.missed).toBe(0); + expect(result.taken).toBe(14); + expect(result.rate).toBe(100); + }); + + it("keeps one course on the behaviour every medication had before", () => { + const ctx = buildComplianceMedicationContext( + { ...MEDICATION, courses: [COURSES[1]] }, + EVENTS[EVENTS.length - 1].takenAt, + TZ, + ); + const slots = expectedSlotsBetween( + DAILY, + new Date("2026-03-22T00:00:00.000Z"), + NOW, + ctx, + EVENTS, + ); + expect(slots).toHaveLength(7); + }); +}); + +describe("the streak across two courses (v1.40, #1024)", () => { + // Course A Jun 1-10 with Jun 5 missed, course B Jun 13 onward, every other + // course day taken. The miss sits in the earlier course: a streak that only + // saw the latest course would run unbroken through the whole window. + const A = { + startsOn: dateOfDayKey("2026-06-01"), + endsOn: dateOfDayKey("2026-06-10"), + }; + const B = { startsOn: dateOfDayKey("2026-06-13"), endsOn: null }; + const NOW_B = new Date("2026-06-27T18:00:00.000Z"); + const taken = [ + ...dayKeys("2026-06-01", "2026-06-10").filter((d) => d !== "2026-06-05"), + ...dayKeys("2026-06-13", "2026-06-27"), + ].map((day) => ({ + scheduledFor: new Date(`${day}T06:00:00.000Z`), + takenAt: new Date(`${day}T06:05:00.000Z`), + skipped: false, + })); + + it("stops at the miss in the earlier course", () => { + const ctx = buildComplianceMedicationContext( + { + startsOn: B.startsOn, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-05-20T10:00:00.000Z"), + courses: [A, B], + }, + taken[taken.length - 1].takenAt, + TZ, + ); + const result = calculateCompliance(taken, DAILY, 30, ctx.createdAt, { + now: NOW_B, + medicationContext: ctx, + }); + expect(result.missed).toBe(1); + // Jun 6 to Jun 27. The two gap days expect nothing and advance the + // streak like any out-of-cadence day; the miss on Jun 5 ends the run. + expect(result.streak).toBe(22); + }); +}); diff --git a/src/lib/analytics/compliance/adapters.ts b/src/lib/analytics/compliance/adapters.ts index 3de29fb32..5d0aa907c 100644 --- a/src/lib/analytics/compliance/adapters.ts +++ b/src/lib/analytics/compliance/adapters.ts @@ -9,10 +9,11 @@ import { type Occurrence, type RecurrenceContext, } from "@/lib/medications/scheduling/recurrence"; -import type { - ComplianceIntakeInstant, - ComplianceMedicationContext, - ComplianceSchedule, +import { + complianceMintWindow, + type ComplianceIntakeInstant, + type ComplianceMedicationContext, + type ComplianceSchedule, } from "./types"; /** @@ -48,17 +49,29 @@ export function rollingIntakeInstants( export function toRecurrenceCtx( ctx: ComplianceMedicationContext, idTag: string, + /** + * v1.40 (#1024) — `"courses"` expands over the span of every course (the + * retrospective minters, which then drop the slots between courses); + * the default is the medication's own window (the forward-looking cycle). + */ + span: "current" | "courses" = "current", ): RecurrenceContext { + const window = + span === "courses" + ? complianceMintWindow(ctx) + : { startsOn: ctx.startsOn, endsOn: ctx.endsOn }; return { medication: { id: idTag, - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, + startsOn: window.startsOn, + endsOn: window.endsOn, oneShot: ctx.oneShot, createdAt: ctx.createdAt, }, timeZone: ctx.timeZone, lastIntakeAt: ctx.lastIntakeAt, + // v1.40 (#1024) — a medication: `startsOn` is a course start. + rollingAnchor: "courseStart", }; } diff --git a/src/lib/analytics/compliance/cycle.ts b/src/lib/analytics/compliance/cycle.ts index 1ed582699..a47b263bb 100644 --- a/src/lib/analytics/compliance/cycle.ts +++ b/src/lib/analytics/compliance/cycle.ts @@ -1,6 +1,8 @@ // Extracted from the former single-file `compliance.ts`. See `../compliance.ts` // (the barrel) for the module map. Pure move — no logic changes. +import { dayKeyOfDate } from "@/lib/medications/course-window"; +import { userDayKey } from "@/lib/tz/format"; import { nextOccurrenceAfter, type CanonicalSchedule, @@ -156,9 +158,18 @@ export function buildCurrentCycle( if (canonical.scheduleType === "PRN") continue; const n = canonical.rollingIntervalDays; if (n !== null && n > 0) { - const anchor = + // v1.40 (#1024) — an intake whose next dose would fall before the + // course starts belongs to an earlier course and does not anchor this + // one (the recurrence engine's rule); otherwise it anchors as before. + const fromIntake = lastIntakeAt !== null ? new Date(lastIntakeAt.getTime() + n * DAY_MS) + : null; + const anchor = + fromIntake !== null && + (ctx.startsOn === null || + userDayKey(fromIntake, ctx.timeZone) >= dayKeyOfDate(ctx.startsOn)) + ? fromIntake : (ctx.startsOn ?? ctx.createdAt); if (ctx.endsOn && anchor.getTime() > ctx.endsOn.getTime()) continue; const graceMs = (canonical.reminderGraceMinutes ?? 60) * 60 * 1000; diff --git a/src/lib/analytics/compliance/display.ts b/src/lib/analytics/compliance/display.ts index 395398c7d..f958c417a 100644 --- a/src/lib/analytics/compliance/display.ts +++ b/src/lib/analytics/compliance/display.ts @@ -384,6 +384,8 @@ export function calculateCompliance( lastIntakeAt: ctx.lastIntakeAt, timeZone: ctx.timeZone, scheduleRevisions: ctx.scheduleRevisions, + // v1.40 (#1024) — the streak walks the same course days the rate does. + courses: ctx.courses, } : undefined; @@ -747,6 +749,8 @@ function buildTimelineForWindow( lastIntakeAt: ctx.lastIntakeAt, timeZone: ctx.timeZone, scheduleRevisions: ctx.scheduleRevisions, + // v1.40 (#1024) — the same course days the ledger counts. + courses: ctx.courses, }; const normalisedEvents: IntakeEventLike[] = events diff --git a/src/lib/analytics/compliance/ledger.ts b/src/lib/analytics/compliance/ledger.ts index 0d40c6336..baffb1123 100644 --- a/src/lib/analytics/compliance/ledger.ts +++ b/src/lib/analytics/compliance/ledger.ts @@ -13,10 +13,12 @@ import { type HistoryIntake, } from "@/lib/medications/scheduling/dose-history"; import { userDayKey } from "@/lib/tz/resolver"; -import type { - ComplianceMedicationContext, - ComplianceSchedule, - IntakeEvent, +import { + complianceMintWindow, + slotInsideCourses, + type ComplianceMedicationContext, + type ComplianceSchedule, + type IntakeEvent, } from "./types"; import { intakeInstantsAtOrBefore, @@ -113,14 +115,17 @@ export function buildComplianceLedgerRows( now: Date, windowConfig?: DoseWindowConfig, ): DoseHistoryRow[] { + // v1.40 (#1024) — expand over the span of every course; the slots between + // courses are dropped below. + const mintWindow = complianceMintWindow(ctx); const medication: BandMinterMedication = { id: "compliance-tally", - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, + startsOn: mintWindow.startsOn, + endsOn: mintWindow.endsOn, oneShot: ctx.oneShot, createdAt: ctx.createdAt, }; - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally"); + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally", "courses"); const canonicalSchedules = schedules.map((s, i) => { const canonical = toCanonicalSchedule(s, `compliance-tally-${i}`); // A legacy daily schedule carries only `windowStart` (no `timesOfDay`, @@ -170,7 +175,13 @@ export function buildComplianceLedgerRows( // excluded from the denominator, exactly as the literature requires. const bands: SlotBand[] = []; for (const g of groups) { - if (g.hasExpectedSlots) bands.push(...g.bands); + // v1.40 (#1024) — a slot on a day between two courses was never + // expected. Dropped before the reconstruction, so a dose logged in the + // gap stays in the ledger as the off-schedule take it is (outside the + // denominator), which is also how the dose history shows it. + if (g.hasExpectedSlots) { + bands.push(...g.bands.filter((b) => slotInsideCourses(ctx, b.at))); + } } const intakes: HistoryIntake[] = events diff --git a/src/lib/analytics/compliance/occurrences.ts b/src/lib/analytics/compliance/occurrences.ts index 4b572e5c1..0da497ea5 100644 --- a/src/lib/analytics/compliance/occurrences.ts +++ b/src/lib/analytics/compliance/occurrences.ts @@ -3,10 +3,11 @@ import { occurrencesAcrossEras } from "@/lib/medications/scheduling/schedule-eras"; import type { Occurrence } from "@/lib/medications/scheduling/recurrence"; -import type { - ComplianceIntakeInstant, - ComplianceMedicationContext, - ComplianceSchedule, +import { + slotInsideCourses, + type ComplianceIntakeInstant, + type ComplianceMedicationContext, + type ComplianceSchedule, } from "./types"; import { expandComplianceOccurrences, @@ -36,7 +37,7 @@ export function expectedSlotCountForDay( ctx: ComplianceMedicationContext, intakes?: ComplianceIntakeInstant[], ): number { - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily"); + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily", "courses"); const now = new Date(); const retro = intakes && schedules.some((s) => s.rollingIntervalDays != null) @@ -63,7 +64,8 @@ export function expectedSlotCountForDay( retro, ), { oneShot: ctx.oneShot }, - ).length; + // v1.40 (#1024) — a day between two courses expects nothing. + ).filter((o) => slotInsideCourses(ctx, o.at)).length; } /** @@ -95,7 +97,7 @@ export function expectedSlotsBetween( ctx: ComplianceMedicationContext, intakes?: ComplianceIntakeInstant[], ): Occurrence[] { - const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots"); + const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots", "courses"); const effectiveFrom = ctx.createdAt.getTime() > from.getTime() ? ctx.createdAt : from; // v1.13.x — for a ROLLING schedule the expected grid is reconstructed from @@ -123,5 +125,6 @@ export function expectedSlotsBetween( retro, ), { oneShot: ctx.oneShot }, - ); + // v1.40 (#1024) — a day between two courses expects nothing. + ).filter((o) => slotInsideCourses(ctx, o.at)); } diff --git a/src/lib/analytics/compliance/types.ts b/src/lib/analytics/compliance/types.ts index 4e0f7ac61..67d93ac6a 100644 --- a/src/lib/analytics/compliance/types.ts +++ b/src/lib/analytics/compliance/types.ts @@ -3,6 +3,11 @@ import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; import type { ScheduleType } from "@/lib/medications/scheduling/recurrence"; +import { + isInsideCourses, + type CourseSpan, +} from "@/lib/medications/course-window"; +import { userDayKey } from "@/lib/tz/format"; export interface IntakeEvent { takenAt: Date | null; @@ -133,6 +138,16 @@ export const SCHEDULE_COMPLIANCE_SELECT = { doseWindows: true, } as const; +/** + * v1.40 (#1024) — the ONE relation select for the courses a compliance + * computation threads into {@link buildComplianceMedicationContext}, the + * sibling of {@link SCHEDULE_COMPLIANCE_SELECT}. + */ +export const COURSES_COMPLIANCE_SELECT = { + select: { startsOn: true, endsOn: true }, + orderBy: { startsOn: "asc" }, +} as const; + /** * v1.7.0 SB-SCHED-2 — medication-level context the canonical engine * needs to expand expected slots. When supplied, `calculateCompliance` @@ -162,6 +177,52 @@ export interface ComplianceMedicationContext { * callers without eras keep counting every expected slot. */ pauseEras?: MedicationPauseEraLike[]; + /** + * v1.40 (#1024) — the medication's courses. `startsOn`/`endsOn` above are + * the row's projection (the latest course). With two or more courses the + * retrospective minters expand over the span from the first course's start + * to the last one's end and drop every expected slot whose local day falls + * in no course, so a gap between courses expects nothing. Absent or a + * single course: today's behaviour, the projection window alone. + */ + courses?: CourseSpan[]; +} + +/** + * v1.40 (#1024) — the window the retrospective minters expand over: the span + * of every course when there are several, else the medication's own window. + */ +export function complianceMintWindow( + ctx: Pick, +): { + startsOn: Date | null; + endsOn: Date | null; +} { + const courses = ctx.courses; + if (!courses || courses.length < 2) { + return { startsOn: ctx.startsOn, endsOn: ctx.endsOn }; + } + const sorted = [...courses].sort( + (a, b) => a.startsOn.getTime() - b.startsOn.getTime(), + ); + return { + startsOn: sorted[0].startsOn, + endsOn: sorted[sorted.length - 1].endsOn, + }; +} + +/** + * v1.40 (#1024) — whether an expected slot at `at` belongs to a course. True + * for every slot when the medication has fewer than two courses (the + * projection window already bounds the expansion). + */ +export function slotInsideCourses( + ctx: Pick, + at: Date, +): boolean { + const courses = ctx.courses; + if (!courses || courses.length < 2) return true; + return isInsideCourses(userDayKey(at, ctx.timeZone), courses); } /** @@ -190,6 +251,8 @@ export function buildComplianceMedicationContext( scheduleRevisions?: ScheduleRevisionLike[]; /** v1.25 H-MED1 — thread the pause eras when the caller carries them. */ pauseEras?: MedicationPauseEraLike[]; + /** v1.40 (#1024) — thread the courses when the caller carries them. */ + courses?: CourseSpan[]; }, lastIntakeAt: Date | null, timeZone: string, @@ -203,6 +266,7 @@ export function buildComplianceMedicationContext( timeZone, ...(med.scheduleRevisions && { scheduleRevisions: med.scheduleRevisions }), ...(med.pauseEras && { pauseEras: med.pauseEras }), + ...(med.courses && { courses: med.courses }), }; } diff --git a/src/lib/analytics/schedule-anchored-compliance.ts b/src/lib/analytics/schedule-anchored-compliance.ts index f9d9b1d9f..0889e9c08 100644 --- a/src/lib/analytics/schedule-anchored-compliance.ts +++ b/src/lib/analytics/schedule-anchored-compliance.ts @@ -31,6 +31,7 @@ import { prisma } from "@/lib/db"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, expectedSlotCountForDay, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, @@ -70,6 +71,8 @@ export async function buildScheduleAnchoredComplianceBuckets( scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, }); diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 21deb72c0..7432a0752 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -210,10 +210,11 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ kind: "string", }, - // ───── Custom labels (mood + cycle) ───── + // ───── Custom labels (mood, cycle, medication categories) ───── { model: "MoodTag", field: "labelEncrypted", kind: "string" }, { model: "MoodTagCategory", field: "labelEncrypted", kind: "string" }, { model: "CycleSymptom", field: "labelEncrypted", kind: "string" }, + { model: "MedicationCategoryLabel", field: "labelEncrypted", kind: "bytes" }, // ───── Notification + push secrets ───── { model: "NotificationChannel", field: "config", kind: "string" }, @@ -337,6 +338,7 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // note — the last plaintext PHI columns left after the v1.23 rollout. { model: "MedicationSideEffect", field: "notesEncrypted", kind: "bytes" }, { model: "MedicationDoseChange", field: "noteEncrypted", kind: "bytes" }, + { model: "MedicationCourse", field: "noteEncrypted", kind: "bytes" }, { model: "MedicationInventoryItem", field: "notesEncrypted", kind: "bytes" }, // ───── v1.39.3 custom-metric reading note (Bytes column) ───── diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index c9b2926c3..42956e113 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -73,6 +73,11 @@ export const WIPE_MODELS = [ "MedicationInventoryItem", "MedicationIntakeImportJob", "MedicationComplianceRollup", + // The account's own category names (v1.40, #1041). No child rows; the + // medications that name one go with their own entry. + "MedicationCategoryLabel", + // The courses (v1.40, #1024), before the medications they cascade from. + "MedicationCourse", // ── Mood ──────────────────────────────────────────────────────────────── // The day context, before the entry it hangs off: it cascades either way, diff --git a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts index 61b436cff..b3e53dc5f 100644 --- a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts +++ b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts @@ -1,4 +1,5 @@ import type { jsPDF } from "jspdf"; +import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; import autoTable from "jspdf-autotable"; import { adherenceRatePercent } from "../doctor-report-data"; import { @@ -67,7 +68,20 @@ export function buildMedicationMoodWellnessSection( doc.text(t("doctorReport.complianceTitle"), margin, y); y += 6; - const compRows = complianceEntries.map(([, c]) => { + // v1.40 (#1024) — a medication taken in courses names each course inside + // the window under its name, one line per course. + const courseLines = (id: string): string[] => + (data.medications.find((m) => m.id === id)?.courses ?? []).map((c) => + c.endsOn + ? t("doctorReport.courseRange", { + start: fmtDate(dateOnlyAtNoonUtc(c.startsOn).toISOString()), + end: fmtDate(dateOnlyAtNoonUtc(c.endsOn).toISOString()), + }) + : t("doctorReport.courseSince", { + start: fmtDate(dateOnlyAtNoonUtc(c.startsOn).toISOString()), + }), + ); + const compRows = complianceEntries.map(([id, c]) => { // v1.17 W1a — `total` is the ledger rate denominator (taken + missed, // deliberate skips excluded), so `taken / total` matches the app's // detail-page adherence %. The column is labelled "Expected" rather @@ -77,7 +91,7 @@ export function buildMedicationMoodWellnessSection( const ratePct = adherenceRatePercent(c.taken, c.total); const rate = ratePct !== null ? `${num(ratePct, 0)}%` : "—"; return [ - c.name, + [c.name, ...courseLines(id)].join("\n"), String(c.taken), String(c.skipped), String(c.missed), diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index cdacd85b1..cf45a0c6b 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -137,6 +137,17 @@ export interface DoctorReportData { windowEnd: string; label: string | null; }>; + /** + * v1.40 (#1024) — the courses that overlap the report window, ascending + * (`YYYY-MM-DD`, both ends inclusive; `endsOn` null = open), with where + * the window's last day sits in each. Empty for a medication taken + * continuously. Optional so older fixtures typecheck. + */ + courses?: Array<{ + startsOn: string; + endsOn: string | null; + status: "UPCOMING" | "CURRENT" | "ENDED"; + }>; }>; /** * v1.9.0 — acted medication-intake events over the report window, fed @@ -537,6 +548,8 @@ export interface DoctorReportComplianceMedication { scheduleRevisions?: ScheduleRevisionLike[]; /** v1.25 H-MED1 — pause eras so paused days drop out of the denominator. */ pauseEras?: MedicationPauseEraLike[]; + /** v1.40 (#1024) — the courses, so a gap between two expects nothing. */ + courses?: { startsOn: Date; endsOn: Date | null }[]; } /** A window-bounded intake row keyed to its medication. */ diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index 3d94b9e17..61d5abe4f 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -162,6 +162,7 @@ function seed() { ], scheduleRevisions: [], pauseEras: [], + courses: [], doseChanges: [ { effectiveFrom: new Date("2026-01-05T00:00:00.000Z"), diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index a92c0b886..79aaacad5 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -15,6 +15,7 @@ * * Pure data assembly — no auth, no rate limit, no audit. Idempotent. */ +import { courseStatusOn, dayKeyOfDate } from "@/lib/medications/course-window"; import { prisma } from "@/lib/db"; import { getEffectiveRange, @@ -34,7 +35,10 @@ import { } from "@/lib/analytics/sleep-night"; import { buildCycleExportSummary } from "@/lib/cycle/export-data"; import { resolveModuleMap } from "@/lib/modules/gate"; -import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; +import { + COURSES_COMPLIANCE_SELECT, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; import { MEASUREMENT_LEAF_IDS } from "@/lib/report-selection/catalogue"; import type { ReportSelection } from "@/lib/report-selection/selection"; import { @@ -256,25 +260,54 @@ export async function collectDoctorReportData( // whenever it was entered. Dose history and the last intake stop // at the window's end too, so a report for March does not show a // dose set in June. + // + // v1.40 (#1024) — a medication taken in courses is part of the + // window when one of its courses overlaps it; the row's own + // window is only the latest course. One without courses keeps + // the rule above. where: { userId, active: true, - AND: [ + OR: [ { - OR: [ - { startsOn: { lte: lastDay } }, - { startsOn: null, createdAt: { lte: end } }, - { - intakeEvents: { - some: { - deletedAt: null, - scheduledFor: { gte: start, lte: end }, - }, - }, + courses: { + some: { + startsOn: { lte: lastDay }, + OR: [{ endsOn: null }, { endsOn: { gte: firstDay } }], }, + }, + }, + { + // A dose logged inside the window outside every course (the + // person knows better than the window) still places it. + courses: { some: {} }, + intakeEvents: { + some: { + deletedAt: null, + scheduledFor: { gte: start, lte: end }, + }, + }, + }, + { + courses: { none: {} }, + AND: [ + { + OR: [ + { startsOn: { lte: lastDay } }, + { startsOn: null, createdAt: { lte: end } }, + { + intakeEvents: { + some: { + deletedAt: null, + scheduledFor: { gte: start, lte: end }, + }, + }, + }, + ], + }, + { OR: [{ endsOn: null }, { endsOn: { gte: firstDay } }] }, ], }, - { OR: [{ endsOn: null }, { endsOn: { gte: firstDay } }] }, ], }, include: { @@ -292,6 +325,8 @@ export async function collectDoctorReportData( }, }, pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, doseChanges: { where: { effectiveFrom: { lte: end } }, orderBy: { effectiveFrom: "asc" }, @@ -438,6 +473,7 @@ export async function collectDoctorReportData( schedules: m.schedules, scheduleRevisions: m.scheduleRevisions, pauseEras: m.pauseEras, + courses: m.courses, })), intakeEvents.map((e) => ({ medicationId: e.medicationId, @@ -682,6 +718,18 @@ export async function collectDoctorReportData( windowEnd: s.windowEnd, label: s.label, })), + // v1.40 (#1024) — the courses inside the window, one line each. + courses: m.courses + .filter( + (c) => + c.startsOn.getTime() <= lastDay.getTime() && + (c.endsOn === null || c.endsOn.getTime() >= firstDay.getTime()), + ) + .map((c) => ({ + startsOn: dayKeyOfDate(c.startsOn), + endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, + status: courseStatusOn(c, dayKeyOfDate(lastDay)), + })), })) : [], medicationAdministrations: gate.admits("MEDICATION_ADMINISTRATIONS") diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index d1a242631..61402aedb 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -15,8 +15,8 @@ const mocks = vi.hoisted(() => ({ // The category side table is raw SQL on the shared client; the round trip // through it is proven in tests/integration/backup-round-trip.test.ts. vi.mock("@/lib/medication-category", () => ({ - ensureMedicationCategoryTable: vi.fn(async () => {}), getMedicationCategories: vi.fn(async () => ({})), + readMedicationCategoryLabelsForBackup: vi.fn(async () => []), setMedicationCategory: vi.fn(async () => "OTHER"), })); vi.mock("@/lib/export/paged-measurements", () => ({ @@ -190,6 +190,23 @@ function makePrisma() { createdAt: new Date("2026-07-15T08:01:00.000Z"), }, ], + // v1.40 (#1024) — one ended course with a note, one running. + courses: [ + { + id: "course-march", + startsOn: new Date("2026-03-01T00:00:00.000Z"), + endsOn: new Date("2026-03-07T00:00:00.000Z"), + noteEncrypted: encryptToBytes("the March flu"), + createdAt: new Date("2026-03-01T08:00:00.000Z"), + }, + { + id: "course-june", + startsOn: new Date("2026-06-10T00:00:00.000Z"), + endsOn: null, + noteEncrypted: null, + createdAt: new Date("2026-06-10T08:00:00.000Z"), + }, + ], // A two-step ramp, one row carrying an encrypted note and one // without, so both arms of the note contract are exercised. doseChanges: [ @@ -744,6 +761,35 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { * into a portable export and losing the note are opposite failures of the same * decision. */ +describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => { + it.each(["portable-export", "disaster-recovery"] as const)( + "carries every course with its note readable in a %s file", + async (purpose) => { + installSectionMocks(); + const { payload } = await buildFullBackupPayload( + makePrisma() as never, + "user-1", + { purpose, exportedAt: new Date("2026-07-20T00:00:00.000Z") }, + ); + const parsed = parseBackupPayload(payload); + expect(parsed.medications[0].courses).toEqual([ + { + startsOn: "2026-03-01", + endsOn: "2026-03-07", + note: "the March flu", + createdAt: "2026-03-01T08:00:00.000Z", + }, + { + startsOn: "2026-06-10", + endsOn: null, + note: null, + createdAt: "2026-06-10T08:00:00.000Z", + }, + ]); + }, + ); +}); + describe("buildFullBackupPayload — medication side effects", () => { it("decrypts the note and emits no ciphertext in a portable export", async () => { installSectionMocks(); diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index 116fdf6d6..39a42b987 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -34,8 +34,10 @@ const mocks = vi.hoisted(() => ({ // The category side table is raw SQL on the shared client; the round trip // through it is proven in tests/integration/backup-round-trip.test.ts. vi.mock("@/lib/medication-category", () => ({ - ensureMedicationCategoryTable: vi.fn(async () => {}), getMedicationCategories: vi.fn(async () => ({})), + readMedicationCategoryLabelsForBackup: vi.fn(async () => []), + encryptCategoryLabel: vi.fn(() => new Uint8Array()), + mintCustomMedicationCategoryKey: vi.fn(() => "custom:minted"), setMedicationCategory: vi.fn(async () => "OTHER"), })); vi.mock("@/lib/db", () => ({ diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 14118bde3..aa686e0df 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -77,6 +77,18 @@ export const BACKED_UP_MODELS = [ "MedicationEfficacyTarget", "MedicationInventoryItem", "MedicationInventoryEvent", + // The clinical category the person filed each medication under. Before + // v1.40 this was a raw table the app created at runtime and judged under + // RAW_SQL_TABLES; it rides inside its medication (`category`) in both + // formats and is written back in the restore transaction. Without it every + // restored medication reads as Other. + "MedicationCategoryAssignment", + // The categories the person named themselves (v1.40, #1041). A medication + // filed under one carries its key; without the row the key reads as Other. + "MedicationCategoryLabel", + // The courses (v1.40, #1024). Once a second one exists the earlier spans + // live here and nowhere else; the medication row keeps only the latest. + "MedicationCourse", // ── Mood ────────────────────────────────────────────────────────────────── "MoodEntry", @@ -259,6 +271,10 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // create }` back), the way a medication's side effects do. "src/lib/export/symptoms-backup.ts", "src/lib/cycle/backup.ts", + // The medication category is read through the one helper that normalises + // it; the payload writer calls the helper rather than the delegate. The + // same "the caller delegates" shape as the measurement pager. + "src/lib/medication-category.ts", ]; export const BACKUP_RESTORE_FILES: readonly string[] = [ @@ -278,6 +294,8 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/ecg-backup.ts", "src/lib/export/symptoms-backup.ts", "src/lib/cycle/backup.ts", + // The restore writes the medication category through the same helper. + "src/lib/medication-category.ts", ]; /** @@ -306,6 +324,9 @@ export const TWO_ENDED_MODELS = [ "MedicationSideEffect", "MedicationPauseEra", "MedicationDoseChange", + "MedicationCategoryAssignment", + "MedicationCategoryLabel", + "MedicationCourse", "MoodEntry", "MoodContext", "MoodEntryTagLink", @@ -726,16 +747,13 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { * every backup until v1.39.4. The guard beside this file finds each * `CREATE TABLE IF NOT EXISTS` in the source and demands a verdict here, or a * schema model mapped to the same name. + * + * Empty since v1.40: the one such table, `medication_categories`, is the + * `MedicationCategoryAssignment` model now and judged with the others. */ export const RAW_SQL_TABLES: Readonly< Record -> = { - medication_categories: { - verdict: "BACKED_UP", - reason: - "The clinical category the person chose for each medication. Carried inside its medication in both backup formats and written back in the restore transaction; without it every restored medication reads as Other.", - }, -}; +> = {}; /** Auth material: not user data, and excluded from the wipe for the same reason. */ export const AUTH_MODELS_OUT_OF_SCOPE: readonly string[] = [ diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index a57822647..9bd1b84d7 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -20,6 +20,8 @@ * one reaches the other; the integration suite pins the two outputs byte for * byte rather than trusting that sentence. */ +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { dayKeyOfDate } from "@/lib/medications/course-window"; import { Buffer } from "node:buffer"; import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; @@ -128,7 +130,10 @@ import { buildAccountSettingsBackupSection, type AccountSettingsBackupSection, } from "@/lib/export/account-settings-backup"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { + getMedicationCategories, + readMedicationCategoryLabelsForBackup, +} from "@/lib/medication-category"; export interface FullBackupCounts extends @@ -740,6 +745,7 @@ export async function buildFullBackupPayload( schedules: true, sideEffects: { orderBy: { occurredAt: "desc" } }, pauseEras: { orderBy: { pausedAt: "asc" } }, + courses: { orderBy: { startsOn: "asc" } }, doseChanges: { orderBy: { effectiveFrom: "asc" } }, inventoryItems: { orderBy: { createdAt: "asc" } }, inventoryEvents: { orderBy: { occurredAt: "asc" } }, @@ -976,13 +982,20 @@ export async function buildFullBackupPayload( : moodEntries!, }; - // The clinical category lives in a raw side table rather than on the - // medication row, so no Prisma include reaches it. Before v1.39.4 no backup - // carried it and every restored medication came back as "Other". + // The clinical category lives in its own table rather than on the + // medication row and is read through the one helper that normalises it. + // Before v1.39.4 no backup carried it and every restored medication came + // back as "Other". const categoryByMedication = await getMedicationCategories( medications.map((m) => m.id), prisma, ); + // v1.40 (#1041) — the account's own categories, restored before the + // medications that name them by key. Labels travel decrypted so a file + // restores on an instance with a different key; every one is carried, + // hidden ones included, because a medication can still be filed under it. + const customMedicationCategories = + await readMedicationCategoryLabelsForBackup(userId, prisma); // Where each archived era sits in its OWN drug's ordered list. Built once // here rather than per row, and scoped per medication because the supersede @@ -1014,6 +1027,7 @@ export async function buildFullBackupPayload( : null, ...accountSettingsSection, measurements: bulk.measurements, + customMedicationCategories, medications: medications.map((m) => ({ ...(disasterRecovery ? { @@ -1122,6 +1136,15 @@ export async function buildFullBackupPayload( resumedAt: p.resumedAt ? p.resumedAt.toISOString() : null, createdAt: p.createdAt.toISOString(), })), + // v1.40 (#1024) — the courses: "when did I last take this" lives here + // and nowhere else once a second course exists. The medication's own + // window above is the projection of the latest one. + courses: m.courses.map((c) => ({ + startsOn: dayKeyOfDate(c.startsOn), + endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, + note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null, + createdAt: c.createdAt.toISOString(), + })), // Titration: when the dose moved, to what, and why. The drug comes // back at today's dose either way; without these the ramp that led to // it is gone, and that ramp is what a prescriber asks about. diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 53e6608b9..0ac7e1a68 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -27,6 +27,12 @@ * replacement is still one transaction, so a failure part-way leaves the * account exactly as it was. */ +import { DEFAULT_TIMEZONE, userDayKey, validTimezoneOr } from "@/lib/tz/format"; +import { + courseFromWindow, + dateOfDayKey, + projectCourseWindow, +} from "@/lib/medications/course-window"; import { Buffer } from "node:buffer"; import { prisma, toJson } from "@/lib/db"; @@ -109,7 +115,8 @@ import { restoreEcgData } from "@/lib/export/ecg-backup"; import { restoreSymptomsData } from "@/lib/export/symptoms-backup"; import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at"; import { - ensureMedicationCategoryTable, + encryptCategoryLabel, + mintCustomMedicationCategoryKey, setMedicationCategory, } from "@/lib/medication-category"; import { invalidateUserData } from "@/lib/cache/invalidate"; @@ -684,10 +691,6 @@ export async function restoreBackup( skipped: RestoreSkipSummary; accountSettings: AccountSettingsRestoreResult; }; - // The category side table is created lazily. Doing that here, before the - // transaction, keeps its DDL (which takes a table lock) out of a - // transaction that already holds row locks on the same table. - await ensureMedicationCategoryTable(); try { report("clearing"); outcome = await prisma.$transaction( @@ -1058,6 +1061,46 @@ export async function restoreBackup( // is where an operator has to go to see what the position meant, and // an id would be one the restore never wrote. const unresolvedRevisionLinks: string[] = []; + // v1.40 (#1041) — the account's own medication categories, before + // the medications that name them by key. Delete-then-recreate like + // the medications themselves. A key held by another account on this + // instance (a portable file from elsewhere) is re-minted, and the + // medications below are mapped onto the new key. + await tx.medicationCategoryLabel.deleteMany({ + where: { userId: ownerId }, + }); + const categoryKeyRemap = new Map(); + for (const c of payload.customMedicationCategories) { + const taken = await tx.medicationCategoryLabel.findUnique({ + where: { key: c.key }, + select: { id: true }, + }); + const key = taken ? mintCustomMedicationCategoryKey() : c.key; + if (key !== c.key) categoryKeyRemap.set(c.key, key); + await tx.medicationCategoryLabel.create({ + data: { + userId: ownerId, + key, + labelEncrypted: encryptCategoryLabel(c.label), + sortOrder: c.sortOrder, + isActive: c.isActive, + ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + }, + }); + } + + // v1.40 (#1024) — the zone a derived course's creation day is read + // in: the file's own account settings, else the account's stored one. + const restoreTz = validTimezoneOr( + payload.accountSettings?.timezone ?? + ( + await tx.user.findUnique({ + where: { id: ownerId }, + select: { timezone: true }, + }) + )?.timezone, + DEFAULT_TIMEZONE, + ); let medicationIndex = 0; reportSection("medications"); const restoreStartedAt = new Date(); @@ -1267,11 +1310,59 @@ export async function restoreBackup( }); restoredMedicationIds.add(created.id); if (!medByName.has(m.name)) medByName.set(m.name, created.id); - // v1.39.4 — the clinical category lives in a side table keyed on the + // v1.39.4 — the clinical category lives in its own table keyed on the // medication id, so it is written after the row exists and inside the // same transaction. OTHER is what a missing row already reads as. if (m.category && m.category !== "OTHER") { - await setMedicationCategory(created.id, m.category, tx); + await setMedicationCategory( + created.id, + categoryKeyRemap.get(m.category) ?? m.category, + tx, + ); + } + + // v1.40 (#1024) — the courses, then the window they project. A file + // written before courses existed carries none; the medication's own + // window then describes its one course, derived exactly as the + // migration's backfill did (an end-only window runs from creation, + // or from its end day when that lies earlier). + const courseRows: Array<{ + startsOn: Date; + endsOn: Date | null; + note: string | null; + createdAt?: Date; + }> = + m.courses !== undefined + ? m.courses.map((c) => ({ + startsOn: dateOfDayKey(c.startsOn), + endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null, + note: c.note ?? null, + ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + })) + : (() => { + const derived = courseFromWindow( + created.startsOn, + created.endsOn, + userDayKey(created.createdAt, restoreTz), + ); + return derived ? [{ ...derived, note: null }] : []; + })(); + if (courseRows.length > 0) { + await tx.medicationCourse.createMany({ + data: courseRows.map((c) => ({ + medicationId: created.id, + userId: ownerId, + startsOn: c.startsOn, + endsOn: c.endsOn, + noteEncrypted: c.note ? encryptToBytes(c.note) : null, + ...(c.createdAt ? { createdAt: c.createdAt } : {}), + })), + }); + const window = projectCourseWindow(courseRows); + await tx.medication.update({ + where: { id: created.id }, + data: { startsOn: window.startsOn, endsOn: window.endsOn }, + }); } // ── The archived schedule eras, in two passes ──────────────── diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index e8844d820..473758eae 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -458,6 +458,38 @@ describe("buildFhirDocumentBundle", () => { expect(meds[0].dosage?.[0].text).toBe("5mg"); }); + it("emits one statement per course with its period and status (v1.40, #1024)", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medications: [ + { + id: "med-tamiflu", + name: "Tamiflu", + dose: "75 mg", + schedules: [], + courses: [ + { startsOn: "2026-03-01", endsOn: "2026-03-07", status: "ENDED" }, + { startsOn: "2026-06-10", endsOn: null, status: "CURRENT" }, + ], + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const meds = bundle.entry + .map((e) => e.resource) + .filter( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + expect(meds.map((m) => [m.status, m.effectivePeriod])).toEqual([ + ["completed", { start: "2026-03-01", end: "2026-03-07" }], + ["active", { start: "2026-06-10" }], + ]); + expect(new Set(meds.map((m) => m.id)).size).toBe(2); + }); + it("keeps a text-only medicationCodeableConcept when no codes are stored", () => { const bundle = buildFhirDocumentBundle( makeData(), diff --git a/src/lib/fhir/resources/medications.ts b/src/lib/fhir/resources/medications.ts index ea14d2be7..56cbf7f46 100644 --- a/src/lib/fhir/resources/medications.ts +++ b/src/lib/fhir/resources/medications.ts @@ -154,22 +154,50 @@ export function medicationStatementsFromReportData( const germanAtc = options.germanAtc ?? false; const statements: FhirMedicationStatement[] = []; let medSeq = 0; + // v1.40 (#1024) — a medication taken in courses is one statement per + // course inside the window, each with its `effectivePeriod` and a status + // read against the window's last day: `completed` once it has ended, + // `intended` before it starts, `active` otherwise. A medication taken + // continuously stays one `active` statement, as before. for (const med of data.medications) { - medSeq += 1; - const stmt: FhirMedicationStatement = { - resourceType: "MedicationStatement", - id: `med-${medSeq}`, - status: "active", - medicationCodeableConcept: medicationConcept( - med.name, - med.atcCode, - med.rxNormCode, - germanAtc, - ), - subject: patientRef, - }; - if (med.dose) stmt.dosage = [{ text: med.dose }]; - statements.push(stmt); + const concept = medicationConcept( + med.name, + med.atcCode, + med.rxNormCode, + germanAtc, + ); + const courses = med.courses ?? []; + const spans: Array<{ + status: FhirMedicationStatement["status"]; + effectivePeriod?: { start: string; end?: string }; + }> = + courses.length === 0 + ? [{ status: "active" }] + : courses.map((c) => ({ + status: + c.status === "ENDED" + ? "completed" + : c.status === "UPCOMING" + ? "intended" + : "active", + effectivePeriod: { + start: c.startsOn, + ...(c.endsOn ? { end: c.endsOn } : {}), + }, + })); + for (const span of spans) { + medSeq += 1; + const stmt: FhirMedicationStatement = { + resourceType: "MedicationStatement", + id: `med-${medSeq}`, + status: span.status, + medicationCodeableConcept: concept, + subject: patientRef, + ...(span.effectivePeriod && { effectivePeriod: span.effectivePeriod }), + }; + if (med.dose) stmt.dosage = [{ text: med.dose }]; + statements.push(stmt); + } } return statements; } diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 649d08a32..9685b4419 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -160,7 +160,7 @@ export interface FhirPeriod { export interface FhirMedicationStatement { resourceType: "MedicationStatement"; id: string; - status: "active" | "completed" | "stopped" | "unknown"; + status: "active" | "completed" | "intended" | "stopped" | "unknown"; medicationCodeableConcept: FhirCodeableConcept; subject: FhirReference; effectivePeriod?: FhirPeriod; diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index b5bf3c6ec..4057590a4 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -23,6 +23,7 @@ import { } from "@/lib/analytics/correlations"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, calculateCompliance, expectsDoses, lastNonSkippedTakenAt, @@ -338,6 +339,8 @@ export async function prepareBloodPressureStatusForUser( scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, }); diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index b0278790d..55d37773f 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -22,6 +22,7 @@ import { prisma } from "@/lib/db"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, buildMedicationComplianceBundle, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, @@ -89,6 +90,8 @@ export async function fetchComplianceSeries( scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, orderBy: { name: "asc" }, }); diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 58c1e0742..93b287ffb 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -8,6 +8,7 @@ import { summarize } from "@/lib/analytics/trends"; import type { DataPoint } from "@/lib/analytics/trends"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, calculateCompliance, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, @@ -1665,6 +1666,8 @@ export async function extractFeatures( scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, }); diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 9008c0435..299b97a64 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -7,6 +7,7 @@ import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype"; import type { Locale } from "@/lib/i18n/config"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, buildMedicationComplianceBundle, dailyComplianceRatesFromLedger, expectsDoses, @@ -207,6 +208,8 @@ export async function prepareMedicationComplianceStatusForUser( scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, orderBy: { name: "asc" }, }); diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts index eae946c0a..72794edcf 100644 --- a/src/lib/mcp/__tests__/resources.test.ts +++ b/src/lib/mcp/__tests__/resources.test.ts @@ -10,6 +10,9 @@ vi.mock("@/lib/db", () => ({ }, })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/medication-category", () => ({ + resolveMedicationCategories: vi.fn(), +})); vi.mock("@/lib/doctor-report-data", () => ({ collectDoctorReportData: vi.fn(), })); @@ -27,6 +30,7 @@ import { prisma } from "@/lib/db"; import { collectDoctorReportData } from "@/lib/doctor-report-data"; import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; import { metricStatusDiscoveryRows } from "@/lib/mcp/rich-reads"; +import { resolveMedicationCategories } from "@/lib/medication-category"; import type { McpAuthContext } from "../auth"; const CTX: McpAuthContext = { @@ -104,8 +108,15 @@ describe("healthlog://profile", () => { describe("healthlog://medications", () => { it("lists medications + schedules scoped to the session user", async () => { + vi.mocked(resolveMedicationCategories).mockResolvedValue({ + "med-a": { + category: "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", + categoryLabel: "Travel kit", + }, + }); vi.mocked(prisma.medication.findMany).mockResolvedValue([ { + id: "med-a", name: "Med A", dose: "10mg", treatmentClass: "GENERIC", @@ -139,6 +150,14 @@ describe("healthlog://medications", () => { expect(result.present).toBe(true); expect(result.count).toBe(1); expect(Array.isArray(result.medications)).toBe(true); + // v1.40 (#1041) — the custom category rides with its label, fenced as + // the person's own text. + expect( + (result.medications as Array>)[0], + ).toMatchObject({ + category: "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", + categoryLabel: "<<>>Travel kit<<>>", + }); }); it("returns { present: false } when no medications are tracked", async () => { diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts index f0bf9d68c..f0724414e 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -26,6 +26,9 @@ * external-assistant boundary. */ import { prisma } from "@/lib/db"; +import { fenceUserText } from "@/lib/ai/coach/data-fence"; +import { resolveMedicationCategories } from "@/lib/medication-category"; +import { dayKeyOfDate } from "@/lib/medications/course-window"; import { annotate } from "@/lib/logging/context"; import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory"; @@ -130,6 +133,11 @@ function ageYears(dateOfBirth: Date | null): number | null { */ const MODULE_DISABLED = { present: false, reason: "module_disabled" } as const; +/** A person-written label, fenced as user text; null stays null. */ +function fenceOptional(text: string | null | undefined): string | null { + return text ? fenceUserText(text) : null; +} + function firstVar(value: string | string[] | undefined): string { if (Array.isArray(value)) return value[0] ?? ""; return value ?? ""; @@ -185,7 +193,13 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ } const medications = await prisma.medication.findMany({ where: { userId: ctx.userId }, - include: { schedules: true }, + include: { + schedules: true, + courses: { + select: { startsOn: true, endsOn: true }, + orderBy: { startsOn: "asc" }, + }, + }, orderBy: { createdAt: "desc" }, }); annotate({ @@ -193,6 +207,9 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ meta: { resource: "medications", present: medications.length > 0 }, }); if (medications.length === 0) return { present: false, count: 0 }; + const categories = await resolveMedicationCategories( + medications.map((med) => med.id), + ); return { present: true, count: medications.length, @@ -200,6 +217,11 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ name: med.name, dose: med.dose, treatmentClass: med.treatmentClass, + // v1.40 (#1041) — the clinical category: a built-in value or + // `custom:`, with the person's own label (their text, fenced) + // for a custom one. + category: categories[med.id]?.category ?? "OTHER", + categoryLabel: fenceOptional(categories[med.id]?.categoryLabel), asNeeded: med.asNeeded, // v1.39.1 (#1033) — false: kept as a record, nothing is due and // no adherence applies; the schedules below are information. @@ -207,6 +229,12 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ paused: med.pausedAt !== null, startsOn: med.startsOn ? med.startsOn.toISOString() : null, endsOn: med.endsOn ? med.endsOn.toISOString() : null, + // v1.40 (#1024) — every course, so "when did they last take it" is + // answerable; startsOn/endsOn above stay the current course. + courses: (med.courses ?? []).map((c) => ({ + startsOn: dayKeyOfDate(c.startsOn), + endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, + })), schedules: med.schedules.map((s) => ({ label: s.label ?? null, dose: s.dose ?? null, diff --git a/src/lib/measurement-reminders/scheduling.ts b/src/lib/measurement-reminders/scheduling.ts index 49cbf8aaf..7304561e1 100644 --- a/src/lib/measurement-reminders/scheduling.ts +++ b/src/lib/measurement-reminders/scheduling.ts @@ -165,6 +165,8 @@ export function buildReminderRecurrence( createdAt: reminder.createdAt, }, timeZone: tz, + // No `rollingAnchor`: the anchor date is not a course start, so the last + // satisfaction always anchors the next due, however long ago it was. lastIntakeAt: reminder.lastSatisfiedAt, }; diff --git a/src/lib/medication-category.ts b/src/lib/medication-category.ts index 04d64d771..f6240db5a 100644 --- a/src/lib/medication-category.ts +++ b/src/lib/medication-category.ts @@ -1,110 +1,309 @@ +import { randomUUID } from "node:crypto"; + +import type { Prisma } from "@/generated/prisma/client"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { prisma } from "@/lib/db"; -import { MEDICATION_CATEGORY_VALUES } from "@/lib/validations/medication"; +import { annotate } from "@/lib/logging/context"; +import { + CUSTOM_MEDICATION_CATEGORY_PREFIX, + MEDICATION_CATEGORY_VALUES, + isCustomMedicationCategoryKey, +} from "@/lib/validations/medication"; const MEDICATION_CATEGORIES = MEDICATION_CATEGORY_VALUES; export type MedicationCategory = (typeof MEDICATION_CATEGORIES)[number]; +/** + * What a medication's `category` resolves to: a built-in value, or the + * `custom:` key of one of its owner's own categories. + */ +export type MedicationCategoryKey = MedicationCategory | `custom:${string}`; + +export interface ResolvedMedicationCategory { + category: MedicationCategoryKey; + /** The decrypted label of a custom category; null for a built-in one. */ + categoryLabel: string | null; +} + const DEFAULT_CATEGORY: MedicationCategory = "OTHER"; /** - * The raw-SQL surface the helpers below need. The default is the shared + * The client surface the helpers below need. The default is the shared * client; the backup builder and the restore pass their own (the restore's * transaction client, so a category row lands in the same transaction as * the medication it points at and its foreign key can see that row). */ -type RawSqlClient = Pick< - typeof prisma, - "$queryRawUnsafe" | "$executeRawUnsafe" +type CategoryClient = Pick< + Prisma.TransactionClient, + "medicationCategoryAssignment" | "medicationCategoryLabel" >; -let ensureTablePromise: Promise | null = null; - -function normalizeCategory(input: unknown): MedicationCategory { - if (typeof input !== "string") return DEFAULT_CATEGORY; - return MEDICATION_CATEGORIES.includes(input as MedicationCategory) - ? (input as MedicationCategory) - : DEFAULT_CATEGORY; +function isBuiltIn(input: string): input is MedicationCategory { + return MEDICATION_CATEGORIES.includes(input as MedicationCategory); } -export async function ensureMedicationCategoryTable() { - if (!ensureTablePromise) { - ensureTablePromise = (async () => { - await prisma.$executeRawUnsafe(` - CREATE TABLE IF NOT EXISTS medication_categories ( - medication_id TEXT PRIMARY KEY REFERENCES medications(id) ON DELETE CASCADE, - category TEXT NOT NULL DEFAULT 'OTHER', - updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - `); - - await prisma.$executeRawUnsafe(` - CREATE INDEX IF NOT EXISTS medication_categories_category_idx - ON medication_categories(category); - `); - })().catch((err) => { - ensureTablePromise = null; - throw err; - }); - } - - await ensureTablePromise; -} - -export async function getMedicationCategories( +/** + * Each medication's category, resolved against its owner's own labels. A + * custom key resolves only to a label owned by the medication's owner (active + * or hidden: hiding affects the picker, not what a medication shows). A key + * with no such label — a restore that carried the medication but not the + * label — reads as OTHER and is counted on the wide event, never a 500. + */ +export async function resolveMedicationCategories( medicationIds: string[], - client: RawSqlClient = prisma, -): Promise> { + client: CategoryClient = prisma, +): Promise> { if (medicationIds.length === 0) return {}; - await ensureMedicationCategoryTable(); - const rows = await client.$queryRawUnsafe< - Array<{ medication_id: string; category: string }> - >( - ` - SELECT medication_id, category - FROM medication_categories - WHERE medication_id = ANY($1::text[]) - `, - medicationIds, - ); + const rows = await client.medicationCategoryAssignment.findMany({ + where: { medicationId: { in: medicationIds } }, + select: { + medicationId: true, + category: true, + medication: { select: { userId: true } }, + }, + }); - const map: Record = {}; + const customKeys = [ + ...new Set( + rows + .map((row) => row.category) + .filter((c) => isCustomMedicationCategoryKey(c)), + ), + ]; + const labels = + customKeys.length === 0 + ? [] + : await client.medicationCategoryLabel.findMany({ + where: { key: { in: customKeys } }, + select: { key: true, userId: true, labelEncrypted: true }, + }); + const labelByKey = new Map(labels.map((l) => [l.key, l])); + + const map: Record = {}; for (const id of medicationIds) { - map[id] = DEFAULT_CATEGORY; + map[id] = { category: DEFAULT_CATEGORY, categoryLabel: null }; } + let dangling = 0; for (const row of rows) { - map[row.medication_id] = normalizeCategory(row.category); + if (isBuiltIn(row.category)) { + map[row.medicationId] = { category: row.category, categoryLabel: null }; + continue; + } + const label = labelByKey.get(row.category); + if ( + isCustomMedicationCategoryKey(row.category) && + label && + label.userId === row.medication.userId + ) { + map[row.medicationId] = { + category: row.category as MedicationCategoryKey, + categoryLabel: decryptFromBytes(label.labelEncrypted), + }; + continue; + } + if (isCustomMedicationCategoryKey(row.category)) dangling += 1; + } + if (dangling > 0) { + annotate({ meta: { "medication.category.dangling": dangling } }); } return map; } +/** Each medication's resolved category key (built-in or custom), no labels. */ +export async function getMedicationCategories( + medicationIds: string[], + client: CategoryClient = prisma, +): Promise> { + const resolved = await resolveMedicationCategories(medicationIds, client); + return Object.fromEntries( + Object.entries(resolved).map(([id, r]) => [id, r.category]), + ); +} + +/** + * Whether `category` may be written onto a medication of `userId`: any + * built-in value, or a custom key whose label belongs to that account. + */ +export async function isAssignableCategory( + userId: string, + category: string, + client: CategoryClient = prisma, +): Promise { + if (isBuiltIn(category)) return true; + if (!isCustomMedicationCategoryKey(category)) return false; + const label = await client.medicationCategoryLabel.findFirst({ + where: { key: category, userId }, + select: { key: true }, + }); + return label !== null; +} + +/** + * Write a medication's category. A built-in value is stored as given; a + * custom key only when it belongs to the medication's owner, otherwise the + * write falls back to OTHER (the routes refuse a foreign key with a 422 + * before they get here; this is the floor for every other caller). + */ export async function setMedicationCategory( medicationId: string, category: unknown, - client: RawSqlClient = prisma, -) { - await ensureMedicationCategoryTable(); - const normalized = normalizeCategory(category); + client: CategoryClient & + Pick = prisma, +): Promise { + let normalized: MedicationCategoryKey = DEFAULT_CATEGORY; + if (typeof category === "string") { + if (isBuiltIn(category)) { + normalized = category; + } else if (isCustomMedicationCategoryKey(category)) { + const owner = await client.medication.findUnique({ + where: { id: medicationId }, + select: { userId: true }, + }); + if ( + owner && + (await isAssignableCategory(owner.userId, category, client)) + ) { + normalized = category as MedicationCategoryKey; + } + } + } - await client.$executeRawUnsafe( - ` - INSERT INTO medication_categories (medication_id, category, updated_at) - VALUES ($1, $2, NOW()) - ON CONFLICT (medication_id) - DO UPDATE SET category = EXCLUDED.category, updated_at = NOW() - `, - medicationId, - normalized, - ); + await client.medicationCategoryAssignment.upsert({ + where: { medicationId }, + create: { medicationId, category: normalized }, + update: { category: normalized }, + }); return normalized; } -export async function deleteMedicationCategory(medicationId: string) { - await ensureMedicationCategoryTable(); - await prisma.$executeRawUnsafe( - `DELETE FROM medication_categories WHERE medication_id = $1`, - medicationId, - ); +export async function deleteMedicationCategory( + medicationId: string, + client: CategoryClient = prisma, +) { + await client.medicationCategoryAssignment.deleteMany({ + where: { medicationId }, + }); +} + +// ── The person's own categories ───────────────────────────────────────────── + +export interface MedicationCategoryLabelDto { + key: string; + label: string; + sortOrder: number; + isActive: boolean; + /** How many of the owner's medications are filed under it. */ + medicationCount: number; +} + +/** Mint the key of a new custom category. */ +export function mintCustomMedicationCategoryKey(): string { + return `${CUSTOM_MEDICATION_CATEGORY_PREFIX}${randomUUID()}`; +} + +export function encryptCategoryLabel(label: string): Uint8Array { + return encryptToBytes(label); +} + +export function decryptCategoryLabel(bytes: Uint8Array): string { + return decryptFromBytes(bytes); +} + +/** Every custom category of `userId`, hidden ones included, in picker order. */ +export async function listMedicationCategoryLabels( + userId: string, + client: CategoryClient = prisma, +): Promise { + const rows = await client.medicationCategoryLabel.findMany({ + where: { userId }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + select: { + key: true, + labelEncrypted: true, + sortOrder: true, + isActive: true, + }, + }); + if (rows.length === 0) return []; + const counts = await client.medicationCategoryAssignment.groupBy({ + by: ["category"], + where: { + category: { in: rows.map((r) => r.key) }, + medication: { userId }, + }, + _count: { _all: true }, + }); + const countByKey = new Map(counts.map((c) => [c.category, c._count._all])); + return rows.map((r) => ({ + key: r.key, + label: decryptCategoryLabel(r.labelEncrypted), + sortOrder: r.sortOrder, + isActive: r.isActive, + medicationCount: countByKey.get(r.key) ?? 0, + })); +} + +/** + * Every custom category of `userId` as the backup carries it: the decrypted + * label (re-encrypted under the restoring instance's key) and its creation + * instant, hidden ones included. + */ +export async function readMedicationCategoryLabelsForBackup( + userId: string, + client: CategoryClient = prisma, +): Promise< + Array<{ + key: string; + label: string; + sortOrder: number; + isActive: boolean; + createdAt: string; + }> +> { + const rows = await client.medicationCategoryLabel.findMany({ + where: { userId }, + orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], + select: { + key: true, + labelEncrypted: true, + sortOrder: true, + isActive: true, + createdAt: true, + }, + }); + return rows.map((r) => ({ + key: r.key, + label: decryptCategoryLabel(r.labelEncrypted), + sortOrder: r.sortOrder, + isActive: r.isActive, + createdAt: r.createdAt.toISOString(), + })); +} + +/** + * Delete one of `userId`'s custom categories. Its medications move to OTHER + * in the same transaction, so no medication is ever left pointing at a key + * that no longer exists. Returns how many moved, or null when the key is not + * one of the caller's. + */ +export async function deleteMedicationCategoryLabel( + userId: string, + key: string, +): Promise<{ movedCount: number } | null> { + return prisma.$transaction(async (tx) => { + const owned = await tx.medicationCategoryLabel.findFirst({ + where: { key, userId }, + select: { id: true }, + }); + if (!owned) return null; + const moved = await tx.medicationCategoryAssignment.updateMany({ + where: { category: key, medication: { userId } }, + data: { category: DEFAULT_CATEGORY }, + }); + await tx.medicationCategoryLabel.delete({ where: { id: owned.id } }); + return { movedCount: moved.count }; + }); } diff --git a/src/lib/medications/__tests__/category-filter.test.ts b/src/lib/medications/__tests__/category-filter.test.ts new file mode 100644 index 000000000..2dc819a9a --- /dev/null +++ b/src/lib/medications/__tests__/category-filter.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + ALL_MEDICATION_CATEGORIES, + effectiveCategoryFilter, + filterMedicationsByCategory, + medicationCategoryOptions, +} from "@/lib/medications/category-filter"; +import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; + +const KEY = "custom:11111111-1111-4111-8111-111111111111"; +const t = (key: string) => `t:${key}`; + +const MEDS = [ + { id: "a", active: true, category: "THYROID", categoryLabel: null }, + { id: "b", active: true, category: KEY, categoryLabel: "Travel kit" }, + { id: "c", active: false, category: KEY, categoryLabel: "Travel kit" }, + { id: "d", active: false, category: "THYROID", categoryLabel: null }, +]; + +describe("medication category filter (v1.40, #1041)", () => { + it("offers each category in use once, labelled like the card badge", () => { + expect(medicationCategoryOptions(MEDS, t)).toEqual([ + { value: "THYROID", label: "t:medications.categoryThyroid" }, + { value: KEY, label: "Travel kit" }, + ]); + }); + + it("narrows the active and the inactive block alike", () => { + const active = MEDS.filter((m) => m.active); + const inactive = MEDS.filter((m) => !m.active); + expect(filterMedicationsByCategory(active, KEY).map((m) => m.id)).toEqual([ + "b", + ]); + expect(filterMedicationsByCategory(inactive, KEY).map((m) => m.id)).toEqual( + ["c"], + ); + expect( + filterMedicationsByCategory(active, ALL_MEDICATION_CATEGORIES).map( + (m) => m.id, + ), + ).toEqual(["a", "b"]); + }); + + it("falls back to All when the stored pick is no longer in use", () => { + const options = medicationCategoryOptions(MEDS, t); + expect(effectiveCategoryFilter(KEY, options)).toBe(KEY); + expect( + effectiveCategoryFilter( + "custom:99999999-9999-4999-8999-999999999999", + options, + ), + ).toBe(ALL_MEDICATION_CATEGORIES); + }); + + it("labels a custom category by its own label and an unresolved key as Other", () => { + expect(getMedicationCategoryLabel(KEY, t, "Travel kit")).toBe("Travel kit"); + expect(getMedicationCategoryLabel(KEY, t, null)).toBe( + "t:medications.categoryOther", + ); + expect(getMedicationCategoryLabel("VITAMIN", t)).toBe( + "t:medications.categoryVitamin", + ); + }); +}); diff --git a/src/lib/medications/__tests__/course-window.test.ts b/src/lib/medications/__tests__/course-window.test.ts new file mode 100644 index 000000000..f85f7ce91 --- /dev/null +++ b/src/lib/medications/__tests__/course-window.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; + +import { + canStartCourse, + courseFromWindow, + courseStatusOn, + dateOfDayKey, + isInsideCourses, + previousCourseEndedOn, + projectCourseWindow, + validateCourses, + type CourseSpan, +} from "@/lib/medications/course-window"; + +const d = dateOfDayKey; +const course = (start: string, end: string | null): CourseSpan => ({ + startsOn: d(start), + endsOn: end ? d(end) : null, +}); + +const MARCH = course("2026-03-01", "2026-03-07"); +const JUNE = course("2026-06-10", "2026-06-16"); + +describe("projectCourseWindow", () => { + it("is the latest course, whatever the input order", () => { + expect(projectCourseWindow([JUNE, MARCH])).toEqual({ + startsOn: d("2026-06-10"), + endsOn: d("2026-06-16"), + }); + }); + + it("is empty with no course (chronic since creation)", () => { + expect(projectCourseWindow([])).toEqual({ startsOn: null, endsOn: null }); + }); + + it.each([ + ["before the first course", "2026-02-20"], + ["inside the first course", "2026-03-03"], + ["between the courses", "2026-04-01"], + ["inside the latest course", "2026-06-12"], + ["after the latest course", "2026-08-01"], + ])("matches the course a reader of today would pick, %s", (_label, today) => { + // The written rule: the course containing today, else the next upcoming, + // else the most recent ended. Under the invariant that is the latest. + const courses = [MARCH, JUNE]; + if (validateCourses(courses, today, false) !== null) return; + const containing = courses.find( + (c) => courseStatusOn(c, today) === "CURRENT", + ); + const upcoming = courses.find( + (c) => courseStatusOn(c, today) === "UPCOMING", + ); + const picked = containing ?? upcoming ?? courses[courses.length - 1]; + expect(projectCourseWindow(courses).startsOn).toEqual(picked.startsOn); + }); +}); + +describe("validateCourses", () => { + it("accepts adjacent courses and refuses a shared day", () => { + expect( + validateCourses( + [MARCH, course("2026-03-08", "2026-03-10")], + "2026-04-01", + false, + ), + ).toBeNull(); + expect( + validateCourses( + [MARCH, course("2026-03-07", "2026-03-10")], + "2026-04-01", + false, + ), + ).toBe("overlap"); + }); + + it("refuses an open course that is not the latest", () => { + expect( + validateCourses([course("2026-01-01", null), JUNE], "2026-07-01", false), + ).toBe("overlap"); + }); + + it("refuses a second course queued behind one that has not ended", () => { + // Today is inside March, and June is queued after it. + expect(validateCourses([MARCH, JUNE], "2026-03-03", false)).toBe( + "currentOpen", + ); + expect(validateCourses([MARCH, JUNE], "2026-03-08", false)).toBeNull(); + }); + + it("refuses an end before the start", () => { + expect( + validateCourses( + [course("2026-03-07", "2026-03-01")], + "2026-04-01", + false, + ), + ).toBe("invalidRange"); + }); + + it("refuses a second course on a one-time medication", () => { + expect(validateCourses([MARCH], "2026-04-01", true)).toBeNull(); + expect(validateCourses([MARCH, JUNE], "2026-07-01", true)).toBe("oneShot"); + }); +}); + +describe("courseStatusOn", () => { + it("is inclusive at both ends", () => { + expect(courseStatusOn(MARCH, "2026-02-28")).toBe("UPCOMING"); + expect(courseStatusOn(MARCH, "2026-03-01")).toBe("CURRENT"); + expect(courseStatusOn(MARCH, "2026-03-07")).toBe("CURRENT"); + expect(courseStatusOn(MARCH, "2026-03-08")).toBe("ENDED"); + expect(courseStatusOn(course("2026-03-01", null), "2027-01-01")).toBe( + "CURRENT", + ); + }); +}); + +describe("isInsideCourses", () => { + it("admits the course days and not the gap", () => { + const courses = [MARCH, JUNE]; + expect(isInsideCourses("2026-03-01", courses)).toBe(true); + expect(isInsideCourses("2026-03-07", courses)).toBe(true); + expect(isInsideCourses("2026-03-08", courses)).toBe(false); + expect(isInsideCourses("2026-06-16", courses)).toBe(true); + expect(isInsideCourses("2026-06-17", courses)).toBe(false); + }); + + it("places no restriction without courses", () => { + expect(isInsideCourses("2026-03-08", [])).toBe(true); + }); +}); + +describe("previousCourseEndedOn and canStartCourse", () => { + it("names the end of the latest ended course", () => { + expect(previousCourseEndedOn([MARCH, JUNE], "2026-06-12")).toEqual( + d("2026-03-07"), + ); + expect(previousCourseEndedOn([MARCH, JUNE], "2026-07-01")).toEqual( + d("2026-06-16"), + ); + expect(previousCourseEndedOn([MARCH], "2026-03-03")).toBeNull(); + }); + + it("offers a new course only when every course has ended", () => { + const base = { active: true, oneShot: false, todayKey: "2026-07-01" }; + expect(canStartCourse({ ...base, courses: [MARCH, JUNE] })).toBe(true); + expect( + canStartCourse({ + ...base, + courses: [MARCH, JUNE], + todayKey: "2026-06-12", + }), + ).toBe(false); + // Chronic (no course) is running; a one-time or paused medication never. + expect(canStartCourse({ ...base, courses: [] })).toBe(false); + expect(canStartCourse({ ...base, oneShot: true, courses: [MARCH] })).toBe( + false, + ); + expect(canStartCourse({ ...base, active: false, courses: [MARCH] })).toBe( + false, + ); + }); +}); + +describe("courseFromWindow (the backfill and restore rule)", () => { + it("covers every window shape", () => { + expect( + courseFromWindow(d("2026-03-01"), d("2026-03-07"), "2026-02-10"), + ).toEqual(course("2026-03-01", "2026-03-07")); + expect(courseFromWindow(d("2026-03-01"), null, "2026-02-10")).toEqual( + course("2026-03-01", null), + ); + // Only an end: from the creation day on the person's clock. + expect(courseFromWindow(null, d("2026-04-01"), "2026-02-11")).toEqual( + course("2026-02-11", "2026-04-01"), + ); + // A backdated end before the creation: from the end day. + expect(courseFromWindow(null, d("2026-01-15"), "2026-02-11")).toEqual( + course("2026-01-15", "2026-01-15"), + ); + // Start after end: a one-day course on the end day, still ended. + expect( + courseFromWindow(d("2026-05-10"), d("2026-05-01"), "2026-02-11"), + ).toEqual(course("2026-05-01", "2026-05-01")); + expect(courseFromWindow(null, null, "2026-02-11")).toBeNull(); + }); +}); diff --git a/src/lib/medications/category-filter.ts b/src/lib/medications/category-filter.ts new file mode 100644 index 000000000..67d1d08b8 --- /dev/null +++ b/src/lib/medications/category-filter.ts @@ -0,0 +1,66 @@ +/** + * v1.40 (#1041) — the category filter on the medications list. Pure, so the + * page and its tests share one definition of what the filter offers and what + * it keeps. + */ +import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; + +type Translator = ( + key: string, + params?: Record, +) => string; + +/** The "no filter" sentinel, the value `FilterBarSelect` resets to. */ +export const ALL_MEDICATION_CATEGORIES = "ALL"; + +interface CategorisedMedication { + category: string; + categoryLabel?: string | null; +} + +/** + * The categories the given medications use, each once, labelled the way the + * card badge labels it, sorted by label. The filter offers only these, so a + * pick can never empty the list. + */ +export function medicationCategoryOptions( + medications: readonly CategorisedMedication[], + t: Translator, +): { value: string; label: string }[] { + const byValue = new Map(); + for (const m of medications) { + if (!byValue.has(m.category)) { + byValue.set( + m.category, + getMedicationCategoryLabel(m.category, t, m.categoryLabel), + ); + } + } + return [...byValue.entries()] + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +/** + * The filter value that actually applies: a stored pick whose category no + * medication uses any more (deleted, or every medication moved) falls back to + * All rather than showing an empty list. + */ +export function effectiveCategoryFilter( + stored: string, + options: readonly { value: string }[], +): string { + return options.some((option) => option.value === stored) + ? stored + : ALL_MEDICATION_CATEGORIES; +} + +/** Keep the medications the filter admits, in their order. */ +export function filterMedicationsByCategory( + medications: readonly T[], + filter: string, +): T[] { + return filter === ALL_MEDICATION_CATEGORIES + ? [...medications] + : medications.filter((m) => m.category === filter); +} diff --git a/src/lib/medications/category-label.ts b/src/lib/medications/category-label.ts index 087a2f188..847a7ffe1 100644 --- a/src/lib/medications/category-label.ts +++ b/src/lib/medications/category-label.ts @@ -38,10 +38,18 @@ const MEDICATION_CATEGORY_KEYS: Record = { OTHER: "medications.categoryOther", }; +/** + * v1.40 (#1041) — a category the person named themselves arrives as + * `custom:` with its decrypted label beside it (`categoryLabel` on the + * medication wire); that label wins. A custom key without a label (the server + * resolved it to nothing) reads as Other, like any unknown value. + */ export function getMedicationCategoryLabel( category: string, t: Translator, + customLabel?: string | null, ): string { + if (customLabel) return customLabel; const key = MEDICATION_CATEGORY_KEYS[category] ?? "medications.categoryOther"; return t(key); } diff --git a/src/lib/medications/course-window.ts b/src/lib/medications/course-window.ts new file mode 100644 index 000000000..97f3fc997 --- /dev/null +++ b/src/lib/medications/course-window.ts @@ -0,0 +1,188 @@ +/** + * v1.40 (#1024) — several courses per medication: the pure rules. + * + * A course is a calendar span `[startsOn, endsOn]` (both inclusive, calendar + * days on the person's clock, `endsOn` null = open). A medication with no + * course rows is "chronic since creation", as before this release; one with + * course rows keeps `Medication.startsOn/endsOn` as a projection of ONE of + * them, which every engine that predates courses (reminders, the next-due + * line, `intakeActionable`) keeps reading unchanged. + * + * The invariant that keeps the projection time-independent: courses never + * overlap, and every course except the latest-starting one has ended before + * today. Under it the course containing today, else the next upcoming one, + * else the most recently ended one is always simply the latest course, so a + * projection written at the last course write stays correct as days pass. A + * write that would break the invariant is refused (`validateCourses`). + * + * Pure: no database, no clock (callers pass `today`). + */ +import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; + +export interface CourseSpan { + startsOn: Date; + endsOn: Date | null; +} + +export type CourseStatus = "UPCOMING" | "CURRENT" | "ENDED"; + +/** `YYYY-MM-DD` of a `@db.Date` value. */ +export const dayKeyOfDate = dateOnlyKey; + +/** A `YYYY-MM-DD` key as the `@db.Date` value Prisma writes. */ +export const dateOfDayKey = dayKeyAsUtcMidnight; + +/** Courses ascending by start. */ +export function sortCourses(courses: readonly T[]): T[] { + return [...courses].sort( + (a, b) => a.startsOn.getTime() - b.startsOn.getTime(), + ); +} + +/** + * The window `Medication.startsOn/endsOn` carries: the latest course, or + * nothing at all when the medication has no course (chronic since creation). + */ +export function projectCourseWindow(courses: readonly CourseSpan[]): { + startsOn: Date | null; + endsOn: Date | null; +} { + if (courses.length === 0) return { startsOn: null, endsOn: null }; + const latest = sortCourses(courses)[courses.length - 1]; + return { startsOn: latest.startsOn, endsOn: latest.endsOn }; +} + +/** Where `todayKey` sits in one course. Both ends inclusive. */ +export function courseStatusOn( + course: CourseSpan, + todayKey: string, +): CourseStatus { + if (todayKey < dayKeyOfDate(course.startsOn)) return "UPCOMING"; + if (course.endsOn && todayKey > dayKeyOfDate(course.endsOn)) return "ENDED"; + return "CURRENT"; +} + +export type CourseRefusal = + /** `endsOn` before `startsOn`. */ + | "invalidRange" + /** Two courses share at least one day. */ + | "overlap" + /** A course other than the latest has not ended yet. */ + | "currentOpen" + /** A one-time medication has more than one course. */ + | "oneShot"; + +/** + * Whether a course set may be stored. Adjacent courses (one ends the day + * before the next starts) are fine; sharing a day is an overlap. + */ +export function validateCourses( + courses: readonly CourseSpan[], + todayKey: string, + oneShot: boolean, +): CourseRefusal | null { + for (const c of courses) { + if (c.endsOn && c.endsOn.getTime() < c.startsOn.getTime()) { + return "invalidRange"; + } + } + if (oneShot && courses.length > 1) return "oneShot"; + const sorted = sortCourses(courses); + for (let i = 1; i < sorted.length; i += 1) { + const previous = sorted[i - 1]; + if ( + previous.endsOn === null || + previous.endsOn.getTime() >= sorted[i].startsOn.getTime() + ) { + return "overlap"; + } + } + for (let i = 0; i < sorted.length - 1; i += 1) { + if (courseStatusOn(sorted[i], todayKey) !== "ENDED") return "currentOpen"; + } + return null; +} + +/** + * Whether an instant's local day (`dayKey`) falls inside any course. No + * courses means no restriction (chronic since creation). + */ +export function isInsideCourses( + dayKey: string, + courses: readonly CourseSpan[], +): boolean { + if (courses.length === 0) return true; + return courses.some( + (c) => + dayKey >= dayKeyOfDate(c.startsOn) && + (c.endsOn === null || dayKey <= dayKeyOfDate(c.endsOn)), + ); +} + +/** + * The end of the latest course that has ended before today, or null. "When + * did I last take this" for a medication that is not running now. + */ +export function previousCourseEndedOn( + courses: readonly CourseSpan[], + todayKey: string, +): Date | null { + const ended = sortCourses(courses).filter( + (c) => courseStatusOn(c, todayKey) === "ENDED", + ); + return ended.length === 0 ? null : ended[ended.length - 1].endsOn; +} + +/** + * Whether a new course may start: the medication is active and not a + * one-time one, it has at least one course, and every course has ended. A + * medication with no course is chronic and running, so it ends its course + * first ("End course" writes one). + */ +export function canStartCourse(args: { + active: boolean; + oneShot: boolean; + courses: readonly CourseSpan[]; + todayKey: string; +}): boolean { + if (!args.active || args.oneShot || args.courses.length === 0) return false; + return args.courses.every( + (c) => courseStatusOn(c, args.todayKey) === "ENDED", + ); +} + +/** + * The one course a medication's own window describes, for a medication that + * has no course rows (the migration's backfill, a restore of a file written + * before courses, the first course write on such a row). The same rules as + * the 0368 backfill: + * + * - start and end in order: that window; + * - only a start: open from that day; + * - only an end: from the creation day on the person's clock (`createdKey`), + * or from the end day when that lies earlier; + * - start after end (refused by the API, but older rows exist): every + * reader treated it as empty and ended, so a one-day course on the end + * day, which stays ended and invents no stretch of expected doses; + * - neither: no course (chronic since creation). + */ +export function courseFromWindow( + startsOn: Date | null, + endsOn: Date | null, + createdKey: string, +): CourseSpan | null { + if (startsOn && endsOn) { + return startsOn.getTime() > endsOn.getTime() + ? { startsOn: endsOn, endsOn } + : { startsOn, endsOn }; + } + if (startsOn) return { startsOn, endsOn: null }; + if (endsOn) { + const endKey = dayKeyOfDate(endsOn); + return { + startsOn: dateOfDayKey(endKey < createdKey ? endKey : createdKey), + endsOn, + }; + } + return null; +} diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts new file mode 100644 index 000000000..760c5baee --- /dev/null +++ b/src/lib/medications/courses.ts @@ -0,0 +1,534 @@ +/** + * v1.40 (#1024) — the one writer of a medication's courses and of the window + * the medication row projects from them. + * + * Every write runs in one transaction under a per-medication advisory lock: + * load the courses, apply the change, refuse a set that breaks the course + * rules (`validateCourses`), write, then rewrite `Medication.startsOn/endsOn` + * from the result (`projectCourseWindow`). Nothing else writes those two + * columns for a medication that has courses, so reminders, the next-due line + * and `intakeActionable` — which read the columns — can never disagree with + * the course list. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { prisma } from "@/lib/db"; +import { + canStartCourse, + courseFromWindow, + courseStatusOn, + dateOfDayKey, + dayKeyOfDate, + previousCourseEndedOn, + projectCourseWindow, + sortCourses, + validateCourses, + type CourseRefusal, + type CourseStatus, +} from "@/lib/medications/course-window"; +import { userDayKey } from "@/lib/tz/format"; + +type Tx = Prisma.TransactionClient; + +export type CourseWriteRefusal = + | CourseRefusal + /** Clearing the window of a medication that has several courses. */ + | "windowRequired" + /** The course or the medication is not the caller's. */ + | "notFound"; + +export class CourseWriteError extends Error { + constructor(readonly refusal: CourseWriteRefusal) { + super(`Course write refused: ${refusal}`); + this.name = "CourseWriteError"; + } +} + +/** The wire `meta.errorCode` and status for each refusal. */ +export function courseRefusalResponse(refusal: CourseWriteRefusal): { + status: number; + errorCode: string; + message: string; +} { + switch (refusal) { + case "notFound": + return { + status: 404, + errorCode: "medications.course.notFound", + message: "Course not found", + }; + case "overlap": + return { + status: 422, + errorCode: "medications.course.overlap", + message: "Courses may not share a day", + }; + case "currentOpen": + return { + status: 422, + errorCode: "medications.course.currentOpen", + message: "End the running course before adding one after it", + }; + case "oneShot": + return { + status: 422, + errorCode: "medications.course.oneShot", + message: "A one-time medication has a single course", + }; + case "invalidRange": + return { + status: 422, + errorCode: "medications.course.invalidRange", + message: "A course cannot end before it starts", + }; + case "windowRequired": + return { + status: 422, + errorCode: "medications.course.windowRequired", + message: + "A medication with several courses keeps a start date; delete a course instead", + }; + } +} + +export interface CourseRow { + id: string; + startsOn: Date; + endsOn: Date | null; + noteEncrypted: Uint8Array | null; +} + +async function lockMedication(tx: Tx, medicationId: string) { + await tx.$queryRaw` + SELECT pg_advisory_xact_lock( + hashtext('medication-course'), + hashtext(${medicationId}) + )::text AS locked + `; +} + +async function loadOwned(tx: Tx, userId: string, medicationId: string) { + const medication = await tx.medication.findFirst({ + where: { id: medicationId, userId }, + select: { + id: true, + oneShot: true, + startsOn: true, + endsOn: true, + createdAt: true, + }, + }); + if (!medication) throw new CourseWriteError("notFound"); + const courses = await tx.medicationCourse.findMany({ + where: { medicationId }, + orderBy: { startsOn: "asc" }, + select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true }, + }); + return { medication, courses }; +} + +type ProjectedWindow = { startsOn: Date | null; endsOn: Date | null }; + +/** Write the medication's window from its courses. The only writer. */ +async function project(tx: Tx, medicationId: string): Promise { + const courses = await tx.medicationCourse.findMany({ + where: { medicationId }, + select: { startsOn: true, endsOn: true }, + }); + const window = projectCourseWindow(courses); + await tx.medication.update({ + where: { id: medicationId }, + data: { startsOn: window.startsOn, endsOn: window.endsOn }, + }); + return window; +} + +function refuseIfInvalid( + courses: { startsOn: Date; endsOn: Date | null }[], + todayKey: string, + oneShot: boolean, +) { + const refusal = validateCourses(courses, todayKey, oneShot); + if (refusal) throw new CourseWriteError(refusal); +} + +function encryptNote(note: string | null | undefined) { + if (note === undefined) return undefined; + return note === null || note.trim() === "" ? null : encryptToBytes(note); +} + +interface WriteContext { + userId: string; + medicationId: string; + timeZone: string; + now?: Date; +} + +export async function createCourse( + args: WriteContext & { + startsOn: Date; + endsOn: Date | null; + note?: string | null; + }, +): Promise { + const todayKey = userDayKey(args.now ?? new Date(), args.timeZone); + return prisma.$transaction(async (tx) => { + await lockMedication(tx, args.medicationId); + const { medication, courses } = await loadOwned( + tx, + args.userId, + args.medicationId, + ); + // A medication without course rows is not empty history. With no window + // at all it has been taken continuously since creation and is running + // now, so a course cannot be added beside it (it ends its course first, + // "End course" writes one). With a window the rows never recorded (a + // file restored past the backfill), that window is its first course and + // is materialised before the new one is checked against it. Either way + // nothing the person already recorded can be cut off by the projection. + let existing: { startsOn: Date; endsOn: Date | null }[] = courses; + if (courses.length === 0) { + if (medication.startsOn === null && medication.endsOn === null) { + throw new CourseWriteError("currentOpen"); + } + const implicit = windowAsCourse( + medication.startsOn, + medication.endsOn, + undefined, + medication.createdAt, + args.timeZone, + ); + refuseIfInvalid( + [implicit, { startsOn: args.startsOn, endsOn: args.endsOn }], + todayKey, + medication.oneShot, + ); + await tx.medicationCourse.create({ + data: { + medicationId: args.medicationId, + userId: args.userId, + ...implicit, + }, + }); + existing = [implicit]; + } + refuseIfInvalid( + [...existing, { startsOn: args.startsOn, endsOn: args.endsOn }], + todayKey, + medication.oneShot, + ); + const created = await tx.medicationCourse.create({ + data: { + medicationId: args.medicationId, + userId: args.userId, + startsOn: args.startsOn, + endsOn: args.endsOn, + noteEncrypted: encryptNote(args.note) ?? null, + }, + select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true }, + }); + await project(tx, args.medicationId); + return created; + }); +} + +export async function updateCourse( + args: WriteContext & { + courseId: string; + startsOn?: Date; + endsOn?: Date | null; + note?: string | null; + }, +): Promise { + const todayKey = userDayKey(args.now ?? new Date(), args.timeZone); + return prisma.$transaction(async (tx) => { + await lockMedication(tx, args.medicationId); + const { medication, courses } = await loadOwned( + tx, + args.userId, + args.medicationId, + ); + const current = courses.find((c) => c.id === args.courseId); + if (!current) throw new CourseWriteError("notFound"); + const next = { + startsOn: args.startsOn ?? current.startsOn, + endsOn: args.endsOn === undefined ? current.endsOn : args.endsOn, + }; + refuseIfInvalid( + courses.map((c) => (c.id === current.id ? next : c)), + todayKey, + medication.oneShot, + ); + const noteEncrypted = encryptNote(args.note); + const updated = await tx.medicationCourse.update({ + where: { id: current.id }, + data: { + startsOn: next.startsOn, + endsOn: next.endsOn, + ...(noteEncrypted !== undefined && { noteEncrypted }), + }, + select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true }, + }); + await project(tx, args.medicationId); + return updated; + }); +} + +/** + * Delete one course. Deleting the only course makes the medication chronic + * again (the projection clears both columns); the UI asks first. + */ +export async function deleteCourse( + args: WriteContext & { courseId: string }, +): Promise { + await prisma.$transaction(async (tx) => { + await lockMedication(tx, args.medicationId); + const { courses } = await loadOwned(tx, args.userId, args.medicationId); + if (!courses.some((c) => c.id === args.courseId)) { + throw new CourseWriteError("notFound"); + } + await tx.medicationCourse.delete({ where: { id: args.courseId } }); + await project(tx, args.medicationId); + }); +} + +/** + * The course a window describes. A window with only an end has always meant + * "running since creation" (or since the end day, when that lies earlier). + */ +function windowAsCourse( + startsOn: Date | null, + endsOn: Date | null, + latest: { startsOn: Date } | undefined, + createdAt: Date, + timeZone: string, +): { startsOn: Date; endsOn: Date | null } { + if (!startsOn && latest) return { startsOn: latest.startsOn, endsOn }; + return ( + courseFromWindow(startsOn, endsOn, userDayKey(createdAt, timeZone)) ?? { + startsOn: dateOfDayKey(userDayKey(createdAt, timeZone)), + endsOn, + } + ); +} + +/** + * What `PUT /api/medications/{id}` does with `startsOn` / `endsOn` since + * v1.40: the window the body sets becomes the latest course (created when + * the medication had none), so a client that edits the window as before (the + * iPhone app's course row, the web wizard, "End course") keeps working. The + * two values are the medication's window after the update, with an absent + * field already resolved to its stored value. + * + * Clearing both on a medication with one course deletes it (chronic again, + * the pre-course meaning of no dates); on one with several it is refused, + * because it would silently drop the history. + */ +export async function setCurrentWindow( + args: WriteContext & { startsOn: Date | null; endsOn: Date | null }, +): Promise { + const todayKey = userDayKey(args.now ?? new Date(), args.timeZone); + return prisma.$transaction(async (tx) => { + await lockMedication(tx, args.medicationId); + const { medication, courses } = await loadOwned( + tx, + args.userId, + args.medicationId, + ); + const latest = sortCourses(courses).at(-1); + + if (args.startsOn === null && args.endsOn === null) { + if (!latest) return project(tx, args.medicationId); + if (courses.length > 1) throw new CourseWriteError("windowRequired"); + await tx.medicationCourse.delete({ where: { id: latest.id } }); + return project(tx, args.medicationId); + } + + const next = windowAsCourse( + args.startsOn, + args.endsOn, + latest, + medication.createdAt, + args.timeZone, + ); + + if (latest) { + refuseIfInvalid( + courses.map((c) => (c.id === latest.id ? next : c)), + todayKey, + medication.oneShot, + ); + await tx.medicationCourse.update({ + where: { id: latest.id }, + data: next, + }); + } else { + refuseIfInvalid([next], todayKey, medication.oneShot); + await tx.medicationCourse.create({ + data: { + medicationId: args.medicationId, + userId: args.userId, + ...next, + }, + }); + } + return project(tx, args.medicationId); + }); +} + +/** + * Whether the window a PUT is about to write would be refused. Checked + * BEFORE the medication row is updated, so a refused window leaves the row + * as it was. Same rules as `setCurrentWindow`. + */ +export async function checkCurrentWindow( + args: WriteContext & { + startsOn: Date | null; + endsOn: Date | null; + oneShot: boolean; + createdAt: Date; + }, +): Promise { + const todayKey = userDayKey(args.now ?? new Date(), args.timeZone); + const courses = await prisma.medicationCourse.findMany({ + where: { medicationId: args.medicationId, userId: args.userId }, + orderBy: { startsOn: "asc" }, + select: { id: true, startsOn: true, endsOn: true }, + }); + const latest = courses.at(-1); + if (args.startsOn === null && args.endsOn === null) { + return courses.length > 1 ? "windowRequired" : null; + } + const next = windowAsCourse( + args.startsOn, + args.endsOn, + latest, + args.createdAt, + args.timeZone, + ); + return validateCourses( + latest ? courses.map((c) => (c.id === latest.id ? next : c)) : [next], + todayKey, + args.oneShot, + ); +} + +// ── The read side ────────────────────────────────────────────────────────── + +export interface MedicationCourseDto { + id: string; + startsOn: string; + endsOn: string | null; + status: CourseStatus; + /** Doses logged as taken whose slot falls on a day of this course. */ + takenDoses: number; + /** The person's note on this course, decrypted. */ + note: string | null; +} + +export interface MedicationCourseFields { + courses: MedicationCourseDto[]; + courseCount: number; + previousCourseEndedOn: string | null; + canStartCourse: boolean; +} + +/** Every course of the given medications, ascending by start. */ +export async function loadCoursesFor( + medicationIds: string[], +): Promise> { + const byMedication = new Map(); + if (medicationIds.length === 0) return byMedication; + const rows = await prisma.medicationCourse.findMany({ + where: { medicationId: { in: medicationIds } }, + orderBy: { startsOn: "asc" }, + select: { + id: true, + medicationId: true, + startsOn: true, + endsOn: true, + noteEncrypted: true, + }, + }); + for (const { medicationId, ...row } of rows) { + const list = byMedication.get(medicationId) ?? []; + list.push(row); + byMedication.set(medicationId, list); + } + return byMedication; +} + +/** + * Taken doses per course: intake rows with a `takenAt`, not tombstoned, + * whose scheduled slot falls on a day of the course on the person's clock. + */ +export async function countTakenDosesPerCourse( + medicationIds: string[], + timeZone: string, +): Promise> { + const counts = new Map(); + if (medicationIds.length === 0) return counts; + const rows = await prisma.$queryRaw>` + SELECT c."id", COUNT(e."id")::int AS taken + FROM "medication_courses" c + JOIN "medication_intake_events" e + ON e."medication_id" = c."medication_id" + AND e."taken_at" IS NOT NULL + AND e."deleted_at" IS NULL + AND (e."scheduled_for" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})::date + BETWEEN c."starts_on" AND COALESCE(c."ends_on", 'infinity'::date) + WHERE c."medication_id" = ANY(${medicationIds}::text[]) + GROUP BY c."id" + `; + for (const row of rows) counts.set(row.id, row.taken); + return counts; +} + +/** The resolved course fields the medication list and detail publish. */ +export function buildCourseFields( + medication: { active: boolean; oneShot: boolean }, + courses: readonly CourseRow[], + takenByCourse: ReadonlyMap, + now: Date, + timeZone: string, +): MedicationCourseFields { + const todayKey = userDayKey(now, timeZone); + const ended = previousCourseEndedOn(courses, todayKey); + return { + courses: courses.map((c) => ({ + id: c.id, + startsOn: dayKeyOfDate(c.startsOn), + endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, + status: courseStatusOn(c, todayKey), + takenDoses: takenByCourse.get(c.id) ?? 0, + note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null, + })), + courseCount: courses.length, + previousCourseEndedOn: ended ? dayKeyOfDate(ended) : null, + canStartCourse: canStartCourse({ + active: medication.active, + oneShot: medication.oneShot, + courses, + todayKey, + }), + }; +} + +/** The read side for a set of medications, in two queries. */ +export async function resolveCourseFields( + medications: readonly { id: string; active: boolean; oneShot: boolean }[], + now: Date, + timeZone: string, +): Promise> { + const ids = medications.map((m) => m.id); + const [coursesBy, taken] = await Promise.all([ + loadCoursesFor(ids), + countTakenDosesPerCourse(ids, timeZone), + ]); + return new Map( + medications.map((m) => [ + m.id, + buildCourseFields(m, coursesBy.get(m.id) ?? [], taken, now, timeZone), + ]), + ); +} diff --git a/src/lib/medications/efficacy/build-efficacy.ts b/src/lib/medications/efficacy/build-efficacy.ts index 483e8a066..2841c24ad 100644 --- a/src/lib/medications/efficacy/build-efficacy.ts +++ b/src/lib/medications/efficacy/build-efficacy.ts @@ -24,6 +24,7 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, buildMedicationComplianceBundle, dailyComplianceRatesFromLedger, lastNonSkippedTakenAt, @@ -420,6 +421,8 @@ export async function buildMedicationEfficacy( schedules: true, scheduleRevisions: { orderBy: { validFrom: "asc" } }, pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, doseChanges: { orderBy: { effectiveFrom: "asc" } }, }, }); diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 6af8d17d0..bfcf6f033 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -14,7 +14,11 @@ */ import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; -import { getMedicationCategories } from "@/lib/medication-category"; +import { resolveCourseFields } from "@/lib/medications/courses"; +import { + resolveMedicationCategories, + type ResolvedMedicationCategory, +} from "@/lib/medication-category"; import { effectiveUnitsPerDose, estimateUnitsRunwayDays, @@ -200,9 +204,11 @@ export async function buildMedicationsList( } const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId)); - let categoryMap: Record = {}; + let categoryMap: Record = {}; try { - categoryMap = await getMedicationCategories(medications.map((m) => m.id)); + categoryMap = await resolveMedicationCategories( + medications.map((m) => m.id), + ); } catch { getEvent()?.addWarning("Medication categories could not be loaded"); } @@ -211,6 +217,8 @@ export async function buildMedicationsList( // the list GET is cached 60 s on userId, so a 60 s staleness window is // accepted here as it already is for `todayEventCount`. const now = new Date(); + // v1.40 (#1024) — every medication's courses, two queries for the list. + const courseFieldsByMed = await resolveCourseFields(medications, now, userTz); return medications.map((m) => { // v1.39.1 (#1033) — intake tracking off: the stored rows are a record, @@ -300,7 +308,14 @@ export async function buildMedicationsList( // v1.39.1 (#1033) — `schedules: []` plus `recordedSchedules` when // intake tracking is off (see `scheduleWireFields`). ...scheduleWireFields(m.trackIntake, schedulesDto), - category: categoryMap[m.id] ?? "OTHER", + category: categoryMap[m.id]?.category ?? "OTHER", + // v1.40 (#1041) — the decrypted label of a custom category; null for + // a built-in one, which every client localises from `category`. + categoryLabel: categoryMap[m.id]?.categoryLabel ?? null, + // v1.40 (#1024) — `courses`, `courseCount`, `previousCourseEndedOn`, + // `canStartCourse`. `startsOn`/`endsOn`/`courseStatus` stay the current + // course, which the row projects. + ...courseFieldsByMed.get(m.id), // v1.32.25 — provenance echo. Surfacing the mirror source lets the // web UI and an operator tell an externally-mirrored row (today only // Apple Health) from a native HealthLog medication. NULL for a native diff --git a/src/lib/medications/scheduling/__tests__/recurrence.test.ts b/src/lib/medications/scheduling/__tests__/recurrence.test.ts index 9a355870c..a2e7fac2f 100644 --- a/src/lib/medications/scheduling/__tests__/recurrence.test.ts +++ b/src/lib/medications/scheduling/__tests__/recurrence.test.ts @@ -55,6 +55,7 @@ function makeSchedule( interface CtxOverrides { timeZone?: string; lastIntakeAt?: Date | null; + rollingAnchor?: RecurrenceContext["rollingAnchor"]; medication?: Partial; } @@ -242,6 +243,76 @@ describe("occurrencesBetween — RRULE MONTHLY / YEARLY", () => { // ──────────────────────────────────────────────────────────────────── describe("occurrencesBetween — rolling", () => { + it("lets the last satisfaction anchor a context that declares no course (a booster)", () => { + // Ten-year cadence, last dose fourteen years ago, anchor date today: the + // dose stays overdue rather than coming due on the anchor date. + const NOW = d("2026-06-10T12:00:00Z"); + const schedule = makeSchedule({ + rollingIntervalDays: 3650, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: d("2012-06-01T06:00:00Z"), + medication: { startsOn: d("2026-06-10T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2000-01-01T00:00:00Z"), + new Date(NOW.getTime() + 24 * 60 * 60 * 1000), + ctx, + ); + expect(slots).toHaveLength(1); + expect(slots[0].at.getTime()).toBeLessThan(NOW.getTime()); + }); + + it("keeps anchoring on an intake when the start was only moved forward a little (v1.40)", () => { + // Weekly. Last dose Jun 8; the start was then moved to Jun 10. The next + // dose stays Jun 15 (last dose + 7), as it was before courses existed. + const NOW = d("2026-06-11T12:00:00Z"); + const schedule = makeSchedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + rollingAnchor: "courseStart", + lastIntakeAt: d("2026-06-08T06:00:00Z"), + medication: { startsOn: d("2026-06-10T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + NOW, + new Date(NOW.getTime() + 14 * 24 * 60 * 60 * 1000), + ctx, + ); + expect(slots.map((o) => o.at.toISOString())).toEqual([ + "2026-06-15T06:00:00.000Z", + ]); + }); + + it("does not let an intake from a previous course anchor a new one (v1.40, #1024)", () => { + // The previous course's last dose was months ago; the new course starts + // in two days. Its first dose is due on its start day, not on + // "last intake + 7" (which lies in the past) or on start + 7. + const NOW = d("2026-06-10T12:00:00Z"); + const schedule = makeSchedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + rollingAnchor: "courseStart", + lastIntakeAt: d("2026-03-05T07:00:00Z"), + medication: { startsOn: d("2026-06-12T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + NOW, + new Date(NOW.getTime() + 14 * 24 * 60 * 60 * 1000), + ctx, + ); + expect(slots).toHaveLength(1); + expect(slots[0].at.toISOString()).toBe("2026-06-12T06:00:00.000Z"); + }); + it("rollingIntervalDays=7 + lastIntakeAt=T-3d emits one at T+4d", () => { const NOW = d("2026-06-10T12:00:00Z"); const lastIntake = new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1000); diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 19d6aec93..635018621 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -27,6 +27,12 @@ * W19e tests pin. */ +import { + isInsideCourses, + sortCourses, + type CourseSpan, +} from "@/lib/medications/course-window"; +import { userDayKey } from "@/lib/tz/format"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import type { SlotBand } from "@/lib/medications/scheduling/attribution"; import { type BandMinterMedication } from "@/lib/medications/scheduling/band-minter"; @@ -134,6 +140,36 @@ export interface CadenceEngineContext { * live THEN. Optional: callers without revisions keep live-only minting. */ scheduleRevisions?: ScheduleRevisionLike[]; + /** + * v1.40 (#1024) — the medication's courses. With two or more, every + * timeline here expands over the span of all of them and keeps only the + * slots on a course day, exactly as the compliance ledger does; fewer, + * and `startsOn`/`endsOn` alone bound it, as before. + */ + courses?: CourseSpan[]; +} + +/** v1.40 (#1024) — the span a timeline expands over (see `courses`). */ +function mintWindow(engineCtx: CadenceEngineContext): { + startsOn: Date | null; + endsOn: Date | null; +} { + const courses = engineCtx.courses; + if (!courses || courses.length < 2) { + return { startsOn: engineCtx.startsOn, endsOn: engineCtx.endsOn }; + } + const sorted = sortCourses(courses); + return { + startsOn: sorted[0].startsOn, + endsOn: sorted[sorted.length - 1].endsOn, + }; +} + +/** v1.40 (#1024) — whether a slot at `at` lies on a course day. */ +function onCourseDay(engineCtx: CadenceEngineContext, at: Date): boolean { + const courses = engineCtx.courses; + if (!courses || courses.length < 2) return true; + return isInsideCourses(userDayKey(at, engineCtx.timeZone), courses); } /** Build a `CanonicalSchedule` from a `ScheduleLike` + its index. */ @@ -161,16 +197,19 @@ function toCanonical( function toRecurrenceContext( engineCtx: CadenceEngineContext, ): RecurrenceContext { + const window = mintWindow(engineCtx); return { medication: { id: "cadence-med", - startsOn: engineCtx.startsOn, - endsOn: engineCtx.endsOn, + startsOn: window.startsOn, + endsOn: window.endsOn, oneShot: engineCtx.oneShot, createdAt: engineCtx.createdAt, }, timeZone: engineCtx.timeZone, lastIntakeAt: engineCtx.lastIntakeAt, + // v1.40 (#1024) — a medication: `startsOn` is a course start. + rollingAnchor: "courseStart", }; } @@ -407,12 +446,14 @@ export function expandScheduleSlots( retro.now, ) : occurrencesBetween(canonical, from, inclusiveTo, recurrenceCtx); - return occurrences.map((occ) => ({ - day: startOfLocalDay(occ.at, timeZone), - windowStart: occ.at, - windowEnd: occ.graceUntil, - scheduleIndex, - })); + return occurrences + .filter((occ) => onCourseDay(engineCtx, occ.at)) + .map((occ) => ({ + day: startOfLocalDay(occ.at, timeZone), + windowStart: occ.at, + windowEnd: occ.graceUntil, + scheduleIndex, + })); } const recurrence = parseScheduleRecurrence(schedule.daysOfWeek); @@ -864,23 +905,26 @@ function missedFromLedger( if (!engineCtx) return null; const from = new Date(asOf.getTime() - windowDays * DAY_MS); const userTz = engineCtx.timeZone || timeZone || "UTC"; + const window = mintWindow(engineCtx); const medication: BandMinterMedication = { id: "missed-tally", - startsOn: engineCtx.startsOn, - endsOn: engineCtx.endsOn, + startsOn: window.startsOn, + endsOn: window.endsOn, oneShot: engineCtx.oneShot, createdAt: engineCtx.createdAt, }; const recurrenceCtx: RecurrenceContext = { medication: { id: "missed-tally", - startsOn: engineCtx.startsOn, - endsOn: engineCtx.endsOn, + startsOn: window.startsOn, + endsOn: window.endsOn, oneShot: engineCtx.oneShot, createdAt: engineCtx.createdAt, }, timeZone: userTz, lastIntakeAt: engineCtx.lastIntakeAt, + // v1.40 (#1024) — a medication: `startsOn` is a course start. + rollingAnchor: "courseStart", }; const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => { const base = toCanonical(s, i); @@ -911,7 +955,10 @@ function missedFromLedger( }); const bands: SlotBand[] = []; for (const g of groups) { - if (g.hasExpectedSlots) bands.push(...g.bands); + // v1.40 (#1024) — only slots on a course day, as the compliance ledger. + if (g.hasExpectedSlots) { + bands.push(...g.bands.filter((b) => onCourseDay(engineCtx, b.at))); + } } const intakes: HistoryIntake[] = events .filter((e) => e.scheduledFor >= from && e.scheduledFor <= asOf) diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index 283c74a8e..8a8157db6 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -187,6 +187,8 @@ function ledgerChipCounts( }, timeZone: userTz, lastIntakeAt: engineCtx.lastIntakeAt, + // v1.40 (#1024) — a medication: `startsOn` is a course start. + rollingAnchor: "courseStart", }; const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => { const base: CanonicalSchedule = { diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index fce3930fb..31f1b8177 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -148,6 +148,17 @@ export interface RecurrenceContext { * resolvers, the dedup pass) must change together. */ lastIntakeAt: Date | null; + /** + * v1.40 (#1024) — `"courseStart"`: `startsOn` is the start of a medication + * course, so an intake whose next rolling dose would fall before it belongs + * to an earlier course and does not anchor; the course's first dose is due + * on its start day. Only the medication context builders set it (a guard + * test enumerates them). Absent, the last intake always anchors, which is + * what a measurement or booster reminder needs: its `startsOn` is only an + * anchor date, and a dose fourteen years old on a ten-year cadence must + * stay overdue. + */ + rollingAnchor?: "courseStart"; } export interface Occurrence { @@ -496,11 +507,27 @@ function rollingDoseAnchor( // N calendar days after the local day of the last intake, not N × 24 h: // a late-evening dose across a DST change would otherwise slide onto the // wrong day. - return ctx.lastIntakeAt !== null - ? addCivilDays( - civilDayOfInstant(ctx.lastIntakeAt, ctx.timeZone), - intervalDays, - ) + const fromIntake = + ctx.lastIntakeAt !== null + ? addCivilDays( + civilDayOfInstant(ctx.lastIntakeAt, ctx.timeZone), + intervalDays, + ) + : null; + // v1.40 (#1024) — an intake whose next dose would fall before the course + // starts belongs to an earlier course (a new course after a gap): it does + // not anchor this one, whose first dose is due on its start day. An intake + // whose next dose falls on or after the start (a start moved forward by a + // few days) anchors exactly as before. + const startDay = + ctx.medication.startsOn !== null + ? civilDayOfDate(ctx.medication.startsOn) + : null; + return fromIntake !== null && + (ctx.rollingAnchor !== "courseStart" || + startDay === null || + fromIntake.getTime() >= startDay.getTime()) + ? fromIntake : anchorCivilDay(ctx); } diff --git a/src/lib/medications/scheduling/worker-helpers.ts b/src/lib/medications/scheduling/worker-helpers.ts index f4f80748a..7d1a45199 100644 --- a/src/lib/medications/scheduling/worker-helpers.ts +++ b/src/lib/medications/scheduling/worker-helpers.ts @@ -154,6 +154,8 @@ export function buildRecurrenceContext(input: { }, timeZone: input.userTz, lastIntakeAt: input.lastIntakeAt, + // v1.40 (#1024) — a medication: `startsOn` is a course start. + rollingAnchor: "courseStart", }; } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index b157a9918..4aeb1fb60 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -249,6 +249,16 @@ export const ERROR_CODE_CATALOGUE: Readonly> = ], medication: ["medication.intake.import.invalid_format"], medications: [ + "medications.category.invalid", + "medications.category.limitReached", + "medications.category.unknown", + "medications.course.currentOpen", + "medications.course.invalid", + "medications.course.invalidRange", + "medications.course.notFound", + "medications.course.oneShot", + "medications.course.overlap", + "medications.course.windowRequired", "medications.intake.bulk.apple_health_not_mirrored", "medications.intake.bulk.invalid", "medications.intake.bulk.too_large", diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index bdfc6ae33..5eaeb8528 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -20,6 +20,10 @@ import { glp1PostBodySchema, bulkDeleteIntakeEventsSchema, updateIntakeEventSchema, + createMedicationCategoryLabelSchema, + updateMedicationCategoryLabelSchema, + createMedicationCourseSchema, + updateMedicationCourseSchema, } from "@/lib/validations/medication"; import { scheduleRevisionCreateSchema, @@ -50,12 +54,32 @@ const setMedicationEfficacyTargetRequest = efficacyTargetOverrideSchema.meta({ "Set or clear the user's explicit efficacy-target override for a medication. `clear:true` removes the override so the resolver reverts to the derived (ATC class prefix → name inference) target; otherwise pin exactly ONE of `measurementType` (a metric series) / `biomarkerId` (a lab analyte). `userId` is never a field — ownership is narrowed through the medication (and the biomarker for a lab target).", }); +const createMedicationCategoryRequest = + createMedicationCategoryLabelSchema.meta({ + id: "CreateMedicationCategoryRequest", + description: + "v1.40 — name a new medication category. The label is trimmed, 1 to 40 characters, and encrypted at rest.", + }); + +const updateMedicationCategoryRequest = + updateMedicationCategoryLabelSchema.meta({ + id: "UpdateMedicationCategoryRequest", + description: + "v1.40 — rename, reorder or hide a custom medication category. At least one field. Hiding (`isActive: false`) removes it from the picker; medications filed under it keep it.", + }); + +const categoryKeyPath = z.object({ + key: z.string().describe("`custom:`, the category's key."), +}); + const medicationEfficacyResponse = medicationEfficacyResponseSchema.meta({ id: "MedicationEfficacyResponse", description: "Server-authoritative, strictly-descriptive efficacy view relating a medication to the outcome metric(s)/lab(s) its class is prescribed to move, around its start. Carries the resolved target(s) with their series, the start/dose-change/pause markers, a before/after-start comparison (honest `{present:false}` below the per-side data floor), an adherence lane (cadence-aware per-day rate, never recomputed), an optional conservative level-shift note, and the retarget options. There is NO verdict / score / assessment field by construction — the client renders numbers and neutral connective phrasing only, never a causal or dose-advice claim.", }); import { + medicationCourseResource, + medicationCategoryLabelResource, medicationListEntry, medicationDetailEntry, medicationInventoryItemResource, @@ -923,7 +947,7 @@ export const medicationPaths: NonNullable = { tags: ["Medications"], summary: "Create a medication with at least one schedule", description: - "Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`. v1.28 — a mirror create (`externalSource` + `externalId`) re-posting a pair the caller already holds returns the existing medication with 200 instead of a duplicate. v1.32.25 — a NEW mirror create is refused with 422 (`meta.errorCode = medications.mirror.limit_exceeded`) once the caller already holds 30 medications mirrored from the same external source; an idempotent re-post of a medication already mirrored is unaffected, and native creates are never gated.", + "Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`. v1.28 — a mirror create (`externalSource` + `externalId`) re-posting a pair the caller already holds returns the existing medication with 200 instead of a duplicate. v1.32.25 — a NEW mirror create is refused with 422 (`meta.errorCode = medications.mirror.limit_exceeded`) once the caller already holds 30 medications mirrored from the same external source; an idempotent re-post of a medication already mirrored is unaffected, and native creates are never gated. v1.40 — `category` may be the key of one of the caller's own categories; any other `custom:` key is 422 `medications.category.unknown`.", requestBody: { required: true, content: { "application/json": { schema: createMedicationSchema } }, @@ -946,6 +970,262 @@ export const medicationPaths: NonNullable = { }, }, }, + "/api/medications/categories": { + get: { + tags: ["Medications"], + summary: "List the record's own medication categories", + description: + "v1.40 (#1041) — every custom category of the record, hidden ones included, in picker order (`sortOrder`, then creation), each with the number of medications filed under it. The built-in categories are not listed; they are the fixed values of `MedicationCategory`. Delegable at READ level over the `medications` section.", + responses: { + ...recordRefusal(), + "200": { + description: "The custom categories.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + categories: z.array(medicationCategoryLabelResource), + }), + "ListMedicationCategoriesResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + parameters: [idempotencyKeyParameter], + tags: ["Medications"], + summary: "Create a custom medication category", + description: + "v1.40 (#1041) — mints a `custom:` key and stores the label encrypted. At most 20 per account, hidden ones included: one more is 422 `medications.category.limitReached`; an invalid body is 422 `medications.category.invalid`. Rate-limited 30/min keyed on the actor. Delegable at MANAGE level over the `medications` section. The label never reaches an audit row.", + requestBody: { + required: true, + content: { + "application/json": { schema: createMedicationCategoryRequest }, + }, + }, + responses: { + ...idempotentWrite(), + ...recordRefusal(), + "201": { + description: "The new category.", + content: { + "application/json": { + schema: dataEnvelope( + medicationCategoryLabelResource, + "CreateMedicationCategoryResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/categories/{key}": { + patch: { + tags: ["Medications"], + summary: "Rename, reorder or hide a custom medication category", + description: + "v1.40 (#1041) — field-by-field update of one of the record's own categories. Another account's key, or a built-in value, is 404. Owner-only: not delegable, refused under a record switch. Rate-limited 30/min.", + requestParams: { path: categoryKeyPath }, + requestBody: { + required: true, + content: { + "application/json": { schema: updateMedicationCategoryRequest }, + }, + }, + responses: { + "200": { + description: "The updated category.", + content: { + "application/json": { + schema: dataEnvelope( + medicationCategoryLabelResource, + "UpdateMedicationCategoryResponse", + ), + }, + }, + }, + "404": { + description: "Not one of the record's own categories.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Delete a custom medication category", + description: + "v1.40 (#1041) — deletes the category and, in the same transaction, moves every medication filed under it to OTHER; `movedCount` says how many moved. No medication is left pointing at a key that no longer exists. Another account's key, or a built-in value, is 404. Owner-only: not delegable, refused under a record switch.", + requestParams: { path: categoryKeyPath }, + responses: { + "200": { + description: "Deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + key: z.string(), + movedCount: z + .number() + .int() + .nonnegative() + .describe("Medications moved to OTHER."), + }), + "DeleteMedicationCategoryResponse", + ), + }, + }, + }, + "404": { + description: "Not one of the record's own categories.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/courses": { + get: { + tags: ["Medications"], + summary: "Read a medication's courses", + description: + "v1.40 (#1024) — the same resolved course fields the list and detail reads carry. Owner-only.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "The courses.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + courses: z.array(medicationCourseResource), + courseCount: z.number().int(), + previousCourseEndedOn: z.iso.date().nullable(), + canStartCourse: z.boolean(), + }), + "MedicationCoursesResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or another user's).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + post: { + parameters: [idempotencyKeyParameter], + tags: ["Medications"], + summary: "Start a course", + description: + "v1.40 (#1024) — adds a course and, in the same transaction, rewrites the medication's `startsOn`/`endsOn` to the latest course, so reminders and `intakeActionable` follow with no further write. Refusals: 422 `medications.course.overlap`, `medications.course.currentOpen`, `medications.course.oneShot`, `medications.course.invalidRange`, `medications.course.invalid` (body). Owner-only.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: createMedicationCourseSchema }, + }, + }, + responses: { + ...idempotentWrite(), + "201": { + description: "The new course.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + id: z.string(), + startsOn: z.iso.date(), + endsOn: z.iso.date().nullable(), + }), + "CreateMedicationCourseResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or another user's).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/courses/{courseId}": { + patch: { + tags: ["Medications"], + summary: "Edit a course", + description: + "v1.40 (#1024) — field by field; the medication's window is re-projected in the same transaction. Same refusals as the create. Owner-only.", + requestParams: { + path: z.object({ id: z.string(), courseId: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: updateMedicationCourseSchema }, + }, + }, + responses: { + "200": { + description: "The edited course.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + id: z.string(), + startsOn: z.iso.date(), + endsOn: z.iso.date().nullable(), + }), + "UpdateMedicationCourseResponse", + ), + }, + }, + }, + "404": { + description: + "Course not found (`medications.course.notFound`), or another user's.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Delete a course", + description: + "v1.40 (#1024) — deletes the course and re-projects the medication's window. Deleting the only course makes the medication continuous again (both window columns cleared). Intakes are not touched. Owner-only.", + requestParams: { + path: z.object({ id: z.string(), courseId: z.string() }), + }, + responses: { + "200": { + description: "Deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ id: z.string(), deleted: z.literal(true) }), + "DeleteMedicationCourseResponse", + ), + }, + }, + }, + "404": { + description: + "Course not found (`medications.course.notFound`), or another user's.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/medications/layout": { get: { tags: ["Medications"], @@ -1055,7 +1335,7 @@ export const medicationPaths: NonNullable = { tags: ["Medications"], summary: "Replace a medication (partial fields)", description: - "Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST /api/medications`. Audits as `medication.update`.", + "Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST /api/medications`. Audits as `medication.update`. v1.40 — an absent `category` leaves the stored one untouched (send it only when the person changed it); a `custom:` key that is not the caller's is 422 `medications.category.unknown`. v1.40 (#1024) — `startsOn`/`endsOn` now edit the latest course (created when there is none); the same course refusals apply (422 `medications.course.*`), and clearing both on a medication with several courses is 422 `medications.course.windowRequired`.", requestParams: { path: z.object({ id: z.string() }), }, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 957de5197..4c54d773f 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -33,12 +33,103 @@ import { MEDICATION_IMPORT_SKIP_REASONS } from "@/lib/jobs/medication-intake-imp // schema description AND enforced by the route + a DB CHECK constraint // so iOS code-gen surfaces the mutual exclusion. -export const medicationCategoryEnum = z.enum(MEDICATION_CATEGORY_VALUES).meta({ +// v1.40 (#1041) — widened from the built-in enum to a string: a category is +// a built-in value or the `custom:` key of one of the caller's own +// categories. The component id stays `MedicationCategory` so a generated +// client keeps its type name; a client decodes unknown values tolerantly and +// shows `categoryLabel` when present. +export const medicationCategoryEnum = z.string().meta({ id: "MedicationCategory", - description: - "Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`.", + description: `Clinical category. One of the built-in values (${MEDICATION_CATEGORY_VALUES.join(", ")}) or, since v1.40, \`custom:\`, the key of one of the caller's own categories (see \`GET /api/medications/categories\`), whose label rides beside it as \`categoryLabel\`. A client must decode an unknown value tolerantly and must not write \`category\` back unless the person changed it. Orthogonal to \`MedicationTreatmentClass\`.`, }); +const medicationCategoryLabelField = z + .string() + .nullable() + .describe( + "v1.40 — the decrypted label of a custom category (`category` = `custom:`); null for a built-in category, which a client localises from `category`. Read-only.", + ); + +export const medicationCourseResource = z + .object({ + id: z.string(), + startsOn: z.iso + .date() + .describe("First day of the course, inclusive (`YYYY-MM-DD`)."), + endsOn: z.iso + .date() + .nullable() + .describe("Last day, inclusive. NULL = open (only the latest course)."), + status: z + .enum(["UPCOMING", "CURRENT", "ENDED"]) + .describe("Where today, on the person's clock, sits in this course."), + takenDoses: z + .number() + .int() + .nonnegative() + .describe( + "Doses logged as taken whose slot falls on a day of this course.", + ), + note: z + .string() + .nullable() + .describe("The person's note on this course. Encrypted at rest."), + }) + .meta({ + id: "MedicationCourse", + description: + "v1.40 (#1024) — one course of a medication. Courses never overlap and every course except the latest has ended, so the medication's `startsOn`/`endsOn`/`courseStatus`/`intakeActionable` are always those of the latest course.", + }); + +const medicationCourseFields = { + courses: z + .array(medicationCourseResource) + .describe( + "v1.40 — every course, ascending by start. Empty for a medication taken continuously since creation. Server-resolved; render it, do not recompute it.", + ), + courseCount: z.number().int().nonnegative(), + previousCourseEndedOn: z.iso + .date() + .nullable() + .describe( + "v1.40 — the last day of the most recent course that has ended (answers 'when did I last take it'); null when none has.", + ), + canStartCourse: z + .boolean() + .describe( + "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, not one-time, has a course and every course has ended.", + ), +}; + +export const medicationCategoryLabelResource = z + .object({ + key: z + .string() + .describe( + "`custom:`, the value a medication's `category` carries when it is filed under this category.", + ), + label: z.string().describe("The person's own label, 1 to 40 characters."), + sortOrder: z + .number() + .int() + .describe("Position in the picker and the filter row, ascending."), + isActive: z + .boolean() + .describe( + "false = hidden from the picker. Medications filed under a hidden category keep it.", + ), + medicationCount: z + .number() + .int() + .nonnegative() + .describe("How many of the record's medications are filed under it."), + }) + .meta({ + id: "MedicationCategoryLabel", + description: + "v1.40 (#1041) — a medication category the person named themselves, shown next to the built-in ones. At most 20 per account (hidden ones count).", + }); + export const medicationTreatmentClassEnum = z .enum(MEDICATION_TREATMENT_CLASS_VALUES) .meta({ @@ -250,6 +341,8 @@ export const medicationResource = z export const medicationListEntry = medicationResource .extend({ category: medicationCategoryEnum, + categoryLabel: medicationCategoryLabelField, + ...medicationCourseFields, // v1.32.25 — mirrored-medication provenance echoed on the list read so // the web UI and an operator can tell a mirror row from a native one. externalSource: z @@ -315,6 +408,8 @@ export const medicationListEntry = medicationResource export const medicationDetailEntry = medicationResource .extend({ category: medicationCategoryEnum, + categoryLabel: medicationCategoryLabelField, + ...medicationCourseFields, courseStatus: z .enum(["UPCOMING", "CURRENT", "ENDED"]) .describe( diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index b7f5b2f1a..9b8ff41aa 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -52,6 +52,13 @@ export const medicationKeys = { * layout PUT's response. */ medicationListLayout: () => ["medication-list-layout"] as const, + /** + * v1.40 (#1041) — the record's own medication categories + * (`GET /api/medications/categories`). Under the `["medications"]` prefix: + * a delete moves medications to Other, so the list must refetch with it, + * and a medication write changes the per-category counts. + */ + medicationCategories: () => ["medications", "categories"] as const, medicationCadence: (medicationId: string) => ["medications", medicationId, "cadence"] as const, medicationGlp1Details: (medicationId: string) => diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index 66781e05b..15360ca9b 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -237,6 +237,10 @@ export function recordActivityVerbLine( case "medication.update": return t("recordSharing.activityVerb.medicationUpdate", { name }); + case "medication.category.custom.create": + return t("recordSharing.activityVerb.medicationCategoryCreate", { + name, + }); case "medication.delete": return t("recordSharing.activityVerb.medicationDelete", { name }); case "medication.glp1.update": diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index f59b14b2b..5e7d3f3b2 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -12,7 +12,10 @@ */ import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; -import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; +import { + COURSES_COMPLIANCE_SELECT, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import pLimit from "p-limit"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -186,6 +189,8 @@ export async function buildTargetsResponse(user: AuthedUser) { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, scheduleRevisions: { orderBy: { validFrom: "asc" } }, pauseEras: { select: { pausedAt: true, resumedAt: true } }, + // v1.40 (#1024) — the courses, so a gap between two expects nothing. + courses: COURSES_COMPLIANCE_SELECT, }, orderBy: { name: "asc" }, }); diff --git a/src/lib/targets/types.ts b/src/lib/targets/types.ts index 475fd1e20..a92e3a1db 100644 --- a/src/lib/targets/types.ts +++ b/src/lib/targets/types.ts @@ -68,6 +68,8 @@ export interface TargetMedication { schedules: ComplianceSchedule[]; scheduleRevisions?: ScheduleRevisionLike[]; pauseEras?: MedicationPauseEraLike[]; + /** v1.40 (#1024) — the courses, so a gap between two expects nothing. */ + courses?: { startsOn: Date; endsOn: Date | null }[]; } export interface TargetIntakeEvent extends IntakeEvent { diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 166373083..8a9af3f62 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -212,6 +212,20 @@ const medicationPauseEraSchema = z }) .passthrough(); +/** + * v1.40 (#1024) — one course of a medication: a calendar span (both ends + * inclusive, `YYYY-MM-DD`). The note travels decrypted in both purposes and + * is sealed again on restore, like the custom category label. + */ +const medicationCourseSchema = z + .object({ + startsOn: z.iso.date(), + endsOn: z.iso.date().nullable(), + note: z.string().max(280).nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + /** * One step of a titration: when the dose moved and to what. * @@ -333,6 +347,22 @@ const medicationEfficacyTargetSchema = z }) .passthrough(); +/** + * v1.40 (#1041) — a medication category the account named itself. Carried + * with its decrypted label (the label is re-encrypted on restore under the + * restoring instance's key) and restored BEFORE the medications, whose + * `category` names it by `key`. + */ +const customMedicationCategorySchema = z + .object({ + key: z.string().regex(/^custom:[0-9a-f-]{36}$/), + label: z.string().trim().min(1).max(40), + sortOrder: z.number().int().min(0).default(0), + isActive: z.boolean().default(true), + createdAt: isoDateTime.optional(), + }) + .passthrough(); + const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -361,7 +391,8 @@ const medicationSchema = z // v1.39.4 — the clinical category. Absent in older files. A string, not // the enum: a file from a newer server may name a category this one does // not know, and the restore reads that as OTHER rather than refusing the - // whole file. + // whole file. Since v1.40 it may be `custom:`, the key of an entry + // in `customMedicationCategories`. category: z.string().max(64).optional(), deliveryForm: z.enum(MedicationDeliveryForm).optional(), trackInjectionSites: z.boolean().optional(), @@ -396,6 +427,10 @@ const medicationSchema = z // Same default again. A drug whose plan has never been replaced has no // archived era, and a file written before the eras travelled has no key. scheduleRevisions: z.array(medicationScheduleRevisionSchema).default([]), + // v1.40 (#1024) — NOT defaulted: absent means a file written before + // courses existed, and the restore then derives the one course the + // medication's own window describes, exactly as the migration did. + courses: z.array(medicationCourseSchema).optional(), }) .passthrough(); @@ -1872,6 +1907,10 @@ export const backupPayloadSchema = z accountSettings: accountSettingsBackupSchema.nullable().default(null), measurements: z.array(measurementSchema).default([]), medications: z.array(medicationSchema).default([]), + // v1.40 — absent in older files, which carry no custom category either. + customMedicationCategories: z + .array(customMedicationCategorySchema) + .default([]), intakeEvents: z.array(intakeEventSchema).default([]), moodEntries: z.array(moodEntrySchema).default([]), // v1.15.0 — cycle-tracking tables. Default to empty arrays / null so a diff --git a/src/lib/validations/medication/base.ts b/src/lib/validations/medication/base.ts index fdd1ff527..6590fad67 100644 --- a/src/lib/validations/medication/base.ts +++ b/src/lib/validations/medication/base.ts @@ -63,8 +63,8 @@ export const rxNormCodeField = z "Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. NULL clears it; absent leaves it untouched.", ); /** - * Clinical-category values stored in the `medication_categories` side- - * table (TEXT column). v1.5.4 adds `DIABETES` and `ANTIBIOTIC` so the + * Clinical-category values stored in the `medication_categories` table + * (TEXT column, the `MedicationCategoryAssignment` model). v1.5.4 adds `DIABETES` and `ANTIBIOTIC` so the * wizard's Step 2 taxonomy can write a first-class bucket for those * two rows instead of collapsing them into `OTHER`. The column is a * plain TEXT field — no Prisma enum exists to migrate; the Zod values @@ -91,6 +91,63 @@ export const MEDICATION_CATEGORY_VALUES = [ export type MedicationCategoryValue = (typeof MEDICATION_CATEGORY_VALUES)[number]; +/** + * v1.40 (#1041) — a category the person named themselves is addressed by the + * key of its `MedicationCategoryLabel` row, `custom:`. The `category` + * field on the medication wire is a built-in value or such a key. + */ +export const CUSTOM_MEDICATION_CATEGORY_PREFIX = "custom:"; + +/** Per-account ceiling on custom medication categories (hidden ones count). */ +export const MAX_CUSTOM_MEDICATION_CATEGORIES = 20; + +const CUSTOM_MEDICATION_CATEGORY_KEY = /^custom:[0-9a-f-]{36}$/; + +/** Is `value` a custom-category key rather than a built-in value? */ +export function isCustomMedicationCategoryKey(value: string): boolean { + return value.startsWith(CUSTOM_MEDICATION_CATEGORY_PREFIX); +} + +/** + * The `category` a create or update body may carry: a built-in value or a + * well-formed custom key. Ownership of a custom key is checked by the route + * (422 `medications.category.unknown`), not here. + */ +export const medicationCategoryInputSchema = z + .union([ + z.enum(MEDICATION_CATEGORY_VALUES), + z.string().regex(CUSTOM_MEDICATION_CATEGORY_KEY, "Unknown category"), + ]) + .describe( + "A built-in clinical category (BLOOD_PRESSURE, VITAMIN, SUPPLEMENT, PAIN_RELIEF, ALLERGY, DIGESTIVE, THYROID, HORMONE, SKIN, SLEEP_AID, DIABETES, ANTIBIOTIC, MENTAL_HEALTH, OTHER) or `custom:`, the key of one of the caller's own categories.", + ); + +const customCategoryLabelSchema = z + .string() + .trim() + .min(1, "Label must not be empty") + .max(40, "Label must be at most 40 characters"); + +/** `POST /api/medications/categories` body. */ +export const createMedicationCategoryLabelSchema = z.object({ + label: customCategoryLabelSchema, +}); + +/** `PATCH /api/medications/categories/{key}` body: at least one field. */ +export const updateMedicationCategoryLabelSchema = z + .object({ + label: customCategoryLabelSchema.optional(), + sortOrder: z.number().int().min(0).max(1000).optional(), + isActive: z.boolean().optional(), + }) + .refine( + (v) => + v.label !== undefined || + v.sortOrder !== undefined || + v.isActive !== undefined, + "At least one field is required", + ); + /** * v1.4.25 W4d — Prisma-level treatment class. Orthogonal to * `MEDICATION_CATEGORY_VALUES` (the clinical taxonomy that lives in the diff --git a/src/lib/validations/medication/course.ts b/src/lib/validations/medication/course.ts new file mode 100644 index 000000000..f85cf7b67 --- /dev/null +++ b/src/lib/validations/medication/course.ts @@ -0,0 +1,54 @@ +import { z } from "zod/v4"; + +/** + * v1.40 (#1024) — the bodies of `POST /api/medications/{id}/courses` and + * `PATCH /api/medications/{id}/courses/{courseId}`. A course carries a + * calendar span and an optional note; no schedule or dose of its own. + */ +const courseDate = z.iso.date().transform((s) => new Date(s)); + +const courseNote = z + .string() + .trim() + .max(280, "Note must be at most 280 characters") + .nullable(); + +export const createMedicationCourseSchema = z + .object({ + startsOn: courseDate.describe( + "First day of the course (ISO `YYYY-MM-DD`, inclusive, the person's calendar).", + ), + endsOn: courseDate + .nullable() + .optional() + .describe( + "Last day (inclusive). NULL or absent = open; only the latest course may be open.", + ), + note: courseNote + .optional() + .describe("Why this course, in the person's words. Encrypted at rest."), + }) + .meta({ + id: "CreateMedicationCourseRequest", + description: + "v1.40 — start a course. Refused when it shares a day with another course (422 `medications.course.overlap`), when it would sit after a course that has not ended (422 `medications.course.currentOpen`), on a one-time medication that already has one (422 `medications.course.oneShot`), or when it ends before it starts (422 `medications.course.invalidRange`).", + }); + +export const updateMedicationCourseSchema = z + .object({ + startsOn: courseDate.optional(), + endsOn: courseDate.nullable().optional(), + note: courseNote.optional(), + }) + .refine( + (v) => + v.startsOn !== undefined || + v.endsOn !== undefined || + v.note !== undefined, + "At least one field is required", + ) + .meta({ + id: "UpdateMedicationCourseRequest", + description: + "v1.40 — edit a course field by field. Same refusals as the create.", + }); diff --git a/src/lib/validations/medication/create-update.ts b/src/lib/validations/medication/create-update.ts index b774984ef..7337b34a4 100644 --- a/src/lib/validations/medication/create-update.ts +++ b/src/lib/validations/medication/create-update.ts @@ -1,7 +1,7 @@ import { z } from "zod/v4"; import { - MEDICATION_CATEGORY_VALUES, + medicationCategoryInputSchema, MEDICATION_DELIVERY_FORM_VALUES, MEDICATION_TREATMENT_CLASS_VALUES, atcCodeField, @@ -99,7 +99,7 @@ export const createMedicationSchema = z .object({ name: z.string().min(1).max(100), dose: z.string().min(1).max(50), - category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), + category: medicationCategoryInputSchema.optional(), /** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */ treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), /** v1.4.25 W4d — doses per pen/vial for inventory tracking. @@ -267,7 +267,7 @@ export const updateMedicationSchema = z .object({ name: z.string().min(1).max(100).optional(), dose: z.string().min(1).max(50).optional(), - category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), + category: medicationCategoryInputSchema.optional(), treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(), /** v1.16.10 — inventory units one dose consumes. Default 1. diff --git a/src/lib/validations/medication/index.ts b/src/lib/validations/medication/index.ts index 44c2c4250..e85da9a93 100644 --- a/src/lib/validations/medication/index.ts +++ b/src/lib/validations/medication/index.ts @@ -14,3 +14,4 @@ export * from "./schedule"; export * from "./create-update"; export * from "./intake"; export * from "./inventory"; +export * from "./course"; diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 635a5c4aa..c96600c11 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -681,6 +681,14 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "create", ), + handler( + "app/api/medications/categories/route.ts", + "/api/medications/categories", + "POST", + "medications", + "manage", + "create", + ), handler( "app/api/nutrients/water/route.ts", "/api/nutrients/water", diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 00b56d89a..2911cadfc 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -66,7 +66,7 @@ import { } from "@/lib/withings/ecg-waveform-codec"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { - getMedicationCategories, + resolveMedicationCategories, setMedicationCategory, } from "@/lib/medication-category"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; @@ -188,6 +188,7 @@ const COACH_RESULTS_JSON = JSON.stringify([ chart: { kind: "line", x: "period", series: ["sys"] }, }, ]); +const ROUND_TRIP_CATEGORY_KEY = "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10"; const DOSE_CHANGE_NOTE = "titration note, encrypted at rest"; const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)"; const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose"; @@ -220,6 +221,13 @@ const COUNT_BACK: Record< // No own `userId` column — reached through the drug, like the schedules. MedicationDoseChange: (p, userId) => p.medicationDoseChange.count({ where: { medication: { userId } } }), + // No own `userId` column — reached through the drug, like the schedules. + MedicationCategoryAssignment: (p, userId) => + p.medicationCategoryAssignment.count({ where: { medication: { userId } } }), + MedicationCategoryLabel: (p, userId) => + p.medicationCategoryLabel.count({ where: { userId } }), + MedicationCourse: (p, userId) => + p.medicationCourse.count({ where: { userId } }), MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }), MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }), MoodEntryTagLink: (p, userId) => @@ -408,14 +416,33 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { }, }); + // Two courses with a gap, and the window the row projects from them (the + // latest). The earlier course exists nowhere else once it has ended. const medication = await prisma.medication.create({ data: { userId: OWNER_ID, name: "Round-trip tablet", dose: "5 mg", + startsOn: AT("2026-06-10T00:00:00.000Z"), + endsOn: AT("2026-06-16T00:00:00.000Z"), schedules: { create: { windowStart: "08:00", windowEnd: "09:00", label: "Morning" }, }, + courses: { + create: [ + { + userId: OWNER_ID, + startsOn: AT("2026-03-01T00:00:00.000Z"), + endsOn: AT("2026-03-07T00:00:00.000Z"), + noteEncrypted: encryptToBytes("the March flu"), + }, + { + userId: OWNER_ID, + startsOn: AT("2026-06-10T00:00:00.000Z"), + endsOn: AT("2026-06-16T00:00:00.000Z"), + }, + ], + }, }, }); await prisma.medicationIntakeEvent.create({ @@ -426,6 +453,20 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { takenAt: AT("2026-07-01T08:04:00.000Z"), }, }); + // Not OTHER: a missing row already reads as OTHER, so only a chosen value + // proves the category came back. A category the person named, so the + // assignment can only resolve if the label came back before it. + await prisma.medicationCategoryLabel.create({ + data: { + userId: OWNER_ID, + key: ROUND_TRIP_CATEGORY_KEY, + labelEncrypted: encryptToBytes("Heart and circulation"), + sortOrder: 2, + }, + }); + await prisma.medicationCategoryAssignment.create({ + data: { medicationId: medication.id, category: ROUND_TRIP_CATEGORY_KEY }, + }); // The one row in this fixture that is also checked field by field after the // restore — see the assertion at the end of the test for why. await prisma.medicationSideEffect.create({ @@ -2913,8 +2954,22 @@ describe("every model the plan claims two-ended survives a real restore", () => await prisma.medication.create({ data: { userId: OWNER_ID, name: "Unfiled", dose: "1 tab" }, }); + const travel = await prisma.medication.create({ + data: { userId: OWNER_ID, name: "Tamiflu", dose: "75 mg" }, + }); await setMedicationCategory(thyroid.id, "THYROID"); await setMedicationCategory(mood.id, "MENTAL_HEALTH"); + // v1.40 — a category the person named. Hidden, because a hidden + // category still files the medication and must travel with it. + await prisma.medicationCategoryLabel.create({ + data: { + userId: OWNER_ID, + key: ROUND_TRIP_CATEGORY_KEY, + labelEncrypted: encryptToBytes("Travel kit"), + isActive: false, + }, + }); + await setMedicationCategory(travel.id, ROUND_TRIP_CATEGORY_KEY); const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { purpose, @@ -2928,7 +2983,17 @@ describe("every model the plan claims two-ended survives a real restore", () => Levothyroxine: "THYROID", Sertraline: "MENTAL_HEALTH", Unfiled: "OTHER", + Tamiflu: ROUND_TRIP_CATEGORY_KEY, }); + expect(payload.customMedicationCategories).toEqual([ + { + key: ROUND_TRIP_CATEGORY_KEY, + label: "Travel kit", + sortOrder: 0, + isActive: false, + createdAt: expect.any(String), + }, + ]); await prisma.user.delete({ where: { id: OWNER_ID } }); await createOwner(prisma); @@ -2953,16 +3018,24 @@ describe("every model the plan claims two-ended survives a real restore", () => where: { userId: OWNER_ID }, select: { id: true, name: true }, }); - const categories = await getMedicationCategories( + const categories = await resolveMedicationCategories( restored.map((m) => m.id), ); expect( Object.fromEntries(restored.map((m) => [m.name, categories[m.id]])), ).toEqual({ - Levothyroxine: "THYROID", - Sertraline: "MENTAL_HEALTH", - Unfiled: "OTHER", + Levothyroxine: { category: "THYROID", categoryLabel: null }, + Sertraline: { category: "MENTAL_HEALTH", categoryLabel: null }, + Unfiled: { category: "OTHER", categoryLabel: null }, + Tamiflu: { + category: ROUND_TRIP_CATEGORY_KEY, + categoryLabel: "Travel kit", + }, }); + const label = await prisma.medicationCategoryLabel.findUniqueOrThrow({ + where: { key: ROUND_TRIP_CATEGORY_KEY }, + }); + expect(label.isActive).toBe(false); }, ); diff --git a/tests/integration/medication-courses.test.ts b/tests/integration/medication-courses.test.ts new file mode 100644 index 000000000..dc9682456 --- /dev/null +++ b/tests/integration/medication-courses.test.ts @@ -0,0 +1,486 @@ +/** + * Several courses per medication (#1024) against a real Postgres, through the + * shipped route exports and the shipped migration backfill. + * + * What the file pins: + * - the migration's backfill turns every medication window into one course + * (an end-only window starting on the creation day), leaves a medication + * without a window course-less, and is a no-op on a re-run; + * - after every course write (create, edit, delete) the medication row + * carries the latest course as its window, so reminders and the dose + * actions read the right one; + * - overlapping courses, a course queued behind a running one and a second + * course on a one-time medication are refused, and a refused write + * changes nothing; + * - the medication's own PUT edits the latest course; + * - the list and detail reads publish the resolved course fields. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER = "user-med-courses"; +const D = (key: string) => new Date(`${key}T00:00:00.000Z`); +const key = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : null); + +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), +})); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); + await prisma.user.create({ + data: { + id: OWNER, + username: OWNER, + email: `${OWNER}@example.test`, + timezone: "UTC", + }, + }); + const session = await prisma.session.create({ + data: { userId: OWNER, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function req(method: string, path: string, body?: unknown) { + return new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +async function makeMedication(window: { + startsOn?: string | null; + endsOn?: string | null; + oneShot?: boolean; +}) { + const { POST } = await import("@/app/api/medications/route"); + const res = await POST( + req("POST", "/api/medications", { + name: "Tamiflu", + dose: "75 mg", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + ...window, + }), + ); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(201); + return body.data as { id: string }; +} + +async function postCourse(id: string, body: unknown) { + const { POST } = await import("@/app/api/medications/[id]/courses/route"); + return POST(req("POST", `/api/medications/${id}/courses`, body), { + params: Promise.resolve({ id }), + }); +} + +async function window(id: string) { + const row = await getPrismaClient().medication.findUniqueOrThrow({ + where: { id }, + select: { startsOn: true, endsOn: true }, + }); + return { startsOn: key(row.startsOn), endsOn: key(row.endsOn) }; +} + +async function courses(id: string) { + const rows = await getPrismaClient().medicationCourse.findMany({ + where: { medicationId: id }, + orderBy: { startsOn: "asc" }, + }); + return rows.map((r) => [key(r.startsOn), key(r.endsOn)]); +} + +describe("migration 0368 backfill", () => { + const SQL = readFileSync( + join( + process.cwd(), + "prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql", + ), + "utf8", + ); + const backfill = SQL.slice(SQL.indexOf('INSERT INTO "medication_courses"')); + + it("makes one course of every window and none of no window, idempotently", async () => { + const prisma = getPrismaClient(); + const created = new Date("2026-02-10T09:00:00.000Z"); + const base = { userId: OWNER, dose: "1", createdAt: created }; + const both = await prisma.medication.create({ + data: { + ...base, + name: "both", + startsOn: D("2026-03-01"), + endsOn: D("2026-03-07"), + }, + }); + const startOnly = await prisma.medication.create({ + data: { ...base, name: "start", startsOn: D("2026-03-01") }, + }); + const endOnly = await prisma.medication.create({ + data: { ...base, name: "end", endsOn: D("2026-04-01") }, + }); + const backdated = await prisma.medication.create({ + data: { ...base, name: "backdated", endsOn: D("2026-01-15") }, + }); + const chronic = await prisma.medication.create({ + data: { ...base, name: "chronic" }, + }); + + const eligible = await prisma.medication.count({ + where: { OR: [{ startsOn: { not: null } }, { endsOn: { not: null } }] }, + }); + const inserted = await prisma.$executeRawUnsafe(backfill); + expect(inserted).toBe(eligible); + expect(await prisma.$executeRawUnsafe(backfill)).toBe(0); + + expect(await courses(both.id)).toEqual([["2026-03-01", "2026-03-07"]]); + expect(await courses(startOnly.id)).toEqual([["2026-03-01", null]]); + expect(await courses(endOnly.id)).toEqual([["2026-02-10", "2026-04-01"]]); + expect(await courses(backdated.id)).toEqual([["2026-01-15", "2026-01-15"]]); + expect(await courses(chronic.id)).toEqual([]); + // Start after end (an older row): a one-day course on the end day. + // Created in a far-east zone: the creation day is the account's day, + // not the UTC one. An unknown stored zone falls back to the default. + const prismaKiri = getPrismaClient(); + for (const [id, tz] of [ + ["kiri-user", "Pacific/Kiritimati"], + ["mars-user", "Mars/Olympus_Mons"], + ] as const) { + await prismaKiri.user.create({ + data: { id, username: id, email: `${id}@example.test`, timezone: tz }, + }); + } + const late = new Date("2026-02-10T12:00:00.000Z"); // Feb 11 at UTC+14 + const kiri = await prismaKiri.medication.create({ + data: { + userId: "kiri-user", + name: "k", + dose: "1", + createdAt: late, + endsOn: D("2026-04-01"), + }, + }); + const mars = await prismaKiri.medication.create({ + data: { + userId: "mars-user", + name: "m", + dose: "1", + createdAt: new Date("2026-02-10T23:30:00.000Z"), + endsOn: D("2026-04-01"), + }, + }); + const inverted = await prismaKiri.medication.create({ + data: { + ...base, + name: "inverted", + startsOn: D("2026-05-10"), + endsOn: D("2026-05-01"), + }, + }); + await prismaKiri.medicationCourse.deleteMany({}); + await prismaKiri.$executeRawUnsafe(backfill); + expect(await courses(kiri.id)).toEqual([["2026-02-11", "2026-04-01"]]); + // Europe/Berlin: 23:30 UTC on Feb 10 is Feb 11. + expect(await courses(mars.id)).toEqual([["2026-02-11", "2026-04-01"]]); + expect(await courses(inverted.id)).toEqual([["2026-05-01", "2026-05-01"]]); + expect(await prismaKiri.$executeRawUnsafe(backfill)).toBe(0); + + // Data-preserving: the medication columns are untouched. + expect(await window(endOnly.id)).toEqual({ + startsOn: null, + endsOn: "2026-04-01", + }); + }); +}); + +describe("course writes keep the projection", () => { + it("projects the latest course after create, edit and delete", async () => { + const med = await makeMedication({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + }); + expect(await courses(med.id)).toEqual([["2026-03-01", "2026-03-07"]]); + + const created = await postCourse(med.id, { + startsOn: "2026-06-10", + endsOn: "2026-06-16", + note: "flu again", + }); + const createdBody = await created.json(); + expect(created.status, JSON.stringify(createdBody)).toBe(201); + expect(await window(med.id)).toEqual({ + startsOn: "2026-06-10", + endsOn: "2026-06-16", + }); + + const { PATCH, DELETE } = + await import("@/app/api/medications/[id]/courses/[courseId]/route"); + const ctx = { + params: Promise.resolve({ id: med.id, courseId: createdBody.data.id }), + }; + const patched = await PATCH( + req("PATCH", "/x", { endsOn: "2026-06-20" }), + ctx, + ); + expect(patched.status).toBe(200); + expect(await window(med.id)).toEqual({ + startsOn: "2026-06-10", + endsOn: "2026-06-20", + }); + + const deleted = await DELETE(req("DELETE", "/x"), { + params: Promise.resolve({ id: med.id, courseId: createdBody.data.id }), + }); + expect(deleted.status).toBe(200); + expect(await window(med.id)).toEqual({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + }); + + // Deleting the only course makes the medication continuous again. + const [only] = await getPrismaClient().medicationCourse.findMany({ + where: { medicationId: med.id }, + }); + await DELETE(req("DELETE", "/x"), { + params: Promise.resolve({ id: med.id, courseId: only.id }), + }); + expect(await window(med.id)).toEqual({ startsOn: null, endsOn: null }); + }); + + it("refuses an overlap, a queued course and a second one-time course, changing nothing", async () => { + const med = await makeMedication({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + }); + const overlap = await postCourse(med.id, { + startsOn: "2026-03-05", + endsOn: "2026-03-09", + }); + expect(overlap.status).toBe(422); + expect((await overlap.json()).meta.errorCode).toBe( + "medications.course.overlap", + ); + + // A course that has not ended yet cannot have another one queued after + // it, even without sharing a day. + const shift = (days: number) => + new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10); + const running = await makeMedication({ + startsOn: shift(-2), + endsOn: shift(5), + }); + const queued = await postCourse(running.id, { + startsOn: shift(10), + endsOn: shift(12), + }); + expect(queued.status).toBe(422); + expect((await queued.json()).meta.errorCode).toBe( + "medications.course.currentOpen", + ); + + const once = await makeMedication({ + startsOn: "2026-03-01", + oneShot: true, + }); + const second = await postCourse(once.id, { startsOn: "2026-04-01" }); + expect(second.status).toBe(422); + expect((await second.json()).meta.errorCode).toBe( + "medications.course.oneShot", + ); + + expect(await courses(med.id)).toEqual([["2026-03-01", "2026-03-07"]]); + expect(await window(med.id)).toEqual({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + }); + }); + + it("never lets a first course cut off a chronic medication's history", async () => { + // Taken continuously since creation: no window, no course row. A course + // added beside it would project the medication into ending. + const chronic = await makeMedication({}); + const refused = await postCourse(chronic.id, { + startsOn: "2026-03-01", + endsOn: "2026-03-31", + }); + expect(refused.status).toBe(422); + expect((await refused.json()).meta.errorCode).toBe( + "medications.course.currentOpen", + ); + expect(await courses(chronic.id)).toEqual([]); + expect(await window(chronic.id)).toEqual({ startsOn: null, endsOn: null }); + + // A window the rows never recorded (written past the course writer, as + // an older restore could) becomes the first course before the new one. + const legacy = await makeMedication({}); + await getPrismaClient().medication.update({ + where: { id: legacy.id }, + data: { startsOn: D("2026-01-10"), endsOn: D("2026-01-20") }, + }); + const added = await postCourse(legacy.id, { + startsOn: "2026-03-01", + endsOn: "2026-03-05", + }); + expect(added.status).toBe(201); + expect(await courses(legacy.id)).toEqual([ + ["2026-01-10", "2026-01-20"], + ["2026-03-01", "2026-03-05"], + ]); + }); + + it("lists every course's slots in the dose history, none in the gap", async () => { + // Daily 08:00 UTC (the account is on UTC). Course A five days, the third + // missed; a gap; course B five days, all taken. Both ended before today. + const shift = (days: number) => + new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10); + const med = await makeMedication({ + startsOn: shift(-20), + endsOn: shift(-16), + }); + await postCourse(med.id, { startsOn: shift(-10), endsOn: shift(-6) }); + const taken = [-20, -19, -17, -16, -10, -9, -8, -7, -6].map(shift); + await getPrismaClient().medicationIntakeEvent.createMany({ + data: taken.map((day) => ({ + userId: OWNER, + medicationId: med.id, + scheduledFor: new Date(`${day}T08:00:00.000Z`), + takenAt: new Date(`${day}T08:02:00.000Z`), + })), + }); + // The medication existed long before course A. + await getPrismaClient().medication.update({ + where: { id: med.id }, + data: { createdAt: new Date(Date.now() - 40 * 86_400_000) }, + }); + + const { GET } = + await import("@/app/api/medications/[id]/dose-history/route"); + const body = await ( + await GET(req("GET", `/api/medications/${med.id}/dose-history`), { + params: Promise.resolve({ id: med.id }), + }) + ).json(); + const slots = ( + body.data.rows as Array<{ kind: string; at: string; status: string }> + ).filter((r) => r.kind === "slot"); + const days = slots.map((r) => r.at.slice(0, 10)).sort(); + const courseDays = [-20, -19, -18, -17, -16, -10, -9, -8, -7, -6].map( + shift, + ); + expect(days).toEqual(courseDays.sort()); + expect( + slots.filter((r) => r.status === "missed").map((r) => r.at.slice(0, 10)), + ).toEqual([shift(-18)]); + }); + + it("lets the medication's own PUT edit the latest course", async () => { + const med = await makeMedication({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + }); + await postCourse(med.id, { startsOn: "2026-06-10", endsOn: "2026-06-16" }); + const { PUT } = await import("@/app/api/medications/[id]/route"); + const res = await PUT( + req("PUT", `/api/medications/${med.id}`, { endsOn: "2026-06-18" }), + { params: Promise.resolve({ id: med.id }) }, + ); + expect(res.status).toBe(200); + expect(await courses(med.id)).toEqual([ + ["2026-03-01", "2026-03-07"], + ["2026-06-10", "2026-06-18"], + ]); + + // Clearing the window would drop the history: refused, nothing changes. + const cleared = await PUT( + req("PUT", `/api/medications/${med.id}`, { + startsOn: null, + endsOn: null, + }), + { params: Promise.resolve({ id: med.id }) }, + ); + expect(cleared.status).toBe(422); + expect((await cleared.json()).meta.errorCode).toBe( + "medications.course.windowRequired", + ); + expect(await window(med.id)).toEqual({ + startsOn: "2026-06-10", + endsOn: "2026-06-18", + }); + }); + + it("publishes the resolved course fields on the list and the detail", async () => { + const med = await makeMedication({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + }); + await postCourse(med.id, { startsOn: "2026-04-01", endsOn: "2026-04-03" }); + await getPrismaClient().medicationIntakeEvent.create({ + data: { + userId: OWNER, + medicationId: med.id, + scheduledFor: new Date("2026-03-02T08:00:00.000Z"), + takenAt: new Date("2026-03-02T08:03:00.000Z"), + }, + }); + + const { GET } = await import("@/app/api/medications/[id]/route"); + const body = await ( + await GET(req("GET", `/api/medications/${med.id}`), { + params: Promise.resolve({ id: med.id }), + }) + ).json(); + expect(body.data).toMatchObject({ + startsOn: expect.stringContaining("2026-04-01"), + courseCount: 2, + previousCourseEndedOn: "2026-04-03", + canStartCourse: true, + courseStatus: "ENDED", + }); + expect( + body.data.courses.map( + (c: { startsOn: string; status: string; takenDoses: number }) => [ + c.startsOn, + c.status, + c.takenDoses, + ], + ), + ).toEqual([ + ["2026-03-01", "ENDED", 1], + ["2026-04-01", "ENDED", 0], + ]); + + const { GET: list } = await import("@/app/api/medications/route"); + const listBody = await (await list()).json(); + expect(listBody.data[0]).toMatchObject({ + courseCount: 2, + canStartCourse: true, + }); + }); +}); diff --git a/tests/integration/medication-custom-categories.test.ts b/tests/integration/medication-custom-categories.test.ts new file mode 100644 index 000000000..262ea7e75 --- /dev/null +++ b/tests/integration/medication-custom-categories.test.ts @@ -0,0 +1,342 @@ +/** + * Custom medication categories (#1041) against a real Postgres, through the + * shipped route exports. + * + * What the file pins: + * - a category is created, listed with its medication count, renamed and + * hidden through its own routes, and capped at twenty per account; + * - a medication can be filed under one of the caller's own categories and + * the list and detail reads publish the decrypted label beside the key; + * - another account's key is refused (422 `medications.category.unknown`) + * on create and on update, and never resolves on a read; + * - deleting a category moves its medications to OTHER in the same + * transaction, so no medication is left pointing at a key that is gone; + * - a key left behind anyway (written past the routes) reads as OTHER. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER = "user-med-categories"; +const OTHER_USER = "user-med-categories-other"; + +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), +})); + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); + for (const id of [OWNER, OTHER_USER]) { + await prisma.user.create({ + data: { + id, + username: id, + email: `${id}@example.test`, + timezone: "UTC", + }, + }); + } + await signIn(OWNER); +}); + +function req(method: string, path: string, body?: unknown) { + return new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +async function createCategory(label: string): Promise { + const { POST } = await import("@/app/api/medications/categories/route"); + const res = await POST(req("POST", "/api/medications/categories", { label })); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(201); + return body.data.key as string; +} + +async function createMedication(category?: string) { + const { POST } = await import("@/app/api/medications/route"); + return POST( + req("POST", "/api/medications", { + name: "Tamiflu", + dose: "75 mg", + ...(category ? { category } : {}), + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ); +} + +describe("custom medication categories", () => { + it("files a medication under an own category and publishes its label", async () => { + const key = await createCategory(" Travel kit "); + expect(key).toMatch(/^custom:[0-9a-f-]{36}$/); + + const created = await createMedication(key); + const createdBody = await created.json(); + expect(created.status, JSON.stringify(createdBody)).toBe(201); + expect(createdBody.data.category).toBe(key); + expect(createdBody.data.categoryLabel).toBe("Travel kit"); + + const { GET: list } = await import("@/app/api/medications/route"); + const listBody = await (await list()).json(); + expect(listBody.data[0]).toMatchObject({ + category: key, + categoryLabel: "Travel kit", + }); + + const { GET: detail } = await import("@/app/api/medications/[id]/route"); + const detailBody = await ( + await detail(req("GET", `/api/medications/${createdBody.data.id}`), { + params: Promise.resolve({ id: createdBody.data.id }), + }) + ).json(); + expect(detailBody.data).toMatchObject({ + category: key, + categoryLabel: "Travel kit", + }); + + const { GET: categories } = + await import("@/app/api/medications/categories/route"); + const categoriesBody = await (await categories()).json(); + expect(categoriesBody.data.categories).toEqual([ + { + key, + label: "Travel kit", + sortOrder: 0, + isActive: true, + medicationCount: 1, + }, + ]); + + // The label is ciphertext at rest. + const row = + await getPrismaClient().medicationCategoryLabel.findUniqueOrThrow({ + where: { key }, + }); + expect(Buffer.from(row.labelEncrypted).toString("utf8")).not.toContain( + "Travel kit", + ); + }); + + it("refuses another account's key on create and on update", async () => { + await signIn(OTHER_USER); + const foreignKey = await createCategory("Theirs"); + await signIn(OWNER); + + const refused = await createMedication(foreignKey); + const refusedBody = await refused.json(); + expect(refused.status).toBe(422); + expect(refusedBody.meta.errorCode).toBe("medications.category.unknown"); + expect(await getPrismaClient().medication.count()).toBe(0); + + const ok = await createMedication("VITAMIN"); + const okBody = await ok.json(); + const { PUT } = await import("@/app/api/medications/[id]/route"); + const updated = await PUT( + req("PUT", `/api/medications/${okBody.data.id}`, { + category: foreignKey, + }), + { params: Promise.resolve({ id: okBody.data.id }) }, + ); + expect(updated.status).toBe(422); + expect((await updated.json()).meta.errorCode).toBe( + "medications.category.unknown", + ); + const assignment = + await getPrismaClient().medicationCategoryAssignment.findUniqueOrThrow({ + where: { medicationId: okBody.data.id }, + }); + expect(assignment.category).toBe("VITAMIN"); + }); + + it("keeps the category on an update that does not name it", async () => { + const key = await createCategory("Heart"); + const created = await (await createMedication(key)).json(); + const { PUT } = await import("@/app/api/medications/[id]/route"); + const res = await PUT( + req("PUT", `/api/medications/${created.data.id}`, { name: "Renamed" }), + { params: Promise.resolve({ id: created.data.id }) }, + ); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(200); + expect(body.data).toMatchObject({ category: key, categoryLabel: "Heart" }); + }); + + it("keeps a custom category when an older iPhone build sends OTHER back", async () => { + const key = await createCategory("Heart"); + const created = await (await createMedication(key)).json(); + const { hashToken } = await import("@/lib/auth/hmac"); + const raw = "hlk_custom-category-native-0000"; + await getPrismaClient().apiToken.create({ + data: { + userId: OWNER, + name: "native", + tokenHash: hashToken(raw), + permissions: ["*"], + }, + }); + cookieJar.clear(); + headerJar.set("authorization", `Bearer ${raw}`); + const { PUT } = await import("@/app/api/medications/[id]/route"); + const res = await PUT( + req("PUT", `/api/medications/${created.data.id}`, { + name: "Renamed", + category: "OTHER", + }), + { params: Promise.resolve({ id: created.data.id }) }, + ); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(200); + expect(body.data).toMatchObject({ category: key, categoryLabel: "Heart" }); + headerJar.clear(); + await signIn(OWNER); + }); + + it("moves a custom category back to OTHER when the web asks", async () => { + const key = await createCategory("Heart"); + const created = await (await createMedication(key)).json(); + const { PUT } = await import("@/app/api/medications/[id]/route"); + const res = await PUT( + req("PUT", `/api/medications/${created.data.id}`, { category: "OTHER" }), + { params: Promise.resolve({ id: created.data.id }) }, + ); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(200); + expect(body.data.category).toBe("OTHER"); + }); + + it("renames, hides and caps", async () => { + const key = await createCategory("Heart"); + const { PATCH } = + await import("@/app/api/medications/categories/[key]/route"); + const res = await PATCH( + req("PATCH", `/api/medications/categories/${key}`, { + label: "Heart and vessels", + isActive: false, + sortOrder: 4, + }), + { params: Promise.resolve({ key }) }, + ); + expect(await res.json()).toMatchObject({ + data: { + key, + label: "Heart and vessels", + isActive: false, + sortOrder: 4, + medicationCount: 0, + }, + }); + + // Hidden ones count toward the cap: nineteen more fill it. + for (let i = 0; i < 19; i += 1) await createCategory(`Group ${i}`); + const { POST } = await import("@/app/api/medications/categories/route"); + const over = await POST( + req("POST", "/api/medications/categories", { label: "One too many" }), + ); + expect(over.status).toBe(422); + expect((await over.json()).meta.errorCode).toBe( + "medications.category.limitReached", + ); + }); + + it("does not let one account rename or delete another's category", async () => { + await signIn(OTHER_USER); + const foreignKey = await createCategory("Theirs"); + await signIn(OWNER); + const { PATCH, DELETE } = + await import("@/app/api/medications/categories/[key]/route"); + const patched = await PATCH( + req("PATCH", `/api/medications/categories/${foreignKey}`, { + label: "Mine now", + }), + { params: Promise.resolve({ key: foreignKey }) }, + ); + expect(patched.status).toBe(404); + const deleted = await DELETE( + req("DELETE", `/api/medications/categories/${foreignKey}`), + { params: Promise.resolve({ key: foreignKey }) }, + ); + expect(deleted.status).toBe(404); + expect( + await getPrismaClient().medicationCategoryLabel.count({ + where: { key: foreignKey }, + }), + ).toBe(1); + }); + + it("moves the medications of a deleted category to OTHER", async () => { + const key = await createCategory("Short courses"); + const first = await (await createMedication(key)).json(); + const second = await (await createMedication(key)).json(); + const untouched = await (await createMedication("THYROID")).json(); + + const { DELETE } = + await import("@/app/api/medications/categories/[key]/route"); + const res = await DELETE( + req("DELETE", `/api/medications/categories/${key}`), + { params: Promise.resolve({ key }) }, + ); + expect(await res.json()).toEqual({ + data: { key, movedCount: 2 }, + error: null, + }); + + const rows = await getPrismaClient().medicationCategoryAssignment.findMany({ + orderBy: { medicationId: "asc" }, + }); + const byId = Object.fromEntries( + rows.map((r) => [r.medicationId, r.category]), + ); + // The stored value itself, not what a read resolves it to: a dangling key + // would also READ as OTHER, which is exactly what this must not rely on. + expect(byId[first.data.id]).toBe("OTHER"); + expect(byId[second.data.id]).toBe("OTHER"); + expect(byId[untouched.data.id]).toBe("THYROID"); + expect(rows.filter((r) => r.category.startsWith("custom:"))).toEqual([]); + }); + + it("reads a key with no label as OTHER", async () => { + const created = await (await createMedication("VITAMIN")).json(); + await getPrismaClient().medicationCategoryAssignment.update({ + where: { medicationId: created.data.id }, + data: { category: "custom:00000000-0000-4000-8000-000000000000" }, + }); + const { GET } = await import("@/app/api/medications/[id]/route"); + const body = await ( + await GET(req("GET", `/api/medications/${created.data.id}`), { + params: Promise.resolve({ id: created.data.id }), + }) + ).json(); + expect(body.data).toMatchObject({ category: "OTHER", categoryLabel: null }); + }); +}); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 24ac022ef..7810307f0 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob>( describe("the complete MANAGE handler matrix", () => { it("starts with a non-empty, exact handler inventory", () => { - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(83); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(84); }); }); @@ -2423,6 +2423,18 @@ proveManageRoute("POST /api/cycle/symptoms/custom", { })) === 1, }); +proveManageRoute("POST /api/medications/categories", { + route: "/api/medications/categories", + prepare: async (ownerId) => ({ ownerId }), + body: () => ({ label: "Travel kit" }), + ok: 201, + auditAction: "medication.category.custom.create", + applied: async ({ ownerId }) => + (await getPrismaClient().medicationCategoryLabel.count({ + where: { userId: ownerId }, + })) === 1, +}); + proveManageRoute("POST /api/nutrients/water", { route: "/api/nutrients/water", prepare: async (ownerId) => { @@ -2753,13 +2765,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(83); + expect(STRICT_DRIVER_KEYS.size).toBe(84); 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(83); + expect(REAL_EFFECT_KEYS.size).toBe(84); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); }); });