adaa837564
Manual: DELETE /api/v1/ai/chat-history (scoped by recipeId or scope=general, matching GET's scoping) plus a trash-icon button + confirm dialog in both chat panels' headers. Automatic: new internal cron endpoint (chat-cleanup), same shared-secret pattern as the existing leftover-reminders/weekly-digest crons, deletes any chat_messages older than 90 days. Wired into cron/crontab (daily, 03:00 UTC) and the Dockerfile's cron stage. Verified locally: cleared a real conversation through the actual UI and confirmed it didn't come back on reopen (not just cleared client-side); inserted a 100-day-old and a 5-day-old message directly, called the cron endpoint with the real shared-secret check, confirmed only the old one was deleted and the recent one survived; confirmed the endpoint 401s with no/wrong secret.
84 lines
3.0 KiB
TypeScript
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 { requireSession } 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 requireSession();
|
|
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 requireSession();
|
|
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 });
|
|
}
|