feat(medications): end a course now through its own action

"End course" now posts to `POST /api/medications/{id}/courses/end`, which
ends the running course at that moment: its last day becomes today on the
person's clock and the instant is stored as `endedAt`, so a dose later today
is not due, not reminded and never missed, while one taken earlier still
counts. Pending placeholders already minted for later slots are tombstoned.
A medication without dates gets the course its window describes, ended now.

End stays its own verb instead of `PUT { endsOn }`: an end date set through
the PUT or the course editor keeps meaning the whole day, and the PUT keeps
accepting it on any course, since moving an end is a legitimate edit.

The list and detail reads serve `canEndCourse`, true exactly while a course
is running, and the action refuses with 409 `medications.course.notRunning`
when it is false, so no client can reopen an ended course or invent a
one-day course from a planned one by ending it. The web controls render End
from the served flag; the contributed tests keep their cases against it.

Refs #1280
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 22:24:15 +02:00
1 parent 23a67f61e6
commit 275367f6e9
15 files changed
+990 -124

No files matched your search

+1
View File
@@ -312,6 +312,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"medications.course.invalid",
"medications.course.invalidRange",
"medications.course.notFound",
"medications.course.notRunning",
"medications.course.oneShot",
"medications.course.overlap",
"medications.course.windowRequired",
@@ -1178,6 +1178,45 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/medications/{id}/courses/end": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Medications"],
summary: "End the running course now",
description:
"#1280 — \"End course\". No body. The running course's last day becomes today on the person's clock and the moment is stored as its `endedAt`: a dose slot later today is not due, not reminded and not expected by compliance, a dose taken earlier today still counts, and `courseStatus` reads ENDED at once. Pending placeholders for slots at or after the moment are removed (tombstoned for sync). A medication with no dates gets the course its window describes, from creation to today. An end date set through `PUT /api/medications/{id}` or the course editor keeps meaning the whole day; only this action ends a course at an instant. Refused with 409 `medications.course.notRunning` while no course is running — the rule the reads publish as `canEndCourse`. Owner-only.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
...idempotentWrite(),
"200": {
description: "The medication's window after ending.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
startsOn: z.iso.date().nullable(),
endsOn: z.iso.date().nullable(),
endedAt: isoDateTime().nullable(),
}),
"EndMedicationCourseResponse",
),
},
},
},
"404": {
description:
"Medication not found (`medications.course.notFound`), or another user's.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description:
"No course is running now (`medications.course.notRunning`): it has ended already or has not started.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/medications/{id}/courses/{courseId}": {
patch: {
tags: ["Medications"],
+14 -4
View File
@@ -69,7 +69,7 @@ export const medicationCourseResource = z
endedAt: isoDateTime()
.nullable()
.describe(
'#1280 — the instant "End course" ended this course, on its `endsOn` day. No dose slot at or after it is due, reminded or expected, and the course is ENDED from it on. NULL when the whole `endsOn` day counts (an end date set in the editor). Any edit that moves `endsOn` clears it.',
"#1280 — the instant `POST /api/medications/{id}/courses/end` ended this course, on its `endsOn` day. No dose slot at or after it is due, reminded or expected, and the course is ENDED from it on. NULL when the whole `endsOn` day counts (an end date set in the editor). Any edit that moves `endsOn` clears it.",
),
status: z
.enum(["UPCOMING", "CURRENT", "ENDED"])
@@ -375,7 +375,7 @@ export const medicationResource = z
.nullable()
.optional()
.describe(
'#1280 — the instant the latest course was ended with "End course" (the course\'s own `endedAt`). From it on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. Read-only — written by the course writer only.',
"#1280 — the instant the latest course was ended with `POST /api/medications/{id}/courses/end` (the course's own `endedAt`). From it on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. Read-only — written by the course writer only.",
),
oneShot: z
.boolean()
@@ -453,13 +453,18 @@ export const medicationListEntry = medicationResource
courseStatus: z
.enum(["UPCOMING", "CURRENT", "ENDED"])
.describe(
'v1.39.4 — where today (on the user\'s clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with "End course": then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.',
"v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with `POST /api/medications/{id}/courses/end`: then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.",
),
intakeActionable: z
.boolean()
.describe(
"v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored.",
),
canEndCourse: z
.boolean()
.describe(
'#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.',
),
lastTakenAt: isoDateTime()
.nullable()
.describe(
@@ -519,13 +524,18 @@ export const medicationDetailEntry = medicationResource
courseStatus: z
.enum(["UPCOMING", "CURRENT", "ENDED"])
.describe(
'v1.39.4 — where today (on the user\'s clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with "End course": then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.',
"v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with `POST /api/medications/{id}/courses/end`: then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.",
),
intakeActionable: z
.boolean()
.describe(
"v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored.",
),
canEndCourse: z
.boolean()
.describe(
'#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.',
),
stockUnitsRemaining: z
.number()
.nullable()