mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(coach): GET list, GET single, DELETE conversation endpoints
GET /api/insights/chat returns the caller's conversations newest-first with cursor pagination (default limit 20, hard cap 50). Each row carries id, title, timestamps, and a message count so the rail can render without paying the per-message decrypt cost. GET /api/insights/chat/[id] fetches one conversation with every message decrypted in memory before serialisation. Provenance metadata round-trips alongside the assistant turns. DELETE /api/insights/chat/[id] hard-deletes the thread; the FK chain cascades the message rows. Cross-user lookups map to 404, never 403, so the existence channel never leaks across accounts. 6 integration tests cover the list ordering, owner-only filter, decryption round-trip, and the 404 boundary on both verbs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
16f3ece90f
commit
3168ba8e0a
3 files changed
+352
No files matched your search
@@ -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";
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||||
|
import { apiSuccess } from "@/lib/api-response";
|
||||||
import { withIdempotency } from "@/lib/idempotency";
|
import { withIdempotency } from "@/lib/idempotency";
|
||||||
import { annotate } from "@/lib/logging/context";
|
import { annotate } from "@/lib/logging/context";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
appendMessage,
|
appendMessage,
|
||||||
createConversation,
|
createConversation,
|
||||||
fetchConversationWithMessages,
|
fetchConversationWithMessages,
|
||||||
|
listConversations,
|
||||||
} from "@/lib/ai/coach/persistence";
|
} from "@/lib/ai/coach/persistence";
|
||||||
import {
|
import {
|
||||||
buildDateKey,
|
buildDateKey,
|
||||||
@@ -449,6 +451,35 @@ const handler = apiHandler(async (request: NextRequest) => {
|
|||||||
|
|
||||||
export const POST = handler;
|
export const POST = handler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/insights/chat?cursor=<id>&limit=<n>
|
||||||
|
*
|
||||||
|
* 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.
|
// Disable the static-page optimisation; we are always streaming.
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -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<T> {
|
||||||
|
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<Response>)(
|
||||||
|
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<Response>)(
|
||||||
|
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<Response>
|
||||||
|
)(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<Response>
|
||||||
|
)(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<Response>
|
||||||
|
)(
|
||||||
|
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<Response>
|
||||||
|
)(
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user