fix(coach): surface the proactive nudge in the conversation rail

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 21:19:17 +02:00
1 parent ae3091df9e
commit f453f8a758
5 files changed
+225 -2

No files matched your search

+72 -2
View File
@@ -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<unknown>) => 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:",
);
});
});
+54
View File
@@ -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
@@ -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);
});
});
+35
View File
@@ -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<CoachNudgeSummary> {
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,
+1
View File
@@ -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,