mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(daily): a calm opt-in morning briefing push
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.
This commit is contained in:
1 parent
b6ded4d1f9
commit
04cb80da6a
12 files changed
+940
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> = {}): 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> = {},
|
||||
): DailyBriefingDispatchDeps & { dispatch: ReturnType<typeof vi.fn> } {
|
||||
const dispatch = vi.fn(async () => ({
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
}));
|
||||
return {
|
||||
dispatch,
|
||||
loadDigest: async () => makeDigest(),
|
||||
isModuleEnabled: async () => true,
|
||||
...over,
|
||||
} as DailyBriefingDispatchDeps & { dispatch: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<DailyDigest>;
|
||||
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<DailyBriefingDispatchResult> {
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -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<typeof maybeDispatchDailyBriefing>(
|
||||
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<typeof maybeDispatchDailyBriefing>(
|
||||
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<typeof maybeDispatchDailyBriefing>(
|
||||
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 * * * *");
|
||||
});
|
||||
});
|
||||
@@ -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<DailyBriefingTickSummary> {
|
||||
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<string, string>();
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<EventType, boolean> = {
|
||||
// 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<ChannelType, string> = {
|
||||
|
||||
Reference in new issue
Block a user