merge: Coach v1.18.6 nudge and conversation continuity

Add the Coach proactive nudge-status surface and last-seen tracking
(migration renumbered to 0176) plus the seen endpoint.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 22:04:31 +02:00
commit 6614e0646f
15 files changed
+455 -63

No files matched your search

@@ -0,0 +1,15 @@
-- v1.18.6 (CCH-03) — Coach "last seen" stamp for the FAB unread dot.
--
-- A single nullable timestamp on `users`. The proactive Coach nudge now
-- lands as an ASSISTANT message in the conversation rail (CCH-02); the
-- Coach FAB shows a discreet unread dot when the newest Coach message is
-- newer than this stamp, and clears it once the user opens the Coach
-- (the open writes the current time here via POST
-- /api/insights/coach/seen).
--
-- NULL backfills every existing row to "never opened" — harmless: the
-- dot only paints when an actual newer Coach message exists, so a user
-- with no proactive nudge yet sees nothing. No data migration needed.
ALTER TABLE "users"
ADD COLUMN "coach_last_seen_at" TIMESTAMP(3);
+12
View File
@@ -464,6 +464,18 @@ model User {
// `GET /api/auth/me` as a `modules: { <key>: boolean }` map.
modulePreferencesJson Json? @map("module_preferences_json")
// v1.18.6 (CCH-03) — "Coach last seen" stamp powering the unread dot
// on the Coach FAB. The proactive nudge now lands as an ASSISTANT
// message in the conversation rail (CCH-02); the FAB shows a discreet
// unread dot when the newest Coach message is newer than this stamp,
// and clears it when the user opens the Coach (drawer or page, which
// writes the current time here via `POST /api/insights/coach/seen`).
// Null = never opened the Coach, so any existing message reads as
// unread once. Server-authoritative so the signal is consistent across
// web + iOS; the FAB keeps a local mirror only as an instant-paint
// optimisation.
coachLastSeenAt DateTime? @map("coach_last_seen_at")
// v1.5.5 — self-hosted avatar storage.
//
// Replaces the Gravatar third-party leak (the old `getGravatarUrl`
@@ -1,18 +1,26 @@
/**
* GET /api/insights/coach/nudge-status — has the proactive Coach nudge
* something the caller has not engaged with yet?
* GET /api/insights/coach/nudge-status — is there an unread Coach
* message the caller has not opened yet?
*
* v1.16.1 — feeds the floating Coach bubble: the bubble renders only
* while an unseen Coach-initiated nudge exists, instead of being a
* permanent FAB. "Unseen" is derived, not stored: the latest successful
* `COACH_NUDGE` dispatch (the `push_attempts` ledger the nudge cron
* already anchors its frequency cap on) counts as read once the user
* has sent a Coach message after it. The client additionally remembers
* a local "seen" stamp so opening the chat without sending also clears
* the bubble on that device.
* v1.18.6 (CCH-03) — the proactive Coach nudge now lands as a real
* ASSISTANT message in the conversation rail (CCH-02), not as a
* notification-only dispatch. The unread signal moved with it: instead
* of anchoring on the `push_attempts` ledger (which is empty when no
* push channel is configured, so the nudge was invisible), the status
* compares the newest Coach assistant message against the
* server-authoritative `User.coachLastSeenAt` stamp.
*
* No new table, no migration: both timestamps come from existing rows,
* scoped to the caller.
* `unread` is true when an assistant message exists that is newer than
* the last time the user opened the Coach (drawer or page, which writes
* `coachLastSeenAt` via `POST /api/insights/coach/seen`). A user who has
* never opened the Coach reads any existing nudge as unread exactly
* once. Server-authoritative so the signal is consistent across web +
* iOS; the FAB keeps a local mirror only as an instant-paint
* optimisation.
*
* `nudgedAt` carries the newest assistant-message timestamp so the FAB's
* local seen-stamp keys on a stable value (kept for the existing client
* contract).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -23,23 +31,26 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
const [lastNudge, lastUserMessage] = await Promise.all([
prisma.pushAttempt.findFirst({
where: { userId: user.id, eventType: "COACH_NUDGE", result: "ok" },
// The newest assistant message across the caller's conversations — the
// proactive nudge writes one, and so does every normal Coach reply. We
// intentionally key on assistant (not user) messages: the unread dot
// means "the Coach said something", and the open-stamp clears it.
const [lastAssistant, seen] = await Promise.all([
prisma.coachMessage.findFirst({
where: { role: "assistant", conversation: { userId: user.id } },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
prisma.coachMessage.findFirst({
where: { role: "user", conversation: { userId: user.id } },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
prisma.user.findUnique({
where: { id: user.id },
select: { coachLastSeenAt: true },
}),
]);
const nudgedAt = lastNudge?.createdAt ?? null;
const nudgedAt = lastAssistant?.createdAt ?? null;
const lastSeenAt = seen?.coachLastSeenAt ?? null;
const unread =
nudgedAt !== null &&
(lastUserMessage === null || lastUserMessage.createdAt < nudgedAt);
nudgedAt !== null && (lastSeenAt === null || lastSeenAt < nudgedAt);
return apiSuccess({
nudgedAt: nudgedAt ? nudgedAt.toISOString() : null,
+33
View File
@@ -0,0 +1,33 @@
/**
* POST /api/insights/coach/seen — mark every Coach message as read.
*
* v1.18.6 (CCH-03) — opening the Coach (drawer or full page) clears the
* unread dot on the FAB. The open stamps `User.coachLastSeenAt = now`;
* the FAB's `GET /api/insights/coach/nudge-status` then reads no
* assistant message newer than the stamp and drops the dot. Server-
* authoritative so the cleared state follows the user across web + iOS,
* not just the device that opened the Coach.
*
* No request body — the timestamp is server-minted from `now()` so a
* client can never backdate the stamp to suppress a future nudge.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
const seenAt = new Date();
await prisma.user.update({
where: { id: user.id },
data: { coachLastSeenAt: seenAt },
});
return apiSuccess({ seenAt: seenAt.toISOString() });
});
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
+30 -9
View File
@@ -63,15 +63,35 @@ export default function CoachPage() {
<div
data-slot="coach-page"
data-tour-id="coach-hero"
// v1.18.1 (W-COACH-UI C1/C3) — the card was capped at
// `100dvh-13rem`, leaving a band of wasted space below it on a
// standalone route that no longer carries the Insights tab strip.
// Claim the page height down to a comfortable bottom gutter so the
// thread + rail fill the viewport, and lift the card with a soft
// shadow so it reads as one contained surface rather than a thin
// outline.
className="bg-card flex h-[calc(100dvh-8rem)] min-h-[32rem] flex-col overflow-hidden rounded-2xl border shadow-sm"
// v1.18.6 (CCH-01) — Marc: the Coach read "cut off" vs other pages
// and wanted more width / a focused Claude-like surface. The page
// sits inside `<AuthShell>`'s shared `mx-auto max-w-screen-xl px-4
// pt-6 pb-20 md:px-6` content frame, which boxed the chat into the
// same narrow column every dashboard card uses and left a tall dead
// band below the card (the `pb-20` FAB gutter, pointless here since
// the FAB hides on `/coach`). Break out of that frame: negative
// margins cancel the container's horizontal + top/bottom padding so
// the chat claims the full content width and the viewport height,
// then a light inner padding keeps it off the window edge. The card
// itself drops its rounded border on small screens (edge-to-edge,
// app-like) and keeps the contained card look from `sm+`.
//
// v1.18.1 (W-COACH-UI C1/C3) — the card formerly capped at
// `100dvh-13rem`, leaving wasted space below it; we now fill the
// reclaimed height.
className={
// Cancel the AuthShell container's padding (`-mt-6` top,
// `-mb-20` bottom, `-mx-4 md:-mx-6` sides) so the surface is
// full-bleed, then re-pad lightly. The height is the full
// viewport minus the top bar (`4rem`) and the small gutters.
"-mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col md:-mx-6 " +
"px-2 pt-2 pb-3 sm:px-4 sm:pt-4 sm:pb-4"
}
>
<div
data-slot="coach-page-card"
className="bg-card flex min-h-0 flex-1 flex-col overflow-hidden rounded-none border-y shadow-sm sm:rounded-2xl sm:border"
>
<CoachConversation
surface="page"
autoFocusComposer
@@ -95,7 +115,8 @@ export default function CoachPage() {
</Button>
</>
}
/>
/>
</div>
</div>
);
}
@@ -78,7 +78,18 @@ export function CoachDrawerBody({
<h2 className="border-border/70 text-muted-foreground flex h-14 shrink-0 items-center border-b px-4 text-xs font-medium tracking-wide uppercase">
{t("insights.coach.historyTitle")}
</h2>
<div className="min-h-0 flex-1 overflow-y-auto">{historyRail}</div>
{/* v1.18.6 (CCH-01) — the wrapping `overflow-y-auto` div here
was the broken left scrollbar: `<HistoryRail>` is itself a
`flex h-full flex-col` with its OWN `overflow-y-auto` list,
so this nested it inside a second scroll container. The
outer container scrolled the whole rail (search field
included) against an `h-full` child whose height resolved to
its content, leaving the inner list's `flex-1 min-h-0`
without a bounded track — the scrollbar rendered against an
indeterminate height and read as half-broken. Drop the outer
scroll: the rail keeps a fixed search header and its single
internal list-scroll is the only scroll region. */}
<div className="flex min-h-0 flex-1 flex-col">{historyRail}</div>
</aside>
)}
@@ -363,6 +363,13 @@ export function MessageThread({
data-slot="coach-message-thread"
className={cn(
"flex h-full flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-6",
// v1.18.6 (CCH-01) — on the now full-width page surface a thread
// stretched edge-to-edge sprawled the prose to an unreadable
// measure. Centre the content on a comfortable max width (Claude-
// like) via an `mx-auto` inner gutter; the scrollbar still rides
// the surface edge. The drawer surface is already narrow, so the
// cap only bites on the wide page surface.
"[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-3xl",
"scroll-smooth",
)}
>
+70 -19
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -13,18 +13,26 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
/**
* v1.16.8 — the Coach FAB is a permanent launcher again, now on EVERY
* authenticated page (mounted once in `<AuthShell>`, no longer scoped
* to `/insights/**`). The v1.16.1 nudge-only bubble folded into it: the
* FAB always renders, and an unseen proactive `COACH_NUDGE` (surfaced
* FAB always renders, and an unread proactive Coach message (surfaced
* by `/api/insights/coach/nudge-status`) paints a small unread dot on
* its corner instead of toggling the whole button. The dot clears once
* the nudge counts as read — the user sent a Coach message after the
* nudge (server-derived), or opened the Coach on this device (local
* seen stamp keyed by the nudge timestamp).
* its corner instead of toggling the whole button.
*
* v1.18.6 (CCH-02/CCH-03) — the proactive nudge now lands as a real
* ASSISTANT message in the conversation rail, not a notification-only
* dispatch, so the unread signal moved onto the server-authoritative
* `User.coachLastSeenAt` stamp: `status.unread` is true while a Coach
* message is newer than the last time the user opened the Coach. Opening
* the Coach (this FAB, or the `/coach` page) fires
* `POST /api/insights/coach/seen`, which stamps the server and clears
* the dot everywhere (web + iOS). A local "seen" stamp is kept only as
* an instant-paint optimisation so the dot disappears the moment the
* user taps, before the mutation round-trips.
*
* The FAB hides inside the Coach view itself (`/coach`) — a
* launcher pointing at the page the user is already on is noise.
@@ -44,9 +52,12 @@ export interface CoachNudgeStatus {
}
/**
* Pure unread derivation — server signal AND-ed with the local
* seen-stamp. Exported so the unit test pins the contract without a
* QueryClient round-trip.
* Pure unread derivation — the server signal is authoritative
* (`status.unread` already compares the newest Coach message against the
* persisted `coachLastSeenAt`). The local seen-stamp only suppresses the
* dot for an instant after the user taps, before the mark-seen mutation
* + status refetch land. Exported so the unit test pins the contract
* without a QueryClient round-trip.
*/
export function isNudgeUnread(
status: CoachNudgeStatus | undefined,
@@ -77,6 +88,7 @@ export function LayoutCoachFab() {
const coachAvailable = !!launch && flags.coach && !disableCoach;
const onCoachPage = pathname?.startsWith("/coach") ?? false;
const queryClient = useQueryClient();
const { data: status } = useQuery({
queryKey: queryKeys.coachNudgeStatus(),
@@ -87,6 +99,22 @@ export function LayoutCoachFab() {
staleTime: 5 * 60 * 1000,
});
// v1.18.6 (CCH-03) — stamp `coachLastSeenAt` server-side when the user
// opens the Coach so the dot clears across every device. Fire-and-
// forget from the UI's perspective (the local seen-stamp already
// suppressed the dot); on success the status query refetches so the
// server-authoritative `unread` re-resolves to false.
const markSeen = useMutation({
mutationKey: queryKeys.coachMarkSeen(),
mutationFn: async () =>
apiPost<{ seenAt: string }>("/api/insights/coach/seen", {}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.coachNudgeStatus(),
});
},
});
const nudgedAt = status?.nudgedAt ?? null;
const unread = isNudgeUnread(status, seenStamp);
@@ -107,16 +135,30 @@ export function LayoutCoachFab() {
prevUnreadRef.current = unread;
}, [unread, t]);
// Visiting the Coach page itself counts as reading the nudge on this
// device — persist the stamp so the dot stays gone after leaving.
// Visiting the Coach page itself counts as reading the nudge — persist
// the local instant-paint stamp AND stamp the server so the dot stays
// gone after leaving, on every device. Fired once per page entry: the
// ref guards against the effect re-running on unrelated re-renders.
const seenStampedRef = useRef(false);
useEffect(() => {
if (!onCoachPage || nudgedAt === null) return;
try {
window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
} catch {
// Storage unavailable (private mode) — the server-side read
// signal still clears the dot once the user sends a message.
if (!onCoachPage) {
seenStampedRef.current = false;
return;
}
if (seenStampedRef.current) return;
seenStampedRef.current = true;
if (nudgedAt !== null) {
try {
window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
} catch {
// Storage unavailable (private mode) — the server stamp below
// still clears the dot once it round-trips.
}
}
markSeen.mutate();
// `markSeen` is a stable mutation object; excluding it keeps the
// effect from re-firing on its internal state changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onCoachPage, nudgedAt]);
if (!coachAvailable) return null;
@@ -136,6 +178,10 @@ export function LayoutCoachFab() {
// Best effort — see above.
}
}
// v1.18.6 (CCH-03) — stamp the server so the dot clears on every
// device, not just this one. The local stamp above already hid the
// dot for an instant paint; the mutation makes it durable.
if (unread) markSeen.mutate();
// v1.16.11 — open the side drawer in place (the launch context the
// layout mount consumes) instead of navigating to the chat page;
// the conversation arrives without losing the page underneath.
@@ -194,10 +240,15 @@ export function LayoutCoachFab() {
>
<Sparkles className="size-6" aria-hidden="true" />
{unread ? (
// v1.18.6 (CCH-03) — discreet "the Coach said something" dot.
// Deliberately NOT an alarming red (the medication-card rule:
// status via calm signal, never an alarm tint): a small cyan
// dot ringed against the FAB background reads as informative,
// not urgent.
<span
data-slot="coach-fab-unread"
aria-hidden="true"
className="border-background bg-dracula-red absolute top-0.5 right-0.5 size-3 rounded-full border-2"
className="border-background bg-dracula-cyan absolute top-0.5 right-0.5 size-3 rounded-full border-2"
/>
) : null}
</Button>
+16 -10
View File
@@ -13,7 +13,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { TargetAdjustButton } from "@/components/insights/target-adjust-button";
import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
@@ -108,13 +107,12 @@ export interface SubPageShellProps {
/**
* v1.8.6 — opt-in Coach launch in the page header.
*
* When true, the shell mounts an icon-only `<CoachLaunchButton>` at
* the top-right of the header, vertically aligned with the heading, so
* "Coach fragen" reads as a header action rather than a foot-of-page
* button. Every routed category page passes it; the mother page and
* empty-state branches omit it. The button self-gates on the Coach
* feature flag + per-user opt-out, so a disabled-Coach tenant shows
* nothing.
* v1.18.6 (CCH-04) — RETIRED as a rendered control. With the Coach FAB
* anchored bottom-right on every authenticated page, the per-metric
* Coach icon was a redundant second entry point, so the shell no longer
* paints anything for it. The prop stays on the signature (every routed
* category page still passes it) to avoid a churny rename across ~14
* call sites; it is intentionally inert.
*/
coachLaunch?: boolean;
/**
@@ -139,7 +137,10 @@ export function SubPageShell({
focusOnMount = false,
statStrip,
diversityNudge,
coachLaunch = false,
// v1.18.6 (CCH-04) — `coachLaunch` is intentionally not destructured:
// the prop is still accepted on the type (call sites pass it) but the
// shell no longer renders a Coach control. The FAB is the single Coach
// entry point app-wide.
showAllValuesType,
children,
}: SubPageShellProps) {
@@ -270,7 +271,12 @@ export function SubPageShell({
</Button>
) : null}
<TargetAdjustButton />
{coachLaunch ? <CoachLaunchButton variant="icon" /> : null}
{/* v1.18.6 (CCH-04) — the per-metric Coach launch icon is
gone. With the Coach FAB anchored bottom-right on every
authenticated page, a second Coach affordance in each
metric header was redundant. The `coachLaunch` prop is
still accepted (every metric page passes it) but no longer
paints a control — the FAB is the single Coach entry. */}
{/* v1.16.8 — the v1.16.4 customise cog is gone: the sticky
tab strip above already carries the same control on every
insights surface, so the header copy was a duplicate. */}
+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,
+2
View File
@@ -11,4 +11,6 @@ export const coachKeys = {
coachAboutMeQuestions: () => ["coach-about-me", "questions"] as const,
/** v1.16.1 — unseen proactive-nudge state for the floating Coach bubble. */
coachNudgeStatus: () => ["coach-nudge-status"] as const,
/** v1.18.6 (CCH-03) — mark-Coach-seen mutation (clears the FAB unread dot). */
coachMarkSeen: () => ["coach-mark-seen"] as const,
};