feat(coach): document chats live in the Coach view and history

Asking the Coach about a document opened a separate, half-finished chat panel
that never appeared in Coach history. The bespoke drawer is gone; a document
conversation now opens the real Coach view, appears in the Coach list with a
document badge and a "Chatting about: <title>" note, and is read back through
the same history rail as any other thread.

The prompt-injection fence is unchanged and stays server-side. A document turn
is still sent through the hardened fenced endpoint (no tools, no health
snapshot, grounded in the document text only); the coach tool route continues
to fetch with documentId null, so a document-scoped conversation can never be
continued through the tool loop even from a hostile client. Only the surface
moved into the Coach; the boundary did not.

The relaxed conversation-list and detail filters drop only the documentId
predicate — userId scoping is unchanged, so a document thread is still visible
only to its owner. CoachConversationDTO gains documentId + documentTitle
(server-derived, title/filename only); no schema change (documentId exists
since 0230). OpenAPI regenerated.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 11:01:59 +02:00
1 parent 57539283c8
commit 5b24ca0fa1
28 files changed
+605 -1112

No files matched your search

@@ -0,0 +1,169 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
/**
* v1.28.51 (Documents R3, Design A) — the Coach rail + detail READERS now
* surface document-scoped threads alongside health threads (the DTO carries
* `documentId` + `documentTitle`). These tests pin two invariants of the
* filter relaxation:
* 1. Relaxing the `documentId` scope must NEVER relax the `userId` scope —
* ownership stays narrowed on every read.
* 2. Omitting the `documentId` option drops the scope filter (a union of both
* health + doc threads); passing it keeps the filter (the document route's
* own isolation is unchanged).
* The DTO now resolves `documentTitle` from the joined document.
*/
const findMany = vi.fn();
const findFirst = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
coachConversation: {
findMany: (...args: unknown[]) => findMany(...args),
findFirst: (...args: unknown[]) => findFirst(...args),
},
},
}));
vi.mock("../bytes-codec", () => ({
encryptToBytes: vi.fn(),
decryptFromBytes: vi.fn(() => "decrypted body"),
}));
import {
fetchConversationWithMessages,
listConversations,
} from "../persistence";
const now = new Date("2026-07-16T10:00:00.000Z");
beforeEach(() => {
findMany.mockReset();
findFirst.mockReset();
});
describe("listConversations — filter relaxation + DTO", () => {
it("omits the documentId filter when no documentId is passed (union of both scopes), but keeps userId narrowed", async () => {
findMany.mockResolvedValue([
{
id: "c1",
title: "Health thread",
createdAt: now,
updatedAt: now,
documentId: null,
_count: { messages: 3 },
document: null,
},
{
id: "c2",
title: "Doc thread",
createdAt: now,
updatedAt: now,
documentId: "doc-1",
_count: { messages: 2 },
document: { title: "Blood panel", filename: "panel.pdf" },
},
]);
const page = await listConversations({ userId: "user-1" });
const where = findMany.mock.calls[0][0].where;
// userId stays narrowed — the relaxation never widens ownership.
expect(where.userId).toBe("user-1");
// No documentId key at all → no scope filter → both scopes returned.
expect("documentId" in where).toBe(false);
// The DTO now carries the scope discriminator + resolved title.
expect(page.conversations[0]).toMatchObject({
id: "c1",
documentId: null,
documentTitle: null,
});
expect(page.conversations[1]).toMatchObject({
id: "c2",
documentId: "doc-1",
documentTitle: "Blood panel",
});
});
it("still applies the documentId filter when the document route passes it (isolation unchanged)", async () => {
findMany.mockResolvedValue([]);
await listConversations({ userId: "user-1", documentId: "doc-9" });
const where = findMany.mock.calls[0][0].where;
expect(where.userId).toBe("user-1");
expect(where.documentId).toBe("doc-9");
});
it("falls back to filename when the document has no title", async () => {
findMany.mockResolvedValue([
{
id: "c3",
title: "t",
createdAt: now,
updatedAt: now,
documentId: "doc-3",
_count: { messages: 1 },
document: { title: null, filename: "scan.pdf" },
},
]);
const page = await listConversations({ userId: "user-1" });
expect(page.conversations[0].documentTitle).toBe("scan.pdf");
});
});
describe("fetchConversationWithMessages — filter relaxation + DTO", () => {
it("reads across both scopes (no documentId filter) while keeping userId narrowed", async () => {
findFirst.mockResolvedValue({
id: "c2",
title: "Doc thread",
createdAt: now,
updatedAt: now,
documentId: "doc-1",
summaryEncrypted: null,
document: { title: "Blood panel", filename: "panel.pdf" },
messages: [
{
id: "m1",
role: "user",
encryptedContent: new Uint8Array(),
metricSourceJson: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
createdAt: now,
},
],
});
const detail = await fetchConversationWithMessages("user-1", "c2");
const where = findFirst.mock.calls[0][0].where;
expect(where.id).toBe("c2");
expect(where.userId).toBe("user-1");
// No documentId opt → no scope filter (the relaxed coach detail reader).
expect("documentId" in where).toBe(false);
expect(detail?.documentId).toBe("doc-1");
expect(detail?.documentTitle).toBe("Blood panel");
});
it("keeps the documentId filter when the document route passes it", async () => {
findFirst.mockResolvedValue(null);
await fetchConversationWithMessages("user-1", "c2", {
documentId: "doc-1",
});
const where = findFirst.mock.calls[0][0].where;
expect(where.userId).toBe("user-1");
expect(where.documentId).toBe("doc-1");
});
it("keeps the strict null-scope filter when the coach SEND path passes documentId: null", async () => {
findFirst.mockResolvedValue(null);
await fetchConversationWithMessages("user-1", "c2", { documentId: null });
const where = findFirst.mock.calls[0][0].where;
expect(where.userId).toBe("user-1");
// The tool-route send path stays fenced to health threads.
expect(where.documentId).toBeNull();
});
});
+30
View File
@@ -163,9 +163,28 @@ export async function createConversation(
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
messageCount: 0,
// v1.28.51 — carry the scope discriminator on the created DTO. The title
// is resolved lazily by the list/detail reads (which join the document);
// create does not join, so a fresh doc thread reports null until reloaded.
documentId: row.documentId ?? null,
documentTitle: null,
};
}
/**
* v1.28.51 (Documents R3) — resolve a joined document's badge title: its
* user-given `title`, falling back to the `filename`, or null when neither is
* set (or the thread has no document). Both columns are plaintext already
* (see the schema note on `InboundDocument.title`), so this leaks no health
* values the row did not already carry in the clear.
*/
function resolveDocumentTitle(
document: { title: string | null; filename: string | null } | null,
): string | null {
if (!document) return null;
return document.title ?? document.filename ?? null;
}
/**
* Append one message to an existing conversation. Bumps the parent
* `updatedAt` so the history rail orders by most-recent-activity.
@@ -302,6 +321,10 @@ export async function fetchConversationWithMessages(
orderBy: { createdAt: "desc" },
take: CONVERSATION_MESSAGE_DETAIL_CAP,
},
// v1.28.51 — join the owning document (if any) so the detail DTO can
// badge a doc-scoped thread with its title. Only the two plaintext
// label columns are selected; the encrypted document body is untouched.
document: { select: { title: true, filename: true } },
},
});
if (!row) return null;
@@ -339,6 +362,8 @@ export async function fetchConversationWithMessages(
messageCount: messages.length,
messages,
summary,
documentId: row.documentId ?? null,
documentTitle: resolveDocumentTitle(row.document),
};
}
@@ -381,6 +406,9 @@ export async function listConversations(
: {}),
include: {
_count: { select: { messages: true } },
// v1.28.51 — join the owning document (label columns only) so the rail
// can badge a doc-scoped thread with its title. Null on Coach threads.
document: { select: { title: true, filename: true } },
},
});
@@ -394,6 +422,8 @@ export async function listConversations(
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
messageCount: r._count.messages,
documentId: r.documentId ?? null,
documentTitle: resolveDocumentTitle(r.document),
})),
nextCursor,
};
+16
View File
@@ -369,6 +369,22 @@ export interface CoachConversationDTO {
createdAt: string;
updatedAt: string;
messageCount: number;
/**
* v1.28.51 (Documents R3, Design A) — when set, this thread is a chat SCOPED
* to one stored document (the discriminator `coach_conversations.document_id`).
* Null / absent = a normal Coach thread (health-record surface). The Coach rail
* badges a doc-scoped thread and, crucially, routes its turns through the
* HARDENED fenced document endpoint — never the tool route. Server-derived from
* the row; never a client input.
*/
documentId?: string | null;
/**
* v1.28.51 — the owning document's resolved title (its `title`, falling back to
* `filename`), for the "Chatting about: <title>" badge. Null when the document
* has neither, or on a non-document thread. Plaintext (same posture as the
* document title column); no health values.
*/
documentTitle?: string | null;
}
/**
+24
View File
@@ -508,6 +508,18 @@ const coachConversationSchema = z
.datetime({ offset: true })
.describe("Bumped on every appended message; the rail orders by this."),
messageCount: z.number().int(),
documentId: z
.string()
.nullish()
.describe(
"When set, this thread is scoped to one stored document (a document chat, run on the hardened fenced endpoint). Null/absent for a normal health thread.",
),
documentTitle: z
.string()
.nullish()
.describe(
"The owning document's resolved title (title, falling back to filename) for the scope badge. Null on a health thread or when the document has neither.",
),
})
.meta({
id: "CoachConversation",
@@ -548,6 +560,18 @@ const coachConversationDetailSchema = z
.describe(
"Rolling summary of turns elided past the history window; null when none is on file.",
),
documentId: z
.string()
.nullish()
.describe(
"When set, this thread is scoped to one stored document; the client routes its turns through the hardened fenced document endpoint, never the tool route.",
),
documentTitle: z
.string()
.nullish()
.describe(
"The owning document's resolved title for the scope badge. Null on a health thread.",
),
})
.meta({
id: "CoachConversationDetail",
-10
View File
@@ -66,16 +66,6 @@ export const documentKeys = {
*/
inboundDocumentAiCapability: () =>
["documents", "inbound", "ai-capability"] as const,
/**
* The scoped "chat about this document" thread
* (`GET /api/documents/inbound/{id}/chat`). Keyed on the document id so two
* open detail sheets never share a thread cache slot; a sent turn's SSE
* `done` frame invalidates exactly this key so the persisted history reloads.
* Stays under the `["documents", "inbound"]` prefix so a document delete /
* bulk write still evicts it.
*/
inboundDocumentChat: (documentId: string) =>
["documents", "inbound", documentId, "chat"] as const,
/**
* The per-user "read documents automatically with AI" opt-in
* (`GET/PATCH /api/auth/me/documents-auto-ai-read`). Drives the AI-settings