diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ab0c02b1a..c475bbc1a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5874,9 +5874,8 @@ paths: 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. Rate-limited 30/min keyed on the actor. Delegable at MANAGE level over the `medications` - section. + 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 @@ -5899,7 +5898,6 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationCategoryResponse" "401": *a1 - "403": *a11 "404": description: Not one of the record's own categories. content: @@ -5912,9 +5910,9 @@ paths: 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. Delegable at MANAGE level over the `medications` section. + 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 @@ -5931,7 +5929,6 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationCategoryResponse" "401": *a1 - "403": *a11 "404": description: Not one of the record's own categories. content: @@ -5940,6 +5937,145 @@ paths: $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: @@ -6053,7 +6189,9 @@ paths: 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`. + `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 @@ -24720,6 +24858,52 @@ components: 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: @@ -32083,7 +32267,9 @@ components: 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 — medications.category.invalid, - medications.category.limitReached, medications.category.unknown, + 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, @@ -35394,6 +35580,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 @@ -44772,6 +44985,28 @@ components: - 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 @@ -44865,6 +45100,10 @@ components: - schedules - category - categoryLabel + - courses + - courseCount + - previousCourseEndedOn + - canStartCourse - externalSource - courseStatus - intakeActionable @@ -44887,6 +45126,51 @@ components: `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: @@ -45054,6 +45338,28 @@ components: - 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: @@ -45115,6 +45421,10 @@ components: - schedules - category - categoryLabel + - courses + - courseCount + - previousCourseEndedOn + - canStartCourse - courseStatus - intakeActionable - stockUnitsRemaining @@ -45242,6 +45552,143 @@ components: - 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/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql index 23d971b47..58d2e8ebb 100644 --- a/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql +++ b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql @@ -40,3 +40,57 @@ 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. A course +-- needs a start, and a medication with only an end has always been read as +-- running from its creation day, so that is its start (or the end day, when +-- the end lies before the creation). 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_' || m."id", + m."id", + m."user_id", + -- An end-only window that ends before the creation day (a backdated + -- record) starts on its end day instead, so no course ends before it starts. + COALESCE(m."starts_on", LEAST(m."created_at"::date, m."ends_on")), + m."ends_on", + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM "medications" m +WHERE m."starts_on" IS NOT NULL OR m."ends_on" IS NOT NULL +ON CONFLICT ("id") DO NOTHING; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 68d977d82..eb02f4ea7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -721,6 +721,7 @@ model User { 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. @@ -2797,6 +2798,10 @@ model Medication { /// 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) @@ -2861,6 +2866,36 @@ model MedicationCategoryLabel { @@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 diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 4dfee834f..984291c64 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -664,6 +664,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__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 03fe54a8d..0aa719d4b 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -475,6 +475,13 @@ const REGISTRY: DestructiveEntry[] = [ 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/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index d3e4e7ecc..349245bea 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -270,6 +270,9 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // 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/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/route.ts b/src/app/api/insights/comprehensive/route.ts index da41187b3..f15b92687 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -17,6 +17,7 @@ import { } from "@/lib/analytics/correlations"; import { buildComplianceMedicationContext, + COURSES_COMPLIANCE_SELECT, calculateCompliance, expectsDoses, lastNonSkippedTakenAt, @@ -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` diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 3bdc698f4..ed1e5b8bf 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -49,6 +49,14 @@ 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({}), @@ -72,6 +80,7 @@ vi.mock("next/headers", () => ({ import { PUT } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { resolveCourseFields } from "@/lib/medications/courses"; import { getMedicationCategories, isAssignableCategory, @@ -102,7 +111,9 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - // v1.40 — the category helpers' defaults, which `resetAllMocks` clears. + // 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( 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]/route.ts b/src/app/api/medications/[id]/route.ts index ad3e6c63b..de6424398 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -19,6 +19,12 @@ import { setMedicationCategory, } from "@/lib/medication-category"; import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; +import { + checkCurrentWindow, + courseRefusalResponse, + resolveCourseFields, + setCurrentWindow, +} from "@/lib/medications/courses"; import { schedulesMateriallyDiffer, toRevisionPayloadEntry, @@ -238,6 +244,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), @@ -246,6 +261,7 @@ export const GET = apiHandler( ...scheduleWireFields(medication.trackIntake, schedulesDto), category, categoryLabel, + ...courseFields, nextDueAt: display ? display.at.toISOString() : null, nextDueOverdue: display?.overdue ?? false, // v1.39.4 (#1040) — see `resolveIntakeActionability`. @@ -294,6 +310,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 } }, }, }); @@ -494,6 +513,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 ? {} @@ -1002,6 +1049,18 @@ export const PUT = apiHandler( } } + // v1.40 (#1024) — write the window onto the latest course and project it + // back, then serve the projected row. + if (touchesWindow) { + const projected = await setCurrentWindow({ + userId: user.id, + medicationId: id, + timeZone: courseTz, + ...nextWindow, + }); + if (projected) medication = { ...medication, ...projected }; + } + if (category !== undefined) await setMedicationCategory(id, category); const resolvedCategory = (await resolveMedicationCategories([id]))[id]; const normalizedCategory = resolvedCategory?.category ?? "OTHER"; @@ -1068,6 +1127,10 @@ 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 6f58b809e..a60495d7e 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -51,6 +51,14 @@ 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({}), resolveMedicationCategories: vi.fn().mockResolvedValue({}), @@ -83,6 +91,7 @@ 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 { resolveCourseFields } from "@/lib/medications/courses"; import { getMedicationCategories, isAssignableCategory, @@ -106,7 +115,9 @@ function postReq(body: unknown): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - // v1.40 — the category helpers' defaults, which `resetAllMocks` clears. + // 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( 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 8f93617a0..4ebc3e955 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -10,6 +10,16 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; +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, @@ -283,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: { @@ -323,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 @@ -468,6 +514,14 @@ 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/medications/page-client.tsx b/src/app/medications/page-client.tsx index d9331fdcc..7d13852ec 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -1,5 +1,6 @@ "use client"; +import type { MedicationCourseFields } from "@/components/medications/course-fields"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -127,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). */ diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 2a5105fb1..eb0951adf 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -230,7 +230,12 @@ export function ClinicianView({ /> {/* ── 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 6eeae397b..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, @@ -174,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[]; } @@ -907,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 70a406226..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"; @@ -144,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[]; } @@ -456,6 +465,8 @@ export function Glp1MedicationCard({ 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-card.tsx b/src/components/medications/medication-card.tsx index edd97d6bb..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"; @@ -129,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[]; } @@ -399,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-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/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..5eae5a23d --- /dev/null +++ b/src/lib/analytics/__tests__/compliance-courses.test.ts @@ -0,0 +1,118 @@ +/** + * 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); + }); +}); diff --git a/src/lib/analytics/compliance/adapters.ts b/src/lib/analytics/compliance/adapters.ts index 3de29fb32..ba184dc73 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,12 +49,22 @@ 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, }, diff --git a/src/lib/analytics/compliance/cycle.ts b/src/lib/analytics/compliance/cycle.ts index 1ed582699..3626eca5d 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,8 +158,14 @@ export function buildCurrentCycle( if (canonical.scheduleType === "PRN") continue; const n = canonical.rollingIntervalDays; if (n !== null && n > 0) { + // v1.40 (#1024) — an intake from a previous course does not anchor the + // current one; its first dose is due on its start day. + const intakeInCourse = + lastIntakeAt !== null && + (ctx.startsOn === null || + userDayKey(lastIntakeAt, ctx.timeZone) >= dayKeyOfDate(ctx.startsOn)); const anchor = - lastIntakeAt !== null + intakeInCourse && lastIntakeAt !== null ? new Date(lastIntakeAt.getTime() + n * DAY_MS) : (ctx.startsOn ?? ctx.createdAt); if (ctx.endsOn && anchor.getTime() > ctx.endsOn.getTime()) continue; diff --git a/src/lib/analytics/compliance/ledger.ts b/src/lib/analytics/compliance/ledger.ts index 0d40c6336..02ea6610a 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`, @@ -188,7 +193,19 @@ export function buildComplianceLedgerRows( // Slots before the medication's creation count only when a recorded dose // claims them, so a caller's window reaching past the creation never // mints phantom misses (#1028). - const rows = reconstructDoseHistory(bands, intakes, now, ctx.createdAt); + const reconstructed = reconstructDoseHistory( + bands, + intakes, + now, + ctx.createdAt, + ); + + // v1.40 (#1024) — an expected slot on a day between two courses was never + // expected: drop it. Only slot rows go; a dose logged in the gap stays as + // the recorded intake it is. + const rows = reconstructed.filter( + (row) => row.kind !== "slot" || slotInsideCourses(ctx, row.at), + ); // v1.25 H-MED1 — drop expected dose slots whose anchor falls inside a // pause interval. While a medication is paused no dose is expected, so a 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..198d52605 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,50 @@ 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: ComplianceMedicationContext): { + 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: ComplianceMedicationContext, + at: Date, +): boolean { + const courses = ctx.courses; + if (!courses || courses.length < 2) return true; + return isInsideCourses(userDayKey(at, ctx.timeZone), courses); } /** @@ -190,6 +249,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 +264,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 6206b7254..9e39fb077 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -335,6 +335,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 ad8a4f43e..d4b40a9de 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -76,6 +76,8 @@ export const WIPE_MODELS = [ // 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 351b9ff23..5cbfcf0fa 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -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: [ @@ -743,6 +760,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/backup-plan.ts b/src/lib/export/backup-plan.ts index 14ebbb35f..acbe9e7dc 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -86,6 +86,9 @@ export const BACKED_UP_MODELS = [ // 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", @@ -314,6 +317,7 @@ export const TWO_ENDED_MODELS = [ "MedicationDoseChange", "MedicationCategoryAssignment", "MedicationCategoryLabel", + "MedicationCourse", "MoodEntry", "MoodContext", "MoodEntryTagLink", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 63892829f..236e25188 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"; @@ -735,6 +737,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" } }, @@ -1119,6 +1122,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 f7299abac..292e01ca7 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -27,6 +27,11 @@ * replacement is still one transaction, so a failure part-way leaves the * account exactly as it was. */ +import { + dateOfDayKey, + dayKeyOfDate, + projectCourseWindow, +} from "@/lib/medications/course-window"; import { Buffer } from "node:buffer"; import { prisma, toJson } from "@/lib/db"; @@ -1301,6 +1306,59 @@ export async function restoreBackup( ); } + // 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) } : {}), + })) + : created.startsOn || created.endsOn + ? [ + { + startsOn: + created.startsOn ?? + (created.endsOn && + created.endsOn.getTime() < + dateOfDayKey( + dayKeyOfDate(created.createdAt), + ).getTime() + ? created.endsOn + : dateOfDayKey(dayKeyOfDate(created.createdAt))), + endsOn: created.endsOn, + 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 ──────────────── // // An era carries `supersededByRevisionId`, the pointer from a 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 aaed68cfa..7bbd1d5fb 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, @@ -83,6 +84,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/resources.ts b/src/lib/mcp/resources.ts index 357894bab..f0724414e 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -28,6 +28,7 @@ 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"; @@ -192,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({ @@ -222,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/medications/__tests__/course-window.test.ts b/src/lib/medications/__tests__/course-window.test.ts new file mode 100644 index 000000000..292ffd117 --- /dev/null +++ b/src/lib/medications/__tests__/course-window.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import { + canStartCourse, + 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, + ); + }); +}); diff --git a/src/lib/medications/course-window.ts b/src/lib/medications/course-window.ts new file mode 100644 index 000000000..8b6ca8b5e --- /dev/null +++ b/src/lib/medications/course-window.ts @@ -0,0 +1,152 @@ +/** + * 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", + ); +} diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts new file mode 100644 index 000000000..f7c82c468 --- /dev/null +++ b/src/lib/medications/courses.ts @@ -0,0 +1,503 @@ +/** + * 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, + 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, + ); + refuseIfInvalid( + [...courses, { 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) return { startsOn, endsOn }; + if (latest) return { startsOn: latest.startsOn, endsOn }; + const creationKey = userDayKey(createdAt, timeZone); + const endKey = endsOn ? dayKeyOfDate(endsOn) : null; + return { + startsOn: dateOfDayKey( + endKey && endKey < creationKey ? endKey : creationKey, + ), + 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 null; + 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 e40eeafb5..bfcf6f033 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -14,6 +14,7 @@ */ import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; +import { resolveCourseFields } from "@/lib/medications/courses"; import { resolveMedicationCategories, type ResolvedMedicationCategory, @@ -216,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, @@ -309,6 +312,10 @@ export async function buildMedicationsList( // 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..51ef9c400 100644 --- a/src/lib/medications/scheduling/__tests__/recurrence.test.ts +++ b/src/lib/medications/scheduling/__tests__/recurrence.test.ts @@ -242,6 +242,29 @@ describe("occurrencesBetween — RRULE MONTHLY / YEARLY", () => { // ──────────────────────────────────────────────────────────────────── describe("occurrencesBetween — rolling", () => { + 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({ + 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/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index fce3930fb..887d3ca95 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -493,14 +493,23 @@ function rollingDoseAnchor( ctx: RecurrenceContext, ): Date { const intervalDays = schedule.rollingIntervalDays ?? 0; + // v1.40 (#1024) — an intake from before the course started (a previous + // course of the same medication) does not anchor this one: the new + // course's first dose is due on its start day, as for a course with no + // intake at all. + const intakeDay = + ctx.lastIntakeAt !== null + ? civilDayOfInstant(ctx.lastIntakeAt, ctx.timeZone) + : null; + const anchorsThisCourse = + intakeDay !== null && + (ctx.medication.startsOn === null || + intakeDay.getTime() >= civilDayOfDate(ctx.medication.startsOn).getTime()); // 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, - ) + return anchorsThisCourse + ? addCivilDays(intakeDay, intervalDays) : anchorCivilDay(ctx); } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 4b1cfd108..c69c5c5b0 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -252,6 +252,13 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "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 c4b5d2ba1..5eaeb8528 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -22,6 +22,8 @@ import { updateIntakeEventSchema, createMedicationCategoryLabelSchema, updateMedicationCategoryLabelSchema, + createMedicationCourseSchema, + updateMedicationCourseSchema, } from "@/lib/validations/medication"; import { scheduleRevisionCreateSchema, @@ -76,6 +78,7 @@ const medicationEfficacyResponse = medicationEfficacyResponseSchema.meta({ "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, @@ -1086,6 +1089,143 @@ export const medicationPaths: NonNullable = { }, }, }, + "/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"], @@ -1195,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`. 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`.", + "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 905c53cf1..4c54d773f 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -50,6 +50,57 @@ const medicationCategoryLabelField = z "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 @@ -291,6 +342,7 @@ 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 @@ -357,6 +409,7 @@ export const medicationDetailEntry = medicationResource .extend({ category: medicationCategoryEnum, categoryLabel: medicationCategoryLabelField, + ...medicationCourseFields, courseStatus: z .enum(["UPCOMING", "CURRENT", "ENDED"]) .describe( 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 5bb94f86e..09ff16941 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. * @@ -413,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(); 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/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/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index ec6b9db60..7d3cee7a6 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -226,6 +226,8 @@ const COUNT_BACK: Record< 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) => @@ -411,14 +413,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({ diff --git a/tests/integration/medication-courses.test.ts b/tests/integration/medication-courses.test.ts new file mode 100644 index 000000000..88cddfc83 --- /dev/null +++ b/tests/integration/medication-courses.test.ts @@ -0,0 +1,361 @@ +/** + * 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([]); + // 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("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, + }); + }); +});