diff --git a/messages/de.json b/messages/de.json index f3cf1a8af..be2abd1b4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7417,6 +7417,9 @@ "sleepDebtBody": "Einige deiner Nächte waren diese Woche recht kurz. Wenn du willst, suchen wir gemeinsam nach einem Muster.", "measurementGapBody": "Dein Protokoll ist seit etwa einer Woche still. Wann immer es dir passt, hilft ein kurzer Austausch beim Wiedereinstieg.", "focusNote": "Das passt auch zu dem, was du selbst im Blick behalten möchtest.", + "reminderSurfacedTitle": "Deine Erinnerung", + "reminderSurfacedLeadOne": "Du wolltest heute daran erinnert werden:", + "reminderSurfacedLeadMany": "Du wolltest heute an {count} Dinge erinnert werden:", "questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?", "questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?", "questionAllergies": "Hast du Allergien oder Unverträglichkeiten?", diff --git a/messages/en.json b/messages/en.json index 9969e7de4..a4745ee09 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7417,6 +7417,9 @@ "sleepDebtBody": "Several of your nights came up short this week. If you want, we can look for a pattern together.", "measurementGapBody": "Your log has been quiet for about a week. Whenever it suits you, a quick check-in helps you pick things back up.", "focusNote": "It also ties into what you said you wanted to keep an eye on.", + "reminderSurfacedTitle": "The reminder you asked for", + "reminderSurfacedLeadOne": "You asked me to bring this back to you today:", + "reminderSurfacedLeadMany": "You asked me to bring {count} things back to you today:", "questionConditions": "Do you have any chronic conditions the Coach should know about?", "questionFocus": "Is there anything specific the Coach should pay attention to?", "questionAllergies": "Do you have any allergies or intolerances?", diff --git a/messages/es.json b/messages/es.json index a800a3727..a2d577662 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7417,6 +7417,9 @@ "sleepDebtBody": "Varias de tus noches se quedaron cortas esta semana. Si te apetece, buscamos un patrón juntos.", "measurementGapBody": "Tu registro lleva una semana en silencio. Cuando te venga bien, una breve puesta al día ayuda a retomarlo.", "focusNote": "También tiene que ver con eso que querías vigilar.", + "reminderSurfacedTitle": "Tu recordatorio", + "reminderSurfacedLeadOne": "Me pediste que hoy te recordara esto:", + "reminderSurfacedLeadMany": "Me pediste que hoy te recordara {count} cosas:", "questionConditions": "¿Tienes enfermedades crónicas que el Coach deba conocer?", "questionFocus": "¿Hay algo concreto en lo que el Coach deba fijarse?", "questionAllergies": "¿Tienes alergias o intolerancias?", diff --git a/messages/fr.json b/messages/fr.json index b14279d03..6a3e7939e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7417,6 +7417,9 @@ "sleepDebtBody": "Plusieurs de vos nuits ont été un peu courtes cette semaine. Si vous voulez, on cherche une régularité ensemble.", "measurementGapBody": "Votre journal est silencieux depuis environ une semaine. Quand cela vous convient, un petit point aide à reprendre.", "focusNote": "Cela rejoint aussi ce que vous vouliez garder à l'œil.", + "reminderSurfacedTitle": "Ton rappel", + "reminderSurfacedLeadOne": "Tu m'as demandé de te rappeler ceci aujourd'hui :", + "reminderSurfacedLeadMany": "Tu m'as demandé de te rappeler {count} choses aujourd'hui :", "questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?", "questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?", "questionAllergies": "Avez-vous des allergies ou des intolérances ?", diff --git a/messages/it.json b/messages/it.json index 72a8faeb1..be11085ae 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7417,6 +7417,9 @@ "sleepDebtBody": "Diverse delle tue notti sono state un po' corte questa settimana. Se ti va, cerchiamo insieme uno schema.", "measurementGapBody": "Il tuo diario è in silenzio da circa una settimana. Quando ti è comodo, due parole aiutano a riprendere.", "focusNote": "Si lega anche a ciò che volevi tenere d'occhio.", + "reminderSurfacedTitle": "Il tuo promemoria", + "reminderSurfacedLeadOne": "Mi avevi chiesto di ricordarti questo oggi:", + "reminderSurfacedLeadMany": "Mi avevi chiesto di ricordarti {count} cose oggi:", "questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?", "questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?", "questionAllergies": "Hai allergie o intolleranze?", diff --git a/messages/pl.json b/messages/pl.json index ddd1b58b4..7b0cbd0af 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7417,6 +7417,9 @@ "sleepDebtBody": "Kilka Twoich nocy było w tym tygodniu krótkich. Jeśli masz ochotę, poszukajmy razem wzorca.", "measurementGapBody": "Twój dziennik milczy od około tygodnia. Kiedy Ci pasuje, krótka rozmowa pomoże wrócić do rutyny.", "focusNote": "Wiąże się to też z tym, na co chciałeś/chciałaś zwracać uwagę.", + "reminderSurfacedTitle": "Twoje przypomnienie", + "reminderSurfacedLeadOne": "Prosiłeś, żeby przypomnieć ci o tym dzisiaj:", + "reminderSurfacedLeadMany": "Prosiłeś, żeby przypomnieć ci dzisiaj o {count} sprawach:", "questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?", "questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?", "questionAllergies": "Czy masz alergie lub nietolerancje?", diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 0d652d85e..19bc4f3c4 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -122,27 +122,20 @@ export function LayoutCoachFab() { }, }); - // v1.22 (M4) — surface a due/surfaced Coach reminder on the FAB. Additive: - // the dot also lights when the daily sweep has flipped a "remind me" reminder - // to due, independent of the nudge seen-stamp (resolving the reminder in the - // ledger clears it). Same cheap, gated, stale-while-revalidate read shape. - const { data: dueReminders } = useQuery({ - queryKey: queryKeys.coachReminders("due,surfaced"), - queryFn: async () => { - const data = await apiGet<{ reminders?: unknown[] } | undefined>( - "/api/coach/reminders?status=due,surfaced", - ); - return data?.reminders ?? []; - }, - enabled: coachAvailable && !onCoachPage, - staleTime: 5 * 60 * 1000, - }); - const hasDueReminders = (dueReminders?.length ?? 0) > 0; - const nudgedAt = status?.nudgedAt ?? null; + // ONE source for the dot: an assistant message the user has not opened yet. + // + // v1.22 added a second arm — a separate "any due reminder?" query — and that + // is the arm that produced a badge over an empty Coach. The sweep flipped a + // reminder to `due` without writing anything into a conversation, so the dot + // promised a message that did not exist; opening the Coach showed the empty + // state and did not clear the dot either, because only resolving the reminder + // in Settings could. The sweep now writes the reminder into the thread as a + // real assistant message (`coach-reminder-sweep.ts`), which the nudge status + // already reports — so the reminder still lights the dot, through the message + // it actually wrote, and opening the Coach clears it like any other. const unread = isNudgeUnread(status, seenStamp); - // The visual dot lights for either an unread nudge OR a due reminder. - const showDot = unread || hasDueReminders; + const showDot = unread; // The unread dot is visual-only (`aria-hidden`) and the swapped // `aria-label` is not announced on mutation — a screen-reader user diff --git a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts index ecb93983d..fee4c27f4 100644 --- a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts +++ b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts @@ -1,10 +1,12 @@ /** * v1.22 (M4) — daily Coach-reminder sweep. * - * Covers: overdue active date-reminders flip to `due`; a passed CoachPlan - * reviewDate mints a one-off reminder from the plan's own cue→action text and - * clears the reviewDate (activating the dangling B1 column); an undecryptable - * plan is skipped (counted errored) without sinking the tick. + * Covers: a passed CoachPlan reviewDate mints a one-off reminder from the + * plan's own cue→action text and clears the reviewDate (activating the dangling + * B1 column); an undecryptable plan is skipped (counted errored) without + * sinking the tick; and — v1.37 — surfacing writes the reminder into a + * conversation and flips its status in one transaction, so a badge can never + * point at a message that was never written. */ import { describe, it, expect, vi } from "vitest"; @@ -25,84 +27,179 @@ function bytes(tag: string): Uint8Array { return new Uint8Array(Buffer.from(tag, "utf8")); } +/** + * A prisma double whose `$transaction` accepts BOTH shapes the sweep uses: an + * array of pre-built operations (the plan-review mint) and an interactive + * callback (the surfacing step). + */ +function makePrisma(options: { + overdue?: { + id: string; + userId: string; + noteEncrypted: Uint8Array; + }[]; + plans?: { + id: string; + userId: string; + metric: string; + ifCueEncrypted: Uint8Array; + thenActionEncrypted: Uint8Array; + }[]; +}) { + const tx = { + coachConversation: { create: vi.fn(async () => ({ id: "c1" })) }, + coachMessage: { create: vi.fn(async () => ({ id: "m1" })) }, + coachReminder: { updateMany: vi.fn(async () => ({ count: 0 })) }, + }; + const prisma = { + coachReminder: { + findMany: vi.fn(async () => options.overdue ?? []), + create: vi.fn(async () => ({ id: "r-new" })), + updateMany: vi.fn(async () => ({ count: 0 })), + }, + coachPlan: { + findMany: vi.fn(async () => options.plans ?? []), + update: vi.fn(async () => ({})), + }, + coachConversation: { create: vi.fn(async () => ({ id: "c1" })) }, + coachMessage: { create: vi.fn(async () => ({ id: "m1" })) }, + user: { findUnique: vi.fn(async () => ({ locale: "en" })) }, + $transaction: vi.fn(async (arg: unknown) => { + if (typeof arg === "function") { + return (arg as (t: typeof tx) => Promise)(tx); + } + return Promise.all(arg as Promise[]); + }), + tx, + }; + return prisma; +} + describe("runCoachReminderSweep", () => { - it("flips overdue active date-reminders to due and mints plan-review reminders", async () => { - const prisma = { - coachReminder: { - updateMany: vi.fn(async () => ({ count: 3 })), - create: vi.fn(async () => ({ id: "r-new" })), - }, - coachPlan: { - findMany: vi.fn(async () => [ - { - id: "p1", - userId: "u1", - metric: "WEIGHT", - ifCueEncrypted: bytes("every morning"), - thenActionEncrypted: bytes("weigh in"), - }, - ]), - update: vi.fn(async () => ({})), - }, - // The mint runs as one atomic batch; resolve the built ops together. - $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), - }; + it("mints a plan-review reminder from the plan's own prose and clears the reviewDate", async () => { + const prisma = makePrisma({ + plans: [ + { + id: "p1", + userId: "u1", + metric: "WEIGHT", + ifCueEncrypted: bytes("every morning"), + thenActionEncrypted: bytes("weigh in"), + }, + ], + }); const summary = await runCoachReminderSweep(prisma as never, NOW); - expect(summary.remindersDue).toBe(3); expect(summary.planReviewsMinted).toBe(1); expect(summary.errored).toBe(0); - // The flip query is scoped to active, date-triggered, overdue rows. - const flipArgs = prisma.coachReminder.updateMany.mock - .calls[0] as unknown as [ - { where: { status: string; triggerKind: string; dueAt: unknown } }, - ]; - const where = flipArgs[0].where; - expect(where.status).toBe("active"); - expect(where.triggerKind).toBe("date"); - expect(where.dueAt).toEqual({ not: null, lte: NOW }); - - // The minted reminder carries the plan's own prose + relatedPlanId. const createArgs = prisma.coachReminder.create.mock.calls[0] as unknown as [ { data: { relatedPlanId: string; source: string; status: string } }, ]; const data = createArgs[0].data; expect(data.relatedPlanId).toBe("p1"); expect(data.source).toBe("extractor"); - expect(data.status).toBe("due"); + // Minted `active`, not `due`: the surfacing pass in this same tick is what + // takes it to the conversation, and there is only one such path. + expect(data.status).toBe("active"); - // The plan's reviewDate is cleared so the review fires exactly once. expect(prisma.coachPlan.update).toHaveBeenCalledWith({ where: { id: "p1" }, data: { reviewDate: null }, }); + }); - // The create + clear commit atomically — a half-applied mint would let the - // next tick re-select the plan and duplicate the review reminder. - expect(prisma.$transaction).toHaveBeenCalledTimes(1); + it("writes the reminder into a conversation and flips its status in ONE transaction", async () => { + const prisma = makePrisma({ + overdue: [ + { id: "r1", userId: "u1", noteEncrypted: bytes("ask about my sleep") }, + ], + }); + + const summary = await runCoachReminderSweep(prisma as never, NOW); + expect(summary.remindersDue).toBe(1); + expect(summary.errored).toBe(0); + + // A conversation with the note as an ASSISTANT message — the row the + // unread badge is derived from. + expect(prisma.tx.coachConversation.create).toHaveBeenCalledTimes(1); + const msgArgs = prisma.tx.coachMessage.create.mock.calls[0] as unknown as [ + { data: { role: string; encryptedContent: Uint8Array } }, + ]; + expect(msgArgs[0].data.role).toBe("assistant"); + const body = Buffer.from(msgArgs[0].data.encryptedContent).toString("utf8"); + expect(body).toContain("dec:ask about my sleep"); + + // …and the status flip rides the SAME transaction, so neither half can + // land without the other. + const flip = prisma.tx.coachReminder.updateMany.mock + .calls[0] as unknown as [ + { where: { id: { in: string[] } }; data: { status: string } }, + ]; + expect(flip[0].where.id.in).toEqual(["r1"]); + expect(flip[0].data.status).toBe("surfaced"); + }); + + it("picks up rows an older build left stuck on `due`", async () => { + const prisma = makePrisma({ + overdue: [{ id: "r-old", userId: "u1", noteEncrypted: bytes("stuck") }], + }); + + await runCoachReminderSweep(prisma as never, NOW); + + const where = ( + prisma.coachReminder.findMany.mock.calls[0] as unknown as [ + { where: { status: { in: string[] } } }, + ] + )[0].where; + expect(where.status.in).toEqual(["active", "due"]); + }); + + it("leaves the reminder alone when the conversation write fails", async () => { + const prisma = makePrisma({ + overdue: [{ id: "r1", userId: "u1", noteEncrypted: bytes("note") }], + }); + prisma.$transaction = vi.fn(async (arg: unknown) => { + if (typeof arg === "function") throw new Error("db down"); + return Promise.all(arg as Promise[]); + }) as never; + + const summary = await runCoachReminderSweep(prisma as never, NOW); + // Not counted as surfaced — the next tick retries, and until it succeeds + // nothing claims a message that does not exist. + expect(summary.remindersDue).toBe(0); + expect(summary.errored).toBe(1); + expect(prisma.tx.coachReminder.updateMany).not.toHaveBeenCalled(); + }); + + it("skips an undecryptable note without losing the ones beside it", async () => { + const prisma = makePrisma({ + overdue: [ + { id: "r-bad", userId: "u1", noteEncrypted: bytes("__bad__") }, + { id: "r-ok", userId: "u1", noteEncrypted: bytes("fine") }, + ], + }); + + const summary = await runCoachReminderSweep(prisma as never, NOW); + expect(summary.remindersDue).toBe(1); + expect(summary.errored).toBe(1); + const flip = prisma.tx.coachReminder.updateMany.mock + .calls[0] as unknown as [{ where: { id: { in: string[] } } }]; + expect(flip[0].where.id.in).toEqual(["r-ok"]); }); it("skips an undecryptable plan without sinking the tick", async () => { - const prisma = { - coachReminder: { - updateMany: vi.fn(async () => ({ count: 0 })), - create: vi.fn(async () => ({ id: "r-new" })), - }, - coachPlan: { - findMany: vi.fn(async () => [ - { - id: "p1", - userId: "u1", - metric: "SLEEP", - ifCueEncrypted: bytes("__bad__"), - thenActionEncrypted: bytes("lights out"), - }, - ]), - update: vi.fn(async () => ({})), - }, - $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), - }; + const prisma = makePrisma({ + plans: [ + { + id: "p1", + userId: "u1", + metric: "SLEEP", + ifCueEncrypted: bytes("__bad__"), + thenActionEncrypted: bytes("lights out"), + }, + ], + }); const summary = await runCoachReminderSweep(prisma as never, NOW); expect(summary.planReviewsMinted).toBe(0); diff --git a/src/lib/jobs/coach-reminder-sweep.ts b/src/lib/jobs/coach-reminder-sweep.ts index 136bf23fe..010bde5be 100644 --- a/src/lib/jobs/coach-reminder-sweep.ts +++ b/src/lib/jobs/coach-reminder-sweep.ts @@ -5,8 +5,7 @@ * per tick that brings a reminder back at its chosen moment. Two sources: * * 1. `CoachReminder` rows that are date-triggered, `active`, and whose `dueAt` - * has passed → flipped to `due` (the in-app tile + indicator read `due` / - * `surfaced`). `lastSurfacedAt` is stamped + `surfaceCount` bumped. + * has passed. * 2. `CoachPlan` rows whose dangling `reviewDate` (written nowhere, read * nowhere until now — see the design §1c) has passed and that are still * `active` → a one-off `CoachReminder` is minted from the plan's own @@ -14,9 +13,30 @@ * `reviewDate` is cleared so the review fires once. This finally activates * the column the B1 schema anticipated. * - * In-app surfacing ONLY in this wave: push delivery + the receptivity gate (B4) - * land later. The sweep never calls a provider and never pushes — it only flips - * status + mints the plan-review reminder, both idempotent across re-fires. + * Both sources converge on ONE surfacing step, and that convergence is the + * point. Until v1.37 the sweep only flipped a reminder's status to `due` and + * stopped: nothing was written into the conversation, `surfaced` had no writer + * at all, and the FAB lit its unread dot off a separate "is anything due?" + * query. So the dot appeared over an empty Coach, opening it showed the empty + * state, and opening it did not clear the dot either — the only way out was to + * resolve the reminder in Settings, which nothing on the badge suggested. A + * badge and the content it promises must come from one event. + * + * They do now. Surfacing writes the reminder into the user's Coach thread as a + * real assistant message and flips the reminder to `surfaced` in the SAME + * transaction, so the two cannot drift: no message, no status change, no + * badge. The badge itself is derived from that message + * (`readCoachNudgeStatus` compares the newest assistant message against + * `User.coachLastSeenAt`), which means opening the Coach clears it the way it + * clears any other proactive message. + * + * Rows left `due` by an older build are picked up by the same pass, so a badge + * that has been stuck on an empty conversation resolves into the message it + * always implied rather than staying stuck. + * + * The sweep still never calls a provider and never pushes. The message body is + * a deterministic localized template around the user's OWN note text — the + * words they asked to be reminded of, never anything fabricated. * * Context-cue reminders (`triggerKind: "context"`) are NOT evaluated here — that * is a follow-on (F4); the column exists and capture supports it, but the date @@ -25,6 +45,9 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { Locale } from "@/lib/i18n/config"; +import { defaultLocale, locales } from "@/lib/i18n/config"; export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep"; // Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both @@ -33,8 +56,13 @@ export const COACH_REMINDER_SWEEP_CRON = "20 5 * * *"; /** Bound the plan-review fan-out per tick so one sweep can't run unbounded. */ const PLAN_REVIEW_BATCH = 200; +/** Bound the surfacing fan-out per tick for the same reason. */ +const SURFACE_BATCH = 500; +/** How many notes one surfacing message carries before the rest wait a tick. */ +const MAX_NOTES_PER_MESSAGE = 6; export interface CoachReminderSweepSummary { + /** Reminders whose moment arrived AND that reached the conversation. */ remindersDue: number; planReviewsMinted: number; errored: number; @@ -42,12 +70,43 @@ export interface CoachReminderSweepSummary { type SweepPrisma = Pick< PrismaClient, - "coachReminder" | "coachPlan" | "$transaction" + | "coachReminder" + | "coachPlan" + | "coachConversation" + | "coachMessage" + | "user" + | "$transaction" >; +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + /** - * Run one sweep tick. Idempotent: a reminder already `due` is not re-flipped, - * and a plan whose `reviewDate` was cleared on a prior tick is not re-minted. + * The surfacing message for one user's due reminders. Deterministic, localized, + * and built only from the user's own note text — the sweep composes, it never + * generates. + */ +export function buildReminderSurfaceMessage( + notes: readonly string[], + locale: string | null | undefined, +): { title: string; body: string } { + const t = getServerTranslator(resolveLocale(locale)).t; + const title = t("coachNudges.reminderSurfacedTitle"); + const lead = + notes.length === 1 + ? t("coachNudges.reminderSurfacedLeadOne") + : t("coachNudges.reminderSurfacedLeadMany", { count: notes.length }); + const body = [lead, ...notes.map((n) => `• ${n}`)].join("\n"); + return { title, body }; +} + +/** + * Run one sweep tick. Idempotent: a reminder already `surfaced` is not + * re-surfaced, and a plan whose `reviewDate` was cleared on a prior tick is not + * re-minted. */ export async function runCoachReminderSweep( prisma: SweepPrisma, @@ -59,23 +118,10 @@ export async function runCoachReminderSweep( errored: 0, }; - // ── 1. flip overdue active date-reminders → due ────────────── - const due = await prisma.coachReminder.updateMany({ - where: { - deletedAt: null, - status: "active", - triggerKind: "date", - dueAt: { not: null, lte: now }, - }, - data: { - status: "due", - lastSurfacedAt: now, - surfaceCount: { increment: 1 }, - }, - }); - summary.remindersDue = due.count; - - // ── 2. mint plan-review reminders for passed reviewDates ────── + // ── 1. mint plan-review reminders for passed reviewDates ────── + // + // Minted `active` with `dueAt: now` so the surfacing pass below picks them + // up in this same tick. One path reaches the conversation, not two. const plans = await prisma.coachPlan.findMany({ where: { deletedAt: null, @@ -120,10 +166,8 @@ export async function runCoachReminderSweep( relatedPlanId: plan.id, triggerKind: "date", dueAt: now, - status: "due", + status: "active", source: "extractor", - lastSurfacedAt: now, - surfaceCount: 1, }, }), prisma.coachPlan.update({ @@ -137,5 +181,94 @@ export async function runCoachReminderSweep( } } + // ── 2. surface everything whose moment has passed ───────────── + // + // `due` is included alongside `active` deliberately: rows an older build + // flipped to `due` never reached a conversation, and they are exactly the + // ones showing a badge with nothing behind it. Picking them up here converts + // a stuck indicator into the message it always promised. + const overdue = await prisma.coachReminder.findMany({ + where: { + deletedAt: null, + status: { in: ["active", "due"] }, + triggerKind: "date", + dueAt: { not: null, lte: now }, + }, + take: SURFACE_BATCH, + orderBy: { dueAt: "asc" }, + select: { id: true, userId: true, noteEncrypted: true }, + }); + + const byUser = new Map(); + for (const row of overdue) { + const bucket = byUser.get(row.userId); + if (bucket) bucket.push(row); + else byUser.set(row.userId, [row]); + } + + for (const [userId, rows] of byUser) { + try { + // Decrypt fault-isolated per row: one undecryptable note must not cost + // the user the reminders that sit beside it. + const usable: { id: string; note: string }[] = []; + for (const row of rows) { + try { + usable.push({ + id: row.id, + note: decryptFromBytes(row.noteEncrypted), + }); + } catch { + summary.errored += 1; + } + } + if (usable.length === 0) continue; + + const batch = usable.slice(0, MAX_NOTES_PER_MESSAGE); + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const { title, body } = buildReminderSurfaceMessage( + batch.map((r) => r.note), + user?.locale, + ); + + // Conversation + message + status flip in ONE transaction. A partial + // write is the failure this whole change exists to remove: a status + // without a message is a badge pointing at nothing, and a message + // without a status change re-surfaces the same note every night. + await prisma.$transaction(async (tx) => { + const conversation = await tx.coachConversation.create({ + data: { userId, title: title.slice(0, 80) }, + }); + await tx.coachMessage.create({ + data: { + conversationId: conversation.id, + role: "assistant", + encryptedContent: encryptToBytes(body), + metricSourceJson: null, + // Same tag the proactive nudge uses — this IS a proactive message, + // and the nudge cron's frequency gate reads the tag back. + providerType: "nudge", + promptVersion: null, + }, + }); + await tx.coachReminder.updateMany({ + where: { id: { in: batch.map((r) => r.id) } }, + data: { + status: "surfaced", + lastSurfacedAt: now, + surfaceCount: { increment: 1 }, + }, + }); + }); + summary.remindersDue += batch.length; + } catch { + // Leave the rows where they were: the next tick retries, and until then + // no badge claims a message that was never written. + summary.errored += 1; + } + } + return summary; } diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 9ebca0bc2..3771f9ab2 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -822,10 +822,12 @@ export async function registerStatusQueues( }, ); - // v1.22 (M4) — daily Coach-reminder sweep. Single-flight; flipping an overdue - // reminder to `due` and minting plan-review reminders are both idempotent - // across re-fires (an already-due reminder is not re-flipped; a plan whose - // reviewDate was cleared is not re-minted). No provider call, no push. + // v1.22 (M4) — daily Coach-reminder sweep. Single-flight; surfacing an + // overdue reminder into the user's Coach thread and minting plan-review + // reminders are both idempotent across re-fires (an already-surfaced + // reminder is not re-written; a plan whose reviewDate was cleared is not + // re-minted). No provider call, no push — the message body is a + // deterministic localized template around the user's own note. await createAndWork( boss, COACH_REMINDER_SWEEP_QUEUE, diff --git a/tests/integration/coach-reminder-surfacing.test.ts b/tests/integration/coach-reminder-surfacing.test.ts new file mode 100644 index 000000000..cbd927b0a --- /dev/null +++ b/tests/integration/coach-reminder-surfacing.test.ts @@ -0,0 +1,239 @@ +/** + * The Coach badge and the Coach conversation, over the real assembly. + * + * The report: the FAB shows its unread dot, you open the Coach, the + * conversation is empty, and the dot is still there afterwards. Two separate + * paths were feeding one indicator — a "is any reminder due?" query lit the + * dot, while the nightly sweep that made the reminder due wrote nothing into a + * conversation and had no way to be cleared by opening one. + * + * The unit suite can prove the sweep CALLS the right prisma methods; it cannot + * prove a row exists, that the badge derives from that row, or that opening + * the Coach clears it. Those three claims are the defect, so they are asserted + * here against Postgres, through the same functions the cron and the FAB use: + * `runCoachReminderSweep` writes, `readCoachNudgeStatus` reads, and the seen + * stamp in between is the one `POST /api/insights/coach/seen` writes. + * + * The note text is round-tripped through the real AES codec rather than a + * mock, so an encoding mistake between the sweep and the conversation reader + * fails here instead of shipping as an unreadable reminder. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { runCoachReminderSweep } from "@/lib/jobs/coach-reminder-sweep"; +import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; + +const NOW = new Date("2026-08-07T05:20:00.000Z"); +const YESTERDAY = new Date("2026-08-06T09:00:00.000Z"); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +async function seedUser(username: string, locale = "en") { + return getPrismaClient().user.create({ + data: { + username, + email: `${username}@example.test`, + timezone: "UTC", + locale, + }, + }); +} + +describe("a due Coach reminder reaches the conversation the badge points at", () => { + it("writes the note as an assistant message, and that message is what makes the badge unread", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("reminder-surfacing"); + + await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("ask me about my sleep again"), + triggerKind: "date", + dueAt: YESTERDAY, + status: "active", + source: "user", + }, + }); + + // Before the sweep there is nothing to be unread ABOUT. + const before = await readCoachNudgeStatus(user.id); + expect(before.unread).toBe(false); + expect(before.conversationId).toBeNull(); + + const summary = await runCoachReminderSweep(prisma, NOW); + expect(summary.remindersDue).toBe(1); + expect(summary.errored).toBe(0); + + // BOTH ENDS. The row exists… + const messages = await prisma.coachMessage.findMany({ + where: { conversation: { userId: user.id } }, + }); + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe("assistant"); + expect(decryptFromBytes(messages[0].encryptedContent)).toContain( + "ask me about my sleep again", + ); + + // …and the badge is derived from THAT row, not from a parallel query. + const after = await readCoachNudgeStatus(user.id); + expect(after.unread).toBe(true); + expect(after.conversationId).toBe(messages[0].conversationId); + + // The reminder is marked surfaced, so it stops being pending work. + const reminder = await prisma.coachReminder.findFirstOrThrow({ + where: { userId: user.id }, + }); + expect(reminder.status).toBe("surfaced"); + expect(reminder.surfaceCount).toBe(1); + }); + + it("opening the Coach clears the badge", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("reminder-seen"); + await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("check the blood pressure trend"), + triggerKind: "date", + dueAt: YESTERDAY, + status: "active", + source: "user", + }, + }); + await runCoachReminderSweep(prisma, NOW); + expect((await readCoachNudgeStatus(user.id)).unread).toBe(true); + + // What `POST /api/insights/coach/seen` writes when the Coach is opened. + // Anchored on the MESSAGE's own `createdAt`: the sweep takes its `now` for + // the due-date arithmetic, but the row is stamped by the database, and the + // route stamps the seen time from the same clock the row came from. + const written = await prisma.coachMessage.findFirstOrThrow({ + where: { conversation: { userId: user.id } }, + }); + await prisma.user.update({ + where: { id: user.id }, + data: { coachLastSeenAt: new Date(written.createdAt.getTime() + 1_000) }, + }); + + expect((await readCoachNudgeStatus(user.id)).unread).toBe(false); + }); + + it("does not surface the same reminder twice", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("reminder-idempotent"); + await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("weekly review"), + triggerKind: "date", + dueAt: YESTERDAY, + status: "active", + source: "user", + }, + }); + + await runCoachReminderSweep(prisma, NOW); + const second = await runCoachReminderSweep( + prisma, + new Date(NOW.getTime() + 86_400_000), + ); + + expect(second.remindersDue).toBe(0); + expect( + await prisma.coachMessage.count({ + where: { conversation: { userId: user.id } }, + }), + ).toBe(1); + }); + + it("takes a reminder an older build left stuck on `due` and gives it the message it promised", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("reminder-legacy"); + await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("the note nobody ever saw"), + triggerKind: "date", + dueAt: new Date("2026-05-01T09:00:00.000Z"), + status: "due", + lastSurfacedAt: new Date("2026-05-01T05:20:00.000Z"), + surfaceCount: 1, + source: "user", + }, + }); + + // This is the reported state exactly: something the UI treated as + // outstanding, with an empty conversation behind it. + expect( + await prisma.coachMessage.count({ + where: { conversation: { userId: user.id } }, + }), + ).toBe(0); + + await runCoachReminderSweep(prisma, NOW); + + const messages = await prisma.coachMessage.findMany({ + where: { conversation: { userId: user.id } }, + }); + expect(messages).toHaveLength(1); + expect(decryptFromBytes(messages[0].encryptedContent)).toContain( + "the note nobody ever saw", + ); + expect((await readCoachNudgeStatus(user.id)).unread).toBe(true); + }); + + it("keeps a failed write from leaving a badge with nothing behind it", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("reminder-partial"); + const reminder = await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes("intact note"), + triggerKind: "date", + dueAt: YESTERDAY, + status: "active", + source: "user", + }, + }); + + // Fail the transaction the way a real outage would — inside the write, so + // the conversation, the message and the status flip are all in flight. + const failing = new Proxy(prisma, { + get(target, prop, receiver) { + if (prop === "$transaction") { + return (arg: unknown) => { + if (typeof arg === "function") { + return Promise.reject(new Error("connection reset")); + } + const passthrough = Reflect.get(target, prop, receiver) as ( + ops: unknown, + ) => Promise; + return passthrough.call(target, arg); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + + const summary = await runCoachReminderSweep(failing as typeof prisma, NOW); + expect(summary.remindersDue).toBe(0); + expect(summary.errored).toBe(1); + + // Nothing written, nothing marked — and the badge stays dark, which is the + // honest state: there is no message to read. + expect( + await prisma.coachMessage.count({ + where: { conversation: { userId: user.id } }, + }), + ).toBe(0); + const untouched = await prisma.coachReminder.findFirstOrThrow({ + where: { id: reminder.id }, + }); + expect(untouched.status).toBe("active"); + expect((await readCoachNudgeStatus(user.id)).unread).toBe(false); + }); +});