mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
ae3091df9e
commit
f453f8a758
5 files changed
+225
-2
No files matched your search
@@ -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:",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user