From 04cb80da6a4c2e54b9280d1c1fca3418d6564cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 23:23:41 +0200 Subject: [PATCH] feat(daily): a calm opt-in morning briefing push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a once-daily briefing notification that reads the cached daily digest and never makes a fresh model call. It rides the existing dispatch cascade (APNs, Telegram, ntfy, webhook, email, web push), so it inherits the hard-reject classification and the push-attempts ledger unchanged. - New DAILY_BRIEFING event type, default off: the briefing stays silent until the user opts a channel in from the notifications matrix, so the channel choice is explicit and a fresh account never gets an unasked-for push. - One decision seam both triggers share: the sleep-arrival morning refresh fires it the moment the day finalises, and a fixed local-morning fallback slot covers the case where last night's sleep never synced. Whichever wins the morning writes the ledger row; the once-per-local-day cap suppresses the other. - The body is the digest line — the cached briefing lead when present, else the deterministic floor — so a keyless self-hoster still gets a first-class push. Provisional days carry an honest "sleep not in yet" note. - Non-clinical and non-urgent by construction, fired only inside a local morning window: it is a nudge, never the anomaly channel, and never bypasses Focus. Empty accounts are left alone rather than nudged with a hollow all-clear. - Localised across all six locales. --- messages/de.json | 6 + messages/en.json | 6 + messages/es.json | 6 + messages/fr.json | 6 + messages/it.json | 6 + messages/pl.json | 6 + .../__tests__/daily-briefing-push.test.ts | 302 ++++++++++++++++++ src/lib/daily/daily-briefing-push.ts | 255 +++++++++++++++ src/lib/jobs/__tests__/daily-briefing.test.ts | 117 +++++++ src/lib/jobs/daily-briefing.ts | 149 +++++++++ src/lib/jobs/reminder/register-status.ts | 64 ++++ src/lib/notifications/types.ts | 17 + 12 files changed, 940 insertions(+) create mode 100644 src/lib/daily/__tests__/daily-briefing-push.test.ts create mode 100644 src/lib/daily/daily-briefing-push.ts create mode 100644 src/lib/jobs/__tests__/daily-briefing.test.ts create mode 100644 src/lib/jobs/daily-briefing.ts diff --git a/messages/de.json b/messages/de.json index f9103f0ec..b0ab994a0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6516,6 +6516,8 @@ "eventMeasurementReminderDesc": "Wird gesendet, wenn eine erfasste Messung fällig ist.", "eventMedicationIntakeSync": "Einnahme-Synchronisierung", "eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.", + "eventDailyBriefing": "Täglicher Überblick", + "eventDailyBriefingDesc": "Eine ruhige Morgenzusammenfassung deines Tages. Standardmäßig aus, bis du sie aktivierst.", "security": { "newDeviceTitle": "Neue Anmeldung bei deinem Konto", "newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.", @@ -8504,6 +8506,10 @@ "score": "Dein Gesundheitsscore heute liegt bei {score}.", "allClear": "Heute braucht nichts deine Aufmerksamkeit — alles verläuft normal." }, + "push": { + "title": "Dein Morgenüberblick", + "bodyProvisional": "{line} · Der Schlaf von letzter Nacht fehlt noch." + }, "item": { "doseWindow": { "title": "Medikament fällig", diff --git a/messages/en.json b/messages/en.json index d40e57f56..081f05161 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6516,6 +6516,8 @@ "eventMeasurementReminderDesc": "Sent when a tracked measurement is due.", "eventMedicationIntakeSync": "Medication intake sync", "eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.", + "eventDailyBriefing": "Daily briefing", + "eventDailyBriefingDesc": "One calm morning summary of your day's read. Off until you turn it on.", "security": { "newDeviceTitle": "New sign-in to your account", "newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.", @@ -8504,6 +8506,10 @@ "score": "Your health score today is {score}.", "allClear": "Nothing needs your attention today — everything's tracking normally." }, + "push": { + "title": "Your morning briefing", + "bodyProvisional": "{line} · Last night's sleep isn't in yet." + }, "item": { "doseWindow": { "title": "Medication due", diff --git a/messages/es.json b/messages/es.json index c04042712..11e111c5f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6516,6 +6516,8 @@ "eventMeasurementReminderDesc": "Se envía cuando una medición registrada está pendiente.", "eventMedicationIntakeSync": "Sincronización de tomas", "eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.", + "eventDailyBriefing": "Resumen diario", + "eventDailyBriefingDesc": "Un resumen matinal y tranquilo de tu día. Desactivado hasta que lo actives.", "security": { "newDeviceTitle": "Nuevo inicio de sesión en tu cuenta", "newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.", @@ -8504,6 +8506,10 @@ "score": "Tu puntuación de salud hoy es {score}.", "allClear": "Hoy nada requiere tu atención: todo evoluciona con normalidad." }, + "push": { + "title": "Tu resumen matinal", + "bodyProvisional": "{line} · El sueño de anoche aún no está disponible." + }, "item": { "doseWindow": { "title": "Medicación pendiente", diff --git a/messages/fr.json b/messages/fr.json index 7a877c52b..8f79bad06 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6516,6 +6516,8 @@ "eventMeasurementReminderDesc": "Envoyé lorsqu'une mesure suivie est due.", "eventMedicationIntakeSync": "Synchronisation des prises", "eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.", + "eventDailyBriefing": "Résumé quotidien", + "eventDailyBriefingDesc": "Un résumé matinal et posé de votre journée. Désactivé jusqu'à ce que vous l'activiez.", "security": { "newDeviceTitle": "Nouvelle connexion à votre compte", "newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.", @@ -8504,6 +8506,10 @@ "score": "Ton score de santé aujourd'hui est de {score}.", "allClear": "Rien ne requiert ton attention aujourd'hui — tout évolue normalement." }, + "push": { + "title": "Votre résumé du matin", + "bodyProvisional": "{line} · Le sommeil de la nuit dernière n'est pas encore disponible." + }, "item": { "doseWindow": { "title": "Médicament à prendre", diff --git a/messages/it.json b/messages/it.json index 2aab1764c..a80ce17f7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6516,6 +6516,8 @@ "eventMeasurementReminderDesc": "Inviato quando una misurazione tracciata è in scadenza.", "eventMedicationIntakeSync": "Sincronizzazione assunzioni", "eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.", + "eventDailyBriefing": "Riepilogo quotidiano", + "eventDailyBriefingDesc": "Un riepilogo mattutino e tranquillo della tua giornata. Disattivato finché non lo attivi.", "security": { "newDeviceTitle": "Nuovo accesso al tuo account", "newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.", @@ -8504,6 +8506,10 @@ "score": "Il tuo punteggio di salute oggi è {score}.", "allClear": "Oggi nulla richiede la tua attenzione — tutto procede normalmente." }, + "push": { + "title": "Il tuo riepilogo mattutino", + "bodyProvisional": "{line} · Il sonno della scorsa notte non è ancora disponibile." + }, "item": { "doseWindow": { "title": "Farmaco da assumere", diff --git a/messages/pl.json b/messages/pl.json index 6f1b22347..314f7ea7d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6516,6 +6516,8 @@ "eventMeasurementReminderDesc": "Wysyłane, gdy śledzony pomiar jest wymagany.", "eventMedicationIntakeSync": "Synchronizacja przyjęć leku", "eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.", + "eventDailyBriefing": "Codzienne podsumowanie", + "eventDailyBriefingDesc": "Spokojne poranne podsumowanie Twojego dnia. Wyłączone, dopóki go nie włączysz.", "security": { "newDeviceTitle": "Nowe logowanie do Twojego konta", "newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.", @@ -8504,6 +8506,10 @@ "score": "Twój dzisiejszy wynik zdrowia to {score}.", "allClear": "Dziś nic nie wymaga Twojej uwagi — wszystko przebiega normalnie." }, + "push": { + "title": "Twoje poranne podsumowanie", + "bodyProvisional": "{line} · Brakuje jeszcze snu z zeszłej nocy." + }, "item": { "doseWindow": { "title": "Lek do przyjęcia", diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts new file mode 100644 index 000000000..6aae8c602 --- /dev/null +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -0,0 +1,302 @@ +/** + * S5 — daily-briefing push decision seam + composer. + * + * Pins the once-per-day, opt-in-only, morning-window, non-alert contract of + * `maybeDispatchDailyBriefing` and the floor/lead composition of + * `buildDailyBriefingPush`, all without a DB / provider / boss. + */ +import { describe, it, expect, vi } from "vitest"; +import type { PrismaClient } from "@/generated/prisma/client"; + +import { + maybeDispatchDailyBriefing, + buildDailyBriefingPush, + digestHasSubstance, + type DailyBriefingDispatchDeps, +} from "@/lib/daily/daily-briefing-push"; +import type { DailyDigest } from "@/lib/daily/digest"; +import { isUrgentPayload } from "@/lib/notifications/types"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +// 06:00Z → 08:00 in Europe/Berlin (summer) → inside the morning window AND the +// fixed fallback hour. The tz-window cases move only this instant. +const IN_WINDOW = new Date("2026-07-16T06:00:00Z"); +const BEFORE_WINDOW = new Date("2026-07-16T00:30:00Z"); // 02:30 Berlin +const AFTER_WINDOW = new Date("2026-07-16T12:30:00Z"); // 14:30 Berlin + +function makeDigest(over: Partial = {}): DailyDigest { + return { + generatedAt: "2026-07-16T06:00:00.000Z", + phase: "final", + sleepPending: false, + score: { value: 82, band: "good", delta: 1 }, + topSignal: null, + briefingLead: "Sleep looked solid last night.", + line: "Sleep looked solid last night.", + worthALook: [], + ...over, + }; +} + +function makePrisma(opts: { + user?: unknown; + optIn?: unknown; + lastOk?: { createdAt: Date } | null; +}) { + const user = + opts.user === undefined + ? { + id: "u1", + timezone: "Europe/Berlin", + locale: "en", + morningDigestRefreshedOn: null, + } + : opts.user; + return { + user: { findUnique: vi.fn(async () => user) }, + notificationPreference: { + findFirst: vi.fn(async () => + opts.optIn === undefined ? { id: "pref1" } : opts.optIn, + ), + }, + pushAttempt: { findFirst: vi.fn(async () => opts.lastOk ?? null) }, + } as unknown as PrismaClient; +} + +function makeDeps( + over: Partial = {}, +): DailyBriefingDispatchDeps & { dispatch: ReturnType } { + const dispatch = vi.fn(async () => ({ + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, + })); + return { + dispatch, + loadDigest: async () => makeDigest(), + isModuleEnabled: async () => true, + ...over, + } as DailyBriefingDispatchDeps & { dispatch: ReturnType }; +} + +describe("buildDailyBriefingPush", () => { + const { t } = getServerTranslator("en"); + + it("uses the digest line verbatim as the body when the day is final", () => { + const digest = makeDigest({ line: "Pulse ran a touch high yesterday." }); + const { title, body } = buildDailyBriefingPush(digest, t); + expect(body).toBe("Pulse ran a touch high yesterday."); + expect(title).toBe(t("daily.push.title")); + }); + + it("floor: a no-AI user (no briefing lead) still gets the deterministic line", () => { + // composeLine's floor already sits in `digest.line`; the push carries it + // unchanged, so a keyless self-hoster gets a first-class body. + const digest = makeDigest({ + briefingLead: null, + topSignal: null, + line: "Your health score today is 82.", + }); + const { body } = buildDailyBriefingPush(digest, t); + expect(body).toBe("Your health score today is 82."); + }); + + it("provisional: appends the honest sleep-pending wording", () => { + const digest = makeDigest({ + sleepPending: true, + line: "Trends held steady this week.", + }); + const { body } = buildDailyBriefingPush(digest, t); + expect(body).toContain("Trends held steady this week."); + expect(body).not.toBe("Trends held steady this week."); + }); +}); + +describe("digestHasSubstance", () => { + it("is false for an empty account (score/lead/signal all null, no items)", () => { + expect( + digestHasSubstance( + makeDigest({ + score: null, + briefingLead: null, + topSignal: null, + worthALook: [], + }), + ), + ).toBe(false); + }); + + it("is true when a score is present", () => { + expect( + digestHasSubstance( + makeDigest({ briefingLead: null, topSignal: null, worthALook: [] }), + ), + ).toBe(true); + }); +}); + +describe("maybeDispatchDailyBriefing", () => { + it("opted-in + substantive digest + in window → exactly one DAILY_BRIEFING dispatch", async () => { + const prisma = makePrisma({}); + const deps = makeDeps(); + const result = await maybeDispatchDailyBriefing( + prisma, + "u1", + IN_WINDOW, + deps, + ); + + expect(result).toBe("sent"); + expect(deps.dispatch).toHaveBeenCalledTimes(1); + const payload = deps.dispatch.mock.calls[0][0]; + expect(payload.eventType).toBe("DAILY_BRIEFING"); + expect(payload.metadata?.url).toBe("/"); + }); + + it("never routes as an alert: the payload is non-urgent", async () => { + const prisma = makePrisma({}); + const deps = makeDeps(); + await maybeDispatchDailyBriefing(prisma, "u1", IN_WINDOW, deps); + const payload = deps.dispatch.mock.calls[0][0]; + // No urgent flag AND not MEDICATION_REMINDER → the urgency classifier is + // false, so it can never escalate to a time-sensitive / Focus-bypass send. + expect(payload.urgent).toBeUndefined(); + expect(isUrgentPayload(payload)).toBe(false); + }); + + it("opted-out (no enabled preference) → no dispatch", async () => { + const prisma = makePrisma({ optIn: null }); + const deps = makeDeps(); + const result = await maybeDispatchDailyBriefing( + prisma, + "u1", + IN_WINDOW, + deps, + ); + expect(result).toBe("opted-out"); + expect(deps.dispatch).not.toHaveBeenCalled(); + }); + + it("no digest substance → no dispatch", async () => { + const prisma = makePrisma({}); + const deps = makeDeps({ + loadDigest: async () => + makeDigest({ + score: null, + briefingLead: null, + topSignal: null, + worthALook: [], + }), + }); + const result = await maybeDispatchDailyBriefing( + prisma, + "u1", + IN_WINDOW, + deps, + ); + expect(result).toBe("no-digest"); + expect(deps.dispatch).not.toHaveBeenCalled(); + }); + + it("insights module off → no dispatch", async () => { + const prisma = makePrisma({}); + const deps = makeDeps({ isModuleEnabled: async () => false }); + const result = await maybeDispatchDailyBriefing( + prisma, + "u1", + IN_WINDOW, + deps, + ); + expect(result).toBe("module-off"); + expect(deps.dispatch).not.toHaveBeenCalled(); + }); + + it("frequency cap: an ok ledger row earlier the SAME local day suppresses the second push", async () => { + const prisma = makePrisma({ + // 04:00Z → 06:00 Berlin, same local day as the 08:00 attempt. + lastOk: { createdAt: new Date("2026-07-16T04:00:00Z") }, + }); + const deps = makeDeps(); + const result = await maybeDispatchDailyBriefing( + prisma, + "u1", + IN_WINDOW, + deps, + ); + expect(result).toBe("suppressed-frequency"); + expect(deps.dispatch).not.toHaveBeenCalled(); + }); + + it("frequency cap: an ok ledger row on the PREVIOUS local day does not suppress", async () => { + const prisma = makePrisma({ + // 20:00Z on the 15th → 22:00 Berlin on the 15th → previous local day. + lastOk: { createdAt: new Date("2026-07-15T20:00:00Z") }, + }); + const deps = makeDeps(); + const result = await maybeDispatchDailyBriefing( + prisma, + "u1", + IN_WINDOW, + deps, + ); + expect(result).toBe("sent"); + expect(deps.dispatch).toHaveBeenCalledTimes(1); + }); + + it("tz timing: before the local morning window → no dispatch", async () => { + const prisma = makePrisma({}); + const deps = makeDeps(); + const result = await maybeDispatchDailyBriefing( + prisma, + "u1", + BEFORE_WINDOW, + deps, + ); + expect(result).toBe("outside-window"); + expect(deps.dispatch).not.toHaveBeenCalled(); + }); + + it("tz timing: after the local morning window → no dispatch", async () => { + const prisma = makePrisma({}); + const deps = makeDeps(); + const result = await maybeDispatchDailyBriefing( + prisma, + "u1", + AFTER_WINDOW, + deps, + ); + expect(result).toBe("outside-window"); + expect(deps.dispatch).not.toHaveBeenCalled(); + }); + + it("no channel delivered → no-channel (ledger slot left free)", async () => { + const prisma = makePrisma({}); + const deps = makeDeps({ + dispatch: vi.fn(async () => ({ + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, + })) as unknown as DailyBriefingDispatchDeps["dispatch"], + }); + const result = await maybeDispatchDailyBriefing( + prisma, + "u1", + IN_WINDOW, + deps, + ); + expect(result).toBe("no-channel"); + }); + + it("missing user → missing-user, never throws", async () => { + const prisma = makePrisma({ user: null }); + const deps = makeDeps(); + const result = await maybeDispatchDailyBriefing( + prisma, + "u1", + IN_WINDOW, + deps, + ); + expect(result).toBe("missing-user"); + expect(deps.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/daily/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts new file mode 100644 index 000000000..3f9e12408 --- /dev/null +++ b/src/lib/daily/daily-briefing-push.ts @@ -0,0 +1,255 @@ +/** + * S5 — the daily briefing push (§2.2). + * + * A CALM, once-per-day morning nudge that rides the EXISTING notification + * cascade (`dispatchNotification` → APNs → Telegram → ntfy → Webhook → Email → + * Web Push), inheriting the hard-reject classification and the `push_attempts` + * ledger for free. It reads the ALREADY-CACHED daily digest (S1 `buildDailyDigest` + * via `loadDailyDigest`) — it NEVER makes a fresh AI/provider call (the + * warm-on-mount ban extends to warm-on-dispatch), so a keyless self-hoster gets + * a first-class push from the digest's deterministic `line` floor. + * + * This module owns ONE decision seam — `maybeDispatchDailyBriefing` — that both + * triggers funnel through: the S4 morning-refresh finalisation hook (event- + * driven, on sleep arrival) and the fixed local-morning fallback cron + * (`@/lib/jobs/daily-briefing`). Whichever fires first that morning writes the + * `ok` ledger row; the frequency-cap check suppresses the second — one push per + * user per local day, no "you haven't opened the app" second push, ever. + * + * Privacy + calm posture (load-bearing): the body is `digest.line`, which is + * non-clinical by construction (a briefing lead sentence, the top signal's + * headline, a 0–100 score statement, or the honest all-clear — never a raw BP / + * glucose figure). It is dispatched NON-URGENT, so it never escalates to a + * Focus-bypassing / time-sensitive delivery — a concerning reading always stays + * on `MEASUREMENT_ANOMALY`, never here. It fires ONLY inside a local morning + * window, which is the quiet-hours guarantee for this nudge. + */ +import type { User } from "@/generated/prisma/client"; +import type { PrismaClient } from "@/generated/prisma/client"; + +import { annotate, getEvent } from "@/lib/logging/context"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { ServerTranslator } from "@/lib/i18n/server-translator"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { userDayKey } from "@/lib/tz/format"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { loadDailyDigest } from "@/lib/daily/load-digest"; +import type { DailyDigest } from "@/lib/daily/digest"; + +/** + * The local-morning window this nudge may fire in, [earliest, latest). Both the + * sleep-arrival finalisation hook and the fixed fallback slot are gated by it, + * so a late-afternoon sleep sync (a full re-sync, a device that reconnected + * hours after waking) can never fire a "morning briefing" at an odd hour — + * outside the window the day simply carries no push (never-nag). 05:00 is the + * earliest a genuine wake-time sleep sync lands; noon is a firm ceiling. + */ +export const DAILY_BRIEFING_MORNING_EARLIEST_HOUR = 5; +export const DAILY_BRIEFING_MORNING_LATEST_HOUR = 12; + +/** + * The fixed local hour the fallback cron dispatches at when sleep never + * arrived (so the finalisation hook never fired). Inside the window above; the + * earlier part of the window is left for the event-driven finalisation push, so + * a user whose sleep synced on wake gets the warmer FINAL digest first and the + * ledger suppresses this slot. + */ +export const DAILY_BRIEFING_FALLBACK_HOUR = 8; + +/** The dashboard / Today view the push deep-links to (same-origin path). */ +const DAILY_BRIEFING_DEEP_LINK = "/"; + +/** + * The outcome of one dispatch attempt — one value per branch so the wide-event + * annotation (and the tests) can assert the exact decision the seam took. + */ +export type DailyBriefingDispatchResult = + | "sent" + | "suppressed-frequency" + | "no-digest" + | "opted-out" + | "module-off" + | "outside-window" + | "no-channel" + | "missing-user" + | "error"; + +export interface DailyBriefingDispatchDeps { + dispatch?: typeof dispatchNotification; + loadDigest?: (user: User, now: Date) => Promise; + isModuleEnabled?: typeof isModuleEnabled; +} + +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + +/** + * A digest is "substantive" — worth a morning push — when it carries a score, a + * briefing lead, a top signal, OR a worth-a-look item. A brand-new / empty + * account whose only line would be the generic all-clear is NOT nudged every + * morning (the never-nag rule); it falls through to the `no-digest` skip. + */ +export function digestHasSubstance(digest: DailyDigest): boolean { + return ( + digest.score !== null || + digest.briefingLead !== null || + digest.topSignal !== null || + digest.worthALook.length > 0 + ); +} + +/** + * Compose the push title + body from the cached digest. The body IS + * `digest.line` — the cached briefing lead when present, else the deterministic + * floor — so a no-AI self-hoster still gets a meaningful push. When the day is + * still provisional (last night's sleep not yet in), the honest sleep-pending + * wording is appended so the read is never silently presented as final. + */ +export function buildDailyBriefingPush( + digest: DailyDigest, + t: ServerTranslator["t"], +): { title: string; body: string } { + const title = t("daily.push.title"); + const body = digest.sleepPending + ? t("daily.push.bodyProvisional", { line: digest.line }) + : digest.line; + return { title, body }; +} + +/** + * The ONE dispatch-decision seam. Fully fault-isolated — every failure returns + * a result value rather than throwing, so neither the sleep-arrival hook nor + * the cron tick can be broken by it. + * + * Gates, cheapest first: + * 1. user missing → `missing-user` + * 2. no opt-in (default OFF) → `opted-out` (no enabled DAILY_BRIEFING pref) + * 3. outside morning window → `outside-window` + * 4. insights module off → `module-off` + * 5. already pushed today → `suppressed-frequency` (push_attempts ledger) + * 6. digest not substantive → `no-digest` + * 7. dispatch; no channel → `no-channel`; else `sent`. + */ +export async function maybeDispatchDailyBriefing( + prisma: PrismaClient, + userId: string, + now: Date, + deps: DailyBriefingDispatchDeps = {}, +): Promise { + const dispatch = deps.dispatch ?? dispatchNotification; + const loadDigest = deps.loadDigest ?? loadDailyDigest; + const moduleGate = deps.isModuleEnabled ?? isModuleEnabled; + + try { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + annotate({ action: { name: "daily.briefing_push.missing_user" } }); + return "missing-user"; + } + + // Opt-in: DAILY_BRIEFING defaults OFF, so an opted-in user is one with at + // least one enabled per-channel preference row. Silence until then. + const optIn = await prisma.notificationPreference.findFirst({ + where: { + eventType: "DAILY_BRIEFING", + enabled: true, + channel: { userId }, + }, + select: { id: true }, + }); + if (!optIn) { + annotate({ action: { name: "daily.briefing_push.opted_out" } }); + return "opted-out"; + } + + const tz = user.timezone || "Europe/Berlin"; + const { hour } = wallClockInTz(now, tz); + if ( + hour < DAILY_BRIEFING_MORNING_EARLIEST_HOUR || + hour >= DAILY_BRIEFING_MORNING_LATEST_HOUR + ) { + annotate({ + action: { name: "daily.briefing_push.outside_window" }, + meta: { local_hour: hour }, + }); + return "outside-window"; + } + + // Insights is the digest's home module; a user who turned it off gets no + // digest surfaces at all, so no morning push either. + if (!(await moduleGate(userId, "insights"))) { + annotate({ action: { name: "daily.briefing_push.module_off" } }); + return "module-off"; + } + + // One per user per LOCAL day. Anchored on the `push_attempts` ledger the + // senders already write (`ok` on a successful delivery), exactly like the + // coach-nudge cap: whichever trigger fired first this morning left an `ok` + // row, so the second is suppressed. Compared by local day-key (DST-safe), + // so the day boundary is the user's, not UTC's. + const lastOk = await prisma.pushAttempt.findFirst({ + where: { userId, eventType: "DAILY_BRIEFING", result: "ok" }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }); + if (lastOk && userDayKey(lastOk.createdAt, tz) === userDayKey(now, tz)) { + annotate({ + action: { name: "daily.briefing_push.suppressed_frequency" }, + }); + return "suppressed-frequency"; + } + + const digest = await loadDigest(user, now); + if (!digestHasSubstance(digest)) { + annotate({ action: { name: "daily.briefing_push.no_digest" } }); + return "no-digest"; + } + + const { t } = getServerTranslator(resolveLocale(user.locale)); + const { title, body } = buildDailyBriefingPush(digest, t); + + const outcome = await dispatch({ + // NON-URGENT by construction: no `urgent` flag, and DAILY_BRIEFING is not + // MEDICATION_REMINDER, so `isUrgentPayload` is false — this never + // escalates to a time-sensitive / Focus-bypassing delivery. It is a calm + // nudge, never the anomaly channel. + eventType: "DAILY_BRIEFING", + userId, + title, + message: body, + metadata: { + url: DAILY_BRIEFING_DEEP_LINK, + phase: digest.phase, + scheduledAt: now.toISOString(), + }, + }); + + if (!outcome.dispatched) { + // No channel delivered — leave the ledger slot free (no `ok` row) so a + // later trigger this morning can still succeed once a channel recovers. + annotate({ action: { name: "daily.briefing_push.no_channel" } }); + return "no-channel"; + } + + annotate({ + action: { name: "daily.briefing_push.sent" }, + meta: { + daily_briefing_phase: digest.phase, + daily_briefing_sleep_pending: digest.sleepPending, + }, + }); + return "sent"; + } catch (err) { + getEvent()?.addWarning( + `daily-briefing push failed for ${userId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return "error"; + } +} diff --git a/src/lib/jobs/__tests__/daily-briefing.test.ts b/src/lib/jobs/__tests__/daily-briefing.test.ts new file mode 100644 index 000000000..8ae057493 --- /dev/null +++ b/src/lib/jobs/__tests__/daily-briefing.test.ts @@ -0,0 +1,117 @@ +/** + * S5 — daily-briefing fallback cron: tz slot-gating + result accounting, plus + * the dead-queue wiring guard (imported / in allQueues / scheduled / worked). + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, it, expect, vi } from "vitest"; +import type { PrismaClient } from "@/generated/prisma/client"; + +import { + runDailyBriefingTick, + DAILY_BRIEFING_QUEUE, + DAILY_BRIEFING_CRON, +} from "@/lib/jobs/daily-briefing"; +import type { maybeDispatchDailyBriefing } from "@/lib/daily/daily-briefing-push"; + +// 06:00Z → 08:00 Berlin (the fallback hour) but 02:00 in New York (not the +// slot), so a mixed-tz cohort exercises the per-user local-hour gate. +const NOW = new Date("2026-07-16T06:00:00Z"); + +function makePrisma( + cohort: Array<{ userId: string; timezone: string }>, +): PrismaClient { + return { + notificationPreference: { + findMany: vi.fn(async () => + cohort.map((c) => ({ + channel: { userId: c.userId, user: { timezone: c.timezone } }, + })), + ), + }, + } as unknown as PrismaClient; +} + +describe("runDailyBriefingTick — slot gating", () => { + it("only calls the dispatch seam for users at their local fallback hour", async () => { + const prisma = makePrisma([ + { userId: "berlin", timezone: "Europe/Berlin" }, // 08:00 → in slot + { userId: "ny", timezone: "America/New_York" }, // 02:00 → not slot + ]); + const maybeDispatch = vi.fn( + async () => "sent", + ); + + const summary = await runDailyBriefingTick(prisma, NOW, { maybeDispatch }); + + expect(summary.candidatesScanned).toBe(2); + expect(summary.inSlot).toBe(1); + expect(summary.sent).toBe(1); + expect(maybeDispatch).toHaveBeenCalledTimes(1); + expect(maybeDispatch.mock.calls[0][1]).toBe("berlin"); + }); + + it("routes each dispatch result into its summary bucket", async () => { + const prisma = makePrisma([ + { userId: "berlin", timezone: "Europe/Berlin" }, + ]); + const maybeDispatch = vi.fn( + async () => "suppressed-frequency", + ); + + const summary = await runDailyBriefingTick(prisma, NOW, { maybeDispatch }); + expect(summary.suppressedFrequency).toBe(1); + expect(summary.sent).toBe(0); + }); + + it("an empty opted-in cohort is a clean no-op", async () => { + const prisma = makePrisma([]); + const maybeDispatch = vi.fn( + async () => "sent", + ); + const summary = await runDailyBriefingTick(prisma, NOW, { maybeDispatch }); + expect(summary.candidatesScanned).toBe(0); + expect(maybeDispatch).not.toHaveBeenCalled(); + }); +}); + +describe("reminder-worker — daily-briefing wiring", () => { + const registrar = readFileSync( + join(__dirname, "..", "reminder", "register-status.ts"), + "utf8", + ); + + it("imports the queue symbols from the daily-briefing module", () => { + expect(registrar).toMatch(/from\s*["']@\/lib\/jobs\/daily-briefing["']/); + expect(registrar).toMatch(/\bDAILY_BRIEFING_QUEUE\b/); + expect(registrar).toMatch(/\bDAILY_BRIEFING_CRON\b/); + expect(registrar).toMatch(/\brunDailyBriefingTick\b/); + }); + + it("registers the queue in allQueues and schedules the cron", () => { + const allQueues = registrar.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); + expect(allQueues).not.toBeNull(); + expect(allQueues![1]).toMatch(/\bDAILY_BRIEFING_QUEUE\b/); + expect(registrar).toMatch( + /\[DAILY_BRIEFING_QUEUE,\s*DAILY_BRIEFING_CRON\]/, + ); + }); + + it("wires a boss.work handler that runs the tick", () => { + expect(registrar).toMatch( + /boss\.work[\s\S]{0,200}DAILY_BRIEFING_QUEUE[\s\S]{0,400}runDailyBriefingTick/, + ); + }); + + it("fires the finalisation-hook push after a finalised morning refresh", () => { + expect(registrar).toMatch( + /result\.status\s*===\s*["']finalised["'][\s\S]{0,300}maybeDispatchDailyBriefing/, + ); + }); + + it("uses an every-15-min cron for the fallback slot", () => { + expect(DAILY_BRIEFING_QUEUE).toBe("daily-briefing"); + expect(DAILY_BRIEFING_CRON).toBe("*/15 * * * *"); + }); +}); diff --git a/src/lib/jobs/daily-briefing.ts b/src/lib/jobs/daily-briefing.ts new file mode 100644 index 000000000..d91d0097a --- /dev/null +++ b/src/lib/jobs/daily-briefing.ts @@ -0,0 +1,149 @@ +/** + * S5 — the daily-briefing fallback cron. + * + * The PRIMARY trigger for the morning push is event-driven: the S4 + * morning-refresh worker dispatches it the instant last night's sleep lands and + * the digest finalises (see `register-status.ts`). This cron is the FALLBACK + * slot for the honest-degradation case — a user whose sleep never synced, so no + * finalisation ever fired. It ticks every 15 minutes (the mood- / + * measurement-reminder cadence) and, for each OPTED-IN user whose local wall + * clock is at the fixed fallback hour, funnels through the same + * `maybeDispatchDailyBriefing` decision seam. The `push_attempts` frequency cap + * means a user who already got the finalisation push earlier this morning is + * suppressed here — one push per user per local day, no matter which trigger + * won the race. + * + * The opted-in cohort is small (DAILY_BRIEFING defaults OFF), and the per-user + * work is gated on the exact local fallback hour, so the tick is cheap: it only + * loads a digest for the handful of users at their fallback minute-window this + * tick. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { getEvent } from "@/lib/logging/context"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { + DAILY_BRIEFING_FALLBACK_HOUR, + maybeDispatchDailyBriefing, + type DailyBriefingDispatchDeps, +} from "@/lib/daily/daily-briefing-push"; + +export const DAILY_BRIEFING_QUEUE = "daily-briefing"; +/** Every 15 minutes; the per-user local-hour gate selects the fallback slot. */ +export const DAILY_BRIEFING_CRON = "*/15 * * * *"; + +export interface DailyBriefingTickSummary { + candidatesScanned: number; + inSlot: number; + sent: number; + suppressedFrequency: number; + noDigest: number; + optedOut: number; + moduleOff: number; + noChannel: number; + outsideWindow: number; + failed: number; +} + +/** + * Run one fallback-cron tick. Injectable `dispatch` / `maybeDispatch` so the + * unit tests pin the slot-gating + result accounting without a running boss, + * provider, or DB. + */ +export async function runDailyBriefingTick( + prisma: PrismaClient, + now: Date, + options: { + dispatch?: typeof dispatchNotification; + /** Injectable decision seam (defaults to the real one). */ + maybeDispatch?: typeof maybeDispatchDailyBriefing; + dispatchDeps?: DailyBriefingDispatchDeps; + } = {}, +): Promise { + const maybeDispatch = options.maybeDispatch ?? maybeDispatchDailyBriefing; + const dispatchDeps: DailyBriefingDispatchDeps = { + ...(options.dispatchDeps ?? {}), + ...(options.dispatch ? { dispatch: options.dispatch } : {}), + }; + + const summary: DailyBriefingTickSummary = { + candidatesScanned: 0, + inSlot: 0, + sent: 0, + suppressedFrequency: 0, + noDigest: 0, + optedOut: 0, + moduleOff: 0, + noChannel: 0, + outsideWindow: 0, + failed: 0, + }; + + // The opted-in cohort: users with at least one enabled DAILY_BRIEFING + // preference row. Deduped by userId (a user may have opted several channels + // in) and carrying the timezone so the slot gate needs no extra read. + const prefs = await prisma.notificationPreference.findMany({ + where: { eventType: "DAILY_BRIEFING", enabled: true }, + select: { + channel: { + select: { userId: true, user: { select: { timezone: true } } }, + }, + }, + }); + + const cohort = new Map(); + for (const pref of prefs) { + const userId = pref.channel?.userId; + if (!userId) continue; + cohort.set(userId, pref.channel.user?.timezone || "Europe/Berlin"); + } + + for (const [userId, tz] of cohort) { + summary.candidatesScanned += 1; + try { + // Fire only at the fixed fallback hour; the earlier part of the morning + // window belongs to the event-driven finalisation push. + if (wallClockInTz(now, tz).hour !== DAILY_BRIEFING_FALLBACK_HOUR) { + continue; + } + summary.inSlot += 1; + + const result = await maybeDispatch(prisma, userId, now, dispatchDeps); + switch (result) { + case "sent": + summary.sent += 1; + break; + case "suppressed-frequency": + summary.suppressedFrequency += 1; + break; + case "no-digest": + summary.noDigest += 1; + break; + case "opted-out": + summary.optedOut += 1; + break; + case "module-off": + summary.moduleOff += 1; + break; + case "no-channel": + summary.noChannel += 1; + break; + case "outside-window": + summary.outsideWindow += 1; + break; + default: + summary.failed += 1; + } + } catch (err) { + summary.failed += 1; + getEvent()?.addWarning( + `daily-briefing tick failed for ${userId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + return summary; +} diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 4198962df..315ed0eaf 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -29,6 +29,12 @@ import { runMorningDigestRefresh, type MorningDigestRefreshPayload, } from "@/lib/jobs/morning-digest-refresh"; +import { + DAILY_BRIEFING_QUEUE, + DAILY_BRIEFING_CRON, + runDailyBriefingTick, +} from "@/lib/jobs/daily-briefing"; +import { maybeDispatchDailyBriefing } from "@/lib/daily/daily-briefing-push"; import { RECOVERY_SCORE_QUEUE, RECOVERY_SCORE_CRON, @@ -188,6 +194,11 @@ const allQueues = [ // v1.16.11 — daily medication low-stock pass. Without this entry the 09:00 // schedule silently no-ops and no low-stock alert ever fires. MEDICATION_LOW_STOCK_QUEUE, + // S5 — daily-briefing fallback cron. The primary morning push rides the + // sleep-arrival finalisation hook below; this queue is the fixed local-morning + // slot for the no-sleep-arrived case. Without this entry the every-15-min + // schedule silently no-ops and the fallback push never fires. + DAILY_BRIEFING_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -243,6 +254,11 @@ const schedules: ScheduleEntry[] = [ // can act on it. Once daily; the per-medication stamp keeps it at // one push per threshold crossing. [MEDICATION_LOW_STOCK_QUEUE, MEDICATION_LOW_STOCK_CRON], + // S5 — daily-briefing fallback slot. Ticks every 15 min; the per-user + // local-hour gate inside the tick selects each user's fixed morning slot, so + // one UTC cron serves every timezone. The `push_attempts` cap suppresses it + // when the sleep-arrival finalisation push already fired this morning. + [DAILY_BRIEFING_QUEUE, DAILY_BRIEFING_CRON], ]; /** @@ -326,6 +342,20 @@ export async function registerStatusQueues( "morning_refresh", `${result.status}:${result.comprehensive ?? "none"}`, ); + // S5 — the day just finalised (last night's sleep folded in), so + // this is the natural, event-driven moment to fire the calm morning + // push carrying the FINAL digest. Fully fault-isolated inside the + // seam; the opt-in / morning-window / once-per-day gates all live + // there, so a user who never opted in or is outside the window is a + // clean no-op. The fallback cron covers the no-sleep-arrived case. + if (result.status === "finalised") { + const pushResult = await maybeDispatchDailyBriefing( + getWorkerPrisma(), + job.data.userId, + new Date(), + ); + evt.addMeta("daily_briefing_push", pushResult); + } } catch (err) { recordError(); workerLog("error", "[morning-digest-refresh] pass failed", err); @@ -429,6 +459,40 @@ export async function registerStatusQueues( }); }, ); + // S5 — daily-briefing fallback slot. Single-flight; the `push_attempts` + // frequency cap makes an overlapping tick a no-op (a second same-day attempt + // is suppressed), and the tick only loads a digest for opted-in users at + // their exact local fallback hour. No provider call — it reads the cached + // digest and dispatches the deterministic line + cached lead. + await boss.work(DAILY_BRIEFING_QUEUE, { localConcurrency: 1 }, async () => { + await withBackgroundEvent("job.daily_briefing", async (evt) => { + try { + const summary = await runDailyBriefingTick( + getWorkerPrisma(), + new Date(), + ); + evt.setBackground({ + task_name: "job.daily_briefing", + result: { + candidates_scanned: summary.candidatesScanned, + in_slot: summary.inSlot, + sent: summary.sent, + suppressed_frequency: summary.suppressedFrequency, + no_digest: summary.noDigest, + opted_out: summary.optedOut, + module_off: summary.moduleOff, + no_channel: summary.noChannel, + outside_window: summary.outsideWindow, + failed: summary.failed, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); + }); // v1.10.0 — computed scores (WX-E). Nightly Stress-score (HRV-derived // proxy) compute + store. Single-flight so two ticks never double-walk // the cohort. The runner iterates every eligible user and upserts one diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 860e78af6..102871b2e 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -101,6 +101,17 @@ export const EVENT_TYPES = [ // would defeat the feature. An explicit per-channel `NotificationPreference` // row still wins for a user who opts out. "SECURITY_ALERT", + // S5 — the once-daily calm morning briefing. Default OFF (see + // EVENT_DEFAULT_ENABLED, the PERSONAL_RECORD precedent): the user opts in + // per-channel from the /notifications matrix, so the channel choice is + // explicit at opt-in. It reads the CACHED daily digest — never a fresh AI + // call — and carries a minimal, non-clinical line (cached briefing lead with + // a deterministic floor). It is a nudge, NEVER the anomaly channel: it is + // dispatched non-urgent, fired at most once per user per local day (the + // `push_attempts` ledger is the frequency anchor), and only inside a local + // morning window, so a concerning reading always stays on + // `MEASUREMENT_ANOMALY` and this never bypasses Focus. + "DAILY_BRIEFING", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -162,6 +173,12 @@ export const EVENT_DEFAULT_ENABLED: Record = { // needs surfaced. An explicit per-channel `NotificationPreference` row // (enabled=false) still suppresses it for a user who opts out. SECURITY_ALERT: true, + // S5 — OFF by default (the PERSONAL_RECORD precedent). The calm daily + // briefing stays silent until the user explicitly opts in per-channel at + // /notifications; the dispatcher's default-policy gate suppresses every + // channel that has no explicit enabled row, so a fresh account never + // receives a morning push it did not ask for. + DAILY_BRIEFING: false, }; export const CHANNEL_TYPE_LABELS: Record = {