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:
+2
-1
@@ -76,5 +76,6 @@ RUN apk add --no-cache curl
|
||||
COPY cron/crontab /etc/crontabs/root
|
||||
COPY cron/run-digest.sh /usr/local/bin/run-digest.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"]
|
||||
|
||||
@@ -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 { cn } from "@/lib/utils";
|
||||
import { ChatHistorySearch } from "./chat-history-search";
|
||||
import { ClearChatHistoryButton } from "./clear-chat-history-button";
|
||||
|
||||
type Message = {
|
||||
role: "user" | "assistant";
|
||||
@@ -103,7 +104,10 @@ export function CookingAssistantPanel() {
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
{t("title")}
|
||||
</SheetTitle>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ChatHistorySearch />
|
||||
<ClearChatHistoryButton disabled={messages.length === 0} onCleared={() => setMessages([])} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-left">{t("subtitle")}</p>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MessageCircle, Send, Bot, User } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatHistorySearch } from "./chat-history-search";
|
||||
import { ClearChatHistoryButton } from "./clear-chat-history-button";
|
||||
|
||||
type Message = {
|
||||
role: "user" | "assistant";
|
||||
@@ -105,7 +106,10 @@ export function RecipeChatPanel({ recipeId, recipeTitle }: Props) {
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
{t("title")}
|
||||
</SheetTitle>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ChatHistorySearch recipeId={recipeId} />
|
||||
<ClearChatHistoryButton recipeId={recipeId} disabled={messages.length === 0} onCleared={() => setMessages([])} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-left">{recipeTitle}</p>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -393,7 +393,14 @@
|
||||
"searchPlaceholder": "Search past questions…",
|
||||
"searching": "Searching…",
|
||||
"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": {
|
||||
"sorry": "Sorry, I couldn't answer that.",
|
||||
|
||||
@@ -393,7 +393,14 @@
|
||||
"searchPlaceholder": "Rechercher une question passée…",
|
||||
"searching": "Recherche…",
|
||||
"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": {
|
||||
"sorry": "Désolé, je n'ai pas pu répondre à cela.",
|
||||
|
||||
@@ -3,3 +3,6 @@
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
Executable
+17
@@ -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"
|
||||
Reference in New Issue
Block a user