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:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user