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

+164 -65
View File
@@ -6861,6 +6861,49 @@ paths:
"409": *a7
"422": *a4
"429": *a5
/api/medications/{id}/courses/end:
post:
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."
parameters:
- in: path
name: id
schema:
type: string
required: true
- *a6
responses:
"200":
description: The medication's window after ending.
content:
application/json:
schema:
$ref: "#/components/schemas/EndMedicationCourseResponse"
"401": *a1
"403": *a9
"404":
description: Medication not found (`medications.course.notFound`), or another user's.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: "No course is running now (`medications.course.notRunning`): it has ended already or has not started."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/medications/{id}/courses/{courseId}:
patch:
tags:
@@ -38495,55 +38538,56 @@ components:
medication.intake.notTracked, medication.link.invalid; medications — 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.ingredients.extract.budgetExceeded, medications.ingredients.extract.failed,
medications.ingredients.extract.fileTooLarge, medications.ingredients.extract.fileType,
medications.ingredients.extract.rateLimited, medications.ingredients.invalid,
medications.ingredients.unitMismatch, 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, medications.intake.taken_at.before_start,
medications.mirror.limit_exceeded, medications.notFound; mentalHealth — mentalHealth.rateLimited; module
— module.disabled; modules — modules.body.invalid_json, modules.invalid; mood —
mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, mood.create.external_id_not_delegable,
mood.create.invalid, mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid,
mood.not_found, mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid;
notification-prefs — notification-prefs.body.invalid_json; notification —
notification.action.already_used, notification.action.expired, notification.action.invalid,
notification.action.module_disabled; nutrient — nutrient.batch.invalid, nutrient.batch.too_large,
nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day;
onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid,
onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch,
onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited,
onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown,
passkey.verification.failed; personal_records — personal_records.invalid_query; practitioner —
practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited,
profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved;
profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported,
profile_claim.sso_unavailable, profile_claim.taken; record_write — record_write.rate_limited;
report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape,
report-selection.leaves.unknown; route — route.retired; search — search.invalid; security_activity —
security_activity.invalid; share-link — share-link.document.decryptFailed, share-link.document.notFound,
share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound,
share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf;
sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending,
sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid,
sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account,
sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended,
sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found,
sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms —
symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached,
tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination —
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
— about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
medications.course.notRunning, medications.course.oneShot, medications.course.overlap,
medications.course.windowRequired, medications.ingredients.extract.budgetExceeded,
medications.ingredients.extract.failed, medications.ingredients.extract.fileTooLarge,
medications.ingredients.extract.fileType, medications.ingredients.extract.rateLimited,
medications.ingredients.invalid, medications.ingredients.unitMismatch,
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,
medications.intake.taken_at.before_start, medications.mirror.limit_exceeded, medications.notFound;
mentalHealth — mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json,
modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large,
mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp,
mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range,
mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json;
notification — notification.action.already_used, notification.action.expired,
notification.action.invalid, notification.action.module_disabled; nutrient — nutrient.batch.invalid,
nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid,
nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited,
onboarding.complete.invalid, onboarding.complete.recordNotFound,
onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid,
onboarding.restart.rateLimited, onboarding.tour.invalid; passkey — passkey.challenge.expired,
passkey.response.invalid, passkey.unknown, passkey.verification.failed; personal_records —
personal_records.invalid_query; practitioner — practitioner.invalid; profile —
profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired,
profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid,
profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write —
record_write.rate_limited; report-selection — report-selection.body.invalid_json,
report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; search —
search.invalid; security_activity — security_activity.invalid; share-link —
share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused,
sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found,
sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached,
symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid;
timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached,
tokens.measurements.ceiling_reached, tokens.mirror.not_measurement_token,
tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid,
vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found,
vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found,
vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted,
workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict,
ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
@@ -53820,9 +53864,9 @@ components:
- type: "null"
description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.
endedAt:
description: "#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."
description: "#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."
anyOf:
- type: string
format: date-time
@@ -53925,13 +53969,19 @@ components:
- ENDED
description: "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."
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:
type: boolean
description: "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:
type: boolean
description: '#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:
anyOf:
- type: string
@@ -54017,6 +54067,7 @@ components:
- nextDueScheduleId
- courseStatus
- intakeActionable
- canEndCourse
- lastTakenAt
- todayEventCount
- stockUnitsRemaining
@@ -54059,9 +54110,9 @@ components:
format: date-time
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])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: '#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.'
description: "#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:
type: string
enum:
@@ -54272,9 +54323,9 @@ components:
- type: "null"
description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.
endedAt:
description: "#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."
description: "#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."
anyOf:
- type: string
format: date-time
@@ -54372,13 +54423,19 @@ components:
- ENDED
description: "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."
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:
type: boolean
description: "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:
type: boolean
description: '#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:
description: "v1.37.19 — usable inventory units left (same semantics as the list entry's field): NULL = inventory
tracking off; 0 = tracking on, supply ran out."
@@ -54445,6 +54502,7 @@ components:
- canStartCourse
- courseStatus
- intakeActionable
- canEndCourse
- stockUnitsRemaining
- stockDosesRemaining
- runwayDays
@@ -54725,6 +54783,47 @@ components:
- data
- error
additionalProperties: false
EndMedicationCourseResponse:
type: object
properties:
data:
type: object
properties:
startsOn:
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"
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"
endedAt:
anyOf:
- type: string
format: date-time
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])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
required:
- startsOn
- endsOn
- endedAt
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
UpdateMedicationCourseResponse:
type: object
properties:
@@ -93831,9 +93930,9 @@ components:
- type: "null"
description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.
endedAt:
description: "#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."
description: "#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."
anyOf:
- type: string
format: date-time
@@ -73,6 +73,11 @@ const ALLOWLIST: Record<string, { max: number; reason: string }> = {
max: 2,
reason: "the cases pin the clock with fake timers around the fixed dates",
},
"tests/integration/medication-course-end.test.ts": {
max: 1,
reason:
"the cases pin the clock with fake timers; the history read starts on the fixed day the course was ended",
},
"tests/integration/symptoms.test.ts": {
max: 1,
reason:
@@ -0,0 +1,78 @@
/**
* #1280 — "End course": end the running course now.
*
* POST /api/medications/{id}/courses/end — no body. The course's last day
* becomes today on the person's clock and the moment is recorded as
* its `endedAt`, so a dose later today is not due, not reminded and
* not expected, while one taken earlier today still counts. The
* medication reads as ENDED at once.
*
* An action of its own rather than `PUT { endsOn }`: an end date set through
* the PUT or the course editor keeps meaning the whole day. Refused with 409
* `medications.course.notRunning` while no course is running, the same rule
* the reads publish as `canEndCourse`.
*
* Owner-only, like the other course routes.
*/
import { NextRequest } from "next/server";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import {
CourseWriteError,
courseRefusalResponse,
endCurrentCourse,
} from "@/lib/medications/courses";
import { dayKeyOfDate } from "@/lib/medications/course-window";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
withIdempotency<[NextRequest, RouteParams]>(postEndCourse),
);
async function postEndCourse(
request: NextRequest,
{ params }: RouteParams,
): Promise<Response> {
const { user } = await requireAuth();
const { id } = await params;
try {
const window = await endCurrentCourse({
userId: user.id,
medicationId: id,
timeZone: user.timezone || DEFAULT_TIMEZONE,
});
invalidateUserMedications(user.id, { evict: true });
const body = {
startsOn: window.startsOn ? dayKeyOfDate(window.startsOn) : null,
endsOn: window.endsOn ? dayKeyOfDate(window.endsOn) : null,
endedAt: window.endedAt ? window.endedAt.toISOString() : null,
};
await auditLog("medication.course.end", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id, ...body },
});
annotate({
action: { name: "medication.course.end" },
meta: { medicationId: id },
});
return apiSuccess(body);
} 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;
}
}
@@ -193,6 +193,7 @@ export interface MedicationDetailSnapshot {
/** v1.40 (#1024) — the server-resolved courses. */
courses?: MedicationCourseWire[];
canStartCourse?: boolean;
canEndCourse?: boolean;
recordedSchedules?: ScheduleSnapshot[];
schedules: ScheduleSnapshot[];
/** v1.43 — the composition per unit, as the server delivered it. */
@@ -3,13 +3,19 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { updateMedicationSchema } from "@/lib/validations/medication/create-update";
import {
canEndCourse,
endCourseBody,
LifecycleManageBody,
} from "../destructive-zone-section";
courseEndNow,
dayKeyOfDate,
} from "@/lib/medications/course-window";
import { updateMedicationSchema } from "@/lib/validations/medication/create-update";
import { LifecycleManageBody } from "../destructive-zone-section";
/** The end day End writes, as the `YYYY-MM-DD` an `endsOn` field carries. */
function endCourseBody(now: Date, timeZone: string): { endsOn: string } {
return { endsOn: dayKeyOfDate(courseEndNow(now, timeZone).endsOn) };
}
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
@@ -22,8 +28,9 @@ vi.mock("@/hooks/use-auth", () => ({
/**
* "End medication" sent `new Date().toISOString()`, a full instant, against an
* `endsOn` that only takes `YYYY-MM-DD`, so every press answered 422 and the
* toast said the medication could not be ended. The body now carries the
* person's own calendar day.
* toast said the medication could not be ended. End now asks the server to
* end the course (`POST /courses/end`), which writes the person's own
* calendar day (`courseEndNow`) as the last day.
*/
describe("endCourseBody", () => {
it("is a date the update schema accepts", () => {
@@ -58,10 +65,12 @@ describe("endCourseBody", () => {
* Ending writes today as the end of the latest course. On a course that has
* already ended that would reopen it up to today, and on one that has not
* started it would become a one-day course today, so End is only offered while
* the course runs. A medication with no dates is CURRENT.
* the course runs. The server resolves that as `canEndCourse` (a medication
* with no dates is CURRENT); the controls render from it, and without it
* (a read that does not carry it) End stays hidden.
*/
describe("End is offered only for a running course", () => {
function render(courseStatus?: "UPCOMING" | "CURRENT" | "ENDED") {
function render(served: boolean | undefined) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<QueryClientProvider client={new QueryClient()}>
@@ -70,7 +79,7 @@ describe("End is offered only for a running course", () => {
id: "med-1",
name: "Example",
active: true,
courseStatus,
canEndCourse: served,
}}
/>
</QueryClientProvider>
@@ -80,12 +89,13 @@ describe("End is offered only for a running course", () => {
it.each([
["CURRENT", true],
[undefined, true],
[undefined, false],
["ENDED", false],
["UPCOMING", false],
] as const)("course %s → End shown: %s", (status, shown) => {
expect(canEndCourse(status)).toBe(shown);
const html = render(status);
const served = status === undefined ? undefined : canEndCourse(status);
expect(served ?? false).toBe(shown);
const html = render(served);
expect(html.includes('data-slot="destructive-zone-end"')).toBe(shown);
// Pausing stays available whatever the course.
expect(html).toContain('id="medication-detail-pause-switch"');
@@ -13,8 +13,10 @@
* - Tier 1 Pausieren: PUT `{ active: false | true }`. Server derives
* `pausedAt`. Switch wraps in `<label>` so the entire row is the
* AT hit target.
* - Tier 2 Beenden: PUT `{ endsOn: <today YYYY-MM-DD> }`. AlertDialog
* confirm; Cancel autofocus stays default.
* - Tier 2 Beenden: POST `/courses/end` (#1280), no body: the server ends
* the running course at that moment, on the person's own day. Offered
* while the read serves `canEndCourse`. AlertDialog confirm; Cancel
* autofocus stays default.
* - Tier 3a Verlauf löschen: DELETE `/intake/purge`. Disabled when
* `intakeCount === 0`. AlertDialog confirm.
* - Tier 3b Medikament löschen: DELETE `/api/medications/[id]`.
@@ -46,33 +48,9 @@ import {
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateMedicationReads, queryKeys } from "@/lib/query-keys";
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
import type { CourseStatus } from "@/lib/medications/course-window";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
/**
* The PUT body that ends a course today: the person's own calendar day as
* `YYYY-MM-DD`, the only shape `endsOn` accepts. A full ISO instant was
* refused with 422 on every press, and its date was the UTC one, a day off
* around midnight for anyone not on UTC.
*/
export function endCourseBody(now: Date, timeZone: string): { endsOn: string } {
return { endsOn: userDayKey(now, timeZone) };
}
/**
* Whether "End" may be offered. Only a course that is running today can be
* ended today: the PUT rewrites the latest course's end, so pressing it on an
* ended course would reopen it up to today, and on a course that has not
* started it would turn into a one-day course today. Unknown (an older
* server) keeps the button.
*/
export function canEndCourse(courseStatus: CourseStatus | undefined): boolean {
return courseStatus === undefined || courseStatus === "CURRENT";
}
import { apiDelete, apiPost, apiPut } from "@/lib/api/api-fetch";
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
const PAUSE_TITLE_ID = "medication-detail-pause-title";
@@ -90,8 +68,12 @@ export function LifecycleManageBody({
id: string;
name: string;
active: boolean;
/** Where today sits in the course (server-resolved). */
courseStatus?: CourseStatus;
/**
* Whether a course is running that End may end now (server-resolved).
* Ending writes today as the course's last day, so on an ended course it
* would reopen it and on one not yet started invent a one-day course.
*/
canEndCourse?: boolean;
};
onAfterAction?: () => void;
}) {
@@ -158,7 +140,7 @@ export function LifecycleManageBody({
/>
</label>
{canEndCourse(medication.courseStatus) && (
{medication.canEndCourse === true && (
<EndCourseControl
medicationId={medicationId}
medicationName={medication.name}
@@ -185,8 +167,6 @@ function EndCourseControl({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const userTz = user?.timezone || DEFAULT_TIMEZONE;
const [endDialogOpen, setEndDialogOpen] = useState(false);
const [tier2Busy, setTier2Busy] = useState(false);
@@ -195,10 +175,7 @@ function EndCourseControl({
if (tier2Busy) return;
setTier2Busy(true);
try {
await apiPut(
`/api/medications/${medicationId}`,
endCourseBody(new Date(), userTz),
);
await apiPost(`/api/medications/${medicationId}/courses/end`);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.zone.end.toast"));
setEndDialogOpen(false);
@@ -207,6 +184,9 @@ function EndCourseControl({
showErrorToast(t("medications.detail.zone.end.failed"), {
cause: failure,
});
// A 409 means the course is no longer running (ended in another tab):
// refetch, so the served `canEndCourse` takes the button away.
void invalidateMedicationReads(queryClient);
} finally {
setTier2Busy(false);
}
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
canEndCourse,
canStartCourse,
courseEndNow,
courseFromWindow,
courseStatusAt,
courseStatusOn,
@@ -186,6 +188,29 @@ describe("ending a course now (#1280)", () => {
expect(at("2026-06-12T22:00:00.000Z")).toBe("ENDED");
});
it("offers End only while a course runs", () => {
expect(canEndCourse("CURRENT")).toBe(true);
expect(canEndCourse("ENDED")).toBe(false);
expect(canEndCourse("UPCOMING")).toBe(false);
// No dates at all is running.
expect(
canEndCourse(
courseStatusAt({ startsOn: null, endsOn: null }, ENDED_AT, "UTC"),
),
).toBe(true);
});
it("ends on the person's own day, at the moment itself", () => {
const late = new Date("2026-06-11T23:30:00.000Z");
expect(courseEndNow(late, "Europe/Warsaw")).toEqual({
endsOn: d("2026-06-12"),
endedAt: late,
});
expect(courseEndNow(late, "America/New_York").endsOn).toEqual(
d("2026-06-11"),
);
});
it("lets a new course start once the ended one is behind it", () => {
const base = { active: true, oneShot: false, todayKey: "2026-06-12" };
expect(canStartCourse({ ...base, courses: [MARCH, ended] })).toBe(true);
@@ -46,6 +46,7 @@ describe.each(ZONES)("course window in %s", (tz) => {
expect(r).toEqual({
courseStatus: "CURRENT",
intakeActionable: true,
canEndCourse: true,
});
}
});
@@ -55,6 +56,7 @@ describe.each(ZONES)("course window in %s", (tz) => {
expect(r).toEqual({
courseStatus: "ENDED",
intakeActionable: false,
canEndCourse: false,
});
});
@@ -63,10 +65,12 @@ describe.each(ZONES)("course window in %s", (tz) => {
expect(resolveIntakeActionability(med, at(tz, 24, 23, 59), tz)).toEqual({
courseStatus: "UPCOMING",
intakeActionable: false,
canEndCourse: false,
});
expect(resolveIntakeActionability(med, at(tz, 25, 0, 0), tz)).toEqual({
courseStatus: "CURRENT",
intakeActionable: true,
canEndCourse: true,
});
});
});
@@ -90,6 +94,7 @@ describe("resolveIntakeActionability", () => {
expect(r).toEqual({
courseStatus: "CURRENT",
intakeActionable: false,
canEndCourse: true,
});
});
@@ -102,6 +107,7 @@ describe("resolveIntakeActionability", () => {
expect(r).toEqual({
courseStatus: "CURRENT",
intakeActionable: false,
canEndCourse: true,
});
});
@@ -131,6 +137,7 @@ describe("a course ended with End course (#1280)", () => {
expect(before).toEqual({
courseStatus: "CURRENT",
intakeActionable: true,
canEndCourse: true,
});
const after = resolveIntakeActionability(
ended,
@@ -140,6 +147,7 @@ describe("a course ended with End course (#1280)", () => {
expect(after).toEqual({
courseStatus: "ENDED",
intakeActionable: false,
canEndCourse: false,
});
});
});
+23
View File
@@ -143,6 +143,29 @@ export function courseStatusAt(
return "CURRENT";
}
/**
* #1280 — the end "End course" writes: the person's own calendar day as the
* course's last day (never the UTC one, a day off around midnight), and the
* moment itself as `endedAt`.
*/
export function courseEndNow(
now: Date,
timeZone: string,
): { endsOn: Date; endedAt: Date } {
return { endsOn: dateOfDayKey(userDayKey(now, timeZone)), endedAt: now };
}
/**
* #1280 — whether "End course" may be pressed: a course is running now. On
* a course that has ended, ending it again would reopen it up to today; on
* one that has not started, it would turn into a one-day course today. The
* medication read wire publishes the answer as `canEndCourse`, and the End
* action refuses when it is false.
*/
export function canEndCourse(courseStatus: CourseStatus): boolean {
return courseStatus === "CURRENT";
}
export type CourseRefusal =
/** `endsOn` before `startsOn`. */
| "invalidRange"
+98 -4
View File
@@ -10,16 +10,21 @@
* and `intakeActionable` — which read the columns — can never disagree with
* the course list.
*
* #1280 — a course may carry `endedAt`, the instant it was ended at. Every
* write that moves a course's `endsOn` clears it: an end date chosen in the
* editor means the whole day.
* #1280 — "End course" (`endCurrentCourse`) ends the running course at the
* moment it is pressed: `endsOn` becomes today and `endedAt` records the
* instant, so no slot after it is due, reminded or expected. Every other
* write that moves a course's `endsOn` clears `endedAt`: an end date chosen
* in the editor means the whole day.
*/
import type { Prisma } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { prisma } from "@/lib/db";
import {
canEndCourse,
canStartCourse,
courseEndNow,
courseFromWindow,
courseStatusAt,
courseStatusOn,
dateOfDayKey,
dayKeyOfDate,
@@ -43,7 +48,9 @@ export type CourseWriteRefusal =
/** Clearing the window of a medication that has several courses. */
| "windowRequired"
/** The course or the medication is not the caller's. */
| "notFound";
| "notFound"
/** #1280 — "End course" on a medication with no course running now. */
| "notRunning";
export class CourseWriteError extends Error {
constructor(readonly refusal: CourseWriteRefusal) {
@@ -89,6 +96,12 @@ export function courseRefusalResponse(refusal: CourseWriteRefusal): {
errorCode: "medications.course.invalidRange",
message: "A course cannot end before it starts",
};
case "notRunning":
return {
status: 409,
errorCode: "medications.course.notRunning",
message: "No course is running to end",
};
case "windowRequired":
return {
status: 422,
@@ -450,6 +463,87 @@ function keptEndedAt(
: null;
}
/**
* #1280 — "End course": end the running course now.
*
* The course's last day becomes today on the person's clock and `endedAt`
* records the moment. Doses earlier today stay inside the course and count;
* a slot later today is not due, not reminded and not expected, and the
* course reads as ENDED at once (`courseStatusAt`). A medication without a
* course row (chronic since creation, or a window never materialised) gets
* the course its window describes, ended now.
*
* The pending placeholders already minted for slots at or after that moment
* (the today projector mints the whole day) are tombstoned, as a schedule
* replace does, so no list shows them and the auto-miss pass never stamps
* them a miss. An actioned row is history and stays.
*
* Refused with `notRunning` unless a course is running now
* (`canEndCourse`): ending an ended course would reopen it up to today, and
* ending one that has not started would invent a one-day course.
*/
export async function endCurrentCourse(
args: WriteContext,
): Promise<ProjectedWindow> {
const now = args.now ?? new Date();
const todayKey = userDayKey(now, args.timeZone);
const { endsOn: today, endedAt } = courseEndNow(now, args.timeZone);
return prisma.$transaction(async (tx) => {
await lockMedication(tx, args.medicationId);
const { medication, courses } = await loadOwned(
tx,
args.userId,
args.medicationId,
);
if (!canEndCourse(courseStatusAt(medication, now, args.timeZone))) {
throw new CourseWriteError("notRunning");
}
const latest = sortCourses(courses).at(-1);
if (latest) {
const ended = { startsOn: latest.startsOn, endsOn: today, endedAt };
refuseIfInvalid(
courses.map((c) => (c.id === latest.id ? ended : c)),
todayKey,
medication.oneShot,
);
await tx.medicationCourse.update({
where: { id: latest.id },
data: { endsOn: today, endedAt },
});
} else {
const implicit = windowAsCourse(
medication.startsOn,
today,
undefined,
medication.createdAt,
args.timeZone,
);
refuseIfInvalid([implicit], todayKey, medication.oneShot);
await tx.medicationCourse.create({
data: {
medicationId: args.medicationId,
userId: args.userId,
...implicit,
endedAt,
},
});
}
await tx.medicationIntakeEvent.updateMany({
where: {
userId: args.userId,
medicationId: args.medicationId,
deletedAt: null,
takenAt: null,
skipped: false,
autoMissed: false,
scheduledFor: { gte: now },
},
data: { deletedAt: now, syncVersion: { increment: 1 } },
});
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
+7 -2
View File
@@ -23,10 +23,13 @@
* Logging a past dose from the history is a different verb and is not
* gated here; an ended course can still be completed after the fact.
*
* #1280 — a course ended at an instant (`endedAt`) is ENDED from it, so the
* action row goes away at once instead of at midnight.
* #1280 — `canEndCourse` rides along: "End course" is offered exactly while
* the course is CURRENT, from the same resolution. A course ended with it is
* ENDED from that instant (`endedAt`), so the action row and the End button
* go away at once instead of at midnight.
*/
import {
canEndCourse,
courseStatusAt,
type CourseStatus,
} from "@/lib/medications/course-window";
@@ -35,6 +38,7 @@ import { isRecordOnly } from "@/lib/medications/intake-tracking";
export interface IntakeActionability {
courseStatus: CourseStatus;
intakeActionable: boolean;
canEndCourse: boolean;
}
export function resolveIntakeActionability(
@@ -55,5 +59,6 @@ export function resolveIntakeActionability(
medication.active &&
!isRecordOnly(medication) &&
courseStatus === "CURRENT",
canEndCourse: canEndCourse(courseStatus),
};
}
+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()
@@ -0,0 +1,488 @@
/**
* "End course" (#1280) against a real Postgres, through the shipped routes,
* the reminder worker, the today projector and the auto-miss pass.
*
* What the file pins:
* - the medication's PUT takes `endsOn` as a date (the request the End
* button used to send as a full instant was refused with 422), and an
* end date set that way keeps meaning the whole day;
* - `POST /courses/end` ends the running course at that moment: the last
* day is today on the person's clock and `endedAt` the instant, and the
* reads serve it ENDED with `canEndCourse` false at once;
* - a dose slot later that day is not due (no placeholder on the today
* list), not reminded and never missed, while a dose taken earlier that
* day still counts in compliance;
* - `canEndCourse` is true only while a course runs, and the action refuses
* an ended or upcoming course with 409;
* - an edit that moves the end clears the instant.
*
* The person lives in Europe/Berlin (UTC+2 in June). The medication doses at
* 08:00 and 20:00 local, 06:00Z and 18:00Z.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
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),
}));
vi.mock("@/lib/notifications/dispatcher", () => ({
dispatchNotification: vi.fn().mockResolvedValue({
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
}),
}));
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
const { dispatchNotification } = await import("@/lib/notifications/dispatcher");
const { runIntakeAutoSkipPass } = await import("@/lib/jobs/intake-auto-skip");
const { handleReminderCheck } =
await import("@/lib/jobs/reminder/medication-reminder-check");
const TZ = "Europe/Berlin";
const MORNING = "2026-06-10T06:00:00.000Z";
const EVENING = "2026-06-10T18:00:00.000Z";
const ENDED_AT = "2026-06-10T10:00:00.000Z";
type Handler = (
request: NextRequest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { params: Promise<any> },
) => Promise<Response>;
function at(iso: string): void {
vi.setSystemTime(new Date(iso));
}
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 call(
modulePath: string,
method: "GET" | "POST" | "PUT" | "PATCH",
path: string,
params: Record<string, string>,
body?: unknown,
) {
const route = (await import(/* @vite-ignore */ modulePath)) as Record<
string,
Handler
>;
const res = await route[method](req(method, path, body), {
params: Promise.resolve(params),
});
return { status: res.status, body: await res.json() };
}
const endCourse = (id: string) =>
call(
"@/app/api/medications/[id]/courses/end/route",
"POST",
`/api/medications/${id}/courses/end`,
{ id },
);
const putMedication = (id: string, body: unknown) =>
call(
"@/app/api/medications/[id]/route",
"PUT",
`/api/medications/${id}`,
{ id },
body,
);
const detail = async (id: string) =>
(
await call(
"@/app/api/medications/[id]/route",
"GET",
`/api/medications/${id}`,
{ id },
)
).body.data;
async function makeUser(): Promise<string> {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "course-end",
email: "course-end@example.test",
timezone: TZ,
locale: "en",
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date("2030-01-01T00:00:00.000Z"),
},
});
cookieJar.set("healthlog_session", session.id);
return user.id;
}
/** Twice a day, 08:00 and 20:00, created early on the 10th. */
async function seedTwiceDaily(
userId: string,
window: { startsOn?: Date; endsOn?: Date } = {},
): Promise<string> {
const medication = await getPrismaClient().medication.create({
data: {
userId,
name: "Amoxicillin",
dose: "500 mg",
createdAt: new Date("2026-06-10T03:00:00.000Z"),
notificationsEnabled: true,
...window,
schedules: {
create: {
windowStart: "08:00",
windowEnd: "08:00",
timesOfDay: ["08:00", "20:00"],
rrule: "FREQ=DAILY",
scheduleType: "SCHEDULED",
},
},
},
select: { id: true },
});
return medication.id;
}
async function takeMorningDose(userId: string, medicationId: string) {
await getPrismaClient().medicationIntakeEvent.create({
data: {
userId,
medicationId,
scheduledFor: new Date(MORNING),
takenAt: new Date("2026-06-10T06:10:00.000Z"),
source: "WEB",
},
});
}
async function liveRowsOn(medicationId: string, scheduledFor: string) {
return getPrismaClient().medicationIntakeEvent.findMany({
where: {
medicationId,
deletedAt: null,
scheduledFor: new Date(scheduledFor),
},
});
}
function dispatchedFor(medicationId: string): number {
return vi
.mocked(dispatchNotification)
.mock.calls.filter(
([arg]) =>
(arg as { metadata?: { medicationId?: string } }).metadata
?.medicationId === medicationId,
).length;
}
beforeEach(async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.mocked(dispatchNotification).mockClear();
cookieJar.clear();
headerJar.clear();
await truncateAllTables(getPrismaClient());
});
afterEach(() => {
vi.useRealTimers();
});
describe("the medication PUT takes the end as a date (#1280)", () => {
it("answers 200 for a date and 422 for the full instant the button used to send", async () => {
at(ENDED_AT);
const userId = await makeUser();
const id = await seedTwiceDaily(userId);
const instant = await putMedication(id, { endsOn: ENDED_AT });
expect(instant.status).toBe(422);
const date = await putMedication(id, { endsOn: "2026-06-10" });
expect(date.status, JSON.stringify(date.body)).toBe(200);
// An end date written this way is the whole day: still running, still
// offering End, no instant recorded.
const read = await detail(id);
expect(read.courseStatus).toBe("CURRENT");
expect(read.canEndCourse).toBe(true);
expect(read.endedAt).toBeNull();
expect(read.courses[0]).toMatchObject({
endsOn: "2026-06-10",
endedAt: null,
});
});
});
describe("POST /courses/end ends the course now", () => {
it("records today and the instant, and the reads serve it ended at once", async () => {
at(ENDED_AT);
const userId = await makeUser();
const id = await seedTwiceDaily(userId);
expect((await detail(id)).canEndCourse).toBe(true);
const res = await endCourse(id);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.data).toEqual({
startsOn: "2026-06-10",
endsOn: "2026-06-10",
endedAt: ENDED_AT,
});
const course = await getPrismaClient().medicationCourse.findFirstOrThrow({
where: { medicationId: id },
});
expect(course.endedAt?.toISOString()).toBe(ENDED_AT);
const read = await detail(id);
expect(read).toMatchObject({
endedAt: ENDED_AT,
courseStatus: "ENDED",
canEndCourse: false,
intakeActionable: false,
remindersActive: false,
canStartCourse: true,
});
expect(read.courses[0]).toMatchObject({
status: "ENDED",
endsOn: "2026-06-10",
endedAt: ENDED_AT,
});
// A second press finds nothing running.
const again = await endCourse(id);
expect(again.status).toBe(409);
expect(again.body.meta.errorCode).toBe("medications.course.notRunning");
});
it("drops the evening dose: not listed, not reminded, never missed", async () => {
at("2026-06-10T09:00:00.000Z");
const userId = await makeUser();
const id = await seedTwiceDaily(userId);
// The today list mints both placeholders before the course is ended.
const today = () =>
call(
"@/app/api/medications/intake/route",
"GET",
"/api/medications/intake?scope=today",
{},
);
expect((await today()).status).toBe(200);
expect(await liveRowsOn(id, EVENING)).toHaveLength(1);
at(ENDED_AT);
expect((await endCourse(id)).status).toBe(200);
expect(await liveRowsOn(id, EVENING)).toHaveLength(0);
// The today list does not bring it back.
at("2026-06-10T12:00:00.000Z");
expect((await today()).status).toBe(200);
expect(await liveRowsOn(id, EVENING)).toHaveLength(0);
// Through the evening window and its follow-ups: no reminder, no
// missed-dose placeholder.
for (const tick of [
"2026-06-10T18:00:00.000Z",
"2026-06-10T18:45:00.000Z",
"2026-06-10T19:30:00.000Z",
"2026-06-10T21:00:00.000Z",
]) {
at(tick);
await handleReminderCheck([]);
}
expect(dispatchedFor(id)).toBe(0);
expect(await liveRowsOn(id, EVENING)).toHaveLength(0);
// Two days on, the auto-miss pass stamps the morning dose, which fell
// due while the course ran and was never taken, and nothing after the
// end.
await runIntakeAutoSkipPass(getPrismaClient(), {
nowMs: Date.parse("2026-06-12T12:00:00.000Z"),
});
const missed = await getPrismaClient().medicationIntakeEvent.findMany({
where: { medicationId: id, autoMissed: true },
select: { scheduledFor: true },
});
expect(missed.map((r) => r.scheduledFor.toISOString())).toEqual([MORNING]);
});
it("lists no evening dose when the day's list is first read after the end", async () => {
at(ENDED_AT);
const userId = await makeUser();
const id = await seedTwiceDaily(userId);
expect((await endCourse(id)).status).toBe(200);
const today = await call(
"@/app/api/medications/intake/route",
"GET",
"/api/medications/intake?scope=today",
{},
);
expect(today.status).toBe(200);
expect(await liveRowsOn(id, MORNING)).toHaveLength(1);
expect(await liveRowsOn(id, EVENING)).toHaveLength(0);
});
it("keeps the morning dose taken before the end in compliance", async () => {
at(ENDED_AT);
const userId = await makeUser();
const id = await seedTwiceDaily(userId);
await takeMorningDose(userId, id);
expect((await endCourse(id)).status).toBe(200);
at("2026-06-11T08:00:00.000Z");
const compliance = await call(
"@/app/api/medications/[id]/compliance/route",
"GET",
`/api/medications/${id}/compliance`,
{ id },
);
expect(compliance.status).toBe(200);
expect(compliance.body.data.compliance7).toMatchObject({
totalExpected: 1,
taken: 1,
missed: 0,
rate: 100,
});
const history = await call(
"@/app/api/medications/[id]/dose-history/route",
"GET",
`/api/medications/${id}/dose-history?from=2026-06-10T00:00:00.000Z`,
{ id },
);
expect(history.status).toBe(200);
const slots = (
history.body.data.rows as Array<{
kind: string;
at: string;
status: string;
}>
).filter((r) => r.kind === "slot");
expect(slots.map((r) => [r.at, r.status])).toEqual([
[MORNING, "taken_on_time"],
]);
});
it("ends a medication without dates from its creation day", async () => {
at(ENDED_AT);
const userId = await makeUser();
const id = await seedTwiceDaily(userId);
expect(
await getPrismaClient().medicationCourse.count({
where: { medicationId: id },
}),
).toBe(0);
expect((await endCourse(id)).status).toBe(200);
const row = await getPrismaClient().medication.findUniqueOrThrow({
where: { id },
select: { startsOn: true, endsOn: true, endedAt: true },
});
expect(row.startsOn?.toISOString().slice(0, 10)).toBe("2026-06-10");
expect(row.endsOn?.toISOString().slice(0, 10)).toBe("2026-06-10");
expect(row.endedAt?.toISOString()).toBe(ENDED_AT);
});
});
describe("canEndCourse follows the course", () => {
it.each([
["running (no dates)", {}, true],
[
"running (dated)",
{ startsOn: new Date("2026-06-01T00:00:00.000Z") },
true,
],
["upcoming", { startsOn: new Date("2026-06-20T00:00:00.000Z") }, false],
[
"ended",
{
startsOn: new Date("2026-05-01T00:00:00.000Z"),
endsOn: new Date("2026-05-31T00:00:00.000Z"),
},
false,
],
] as const)("%s → %s", async (_label, window, expected) => {
at(ENDED_AT);
const userId = await makeUser();
const id = await seedTwiceDaily(userId, window);
expect((await detail(id)).canEndCourse).toBe(expected);
const list = await call(
"@/app/api/medications/route",
"GET",
"/api/medications",
{},
);
const entry = (
list.body.data as Array<{ id: string; canEndCourse: boolean }>
).find((m) => m.id === id);
expect(entry?.canEndCourse).toBe(expected);
const res = await endCourse(id);
expect(res.status).toBe(expected ? 200 : 409);
});
});
describe("an edit that moves the end clears the instant", () => {
it("reopens the course to the end of its new last day", async () => {
at(ENDED_AT);
const userId = await makeUser();
const id = await seedTwiceDaily(userId);
expect((await endCourse(id)).status).toBe(200);
const course = await getPrismaClient().medicationCourse.findFirstOrThrow({
where: { medicationId: id },
});
// Same last day, only the note: the instant stays.
const note = await call(
"@/app/api/medications/[id]/courses/[courseId]/route",
"PATCH",
`/api/medications/${id}/courses/${course.id}`,
{ id, courseId: course.id },
{ note: "Stopped after the morning dose" },
);
expect(note.status, JSON.stringify(note.body)).toBe(200);
expect((await detail(id)).endedAt).toBe(ENDED_AT);
// A later last day: a whole-day end again, running once more.
const moved = await call(
"@/app/api/medications/[id]/courses/[courseId]/route",
"PATCH",
`/api/medications/${id}/courses/${course.id}`,
{ id, courseId: course.id },
{ endsOn: "2026-06-12" },
);
expect(moved.status, JSON.stringify(moved.body)).toBe(200);
const read = await detail(id);
expect(read).toMatchObject({
endedAt: null,
courseStatus: "CURRENT",
canEndCourse: true,
});
});
});