mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
1 parent
23a67f61e6
commit
275367f6e9
15 files changed
+990
-124
No files matched your search
@@ -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"],
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user