fix(migrations): tighten and widen the 0355 repair where the evidence allows

A snooze that had already run out before the send is history, not a
choice about the reminded slot, so it no longer keeps a row from being
repaired. The "no due date" arm matched nothing, because a reminder
cannot be created without a cadence; it now covers a rule whose last
occurrence was the one sent, which is open unless it was done or
skipped since, while a course whose window closed before the send stays
closed through the end-date guard. The start-page step reads a stored
item list only when it is a list, so a malformed layout can no longer
fail the migration.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 10:41:02 +02:00
1 parent 7aefde67e2
commit f3df0023b2
2 files changed
+48 -14

No files matched your search

@@ -30,8 +30,9 @@ ALTER TABLE "measurement_reminders" ADD COLUMN "last_notified_at" TIMESTAMP(3);
-- longer than seven days, a recurrence rule, or no cadence at all.
-- Measurement reminders are included; whether it names a measurement
-- type does not matter;
-- - it was not done or skipped after that send, and it carries no
-- snooze;
-- - it was not done or skipped after that send, and no snooze reaches
-- past it (a snooze that had already run out before the send is
-- history, not a choice about this slot);
-- - the send was its most recent claim, and the row's last write
-- (`updated_at`) lands within a minute before to ten minutes after
-- that claim. The roll-on is the write that follows a delivery by
@@ -42,7 +43,9 @@ ALTER TABLE "measurement_reminders" ADD COLUMN "last_notified_at" TIMESTAMP(3);
-- and what the new behaviour no longer writes: more than a week past
-- the send (a weekly or shorter cycle, including a rule that recurs
-- within the week, still rolls on, so it is not repaired), or no due
-- date at all for a reminder with no cadence.
-- date at all: a rule whose last occurrence was the one sent, which
-- is still open unless it was done or skipped since. A course whose
-- window ended before the send is excluded by the end-date guard.
--
-- The due date goes back to the instant of that send, which is the day
-- the reminder went out and at its notify hour. `last_notified_at`
@@ -77,7 +80,7 @@ WHERE r."id" = lc."reminder_id"
OR r."rrule" IS NOT NULL
OR (r."interval_days" IS NULL AND r."rrule" IS NULL)
)
AND r."snoozed_until" IS NULL
AND (r."snoozed_until" IS NULL OR r."snoozed_until" <= lc."sent_at")
AND r."last_notified_at" IS NULL
AND (r."last_satisfied_at" IS NULL OR r."last_satisfied_at" < lc."sent_at")
AND (r."last_skipped_at" IS NULL OR r."last_skipped_at" < lc."sent_at")
@@ -86,11 +89,7 @@ WHERE r."id" = lc."reminder_id"
AND r."updated_at" <= lc."sent_at" + INTERVAL '10 minutes'
AND (
r."next_due_at" > lc."sent_at" + INTERVAL '7 days 12 hours'
OR (
r."next_due_at" IS NULL
AND r."interval_days" IS NULL
AND r."rrule" IS NULL
)
OR r."next_due_at" IS NULL
);
-- 3. Show booked visits on the start page for people who chose its items
@@ -103,13 +102,17 @@ WHERE r."id" = lc."reminder_id"
-- non-empty list that lacks it. An empty list is the whole rail turned off
-- and stays empty. A person who has deliberately switched visits off since
-- v1.37.2 cannot be told apart from one who never had the choice; they see
-- visits again and can switch them off once more, which now sticks.
-- visits again and can switch them off once more, which now sticks. A
-- stored value that is not a list is left alone rather than read.
UPDATE "users"
SET "dashboard_widgets_json" = jsonb_set(
"dashboard_widgets_json",
'{enabledHeroItemKinds}',
("dashboard_widgets_json" -> 'enabledHeroItemKinds') || '["upcoming_visit"]'::jsonb
)
WHERE jsonb_typeof("dashboard_widgets_json" -> 'enabledHeroItemKinds') = 'array'
AND jsonb_array_length("dashboard_widgets_json" -> 'enabledHeroItemKinds') > 0
AND NOT ("dashboard_widgets_json" -> 'enabledHeroItemKinds') ? 'upcoming_visit';
WHERE CASE
WHEN jsonb_typeof("dashboard_widgets_json" -> 'enabledHeroItemKinds') = 'array'
THEN jsonb_array_length("dashboard_widgets_json" -> 'enabledHeroItemKinds') > 0
AND NOT ("dashboard_widgets_json" -> 'enabledHeroItemKinds') ? 'upcoming_visit'
ELSE FALSE
END;
+32 -1
View File
@@ -226,7 +226,12 @@ describe("an open fortnightly questionnaire through the reminder tick", () => {
source: "MANUAL",
},
});
const next = await tick(new Date(filledAt.getTime() + 15 * 60 * 1000));
// Outside the notify hour the open slot is not even polled (the
// eventful satisfy worker covers that moment); the next notify hour's
// poll resolves it.
const afternoon = await tick(new Date(filledAt.getTime() + 15 * 60 * 1000));
expect(afternoon.autoResolved).toBe(0);
const next = await tick(new Date(DUE.getTime() + 2 * DAY));
expect(next.autoResolved).toBe(1);
row = await prisma.measurementReminder.findUniqueOrThrow({
where: { id: created.id },
@@ -338,6 +343,19 @@ describe("migration 0355", () => {
updatedAt: new Date("2026-09-26T07:00:03.900Z"),
},
{ id: "yearly-measurement", data: { measurementType: "WEIGHT" } },
{
// A snooze that ran out before the send says nothing about this slot.
id: "old-snooze",
data: { snoozedUntil: new Date("2026-09-20T07:00:00.000Z") },
},
{
// A rule whose last occurrence was the one sent: rolled to nothing.
id: "rule-exhausted",
data: {
rrule: "FREQ=MONTHLY;UNTIL=20260930T000000Z",
nextDueAt: null,
},
},
{
// An earlier cycle's send is older; the latest send decides.
id: "monthly-many-sends",
@@ -422,6 +440,15 @@ describe("migration 0355", () => {
},
{ id: "appointment", data: { origin: "ENCOUNTER", rrule: null } },
{ id: "deleted", data: { deletedAt: ROLLED } },
{
// A course whose window closed before the send stays closed.
id: "course-ended",
data: {
rrule: "FREQ=DAILY",
nextDueAt: null,
endsOn: new Date("2026-09-25T00:00:00.000Z"),
},
},
{ id: "disabled", data: { enabled: false } },
{ id: "never-sent", claims: [] },
{
@@ -483,6 +510,9 @@ describe("migration 0355", () => {
dashboardWidgetsJson: { version: 1, widgets: [] },
});
await seedUser("hero-none");
await seedUser("hero-malformed", {
dashboardWidgetsJson: { version: 1, enabledHeroItemKinds: "all" },
});
await runDataSteps();
await runDataSteps();
@@ -500,5 +530,6 @@ describe("migration 0355", () => {
]);
expect(await layout("hero-default")).toEqual({ version: 1, widgets: [] });
expect(await layout("hero-none")).toBeNull();
expect((await layout("hero-malformed"))?.enabledHeroItemKinds).toBe("all");
});
});