mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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 { 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=<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.
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
Reference in new issue
Block a user