"use server"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { requirePermission } from "@/lib/admin/guard"; import { db, WebsiteArticleComments, WebsiteArticleReactions, WebsiteArticles, } from "@/lib/db"; import { formPositiveBigInt } from "@/lib/form-data"; import { slugify } from "@/lib/format"; import { PERMS } from "@/lib/permissions"; import { notify } from "@/lib/services/webhook"; const ARTICLE_STATUSES = ["published", "scheduled", "draft"] as const; type ArticleStatus = (typeof ARTICLE_STATUSES)[number]; function parseArticleStatus(raw: unknown): ArticleStatus { const value = String(raw ?? "published").trim(); return (ARTICLE_STATUSES as readonly string[]).includes(value) ? (value as ArticleStatus) : "published"; } function parsePublishAt(raw: unknown): Date | null { const value = String(raw ?? "").trim(); if (!value) return null; const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } async function uniqueSlug(title: string): Promise { const base = slugify(title); let slug = base; let n = 2; for (;;) { const [existing] = await db .select({ id: WebsiteArticles.id }) .from(WebsiteArticles) .where(eq(WebsiteArticles.slug, slug)) .limit(1); if (!existing) return slug; slug = `${base}-${n++}`; } } export async function createArticle(formData: FormData): Promise { const staff = await requirePermission(PERMS.NEWS_EDIT); const title = String(formData.get("title") ?? "") .normalize("NFC") .trim(); const shortStory = String(formData.get("shortStory") ?? "") .normalize("NFC") .trim(); const fullStory = String(formData.get("fullStory") ?? "") .normalize("NFC") .trim(); const image = String(formData.get("image") ?? "") .normalize("NFC") .trim(); const rawSlug = String(formData.get("slug") ?? "").trim(); const status = parseArticleStatus(formData.get("status")); const rawPublishAt = String(formData.get("publishAt") ?? "").trim(); if (!title) return; if (status === "scheduled" && !parsePublishAt(rawPublishAt)) { redirect( "/admin/articles/new?error=Scheduled articles need a valid publish date.", ); } try { const now = new Date(); const publishAt = parsePublishAt(rawPublishAt); const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title); await db.insert(WebsiteArticles).values({ slug, title: title.slice(0, 255), shortStory: shortStory.slice(0, 255), fullStory, image: image.slice(0, 255), userId: staff.id, createdAt: now, updatedAt: now, status, publishAt, publishedAt: status === "published" ? now : null, }); notify({ action: "news_publish", actor: staff.username, target: title, details: slug, }); } catch { // Database error — re-render unchanged with error. redirect( "/admin/articles/new?error=Database error while creating article. Please try again.", ); } redirect("/admin/articles"); } export async function updateArticle(formData: FormData): Promise { await requirePermission(PERMS.NEWS_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) redirect("/admin/articles?error=Missing article id"); const rawSlug = String(formData.get("slug") ?? "").trim(); const status = parseArticleStatus(formData.get("status")); const rawPublishAt = String(formData.get("publishAt") ?? "").trim(); if (status === "scheduled" && !parsePublishAt(rawPublishAt)) { redirect( "/admin/articles?error=Scheduled articles need a valid publish date.", ); } try { const publishAt = parsePublishAt(rawPublishAt); const [existing] = await db .select({ status: WebsiteArticles.status, publishedAt: WebsiteArticles.publishedAt, }) .from(WebsiteArticles) .where(eq(WebsiteArticles.id, id)) .limit(1); const publishedAt = status === "published" ? (existing?.publishedAt ?? new Date()) : null; await db .update(WebsiteArticles) .set({ title: String(formData.get("title") ?? "") .normalize("NFC") .trim() .slice(0, 255), ...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}), shortStory: String(formData.get("shortStory") ?? "") .normalize("NFC") .trim() .slice(0, 255), fullStory: String(formData.get("fullStory") ?? "") .normalize("NFC") .trim(), image: String(formData.get("image") ?? "") .normalize("NFC") .trim() .slice(0, 255), status, publishAt, publishedAt, updatedAt: new Date(), }) .where(eq(WebsiteArticles.id, id)); } catch { redirect("/admin/articles?error=Update failed"); } revalidatePath(`/admin/articles/${id}`); redirect("/admin/articles"); } export async function deleteArticle(formData: FormData): Promise { const staff = await requirePermission(PERMS.NEWS_EDIT); const id = formPositiveBigInt(formData, "id"); if (!id) redirect("/admin/articles?error=Missing article id"); const [article] = await db .select({ title: WebsiteArticles.title }) .from(WebsiteArticles) .where(eq(WebsiteArticles.id, id)) .limit(1); try { await db.transaction(async (tx) => { await tx .delete(WebsiteArticleReactions) .where(eq(WebsiteArticleReactions.articleId, id)); await tx .delete(WebsiteArticleComments) .where(eq(WebsiteArticleComments.articleId, id)); await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id)); }); notify({ action: "news_delete", actor: staff.username, target: article?.title ?? String(id), }); } catch { redirect("/admin/articles?error=Delete failed"); } redirect("/admin/articles"); }