From adaa8375648eebefebfd9cdd8a5deba2339cac7b Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sun, 12 Jul 2026 23:27:05 +0200 Subject: [PATCH] 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. --- Dockerfile | 3 +- .../api/internal/cron/chat-cleanup/route.ts | 39 +++++++++ apps/web/app/api/v1/ai/chat-history/route.ts | 30 +++++++ .../recipe/clear-chat-history-button.tsx | 83 +++++++++++++++++++ .../recipe/cooking-assistant-panel.tsx | 6 +- .../components/recipe/recipe-chat-panel.tsx | 6 +- apps/web/messages/en.json | 9 +- apps/web/messages/fr.json | 9 +- cron/crontab | 3 + cron/run-chat-cleanup.sh | 17 ++++ 10 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 apps/web/app/api/internal/cron/chat-cleanup/route.ts create mode 100644 apps/web/components/recipe/clear-chat-history-button.tsx create mode 100755 cron/run-chat-cleanup.sh diff --git a/Dockerfile b/Dockerfile index 0b09aa3..46c7e73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/apps/web/app/api/internal/cron/chat-cleanup/route.ts b/apps/web/app/api/internal/cron/chat-cleanup/route.ts new file mode 100644 index 0000000..3239668 --- /dev/null +++ b/apps/web/app/api/internal/cron/chat-cleanup/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/v1/ai/chat-history/route.ts b/apps/web/app/api/v1/ai/chat-history/route.ts index e7c016c..34984c5 100644 --- a/apps/web/app/api/v1/ai/chat-history/route.ts +++ b/apps/web/app/api/v1/ai/chat-history/route.ts @@ -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 }); +} diff --git a/apps/web/components/recipe/clear-chat-history-button.tsx b/apps/web/components/recipe/clear-chat-history-button.tsx new file mode 100644 index 0000000..729f0c7 --- /dev/null +++ b/apps/web/components/recipe/clear-chat-history-button.tsx @@ -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 ( + <> + + + + + + {t("clearConfirmTitle")} + {t("clearConfirmDescription")} + + + {t("clearCancel")} + { void handleClear(); }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("clearConfirm")} + + + + + + ); +} diff --git a/apps/web/components/recipe/cooking-assistant-panel.tsx b/apps/web/components/recipe/cooking-assistant-panel.tsx index 9f4af72..2ed6cfe 100644 --- a/apps/web/components/recipe/cooking-assistant-panel.tsx +++ b/apps/web/components/recipe/cooking-assistant-panel.tsx @@ -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() { {t("title")} - +
+ + setMessages([])} /> +

{t("subtitle")}

diff --git a/apps/web/components/recipe/recipe-chat-panel.tsx b/apps/web/components/recipe/recipe-chat-panel.tsx index b529911..7b4cfcd 100644 --- a/apps/web/components/recipe/recipe-chat-panel.tsx +++ b/apps/web/components/recipe/recipe-chat-panel.tsx @@ -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) { {t("title")} - +
+ + setMessages([])} /> +

{recipeTitle}

diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index d7c7e44..5a1f4b8 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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.", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index d0323ba..f161e78 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -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.", diff --git a/cron/crontab b/cron/crontab index 68c114f..a9ba7b9 100644 --- a/cron/crontab +++ b/cron/crontab @@ -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 diff --git a/cron/run-chat-cleanup.sh b/cron/run-chat-cleanup.sh new file mode 100755 index 0000000..974b0d0 --- /dev/null +++ b/cron/run-chat-cleanup.sh @@ -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"