fix(daily): send the morning briefing push with AI analysis off

The push refused outright while the `insights` module was off, which
now means only "AI analysis". The digest it reads already drops the
model-written lead and top signal in that case, so the push goes out
with the deterministic line (the score, or the day's items) and a
digest with nothing to say is still skipped. The `module-off` outcome
and its counter are gone.

Refs #975
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:17:00 +02:00
1 parent c509afeb2e
commit 7df260d62e
4 files changed
+23 -26

No files matched your search

@@ -82,7 +82,6 @@ function makeDeps(
return {
dispatch,
loadDigest: async () => makeDigest(),
isModuleEnabled: async () => true,
...over,
} as DailyBriefingDispatchDeps & { dispatch: ReturnType<typeof vi.fn> };
}
@@ -253,17 +252,27 @@ describe("maybeDispatchDailyBriefing", () => {
expect(deps.dispatch).not.toHaveBeenCalled();
});
it("insights module off → no dispatch", async () => {
it("AI analysis off: the push still goes out with the deterministic line", async () => {
// The digest the loader returns when the briefing capability is
// unavailable: no lead, no top signal, the score floor as the line.
const prisma = makePrisma({});
const deps = makeDeps({ isModuleEnabled: async () => false });
const deps = makeDeps({
loadDigest: async () =>
makeDigest({
briefingLead: null,
topSignal: null,
line: "Your health score today is 82.",
}),
});
const result = await maybeDispatchDailyBriefing(
prisma,
"u1",
IN_WINDOW,
deps,
);
expect(result).toBe("module-off");
expect(deps.dispatch).not.toHaveBeenCalled();
expect(result).toBe("sent");
const payload = JSON.stringify(deps.dispatch.mock.calls[0]);
expect(payload).toContain("Your health score today is 82.");
});
it("frequency cap: an ok ledger row earlier the SAME local day suppresses the second push", async () => {
+9 -15
View File
@@ -32,7 +32,6 @@ import type { Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
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";
@@ -72,7 +71,6 @@ export type DailyBriefingDispatchResult =
| "suppressed-frequency"
| "no-digest"
| "opted-out"
| "module-off"
| "outside-window"
| "no-channel"
| "missing-user"
@@ -81,7 +79,6 @@ export type DailyBriefingDispatchResult =
export interface DailyBriefingDispatchDeps {
dispatch?: typeof dispatchNotification;
loadDigest?: (user: User, now: Date, locale: Locale) => Promise<DailyDigest>;
isModuleEnabled?: typeof isModuleEnabled;
}
function loadDailyBriefingDigest(
@@ -137,10 +134,15 @@ export function buildDailyBriefingPush(
* 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`.
* 4. already pushed today → `suppressed-frequency` (push_attempts ledger)
* 5. digest not substantive → `no-digest`
* 6. dispatch; no channel → `no-channel`; else `sent`.
*
* No module gate. The `insights` module is the AI analysis opt-out: with it
* off (or AI unavailable for any other reason) the digest carries no briefing
* lead or top signal, and the push body falls to the digest's deterministic
* line (the score, or the all-clear). A digest with nothing deterministic to
* say is skipped as `no-digest`, like any other.
*/
export async function maybeDispatchDailyBriefing(
prisma: PrismaClient,
@@ -150,7 +152,6 @@ export async function maybeDispatchDailyBriefing(
): Promise<DailyBriefingDispatchResult> {
const dispatch = deps.dispatch ?? dispatchNotification;
const loadDigest = deps.loadDigest ?? loadDailyBriefingDigest;
const moduleGate = deps.isModuleEnabled ?? isModuleEnabled;
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
@@ -187,13 +188,6 @@ export async function maybeDispatchDailyBriefing(
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`
-5
View File
@@ -40,7 +40,6 @@ export interface DailyBriefingTickSummary {
suppressedFrequency: number;
noDigest: number;
optedOut: number;
moduleOff: number;
noChannel: number;
outsideWindow: number;
failed: number;
@@ -74,7 +73,6 @@ export async function runDailyBriefingTick(
suppressedFrequency: 0,
noDigest: 0,
optedOut: 0,
moduleOff: 0,
noChannel: 0,
outsideWindow: 0,
failed: 0,
@@ -123,9 +121,6 @@ export async function runDailyBriefingTick(
case "opted-out":
summary.optedOut += 1;
break;
case "module-off":
summary.moduleOff += 1;
break;
case "no-channel":
summary.noChannel += 1;
break;
-1
View File
@@ -663,7 +663,6 @@ export async function registerStatusQueues(
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,