diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts index f7c82c468..c4646e017 100644 --- a/src/lib/medications/courses.ts +++ b/src/lib/medications/courses.ts @@ -178,8 +178,41 @@ export async function createCourse( 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( - [...courses, { startsOn: args.startsOn, endsOn: args.endsOn }], + [...existing, { startsOn: args.startsOn, endsOn: args.endsOn }], todayKey, medication.oneShot, ); diff --git a/tests/integration/medication-courses.test.ts b/tests/integration/medication-courses.test.ts index 88cddfc83..54fd4190d 100644 --- a/tests/integration/medication-courses.test.ts +++ b/tests/integration/medication-courses.test.ts @@ -275,6 +275,39 @@ describe("course writes keep the projection", () => { }); }); + it("never lets a first course cut off a chronic medication's history", async () => { + // Taken continuously since creation: no window, no course row. A course + // added beside it would project the medication into ending. + const chronic = await makeMedication({}); + const refused = await postCourse(chronic.id, { + startsOn: "2026-03-01", + endsOn: "2026-03-31", + }); + expect(refused.status).toBe(422); + expect((await refused.json()).meta.errorCode).toBe( + "medications.course.currentOpen", + ); + expect(await courses(chronic.id)).toEqual([]); + expect(await window(chronic.id)).toEqual({ startsOn: null, endsOn: null }); + + // A window the rows never recorded (written past the course writer, as + // an older restore could) becomes the first course before the new one. + const legacy = await makeMedication({}); + await getPrismaClient().medication.update({ + where: { id: legacy.id }, + data: { startsOn: D("2026-01-10"), endsOn: D("2026-01-20") }, + }); + const added = await postCourse(legacy.id, { + startsOn: "2026-03-01", + endsOn: "2026-03-05", + }); + expect(added.status).toBe(201); + expect(await courses(legacy.id)).toEqual([ + ["2026-01-10", "2026-01-20"], + ["2026-03-01", "2026-03-05"], + ]); + }); + it("lets the medication's own PUT edit the latest course", async () => { const med = await makeMedication({ startsOn: "2026-03-01",