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
+2 -1
View File
@@ -76,5 +76,6 @@ RUN apk add --no-cache curl
COPY cron/crontab /etc/crontabs/root COPY cron/crontab /etc/crontabs/root
COPY cron/run-digest.sh /usr/local/bin/run-digest.sh COPY cron/run-digest.sh /usr/local/bin/run-digest.sh
COPY cron/run-leftover-reminders.sh /usr/local/bin/run-leftover-reminders.sh COPY cron/run-leftover-reminders.sh /usr/local/bin/run-leftover-reminders.sh
RUN chmod +x /usr/local/bin/run-digest.sh /usr/local/bin/run-leftover-reminders.sh COPY cron/run-chat-cleanup.sh /usr/local/bin/run-chat-cleanup.sh
RUN chmod +x /usr/local/bin/run-digest.sh /usr/local/bin/run-leftover-reminders.sh /usr/local/bin/run-chat-cleanup.sh
CMD ["crond", "-f", "-l", "2"] CMD ["crond", "-f", "-l", "2"]
@@ -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 });
}
@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export function ClearChatHistoryButton({
recipeId,
disabled,
onCleared,
}: {
recipeId?: string;
disabled?: boolean;
onCleared: () => void;
}) {
const t = useTranslations("chatHistory");
const [confirmOpen, setConfirmOpen] = useState(false);
const [clearing, setClearing] = useState(false);
async function handleClear() {
setClearing(true);
try {
const params = new URLSearchParams();
if (recipeId) params.set("recipeId", recipeId);
else params.set("scope", "general");
const res = await fetch(`/api/v1/ai/chat-history?${params.toString()}`, { method: "DELETE" });
if (!res.ok) {
toast.error(t("clearFailed"));
return;
}
onCleared();
toast.success(t("cleared"));
} catch {
toast.error(t("clearFailed"));
} finally {
setClearing(false);
setConfirmOpen(false);
}
}
return (
<>
<button
type="button"
onClick={() => setConfirmOpen(true)}
disabled={disabled || clearing}
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground disabled:opacity-40"
aria-label={t("clearAriaLabel")}
>
<Trash2 className="h-4 w-4" />
</button>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("clearConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("clearConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("clearCancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => { void handleClear(); }}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("clearConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -9,6 +9,7 @@ import { MessageCircle, Send, Bot, User } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChatHistorySearch } from "./chat-history-search"; import { ChatHistorySearch } from "./chat-history-search";
import { ClearChatHistoryButton } from "./clear-chat-history-button";
type Message = { type Message = {
role: "user" | "assistant"; role: "user" | "assistant";
@@ -103,7 +104,10 @@ export function CookingAssistantPanel() {
<Bot className="h-4 w-4 text-primary" /> <Bot className="h-4 w-4 text-primary" />
{t("title")} {t("title")}
</SheetTitle> </SheetTitle>
<ChatHistorySearch /> <div className="flex items-center gap-0.5">
<ChatHistorySearch />
<ClearChatHistoryButton disabled={messages.length === 0} onCleared={() => setMessages([])} />
</div>
</div> </div>
<p className="text-xs text-muted-foreground text-left">{t("subtitle")}</p> <p className="text-xs text-muted-foreground text-left">{t("subtitle")}</p>
</SheetHeader> </SheetHeader>
@@ -9,6 +9,7 @@ import { MessageCircle, Send, Bot, User } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChatHistorySearch } from "./chat-history-search"; import { ChatHistorySearch } from "./chat-history-search";
import { ClearChatHistoryButton } from "./clear-chat-history-button";
type Message = { type Message = {
role: "user" | "assistant"; role: "user" | "assistant";
@@ -105,7 +106,10 @@ export function RecipeChatPanel({ recipeId, recipeTitle }: Props) {
<Bot className="h-4 w-4 text-primary" /> <Bot className="h-4 w-4 text-primary" />
{t("title")} {t("title")}
</SheetTitle> </SheetTitle>
<ChatHistorySearch recipeId={recipeId} /> <div className="flex items-center gap-0.5">
<ChatHistorySearch recipeId={recipeId} />
<ClearChatHistoryButton recipeId={recipeId} disabled={messages.length === 0} onCleared={() => setMessages([])} />
</div>
</div> </div>
<p className="text-xs text-muted-foreground text-left">{recipeTitle}</p> <p className="text-xs text-muted-foreground text-left">{recipeTitle}</p>
</SheetHeader> </SheetHeader>
+8 -1
View File
@@ -393,7 +393,14 @@
"searchPlaceholder": "Search past questions…", "searchPlaceholder": "Search past questions…",
"searching": "Searching…", "searching": "Searching…",
"noResults": "No matching messages", "noResults": "No matching messages",
"close": "Close" "close": "Close",
"clearAriaLabel": "Clear chat history",
"clearConfirmTitle": "Clear this conversation?",
"clearConfirmDescription": "This deletes the saved history for this chat. This can't be undone.",
"clearCancel": "Cancel",
"clearConfirm": "Clear history",
"cleared": "Chat history cleared",
"clearFailed": "Failed to clear chat history"
}, },
"cookingChat": { "cookingChat": {
"sorry": "Sorry, I couldn't answer that.", "sorry": "Sorry, I couldn't answer that.",
+8 -1
View File
@@ -393,7 +393,14 @@
"searchPlaceholder": "Rechercher une question passée…", "searchPlaceholder": "Rechercher une question passée…",
"searching": "Recherche…", "searching": "Recherche…",
"noResults": "Aucun message correspondant", "noResults": "Aucun message correspondant",
"close": "Fermer" "close": "Fermer",
"clearAriaLabel": "Effacer l'historique",
"clearConfirmTitle": "Effacer cette conversation ?",
"clearConfirmDescription": "Cela supprime l'historique enregistré de cette conversation. Cette action est irréversible.",
"clearCancel": "Annuler",
"clearConfirm": "Effacer l'historique",
"cleared": "Historique effacé",
"clearFailed": "Échec de l'effacement de l'historique"
}, },
"cookingChat": { "cookingChat": {
"sorry": "Désolé, je n'ai pas pu répondre à cela.", "sorry": "Désolé, je n'ai pas pu répondre à cela.",
+3
View File
@@ -3,3 +3,6 @@
# Leftover-expiry reminders — daily at 09:00 UTC. # Leftover-expiry reminders — daily at 09:00 UTC.
0 9 * * * /usr/local/bin/run-leftover-reminders.sh >> /proc/1/fd/1 2>> /proc/1/fd/2 0 9 * * * /usr/local/bin/run-leftover-reminders.sh >> /proc/1/fd/1 2>> /proc/1/fd/2
# AI chat history cleanup (90-day retention) — daily at 03:00 UTC.
0 3 * * * /usr/local/bin/run-chat-cleanup.sh >> /proc/1/fd/1 2>> /proc/1/fd/2
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Triggers deletion of AI chat history older than the retention window on the
# `web` service. Run on a schedule by busybox crond (see cron/crontab). Fails
# loudly (non-zero exit, stderr) but crond just tries again tomorrow — no
# retry loop here on purpose, keep this script trivial.
set -eu
: "${WEB_INTERNAL_URL:=http://web:3000}"
if [ -z "${CRON_SECRET:-}" ]; then
echo "run-chat-cleanup: CRON_SECRET is not set, refusing to call the endpoint" >&2
exit 1
fi
curl -fsS -X POST \
-H "Authorization: Bearer ${CRON_SECRET}" \
"${WEB_INTERNAL_URL}/api/internal/cron/chat-cleanup"