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:
Marc-André Bombeck committed 2026-10-03 22:39:25 +02:00
1 parent 9b71231010
commit a14174fe4d
71 files changed
+3635 -90

No files matched your search

+7
View File
@@ -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",
+141 -1
View File
@@ -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(