Files
Epicure/apps/web/app/api/v1/ai/chat-history/route.ts
T
Arnaud 3e71bd29a2 security: widen API-key auth to content/AI routes (v0.27.0)
Convert requireSession -> requireSessionOrApiKey across recipes,
collections, meal-plans, shopping-lists, pantry, feed, and ai/*
(52 routes) so API keys work end-to-end, not just for the handful of
endpoints that supported them before. Scope was explicitly confirmed
per-resource-family with the user before any file was touched.

Left session-cookie-only, deliberately: users/me*, ai-keys/*,
webhooks/*, conversations/*, notifications/*, push/subscribe, admin/*
— account/credential-adjacent surface that shouldn't widen without a
separate, explicit decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 11:20:26 +02:00

84 lines
3.0 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, chatMessages, eq, and, isNull, desc, asc, ilike } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
const Schema = z.object({
recipeId: z.string().uuid().optional(),
// "general" restricts to the homepage cooking assistant (recipeId null);
// omit both recipeId and scope to search across everything.
scope: z.enum(["general"]).optional(),
q: z.string().max(200).optional(),
limit: z.coerce.number().int().min(1).max(100).default(30),
});
export async function GET(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const { searchParams } = new URL(req.url);
const parsed = Schema.safeParse({
recipeId: searchParams.get("recipeId") ?? undefined,
scope: searchParams.get("scope") ?? undefined,
q: searchParams.get("q") ?? undefined,
limit: searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const { recipeId, scope, q, limit } = parsed.data;
const conditions = [eq(chatMessages.userId, session!.user.id)];
if (recipeId) conditions.push(eq(chatMessages.recipeId, recipeId));
else if (scope === "general") conditions.push(isNull(chatMessages.recipeId));
if (q?.trim()) conditions.push(ilike(chatMessages.content, `%${q.trim()}%`));
const rows = await db.query.chatMessages.findMany({
where: and(...conditions),
orderBy: q?.trim() ? [desc(chatMessages.createdAt)] : [asc(chatMessages.createdAt)],
limit,
with: { recipe: { columns: { id: true, title: true } } },
});
return NextResponse.json({
data: rows.map((r) => ({
id: r.id,
role: r.role,
content: r.content,
createdAt: r.createdAt.toISOString(),
recipeId: r.recipeId,
recipeTitle: r.recipe?.title ?? null,
})),
});
}
const DeleteSchema = z.object({
recipeId: z.string().uuid().optional(),
scope: z.enum(["general"]).optional(),
});
// No `q` here on purpose — clearing is "this whole conversation" (or
// everything), not "every message matching a search term".
export async function DELETE(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const { searchParams } = new URL(req.url);
const parsed = DeleteSchema.safeParse({
recipeId: searchParams.get("recipeId") ?? undefined,
scope: searchParams.get("scope") ?? undefined,
});
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const { recipeId, scope } = parsed.data;
const conditions = [eq(chatMessages.userId, session!.user.id)];
if (recipeId) conditions.push(eq(chatMessages.recipeId, recipeId));
else if (scope === "general") conditions.push(isNull(chatMessages.recipeId));
await db.delete(chatMessages).where(and(...conditions));
return NextResponse.json({ ok: true });
}