From e1a5c639a56db7ea012b05ca01e700a892a78533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 23:44:07 +0200 Subject: [PATCH] fix(mood-reminder): six-locale resolver, ledger-after-delivery, DST-safe reminders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/jobs/__tests__/mood-reminder.test.ts | 238 ++++++++++++------- src/lib/jobs/mood-reminder.ts | 197 ++++++++------- src/lib/jobs/reminder-worker.ts | 62 ++++- src/lib/timezone.ts | 33 +++ 4 files changed, 339 insertions(+), 191 deletions(-) diff --git a/src/lib/jobs/__tests__/mood-reminder.test.ts b/src/lib/jobs/__tests__/mood-reminder.test.ts index d6b633105..03a630667 100644 --- a/src/lib/jobs/__tests__/mood-reminder.test.ts +++ b/src/lib/jobs/__tests__/mood-reminder.test.ts @@ -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; +type DispatchFn = (payload: NotificationPayload) => Promise; + +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(async () => {}); + const dispatch = vi.fn(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(async () => {}); + const dispatch = vi.fn(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(async () => {}); + const dispatch = vi.fn(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(async () => {}); + const dispatch = vi.fn(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(async () => {}); + const dispatch = vi.fn(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(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(async () => {}); + const dispatch = vi.fn(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(async () => {}); + const dispatch = vi.fn(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(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(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); + } + }); }); diff --git a/src/lib/jobs/mood-reminder.ts b/src/lib/jobs/mood-reminder.ts index 9ad52ca8a..0babb8221 100644 --- a/src/lib/jobs/mood-reminder.ts +++ b/src/lib/jobs/mood-reminder.ts @@ -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, + 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; diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index b03c24c62..a8f77f234 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -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[]) { // 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[]) { // 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[]) { * 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[], +) { + 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[]) { 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( + MOOD_REMINDER_CLEANUP_QUEUE, + { localConcurrency: 1 }, + handleMoodReminderCleanup, + ); await boss.work( PR_DETECTION_QUEUE, { localConcurrency: PR_DETECTION_CONCURRENCY }, diff --git a/src/lib/timezone.ts b/src/lib/timezone.ts index 1e3790101..c28ebc326 100644 --- a/src/lib/timezone.ts +++ b/src/lib/timezone.ts @@ -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); +}