mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user