From 29df784aeb9e48519d518dfeed694f5babdb0f5f Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 24 Jul 2026 21:31:47 +0700 Subject: [PATCH] feat: add Redis-backed SSE updates --- app/actions/questions.ts | 28 +- app/actions/submissions.ts | 25 ++ app/admin/backend/actions.ts | 5 +- app/admin/form/[id]/client.tsx | 60 +++- app/admin/form/[id]/extra/client.tsx | 121 ++++--- app/admin/form/[id]/extra/page.tsx | 24 +- app/admin/form/[id]/page.tsx | 24 +- .../form/[id]/result/[submissionId]/page.tsx | 2 + app/admin/form/[id]/result/page.tsx | 2 + app/admin/form/actions.ts | 21 ++ app/admin/form/page.tsx | 2 + app/form/[id]/page.tsx | 2 + app/form/page.tsx | 50 +-- app/sse/[topic]/route.ts | 34 ++ bun.lock | 8 + components/realtime-refresh.tsx | 55 +++ lib/followers/index.ts | 9 +- lib/redis.ts | 64 +++- lib/sse.ts | 313 ++++++++++++++++++ package.json | 2 + skill/erika-sse/SKILL.md | 153 +++++++++ skill/erika-sse/agents/openai.yaml | 4 + 22 files changed, 912 insertions(+), 96 deletions(-) create mode 100644 app/sse/[topic]/route.ts create mode 100644 components/realtime-refresh.tsx create mode 100644 lib/sse.ts create mode 100644 skill/erika-sse/SKILL.md create mode 100644 skill/erika-sse/agents/openai.yaml diff --git a/app/actions/questions.ts b/app/actions/questions.ts index b78e042..f4ce4f5 100644 --- a/app/actions/questions.ts +++ b/app/actions/questions.ts @@ -9,6 +9,7 @@ import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requireAdmin } from "@/lib/auth"; import { sanitizeHtml } from "@/lib/sanitize-html"; +import { sse } from "@/lib/sse"; export async function createQuestion( formId: string, @@ -30,6 +31,11 @@ export async function createQuestion( options: data.options ?? [], allowOther: false, }); + void sse.forms.pub("update", { + formId, + entity: "question", + action: "created", + }); revalidatePath("/form"); revalidatePath("/admin/form"); } @@ -62,6 +68,11 @@ export async function updateQuestion( }; await db.update(questions).set(nextData).where(eq(questions.id, id)); + void sse.forms.pub("update", { + formId, + entity: "question", + action: "updated", + }); revalidatePath("/form"); revalidatePath("/admin/form"); } @@ -96,7 +107,12 @@ export async function bulkUpdateQuestions( .where(eq(questions.id, u.id)) ) ); - + + void sse.forms.pub("update", { + formId, + entity: "question", + action: "updated", + }); revalidatePath("/form"); revalidatePath("/admin/form"); } @@ -123,6 +139,11 @@ export async function deleteQuestion(id: string, formId: string) { await deleteImageFile(q.imageUrl); } await db.delete(questions).where(eq(questions.id, id)); + void sse.forms.pub("update", { + formId, + entity: "question", + action: "deleted", + }); revalidatePath("/form"); revalidatePath(`/admin/form/${formId}`); } @@ -140,6 +161,11 @@ export async function reorderQuestions( .where(eq(questions.id, id)) ) ); + void sse.forms.pub("update", { + formId, + entity: "question", + action: "updated", + }); revalidatePath("/form"); revalidatePath(`/admin/form/${formId}`); } diff --git a/app/actions/submissions.ts b/app/actions/submissions.ts index 44c883d..54393da 100644 --- a/app/actions/submissions.ts +++ b/app/actions/submissions.ts @@ -7,6 +7,7 @@ import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth"; +import { sse } from "@/lib/sse"; export async function submitForm( formId: string, @@ -148,6 +149,11 @@ export async function submitForm( } } + void sse.submissions.pub("update", { + formId, + submissionId: result.submissionId, + action: result.isUpdate ? "updated" : "created", + }); revalidatePath(`/admin/form/${formId}/result`); return { ok: true }; } @@ -155,7 +161,18 @@ export async function submitForm( export async function deleteSubmission(id: string) { await requireAdmin(); + const submission = await db.query.submissions.findFirst({ + where: (submission, { eq }) => eq(submission.id, id), + columns: { formId: true }, + }); await db.delete(submissions).where(eq(submissions.id, id)); + if (submission) { + void sse.submissions.pub("update", { + formId: submission.formId, + submissionId: id, + action: "deleted", + }); + } revalidatePath("/admin/form"); return { ok: true }; } @@ -164,6 +181,10 @@ export async function deleteAllSubmissions(formId: string) { await requireAdmin(); await db.delete(submissions).where(eq(submissions.formId, formId)); + void sse.submissions.pub("update", { + formId, + action: "deleted", + }); revalidatePath(`/admin/form/${formId}/result`); return { ok: true }; } @@ -179,6 +200,10 @@ export async function deleteOwnSubmission(formId: string) { eq(submissions.userDiscordId, discordId) ) ); + void sse.submissions.pub("update", { + formId, + action: "deleted", + }); revalidatePath(`/form`); revalidatePath(`/admin/form/${formId}/result`); return { ok: true }; diff --git a/app/admin/backend/actions.ts b/app/admin/backend/actions.ts index a299127..c1430a4 100644 --- a/app/admin/backend/actions.ts +++ b/app/admin/backend/actions.ts @@ -57,13 +57,14 @@ export async function refetchPlatform( ? JSON.parse(cached) : {}; counts[platform] = count; - await redis.set("erika:follower_counts", JSON.stringify(counts), { ex: 3600 }); + await redis.set("erika:follower_counts", JSON.stringify(counts), "EX", 3600); if (tiktokProfile) { await redis.set( "erika:tiktok_profile", JSON.stringify(tiktokProfile), - { ex: 3600 } + "EX", + 3600, ); } } catch { diff --git a/app/admin/form/[id]/client.tsx b/app/admin/form/[id]/client.tsx index 9e90c6a..f7d248e 100644 --- a/app/admin/form/[id]/client.tsx +++ b/app/admin/form/[id]/client.tsx @@ -78,6 +78,14 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC const formTimerRef = useRef | null>(null); const questionsTimerRef = useRef | null>(null); + const formDirtyRef = useRef(false); + const questionsDirtyRef = useRef(false); + const formSavingRef = useRef(false); + const questionsSavingRef = useRef(false); + const formRevisionRef = useRef(0); + const questionsRevisionRef = useRef(0); + const activeFormSaveRef = useRef(0); + const activeQuestionsSaveRef = useRef(0); // Refs to always read latest state inside debounced callbacks const formTitleRef = useRef(formTitle); @@ -98,6 +106,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC formTimerRef.current = setTimeout(async () => { const title = formTitleRef.current; if (!title.trim()) return; + const revision = formRevisionRef.current; + activeFormSaveRef.current = revision; + formSavingRef.current = true; setFormSaveStatus("saving"); try { await updateForm(form.id, { @@ -105,12 +116,19 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC description: formDescriptionRef.current, isOpen: formIsOpenRef.current, }); + if (formRevisionRef.current === revision) { + formDirtyRef.current = false; + } setFormSaveStatus("saved"); router.refresh(); setTimeout(() => setFormSaveStatus("idle"), 2000); } catch { toast.error("Failed to auto-save form settings."); setFormSaveStatus("idle"); + } finally { + if (activeFormSaveRef.current === revision) { + formSavingRef.current = false; + } } }, 1000); }, [form.id, router]); @@ -121,6 +139,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC questionsTimerRef.current = setTimeout(async () => { const current = questionsRef.current; if (current.length === 0) return; + const revision = questionsRevisionRef.current; + activeQuestionsSaveRef.current = revision; + questionsSavingRef.current = true; setQuestionsSaveStatus("saving"); try { const formattedUpdates = current.map(q => ({ @@ -129,12 +150,19 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC allowOther: q.allowOther, })); await bulkUpdateQuestions(form.id, formattedUpdates); + if (questionsRevisionRef.current === revision) { + questionsDirtyRef.current = false; + } setQuestionsSaveStatus("saved"); router.refresh(); setTimeout(() => setQuestionsSaveStatus("idle"), 2000); } catch { toast.error("Failed to auto-save questions."); setQuestionsSaveStatus("idle"); + } finally { + if (activeQuestionsSaveRef.current === revision) { + questionsSavingRef.current = false; + } } }, 1500); }, [form.id, router]); @@ -148,16 +176,22 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC }, []); const handleFormTitleChange = (val: string) => { + formDirtyRef.current = true; + formRevisionRef.current += 1; setFormTitle(val); autoSaveForm(); }; const handleFormDescriptionChange = (val: string) => { + formDirtyRef.current = true; + formRevisionRef.current += 1; setFormDescription(val); autoSaveForm(); }; const handleFormIsOpenChange = (val: boolean) => { + formDirtyRef.current = true; + formRevisionRef.current += 1; setFormIsOpen(val); autoSaveForm(); }; @@ -179,6 +213,8 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC }; const handleUpdateQuestion = (id: string, updates: Partial) => { + questionsDirtyRef.current = true; + questionsRevisionRef.current += 1; setQuestions((prev) => prev.map((q) => (q.id === id ? { ...q, ...updates } as Question : q)) ); @@ -188,6 +224,11 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC const handleDeleteQuestion = async (id: string) => { try { await deleteQuestion(id, form.id); + setQuestions((current) => { + const next = current.filter((question) => question.id !== id); + questionsRef.current = next; + return next; + }); router.refresh(); } catch (err: unknown) { console.error("Failed to delete question:", err); @@ -208,6 +249,8 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC q.displayOrder = idx; }); + questionsDirtyRef.current = true; + questionsRevisionRef.current += 1; setQuestions(newQuestions); autoSaveQuestions(); }; @@ -287,8 +330,21 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC }; useEffect(() => { - const timeout = setTimeout(() => setQuestions(initialQuestions), 0); - return () => clearTimeout(timeout); + if (formDirtyRef.current || formSavingRef.current) return; + + setFormTitle(form.title); + setFormDescription(form.description || ""); + setFormIsOpen(form.isOpen); + formTitleRef.current = form.title; + formDescriptionRef.current = form.description || ""; + formIsOpenRef.current = form.isOpen; + }, [form]); + + useEffect(() => { + if (questionsDirtyRef.current || questionsSavingRef.current) return; + + setQuestions(initialQuestions); + questionsRef.current = initialQuestions; }, [initialQuestions]); return ( diff --git a/app/admin/form/[id]/extra/client.tsx b/app/admin/form/[id]/extra/client.tsx index dd657f9..a73915a 100644 --- a/app/admin/form/[id]/extra/client.tsx +++ b/app/admin/form/[id]/extra/client.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState, useTransition, useEffect, useRef } from "react"; +import { useCallback, useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -45,12 +45,21 @@ export default function FormExtraClient({ form }: FormExtraClientProps) { const [roles, setRoles] = useState([]); const [loadingRoles, setLoadingRoles] = useState(true); - const [, startSaveTransition] = useTransition(); const [saveStatus, setSaveStatus] = useState("idle"); const [lastEdited, setLastEdited] = useState<"access" | "webhook" | null>(null); - const isFirstRender = useRef(true); const saveTimeout = useRef(null); + const dirtyRef = useRef(false); + const savingRef = useRef(false); + const revisionRef = useRef(0); + const activeSaveRef = useRef(0); + const valuesRef = useRef({ + webhookUrl, + webhookTemplate, + webhookUpdateTemplate, + allowedRoles, + deniedRoles, + }); useEffect(() => { getGuildRoles() @@ -59,55 +68,91 @@ export default function FormExtraClient({ form }: FormExtraClientProps) { .finally(() => setLoadingRoles(false)); }, []); - const handleSave = useCallback(() => { - setSaveStatus("saving"); - startSaveTransition(async () => { + useEffect(() => { + valuesRef.current = { + webhookUrl, + webhookTemplate, + webhookUpdateTemplate, + allowedRoles, + deniedRoles, + }; + }, [ + allowedRoles, + deniedRoles, + webhookTemplate, + webhookUpdateTemplate, + webhookUrl, + ]); + + const scheduleSave = useCallback(() => { + if (saveTimeout.current) clearTimeout(saveTimeout.current); + setSaveStatus("idle"); + + saveTimeout.current = setTimeout(async () => { + const revision = revisionRef.current; + const values = valuesRef.current; + activeSaveRef.current = revision; + savingRef.current = true; + setSaveStatus("saving"); + try { await updateForm(form.id, { - discordWebhookUrl: webhookUrl.trim() || null, - discordWebhookTemplate: webhookTemplate.trim() || null, - discordWebhookUpdateTemplate: webhookUpdateTemplate.trim() || null, - allowedRoles, - deniedRoles, + discordWebhookUrl: values.webhookUrl.trim() || null, + discordWebhookTemplate: values.webhookTemplate.trim() || null, + discordWebhookUpdateTemplate: values.webhookUpdateTemplate.trim() || null, + allowedRoles: values.allowedRoles, + deniedRoles: values.deniedRoles, }); + if (revisionRef.current === revision) { + dirtyRef.current = false; + } setSaveStatus("saved"); setTimeout(() => setSaveStatus("idle"), 2000); router.refresh(); } catch (err: unknown) { setSaveStatus("idle"); toast.error(getErrorMessage(err, "Failed to update settings.")); + } finally { + if (activeSaveRef.current === revision) { + savingRef.current = false; + } } - }); - }, [ - allowedRoles, - deniedRoles, - form.id, - router, - startSaveTransition, - webhookTemplate, - webhookUpdateTemplate, - webhookUrl, - ]); + }, 1000); + }, [form.id, router]); useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false; - return; - } - - if (saveTimeout.current) clearTimeout(saveTimeout.current); - - saveTimeout.current = setTimeout(() => { - handleSave(); - }, 1000); - return () => { if (saveTimeout.current) clearTimeout(saveTimeout.current); }; - }, [handleSave]); + }, []); + + useEffect(() => { + if (dirtyRef.current || savingRef.current) return; + + const nextValues = { + webhookUrl: form.discordWebhookUrl || "", + webhookTemplate: form.discordWebhookTemplate || "", + webhookUpdateTemplate: form.discordWebhookUpdateTemplate || "", + allowedRoles: form.allowedRoles || [], + deniedRoles: form.deniedRoles || [], + }; + setWebhookUrl(nextValues.webhookUrl); + setWebhookTemplate(nextValues.webhookTemplate); + setWebhookUpdateTemplate(nextValues.webhookUpdateTemplate); + setAllowedRoles(nextValues.allowedRoles); + setDeniedRoles(nextValues.deniedRoles); + valuesRef.current = nextValues; + }, [form]); + + const markEdited = (section: "access" | "webhook") => { + dirtyRef.current = true; + revisionRef.current += 1; + setLastEdited(section); + scheduleSave(); + }; const toggleRole = (list: string[], setList: (v: string[]) => void, roleId: string) => { - setLastEdited("access"); + markEdited("access"); if (list.includes(roleId)) { setList(list.filter(id => id !== roleId)); } else { @@ -232,7 +277,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) { { setLastEdited("webhook"); setWebhookUrl(e.target.value); }} + onChange={(e) => { markEdited("webhook"); setWebhookUrl(e.target.value); }} placeholder="https://discord.com/api/webhooks/..." /> @@ -241,7 +286,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {