feat: clear chat history manually, auto-expire old history after 90 days

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.
This commit is contained in:
Arnaud
2026-07-12 23:27:05 +02:00
parent 9549684254
commit adaa837564
10 changed files with 200 additions and 5 deletions
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { db, chatMessages, lt } from "@epicure/db";
// Internal cron endpoint — triggered daily by a cron container (see
// compose.prod.yml / cron/crontab). Not part of the public API surface;
// protected by a shared secret rather than user auth.
//
// AI chat history (both the per-recipe chat and the general cooking
// assistant) has no size cap and no per-user retention setting — this just
// deletes anything past a fixed retention window so the table doesn't grow
// unbounded.
const RETENTION_DAYS = 90;
function isAuthorized(req: NextRequest): boolean {
const secret = process.env["CRON_SECRET"];
if (!secret) return false;
const header = req.headers.get("authorization");
if (!header?.startsWith("Bearer ")) return false;
const provided = header.slice("Bearer ".length);
const a = Buffer.from(provided);
const b = Buffer.from(secret);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
export async function POST(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const cutoff = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000);
const deleted = await db.delete(chatMessages).where(lt(chatMessages.createdAt, cutoff)).returning({ id: chatMessages.id });
return NextResponse.json({ ok: true, deleted: deleted.length });
}
@@ -51,3 +51,33 @@ export async function GET(req: NextRequest) {
})),
});
}
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 });
}