feat(medications): end a course now through its own action

"End course" now posts to `POST /api/medications/{id}/courses/end`, which
ends the running course at that moment: its last day becomes today on the
person's clock and the instant is stored as `endedAt`, so a dose later today
is not due, not reminded and never missed, while one taken earlier still
counts. Pending placeholders already minted for later slots are tombstoned.
A medication without dates gets the course its window describes, ended now.

End stays its own verb instead of `PUT { endsOn }`: an end date set through
the PUT or the course editor keeps meaning the whole day, and the PUT keeps
accepting it on any course, since moving an end is a legitimate edit.

The list and detail reads serve `canEndCourse`, true exactly while a course
is running, and the action refuses with 409 `medications.course.notRunning`
when it is false, so no client can reopen an ended course or invent a
one-day course from a planned one by ending it. The web controls render End
from the served flag; the contributed tests keep their cases against it.

Refs #1280
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 22:24:15 +02:00
1 parent 23a67f61e6
commit 275367f6e9
15 files changed
+990 -124

No files matched your search

@@ -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;
}
}