mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Let a medication have several courses
A medication taken now and then (#1024) keeps one row and gains course rows: calendar spans, both ends inclusive, an optional encrypted note, no schedule or dose of their own. Courses never overlap and every course but the latest has ended, so the latest course is always the one the medication's startsOn/endsOn project. src/lib/medications/courses.ts is the only writer of those two columns for a medication with courses; it writes under a per-medication advisory lock and re-projects in the same transaction, so reminders, the next-due line and intakeActionable keep reading the columns and stay right. - Migration 0368 adds medication_courses and backfills one course per medication window (an end-only window starts on the creation day, or on its end day when that lies earlier); a medication without a window stays course-less. Idempotent, data-preserving. - GET/POST /api/medications/{id}/courses and PATCH/DELETE .../courses/{courseId} (owner-only); create seeds the first course from a window; the medication PUT edits the latest course and refuses a window that would break the rules (medications.course.*). - List, detail, create and update carry courses, courseCount, previousCourseEndedOn and canStartCourse, resolved on the server. - Compliance expands over the span of all courses and drops the slots between them; every compliance read selects the courses. A rolling cadence is not anchored by an intake from a previous course. - The card names the last course's end and the running course's number; the detail Lifecycle group lists the courses with start, edit and delete. The Coach compliance block, the MCP resource, the doctor report window and table, the clinician view and FHIR (one MedicationStatement per course with effectivePeriod and status) read the courses. - Backup carries courses per medication and the restore writes them and re-projects; an older file derives the one course its window describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
This commit is contained in:
1 parent
9b71231010
commit
a14174fe4d
71 files changed
+3635
-90
No files matched your search
@@ -270,6 +270,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// store, the same data-layer reasoning.
|
||||
"src/app/api/medications/categories/route.ts",
|
||||
"src/app/api/medications/categories/[key]/route.ts",
|
||||
// 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",
|
||||
"src/app/api/medications/compliance/route.ts",
|
||||
"src/app/api/medications/intake/route.ts",
|
||||
"src/app/api/medications/intake/bulk/route.ts",
|
||||
|
||||
@@ -442,6 +442,7 @@ describe("POST /api/export/health-record — the selection reaches every format"
|
||||
schedules: [],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
courses: [],
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { pairByTimestamp } from "@/lib/analytics/correlations";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
COURSES_COMPLIANCE_SELECT,
|
||||
calculateCompliance,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
@@ -118,6 +119,8 @@ export const GET = apiHandler(async () => {
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
|
||||
courses: COURSES_COMPLIANCE_SELECT,
|
||||
},
|
||||
}),
|
||||
probeRollupCoverage(user.id),
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/lib/analytics/correlations";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
COURSES_COMPLIANCE_SELECT,
|
||||
calculateCompliance,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
@@ -393,6 +394,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
|
||||
courses: COURSES_COMPLIANCE_SELECT,
|
||||
},
|
||||
});
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
|
||||
@@ -49,6 +49,14 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
dayKeyForScheduledFor: vi.fn(() => "2026-06-10"),
|
||||
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
// v1.40 (#1024) — the course writer and reader run against their own
|
||||
// integration suite; here they answer "no course, nothing refused".
|
||||
vi.mock("@/lib/medications/courses", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/medications/courses")>()),
|
||||
resolveCourseFields: vi.fn(async () => new Map()),
|
||||
checkCurrentWindow: vi.fn(async () => null),
|
||||
setCurrentWindow: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/medication-category", () => ({
|
||||
deleteMedicationCategory: vi.fn().mockResolvedValue(undefined),
|
||||
getMedicationCategories: vi.fn().mockResolvedValue({}),
|
||||
@@ -72,6 +80,7 @@ vi.mock("next/headers", () => ({
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveCourseFields } from "@/lib/medications/courses";
|
||||
import {
|
||||
getMedicationCategories,
|
||||
isAssignableCategory,
|
||||
@@ -102,7 +111,9 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// v1.40 — the category helpers' defaults, which `resetAllMocks` clears.
|
||||
// v1.40 — the category and course helpers' defaults, which
|
||||
// `resetAllMocks` clears.
|
||||
vi.mocked(resolveCourseFields).mockResolvedValue(new Map());
|
||||
vi.mocked(resolveMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(isAssignableCategory).mockResolvedValue(true);
|
||||
vi.mocked(setMedicationCategory).mockImplementation(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { COURSES_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
@@ -74,6 +75,8 @@ export const GET = apiHandler(
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
|
||||
courses: COURSES_COMPLIANCE_SELECT,
|
||||
},
|
||||
});
|
||||
// Privacy gate — same sealed 404 for "absent" and "not yours".
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* v1.40 (#1024) — edit or delete one course. Both re-project the
|
||||
* medication's window in the same transaction. Deleting the only course
|
||||
* makes the medication chronic again; the UI asks first. Owner-only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
CourseWriteError,
|
||||
courseRefusalResponse,
|
||||
deleteCourse,
|
||||
updateCourse,
|
||||
} from "@/lib/medications/courses";
|
||||
import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { updateMedicationCourseSchema } from "@/lib/validations/medication";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; courseId: string }> };
|
||||
|
||||
function refused(err: unknown): Response {
|
||||
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;
|
||||
}
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id, courseId } = await params;
|
||||
|
||||
const { data: raw, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = updateMedicationCourseSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "medications.course.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateCourse({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
courseId,
|
||||
timeZone: user.timezone || DEFAULT_TIMEZONE,
|
||||
startsOn: parsed.data.startsOn,
|
||||
endsOn: parsed.data.endsOn,
|
||||
note: parsed.data.note,
|
||||
});
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
await auditLog("medication.course.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationId: id,
|
||||
courseId,
|
||||
fields: Object.keys(parsed.data),
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "medication.course.update" },
|
||||
meta: { medicationId: id },
|
||||
});
|
||||
return apiSuccess({
|
||||
id: updated.id,
|
||||
startsOn: dayKeyOfDate(updated.startsOn),
|
||||
endsOn: updated.endsOn ? dayKeyOfDate(updated.endsOn) : null,
|
||||
});
|
||||
} catch (err) {
|
||||
return refused(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id, courseId } = await params;
|
||||
try {
|
||||
await deleteCourse({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
courseId,
|
||||
timeZone: user.timezone || DEFAULT_TIMEZONE,
|
||||
});
|
||||
} catch (err) {
|
||||
return refused(err);
|
||||
}
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
await auditLog("medication.course.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { medicationId: id, courseId },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "medication.course.delete" },
|
||||
meta: { medicationId: id },
|
||||
});
|
||||
return apiSuccess({ id: courseId, deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* v1.40 (#1024) — a medication's courses.
|
||||
*
|
||||
* GET /api/medications/{id}/courses — the resolved course fields, the same
|
||||
* ones the list and detail reads carry.
|
||||
* POST /api/medications/{id}/courses — start a course (`{ startsOn,
|
||||
* endsOn?, note? }`). The medication's `startsOn`/`endsOn` are
|
||||
* rewritten to the latest course in the same transaction, so reminders
|
||||
* and the dose actions follow without another write.
|
||||
*
|
||||
* Owner-only for now: a delegate edits the current window through the
|
||||
* medication's own PUT, as before.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
CourseWriteError,
|
||||
courseRefusalResponse,
|
||||
createCourse,
|
||||
resolveCourseFields,
|
||||
} from "@/lib/medications/courses";
|
||||
import { dayKeyOfDate } from "@/lib/medications/course-window";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { createMedicationCourseSchema } from "@/lib/validations/medication";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
select: { id: true, active: true, oneShot: true },
|
||||
});
|
||||
if (!medication) return apiError("Medication not found", 404);
|
||||
const fields = (
|
||||
await resolveCourseFields(
|
||||
[medication],
|
||||
new Date(),
|
||||
user.timezone || DEFAULT_TIMEZONE,
|
||||
)
|
||||
).get(id);
|
||||
return apiSuccess(fields);
|
||||
},
|
||||
);
|
||||
|
||||
export const POST = apiHandler(
|
||||
withIdempotency<[NextRequest, RouteParams]>(postCourse),
|
||||
);
|
||||
|
||||
async function postCourse(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const { data: raw, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = createMedicationCourseSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "medications.course.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createCourse({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
timeZone: user.timezone || DEFAULT_TIMEZONE,
|
||||
startsOn: parsed.data.startsOn,
|
||||
endsOn: parsed.data.endsOn ?? null,
|
||||
note: parsed.data.note,
|
||||
});
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
await auditLog("medication.course.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationId: id,
|
||||
courseId: created.id,
|
||||
startsOn: dayKeyOfDate(created.startsOn),
|
||||
endsOn: created.endsOn ? dayKeyOfDate(created.endsOn) : null,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "medication.course.create" },
|
||||
meta: { medicationId: id },
|
||||
});
|
||||
return apiSuccess(
|
||||
{
|
||||
id: created.id,
|
||||
startsOn: dayKeyOfDate(created.startsOn),
|
||||
endsOn: created.endsOn ? dayKeyOfDate(created.endsOn) : null,
|
||||
},
|
||||
201,
|
||||
);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,12 @@ import {
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import {
|
||||
checkCurrentWindow,
|
||||
courseRefusalResponse,
|
||||
resolveCourseFields,
|
||||
setCurrentWindow,
|
||||
} from "@/lib/medications/courses";
|
||||
import {
|
||||
schedulesMateriallyDiffer,
|
||||
toRevisionPayloadEntry,
|
||||
@@ -238,6 +244,15 @@ export const GET = apiHandler(
|
||||
Number(medication.unitsPerDose),
|
||||
);
|
||||
|
||||
// v1.40 (#1024) — the courses, resolved on the server.
|
||||
const courseFields = (
|
||||
await resolveCourseFields(
|
||||
[medication],
|
||||
now,
|
||||
user.timezone || DEFAULT_TIMEZONE,
|
||||
)
|
||||
).get(id);
|
||||
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
@@ -246,6 +261,7 @@ export const GET = apiHandler(
|
||||
...scheduleWireFields(medication.trackIntake, schedulesDto),
|
||||
category,
|
||||
categoryLabel,
|
||||
...courseFields,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
// v1.39.4 (#1040) — see `resolveIntakeActionability`.
|
||||
@@ -294,6 +310,9 @@ export const PUT = apiHandler(
|
||||
name: true,
|
||||
dose: true,
|
||||
endsOn: true,
|
||||
// v1.40 (#1024) — the course check below needs the stored window.
|
||||
startsOn: true,
|
||||
oneShot: true,
|
||||
_count: { select: { schedules: true } },
|
||||
},
|
||||
});
|
||||
@@ -494,6 +513,34 @@ export const PUT = apiHandler(
|
||||
// means the dose is the start date; endsOn auto-matches.
|
||||
const normalisedEndsOn = oneShot === true && startsOn ? startsOn : endsOn;
|
||||
|
||||
// v1.40 (#1024) — the window a PUT sets is the latest course. Check it
|
||||
// against the other courses BEFORE the row changes, so a refused window
|
||||
// leaves the medication as it was.
|
||||
const touchesWindow =
|
||||
startsOn !== undefined ||
|
||||
normalisedEndsOn !== undefined ||
|
||||
oneShot !== undefined;
|
||||
const nextWindow = {
|
||||
startsOn: startsOn !== undefined ? startsOn : existing.startsOn,
|
||||
endsOn:
|
||||
normalisedEndsOn !== undefined ? normalisedEndsOn : existing.endsOn,
|
||||
};
|
||||
const courseTz = user.timezone || DEFAULT_TIMEZONE;
|
||||
if (touchesWindow) {
|
||||
const refusal = await checkCurrentWindow({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
timeZone: courseTz,
|
||||
...nextWindow,
|
||||
oneShot: oneShot ?? existing.oneShot,
|
||||
createdAt: existing.createdAt,
|
||||
});
|
||||
if (refusal) {
|
||||
const r = courseRefusalResponse(refusal);
|
||||
return apiError(r.message, r.status, { errorCode: r.errorCode });
|
||||
}
|
||||
}
|
||||
|
||||
const pausedAtPatch =
|
||||
active === undefined
|
||||
? {}
|
||||
@@ -1002,6 +1049,18 @@ export const PUT = apiHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.40 (#1024) — write the window onto the latest course and project it
|
||||
// back, then serve the projected row.
|
||||
if (touchesWindow) {
|
||||
const projected = await setCurrentWindow({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
timeZone: courseTz,
|
||||
...nextWindow,
|
||||
});
|
||||
if (projected) medication = { ...medication, ...projected };
|
||||
}
|
||||
|
||||
if (category !== undefined) await setMedicationCategory(id, category);
|
||||
const resolvedCategory = (await resolveMedicationCategories([id]))[id];
|
||||
const normalizedCategory = resolvedCategory?.category ?? "OTHER";
|
||||
@@ -1068,6 +1127,10 @@ export const PUT = apiHandler(
|
||||
),
|
||||
category: normalizedCategory,
|
||||
categoryLabel: resolvedCategory?.categoryLabel ?? null,
|
||||
// v1.40 (#1024) — the courses, as every medication read carries them.
|
||||
...(await resolveCourseFields([medication], new Date(), courseTz)).get(
|
||||
id,
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -51,6 +51,14 @@ vi.mock("@/lib/cache/server-cache", () => ({
|
||||
caches: { medicationsList: {} },
|
||||
__resetAllCachesForTests: vi.fn(),
|
||||
}));
|
||||
// v1.40 (#1024) — the course writer and reader run against their own
|
||||
// integration suite; here they answer "no course, nothing refused".
|
||||
vi.mock("@/lib/medications/courses", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/medications/courses")>()),
|
||||
resolveCourseFields: vi.fn(async () => new Map()),
|
||||
checkCurrentWindow: vi.fn(async () => null),
|
||||
setCurrentWindow: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/medication-category", () => ({
|
||||
getMedicationCategories: vi.fn().mockResolvedValue({}),
|
||||
resolveMedicationCategories: vi.fn().mockResolvedValue({}),
|
||||
@@ -83,6 +91,7 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { cachedSwr } from "@/lib/cache/server-cache";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { resolveCourseFields } from "@/lib/medications/courses";
|
||||
import {
|
||||
getMedicationCategories,
|
||||
isAssignableCategory,
|
||||
@@ -106,7 +115,9 @@ function postReq(body: unknown): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// v1.40 — the category helpers' defaults, which `resetAllMocks` clears.
|
||||
// v1.40 — the category and course helpers' defaults, which
|
||||
// `resetAllMocks` clears.
|
||||
vi.mocked(resolveCourseFields).mockResolvedValue(new Map());
|
||||
vi.mocked(resolveMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(isAssignableCategory).mockResolvedValue(true);
|
||||
vi.mocked(setMedicationCategory).mockImplementation(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pLimit from "p-limit";
|
||||
|
||||
import { COURSES_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -59,6 +60,8 @@ export const GET = apiHandler(async () => {
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
|
||||
courses: COURSES_COMPLIANCE_SELECT,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
@@ -10,6 +10,16 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
courseRefusalResponse,
|
||||
resolveCourseFields,
|
||||
} from "@/lib/medications/courses";
|
||||
import {
|
||||
dateOfDayKey,
|
||||
dayKeyOfDate,
|
||||
validateCourses,
|
||||
} from "@/lib/medications/course-window";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
import {
|
||||
createMedicationSchema,
|
||||
isCustomMedicationCategoryKey,
|
||||
@@ -283,6 +293,31 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
const normalisedEndsOn =
|
||||
oneShot === true && startsOn ? startsOn : (endsOn ?? undefined);
|
||||
|
||||
// v1.40 (#1024) — a medication created with a window starts with one
|
||||
// course; the row projects it. An end without a start has always meant
|
||||
// "running since creation", so the course starts today (or on the end day,
|
||||
// when that lies earlier). No window: no course, chronic since creation.
|
||||
let initialCourse: { startsOn: Date; endsOn: Date | null } | null = null;
|
||||
if (startsOn || normalisedEndsOn) {
|
||||
const todayKey = userDayKey(new Date(), user.timezone || DEFAULT_TIMEZONE);
|
||||
const endKey = normalisedEndsOn ? dayKeyOfDate(normalisedEndsOn) : null;
|
||||
initialCourse = {
|
||||
startsOn:
|
||||
startsOn ??
|
||||
dateOfDayKey(endKey && endKey < todayKey ? endKey : todayKey),
|
||||
endsOn: normalisedEndsOn ?? null,
|
||||
};
|
||||
const refusal = validateCourses(
|
||||
[initialCourse],
|
||||
todayKey,
|
||||
oneShot === true,
|
||||
);
|
||||
if (refusal) {
|
||||
const r = courseRefusalResponse(refusal);
|
||||
return apiError(r.message, r.status, { errorCode: r.errorCode });
|
||||
}
|
||||
}
|
||||
|
||||
const createMedication = () =>
|
||||
prisma.medication.create({
|
||||
data: {
|
||||
@@ -323,6 +358,17 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
// v1.5 scheduling primitives — pass-through when supplied.
|
||||
...(startsOn !== undefined && { startsOn }),
|
||||
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
|
||||
...(initialCourse && {
|
||||
startsOn: initialCourse.startsOn,
|
||||
endsOn: initialCourse.endsOn,
|
||||
courses: {
|
||||
create: {
|
||||
userId: user.id,
|
||||
startsOn: initialCourse.startsOn,
|
||||
endsOn: initialCourse.endsOn,
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(oneShot !== undefined && { oneShot }),
|
||||
// v1.16.11 — as-needed flag, field-by-field. An asNeeded create
|
||||
// carries an empty `scheduleInputs`, so the nested create below
|
||||
@@ -468,6 +514,14 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
),
|
||||
category: normalizedCategory,
|
||||
categoryLabel,
|
||||
// v1.40 (#1024) — the courses, as every medication read carries them.
|
||||
...(
|
||||
await resolveCourseFields(
|
||||
[medication],
|
||||
new Date(),
|
||||
user.timezone || DEFAULT_TIMEZONE,
|
||||
)
|
||||
).get(medication.id),
|
||||
},
|
||||
201,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user