From f453f8a758cb204d2873e9a603c831956a3cea9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 21:19:17 +0200 Subject: [PATCH] fix(coach): surface the proactive nudge in the conversation rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proactive Coach nudge dispatched a notification only — it never wrote a CoachMessage, so it was invisible under the conversation list and entirely silent for a user with no push channel configured. Record the nudge as a real conversation before dispatching: create a conversation and write the nudge body as the initial assistant message (encrypted at rest like every other Coach reply), then fire the existing notification + /coach deep link unchanged. A persistence failure is caught locally so it can never swallow the notification, and the tick summary tracks a separate persisted count alongside dispatched. --- .../ai/coach/__tests__/persistence.test.ts | 74 ++++++++++++++++++- src/lib/ai/coach/persistence.ts | 54 ++++++++++++++ src/lib/jobs/__tests__/coach-nudge.test.ts | 63 ++++++++++++++++ src/lib/jobs/coach-nudge.ts | 35 +++++++++ src/lib/jobs/reminder/register-status.ts | 1 + 5 files changed, 225 insertions(+), 2 deletions(-) diff --git a/src/lib/ai/coach/__tests__/persistence.test.ts b/src/lib/ai/coach/__tests__/persistence.test.ts index f506f5865..dbe6a1afc 100644 --- a/src/lib/ai/coach/__tests__/persistence.test.ts +++ b/src/lib/ai/coach/__tests__/persistence.test.ts @@ -1,6 +1,28 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { summariseTitle } from "../persistence"; +// Mock the db + crypto boundaries so `recordProactiveNudge` can be +// exercised without a real Postgres / encryption key. The transaction +// runner simply invokes the callback with the stubbed `tx`. +const txCreate = { + coachConversation: { create: vi.fn() }, + coachMessage: { create: vi.fn() }, +}; +vi.mock("@/lib/db", () => ({ + prisma: { + $transaction: vi.fn( + async (cb: (tx: typeof txCreate) => Promise) => cb(txCreate), + ), + }, +})); +vi.mock("../bytes-codec", () => ({ + encryptToBytes: vi.fn((text: string) => + new TextEncoder().encode(`enc:${text}`), + ), + decryptFromBytes: vi.fn(), +})); + +import { recordProactiveNudge, summariseTitle } from "../persistence"; +import { encryptToBytes } from "../bytes-codec"; describe("summariseTitle", () => { it("returns the input unchanged when below 80 chars", () => { @@ -41,3 +63,51 @@ describe("summariseTitle", () => { expect(summariseTitle(" ")).toBe("New conversation"); }); }); + +describe("recordProactiveNudge", () => { + it("creates a conversation + an encrypted ASSISTANT message in one transaction", async () => { + const now = new Date("2026-06-18T05:15:00.000Z"); + txCreate.coachConversation.create.mockResolvedValue({ + id: "conv_1", + userId: "user_1", + title: "Time to weigh in", + createdAt: now, + updatedAt: now, + }); + txCreate.coachMessage.create.mockResolvedValue({ + id: "msg_1", + createdAt: now, + }); + + const out = await recordProactiveNudge({ + userId: "user_1", + title: "Time to weigh in", + body: "It has been a week — a quick weigh-in keeps the trend honest.", + }); + + expect(out).toEqual({ + conversationId: "conv_1", + messageId: "msg_1", + createdAt: now, + }); + + // Conversation owned by the user, title summarised. + expect(txCreate.coachConversation.create).toHaveBeenCalledWith({ + data: { userId: "user_1", title: "Time to weigh in" }, + }); + + // The body is encrypted at rest (Bytes), role is assistant, and the + // message hangs off the new conversation. No raw plaintext column. + const msgArg = txCreate.coachMessage.create.mock.calls[0][0]; + expect(msgArg.data.conversationId).toBe("conv_1"); + expect(msgArg.data.role).toBe("assistant"); + expect(msgArg.data.providerType).toBe("nudge"); + expect(encryptToBytes).toHaveBeenCalledWith( + "It has been a week — a quick weigh-in keeps the trend honest.", + ); + // The stored column is the ciphertext, never the plaintext body. + expect(new TextDecoder().decode(msgArg.data.encryptedContent)).toContain( + "enc:", + ); + }); +}); diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 9d0c0440c..7e318ee93 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -188,6 +188,60 @@ export async function appendMessage( }; } +export interface RecordProactiveNudgeParams { + userId: string; + /** Conversation title (the nudge headline) — summarised to ≤80 chars. */ + title: string; + /** The nudge body, persisted as the initial ASSISTANT message. */ + body: string; + /** Trigger tag for the wide-event annotation on the caller side. */ + providerType?: string | null; +} + +/** + * v1.18.6 (CCH-02) — record a proactive Coach nudge as a real + * conversation so it shows up in the conversation rail regardless of + * which push channel (if any) the user configured. The proactive cron + * used to dispatch a notification ONLY; with no push channel the nudge + * was entirely invisible. + * + * Creates a fresh conversation and writes the nudge as the initial + * ASSISTANT message in one transaction so a partial write never leaves + * an empty thread in the rail. The body crosses the same + * `encryptToBytes` boundary as every other Coach message, so the nudge + * text is encrypted at rest like a normal reply. Returns the new + * conversation + message ids for the caller's annotation. + */ +export async function recordProactiveNudge( + params: RecordProactiveNudgeParams, +): Promise<{ conversationId: string; messageId: string; createdAt: Date }> { + return prisma.$transaction(async (tx) => { + const conversation = await tx.coachConversation.create({ + data: { + userId: params.userId, + title: summariseTitle(params.title), + }, + }); + const message = await tx.coachMessage.create({ + data: { + conversationId: conversation.id, + role: "assistant", + encryptedContent: encryptToBytes(params.body), + metricSourceJson: null, + providerType: params.providerType ?? "nudge", + promptVersion: null, + }, + }); + // The conversation's `createdAt` == `updatedAt` on creation, so the + // rail already orders it to the top; no extra `update` needed. + return { + conversationId: conversation.id, + messageId: message.id, + createdAt: message.createdAt, + }; + }); +} + /** * Fetch one conversation + every message, decrypting each body on * read. Returns null when the conversation does not exist OR when the diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts index a417e359b..dfa3a5a23 100644 --- a/src/lib/jobs/__tests__/coach-nudge.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge.test.ts @@ -714,4 +714,67 @@ describe("runCoachNudgeTick — gates and prefs", () => { "You wanted to keep an eye on: morning blood pressure.", ); }); + + it("persists the nudge as a conversation before dispatching (CCH-02)", async () => { + const order: string[] = []; + const recordNudge = vi.fn( + async (args: { userId: string; title: string; body: string }) => { + void args; + order.push("persist"); + return { + conversationId: "conv_1", + messageId: "msg_1", + createdAt: now, + }; + }, + ); + const dispatch = vi.fn(async () => { + order.push("dispatch"); + return { dispatched: true }; + }); + const prisma = prismaMock({ + users: [userRow(null)], + intakeRows: failingIntakes, + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: dispatch as never, recordNudge: recordNudge as never }, + ); + expect(summary.dispatched).toBe(1); + expect(summary.persisted).toBe(1); + // The conversation must be written BEFORE the notification fires so + // the rail always carries the nudge even if the push channel is gone. + expect(order).toEqual(["persist", "dispatch"]); + const persistArg = recordNudge.mock.calls[0]?.[0] as unknown as { + userId: string; + title: string; + body: string; + }; + expect(persistArg.userId).toBe("user-1"); + // The persisted body is the same compliance nudge copy the + // notification carries — it lands as the initial assistant message. + expect(persistArg.body).toContain("Your intakes were patchy this week"); + expect(persistArg.title.length).toBeGreaterThan(0); + }); + + it("still dispatches + counts the nudge when persistence throws", async () => { + const recordNudge = vi.fn(async () => { + throw new Error("db down"); + }); + const dispatch = vi.fn(async () => ({ dispatched: true })); + const prisma = prismaMock({ + users: [userRow(null)], + intakeRows: failingIntakes, + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: dispatch as never, recordNudge: recordNudge as never }, + ); + // A persistence failure must not swallow the notification. + expect(dispatch).toHaveBeenCalledTimes(1); + expect(summary.dispatched).toBe(1); + expect(summary.persisted).toBe(0); + }); }); diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 8c5ecb03f..d1fe2724e 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -73,6 +73,7 @@ import { import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { resolveCoachNudgePrefs } from "@/lib/validations/notification-prefs"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { recordProactiveNudge } from "@/lib/ai/coach/persistence"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; @@ -167,6 +168,13 @@ export const COACH_NUDGE_TRIGGER_GROUPS: Record< export interface CoachNudgeSummary { candidatesScanned: number; dispatched: number; + /** + * v1.18.6 (CCH-02) — nudges written into the conversation rail as an + * initial ASSISTANT message. Counted independently of `dispatched`: + * the conversation lands even when no push channel is configured, so + * `persisted` can exceed `dispatched`. + */ + persisted: number; skippedOptedOut: number; skippedNoProvider: number; skippedRecentNudge: number; @@ -695,13 +703,16 @@ export async function runCoachNudgeTick( now: Date, options: { dispatch?: typeof dispatchNotification; + recordNudge?: typeof recordProactiveNudge; } = {}, ): Promise { const dispatchImpl = options.dispatch ?? dispatchNotification; + const recordNudgeImpl = options.recordNudge ?? recordProactiveNudge; const summary: CoachNudgeSummary = { candidatesScanned: 0, dispatched: 0, + persisted: 0, skippedOptedOut: 0, skippedNoProvider: 0, skippedRecentNudge: 0, @@ -839,6 +850,30 @@ export async function runCoachNudgeTick( user.locale, coachFocus, ); + + // v1.18.6 (CCH-02) — persist the nudge as a real conversation + // BEFORE dispatching the notification. The proactive nudge used to + // be notification-only, so a user with no push channel never saw + // it anywhere; writing it as the initial ASSISTANT message means it + // always lands in the conversation rail (and clears the FAB unread + // dot once the user opens the Coach). A persistence failure must + // not swallow the notification, so it is caught locally and the + // dispatch still fires. + try { + await recordNudgeImpl({ + userId: user.id, + title, + body, + }); + summary.persisted += 1; + } catch (persistErr: unknown) { + const message = + persistErr instanceof Error ? persistErr.message : String(persistErr); + getEvent()?.addWarning( + `coach-nudge conversation persist failed for ${user.id}: ${message}`, + ); + } + const outcome = await dispatchImpl({ eventType: "COACH_NUDGE", userId: user.id, diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index ee7d1b900..0d5a7ab67 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -306,6 +306,7 @@ export async function registerStatusQueues( result: { candidates_scanned: summary.candidatesScanned, dispatched: summary.dispatched, + persisted: summary.persisted, skipped_opted_out: summary.skippedOptedOut, skipped_no_provider: summary.skippedNoProvider, skipped_recent_nudge: summary.skippedRecentNudge,