diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b92152575..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, @@ -42034,6 +42078,11 @@ components: 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 + minLength: 1 + - type: "null" note: anyOf: - type: string @@ -53787,9 +53836,10 @@ components: type: boolean description: "v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or - later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and - `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local - reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored." + later on the person's clock, or unset, and no `endedAt` has passed), its intake is tracked (`trackIntake`), + it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A + client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, + not stored." nextDueAt: anyOf: - type: string @@ -53813,6 +53863,15 @@ components: 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: 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 `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 + 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" oneShot: type: boolean description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / @@ -53910,12 +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. 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 @@ -54001,6 +54067,7 @@ components: - nextDueScheduleId - courseStatus - intakeActionable + - canEndCourse - lastTakenAt - todayEventCount - stockUnitsRemaining @@ -54037,13 +54104,23 @@ components: 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" description: Last day, inclusive. NULL = open (only the latest course). + 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" + 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: - UPCOMING - CURRENT - ENDED - description: Where today, on the person's clock, sits in this course. + description: Where today, on the person's clock, sits in this course. A course with `endedAt` is ENDED on its `endsOn` + day already. takenDoses: type: integer minimum: 0 @@ -54058,6 +54135,7 @@ components: - id - startsOn - endsOn + - endedAt - status - takenDoses - note @@ -54244,6 +54322,15 @@ components: 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: 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 `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 + 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" oneShot: type: boolean description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / @@ -54336,12 +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. 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." @@ -54408,6 +54502,7 @@ components: - canStartCourse - courseStatus - intakeActionable + - canEndCourse - stockUnitsRemaining - stockDosesRemaining - runwayDays @@ -54688,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: @@ -93793,6 +93929,15 @@ components: 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: 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 `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 + 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" oneShot: type: boolean description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / diff --git a/prisma/migrations/0482_medication_course_ended_at/migration.sql b/prisma/migrations/0482_medication_course_ended_at/migration.sql new file mode 100644 index 000000000..0b7385f57 --- /dev/null +++ b/prisma/migrations/0482_medication_course_ended_at/migration.sql @@ -0,0 +1,29 @@ +-- "End course" ends a course at the moment it is pressed (#1280). +-- +-- A course's `ends_on` is a calendar day and counts in full: the doses of +-- that day stay due, reminded and expected until local midnight. That is the +-- right reading for an end date written in the course editor ("the last day +-- of the pack"), and the wrong one for "End course", whose dialog promises +-- that nothing more is due. `ended_at` records the instant the course was +-- ended that way: it lies on the `ends_on` day, a dose slot at or after it +-- is not part of the course, and the course reads as ended from then on. +-- NULL keeps the full-day reading every existing course has. +-- +-- `medications.ended_at` is the projection of the latest course's value, +-- written next to `starts_on` / `ends_on` by the one course writer, so the +-- engines that read the medication's window read it too. +-- +-- Nullable, no default, no backfill: no existing course was ended this way. +-- Reversibility: dropping both columns restores the full-day reading. +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + ALTER TABLE "medication_courses" + ADD COLUMN IF NOT EXISTS "ended_at" TIMESTAMPTZ(6); + ALTER TABLE "medications" + ADD COLUMN IF NOT EXISTS "ended_at" TIMESTAMPTZ(6); +END +$migration$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a9c2ba02e..6ef090a1d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2903,6 +2903,12 @@ model Medication { /// minting slots past this date. NULL = no end date (chronic). /// When `oneShot` is true, `endsOn` always equals `startsOn`. endsOn DateTime? @map("ends_on") @db.Date + /// #1280 — the instant the latest course was ended with "End course", the + /// projection of `MedicationCourse.endedAt` written next to `startsOn` / + /// `endsOn` by `src/lib/medications/courses.ts` only. On the `endsOn` day; + /// no dose slot at or after it is due, reminded or expected, and the course + /// reads as ended from it on. NULL = the course runs to the end of `endsOn`. + endedAt DateTime? @map("ended_at") @db.Timestamptz(6) /// v1.5 — single-administration medication (flu shot, vaccine, /// post-op single dose, etc.). When true the medication has at /// most one MedicationIntakeEvent and `active` flips to false once @@ -3157,6 +3163,13 @@ model MedicationCourse { startsOn DateTime @map("starts_on") @db.Date /// Last day, inclusive. NULL = open (only the latest course may be open). endsOn DateTime? @map("ends_on") @db.Date + /// #1280 — set by "End course": the instant the course ended, on the + /// `endsOn` day. A dose slot at or after it is not part of the course (not + /// due, not reminded, not expected by compliance), so the course ends when + /// the person ends it rather than at local midnight. NULL = the whole + /// `endsOn` day counts, the reading of an end date set in the editor. Any + /// later write that moves `endsOn` clears it. + endedAt DateTime? @map("ended_at") @db.Timestamptz(6) /// Why this course, in the person's words. AES-256-GCM at rest. noteEncrypted Bytes? @map("note_encrypted") createdAt DateTime @default(now()) @map("created_at") 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/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 7c7a0acf6..4b1bff5d6 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 87, - "total": 97468, + "total": 97455, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -13,7 +13,7 @@ "components/insights/health-score-card.tsx": 1023, "components/insights/insights-tab-strip.tsx": 1086, "components/measurements/measurement-form.tsx": 815, - "components/medications/detail/medication-detail-tabs.tsx": 1160, + "components/medications/detail/medication-detail-tabs.tsx": 1157, "components/medications/medication-table.tsx": 853, "components/medications/wizard/wizard-payload.ts": 1414, "components/mood/mood-list.tsx": 1408, @@ -76,8 +76,8 @@ "lib/mcp/rich-reads.ts": 1467, "lib/measurements/dense-intraday-retention.ts": 1057, "lib/measurements/import-apple-health-export.ts": 1722, - "lib/medications/scheduling/cadence.ts": 978, - "lib/medications/scheduling/recurrence.ts": 1108, + "lib/medications/scheduling/cadence.ts": 976, + "lib/medications/scheduling/recurrence.ts": 1100, "lib/medications/scheduling/slot-upsert.ts": 912, "lib/notifications/senders/apns.ts": 1100, "lib/oura/sync.ts": 900, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index b20fece34..757ff4a68 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 351, - "total": 113681, + "total": 113672, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -152,7 +152,7 @@ "components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352, "components/measurements/measurement-form.tsx :: MeasurementForm": 500, "components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361, - "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 771, + "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 767, "components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223, "components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266, "components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405, @@ -248,7 +248,7 @@ "lib/ai/anthropic-client.ts :: generateCompletion": 294, "lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218, "lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218, - "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 882, + "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 881, "lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438, "lib/ai/coach/turn/model.ts :: runTurnModel": 540, "lib/ai/coach/turn/pipeline.ts :: runTurn": 274, @@ -276,7 +276,7 @@ "lib/doctor-report-pdf/clinical-records-notes-section.ts :: buildClinicalRecordsNotesSection": 648, "lib/doctor-report-pdf/measurements-charts-section.ts :: buildMeasurementsChartsSection": 384, "lib/doctor-report-pdf/medication-mood-wellness-section.ts :: buildMedicationMoodWellnessSection": 509, - "lib/doctor-report/collect.ts :: collectDoctorReportData": 668, + "lib/doctor-report/collect.ts :: collectDoctorReportData": 667, "lib/doctor-report/dense-buckets.ts :: loadDenseMeasurementBuckets": 210, "lib/documents/fenced-chat.ts :: streamFencedReply": 309, "lib/documents/ingest.ts :: ingestDocument": 272, @@ -333,8 +333,8 @@ "lib/measurements/import-apple-health-export.ts :: streamParseExportXml": 1245, "lib/measurements/import-apple-health-export.ts :: streamParseExportXml > (anonymous) #2": 323, "lib/medications/efficacy/build-efficacy.ts :: buildMedicationEfficacy": 268, - "lib/medications/intake-slot-dedup.ts :: dedupeUserIntakeSlots": 321, - "lib/medications/list-read.ts :: buildMedicationsList": 309, + "lib/medications/intake-slot-dedup.ts :: dedupeUserIntakeSlots": 319, + "lib/medications/list-read.ts :: buildMedicationsList": 308, "lib/medications/scheduling/dose-history.ts :: reconstructDoseHistory": 218, "lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272, "lib/notifications/dispatcher.ts :: dispatchNotification": 378, diff --git a/src/__tests__/recurrence-course-anchor-guard.test.ts b/src/__tests__/recurrence-course-anchor-guard.test.ts index c7e9fd1ca..6ed1d14b8 100644 --- a/src/__tests__/recurrence-course-anchor-guard.test.ts +++ b/src/__tests__/recurrence-course-anchor-guard.test.ts @@ -15,6 +15,12 @@ * which it is. The scan asserts a floor on what it found, so a matcher that * stopped matching fails instead of agreeing with an empty tree. * + * #1280 — the same list carries the instant "End course" ended a course: + * every medication builder threads `endedAt` into the context, or the + * engine would keep a dose due after the course was ended. A builder passes + * when its block names `endedAt` or spreads a window that carries it + * (`...window` from `mintWindow`, `...medicationWindow(row)`). + * * Limit, stated: a literal passed straight to the engine without the * `RecurrenceContext` annotation is invisible here. Every medication path * today goes through one of the annotated builders below. @@ -69,19 +75,22 @@ function blockAt(text: string, open: number): string { interface Builder { file: string; declaresCourseStart: boolean; + threadsEndedAt: boolean; } +const THREADS_ENDED_AT = /\bendedAt\b|\.\.\.\s*(window\b|medicationWindow\()/; + function builders(): Builder[] { const out: Builder[] = []; for (const file of sourceFiles(SRC)) { const text = readSourceText(file); for (const m of text.matchAll(DECLARATION)) { const open = m.index! + m[0].length - 1; + const block = blockAt(text, open); out.push({ file: relative(ROOT, file), - declaresCourseStart: /rollingAnchor\s*:\s*"courseStart"/.test( - blockAt(text, open), - ), + declaresCourseStart: /rollingAnchor\s*:\s*"courseStart"/.test(block), + threadsEndedAt: THREADS_ENDED_AT.test(block), }); } } @@ -114,6 +123,18 @@ describe("the rolling course anchor is opt-in", () => { expect(missing).toEqual([]); }); + it("has every medication builder thread the instant a course was ended", () => { + const medication = builders().filter((b) => + MEDICATION_BUILDERS.includes(b.file), + ); + expect(medication.length).toBeGreaterThanOrEqual(3); + expect(medication.filter((b) => !b.threadsEndedAt)).toEqual([]); + // The matcher, held against what it must and must not accept. + expect(THREADS_ENDED_AT.test("endedAt: row.endedAt,")).toBe(true); + expect(THREADS_ENDED_AT.test("...medicationWindow(row),")).toBe(true); + expect(THREADS_ENDED_AT.test("endsOn: row.endsOn,")).toBe(false); + }); + it("keeps every other builder on the default (the last intake anchors)", () => { const declaring = builders().filter( (b) => NON_MEDICATION_BUILDERS.includes(b.file) && b.declaresCourseStart, diff --git a/src/__tests__/tz-day-paths-harness.test.ts b/src/__tests__/tz-day-paths-harness.test.ts index 598ea1108..edd870974 100644 --- a/src/__tests__/tz-day-paths-harness.test.ts +++ b/src/__tests__/tz-day-paths-harness.test.ts @@ -155,6 +155,7 @@ describe.each(EDGES)("day paths with the host in $zone", (edge) => { startsOn: null, // `endsOn` is a @db.Date column: the calendar date as UTC midnight. endsOn: dayKeyAsUtcMidnight(edge.localDay), + endedAt: null, }, edge.now, edge.zone, diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 9758c75e4..4ab806ca6 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -285,6 +285,8 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // v1.40 (#1024) — a medication's courses, same again. "src/app/api/medications/[id]/courses/route.ts", "src/app/api/medications/[id]/courses/[courseId]/route.ts", + // #1280 — ending the running course is a write to the same course rows. + "src/app/api/medications/[id]/courses/end/route.ts", "src/app/api/medications/compliance/route.ts", "src/app/api/medications/intake/route.ts", "src/app/api/medications/intake/bulk/route.ts", 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/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 61b323b4d..900a7cded 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -234,7 +234,7 @@ export const GET = apiHandler( createdAt: medication.createdAt, }; const ctx = buildRecurrenceContext({ - medication: bandMedication, + medication: { ...bandMedication, endedAt: medication.endedAt }, userTz, lastIntakeAt, }); diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 39a7a8b7b..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. */ @@ -923,11 +924,7 @@ export function MedicationDetailTabs({ defaultOpen={false} >
- +
{/* v1.40 (#1024) — the courses: when it was taken, and a new course once the last one has ended. */} diff --git a/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx b/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx new file mode 100644 index 000000000..ca6b40a6e --- /dev/null +++ b/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + canEndCourse, + 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: () => ({ + user: { id: "test-user", username: "tester", role: "USER" }, + isAuthenticated: true, + isLoading: false, + }), +})); + +/** + * "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. 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", () => { + const body = endCourseBody( + new Date("2026-10-21T09:30:00.000Z"), + "Europe/Berlin", + ); + expect(body).toEqual({ endsOn: "2026-10-21" }); + expect(updateMedicationSchema.safeParse(body).success).toBe(true); + }); + + it("is the person's day, not the UTC one", () => { + // 23:30 UTC is already the next day in Warsaw and still the same day in + // New York. + const lateUtc = new Date("2026-10-20T23:30:00.000Z"); + expect(endCourseBody(lateUtc, "Europe/Warsaw").endsOn).toBe("2026-10-21"); + expect(endCourseBody(lateUtc, "America/New_York").endsOn).toBe( + "2026-10-20", + ); + }); + + it("is what the update schema refused before: a full instant is not a date", () => { + expect( + updateMedicationSchema.safeParse({ + endsOn: new Date("2026-10-21T09:30:00.000Z").toISOString(), + }).success, + ).toBe(false); + }); +}); + +/** + * 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. 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(served: boolean | undefined) { + return renderToStaticMarkup( + + + + + , + ); + } + + it.each([ + ["CURRENT", true], + [undefined, false], + ["ENDED", false], + ["UPCOMING", false], + ] as const)("course %s → End shown: %s", (status, shown) => { + 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 b8e247d76..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 `