"use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; // The reaction set the UI offers. The action rejects anything outside this list // so the website_article_reactions.reaction VARCHAR(50) only ever holds known // values. Keep this in sync with REACTIONS in src/app/news/[slug]/page.tsx. const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]); type ReactionOutcome = | "updated" | "invalid" | "not_found" | "ratelimit" | "error"; function reactionRedirect(slug: string, outcome: ReactionOutcome): never { const path = slug ? `/news/${encodeURIComponent(slug)}` : "/news"; if (outcome === "updated") redirect(`${path}?reaction=1`); redirect(`${path}?error=${outcome}`); } function isNextRedirect(e: unknown): boolean { return ( !!e && typeof e === "object" && "digest" in e && typeof (e as { digest?: unknown }).digest === "string" && (e as { digest: string }).digest.startsWith("NEXT_REDIRECT") ); } /** * Toggle the SIGNED-IN user's reaction on a news article. */ export async function toggleReaction(formData: FormData): Promise { const slugHint = String(formData.get("slug") ?? "") .normalize("NFC") .trim(); let outcome: ReactionOutcome = "error"; let slug = slugHint; try { const session = await auth(); if (!session?.user?.id) { redirect("/login"); } const userId = Number(session.user.id); if (!Number.isFinite(userId)) { redirect("/login"); } await clientIp(); if (!(await rateLimit(`article-reaction:${userId}`, 30, 60_000)).ok) { outcome = "ratelimit"; } else { const reaction = String(formData.get("reaction") ?? "") .normalize("NFC") .trim() .toLowerCase(); if (!ALLOWED_REACTIONS.has(reaction)) { outcome = "invalid"; } else { const articleIdRaw = String(formData.get("articleId") ?? "") .normalize("NFC") .trim(); if (!/^\d+$/.test(articleIdRaw)) { outcome = "invalid"; } else { const articleId = BigInt(articleIdRaw); const article = await prisma.websiteArticles.findUnique({ where: { id: articleId }, select: { slug: true }, }); if (!article) { outcome = "not_found"; } else { slug = article.slug; const existing = await prisma.websiteArticleReactions.findFirst({ where: { userId, articleId, reaction }, select: { id: true, active: true }, }); if (existing?.active) { await prisma.websiteArticleReactions.update({ where: { id: existing.id }, data: { active: false }, }); } else { await prisma.websiteArticleReactions.updateMany({ where: { userId, articleId, active: true }, data: { active: false }, }); if (existing) { await prisma.websiteArticleReactions.update({ where: { id: existing.id }, data: { active: true }, }); } else { await prisma.websiteArticleReactions.create({ data: { userId, articleId, reaction, active: true }, }); } } outcome = "updated"; } } } } } catch (e) { if (isNextRedirect(e)) throw e; outcome = "error"; } if (slug && outcome !== "error") revalidatePath(`/news/${slug}`); reactionRedirect(slug, outcome); }