mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Let a medication have several courses
A medication taken now and then (#1024) keeps one row and gains course rows: calendar spans, both ends inclusive, an optional encrypted note, no schedule or dose of their own. Courses never overlap and every course but the latest has ended, so the latest course is always the one the medication's startsOn/endsOn project. src/lib/medications/courses.ts is the only writer of those two columns for a medication with courses; it writes under a per-medication advisory lock and re-projects in the same transaction, so reminders, the next-due line and intakeActionable keep reading the columns and stay right. - Migration 0368 adds medication_courses and backfills one course per medication window (an end-only window starts on the creation day, or on its end day when that lies earlier); a medication without a window stays course-less. Idempotent, data-preserving. - GET/POST /api/medications/{id}/courses and PATCH/DELETE .../courses/{courseId} (owner-only); create seeds the first course from a window; the medication PUT edits the latest course and refuses a window that would break the rules (medications.course.*). - List, detail, create and update carry courses, courseCount, previousCourseEndedOn and canStartCourse, resolved on the server. - Compliance expands over the span of all courses and drops the slots between them; every compliance read selects the courses. A rolling cadence is not anchored by an intake from a previous course. - The card names the last course's end and the running course's number; the detail Lifecycle group lists the courses with start, edit and delete. The Coach compliance block, the MCP resource, the doctor report window and table, the clinician view and FHIR (one MedicationStatement per course with effectivePeriod and status) read the courses. - Backup carries courses per medication and the restore writes them and re-projects; an older file derives the one course its window describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
This commit is contained in:
1 parent
9b71231010
commit
a14174fe4d
71 files changed
+3635
-90
No files matched your search
+457
-10
@@ -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:<uuid>`); 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:<uuid>`, 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:<uuid>`); 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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -270,6 +270,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// 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",
|
||||
|
||||
@@ -442,6 +442,7 @@ describe("POST /api/export/health-record — the selection reaches every format"
|
||||
schedules: [],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
courses: [],
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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<typeof import("@/lib/medications/courses")>()),
|
||||
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(
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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<Response> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<typeof import("@/lib/medications/courses")>()),
|
||||
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(
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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<Response> {
|
||||
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<Response> {
|
||||
// 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<Response> {
|
||||
),
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -230,7 +230,12 @@ export function ClinicianView({
|
||||
/>
|
||||
|
||||
{/* ── medications ──────────────────────────────────────── */}
|
||||
<MedicationsSection t={t} report={report} scope={scope} />
|
||||
<MedicationsSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
/>
|
||||
<Glp1Section
|
||||
t={t}
|
||||
report={report}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
* A pure server component: no client hooks, no session, no markdown — every
|
||||
* value renders as escaped React text.
|
||||
*/
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import {
|
||||
@@ -312,14 +315,22 @@ export function GlucoseSection({
|
||||
}
|
||||
|
||||
/** The medication list with the adherence rate beside each drug. */
|
||||
/** A course day at noon UTC, so no viewer zone shifts it to a neighbour. */
|
||||
function courseNoon(dayKey: string): string {
|
||||
return dateOnlyAtNoonUtc(dayKey).toISOString();
|
||||
}
|
||||
|
||||
export function MedicationsSection({
|
||||
t,
|
||||
report,
|
||||
scope,
|
||||
fmtDate,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
/** Formats a calendar date for the viewer. */
|
||||
fmtDate: (iso: string) => 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 (
|
||||
<StatRow
|
||||
key={med.id}
|
||||
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
|
||||
value={
|
||||
rate
|
||||
? t("clinicianView.adherence", { rate })
|
||||
: t("clinicianView.noAdherence")
|
||||
}
|
||||
/>
|
||||
<Fragment key={med.id}>
|
||||
<StatRow
|
||||
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
|
||||
value={
|
||||
rate
|
||||
? t("clinicianView.adherence", { rate })
|
||||
: t("clinicianView.noAdherence")
|
||||
}
|
||||
/>
|
||||
{/* v1.40 (#1024) — one row per course inside the window. */}
|
||||
{(med.courses ?? []).map((course, index) => (
|
||||
<StatRow
|
||||
key={`${med.id}-course-${course.startsOn}`}
|
||||
label={t("clinicianView.course", { n: index + 1 })}
|
||||
value={
|
||||
course.endsOn
|
||||
? t("clinicianView.courseRange", {
|
||||
start: fmtDate(courseNoon(course.startsOn)),
|
||||
end: fmtDate(courseNoon(course.endsOn)),
|
||||
})
|
||||
: t("clinicianView.courseSince", {
|
||||
start: fmtDate(courseNoon(course.startsOn)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{complianceEntries
|
||||
|
||||
@@ -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")}
|
||||
</Badge>
|
||||
)}
|
||||
{!courseEnded && courseNumber !== null && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
data-slot="medication-course-number-badge"
|
||||
>
|
||||
{t("medications.course.nth", { n: courseNumber })}
|
||||
</Badge>
|
||||
)}
|
||||
{!notificationsEnabled && !recordOnly && !courseEnded && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
{/* v1.40 (#1024) — the courses: when it was taken, and a new
|
||||
course once the last one has ended. */}
|
||||
<div className="py-3">
|
||||
<CoursesRow
|
||||
medicationId={id}
|
||||
courses={medication.courses ?? []}
|
||||
canStartCourse={medication.canStartCourse === true}
|
||||
canEdit={!inSharedRecord}
|
||||
/>
|
||||
</div>
|
||||
{/* 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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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")}
|
||||
</span>
|
||||
);
|
||||
} else if (medication.asNeeded) {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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<string | null>(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 (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
course
|
||||
? t("medications.course.editTitle")
|
||||
: t("medications.course.startNew")
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={!startsOn || rangeInvalid || saving}
|
||||
data-slot="medication-course-save"
|
||||
>
|
||||
{saving && (
|
||||
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4" data-slot="medication-course-editor">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="medication-course-start">
|
||||
{t("medications.course.startsOn")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="medication-course-start"
|
||||
value={startsOn}
|
||||
onChange={setStartsOn}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="medication-course-end">
|
||||
{t("medications.course.endsOn")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="medication-course-end"
|
||||
value={endsOn}
|
||||
min={startsOn || undefined}
|
||||
onChange={setEndsOn}
|
||||
aria-invalid={rangeInvalid || undefined}
|
||||
aria-describedby="medication-course-end-error"
|
||||
/>
|
||||
<FieldError
|
||||
id="medication-course-end-error"
|
||||
message={
|
||||
rangeInvalid
|
||||
? t("medications.course.error.invalidRange")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="medication-course-note">
|
||||
{t("medications.course.note")}
|
||||
</Label>
|
||||
<Input
|
||||
id="medication-course-note"
|
||||
value={note}
|
||||
maxLength={280}
|
||||
placeholder={t("medications.course.notePlaceholder")}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<FieldError id="medication-course-error" message={error ?? undefined} />
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
|
||||
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<MedicationCourseWire | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [deleting, setDeleting] = useState<MedicationCourseWire | null>(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 (
|
||||
<div className="space-y-3" data-slot="medication-courses-row">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("medications.course.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.course.helper")}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && canStartCourse && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStarting(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
data-slot="medication-course-start-new"
|
||||
>
|
||||
<CalendarPlus aria-hidden="true" className="size-4" />
|
||||
{t("medications.course.startNew")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{courses.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.course.none")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{courses.map((course, index) => (
|
||||
<li
|
||||
key={course.id}
|
||||
className="border-border flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
|
||||
data-slot="medication-course-item"
|
||||
data-status={course.status}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm">
|
||||
{course.endsOn
|
||||
? t("medications.course.range", {
|
||||
start: date(course.startsOn),
|
||||
end: date(course.endsOn),
|
||||
})
|
||||
: t("medications.course.since", {
|
||||
start: date(course.startsOn),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.course.meta", {
|
||||
n: index + 1,
|
||||
status: statusLabel(course.status),
|
||||
taken: course.takenDoses,
|
||||
})}
|
||||
</p>
|
||||
{course.note && (
|
||||
<p className="text-sm break-words">{course.note}</p>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => setEditing(course)}
|
||||
aria-label={t("medications.course.editTitle")}
|
||||
title={t("medications.course.editTitle")}
|
||||
>
|
||||
<Pencil className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive size-11 sm:size-9"
|
||||
onClick={() => setDeleting(course)}
|
||||
aria-label={t("medications.course.delete")}
|
||||
title={t("medications.course.delete")}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{starting && (
|
||||
<CourseEditor
|
||||
medicationId={medicationId}
|
||||
open={starting}
|
||||
onOpenChange={setStarting}
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<CourseEditor
|
||||
medicationId={medicationId}
|
||||
course={editing}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditing(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleting(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.course.deleteTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{courses.length === 1
|
||||
? t("medications.course.deleteLastConfirm")
|
||||
: t("medications.course.deleteBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteBusy}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (deleting) void confirmDelete(deleting);
|
||||
}}
|
||||
>
|
||||
{t("medications.course.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ComplianceBlockContext>,
|
||||
): 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;
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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) ─────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -162,6 +162,7 @@ function seed() {
|
||||
],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
courses: [],
|
||||
doseChanges: [
|
||||
{
|
||||
effectiveFrom: new Date("2026-01-05T00:00:00.000Z"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<T extends CourseSpan>(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",
|
||||
);
|
||||
}
|
||||
@@ -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<ProjectedWindow> {
|
||||
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<CourseRow> {
|
||||
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<CourseRow> {
|
||||
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<void> {
|
||||
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<ProjectedWindow | null> {
|
||||
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<CourseWriteRefusal | null> {
|
||||
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<Map<string, CourseRow[]>> {
|
||||
const byMedication = new Map<string, CourseRow[]>();
|
||||
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<Map<string, number>> {
|
||||
const counts = new Map<string, number>();
|
||||
if (medicationIds.length === 0) return counts;
|
||||
const rows = await prisma.$queryRaw<Array<{ id: string; taken: number }>>`
|
||||
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<string, number>,
|
||||
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<Map<string, MedicationCourseFields>> {
|
||||
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),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -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" } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -252,6 +252,13 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"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",
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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<ZodOpenApiObject["paths"]> = {
|
||||
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() }),
|
||||
},
|
||||
|
||||
@@ -50,6 +50,57 @@ const medicationCategoryLabelField = z
|
||||
"v1.40 — the decrypted label of a custom category (`category` = `custom:<uuid>`); 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(
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
@@ -14,3 +14,4 @@ export * from "./schedule";
|
||||
export * from "./create-update";
|
||||
export * from "./intake";
|
||||
export * from "./inventory";
|
||||
export * from "./course";
|
||||
@@ -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<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// 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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user