feat: add Redis-backed SSE updates

This commit is contained in:
2026-07-24 21:31:47 +07:00 Unverified
parent 5cc7edaa13
commit 29df784aeb
22 changed files with 912 additions and 96 deletions
+27 -1
View File
@@ -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}`);
}
+25
View File
@@ -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 };
+3 -2
View File
@@ -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 {
+58 -2
View File
@@ -78,6 +78,14 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const formTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const questionsTimerRef = useRef<ReturnType<typeof setTimeout> | 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<Question>) => {
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 (
+83 -38
View File
@@ -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<DiscordRole[]>([]);
const [loadingRoles, setLoadingRoles] = useState(true);
const [, startSaveTransition] = useTransition();
const [saveStatus, setSaveStatus] = useState<AutoSaveStatus>("idle");
const [lastEdited, setLastEdited] = useState<"access" | "webhook" | null>(null);
const isFirstRender = useRef(true);
const saveTimeout = useRef<NodeJS.Timeout | null>(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) {
<Input
id="webhook-url"
value={webhookUrl}
onChange={(e) => { setLastEdited("webhook"); setWebhookUrl(e.target.value); }}
onChange={(e) => { markEdited("webhook"); setWebhookUrl(e.target.value); }}
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
@@ -241,7 +286,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
<Textarea
id="webhook-template"
value={webhookTemplate}
onChange={(e) => { setLastEdited("webhook"); setWebhookTemplate(e.target.value); }}
onChange={(e) => { markEdited("webhook"); setWebhookTemplate(e.target.value); }}
placeholder="New submission received from {Name} for skill: {skill}!"
className="min-h-24"
/>
@@ -254,7 +299,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
<Textarea
id="webhook-update-template"
value={webhookUpdateTemplate}
onChange={(e) => { setLastEdited("webhook"); setWebhookUpdateTemplate(e.target.value); }}
onChange={(e) => { markEdited("webhook"); setWebhookUpdateTemplate(e.target.value); }}
placeholder="Submission updated by {Name} for skill: {skill}!"
className="min-h-24"
/>
+14 -10
View File
@@ -1,6 +1,7 @@
import { db } from "@/db";
import { notFound } from "next/navigation";
import FormExtraClient from "./client";
import { RealtimeRefresh } from "@/components/realtime-refresh";
export const dynamic = "force-dynamic";
@@ -19,17 +20,20 @@ export default async function AdminExtraPage(
}
return (
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Extra Settings</h1>
<p className="text-muted-foreground mt-1">
Configure Discord webhook integrations.
</p>
<>
<RealtimeRefresh topic="forms" formId={form.id} />
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Extra Settings</h1>
<p className="text-muted-foreground mt-1">
Configure Discord webhook integrations.
</p>
</div>
</div>
</div>
<FormExtraClient form={form} />
</div>
<FormExtraClient form={form} />
</div>
</>
);
}
+14 -10
View File
@@ -1,6 +1,7 @@
import { db } from "@/db";
import FormEditorClient from "./client";
import { notFound } from "next/navigation";
import { RealtimeRefresh } from "@/components/realtime-refresh";
export const dynamic = "force-dynamic";
@@ -24,17 +25,20 @@ export default async function AdminDashboardPage(
});
return (
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Edit Form</h1>
<p className="text-muted-foreground mt-1">
Customize form settings, add/edit questions, or upload images.
</p>
<>
<RealtimeRefresh topic="forms" formId={form.id} />
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Edit Form</h1>
<p className="text-muted-foreground mt-1">
Customize form settings, add/edit questions, or upload images.
</p>
</div>
</div>
</div>
<FormEditorClient form={form} initialQuestions={questionsList} />
</div>
<FormEditorClient form={form} initialQuestions={questionsList} />
</div>
</>
);
}
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { ArrowLeft, ArrowRight } from "lucide-react";
import SubmissionDetailClient from "./client";
import { DeleteSubmissionButton } from "@/components/delete-buttons";
import { RealtimeRefresh } from "@/components/realtime-refresh";
interface SubmissionDetailPageProps {
params: Promise<{
@@ -48,6 +49,7 @@ export default async function SubmissionDetailPage({ params }: SubmissionDetailP
return (
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-10">
<RealtimeRefresh topic="submissions" formId={formId} />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href={`/admin/form/${formId}/result`}>
+2
View File
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Users, ArrowLeft } from "lucide-react";
import { DeleteSubmissionButton, DeleteAllSubmissionsButton } from "@/components/delete-buttons";
import { RealtimeRefresh } from "@/components/realtime-refresh";
export const dynamic = "force-dynamic";
@@ -21,6 +22,7 @@ export default async function ResultsListPage(
return (
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6">
<RealtimeRefresh topic="submissions" formId={params.id} />
<div>
<Button variant="ghost" asChild className="mb-4">
<Link href={`/admin/form/${params.id}`}>
+21
View File
@@ -7,6 +7,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/auth";
import { sanitizeHtml } from "@/lib/sanitize-html";
import { sse } from "@/lib/sse";
export async function createForm(formData: FormData) {
await requireAdmin();
@@ -19,6 +20,11 @@ export async function createForm(formData: FormData) {
.values({ title, description: description ? sanitizeHtml(description) : null })
.returning();
void sse.forms.pub("update", {
formId: form.id,
entity: "form",
action: "created",
});
revalidatePath("/admin/form");
redirect(`/admin/form/${form.id}`);
}
@@ -31,6 +37,11 @@ export async function createDefaultForm() {
.values({ title: "New Form", description: "" })
.returning();
void sse.forms.pub("update", {
formId: form.id,
entity: "form",
action: "created",
});
revalidatePath("/admin/form");
redirect(`/admin/form/${form.id}`);
}
@@ -48,6 +59,11 @@ export async function updateForm(id: string, data: { title?: string; description
};
await db.update(forms).set(nextData).where(eq(forms.id, id));
void sse.forms.pub("update", {
formId: id,
entity: "form",
action: "updated",
});
revalidatePath("/form");
revalidatePath("/admin/form");
}
@@ -56,5 +72,10 @@ export async function deleteForm(id: string) {
await requireAdmin();
await db.delete(forms).where(eq(forms.id, id));
void sse.forms.pub("update", {
formId: id,
entity: "form",
action: "deleted",
});
revalidatePath("/admin/form");
}
+2
View File
@@ -14,6 +14,7 @@ import { Plus } from "lucide-react";
import { DeleteFormButton } from "@/components/delete-buttons";
import { AdminShellServer } from "@/components/admin-shell-server";
import { createDefaultForm } from "./actions";
import { RealtimeRefresh } from "@/components/realtime-refresh";
export const dynamic = "force-dynamic";
@@ -24,6 +25,7 @@ export default async function AdminFormsPage() {
return (
<AdminShellServer>
<RealtimeRefresh topic="forms" />
<div className="space-y-6 p-4 md:p-6 pb-12 w-full">
<div className="flex items-center justify-between">
<div>
+2
View File
@@ -5,6 +5,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { notFound } from "next/navigation";
import LoginDialog from "@/components/login-dialog";
import { canAccessForm, getSessionDiscordId } from "@/lib/auth";
import { RealtimeRefresh } from "@/components/realtime-refresh";
export const dynamic = "force-dynamic";
@@ -53,6 +54,7 @@ export default async function FormsSubmitPage(
return (
<>
<RealtimeRefresh topic="forms" formId={form.id} />
<div className="max-w-2xl mx-auto p-4 md:p-6 space-y-6">
<FormFillerClient
form={form}
+27 -23
View File
@@ -9,6 +9,7 @@ import { HtmlDisplay } from "@/components/html-display";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { canAccessForm, getSessionDiscordId } from "@/lib/auth";
import { RealtimeRefresh } from "@/components/realtime-refresh";
export const dynamic = "force-dynamic";
@@ -28,28 +29,31 @@ export default async function FormsListingPage() {
}
return (
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-6">
<h1 className="text-3xl font-bold tracking-tight">Form ทั้งหมด</h1>
{visibleForms.length === 0 ? (
<p className="text-muted-foreground">ตอนนี้ยังไม่เปิดรับสัก Form</p>
) : (
<div className="flex flex-col gap-4">
{visibleForms.map((form) => (
<Link key={form.id} href={`/form/${form.id}`}>
<Card className="hover:bg-muted/50 transition-colors h-full flex flex-col">
<CardHeader>
<CardTitle>{form.title}</CardTitle>
{form.description && (
<div className="line-clamp-3 text-sm text-muted-foreground mt-1.5">
<HtmlDisplay html={form.description} />
</div>
)}
</CardHeader>
</Card>
</Link>
))}
</div>
)}
</div>
<>
<RealtimeRefresh topic="forms" />
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-6">
<h1 className="text-3xl font-bold tracking-tight">Form ทั้งหมด</h1>
{visibleForms.length === 0 ? (
<p className="text-muted-foreground">ตอนนี้ยังไม่เปิดรับสัก Form</p>
) : (
<div className="flex flex-col gap-4">
{visibleForms.map((form) => (
<Link key={form.id} href={`/form/${form.id}`}>
<Card className="hover:bg-muted/50 transition-colors h-full flex flex-col">
<CardHeader>
<CardTitle>{form.title}</CardTitle>
{form.description && (
<div className="line-clamp-3 text-sm text-muted-foreground mt-1.5">
<HtmlDisplay html={form.description} />
</div>
)}
</CardHeader>
</Card>
</Link>
))}
</div>
)}
</div>
</>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import {
getAdminDiscordIds,
getSessionDiscordId,
} from "@/lib/auth";
import { isSseTopic, sse } from "@/lib/sse";
export async function GET(
request: Request,
{ params }: { params: Promise<{ topic: string }> },
) {
const { topic } = await params;
if (!isSseTopic(topic)) {
return new Response("Invalid SSE topic", { status: 404 });
}
const endpoint = sse[topic];
if (endpoint.adminOnly) {
const session = await getServerSession(authOptions);
const discordId = getSessionDiscordId(session);
if (!discordId || !getAdminDiscordIds().includes(discordId)) {
return new Response("Unauthorized", { status: 401 });
}
}
try {
return await endpoint.stream({ signal: request.signal });
} catch (error) {
console.error(`Failed to open SSE topic ${topic}`, error);
return new Response("SSE unavailable", { status: 503 });
}
}