From 275367f6e9d8a60b348d85b6ec6c0fe6e49a769e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 24 Oct 2026 22:24:15 +0200 Subject: [PATCH] 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 --- docs/api/openapi.yaml | 229 +++++--- .../aging-fixture-dates-guard.test.ts | 5 + .../api/medications/[id]/courses/end/route.ts | 78 +++ .../detail/medication-detail-tabs.tsx | 1 + .../destructive-zone-section.test.tsx | 36 +- .../sections/destructive-zone-section.tsx | 52 +- .../__tests__/course-window.test.ts | 25 + .../__tests__/intake-actionable.test.ts | 8 + src/lib/medications/course-window.ts | 23 + src/lib/medications/courses.ts | 102 +++- src/lib/medications/intake-actionable.ts | 9 +- src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/medications/paths.ts | 39 ++ src/lib/openapi/routes/medications/schemas.ts | 18 +- .../integration/medication-course-end.test.ts | 488 ++++++++++++++++++ 15 files changed, 990 insertions(+), 124 deletions(-) create mode 100644 src/app/api/medications/[id]/courses/end/route.ts create mode 100644 tests/integration/medication-course-end.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 061a59857..d9ce7210b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/__tests__/aging-fixture-dates-guard.test.ts b/src/__tests__/aging-fixture-dates-guard.test.ts index b4db923dd..ba785e961 100644 --- a/src/__tests__/aging-fixture-dates-guard.test.ts +++ b/src/__tests__/aging-fixture-dates-guard.test.ts @@ -73,6 +73,11 @@ const ALLOWLIST: Record = { 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: diff --git a/src/app/api/medications/[id]/courses/end/route.ts b/src/app/api/medications/[id]/courses/end/route.ts new file mode 100644 index 000000000..3a3088282 --- /dev/null +++ b/src/app/api/medications/[id]/courses/end/route.ts @@ -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 { + 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; + } +} diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 9cefc459a..12f6c37de 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -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. */ diff --git a/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx b/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx index 5992803ea..ca6b40a6e 100644 --- a/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx +++ b/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx @@ -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( @@ -70,7 +79,7 @@ describe("End is offered only for a running course", () => { id: "med-1", name: "Example", active: true, - courseStatus, + canEndCourse: served, }} /> @@ -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"'); diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx index ae1c193c2..f048f06a6 100644 --- a/src/components/medications/sections/destructive-zone-section.tsx +++ b/src/components/medications/sections/destructive-zone-section.tsx @@ -13,8 +13,10 @@ * - Tier 1 Pausieren: PUT `{ active: false | true }`. Server derives * `pausedAt`. Switch wraps in ` - {canEndCourse(medication.courseStatus) && ( + {medication.canEndCourse === true && ( { 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); diff --git a/src/lib/medications/__tests__/intake-actionable.test.ts b/src/lib/medications/__tests__/intake-actionable.test.ts index 5c145d1d0..5fdfac777 100644 --- a/src/lib/medications/__tests__/intake-actionable.test.ts +++ b/src/lib/medications/__tests__/intake-actionable.test.ts @@ -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, }); }); }); diff --git a/src/lib/medications/course-window.ts b/src/lib/medications/course-window.ts index 333feff9a..a97d9711c 100644 --- a/src/lib/medications/course-window.ts +++ b/src/lib/medications/course-window.ts @@ -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" diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts index 2e6ce8772..73471fead 100644 --- a/src/lib/medications/courses.ts +++ b/src/lib/medications/courses.ts @@ -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 { + 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 diff --git a/src/lib/medications/intake-actionable.ts b/src/lib/medications/intake-actionable.ts index cd586a720..4aaf27bcf 100644 --- a/src/lib/medications/intake-actionable.ts +++ b/src/lib/medications/intake-actionable.ts @@ -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), }; } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 7433c5490..eefaaf11d 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -312,6 +312,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "medications.course.invalid", "medications.course.invalidRange", "medications.course.notFound", + "medications.course.notRunning", "medications.course.oneShot", "medications.course.overlap", "medications.course.windowRequired", diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index d5ef6928f..0f8961e85 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -1178,6 +1178,45 @@ export const medicationPaths: NonNullable = { }, }, }, + "/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"], diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 2e6c11d0c..6a5d44487 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -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() diff --git a/tests/integration/medication-course-end.test.ts b/tests/integration/medication-course-end.test.ts new file mode 100644 index 000000000..f60717e17 --- /dev/null +++ b/tests/integration/medication-course-end.test.ts @@ -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 }, +) => Promise; + +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, + 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 { + 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 { + 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, + }); + }); +});