mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(medications): end a course now through its own action
"End course" now posts to `POST /api/medications/{id}/courses/end`, which
ends the running course at that moment: its last day becomes today on the
person's clock and the instant is stored as `endedAt`, so a dose later today
is not due, not reminded and never missed, while one taken earlier still
counts. Pending placeholders already minted for later slots are tombstoned.
A medication without dates gets the course its window describes, ended now.
End stays its own verb instead of `PUT { endsOn }`: an end date set through
the PUT or the course editor keeps meaning the whole day, and the PUT keeps
accepting it on any course, since moving an end is a legitimate edit.
The list and detail reads serve `canEndCourse`, true exactly while a course
is running, and the action refuses with 409 `medications.course.notRunning`
when it is false, so no client can reopen an ended course or invent a
one-day course from a planned one by ending it. The web controls render End
from the served flag; the contributed tests keep their cases against it.
Refs #1280
This commit is contained in:
1 parent
23a67f61e6
commit
275367f6e9
15 files changed
+990
-124
No files matched your search
@@ -73,6 +73,11 @@ const ALLOWLIST: Record<string, { max: number; reason: string }> = {
|
||||
max: 2,
|
||||
reason: "the cases pin the clock with fake timers around the fixed dates",
|
||||
},
|
||||
"tests/integration/medication-course-end.test.ts": {
|
||||
max: 1,
|
||||
reason:
|
||||
"the cases pin the clock with fake timers; the history read starts on the fixed day the course was ended",
|
||||
},
|
||||
"tests/integration/symptoms.test.ts": {
|
||||
max: 1,
|
||||
reason:
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* #1280 — "End course": end the running course now.
|
||||
*
|
||||
* POST /api/medications/{id}/courses/end — no body. The course's last day
|
||||
* becomes today on the person's clock and the moment is recorded as
|
||||
* its `endedAt`, so a dose later today is not due, not reminded and
|
||||
* not expected, while one taken earlier today still counts. The
|
||||
* medication reads as ENDED at once.
|
||||
*
|
||||
* An action of its own rather than `PUT { endsOn }`: an end date set through
|
||||
* the PUT or the course editor keeps meaning the whole day. Refused with 409
|
||||
* `medications.course.notRunning` while no course is running, the same rule
|
||||
* the reads publish as `canEndCourse`.
|
||||
*
|
||||
* Owner-only, like the other course routes.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
CourseWriteError,
|
||||
courseRefusalResponse,
|
||||
endCurrentCourse,
|
||||
} from "@/lib/medications/courses";
|
||||
import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
withIdempotency<[NextRequest, RouteParams]>(postEndCourse),
|
||||
);
|
||||
|
||||
async function postEndCourse(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const window = await endCurrentCourse({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
timeZone: user.timezone || DEFAULT_TIMEZONE,
|
||||
});
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
const body = {
|
||||
startsOn: window.startsOn ? dayKeyOfDate(window.startsOn) : null,
|
||||
endsOn: window.endsOn ? dayKeyOfDate(window.endsOn) : null,
|
||||
endedAt: window.endedAt ? window.endedAt.toISOString() : null,
|
||||
};
|
||||
await auditLog("medication.course.end", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { medicationId: id, ...body },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "medication.course.end" },
|
||||
meta: { medicationId: id },
|
||||
});
|
||||
return apiSuccess(body);
|
||||
} catch (err) {
|
||||
if (err instanceof CourseWriteError) {
|
||||
const r = courseRefusalResponse(err.refusal);
|
||||
annotate({ meta: { "medication.course.refused": err.refusal } });
|
||||
return apiError(r.message, r.status, { errorCode: r.errorCode });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,7 @@ export interface MedicationDetailSnapshot {
|
||||
/** v1.40 (#1024) — the server-resolved courses. */
|
||||
courses?: MedicationCourseWire[];
|
||||
canStartCourse?: boolean;
|
||||
canEndCourse?: boolean;
|
||||
recordedSchedules?: ScheduleSnapshot[];
|
||||
schedules: ScheduleSnapshot[];
|
||||
/** v1.43 — the composition per unit, as the server delivered it. */
|
||||
|
||||
@@ -3,13 +3,19 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { updateMedicationSchema } from "@/lib/validations/medication/create-update";
|
||||
|
||||
import {
|
||||
canEndCourse,
|
||||
endCourseBody,
|
||||
LifecycleManageBody,
|
||||
} from "../destructive-zone-section";
|
||||
courseEndNow,
|
||||
dayKeyOfDate,
|
||||
} from "@/lib/medications/course-window";
|
||||
import { updateMedicationSchema } from "@/lib/validations/medication/create-update";
|
||||
|
||||
import { LifecycleManageBody } from "../destructive-zone-section";
|
||||
|
||||
/** The end day End writes, as the `YYYY-MM-DD` an `endsOn` field carries. */
|
||||
function endCourseBody(now: Date, timeZone: string): { endsOn: string } {
|
||||
return { endsOn: dayKeyOfDate(courseEndNow(now, timeZone).endsOn) };
|
||||
}
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
@@ -22,8 +28,9 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
/**
|
||||
* "End medication" sent `new Date().toISOString()`, a full instant, against an
|
||||
* `endsOn` that only takes `YYYY-MM-DD`, so every press answered 422 and the
|
||||
* toast said the medication could not be ended. The body now carries the
|
||||
* person's own calendar day.
|
||||
* toast said the medication could not be ended. End now asks the server to
|
||||
* end the course (`POST /courses/end`), which writes the person's own
|
||||
* calendar day (`courseEndNow`) as the last day.
|
||||
*/
|
||||
describe("endCourseBody", () => {
|
||||
it("is a date the update schema accepts", () => {
|
||||
@@ -58,10 +65,12 @@ describe("endCourseBody", () => {
|
||||
* Ending writes today as the end of the latest course. On a course that has
|
||||
* already ended that would reopen it up to today, and on one that has not
|
||||
* started it would become a one-day course today, so End is only offered while
|
||||
* the course runs. A medication with no dates is CURRENT.
|
||||
* the course runs. The server resolves that as `canEndCourse` (a medication
|
||||
* with no dates is CURRENT); the controls render from it, and without it
|
||||
* (a read that does not carry it) End stays hidden.
|
||||
*/
|
||||
describe("End is offered only for a running course", () => {
|
||||
function render(courseStatus?: "UPCOMING" | "CURRENT" | "ENDED") {
|
||||
function render(served: boolean | undefined) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
@@ -70,7 +79,7 @@ describe("End is offered only for a running course", () => {
|
||||
id: "med-1",
|
||||
name: "Example",
|
||||
active: true,
|
||||
courseStatus,
|
||||
canEndCourse: served,
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
@@ -80,12 +89,13 @@ describe("End is offered only for a running course", () => {
|
||||
|
||||
it.each([
|
||||
["CURRENT", true],
|
||||
[undefined, true],
|
||||
[undefined, false],
|
||||
["ENDED", false],
|
||||
["UPCOMING", false],
|
||||
] as const)("course %s → End shown: %s", (status, shown) => {
|
||||
expect(canEndCourse(status)).toBe(shown);
|
||||
const html = render(status);
|
||||
const served = status === undefined ? undefined : canEndCourse(status);
|
||||
expect(served ?? false).toBe(shown);
|
||||
const html = render(served);
|
||||
expect(html.includes('data-slot="destructive-zone-end"')).toBe(shown);
|
||||
// Pausing stays available whatever the course.
|
||||
expect(html).toContain('id="medication-detail-pause-switch"');
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
* - Tier 1 Pausieren: PUT `{ active: false | true }`. Server derives
|
||||
* `pausedAt`. Switch wraps in `<label>` so the entire row is the
|
||||
* AT hit target.
|
||||
* - Tier 2 Beenden: PUT `{ endsOn: <today YYYY-MM-DD> }`. AlertDialog
|
||||
* confirm; Cancel autofocus stays default.
|
||||
* - Tier 2 Beenden: POST `/courses/end` (#1280), no body: the server ends
|
||||
* the running course at that moment, on the person's own day. Offered
|
||||
* while the read serves `canEndCourse`. AlertDialog confirm; Cancel
|
||||
* autofocus stays default.
|
||||
* - Tier 3a Verlauf löschen: DELETE `/intake/purge`. Disabled when
|
||||
* `intakeCount === 0`. AlertDialog confirm.
|
||||
* - Tier 3b Medikament löschen: DELETE `/api/medications/[id]`.
|
||||
@@ -46,33 +48,9 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateMedicationReads, queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
|
||||
import type { CourseStatus } from "@/lib/medications/course-window";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* The PUT body that ends a course today: the person's own calendar day as
|
||||
* `YYYY-MM-DD`, the only shape `endsOn` accepts. A full ISO instant was
|
||||
* refused with 422 on every press, and its date was the UTC one, a day off
|
||||
* around midnight for anyone not on UTC.
|
||||
*/
|
||||
export function endCourseBody(now: Date, timeZone: string): { endsOn: string } {
|
||||
return { endsOn: userDayKey(now, timeZone) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether "End" may be offered. Only a course that is running today can be
|
||||
* ended today: the PUT rewrites the latest course's end, so pressing it on an
|
||||
* ended course would reopen it up to today, and on a course that has not
|
||||
* started it would turn into a one-day course today. Unknown (an older
|
||||
* server) keeps the button.
|
||||
*/
|
||||
export function canEndCourse(courseStatus: CourseStatus | undefined): boolean {
|
||||
return courseStatus === undefined || courseStatus === "CURRENT";
|
||||
}
|
||||
import { apiDelete, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
|
||||
const PAUSE_TITLE_ID = "medication-detail-pause-title";
|
||||
@@ -90,8 +68,12 @@ export function LifecycleManageBody({
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
/** Where today sits in the course (server-resolved). */
|
||||
courseStatus?: CourseStatus;
|
||||
/**
|
||||
* Whether a course is running that End may end now (server-resolved).
|
||||
* Ending writes today as the course's last day, so on an ended course it
|
||||
* would reopen it and on one not yet started invent a one-day course.
|
||||
*/
|
||||
canEndCourse?: boolean;
|
||||
};
|
||||
onAfterAction?: () => void;
|
||||
}) {
|
||||
@@ -158,7 +140,7 @@ export function LifecycleManageBody({
|
||||
/>
|
||||
</label>
|
||||
|
||||
{canEndCourse(medication.courseStatus) && (
|
||||
{medication.canEndCourse === true && (
|
||||
<EndCourseControl
|
||||
medicationId={medicationId}
|
||||
medicationName={medication.name}
|
||||
@@ -185,8 +167,6 @@ function EndCourseControl({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const userTz = user?.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
||||
const [tier2Busy, setTier2Busy] = useState(false);
|
||||
@@ -195,10 +175,7 @@ function EndCourseControl({
|
||||
if (tier2Busy) return;
|
||||
setTier2Busy(true);
|
||||
try {
|
||||
await apiPut(
|
||||
`/api/medications/${medicationId}`,
|
||||
endCourseBody(new Date(), userTz),
|
||||
);
|
||||
await apiPost(`/api/medications/${medicationId}/courses/end`);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.zone.end.toast"));
|
||||
setEndDialogOpen(false);
|
||||
@@ -207,6 +184,9 @@ function EndCourseControl({
|
||||
showErrorToast(t("medications.detail.zone.end.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
// A 409 means the course is no longer running (ended in another tab):
|
||||
// refetch, so the served `canEndCourse` takes the button away.
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} finally {
|
||||
setTier2Busy(false);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
canEndCourse,
|
||||
canStartCourse,
|
||||
courseEndNow,
|
||||
courseFromWindow,
|
||||
courseStatusAt,
|
||||
courseStatusOn,
|
||||
@@ -186,6 +188,29 @@ describe("ending a course now (#1280)", () => {
|
||||
expect(at("2026-06-12T22:00:00.000Z")).toBe("ENDED");
|
||||
});
|
||||
|
||||
it("offers End only while a course runs", () => {
|
||||
expect(canEndCourse("CURRENT")).toBe(true);
|
||||
expect(canEndCourse("ENDED")).toBe(false);
|
||||
expect(canEndCourse("UPCOMING")).toBe(false);
|
||||
// No dates at all is running.
|
||||
expect(
|
||||
canEndCourse(
|
||||
courseStatusAt({ startsOn: null, endsOn: null }, ENDED_AT, "UTC"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ends on the person's own day, at the moment itself", () => {
|
||||
const late = new Date("2026-06-11T23:30:00.000Z");
|
||||
expect(courseEndNow(late, "Europe/Warsaw")).toEqual({
|
||||
endsOn: d("2026-06-12"),
|
||||
endedAt: late,
|
||||
});
|
||||
expect(courseEndNow(late, "America/New_York").endsOn).toEqual(
|
||||
d("2026-06-11"),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a new course start once the ended one is behind it", () => {
|
||||
const base = { active: true, oneShot: false, todayKey: "2026-06-12" };
|
||||
expect(canStartCourse({ ...base, courses: [MARCH, ended] })).toBe(true);
|
||||
|
||||
@@ -46,6 +46,7 @@ describe.each(ZONES)("course window in %s", (tz) => {
|
||||
expect(r).toEqual({
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: true,
|
||||
canEndCourse: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -55,6 +56,7 @@ describe.each(ZONES)("course window in %s", (tz) => {
|
||||
expect(r).toEqual({
|
||||
courseStatus: "ENDED",
|
||||
intakeActionable: false,
|
||||
canEndCourse: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,10 +65,12 @@ describe.each(ZONES)("course window in %s", (tz) => {
|
||||
expect(resolveIntakeActionability(med, at(tz, 24, 23, 59), tz)).toEqual({
|
||||
courseStatus: "UPCOMING",
|
||||
intakeActionable: false,
|
||||
canEndCourse: false,
|
||||
});
|
||||
expect(resolveIntakeActionability(med, at(tz, 25, 0, 0), tz)).toEqual({
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: true,
|
||||
canEndCourse: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -90,6 +94,7 @@ describe("resolveIntakeActionability", () => {
|
||||
expect(r).toEqual({
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: false,
|
||||
canEndCourse: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +107,7 @@ describe("resolveIntakeActionability", () => {
|
||||
expect(r).toEqual({
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: false,
|
||||
canEndCourse: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,6 +137,7 @@ describe("a course ended with End course (#1280)", () => {
|
||||
expect(before).toEqual({
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: true,
|
||||
canEndCourse: true,
|
||||
});
|
||||
const after = resolveIntakeActionability(
|
||||
ended,
|
||||
@@ -140,6 +147,7 @@ describe("a course ended with End course (#1280)", () => {
|
||||
expect(after).toEqual({
|
||||
courseStatus: "ENDED",
|
||||
intakeActionable: false,
|
||||
canEndCourse: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -10,16 +10,21 @@
|
||||
* and `intakeActionable` — which read the columns — can never disagree with
|
||||
* the course list.
|
||||
*
|
||||
* #1280 — a course may carry `endedAt`, the instant it was ended at. Every
|
||||
* write that moves a course's `endsOn` clears it: an end date chosen in the
|
||||
* editor means the whole day.
|
||||
* #1280 — "End course" (`endCurrentCourse`) ends the running course at the
|
||||
* moment it is pressed: `endsOn` becomes today and `endedAt` records the
|
||||
* instant, so no slot after it is due, reminded or expected. Every other
|
||||
* write that moves a course's `endsOn` clears `endedAt`: an end date chosen
|
||||
* in the editor means the whole day.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
canEndCourse,
|
||||
canStartCourse,
|
||||
courseEndNow,
|
||||
courseFromWindow,
|
||||
courseStatusAt,
|
||||
courseStatusOn,
|
||||
dateOfDayKey,
|
||||
dayKeyOfDate,
|
||||
@@ -43,7 +48,9 @@ export type CourseWriteRefusal =
|
||||
/** Clearing the window of a medication that has several courses. */
|
||||
| "windowRequired"
|
||||
/** The course or the medication is not the caller's. */
|
||||
| "notFound";
|
||||
| "notFound"
|
||||
/** #1280 — "End course" on a medication with no course running now. */
|
||||
| "notRunning";
|
||||
|
||||
export class CourseWriteError extends Error {
|
||||
constructor(readonly refusal: CourseWriteRefusal) {
|
||||
@@ -89,6 +96,12 @@ export function courseRefusalResponse(refusal: CourseWriteRefusal): {
|
||||
errorCode: "medications.course.invalidRange",
|
||||
message: "A course cannot end before it starts",
|
||||
};
|
||||
case "notRunning":
|
||||
return {
|
||||
status: 409,
|
||||
errorCode: "medications.course.notRunning",
|
||||
message: "No course is running to end",
|
||||
};
|
||||
case "windowRequired":
|
||||
return {
|
||||
status: 422,
|
||||
@@ -450,6 +463,87 @@ function keptEndedAt(
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1280 — "End course": end the running course now.
|
||||
*
|
||||
* The course's last day becomes today on the person's clock and `endedAt`
|
||||
* records the moment. Doses earlier today stay inside the course and count;
|
||||
* a slot later today is not due, not reminded and not expected, and the
|
||||
* course reads as ENDED at once (`courseStatusAt`). A medication without a
|
||||
* course row (chronic since creation, or a window never materialised) gets
|
||||
* the course its window describes, ended now.
|
||||
*
|
||||
* The pending placeholders already minted for slots at or after that moment
|
||||
* (the today projector mints the whole day) are tombstoned, as a schedule
|
||||
* replace does, so no list shows them and the auto-miss pass never stamps
|
||||
* them a miss. An actioned row is history and stays.
|
||||
*
|
||||
* Refused with `notRunning` unless a course is running now
|
||||
* (`canEndCourse`): ending an ended course would reopen it up to today, and
|
||||
* ending one that has not started would invent a one-day course.
|
||||
*/
|
||||
export async function endCurrentCourse(
|
||||
args: WriteContext,
|
||||
): Promise<ProjectedWindow> {
|
||||
const now = args.now ?? new Date();
|
||||
const todayKey = userDayKey(now, args.timeZone);
|
||||
const { endsOn: today, endedAt } = courseEndNow(now, args.timeZone);
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await lockMedication(tx, args.medicationId);
|
||||
const { medication, courses } = await loadOwned(
|
||||
tx,
|
||||
args.userId,
|
||||
args.medicationId,
|
||||
);
|
||||
if (!canEndCourse(courseStatusAt(medication, now, args.timeZone))) {
|
||||
throw new CourseWriteError("notRunning");
|
||||
}
|
||||
const latest = sortCourses(courses).at(-1);
|
||||
if (latest) {
|
||||
const ended = { startsOn: latest.startsOn, endsOn: today, endedAt };
|
||||
refuseIfInvalid(
|
||||
courses.map((c) => (c.id === latest.id ? ended : c)),
|
||||
todayKey,
|
||||
medication.oneShot,
|
||||
);
|
||||
await tx.medicationCourse.update({
|
||||
where: { id: latest.id },
|
||||
data: { endsOn: today, endedAt },
|
||||
});
|
||||
} else {
|
||||
const implicit = windowAsCourse(
|
||||
medication.startsOn,
|
||||
today,
|
||||
undefined,
|
||||
medication.createdAt,
|
||||
args.timeZone,
|
||||
);
|
||||
refuseIfInvalid([implicit], todayKey, medication.oneShot);
|
||||
await tx.medicationCourse.create({
|
||||
data: {
|
||||
medicationId: args.medicationId,
|
||||
userId: args.userId,
|
||||
...implicit,
|
||||
endedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
await tx.medicationIntakeEvent.updateMany({
|
||||
where: {
|
||||
userId: args.userId,
|
||||
medicationId: args.medicationId,
|
||||
deletedAt: null,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
scheduledFor: { gte: now },
|
||||
},
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
return project(tx, args.medicationId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the window a PUT is about to write would be refused. Checked
|
||||
* BEFORE the medication row is updated, so a refused window leaves the row
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -312,6 +312,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"medications.course.invalid",
|
||||
"medications.course.invalidRange",
|
||||
"medications.course.notFound",
|
||||
"medications.course.notRunning",
|
||||
"medications.course.oneShot",
|
||||
"medications.course.overlap",
|
||||
"medications.course.windowRequired",
|
||||
|
||||
@@ -1178,6 +1178,45 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/courses/end": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
tags: ["Medications"],
|
||||
summary: "End the running course now",
|
||||
description:
|
||||
"#1280 — \"End course\". No body. The running course's last day becomes today on the person's clock and the moment is stored as its `endedAt`: a dose slot later today is not due, not reminded and not expected by compliance, a dose taken earlier today still counts, and `courseStatus` reads ENDED at once. Pending placeholders for slots at or after the moment are removed (tombstoned for sync). A medication with no dates gets the course its window describes, from creation to today. An end date set through `PUT /api/medications/{id}` or the course editor keeps meaning the whole day; only this action ends a course at an instant. Refused with 409 `medications.course.notRunning` while no course is running — the rule the reads publish as `canEndCourse`. Owner-only.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"200": {
|
||||
description: "The medication's window after ending.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
startsOn: z.iso.date().nullable(),
|
||||
endsOn: z.iso.date().nullable(),
|
||||
endedAt: isoDateTime().nullable(),
|
||||
}),
|
||||
"EndMedicationCourseResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Medication not found (`medications.course.notFound`), or another user's.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"No course is running now (`medications.course.notRunning`): it has ended already or has not started.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/courses/{courseId}": {
|
||||
patch: {
|
||||
tags: ["Medications"],
|
||||
|
||||
@@ -69,7 +69,7 @@ export const medicationCourseResource = z
|
||||
endedAt: isoDateTime()
|
||||
.nullable()
|
||||
.describe(
|
||||
'#1280 — the instant "End course" ended this course, on its `endsOn` day. No dose slot at or after it is due, reminded or expected, and the course is ENDED from it on. NULL when the whole `endsOn` day counts (an end date set in the editor). Any edit that moves `endsOn` clears it.',
|
||||
"#1280 — the instant `POST /api/medications/{id}/courses/end` ended this course, on its `endsOn` day. No dose slot at or after it is due, reminded or expected, and the course is ENDED from it on. NULL when the whole `endsOn` day counts (an end date set in the editor). Any edit that moves `endsOn` clears it.",
|
||||
),
|
||||
status: z
|
||||
.enum(["UPCOMING", "CURRENT", "ENDED"])
|
||||
@@ -375,7 +375,7 @@ export const medicationResource = z
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'#1280 — the instant the latest course was ended with "End course" (the course\'s own `endedAt`). From it on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. Read-only — written by the course writer only.',
|
||||
"#1280 — the instant the latest course was ended with `POST /api/medications/{id}/courses/end` (the course's own `endedAt`). From it on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. Read-only — written by the course writer only.",
|
||||
),
|
||||
oneShot: z
|
||||
.boolean()
|
||||
@@ -453,13 +453,18 @@ export const medicationListEntry = medicationResource
|
||||
courseStatus: z
|
||||
.enum(["UPCOMING", "CURRENT", "ENDED"])
|
||||
.describe(
|
||||
'v1.39.4 — where today (on the user\'s clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with "End course": then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.',
|
||||
"v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with `POST /api/medications/{id}/courses/end`: then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.",
|
||||
),
|
||||
intakeActionable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored.",
|
||||
),
|
||||
canEndCourse: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.',
|
||||
),
|
||||
lastTakenAt: isoDateTime()
|
||||
.nullable()
|
||||
.describe(
|
||||
@@ -519,13 +524,18 @@ export const medicationDetailEntry = medicationResource
|
||||
courseStatus: z
|
||||
.enum(["UPCOMING", "CURRENT", "ENDED"])
|
||||
.describe(
|
||||
'v1.39.4 — where today (on the user\'s clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with "End course": then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.',
|
||||
"v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with `POST /api/medications/{id}/courses/end`: then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.",
|
||||
),
|
||||
intakeActionable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored.",
|
||||
),
|
||||
canEndCourse: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.',
|
||||
),
|
||||
stockUnitsRemaining: z
|
||||
.number()
|
||||
.nullable()
|
||||
|
||||
Reference in new issue
Block a user