diff --git a/messages/de.json b/messages/de.json index 94ffa09ed..8084f470b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2987,5 +2987,9 @@ "buttonSnoozeOneHour": "πŸ• 1h", "buttonSnoozeThreeHours": "πŸ• 3h", "buttonSkip": "⏭ Überspringen" + }, + "moodReminders": { + "dailyTitle": "Stimmung erfassen", + "dailyBody": "Wie geht es dir heute?" } } diff --git a/messages/en.json b/messages/en.json index cc8043984..84e4fea2d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2987,5 +2987,9 @@ "buttonSnoozeOneHour": "πŸ• 1h", "buttonSnoozeThreeHours": "πŸ• 3h", "buttonSkip": "⏭ Skip" + }, + "moodReminders": { + "dailyTitle": "Log your mood", + "dailyBody": "How are you feeling today?" } } diff --git a/messages/es.json b/messages/es.json index e4cdb79c9..65cb0d819 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2987,5 +2987,9 @@ "buttonSnoozeOneHour": "πŸ• 1h", "buttonSnoozeThreeHours": "πŸ• 3h", "buttonSkip": "⏭ Omitir" + }, + "moodReminders": { + "dailyTitle": "Registrar el Γ‘nimo", + "dailyBody": "ΒΏCΓ³mo te sientes hoy?" } } diff --git a/messages/fr.json b/messages/fr.json index 1917e8ff6..3cdfe68da 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2987,5 +2987,9 @@ "buttonSnoozeOneHour": "πŸ• 1h", "buttonSnoozeThreeHours": "πŸ• 3h", "buttonSkip": "⏭ Ignorer" + }, + "moodReminders": { + "dailyTitle": "Enregistrer votre humeur", + "dailyBody": "Comment vous sentez-vous aujourdhui ?" } } diff --git a/messages/it.json b/messages/it.json index 8c0c39723..12d69c952 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2987,5 +2987,9 @@ "buttonSnoozeOneHour": "πŸ• 1h", "buttonSnoozeThreeHours": "πŸ• 3h", "buttonSkip": "⏭ Salta" + }, + "moodReminders": { + "dailyTitle": "Registra il tuo umore", + "dailyBody": "Come ti senti oggi?" } } diff --git a/messages/pl.json b/messages/pl.json index 87d5ad7dc..7afc2bf37 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2987,5 +2987,9 @@ "buttonSnoozeOneHour": "πŸ• 1h", "buttonSnoozeThreeHours": "πŸ• 3h", "buttonSkip": "⏭ PomiΕ„" + }, + "moodReminders": { + "dailyTitle": "Zapisz nastrΓ³j", + "dailyBody": "Jak siΔ™ dzisiaj czujesz?" } } diff --git a/prisma/migrations/0069_v054_mood_reminder/migration.sql b/prisma/migrations/0069_v054_mood_reminder/migration.sql new file mode 100644 index 000000000..5550a94ef --- /dev/null +++ b/prisma/migrations/0069_v054_mood_reminder/migration.sql @@ -0,0 +1,39 @@ +-- v0.5.4 ios-coord β€” daily mood reminder. +-- +-- Two additions: +-- 1. `users.mood_reminder_enabled` β€” per-user opt-in flag. Default +-- `false` because mood capture is an emotionally-loaded surface; +-- we never want a fresh-install user to receive a 22:00 nudge +-- without first toggling the preference in Settings. +-- 2. `mood_reminder_dispatches` β€” per-(user, local date) ledger +-- enforcing one push per day. Unique constraint doubles as the +-- idempotency anchor for parallel workers + cron re-ticks inside +-- the 22:00 window (cron fires every 15 min). +-- +-- Both additions are additive β€” existing rows stay on the +-- "opt-out-of-everything" path byte-identical to v1.4.38 behaviour. +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "mood_reminder_enabled" BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS "mood_reminder_dispatches" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "date" TEXT NOT NULL, + "dispatched_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "mood_reminder_dispatches_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "mood_reminder_dispatches_user_id_date_key" + ON "mood_reminder_dispatches"("user_id", "date"); + +CREATE INDEX IF NOT EXISTS "mood_reminder_dispatches_user_id_dispatched_at_idx" + ON "mood_reminder_dispatches"("user_id", "dispatched_at"); + +DO $$ BEGIN + ALTER TABLE "mood_reminder_dispatches" + ADD CONSTRAINT "mood_reminder_dispatches_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 824bb8bf9..52d63f97a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -198,6 +198,17 @@ model User { // a full refresh on the next pair". lastSyncedAt DateTime? @map("last_synced_at") + // v0.5.4 ios-coord β€” daily mood-reminder opt-in. + // + // When `true` the reminder worker dispatches a `MOOD_REMINDER` + // notification at 22:00 in the user's local timezone if no + // `MoodEntry` row exists for the local date yet. Default `false` + // because mood capture is an emotional surface β€” the user has to + // explicitly opt in from Settings β†’ Notifications before the + // server starts nudging them. The dedup is handled by the + // `MoodReminderDispatch` table (one row per user + local date). + moodReminderEnabled Boolean @default(false) @map("mood_reminder_enabled") + // v1.4.25 W6c β€” Doctor-report section toggles. // Shape: // { "bp": boolean, @@ -267,6 +278,12 @@ model User { // granularity from the measurement write hooks; WEEK/MONTH/YEAR // are enqueued through pg-boss for the worker to fold. measurementRollups MeasurementRollup[] + // v0.5.4 ios-coord β€” per-day dedup ledger for MOOD_REMINDER pushes. + // One row per (user, local date) the worker has nudged. Prevents + // a second push when the cron rechecks the same hour later in the + // 22:00 window (the cron fires every 15 min β€” without the ledger + // a user could see three pushes back-to-back). + moodReminderDispatches MoodReminderDispatch[] @@map("users") } @@ -1173,6 +1190,27 @@ model MoodEntry { @@map("mood_entries") } +// ─── Mood Reminder Dispatch Ledger (v0.5.4 ios-coord) ───── +// +// Idempotency guard for the daily 22:00 MOOD_REMINDER push. The +// reminder cron fires every 15 min β€” when it lands inside the 22:00 +// window the handler looks up `(userId, date)` here and bails if a row +// already exists, so the user sees exactly one nudge per local day. +// `date` is a YYYY-MM-DD string anchored to the user's timezone at +// dispatch time (same encoding convention as MoodEntry.date). +model MoodReminderDispatch { + id String @id @default(cuid()) + userId String @map("user_id") + date String // YYYY-MM-DD anchored to user.timezone + dispatchedAt DateTime @default(now()) @map("dispatched_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, date]) + @@index([userId, dispatchedAt]) + @@map("mood_reminder_dispatches") +} + // ─── Integration Status (v1.4.15) ───────────────────────── // // Per-(user, integration) row that captures the most recent sync attempt diff --git a/src/lib/jobs/__tests__/mood-reminder.test.ts b/src/lib/jobs/__tests__/mood-reminder.test.ts new file mode 100644 index 000000000..d6b633105 --- /dev/null +++ b/src/lib/jobs/__tests__/mood-reminder.test.ts @@ -0,0 +1,382 @@ +/** + * v0.5.4 ios-coord β€” mood-reminder dispatcher unit tests. + * + * Pins the contract that motivated the v0.5.4 server-side patch: + * - 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. + * + * The tests stub the Prisma surface manually to keep this file free of + * a testcontainer boot. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { + buildMoodReminderPayload, + evaluateMoodReminderWindow, + runMoodReminderTick, + MOOD_REMINDER_LOCAL_HOUR, +} from "../mood-reminder"; +import type { NotificationPayload } from "@/lib/notifications/types"; + +type DispatchFn = (payload: NotificationPayload) => Promise; + +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 }>; + /** Force the next `moodReminderDispatch.create` to throw P2002. */ + raceUserIds: Set; +} + +function makePrisma(state: FakePrismaState) { + return { + user: { + findMany: vi.fn( + async ({ where }: { where: { moodReminderEnabled: boolean } }) => { + return state.candidates.filter( + (c) => c.moodReminderEnabled === where.moodReminderEnabled, + ); + }, + ), + }, + moodEntry: { + findFirst: vi.fn( + async ({ where }: { where: { userId: string; date: string } }) => { + return ( + state.moodEntries.find( + (m) => m.userId === where.userId && m.date === where.date, + ) ?? null + ); + }, + ), + }, + moodReminderDispatch: { + findUnique: vi.fn( + async ({ + where, + }: { + where: { userId_date: { userId: string; date: string } }; + }) => { + return ( + state.dispatches.find( + (d) => + d.userId === where.userId_date.userId && + d.date === where.userId_date.date, + ) ?? null + ); + }, + ), + 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`). " + + "code: P2002", + ); + } + state.dispatches.push(data); + return { id: `dispatch-${state.dispatches.length}`, ...data }; + }, + ), + }, + }; +} + +beforeEach(() => { + vi.useRealTimers(); +}); + +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 r = evaluateMoodReminderWindow( + { timezone: "Europe/Berlin", moodReminderEnabled: false }, + at22, + ); + expect(r.fire).toBe(false); + expect(r.localDate).toBeNull(); + }); + + 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 r = evaluateMoodReminderWindow( + { timezone: "Europe/Berlin", moodReminderEnabled: true }, + at22, + ); + expect(r.fire).toBe(true); + expect(r.localDate).toBe("2026-05-17"); + 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 + const r = evaluateMoodReminderWindow( + { timezone: "Europe/Berlin", moodReminderEnabled: true }, + at2159, + ); + expect(r.fire).toBe(false); + 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 + const r = evaluateMoodReminderWindow( + { timezone: "Europe/Berlin", moodReminderEnabled: true }, + at23, + ); + expect(r.fire).toBe(false); + 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). + const utc = new Date("2026-05-18T02:00:00Z"); + const r = evaluateMoodReminderWindow( + { timezone: "America/New_York", moodReminderEnabled: true }, + utc, + ); + expect(r.fire).toBe(true); + expect(r.localDate).toBe("2026-05-17"); + }); +}); + +describe("buildMoodReminderPayload", () => { + it("returns German strings for locale=de", () => { + const p = buildMoodReminderPayload("de"); + expect(p.title).toBe("Stimmung erfassen"); + expect(p.body).toBe("Wie geht es dir heute?"); + }); + + it("returns English strings for locale=en", () => { + const p = buildMoodReminderPayload("en"); + expect(p.title).toBe("Log your mood"); + expect(p.body).toBe("How are you feeling today?"); + }); + + 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); + }); +}); + +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, + }, + ], + moodEntries: [], + dispatches: [], + raceUserIds: new Set(), + }; + const prisma = makePrisma(state); + const dispatch = vi.fn(async () => {}); + + const summary = await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + }); + + expect(summary.dispatched).toBe(1); + expect(summary.inWindow).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + const firstCall = dispatch.mock.calls[0]; + if (!firstCall) throw new Error("dispatch was not called"); + const call = firstCall[0]; + expect(call.eventType).toBe("MOOD_REMINDER"); + expect(call.userId).toBe("u-1"); + expect(call.title).toBe("Stimmung erfassen"); + expect(call.message).toBe("Wie geht es dir heute?"); + expect(call.metadata?.localDate).toBe("2026-05-17"); + expect(typeof call.metadata?.scheduledAt).toBe("string"); + expect(state.dispatches).toHaveLength(1); + expect(state.dispatches[0]).toEqual({ userId: "u-1", date: "2026-05-17" }); + }); + + 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, + }, + ], + moodEntries: [], + dispatches: [], + raceUserIds: new Set(), + }; + const prisma = makePrisma(state); + const dispatch = vi.fn(async () => {}); + + const summary = await runMoodReminderTick(prisma as never, outOfWindow, { + dispatch, + }); + + expect(summary.dispatched).toBe(0); + expect(summary.skippedOutsideWindow).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("never reads moodReminderEnabled=false users (opt-in gating)", async () => { + const state: FakePrismaState = { + candidates: [], // Prisma `where` already filters; we assert the call shape + moodEntries: [], + dispatches: [], + raceUserIds: new Set(), + }; + const prisma = makePrisma(state); + const dispatch = vi.fn(async () => {}); + + await runMoodReminderTick(prisma as never, inWindow, { dispatch }); + + expect(prisma.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { moodReminderEnabled: true } }), + ); + }); + + 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, + }, + ], + moodEntries: [{ userId: "u-1", date: "2026-05-17" }], + dispatches: [], + raceUserIds: new Set(), + }; + const prisma = makePrisma(state); + const dispatch = vi.fn(async () => {}); + + const summary = await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + }); + + expect(summary.dispatched).toBe(0); + expect(summary.skippedAlreadyLogged).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(state.dispatches).toHaveLength(0); + }); + + 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, + }, + ], + 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 summary = await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + }); + + expect(summary.dispatched).toBe(0); + expect(summary.skippedAlreadyDispatched).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("idempotency: lost P2002 race is swallowed as 'already dispatched'", async () => { + const state: FakePrismaState = { + candidates: [ + { + id: "u-1", + timezone: "Europe/Berlin", + locale: "de", + moodReminderEnabled: true, + }, + ], + moodEntries: [], + dispatches: [], + raceUserIds: new Set(["u-1"]), + }; + const prisma = makePrisma(state); + const dispatch = vi.fn(async () => {}); + + const summary = await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + }); + + expect(summary.dispatched).toBe(0); + expect(summary.skippedAlreadyDispatched).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("dispatches per-user with their own locale (DE + EN mix)", 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, + }, + ], + moodEntries: [], + dispatches: [], + raceUserIds: new Set(), + }; + const prisma = makePrisma(state); + const dispatch = vi.fn(async () => {}); + + const summary = await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + }); + + expect(summary.dispatched).toBe(2); + expect(dispatch).toHaveBeenCalledTimes(2); + const titles = dispatch.mock.calls.map((c) => { + const [payload] = c; + return payload?.title ?? ""; + }); + expect(titles).toContain("Stimmung erfassen"); + expect(titles).toContain("Log your mood"); + }); +}); diff --git a/src/lib/jobs/mood-reminder.ts b/src/lib/jobs/mood-reminder.ts new file mode 100644 index 000000000..9ad52ca8a --- /dev/null +++ b/src/lib/jobs/mood-reminder.ts @@ -0,0 +1,235 @@ +/** + * v0.5.4 ios-coord β€” daily mood-reminder dispatcher. + * + * The reminder cron fires every 15 minutes (same cadence as the + * medication-reminder loop in `reminder-worker.ts`). When the cron lands + * inside the 22:00 hour in a user's local timezone, this module: + * + * 1. Skips users who haven't opted in (`User.moodReminderEnabled = false`). + * 2. Skips users who already logged a mood entry for the local date. + * 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. + * + * Why a separate module instead of folding it into reminder-worker.ts: + * the worker is already 1800 LOC. Splitting the mood-reminder logic out + * keeps the test surface focused and lets the unit tests run without + * booting pg-boss. + */ +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 { getLocalDateParts } from "@/lib/timezone"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; + +/** + * Hour of day (in the user's local timezone) at which the mood reminder + * should fire. 22:00 is late enough that a "did you forget to log?" nudge + * lands in the user's wind-down window without colliding with dinner or + * the medication-reminder hot path. + * + * Exported so the unit tests can pin the contract without scraping the + * handler internals. + */ +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; + dispatched: number; + skippedAlreadyLogged: number; + skippedAlreadyDispatched: number; + skippedOutsideWindow: number; +} + +function resolveLocale(locale: string | null | undefined): Locale { + return locale === "en" || locale === "de" ? locale : defaultLocale; +} + +/** + * Pure decision predicate: should the worker fire a mood reminder for + * this user at this instant? + * + * Returns the local YYYY-MM-DD date string when the answer is "fire"; + * returns `null` when the answer is "skip" β€” covers both + * "outside-window" and "opt-out". Callers branch on the return value. + * + * Pulled out as a pure helper so the unit tests can pin the + * window-boundary contract (21:59 β†’ null, 22:00 β†’ date, 22:59 β†’ date, + * 23:00 β†’ null) without touching the DB. + */ +export function evaluateMoodReminderWindow( + user: Pick, + now: Date, +): { fire: boolean; localDate: string | null; localHour: number } { + if (!user.moodReminderEnabled) { + return { fire: false, localDate: null, localHour: -1 }; + } + const parts = getLocalDateParts(now, user.timezone || "Europe/Berlin"); + const inWindow = parts.hour === MOOD_REMINDER_LOCAL_HOUR; + const isoDate = `${parts.year.toString().padStart(4, "0")}-${parts.month + .toString() + .padStart(2, "0")}-${parts.day.toString().padStart(2, "0")}`; + return { + fire: inWindow, + localDate: inWindow ? isoDate : null, + localHour: parts.hour, + }; +} + +/** + * Build the localised title + body for the mood reminder push. Pulled + * out so a future template change doesn't require re-running the whole + * handler in tests. + */ +export function buildMoodReminderPayload(locale: string | null | undefined): { + title: string; + body: string; +} { + const t = getServerTranslator(resolveLocale(locale)).t; + return { + title: t("moodReminders.dailyTitle"), + body: t("moodReminders.dailyBody"), + }; +} + +/** + * Run one mood-reminder cron tick. Iterates every user with + * `moodReminderEnabled = true`, checks the per-user local-time window, + * and dispatches a `MOOD_REMINDER` notification when warranted. + * + * 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. + * + * Returns a summary so the worker can attach it to the wide-event for + * the observability dashboards. + */ +export async function runMoodReminderTick( + prisma: PrismaClient, + now: Date, + options: { + dispatch?: typeof dispatchNotification; + } = {}, +): Promise { + const dispatchImpl = options.dispatch ?? dispatchNotification; + + const summary: MoodReminderSummary = { + candidatesScanned: 0, + inWindow: 0, + dispatched: 0, + skippedAlreadyLogged: 0, + skippedAlreadyDispatched: 0, + skippedOutsideWindow: 0, + }; + + const candidates = await prisma.user.findMany({ + where: { moodReminderEnabled: true }, + select: { + id: true, + timezone: true, + locale: true, + moodReminderEnabled: 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 }, + }); + } 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")) { + summary.skippedAlreadyDispatched += 1; + continue; + } + throw err; + } + + 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 fe422d7a0..b03c24c62 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -83,6 +83,7 @@ import { getPhaseMessage, getPhaseKeyboard, } from "@/lib/jobs/reminder-phases"; +import { runMoodReminderTick } from "@/lib/jobs/mood-reminder"; import { withBackgroundEvent } from "@/lib/logging/background"; import { assertSubsystemEnabled } from "@/lib/process-type"; import { runOffhostBackup } from "@/lib/jobs/offhost-backup"; @@ -179,6 +180,17 @@ const FEEDBACK_AGGREGATOR_CRON = "0 4 * * *"; // fall to the drain. const DRAIN_CUMULATIVE_QUEUE = "drain-per-sample-cumulative"; const DRAIN_CUMULATIVE_CRON = "45 3 * * *"; +// v0.5.4 ios-coord β€” daily mood-reminder cron. +// +// Runs every 15 minutes so the handler can pick up users whose local +// time has just crossed 22:00 across any IANA timezone without having +// to schedule one cron entry per zone. The handler short-circuits when +// the user's local hour isn't 22, so the 15-min cadence translates to +// ~4 ticks-per-hour Γ— 1 actual-dispatch-window-per-user = at most one +// push per user per day. Idempotency is enforced by the +// `MoodReminderDispatch` ledger inside the handler. +const MOOD_REMINDER_QUEUE = "mood-reminder-check"; +const MOOD_REMINDER_CRON = "*/15 * * * *"; // 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 @@ -281,6 +293,10 @@ interface GeoBackfillPayload { triggeredAt: string; } +interface MoodReminderPayload { + triggeredAt: string; +} + // Re-export timezone utilities under local names for backward compatibility const getUserTodayBounds = getUserTodayBoundsUtil; const getDayOfWeekInTz = getDayOfWeekInTzUtil; @@ -559,6 +575,17 @@ async function handleReminderCheck(jobs: Job[]) { med.user.locale, ); + // 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. + const [winH, winM] = schedule.windowStart.split(":").map(Number); + const scheduledAtIso = new Date( + todayStart.getTime() + winH * 3600000 + winM * 60000, + ).toISOString(); + evt.addMeta( "notification_phase", `${currentPhase}:${med.name}:${schedule.windowStart}-${schedule.windowEnd}`, @@ -575,6 +602,7 @@ async function handleReminderCheck(jobs: Job[]) { scheduleId: schedule.id, phase: currentPhase, date: localDateStr, + scheduledAt: scheduledAtIso, replyMarkup: keyboard, }, }); @@ -717,9 +745,7 @@ async function handleWithingsActivitySync( * activity handler: per-user when the webhook fires, full-iteration * when the cron ticks. */ -async function handleWithingsSleepSync( - jobs: Job[], -) { +async function handleWithingsSleepSync(jobs: Job[]) { await withBackgroundEvent("job.withings_sleep_sync", async (evt) => { const prisma = getWorkerPrisma(); try { @@ -1107,6 +1133,39 @@ async function handleMoodLogSync(jobs: Job[]) { }); } +/** + * v0.5.4 ios-coord β€” daily mood-reminder dispatcher. + * + * Delegates the dispatch decision to `runMoodReminderTick` in + * `mood-reminder.ts` so the unit tests can exercise the logic without + * spinning up pg-boss. The handler is a thin shim that wires the worker + * Prisma singleton + the wide-event sink to the pure function. + */ +async function handleMoodReminderCheck(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.mood_reminder", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const summary = await runMoodReminderTick(prisma, new Date()); + evt.setBackground({ + task_name: "job.mood_reminder", + result: { + candidates_scanned: summary.candidatesScanned, + in_window: summary.inWindow, + dispatched: summary.dispatched, + skipped_already_logged: summary.skippedAlreadyLogged, + skipped_already_dispatched: summary.skippedAlreadyDispatched, + skipped_outside_window: summary.skippedOutsideWindow, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + async function handleRateLimitCleanup(jobs: Job[]) { void jobs; await withBackgroundEvent("job.rate_limit_cleanup", async (evt) => { @@ -1248,8 +1307,10 @@ async function handlePrDetection( // users in that case. The push-suppression flag is irrelevant // for the cron path (the dispatcher's per-user opt-in handles // the loud/quiet decision once the row is written). - const payloadUserId = (job.data as PrDetectionPayload | undefined)?.userId; - const silent = (job.data as PrDetectionPayload | undefined)?.silent ?? false; + const payloadUserId = (job.data as PrDetectionPayload | undefined) + ?.userId; + const silent = + (job.data as PrDetectionPayload | undefined)?.silent ?? false; const userIds: string[] = payloadUserId ? [payloadUserId] : (await p.user.findMany({ select: { id: true } })).map((u) => u.id); @@ -1560,6 +1621,11 @@ export async function startReminderWorker() { // this entry the drain schedule silently no-ops and the // per-sample APPLE_HEALTH rows never collapse. DRAIN_CUMULATIVE_QUEUE, + // v0.5.4 ios-coord β€” mood-reminder cron tick. Same pg-boss v12 + // createQueue contract as the drain queue above; without this + // entry the every-15-min schedule silently no-ops and the + // dispatcher never fires. + MOOD_REMINDER_QUEUE, ]; for (const q of allQueues) { @@ -1573,11 +1639,7 @@ export async function startReminderWorker() { try { await reconcileOrphanImportJobs(); } catch (err) { - workerLog( - "error", - "Failed to reconcile orphan ImportJob rows", - err, - ); + workerLog("error", "Failed to reconcile orphan ImportJob rows", err); } // Schedule recurring cron jobs @@ -1620,6 +1682,11 @@ export async function startReminderWorker() { // rows into one row per day per type. Slots between the // audit-log cleanup (03:15) and the feedback aggregator (04:00). [DRAIN_CUMULATIVE_QUEUE, DRAIN_CUMULATIVE_CRON], + // v0.5.4 ios-coord β€” every-15-min tick for the daily mood reminder. + // The handler short-circuits unless the candidate user's local + // 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], ]; for (const [name, cron] of schedules) { @@ -1727,6 +1794,15 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleGeoBackfill, ); + // v0.5.4 ios-coord β€” single-flight worker. localConcurrency=1 keeps + // two reminder ticks from interleaving against the same user row; + // the dedup ledger would still save us, but skipping the race here + // avoids spurious P2002 errors in the wide-event log. + await boss.work( + MOOD_REMINDER_QUEUE, + { localConcurrency: 1 }, + handleMoodReminderCheck, + ); await boss.work( PR_DETECTION_QUEUE, { localConcurrency: PR_DETECTION_CONCURRENCY }, diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index bc73f7930..151677353 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -39,6 +39,8 @@ vi.mock("@parse/node-apn", () => { public threadId: string | undefined; public collapseId: string | undefined; public payload: unknown; + public category: string | undefined; + public mutableContent: boolean | undefined; constructor() { notificationCtorMock(); } @@ -419,4 +421,68 @@ describe("sendViaApns β€” dispatcher fan-out", () => { const note = sendMock.mock.calls[0][0]; expect(note.alert.body).toBe("take meds"); }); + + it("forwards eventType as aps.category so iOS renders action buttons", async () => { + // Wires the v0.5.4 contract: MEDICATION_REMINDER on the server must + // surface as `aps.category = "MEDICATION_REMINDER"` so iOS looks up + // the UNNotificationCategory the app registered at launch and + // renders Take / Snooze 15 / Skip on the lock-screen. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + metadata: { medicationId: "med-1", scheduleId: "sched-1" }, + }); + const note = sendMock.mock.calls[0][0]; + expect(note.category).toBe("MEDICATION_REMINDER"); + expect(note.mutableContent).toBe(true); + expect(note.threadId).toBe("MEDICATION_REMINDER"); + expect(note.payload.medicationId).toBe("med-1"); + expect(note.payload.scheduleId).toBe("sched-1"); + expect(note.payload.eventType).toBe("MEDICATION_REMINDER"); + }); + + it("forwards MOOD_REMINDER eventType as aps.category too", async () => { + // SR-2 contract: the mood-reminder daily job emits this event-type; + // the iOS app registers a `MOOD_REMINDER` category whose only + // action opens the mood-entry sheet. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "Stimmung erfassen", + message: "Wie geht es dir heute?", + eventType: "MOOD_REMINDER", + metadata: { scheduledAt: "2026-05-17T20:00:00.000Z" }, + }); + const note = sendMock.mock.calls[0][0]; + expect(note.category).toBe("MOOD_REMINDER"); + expect(note.threadId).toBe("MOOD_REMINDER"); + expect(note.payload.scheduledAt).toBe("2026-05-17T20:00:00.000Z"); + }); + + it("forwards explicit category override on sendApnsPush", async () => { + // Lower-level entry β€” directly setting `payload.category` must + // propagate to `note.category` so callers that bypass `sendViaApns` + // (e.g. an admin test endpoint) keep full control. + setEnv(VALID_ENV); + sendMock.mockResolvedValueOnce({ sent: [{ device: "abc" }], failed: [] }); + await sendApnsPush({ + deviceToken: "abc", + environment: "sandbox", + payload: { + alert: { title: "t", body: "b" }, + category: "CUSTOM_CATEGORY", + mutableContent: true, + }, + }); + const note = sendMock.mock.calls[0][0]; + expect(note.category).toBe("CUSTOM_CATEGORY"); + expect(note.mutableContent).toBe(true); + }); }); diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index aa4afeac2..cce0a0d20 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -40,6 +40,25 @@ export interface ApnsPayload { data?: Record; /** APNs `aps.thread-id` β€” groups related alerts in Notification Center. */ threadId?: string; + /** + * APNs `aps.category` β€” names a `UNNotificationCategory` registered by + * the iOS app at launch. The category drives the action-button row on + * the lock-screen / Notification Center entry. iOS HealthLog v0.5.3 + * registers `MEDICATION_REMINDER` (Take / Snooze 15 / Skip) and + * `MOOD_REMINDER` (Log mood). Omit the field for plain alerts without + * actions. + */ + category?: string; + /** + * Sets `aps.mutable-content = 1`. iOS routes the payload through the + * app's Notification Service Extension before the system renders it, + * giving the extension a chance to enrich the alert (e.g. download a + * thumbnail, decrypt a body). HealthLog doesn't ship an NSE yet, but + * the flag is cheap to set and a no-op without one β€” wiring it now + * means the iOS team can drop in an NSE later without a server-side + * roundtrip. + */ + mutableContent?: boolean; } export interface ApnsSendInput { @@ -229,6 +248,27 @@ export async function sendApnsPush( if (input.payload.threadId) { note.threadId = input.payload.threadId; } + if (input.payload.category) { + // node-apn's `category` setter writes through to `aps.category` β€” + // that's the key the iOS notification system reads to look up a + // `UNNotificationCategory` and render its action-button row. The + // category name is a plain string that must match an identifier + // registered by the iOS app at launch (see iOS HealthLog v0.5.3 + // `NotificationCategories`). + // + // The cast is because node-apn 8.1's d.ts file omits the public + // setter (it only documents the matching `aps.category` field on + // the `Aps` interface), but the runtime setter on + // `apsProperties.js` writes the value through unchanged. We assign + // via the `category` setter rather than poking `note.aps.category` + // directly so we stay forward-compatible with future d.ts fixes. + (note as unknown as { category: string }).category = input.payload.category; + } + if (input.payload.mutableContent) { + // node-apn writes this through to `aps.mutable-content = 1`, which + // unlocks the iOS Notification Service Extension hook. + note.mutableContent = true; + } if (input.collapseId) { note.collapseId = input.collapseId; } @@ -356,6 +396,17 @@ export async function sendViaApns( ...(payload.metadata ?? {}), }, threadId: payload.eventType, + // The iOS app registers a `UNNotificationCategory` per event-type + // so the same string doubles as the category identifier. iOS + // ignores categories it doesn't know about, so adding the key + // here is safe for event-types that don't have an actionable + // category registered yet β€” they fall back to a plain alert. + category: payload.eventType, + // Future-proof for an iOS Notification Service Extension. iOS + // ignores the flag when no extension is registered, so it's a + // no-op on today's binary but unblocks NSE work without a + // server-side change. + mutableContent: true, }, collapseId: payload.eventType, }); diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 241a6fac0..4b0bc9676 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -17,6 +17,12 @@ export const EVENT_TYPES = [ // below) so a multi-year Apple Health backfill doesn't fire hundreds // of pushes during initial sync. "PERSONAL_RECORD", + // v0.5.4 ios-coord β€” daily mood-reminder nudge. Default OFF on the + // per-event policy (see DEFAULT_EVENT_PREFS) and additionally gated + // on the per-user `moodReminderEnabled` flag, so a user has to opt + // in twice before the server starts nudging them about an + // emotionally-loaded surface. + "MOOD_REMINDER", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -38,6 +44,11 @@ export const EVENT_DEFAULT_ENABLED: Record = { WITHINGS_SYNC_FAILED: true, SYSTEM_ALERT: true, PERSONAL_RECORD: false, + // v0.5.4 ios-coord β€” see note above on `EVENT_TYPES`. Default OFF + // is the safer posture: even if a future surface forgets to gate on + // `User.moodReminderEnabled`, this per-event default also has to be + // flipped on before the dispatcher will surface the channel. + MOOD_REMINDER: false, }; export const CHANNEL_TYPE_LABELS: Record = {