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("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) {{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"