diff --git a/src/app/api/insights/chat/[id]/route.ts b/src/app/api/insights/chat/[id]/route.ts new file mode 100644 index 000000000..7cfada40d --- /dev/null +++ b/src/app/api/insights/chat/[id]/route.ts @@ -0,0 +1,65 @@ +/** + * GET /api/insights/chat/[id] — fetch one conversation with all + * messages decrypted on the fly. + * DELETE /api/insights/chat/[id] — hard-delete the conversation + + * every message under it. + * + * Ownership: both verbs verify the conversation belongs to the + * authenticated user. A foreign id maps to 404 (NOT 403) so the + * existence channel never leaks across accounts. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; + +import { + deleteConversation, + fetchConversationWithMessages, +} from "@/lib/ai/coach/persistence"; + +interface RouteCtx { + params: Promise<{ id: string }>; +} + +export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => { + const auth = await requireAuth(); + const { id } = await ctx.params; + if (!id) return apiError("coach.conversation.notFound", 404); + + const detail = await fetchConversationWithMessages(auth.user.id, id); + if (!detail) { + return apiError("coach.conversation.notFound", 404); + } + + annotate({ + action: { name: "insights.coach.fetch" }, + meta: { conversationId: id, messageCount: detail.messageCount }, + }); + + return apiSuccess(detail); +}); + +export const DELETE = apiHandler( + async (_request: NextRequest, ctx: RouteCtx) => { + const auth = await requireAuth(); + const { id } = await ctx.params; + if (!id) return apiError("coach.conversation.notFound", 404); + + const ok = await deleteConversation(auth.user.id, id); + if (!ok) { + return apiError("coach.conversation.notFound", 404); + } + + annotate({ + action: { name: "insights.coach.delete" }, + meta: { conversationId: id }, + }); + + return apiSuccess({ deleted: true }); + }, +); + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 162e6f91a..4ad75c609 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -29,6 +29,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; import { withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; @@ -50,6 +51,7 @@ import { appendMessage, createConversation, fetchConversationWithMessages, + listConversations, } from "@/lib/ai/coach/persistence"; import { buildDateKey, @@ -449,6 +451,35 @@ const handler = apiHandler(async (request: NextRequest) => { export const POST = handler; +/** + * GET /api/insights/chat?cursor=&limit= + * + * Cursor-paginated list of the caller's conversations for the rail. + * Default limit 20, hard cap 50. Cursor is the id of the last item + * on the previous page; callers receive `{ nextCursor: null }` when + * they have reached the end. + */ +export const GET = apiHandler(async (request: NextRequest) => { + const auth = await requireAuth(); + const url = new URL(request.url); + const cursor = url.searchParams.get("cursor"); + const limitRaw = url.searchParams.get("limit"); + const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined; + + const page = await listConversations({ + userId: auth.user.id, + cursor, + limit: Number.isFinite(limit) ? (limit as number) : undefined, + }); + + annotate({ + action: { name: "insights.coach.list" }, + meta: { count: page.conversations.length }, + }); + + return apiSuccess(page); +}); + // Disable the static-page optimisation; we are always streaming. export const dynamic = "force-dynamic"; export const runtime = "nodejs"; diff --git a/tests/integration/coach-conversations.test.ts b/tests/integration/coach-conversations.test.ts new file mode 100644 index 000000000..7ca67f8c5 --- /dev/null +++ b/tests/integration/coach-conversations.test.ts @@ -0,0 +1,256 @@ +/** + * Integration coverage for the v1.4.20 Coach conversation endpoints. + * + * GET /api/insights/chat — list (cursor-paginated) + * GET /api/insights/chat/[id] — fetch one + messages + * DELETE /api/insights/chat/[id] — hard-delete the thread + * + * Ownership boundaries: a foreign user's id maps to 404 (NOT 403) so + * the existence channel never leaks. Decryption round-trips on read. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedUserWithSession(username = "coach-list-user"): Promise<{ + userId: string; +}> { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username, role: "USER" }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return { userId: user.id }; +} + +interface Envelope { + data: T | null; + error?: string | null; +} + +describe("GET /api/insights/chat — list integration", () => { + it("returns the user's conversations newest-first", async () => { + const { userId } = await seedUserWithSession(); + const { appendMessage, createConversation } = + await import("@/lib/ai/coach/persistence"); + const a = await createConversation({ userId, title: "first thread" }); + await appendMessage({ + conversationId: a.id, + role: "user", + content: "first turn", + }); + const b = await createConversation({ userId, title: "second thread" }); + await appendMessage({ + conversationId: b.id, + role: "user", + content: "second turn", + }); + + const { GET } = await import("@/app/api/insights/chat/route"); + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/chat"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as Envelope<{ + conversations: Array<{ id: string; messageCount: number }>; + nextCursor: string | null; + }>; + expect(env.data?.conversations.length).toBe(2); + // most recently updated first + expect(env.data?.conversations[0].id).toBe(b.id); + expect(env.data?.conversations[0].messageCount).toBe(1); + expect(env.data?.nextCursor).toBeNull(); + }); + + it("only returns conversations owned by the caller", async () => { + const prisma = getPrismaClient(); + const foreign = await prisma.user.create({ + data: { username: "foreign", role: "USER" }, + }); + await prisma.coachConversation.create({ + data: { userId: foreign.id, title: "not yours" }, + }); + const { userId } = await seedUserWithSession(); + const { createConversation } = await import("@/lib/ai/coach/persistence"); + await createConversation({ userId, title: "yours" }); + + const { GET } = await import("@/app/api/insights/chat/route"); + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/chat"), + ); + const env = (await res.json()) as Envelope<{ + conversations: Array<{ title: string }>; + }>; + expect(env.data?.conversations.map((c) => c.title)).toEqual(["yours"]); + }); +}); + +describe("GET /api/insights/chat/[id] — fetch single", () => { + it("decrypts every message body on read", async () => { + const { userId } = await seedUserWithSession(); + const { appendMessage, createConversation } = + await import("@/lib/ai/coach/persistence"); + const convo = await createConversation({ userId, title: "encrypted test" }); + await appendMessage({ + conversationId: convo.id, + role: "user", + content: "this is the user message", + }); + await appendMessage({ + conversationId: convo.id, + role: "assistant", + content: "this is the assistant reply", + metricSource: { windows: ["last30days"], metrics: ["bp"] }, + providerType: "openai", + promptVersion: "4.20.0", + }); + + const { GET } = await import("@/app/api/insights/chat/[id]/route"); + const res = await ( + GET as unknown as ( + req: Request, + ctx: { params: Promise<{ id: string }> }, + ) => Promise + )(new Request(`http://localhost/api/insights/chat/${convo.id}`), { + params: Promise.resolve({ id: convo.id }), + }); + expect(res.status).toBe(200); + const env = (await res.json()) as Envelope<{ + messages: Array<{ + role: string; + content: string; + providerType: string | null; + }>; + }>; + expect(env.data?.messages.length).toBe(2); + expect(env.data?.messages[0].content).toBe("this is the user message"); + expect(env.data?.messages[1].content).toBe("this is the assistant reply"); + expect(env.data?.messages[1].providerType).toBe("openai"); + }); + + it("returns 404 (not 403) when fetching another user's conversation", async () => { + const prisma = getPrismaClient(); + const foreign = await prisma.user.create({ + data: { username: "foreign-fetch", role: "USER" }, + }); + const foreignConvo = await prisma.coachConversation.create({ + data: { userId: foreign.id, title: "secret" }, + }); + await seedUserWithSession(); + + const { GET } = await import("@/app/api/insights/chat/[id]/route"); + const res = await ( + GET as unknown as ( + req: Request, + ctx: { params: Promise<{ id: string }> }, + ) => Promise + )(new Request(`http://localhost/api/insights/chat/${foreignConvo.id}`), { + params: Promise.resolve({ id: foreignConvo.id }), + }); + expect(res.status).toBe(404); + }); +}); + +describe("DELETE /api/insights/chat/[id]", () => { + it("removes the conversation and cascades messages", async () => { + const { userId } = await seedUserWithSession(); + const { appendMessage, createConversation } = + await import("@/lib/ai/coach/persistence"); + const convo = await createConversation({ userId, title: "to delete" }); + await appendMessage({ + conversationId: convo.id, + role: "user", + content: "ephemeral", + }); + + const { DELETE } = await import("@/app/api/insights/chat/[id]/route"); + const res = await ( + DELETE as unknown as ( + req: Request, + ctx: { params: Promise<{ id: string }> }, + ) => Promise + )( + new Request(`http://localhost/api/insights/chat/${convo.id}`, { + method: "DELETE", + }), + { params: Promise.resolve({ id: convo.id }) }, + ); + expect(res.status).toBe(200); + + const prisma = getPrismaClient(); + expect(await prisma.coachConversation.count()).toBe(0); + expect(await prisma.coachMessage.count()).toBe(0); + }); + + it("returns 404 when deleting another user's conversation", async () => { + const prisma = getPrismaClient(); + const foreign = await prisma.user.create({ + data: { username: "foreign-delete", role: "USER" }, + }); + const foreignConvo = await prisma.coachConversation.create({ + data: { userId: foreign.id, title: "secret" }, + }); + await seedUserWithSession(); + + const { DELETE } = await import("@/app/api/insights/chat/[id]/route"); + const res = await ( + DELETE as unknown as ( + req: Request, + ctx: { params: Promise<{ id: string }> }, + ) => Promise + )( + new Request(`http://localhost/api/insights/chat/${foreignConvo.id}`, { + method: "DELETE", + }), + { params: Promise.resolve({ id: foreignConvo.id }) }, + ); + expect(res.status).toBe(404); + + // Foreign conversation still on disk + expect( + await prisma.coachConversation.count({ where: { userId: foreign.id } }), + ).toBe(1); + }); +});