mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(mood-reminder): six-locale resolver, ledger-after-delivery, DST-safe reminders
Bundle of hotfixes against v0.5.4 PR #190 surfaced by the QA review: - Locale resolver now accepts every supported locale instead of silently demoting es/fr/it/pl to English. The same PR shipped the translations into the locale files; without this change those strings were unreachable dead code. - Ledger row is written only after `dispatchNotification` reports a successful delivery. A transient APNs / network blip used to land the ledger first and silently nuke the day's nudge for that user; now the next tick is free to retry. New `skippedNoChannel` + `failed` counters on the tick summary so the wide-event attributes the outcome. - Per-user `try` wrapper around the inner processing so one bad row (corrupt timezone, dispatcher exception) cannot abort the rest of the 22:00 candidates. - P2002 race on the ledger insert now counts as `dispatched` — the worker that lost the race already delivered the push, so the user did receive it. - Dead-code drop: the now-unused `moodReminderEnabled` select field (the `where` clause already filters), and the redundant `MoodReminderCandidate` interface. - `localHmAsUtc` helper in `@/lib/timezone` re-derives the UTC offset at the target local time so `todayStart.getTime() + h * 3.6e6` no longer drifts by an hour on DST transition days. The medication-reminder `scheduledFor` field and the iOS-snooze `scheduledAt` ISO both route through it now. - New daily 03:25 Europe/Berlin cron sweeps the `mood_reminder_dispatches` ledger to a 90-day retention horizon, matching the audit-log cleanup cadence. Test coverage rewritten to pin every new contract — outcome bubble, ledger-after-delivery, per-user try wrapper, P2002 race semantics, six-locale dispatch — plus a regression test for the v0.5.4 FR apostrophe typo.
This commit is contained in:
1 parent
1bb2afd829
commit
e1a5c639a5
4 files changed
+339
-191
No files matched your search
@@ -1,16 +1,22 @@
|
||||
/**
|
||||
* v0.5.4 ios-coord — mood-reminder dispatcher unit tests.
|
||||
*
|
||||
* Pins the contract that motivated the v0.5.4 server-side patch:
|
||||
* Pins the contract that motivated the v0.5.4 server-side patch and
|
||||
* the v1.4.38.2 hotfix bundle:
|
||||
* - Opt-in gating: `moodReminderEnabled = false` ⇒ never dispatch.
|
||||
* - Window gating: only fires inside the 22:00 local-time hour.
|
||||
* - Logged-today skip: a `MoodEntry` row for the local date short-
|
||||
* circuits the dispatch.
|
||||
* - Idempotency: a `MoodReminderDispatch` row blocks a second push
|
||||
* for the same (user, date) — even when the cron re-ticks inside
|
||||
* the same 22:00 window.
|
||||
* - Locale: title + body come from `messages/{de,en}.json` and fall
|
||||
* back to the app default when the user's locale is null/unknown.
|
||||
* for the same (user, date).
|
||||
* - Locale: title + body come from the user's locale across all six
|
||||
* supported locales (de/en/es/fr/it/pl) and fall back to the app
|
||||
* default for null / unknown locales.
|
||||
* - Ledger-after-delivery: a dispatcher that reports `dispatched =
|
||||
* false` (no channel succeeded) does NOT write a ledger row, so
|
||||
* the next tick is free to retry.
|
||||
* - Per-user try-wrapper: an exception in one user's processing
|
||||
* does not abort the tick for the rest of the candidates.
|
||||
*
|
||||
* The tests stub the Prisma surface manually to keep this file free of
|
||||
* a testcontainer boot.
|
||||
@@ -24,15 +30,27 @@ import {
|
||||
MOOD_REMINDER_LOCAL_HOUR,
|
||||
} from "../mood-reminder";
|
||||
import type { NotificationPayload } from "@/lib/notifications/types";
|
||||
import type { DispatchOutcome } from "@/lib/notifications/dispatcher";
|
||||
|
||||
type DispatchFn = (payload: NotificationPayload) => Promise<void>;
|
||||
type DispatchFn = (payload: NotificationPayload) => Promise<DispatchOutcome>;
|
||||
|
||||
const OK: DispatchOutcome = {
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
};
|
||||
|
||||
const NO_CHANNEL: DispatchOutcome = {
|
||||
dispatched: false,
|
||||
channelsAttempted: 0,
|
||||
channelsSucceeded: 0,
|
||||
};
|
||||
|
||||
interface FakePrismaState {
|
||||
candidates: Array<{
|
||||
id: string;
|
||||
timezone: string;
|
||||
locale: string | null;
|
||||
moodReminderEnabled: boolean;
|
||||
}>;
|
||||
moodEntries: Array<{ userId: string; date: string }>;
|
||||
dispatches: Array<{ userId: string; date: string }>;
|
||||
@@ -43,13 +61,7 @@ interface FakePrismaState {
|
||||
function makePrisma(state: FakePrismaState) {
|
||||
return {
|
||||
user: {
|
||||
findMany: vi.fn(
|
||||
async ({ where }: { where: { moodReminderEnabled: boolean } }) => {
|
||||
return state.candidates.filter(
|
||||
(c) => c.moodReminderEnabled === where.moodReminderEnabled,
|
||||
);
|
||||
},
|
||||
),
|
||||
findMany: vi.fn(async () => state.candidates),
|
||||
},
|
||||
moodEntry: {
|
||||
findFirst: vi.fn(
|
||||
@@ -81,8 +93,6 @@ function makePrisma(state: FakePrismaState) {
|
||||
create: vi.fn(
|
||||
async ({ data }: { data: { userId: string; date: string } }) => {
|
||||
if (state.raceUserIds.has(data.userId)) {
|
||||
// Mirrors the Prisma `P2002` shape closely enough that the
|
||||
// handler's substring match (`includes("P2002")`) catches it.
|
||||
throw new Error(
|
||||
"Invalid `prisma.moodReminderDispatch.create()` invocation: " +
|
||||
"Unique constraint failed on the fields: (`user_id`,`date`). " +
|
||||
@@ -103,7 +113,7 @@ beforeEach(() => {
|
||||
|
||||
describe("evaluateMoodReminderWindow", () => {
|
||||
it("returns fire=false when the user is opted out", () => {
|
||||
const at22 = new Date("2026-05-17T20:00:00Z"); // 22:00 Europe/Berlin
|
||||
const at22 = new Date("2026-05-17T20:00:00Z");
|
||||
const r = evaluateMoodReminderWindow(
|
||||
{ timezone: "Europe/Berlin", moodReminderEnabled: false },
|
||||
at22,
|
||||
@@ -113,7 +123,7 @@ describe("evaluateMoodReminderWindow", () => {
|
||||
});
|
||||
|
||||
it("fires inside the 22:00 hour and returns the local date string", () => {
|
||||
const at22 = new Date("2026-05-17T20:00:00Z"); // 22:00 Europe/Berlin (CEST)
|
||||
const at22 = new Date("2026-05-17T20:00:00Z");
|
||||
const r = evaluateMoodReminderWindow(
|
||||
{ timezone: "Europe/Berlin", moodReminderEnabled: true },
|
||||
at22,
|
||||
@@ -123,8 +133,8 @@ describe("evaluateMoodReminderWindow", () => {
|
||||
expect(r.localHour).toBe(MOOD_REMINDER_LOCAL_HOUR);
|
||||
});
|
||||
|
||||
it("does NOT fire at 21:59 local time (off-by-one guard)", () => {
|
||||
const at2159 = new Date("2026-05-17T19:59:00Z"); // 21:59 Europe/Berlin
|
||||
it("does NOT fire at 21:59 local time", () => {
|
||||
const at2159 = new Date("2026-05-17T19:59:00Z");
|
||||
const r = evaluateMoodReminderWindow(
|
||||
{ timezone: "Europe/Berlin", moodReminderEnabled: true },
|
||||
at2159,
|
||||
@@ -133,8 +143,8 @@ describe("evaluateMoodReminderWindow", () => {
|
||||
expect(r.localHour).toBe(21);
|
||||
});
|
||||
|
||||
it("does NOT fire at 23:00 local time (window end)", () => {
|
||||
const at23 = new Date("2026-05-17T21:00:00Z"); // 23:00 Europe/Berlin
|
||||
it("does NOT fire at 23:00 local time", () => {
|
||||
const at23 = new Date("2026-05-17T21:00:00Z");
|
||||
const r = evaluateMoodReminderWindow(
|
||||
{ timezone: "Europe/Berlin", moodReminderEnabled: true },
|
||||
at23,
|
||||
@@ -143,8 +153,7 @@ describe("evaluateMoodReminderWindow", () => {
|
||||
expect(r.localHour).toBe(23);
|
||||
});
|
||||
|
||||
it("respects a non-Berlin timezone (America/New_York is 6h behind in DST)", () => {
|
||||
// 02:00 UTC on 2026-05-18 = 22:00 EDT on 2026-05-17 (UTC-4 during DST).
|
||||
it("respects a non-Berlin timezone", () => {
|
||||
const utc = new Date("2026-05-18T02:00:00Z");
|
||||
const r = evaluateMoodReminderWindow(
|
||||
{ timezone: "America/New_York", moodReminderEnabled: true },
|
||||
@@ -168,37 +177,48 @@ describe("buildMoodReminderPayload", () => {
|
||||
expect(p.body).toBe("How are you feeling today?");
|
||||
});
|
||||
|
||||
it.each(["es", "fr", "it", "pl"] as const)(
|
||||
"returns native (non-EN) strings for locale=%s",
|
||||
(locale) => {
|
||||
const p = buildMoodReminderPayload(locale);
|
||||
const enTitle = buildMoodReminderPayload("en").title;
|
||||
const enBody = buildMoodReminderPayload("en").body;
|
||||
expect(p.title.length).toBeGreaterThan(0);
|
||||
expect(p.body.length).toBeGreaterThan(0);
|
||||
expect(p.title).not.toBe(enTitle);
|
||||
expect(p.body).not.toBe(enBody);
|
||||
},
|
||||
);
|
||||
|
||||
it("falls back to the app default for null / unknown locales", () => {
|
||||
const p1 = buildMoodReminderPayload(null);
|
||||
const p2 = buildMoodReminderPayload("zz-ZZ");
|
||||
// Both should resolve to the same content (the app default).
|
||||
expect(p1.title).toBe(p2.title);
|
||||
expect(p1.title.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("FR body carries the apostrophe (regression: v0.5.4 shipped 'aujourdhui')", () => {
|
||||
const p = buildMoodReminderPayload("fr");
|
||||
expect(p.body).toContain("aujourd’hui");
|
||||
expect(p.body).not.toContain("aujourdhui ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMoodReminderTick", () => {
|
||||
// 22:00 Europe/Berlin (CEST) ⇒ 20:00 UTC.
|
||||
const inWindow = new Date("2026-05-17T20:00:00Z");
|
||||
// 21:00 Europe/Berlin (CEST) ⇒ 19:00 UTC.
|
||||
const outOfWindow = new Date("2026-05-17T19:00:00Z");
|
||||
|
||||
it("dispatches MOOD_REMINDER for opted-in users in the 22:00 window", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{
|
||||
id: "u-1",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
{ id: "u-1", timezone: "Europe/Berlin", locale: "de" },
|
||||
],
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, inWindow, {
|
||||
dispatch,
|
||||
@@ -223,19 +243,14 @@ describe("runMoodReminderTick", () => {
|
||||
it("does NOT dispatch for users outside the 22:00 window", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{
|
||||
id: "u-1",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
{ id: "u-1", timezone: "Europe/Berlin", locale: "de" },
|
||||
],
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, outOfWindow, {
|
||||
dispatch,
|
||||
@@ -246,15 +261,15 @@ describe("runMoodReminderTick", () => {
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never reads moodReminderEnabled=false users (opt-in gating)", async () => {
|
||||
it("Prisma query filters to moodReminderEnabled=true users only", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [], // Prisma `where` already filters; we assert the call shape
|
||||
candidates: [],
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await runMoodReminderTick(prisma as never, inWindow, { dispatch });
|
||||
|
||||
@@ -266,19 +281,14 @@ describe("runMoodReminderTick", () => {
|
||||
it("skips users who already logged a mood for the local date", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{
|
||||
id: "u-1",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
{ id: "u-1", timezone: "Europe/Berlin", locale: "de" },
|
||||
],
|
||||
moodEntries: [{ userId: "u-1", date: "2026-05-17" }],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, inWindow, {
|
||||
dispatch,
|
||||
@@ -293,20 +303,14 @@ describe("runMoodReminderTick", () => {
|
||||
it("idempotency: a second tick inside the same window does not re-send", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{
|
||||
id: "u-1",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
{ id: "u-1", timezone: "Europe/Berlin", locale: "de" },
|
||||
],
|
||||
moodEntries: [],
|
||||
// Existing dispatch row blocks the push.
|
||||
dispatches: [{ userId: "u-1", date: "2026-05-17" }],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, inWindow, {
|
||||
dispatch,
|
||||
@@ -317,54 +321,93 @@ describe("runMoodReminderTick", () => {
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("idempotency: lost P2002 race is swallowed as 'already dispatched'", async () => {
|
||||
it("ledger-after-delivery: dispatcher reporting dispatched=false leaves the slot free", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{
|
||||
id: "u-1",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
{ id: "u-1", timezone: "Europe/Berlin", locale: "de" },
|
||||
],
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => NO_CHANNEL);
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, inWindow, {
|
||||
dispatch,
|
||||
});
|
||||
|
||||
expect(summary.dispatched).toBe(0);
|
||||
expect(summary.skippedNoChannel).toBe(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
// Ledger MUST stay empty so the next tick (or next request after the
|
||||
// user adds a channel) is free to retry.
|
||||
expect(state.dispatches).toHaveLength(0);
|
||||
expect(prisma.moodReminderDispatch.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("P2002 race: counts as dispatched (both workers pushed, one ledger row survives)", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{ id: "u-1", timezone: "Europe/Berlin", locale: "de" },
|
||||
],
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(["u-1"]),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, inWindow, {
|
||||
dispatch,
|
||||
});
|
||||
|
||||
expect(summary.dispatched).toBe(0);
|
||||
expect(summary.skippedAlreadyDispatched).toBe(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
// The dispatcher delivered (OK), the ledger insert hit P2002. The
|
||||
// user already received the push from this worker; we count it as
|
||||
// dispatched and move on.
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("dispatches per-user with their own locale (DE + EN mix)", async () => {
|
||||
it("per-user try-wrapper: one bad user does not abort the rest of the tick", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{
|
||||
id: "u-de",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
{
|
||||
id: "u-en",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
{ id: "u-bad", timezone: "Europe/Berlin", locale: "de" },
|
||||
{ id: "u-ok", timezone: "Europe/Berlin", locale: "en" },
|
||||
],
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
const dispatch = vi.fn<DispatchFn>(async (payload) => {
|
||||
if (payload.userId === "u-bad") {
|
||||
throw new Error("simulated dispatcher throw");
|
||||
}
|
||||
return OK;
|
||||
});
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, inWindow, {
|
||||
dispatch,
|
||||
});
|
||||
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(summary.failed).toBe(1);
|
||||
expect(state.dispatches.map((d) => d.userId)).toEqual(["u-ok"]);
|
||||
});
|
||||
|
||||
it("dispatches per-user with their own locale (DE + EN mix)", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{ id: "u-de", timezone: "Europe/Berlin", locale: "de" },
|
||||
{ id: "u-en", timezone: "Europe/Berlin", locale: "en" },
|
||||
],
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, inWindow, {
|
||||
dispatch,
|
||||
@@ -379,4 +422,31 @@ describe("runMoodReminderTick", () => {
|
||||
expect(titles).toContain("Stimmung erfassen");
|
||||
expect(titles).toContain("Log your mood");
|
||||
});
|
||||
|
||||
it("dispatches per-user locale for fr/es/it/pl (regression: resolver dropped 4/6)", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{ id: "u-fr", timezone: "Europe/Berlin", locale: "fr" },
|
||||
{ id: "u-es", timezone: "Europe/Berlin", locale: "es" },
|
||||
{ id: "u-it", timezone: "Europe/Berlin", locale: "it" },
|
||||
{ id: "u-pl", timezone: "Europe/Berlin", locale: "pl" },
|
||||
],
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await runMoodReminderTick(prisma as never, inWindow, { dispatch });
|
||||
|
||||
const titles = dispatch.mock.calls.map((c) => c[0].title);
|
||||
const enTitle = buildMoodReminderPayload("en").title;
|
||||
// Each non-EN user must get a title that is NOT the English string.
|
||||
expect(titles).toHaveLength(4);
|
||||
for (const t of titles) {
|
||||
expect(t).not.toBe(enTitle);
|
||||
expect(t.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,9 @@
|
||||
* 3. Skips users who already received a mood reminder today
|
||||
* (idempotency via `MoodReminderDispatch` row).
|
||||
* 4. Dispatches a `MOOD_REMINDER` notification with a locale-aware
|
||||
* title + body and records the dispatch ledger row in the same
|
||||
* transaction so a concurrent worker can't double-fire.
|
||||
* title + body. The ledger row is written only after the dispatcher
|
||||
* confirms at least one channel delivered successfully, so a
|
||||
* transient APNs / network blip leaves the next tick free to retry.
|
||||
*
|
||||
* Why a separate module instead of folding it into reminder-worker.ts:
|
||||
* the worker is already 1800 LOC. Splitting the mood-reminder logic out
|
||||
@@ -21,9 +22,10 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { defaultLocale } from "@/lib/i18n/config";
|
||||
import { defaultLocale, locales } from "@/lib/i18n/config";
|
||||
import { getLocalDateParts } from "@/lib/timezone";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Hour of day (in the user's local timezone) at which the mood reminder
|
||||
@@ -36,18 +38,6 @@ import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
*/
|
||||
export const MOOD_REMINDER_LOCAL_HOUR = 22;
|
||||
|
||||
/**
|
||||
* Minimal user shape needed to decide whether to dispatch. Defined
|
||||
* explicitly so the handler can run against either the global Prisma
|
||||
* client or the per-worker singleton without re-deriving the type.
|
||||
*/
|
||||
export interface MoodReminderCandidate {
|
||||
id: string;
|
||||
timezone: string;
|
||||
locale: string | null;
|
||||
moodReminderEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface MoodReminderSummary {
|
||||
candidatesScanned: number;
|
||||
inWindow: number;
|
||||
@@ -55,10 +45,14 @@ export interface MoodReminderSummary {
|
||||
skippedAlreadyLogged: number;
|
||||
skippedAlreadyDispatched: number;
|
||||
skippedOutsideWindow: number;
|
||||
skippedNoChannel: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
function resolveLocale(locale: string | null | undefined): Locale {
|
||||
return locale === "en" || locale === "de" ? locale : defaultLocale;
|
||||
return locales.includes(locale as Locale)
|
||||
? (locale as Locale)
|
||||
: defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +68,7 @@ function resolveLocale(locale: string | null | undefined): Locale {
|
||||
* 23:00 → null) without touching the DB.
|
||||
*/
|
||||
export function evaluateMoodReminderWindow(
|
||||
user: Pick<MoodReminderCandidate, "timezone" | "moodReminderEnabled">,
|
||||
user: { timezone: string; moodReminderEnabled: boolean },
|
||||
now: Date,
|
||||
): { fire: boolean; localDate: string | null; localHour: number } {
|
||||
if (!user.moodReminderEnabled) {
|
||||
@@ -115,10 +109,15 @@ export function buildMoodReminderPayload(locale: string | null | undefined): {
|
||||
*
|
||||
* The dedup ledger (`MoodReminderDispatch`) is the idempotency anchor:
|
||||
* the `(userId, date)` unique constraint guarantees that two parallel
|
||||
* workers (or a re-tick inside the same hour) won't double-fire.
|
||||
* workers (or a re-tick inside the same hour) won't double-fire. The
|
||||
* ledger row is inserted only after the dispatcher confirms at least
|
||||
* one channel delivered, so a transient APNs blip leaves the slot free
|
||||
* for the next tick to retry rather than silently nuking the day's
|
||||
* nudge.
|
||||
*
|
||||
* Returns a summary so the worker can attach it to the wide-event for
|
||||
* the observability dashboards.
|
||||
* Per-user processing is wrapped in its own try/catch so a single bad
|
||||
* row (corrupt timezone, dispatcher exception) cannot abort the rest
|
||||
* of the tick.
|
||||
*/
|
||||
export async function runMoodReminderTick(
|
||||
prisma: PrismaClient,
|
||||
@@ -136,99 +135,99 @@ export async function runMoodReminderTick(
|
||||
skippedAlreadyLogged: 0,
|
||||
skippedAlreadyDispatched: 0,
|
||||
skippedOutsideWindow: 0,
|
||||
skippedNoChannel: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
const candidates = await prisma.user.findMany({
|
||||
where: { moodReminderEnabled: true },
|
||||
select: {
|
||||
id: true,
|
||||
timezone: true,
|
||||
locale: true,
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
select: { id: true, timezone: true, locale: true },
|
||||
});
|
||||
|
||||
for (const user of candidates) {
|
||||
summary.candidatesScanned += 1;
|
||||
const decision = evaluateMoodReminderWindow(
|
||||
{
|
||||
timezone: user.timezone,
|
||||
moodReminderEnabled: user.moodReminderEnabled,
|
||||
},
|
||||
now,
|
||||
);
|
||||
|
||||
if (!decision.fire || !decision.localDate) {
|
||||
summary.skippedOutsideWindow += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.inWindow += 1;
|
||||
|
||||
// Already nudged today? The dedup row is the source of truth — if
|
||||
// a previous tick succeeded, this branch swallows the work. We
|
||||
// check before the MoodEntry lookup because the ledger query is
|
||||
// strictly cheaper (single indexed row) than scanning today's
|
||||
// mood entries.
|
||||
const existingDispatch = await prisma.moodReminderDispatch.findUnique({
|
||||
where: { userId_date: { userId: user.id, date: decision.localDate } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingDispatch) {
|
||||
summary.skippedAlreadyDispatched += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip when the user already logged a mood for the local date. Same
|
||||
// anchoring convention as `MoodEntry.date` (YYYY-MM-DD in the user's
|
||||
// current timezone). Legacy rows that pre-date the `tz` column are
|
||||
// already pinned to Europe/Berlin via the storage convention; here
|
||||
// we accept whatever `date` string the row carries because the
|
||||
// user-experience contract is "I logged today, don't nudge me".
|
||||
const existingMood = await prisma.moodEntry.findFirst({
|
||||
where: { userId: user.id, date: decision.localDate },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingMood) {
|
||||
summary.skippedAlreadyLogged += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reserve the dedup row FIRST so a parallel worker that races us
|
||||
// hits the unique-constraint violation and bails before fanning a
|
||||
// duplicate push. We catch the P2002 unique-violation path
|
||||
// explicitly because a "lost the race" outcome is not a worker
|
||||
// error — it's the idempotency contract doing its job.
|
||||
try {
|
||||
await prisma.moodReminderDispatch.create({
|
||||
data: { userId: user.id, date: decision.localDate },
|
||||
const decision = evaluateMoodReminderWindow(
|
||||
{ timezone: user.timezone, moodReminderEnabled: true },
|
||||
now,
|
||||
);
|
||||
|
||||
if (!decision.fire || !decision.localDate) {
|
||||
summary.skippedOutsideWindow += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.inWindow += 1;
|
||||
|
||||
// Cheaper guard first: the ledger lookup is a single indexed row
|
||||
// while the mood-entry scan touches today's mood writes.
|
||||
const existingDispatch = await prisma.moodReminderDispatch.findUnique({
|
||||
where: { userId_date: { userId: user.id, date: decision.localDate } },
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Prisma encodes unique-violation as `code === 'P2002'`. Detect
|
||||
// by string-includes rather than instanceof so this module
|
||||
// doesn't pull in the Prisma runtime types just for the guard.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("P2002") || message.includes("Unique constraint")) {
|
||||
if (existingDispatch) {
|
||||
summary.skippedAlreadyDispatched += 1;
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
|
||||
const existingMood = await prisma.moodEntry.findFirst({
|
||||
where: { userId: user.id, date: decision.localDate },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingMood) {
|
||||
summary.skippedAlreadyLogged += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { title, body } = buildMoodReminderPayload(user.locale);
|
||||
|
||||
const outcome = await dispatchImpl({
|
||||
eventType: "MOOD_REMINDER",
|
||||
userId: user.id,
|
||||
title,
|
||||
message: body,
|
||||
metadata: {
|
||||
scheduledAt: now.toISOString(),
|
||||
localDate: decision.localDate,
|
||||
},
|
||||
});
|
||||
|
||||
// No channel succeeded — leave the ledger slot empty so the next
|
||||
// tick can retry once the user adds a channel or the upstream
|
||||
// recovers. The "at most once" trade is: extremely-rare
|
||||
// concurrent-worker race may emit two pushes; in exchange a
|
||||
// transient blip never silently nukes the nudge for the day.
|
||||
if (!outcome.dispatched) {
|
||||
summary.skippedNoChannel += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.moodReminderDispatch.create({
|
||||
data: { userId: user.id, date: decision.localDate },
|
||||
});
|
||||
summary.dispatched += 1;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (
|
||||
message.includes("P2002") ||
|
||||
message.includes("Unique constraint")
|
||||
) {
|
||||
// Lost a race against a parallel worker that also dispatched
|
||||
// successfully — both pushed, only one ledger row survives.
|
||||
// Count it as dispatched anyway (the user got the push).
|
||||
summary.dispatched += 1;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
summary.failed += 1;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
getEvent()?.addWarning(
|
||||
`mood-reminder per-user dispatch failed for ${user.id}: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { title, body } = buildMoodReminderPayload(user.locale);
|
||||
|
||||
await dispatchImpl({
|
||||
eventType: "MOOD_REMINDER",
|
||||
userId: user.id,
|
||||
title,
|
||||
message: body,
|
||||
metadata: {
|
||||
scheduledAt: now.toISOString(),
|
||||
localDate: decision.localDate,
|
||||
},
|
||||
});
|
||||
|
||||
summary.dispatched += 1;
|
||||
}
|
||||
|
||||
return summary;
|
||||
|
||||
@@ -90,6 +90,7 @@ import { runOffhostBackup } from "@/lib/jobs/offhost-backup";
|
||||
import {
|
||||
getUserTodayBounds as getUserTodayBoundsUtil,
|
||||
getDayOfWeekInTz as getDayOfWeekInTzUtil,
|
||||
localHmAsUtc,
|
||||
} from "@/lib/timezone";
|
||||
|
||||
function parseTimeToMinutes(value: string): number {
|
||||
@@ -191,6 +192,14 @@ const DRAIN_CUMULATIVE_CRON = "45 3 * * *";
|
||||
// `MoodReminderDispatch` ledger inside the handler.
|
||||
const MOOD_REMINDER_QUEUE = "mood-reminder-check";
|
||||
const MOOD_REMINDER_CRON = "*/15 * * * *";
|
||||
// v1.4.38.2 — daily retention sweep for the mood-reminder dispatch
|
||||
// ledger. Rows older than 90 days are behavioural footprints of
|
||||
// mood-log gaps; we keep them long enough to debug a duplicate-push
|
||||
// report (~one billing cycle) but no longer. Slots between the
|
||||
// audit-log cleanup (03:15) and the drain (03:45).
|
||||
const MOOD_REMINDER_CLEANUP_QUEUE = "mood-reminder-cleanup";
|
||||
const MOOD_REMINDER_CLEANUP_CRON = "25 3 * * *";
|
||||
const MOOD_REMINDER_RETENTION_DAYS = 90;
|
||||
// v1.4.38 — the per-sample cutoff hours constant now lives on the
|
||||
// helper module so the worker, the admin route, and the CLI all read
|
||||
// the same source of truth. Re-export pulled in alongside
|
||||
@@ -525,9 +534,12 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
// RED phase: create missed intake event
|
||||
if (currentPhase === "RED") {
|
||||
const [h, m] = schedule.windowStart.split(":").map(Number);
|
||||
const scheduledFor = new Date(
|
||||
todayStart.getTime() + h * 3600000 + m * 60000,
|
||||
);
|
||||
// DST-safe: re-derive the offset at the target local time so
|
||||
// the UTC instant is correct on spring-forward / fall-back
|
||||
// days. `todayStart + h * 3.6e6` drifts by an hour twice a
|
||||
// year — the iOS snooze action would otherwise pin against
|
||||
// the wrong baseline.
|
||||
const scheduledFor = localHmAsUtc(now, med.user.timezone, h, m);
|
||||
|
||||
const existingMissed = await prisma.medicationIntakeEvent.count({
|
||||
where: {
|
||||
@@ -578,12 +590,15 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
// v0.5.4 — surface the window-start as an ISO 8601 string so
|
||||
// the iOS notification handler can pin a "snooze 15 min"
|
||||
// action against the actual schedule slot rather than the
|
||||
// wall-clock moment APNs delivered. Computed in UTC from the
|
||||
// user's localised window-start; mirrors the RED-phase
|
||||
// `scheduledFor` field on the intake event.
|
||||
// wall-clock moment APNs delivered. DST-safe via
|
||||
// `localHmAsUtc` so spring-forward / fall-back days don't
|
||||
// drift the baseline by an hour.
|
||||
const [winH, winM] = schedule.windowStart.split(":").map(Number);
|
||||
const scheduledAtIso = new Date(
|
||||
todayStart.getTime() + winH * 3600000 + winM * 60000,
|
||||
const scheduledAtIso = localHmAsUtc(
|
||||
now,
|
||||
med.user.timezone,
|
||||
winH,
|
||||
winM,
|
||||
).toISOString();
|
||||
|
||||
evt.addMeta(
|
||||
@@ -1141,6 +1156,30 @@ async function handleMoodLogSync(jobs: Job<MoodLogSyncPayload>[]) {
|
||||
* spinning up pg-boss. The handler is a thin shim that wires the worker
|
||||
* Prisma singleton + the wide-event sink to the pure function.
|
||||
*/
|
||||
interface MoodReminderCleanupPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
async function handleMoodReminderCleanup(
|
||||
jobs: Job<MoodReminderCleanupPayload>[],
|
||||
) {
|
||||
void jobs;
|
||||
await withBackgroundEvent("job.mood_reminder_cleanup", async (evt) => {
|
||||
const p = getWorkerPrisma();
|
||||
try {
|
||||
const cutoff = new Date();
|
||||
cutoff.setUTCDate(cutoff.getUTCDate() - MOOD_REMINDER_RETENTION_DAYS);
|
||||
const cutoffIso = cutoff.toISOString().slice(0, 10);
|
||||
const deleted = await p.moodReminderDispatch.deleteMany({
|
||||
where: { date: { lt: cutoffIso } },
|
||||
});
|
||||
evt.addMeta("mood_reminder_cleanup_deleted", deleted.count);
|
||||
} catch (err) {
|
||||
evt.addWarning(`mood-reminder-cleanup failed: ${err}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMoodReminderCheck(jobs: Job<MoodReminderPayload>[]) {
|
||||
void jobs;
|
||||
await withBackgroundEvent("job.mood_reminder", async (evt) => {
|
||||
@@ -1626,6 +1665,7 @@ export async function startReminderWorker() {
|
||||
// entry the every-15-min schedule silently no-ops and the
|
||||
// dispatcher never fires.
|
||||
MOOD_REMINDER_QUEUE,
|
||||
MOOD_REMINDER_CLEANUP_QUEUE,
|
||||
];
|
||||
|
||||
for (const q of allQueues) {
|
||||
@@ -1687,6 +1727,7 @@ export async function startReminderWorker() {
|
||||
// time is the 22:00 hour, so the cron costs ~one user-row scan
|
||||
// per tick for the entire opted-in cohort.
|
||||
[MOOD_REMINDER_QUEUE, MOOD_REMINDER_CRON],
|
||||
[MOOD_REMINDER_CLEANUP_QUEUE, MOOD_REMINDER_CLEANUP_CRON],
|
||||
];
|
||||
|
||||
for (const [name, cron] of schedules) {
|
||||
@@ -1803,6 +1844,11 @@ export async function startReminderWorker() {
|
||||
{ localConcurrency: 1 },
|
||||
handleMoodReminderCheck,
|
||||
);
|
||||
await boss.work<MoodReminderCleanupPayload>(
|
||||
MOOD_REMINDER_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMoodReminderCleanup,
|
||||
);
|
||||
await boss.work<PrDetectionPayload>(
|
||||
PR_DETECTION_QUEUE,
|
||||
{ localConcurrency: PR_DETECTION_CONCURRENCY },
|
||||
|
||||
@@ -104,3 +104,36 @@ export function getUserTodayBounds(
|
||||
export function getDayOfWeekInTz(now: Date, tz: string): number {
|
||||
return getLocalDateParts(now, tz).dayOfWeek;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the UTC instant of local `hh:mm` on the local calendar day
|
||||
* implied by `now` in `tz`. DST-safe — `todayStart.getTime() + h * 3.6e6`
|
||||
* drifts by an hour on spring-forward / fall-back days because raw UTC
|
||||
* arithmetic ignores the DST jump between local midnight and local
|
||||
* `hh:mm`. This helper re-derives the offset at the target local time
|
||||
* so the returned instant always represents local `hh:mm`.
|
||||
*/
|
||||
export function localHmAsUtc(
|
||||
now: Date,
|
||||
tz: string,
|
||||
hour: number,
|
||||
minute: number,
|
||||
): Date {
|
||||
const parts = getLocalDateParts(now, tz);
|
||||
const localAtTargetAsUtc = new Date(
|
||||
Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute, 0, 0),
|
||||
);
|
||||
const localNowAsUtc = new Date(
|
||||
Date.UTC(
|
||||
parts.year,
|
||||
parts.month - 1,
|
||||
parts.day,
|
||||
parts.hour,
|
||||
parts.minute,
|
||||
parts.second,
|
||||
),
|
||||
);
|
||||
const offsetMs =
|
||||
Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000;
|
||||
return new Date(localAtTargetAsUtc.getTime() - offsetMs);
|
||||
}
|
||||
Reference in new issue
Block a user