chore(release): v1.4.38.2

Mood-reminder hotfix bundle on top of v1.4.38.1 plus the missing
Settings toggle. Six-axis review of the v0.5.4 iOS-coord patch
surfaced enough blockers that the feature could not have been used
safely as shipped:

- Locale resolver demoted 4 of 6 locales (es/fr/it/pl) to English.
- Dedup ledger committed before delivery — a transient APNs blip
  silently nuked the day's nudge and blocked retries.
- DST-unsafe arithmetic drifted the local-22:00 baseline by an hour
  twice a year for both the mood-reminder push and the medication
  iOS-snooze action.
- APNs payload forwarded the Telegram `replyMarkup` inline-keyboard
  object and other channel-specific extras to Apple's servers and
  the iOS notification handler.
- No Settings UI for the per-user opt-in flag — the feature was
  unreachable for end users regardless of the other fixes.

v1.4.38.2 closes every item above plus adds the missing
`MoodReminderCard` Switch, a 90-day retention sweep for the dispatch
ledger, and a per-user `try` wrapper so one bad row cannot abort the
22:00 tick.

See CHANGELOG.md.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 23:46:09 +02:00
1 parent c4f2e0bc51
commit d224811a34
27 files changed
+2165 -206

No files matched your search

+3
View File
@@ -35,6 +35,7 @@ export const GET = apiHandler(async () => {
heightCm: true,
locale: true,
timezone: true,
moodReminderEnabled: true,
},
});
@@ -47,6 +48,7 @@ export const GET = apiHandler(async () => {
heightCm: dbUser?.heightCm ?? null,
locale: dbUser?.locale ?? null,
timezone: dbUser?.timezone ?? "Europe/Berlin",
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
});
});
@@ -72,5 +74,6 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
heightCm: result.user.heightCm,
locale: result.user.locale,
timezone: result.user.timezone,
moodReminderEnabled: result.user.moodReminderEnabled,
});
});
@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SmilePlus } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
interface ProfileShape {
moodReminderEnabled: boolean;
}
export function MoodReminderCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [optimistic, setOptimistic] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const { data: profile } = useQuery({
queryKey: ["user", "profile"],
queryFn: async () => {
const res = await fetch("/api/user/profile");
if (!res.ok) throw new Error("Failed to load profile");
return (await res.json()).data as ProfileShape;
},
enabled: isAuthenticated,
});
const enabled = optimistic ?? profile?.moodReminderEnabled ?? false;
async function handleToggle(next: boolean) {
setOptimistic(next);
setSaving(true);
setMsg(null);
setMsgType(null);
const res = await fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ moodReminderEnabled: next }),
});
if (res.ok) {
setMsg(
next
? t("notifications.moodReminder.enabledToast")
: t("notifications.moodReminder.disabledToast"),
);
setMsgType("success");
await queryClient.invalidateQueries({ queryKey: ["user", "profile"] });
setOptimistic(null);
} else {
setOptimistic(null);
setMsg(t("notifications.moodReminder.saveError"));
setMsgType("error");
}
setSaving(false);
}
return (
<section
aria-labelledby="settings-mood-reminder-title"
className="bg-card rounded-lg border p-4 sm:p-6"
>
<SettingsCardHeader
icon={SmilePlus}
title={t("notifications.moodReminder.title")}
titleId="settings-mood-reminder-title"
description={t("notifications.moodReminder.description")}
status={
<label className="flex min-h-11 items-center gap-3">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={!isAuthenticated || saving}
aria-label={t("notifications.moodReminder.toggleAria")}
/>
<span className="text-muted-foreground text-xs">
{enabled
? t("notifications.moodReminder.statusOn")
: t("notifications.moodReminder.statusOff")}
</span>
</label>
}
/>
{msg && (
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
}
>
{msg}
</p>
)}
</section>
);
}
@@ -6,6 +6,7 @@ import { ChevronRight } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
import { NotificationStatusCard } from "@/components/settings/notification-status-card";
import { NtfyCard } from "@/components/settings/ntfy-card";
import { TelegramCard } from "@/components/settings/telegram-card";
@@ -105,6 +106,9 @@ export function NotificationsSection() {
<WebPushCard />
</div>
)}
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>
</section>
);
}
+6
View File
@@ -18,6 +18,7 @@ const extendedProfileSchema = profileSchema.extend({
.max(64)
.refine(isValidTimezone, "Invalid IANA timezone")
.optional(),
moodReminderEnabled: z.boolean().optional(),
});
export type ApplyProfileInput = z.infer<typeof extendedProfileSchema>;
@@ -35,6 +36,7 @@ export interface ApplyProfileResult {
gender: string | null;
timezone: string;
locale: string | null;
moodReminderEnabled: boolean;
};
}
@@ -91,6 +93,9 @@ export async function applyProfileUpdate(
}
if (data.locale !== undefined) updates.locale = data.locale;
if (data.timezone !== undefined) updates.timezone = data.timezone;
if (data.moodReminderEnabled !== undefined) {
updates.moodReminderEnabled = data.moodReminderEnabled;
}
// v1.4.18 — capture the prior locale so we can emit a separate
// `settings.locale.update` audit row when the locale actually
@@ -138,6 +143,7 @@ export async function applyProfileUpdate(
gender: updatedUser.gender,
timezone: updatedUser.timezone,
locale: updatedUser.locale,
moodReminderEnabled: updatedUser.moodReminderEnabled,
},
};
}
+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 },
+22 -1
View File
@@ -36,9 +36,21 @@ import { getEvent } from "@/lib/logging/context";
* Also handles legacy Telegram config stored on the User model by
* auto-migrating it to a NotificationChannel record on first dispatch.
*/
export interface DispatchOutcome {
/** True when at least one channel reported a successful delivery. */
dispatched: boolean;
/** Channels considered after enable / cooldown / preference filtering. */
channelsAttempted: number;
/** Subset of attempted channels that returned ok. */
channelsSucceeded: number;
}
export async function dispatchNotification(
payload: NotificationPayload,
): Promise<void> {
): Promise<DispatchOutcome> {
let channelsAttempted = 0;
let channelsSucceeded = 0;
try {
const channels = await prisma.notificationChannel.findMany({
where: { userId: payload.userId, enabled: true },
@@ -143,6 +155,8 @@ export async function dispatchNotification(
continue;
}
channelsAttempted += 1;
try {
const outcome = await sendToChannel(
channel.type as ChannelType,
@@ -156,6 +170,7 @@ export async function dispatchNotification(
userId: channel.userId,
type: channel.type as ChannelType,
});
channelsSucceeded += 1;
continue;
}
@@ -209,6 +224,12 @@ export async function dispatchNotification(
} catch (err) {
getEvent()?.addWarning(`Notification dispatcher error: ${err}`);
}
return {
dispatched: channelsSucceeded > 0,
channelsAttempted,
channelsSucceeded,
};
}
async function sendToChannel(
+34 -1
View File
@@ -393,7 +393,7 @@ export async function sendViaApns(
alert: { title: payload.title, body: stripHtml(payload.message) },
data: {
eventType: payload.eventType,
...(payload.metadata ?? {}),
...pickIosMetadata(payload.metadata),
},
threadId: payload.eventType,
// The iOS app registers a `UNNotificationCategory` per event-type
@@ -455,3 +455,36 @@ export async function sendViaApns(
function stripHtml(s: string): string {
return s.replace(/<[^>]*>/g, "");
}
/**
* Whitelist the metadata keys we send through to iOS. The dispatcher
* payload's `metadata` field is shared across every channel — Telegram
* stuffs an `replyMarkup` keyboard in there, future channels will add
* their own extras — and forwarding the lot to APNs leaks
* channel-specific payload structures into the APNs userInfo Apple
* sees plus the iOS app's notification handler.
*
* Allowed keys are the ones the iOS app actually reads. Add a new key
* here as iOS gains support for it.
*/
const IOS_METADATA_ALLOWLIST = new Set([
"scheduledAt",
"localDate",
"medicationId",
"scheduleId",
"phase",
"date",
]);
function pickIosMetadata(
metadata: Record<string, unknown> | undefined,
): Record<string, unknown> {
if (!metadata) return {};
const out: Record<string, unknown> = {};
for (const key of Object.keys(metadata)) {
if (IOS_METADATA_ALLOWLIST.has(key)) {
out[key] = metadata[key];
}
}
return out;
}
+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);
}