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:
Marc-André Bombeck committed 2026-05-17 23:44:07 +02:00
1 parent 1bb2afd829
commit e1a5c639a5
4 files changed
+339 -191

No files matched your search

+154 -84
View File
@@ -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);
}
});
});
+98 -99
View File
@@ -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;
+54 -8
View File
@@ -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 },
+33
View File
@@ -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);
}