mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
A start moved earlier, an end moved later or removed, or a medication made active again reopened past slots, and the auto-take pass filled up to a week of them as taken. The course window writer and the re-activation paths now move the auto-take floor to the moment of the edit.
566 lines
18 KiB
TypeScript
566 lines
18 KiB
TypeScript
/**
|
|
* v1.40 (#1024) — the one writer of a medication's courses and of the window
|
|
* the medication row projects from them.
|
|
*
|
|
* Every write runs in one transaction under a per-medication advisory lock:
|
|
* load the courses, apply the change, refuse a set that breaks the course
|
|
* rules (`validateCourses`), write, then rewrite `Medication.startsOn/endsOn`
|
|
* from the result (`projectCourseWindow`). Nothing else writes those two
|
|
* columns for a medication that has courses, so reminders, the next-due line
|
|
* and `intakeActionable` — which read the columns — can never disagree with
|
|
* the course list.
|
|
*/
|
|
import type { Prisma } from "@/generated/prisma/client";
|
|
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import { prisma } from "@/lib/db";
|
|
import {
|
|
canStartCourse,
|
|
courseFromWindow,
|
|
courseStatusOn,
|
|
dateOfDayKey,
|
|
dayKeyOfDate,
|
|
previousCourseEndedOn,
|
|
projectCourseWindow,
|
|
sortCourses,
|
|
validateCourses,
|
|
type CourseRefusal,
|
|
type CourseStatus,
|
|
} from "@/lib/medications/course-window";
|
|
import { userDayKey } from "@/lib/tz/format";
|
|
import {
|
|
moveAutoTakeFloorToNow,
|
|
windowWidens,
|
|
} from "@/lib/medications/auto-take";
|
|
|
|
type Tx = Prisma.TransactionClient;
|
|
|
|
export type CourseWriteRefusal =
|
|
| CourseRefusal
|
|
/** Clearing the window of a medication that has several courses. */
|
|
| "windowRequired"
|
|
/** The course or the medication is not the caller's. */
|
|
| "notFound";
|
|
|
|
export class CourseWriteError extends Error {
|
|
constructor(readonly refusal: CourseWriteRefusal) {
|
|
super(`Course write refused: ${refusal}`);
|
|
this.name = "CourseWriteError";
|
|
}
|
|
}
|
|
|
|
/** The wire `meta.errorCode` and status for each refusal. */
|
|
export function courseRefusalResponse(refusal: CourseWriteRefusal): {
|
|
status: number;
|
|
errorCode: string;
|
|
message: string;
|
|
} {
|
|
switch (refusal) {
|
|
case "notFound":
|
|
return {
|
|
status: 404,
|
|
errorCode: "medications.course.notFound",
|
|
message: "Course not found",
|
|
};
|
|
case "overlap":
|
|
return {
|
|
status: 422,
|
|
errorCode: "medications.course.overlap",
|
|
message: "Courses may not share a day",
|
|
};
|
|
case "currentOpen":
|
|
return {
|
|
status: 422,
|
|
errorCode: "medications.course.currentOpen",
|
|
message: "End the running course before adding one after it",
|
|
};
|
|
case "oneShot":
|
|
return {
|
|
status: 422,
|
|
errorCode: "medications.course.oneShot",
|
|
message: "A one-time medication has a single course",
|
|
};
|
|
case "invalidRange":
|
|
return {
|
|
status: 422,
|
|
errorCode: "medications.course.invalidRange",
|
|
message: "A course cannot end before it starts",
|
|
};
|
|
case "windowRequired":
|
|
return {
|
|
status: 422,
|
|
errorCode: "medications.course.windowRequired",
|
|
message:
|
|
"A medication with several courses keeps a start date; delete a course instead",
|
|
};
|
|
}
|
|
}
|
|
|
|
export interface CourseRow {
|
|
id: string;
|
|
startsOn: Date;
|
|
endsOn: Date | null;
|
|
noteEncrypted: Uint8Array | null;
|
|
}
|
|
|
|
async function lockMedication(tx: Tx, medicationId: string) {
|
|
await tx.$queryRaw`
|
|
SELECT pg_advisory_xact_lock(
|
|
hashtext('medication-course'),
|
|
hashtext(${medicationId})
|
|
)::text AS locked
|
|
`;
|
|
}
|
|
|
|
async function loadOwned(tx: Tx, userId: string, medicationId: string) {
|
|
const medication = await tx.medication.findFirst({
|
|
where: { id: medicationId, userId },
|
|
select: {
|
|
id: true,
|
|
oneShot: true,
|
|
startsOn: true,
|
|
endsOn: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
if (!medication) throw new CourseWriteError("notFound");
|
|
const courses = await tx.medicationCourse.findMany({
|
|
where: { medicationId },
|
|
orderBy: { startsOn: "asc" },
|
|
select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true },
|
|
});
|
|
return { medication, courses };
|
|
}
|
|
|
|
type ProjectedWindow = { startsOn: Date | null; endsOn: Date | null };
|
|
|
|
/**
|
|
* Write the medication's window from its courses. The only writer.
|
|
*
|
|
* v1.42.2 — a window that now reaches further into the past than before
|
|
* (an earlier start, a later or removed end) would let the auto-take pass
|
|
* fill the days it reopens as taken; its floor moves to now instead.
|
|
*/
|
|
async function project(tx: Tx, medicationId: string): Promise<ProjectedWindow> {
|
|
const [before, courses] = await Promise.all([
|
|
tx.medication.findUnique({
|
|
where: { id: medicationId },
|
|
select: { startsOn: true, endsOn: true },
|
|
}),
|
|
tx.medicationCourse.findMany({
|
|
where: { medicationId },
|
|
select: { startsOn: true, endsOn: true },
|
|
}),
|
|
]);
|
|
const window = projectCourseWindow(courses);
|
|
await tx.medication.update({
|
|
where: { id: medicationId },
|
|
data: { startsOn: window.startsOn, endsOn: window.endsOn },
|
|
});
|
|
if (before && windowWidens(before, window)) {
|
|
await moveAutoTakeFloorToNow(tx, medicationId);
|
|
}
|
|
return window;
|
|
}
|
|
|
|
function refuseIfInvalid(
|
|
courses: { startsOn: Date; endsOn: Date | null }[],
|
|
todayKey: string,
|
|
oneShot: boolean,
|
|
) {
|
|
const refusal = validateCourses(courses, todayKey, oneShot);
|
|
if (refusal) throw new CourseWriteError(refusal);
|
|
}
|
|
|
|
function encryptNote(note: string | null | undefined) {
|
|
if (note === undefined) return undefined;
|
|
return note === null || note.trim() === "" ? null : encryptToBytes(note);
|
|
}
|
|
|
|
interface WriteContext {
|
|
userId: string;
|
|
medicationId: string;
|
|
timeZone: string;
|
|
now?: Date;
|
|
}
|
|
|
|
export async function createCourse(
|
|
args: WriteContext & {
|
|
startsOn: Date;
|
|
endsOn: Date | null;
|
|
note?: string | null;
|
|
},
|
|
): Promise<CourseRow> {
|
|
const todayKey = userDayKey(args.now ?? new Date(), args.timeZone);
|
|
return prisma.$transaction(async (tx) => {
|
|
await lockMedication(tx, args.medicationId);
|
|
const { medication, courses } = await loadOwned(
|
|
tx,
|
|
args.userId,
|
|
args.medicationId,
|
|
);
|
|
// A medication without course rows is not empty history. With no window
|
|
// at all it has been taken continuously since creation and is running
|
|
// now, so a course cannot be added beside it (it ends its course first,
|
|
// "End course" writes one). With a window the rows never recorded (a
|
|
// file restored past the backfill), that window is its first course and
|
|
// is materialised before the new one is checked against it. Either way
|
|
// nothing the person already recorded can be cut off by the projection.
|
|
let existing: { startsOn: Date; endsOn: Date | null }[] = courses;
|
|
if (courses.length === 0) {
|
|
if (medication.startsOn === null && medication.endsOn === null) {
|
|
throw new CourseWriteError("currentOpen");
|
|
}
|
|
const implicit = windowAsCourse(
|
|
medication.startsOn,
|
|
medication.endsOn,
|
|
undefined,
|
|
medication.createdAt,
|
|
args.timeZone,
|
|
);
|
|
refuseIfInvalid(
|
|
[implicit, { startsOn: args.startsOn, endsOn: args.endsOn }],
|
|
todayKey,
|
|
medication.oneShot,
|
|
);
|
|
await tx.medicationCourse.create({
|
|
data: {
|
|
medicationId: args.medicationId,
|
|
userId: args.userId,
|
|
...implicit,
|
|
},
|
|
});
|
|
existing = [implicit];
|
|
}
|
|
refuseIfInvalid(
|
|
[...existing, { startsOn: args.startsOn, endsOn: args.endsOn }],
|
|
todayKey,
|
|
medication.oneShot,
|
|
);
|
|
const created = await tx.medicationCourse.create({
|
|
data: {
|
|
medicationId: args.medicationId,
|
|
userId: args.userId,
|
|
startsOn: args.startsOn,
|
|
endsOn: args.endsOn,
|
|
noteEncrypted: encryptNote(args.note) ?? null,
|
|
},
|
|
select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true },
|
|
});
|
|
await project(tx, args.medicationId);
|
|
return created;
|
|
});
|
|
}
|
|
|
|
export async function updateCourse(
|
|
args: WriteContext & {
|
|
courseId: string;
|
|
startsOn?: Date;
|
|
endsOn?: Date | null;
|
|
note?: string | null;
|
|
},
|
|
): Promise<CourseRow> {
|
|
const todayKey = userDayKey(args.now ?? new Date(), args.timeZone);
|
|
return prisma.$transaction(async (tx) => {
|
|
await lockMedication(tx, args.medicationId);
|
|
const { medication, courses } = await loadOwned(
|
|
tx,
|
|
args.userId,
|
|
args.medicationId,
|
|
);
|
|
const current = courses.find((c) => c.id === args.courseId);
|
|
if (!current) throw new CourseWriteError("notFound");
|
|
const next = {
|
|
startsOn: args.startsOn ?? current.startsOn,
|
|
endsOn: args.endsOn === undefined ? current.endsOn : args.endsOn,
|
|
};
|
|
refuseIfInvalid(
|
|
courses.map((c) => (c.id === current.id ? next : c)),
|
|
todayKey,
|
|
medication.oneShot,
|
|
);
|
|
const noteEncrypted = encryptNote(args.note);
|
|
const updated = await tx.medicationCourse.update({
|
|
where: { id: current.id },
|
|
data: {
|
|
startsOn: next.startsOn,
|
|
endsOn: next.endsOn,
|
|
...(noteEncrypted !== undefined && { noteEncrypted }),
|
|
},
|
|
select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true },
|
|
});
|
|
await project(tx, args.medicationId);
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete one course. Deleting the only course makes the medication chronic
|
|
* again (the projection clears both columns); the UI asks first.
|
|
*/
|
|
export async function deleteCourse(
|
|
args: WriteContext & { courseId: string },
|
|
): Promise<void> {
|
|
await prisma.$transaction(async (tx) => {
|
|
await lockMedication(tx, args.medicationId);
|
|
const { courses } = await loadOwned(tx, args.userId, args.medicationId);
|
|
if (!courses.some((c) => c.id === args.courseId)) {
|
|
throw new CourseWriteError("notFound");
|
|
}
|
|
await tx.medicationCourse.delete({ where: { id: args.courseId } });
|
|
await project(tx, args.medicationId);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The course a window describes. A window with only an end has always meant
|
|
* "running since creation" (or since the end day, when that lies earlier).
|
|
*/
|
|
function windowAsCourse(
|
|
startsOn: Date | null,
|
|
endsOn: Date | null,
|
|
latest: { startsOn: Date } | undefined,
|
|
createdAt: Date,
|
|
timeZone: string,
|
|
): { startsOn: Date; endsOn: Date | null } {
|
|
if (!startsOn && latest) return { startsOn: latest.startsOn, endsOn };
|
|
return (
|
|
courseFromWindow(startsOn, endsOn, userDayKey(createdAt, timeZone)) ?? {
|
|
startsOn: dateOfDayKey(userDayKey(createdAt, timeZone)),
|
|
endsOn,
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* What `PUT /api/medications/{id}` does with `startsOn` / `endsOn` since
|
|
* v1.40: the window the body sets becomes the latest course (created when
|
|
* the medication had none), so a client that edits the window as before (the
|
|
* iPhone app's course row, the web wizard, "End course") keeps working. The
|
|
* two values are the medication's window after the update, with an absent
|
|
* field already resolved to its stored value.
|
|
*
|
|
* Clearing both on a medication with one course deletes it (chronic again,
|
|
* the pre-course meaning of no dates); on one with several it is refused,
|
|
* because it would silently drop the history.
|
|
*
|
|
* `tx` runs the write inside the caller's transaction, so the PUT's row
|
|
* update and this course write commit or roll back together: a refusal here
|
|
* then leaves nothing of the PUT behind.
|
|
*/
|
|
export async function setCurrentWindow(
|
|
args: WriteContext & { startsOn: Date | null; endsOn: Date | null },
|
|
tx?: Tx,
|
|
): Promise<ProjectedWindow> {
|
|
return tx
|
|
? writeCurrentWindow(tx, args)
|
|
: prisma.$transaction((own) => writeCurrentWindow(own, args));
|
|
}
|
|
|
|
async function writeCurrentWindow(
|
|
tx: Tx,
|
|
args: WriteContext & { startsOn: Date | null; endsOn: Date | null },
|
|
): Promise<ProjectedWindow> {
|
|
const todayKey = userDayKey(args.now ?? new Date(), args.timeZone);
|
|
await lockMedication(tx, args.medicationId);
|
|
const { medication, courses } = await loadOwned(
|
|
tx,
|
|
args.userId,
|
|
args.medicationId,
|
|
);
|
|
const latest = sortCourses(courses).at(-1);
|
|
|
|
if (args.startsOn === null && args.endsOn === null) {
|
|
if (!latest) return project(tx, args.medicationId);
|
|
if (courses.length > 1) throw new CourseWriteError("windowRequired");
|
|
await tx.medicationCourse.delete({ where: { id: latest.id } });
|
|
return project(tx, args.medicationId);
|
|
}
|
|
|
|
const next = windowAsCourse(
|
|
args.startsOn,
|
|
args.endsOn,
|
|
latest,
|
|
medication.createdAt,
|
|
args.timeZone,
|
|
);
|
|
|
|
if (latest) {
|
|
refuseIfInvalid(
|
|
courses.map((c) => (c.id === latest.id ? next : c)),
|
|
todayKey,
|
|
medication.oneShot,
|
|
);
|
|
await tx.medicationCourse.update({
|
|
where: { id: latest.id },
|
|
data: next,
|
|
});
|
|
} else {
|
|
refuseIfInvalid([next], todayKey, medication.oneShot);
|
|
await tx.medicationCourse.create({
|
|
data: {
|
|
medicationId: args.medicationId,
|
|
userId: args.userId,
|
|
...next,
|
|
},
|
|
});
|
|
}
|
|
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
|
|
* as it was. Same rules as `setCurrentWindow`.
|
|
*/
|
|
export async function checkCurrentWindow(
|
|
args: WriteContext & {
|
|
startsOn: Date | null;
|
|
endsOn: Date | null;
|
|
oneShot: boolean;
|
|
createdAt: Date;
|
|
},
|
|
): Promise<CourseWriteRefusal | null> {
|
|
const todayKey = userDayKey(args.now ?? new Date(), args.timeZone);
|
|
const courses = await prisma.medicationCourse.findMany({
|
|
where: { medicationId: args.medicationId, userId: args.userId },
|
|
orderBy: { startsOn: "asc" },
|
|
select: { id: true, startsOn: true, endsOn: true },
|
|
});
|
|
const latest = courses.at(-1);
|
|
if (args.startsOn === null && args.endsOn === null) {
|
|
return courses.length > 1 ? "windowRequired" : null;
|
|
}
|
|
const next = windowAsCourse(
|
|
args.startsOn,
|
|
args.endsOn,
|
|
latest,
|
|
args.createdAt,
|
|
args.timeZone,
|
|
);
|
|
return validateCourses(
|
|
latest ? courses.map((c) => (c.id === latest.id ? next : c)) : [next],
|
|
todayKey,
|
|
args.oneShot,
|
|
);
|
|
}
|
|
|
|
// ── The read side ──────────────────────────────────────────────────────────
|
|
|
|
export interface MedicationCourseDto {
|
|
id: string;
|
|
startsOn: string;
|
|
endsOn: string | null;
|
|
status: CourseStatus;
|
|
/** Doses logged as taken whose slot falls on a day of this course. */
|
|
takenDoses: number;
|
|
/** The person's note on this course, decrypted. */
|
|
note: string | null;
|
|
}
|
|
|
|
export interface MedicationCourseFields {
|
|
courses: MedicationCourseDto[];
|
|
courseCount: number;
|
|
previousCourseEndedOn: string | null;
|
|
canStartCourse: boolean;
|
|
}
|
|
|
|
/** Every course of the given medications, ascending by start. */
|
|
export async function loadCoursesFor(
|
|
medicationIds: string[],
|
|
): Promise<Map<string, CourseRow[]>> {
|
|
const byMedication = new Map<string, CourseRow[]>();
|
|
if (medicationIds.length === 0) return byMedication;
|
|
const rows = await prisma.medicationCourse.findMany({
|
|
where: { medicationId: { in: medicationIds } },
|
|
orderBy: { startsOn: "asc" },
|
|
select: {
|
|
id: true,
|
|
medicationId: true,
|
|
startsOn: true,
|
|
endsOn: true,
|
|
noteEncrypted: true,
|
|
},
|
|
});
|
|
for (const { medicationId, ...row } of rows) {
|
|
const list = byMedication.get(medicationId) ?? [];
|
|
list.push(row);
|
|
byMedication.set(medicationId, list);
|
|
}
|
|
return byMedication;
|
|
}
|
|
|
|
/**
|
|
* Taken doses per course: intake rows with a `takenAt`, not tombstoned,
|
|
* whose scheduled slot falls on a day of the course on the person's clock.
|
|
*/
|
|
export async function countTakenDosesPerCourse(
|
|
medicationIds: string[],
|
|
timeZone: string,
|
|
): Promise<Map<string, number>> {
|
|
const counts = new Map<string, number>();
|
|
if (medicationIds.length === 0) return counts;
|
|
const rows = await prisma.$queryRaw<Array<{ id: string; taken: number }>>`
|
|
SELECT c."id", COUNT(e."id")::int AS taken
|
|
FROM "medication_courses" c
|
|
JOIN "medication_intake_events" e
|
|
ON e."medication_id" = c."medication_id"
|
|
AND e."taken_at" IS NOT NULL
|
|
AND e."deleted_at" IS NULL
|
|
AND (e."scheduled_for" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})::date
|
|
BETWEEN c."starts_on" AND COALESCE(c."ends_on", 'infinity'::date)
|
|
WHERE c."medication_id" = ANY(${medicationIds}::text[])
|
|
GROUP BY c."id"
|
|
`;
|
|
for (const row of rows) counts.set(row.id, row.taken);
|
|
return counts;
|
|
}
|
|
|
|
/** The resolved course fields the medication list and detail publish. */
|
|
export function buildCourseFields(
|
|
medication: { active: boolean; oneShot: boolean },
|
|
courses: readonly CourseRow[],
|
|
takenByCourse: ReadonlyMap<string, number>,
|
|
now: Date,
|
|
timeZone: string,
|
|
): MedicationCourseFields {
|
|
const todayKey = userDayKey(now, timeZone);
|
|
const ended = previousCourseEndedOn(courses, todayKey);
|
|
return {
|
|
courses: courses.map((c) => ({
|
|
id: c.id,
|
|
startsOn: dayKeyOfDate(c.startsOn),
|
|
endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null,
|
|
status: courseStatusOn(c, todayKey),
|
|
takenDoses: takenByCourse.get(c.id) ?? 0,
|
|
note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null,
|
|
})),
|
|
courseCount: courses.length,
|
|
previousCourseEndedOn: ended ? dayKeyOfDate(ended) : null,
|
|
canStartCourse: canStartCourse({
|
|
active: medication.active,
|
|
oneShot: medication.oneShot,
|
|
courses,
|
|
todayKey,
|
|
}),
|
|
};
|
|
}
|
|
|
|
/** The read side for a set of medications, in two queries. */
|
|
export async function resolveCourseFields(
|
|
medications: readonly { id: string; active: boolean; oneShot: boolean }[],
|
|
now: Date,
|
|
timeZone: string,
|
|
): Promise<Map<string, MedicationCourseFields>> {
|
|
const ids = medications.map((m) => m.id);
|
|
const [coursesBy, taken] = await Promise.all([
|
|
loadCoursesFor(ids),
|
|
countTakenDosesPerCourse(ids, timeZone),
|
|
]);
|
|
return new Map(
|
|
medications.map((m) => [
|
|
m.id,
|
|
buildCourseFields(m, coursesBy.get(m.id) ?? [], taken, now, timeZone),
|
|
]),
|
|
);
|
|
}
|