mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Review of the visit work found the appointment arm leaking in both directions: rows the visit routes could strand, and a reminder surface that would act on one as though it were a checkup. An edit could orphan a live reminder. Pointing a still-booked visit at the checkup it closed walked the foreign key off the visit's own reminder, and the next reschedule then minted a second one, because the re-anchor path asked the key what the visit owned instead of asking the engine. An orphan fires: the cron reads the reminder table, not the visit list, so it pushed for an appointment nobody could see, edit or cancel, and deleting the visit did not reach it. The edit arm now refuses the contradiction the create arm already refused, ownership is resolved through the relation, deletion retires the row it owns rather than the row it points at, and a sweep at the end of every visit write retires anything left armed that no visit claims -- so the state is unreachable rather than merely unlikely. The by-id reminder routes never excluded appointments, and that was the larger hole. Marking one done committed the satisfy and THEN failed on the mapper, so every appointment push a person acknowledged answered 500 after the write had landed. An edit could give a spent one-shot a cadence and turn it into a permanent nudge that appears in no list, or switch a cancelled one back on. The delete is a hard delete, so somebody holding only the measurements domain could destroy a row belonging to a visit in the health background. All five refuse in the lookup now -- an appointment is not addressable there at all -- and the chat buttons that mark a reminder done or postpone it refuse it the same way. Nor is an appointment satisfied by a lab result: the auto-satisfy matcher used to resolve any free-text reminder from any panel, which would have marked next month's cardiology appointment attended. A visit is a seam between four domains, and names were crossing it. The values never did, but the title of a discharge letter is itself the sensitive part, and a grant that opened only the health background was never consent for it. Link labels and dates now follow the target's own domain; the link is still listed, so a delegate learns the visit produced a lab result without learning which analyte. Closing a checkup follows the same rule: it re-anchors a preventive-care cadence, which is a measurements write, so a health-background delegate files the visit and the closure is reported as skipped rather than performed in silence. Three smaller repairs. A checkup now closes at the visit's own instant rather than at the moment it was typed in, which anchors a backdated entry correctly and makes correcting a mis-filed visit the no-op it should always have been. A window ending before today no longer widens back out to today. And the fallback in a push notification was the raw enum constant, so a lock screen could read a schema value; it reads the record owner's own language now, in all six.
87 lines
3.0 KiB
TypeScript
87 lines
3.0 KiB
TypeScript
/**
|
||
* The never-nag property, proven rather than asserted in prose.
|
||
*
|
||
* A booked visit's reminder is a one-shot: both cadence columns are NULL, which
|
||
* the schema documents as "anchored on `anchorDate`". The claim that follows —
|
||
* that it can fire once and never again — is a claim about the SAME function
|
||
* the cron calls to advance a fired reminder. If that function returned an
|
||
* instant here, the row would re-arm every tick and nag forever about an
|
||
* appointment that already happened, and nothing else in the system would
|
||
* notice: the row is excluded from every list a person can see.
|
||
*
|
||
* The module said this test existed. It did not. That is the reason it does
|
||
* now: a comment claiming a proof is worse than no comment, because it stops
|
||
* the next reader looking.
|
||
*/
|
||
import { describe, expect, it } from "vitest";
|
||
|
||
import {
|
||
appointmentNextDueAfterFiring,
|
||
appointmentNotifyHour,
|
||
} from "@/lib/encounters/appointment-reminder";
|
||
|
||
const TZ = "Europe/Berlin";
|
||
const APPOINTMENT = new Date("2026-09-14T07:40:00.000Z");
|
||
|
||
/** The row shape a minted appointment reminder actually carries. */
|
||
const oneShot = {
|
||
intervalDays: null,
|
||
rrule: null,
|
||
anchorDate: APPOINTMENT,
|
||
notifyHour: 9,
|
||
createdAt: new Date("2026-08-01T00:00:00.000Z"),
|
||
};
|
||
|
||
describe("a fired appointment reminder can never re-arm", () => {
|
||
it("has no next occurrence once its instant has passed", () => {
|
||
const afterItFired = new Date("2026-09-14T08:00:00.000Z");
|
||
|
||
expect(appointmentNextDueAfterFiring(oneShot, TZ, afterItFired)).toBeNull();
|
||
});
|
||
|
||
it("still has none a year later", () => {
|
||
// The failure this rules out is a cadence quietly inferred from the anchor,
|
||
// which would look correct on the day and nag every year after it.
|
||
expect(
|
||
appointmentNextDueAfterFiring(
|
||
oneShot,
|
||
TZ,
|
||
new Date("2027-10-01T00:00:00.000Z"),
|
||
),
|
||
).toBeNull();
|
||
});
|
||
|
||
it("is not vacuous: a cadenced reminder DOES get a next occurrence", () => {
|
||
// Without this, "returns null" would also pass for a function that returns
|
||
// null unconditionally, which is the shape of green this repository keeps
|
||
// being bitten by.
|
||
const cadenced = { ...oneShot, intervalDays: 30 };
|
||
|
||
expect(
|
||
appointmentNextDueAfterFiring(
|
||
cadenced,
|
||
TZ,
|
||
new Date("2026-09-14T08:00:00.000Z"),
|
||
),
|
||
).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("the hour the nudge fires", () => {
|
||
it("is the appointment's own local hour, not the server's", () => {
|
||
// 07:40 UTC is 09:40 in Berlin, so the nudge belongs at 9.
|
||
expect(appointmentNotifyHour(APPOINTMENT, TZ)).toBe(9);
|
||
});
|
||
|
||
it("reads midnight as hour zero rather than twenty-four", () => {
|
||
// Some locales format midnight as "24"; the column is 0–23.
|
||
expect(
|
||
appointmentNotifyHour(new Date("2026-09-14T22:00:00.000Z"), TZ),
|
||
).toBe(0);
|
||
});
|
||
|
||
it("follows the record's zone, so the same instant differs by zone", () => {
|
||
expect(appointmentNotifyHour(APPOINTMENT, "UTC")).toBe(7);
|
||
});
|
||
});
|