fix(news): restore publication flow and deduplicate dashboard friends
CI / check (push) Successful in 1m6s
CI / deploy (push) Successful in 59s

This commit is contained in:
Simo committed 2026-09-06 18:32:43 +02:00
1 parent ef94646d60
commit 9b0ea2fb16
22 files changed
+638 -195

No files matched your search

+98 -103
View File
@@ -1,9 +1,15 @@
"use server";
import { eq } from "drizzle-orm";
import { and, eq, ne } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { requirePermission } from "@/lib/admin/guard";
import {
ArticleInputError,
type ArticleSaveResult,
readArticleInput,
} from "@/lib/article-input";
import {
db,
WebsiteArticleComments,
@@ -12,152 +18,139 @@ import {
} from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { slugify } from "@/lib/format";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { invalidateNewsCache } from "@/lib/services/news-cache";
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<string> {
async function uniqueSlug(title: string, excludeId?: bigint): Promise<string> {
const base = slugify(title);
let slug = base;
let n = 2;
for (;;) {
for (let n = 2; ; n++) {
const [existing] = await db
.select({ id: WebsiteArticles.id })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.slug, slug))
.where(
and(
eq(WebsiteArticles.slug, slug),
excludeId ? ne(WebsiteArticles.id, excludeId) : undefined,
),
)
.limit(1);
if (!existing) return slug;
slug = `${base}-${n++}`;
const suffix = `-${n}`;
slug = base.slice(0, 255 - suffix.length) + suffix;
}
}
export async function createArticle(formData: FormData): Promise<void> {
async function saveFailure(
error: unknown,
operation: string,
): Promise<ArticleSaveResult> {
const t = await getTranslations("pages.admin.articles.form");
if (error instanceof ArticleInputError)
return { ok: false, error: t(error.code) };
const reference = logger.error("News save failed", {
module: "news",
operation,
error,
});
return { ok: false, error: t("saveFailedReference", { reference }) };
}
async function refreshNews() {
await invalidateNewsCache();
revalidatePath("/admin/articles");
revalidatePath("/news", "layout");
revalidatePath("/me");
revalidatePath("/");
}
export async function createArticle(
formData: FormData,
): Promise<ArticleSaveResult> {
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.",
);
}
let input: ReturnType<typeof readArticleInput>;
let slug: string;
try {
input = readArticleInput(formData);
const { rawSlug, ...fields } = input;
slug = await uniqueSlug(rawSlug || fields.title);
const now = new Date();
const publishAt = parsePublishAt(rawPublishAt);
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
await db.insert(WebsiteArticles).values({
...fields,
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,
publishedAt: fields.status === "published" ? now : null,
});
} catch (error) {
return saveFailure(error, "create");
}
// Auxiliary services must not turn a committed insert into an apparent failure.
if (input.status === "published")
notify({
action: "news_publish",
actor: staff.username,
target: title,
target: input.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");
await refreshNews();
return { ok: true, data: { redirectTo: "/admin/articles" } };
}
export async function updateArticle(formData: FormData): Promise<void> {
await requirePermission(PERMS.NEWS_EDIT);
export async function updateArticle(
formData: FormData,
): Promise<ArticleSaveResult> {
const staff = await requirePermission(PERMS.NEWS_EDIT);
const t = await getTranslations("pages.admin.articles.form");
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.",
);
}
if (!id) return { ok: false, error: t("articleNotFound") };
let input: ReturnType<typeof readArticleInput>;
let becamePublished = false;
let slug: string;
try {
const publishAt = parsePublishAt(rawPublishAt);
input = readArticleInput(formData);
const [existing] = await db
.select({
slug: WebsiteArticles.slug,
status: WebsiteArticles.status,
publishedAt: WebsiteArticles.publishedAt,
})
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, id))
.limit(1);
const publishedAt =
status === "published" ? (existing?.publishedAt ?? new Date()) : null;
if (!existing) return { ok: false, error: t("articleNotFound") };
const { rawSlug, ...fields } = input;
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
slug =
requestedSlug === existing.slug
? existing.slug
: await uniqueSlug(requestedSlug, id);
becamePublished =
fields.status === "published" && existing.status !== "published";
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,
...fields,
slug,
publishedAt:
fields.status === "published"
? (existing.publishedAt ?? new Date())
: null,
updatedAt: new Date(),
})
.where(eq(WebsiteArticles.id, id));
} catch {
redirect("/admin/articles?error=Update failed");
} catch (error) {
return saveFailure(error, "update");
}
if (becamePublished)
notify({
action: "news_publish",
actor: staff.username,
target: input.title,
details: slug,
});
await refreshNews();
revalidatePath(`/admin/articles/${id}`);
redirect("/admin/articles");
return { ok: true, data: { redirectTo: "/admin/articles" } };
}
export async function deleteArticle(formData: FormData): Promise<void> {
@@ -185,8 +178,10 @@ export async function deleteArticle(formData: FormData): Promise<void> {
actor: staff.username,
target: article?.title ?? String(id),
});
} catch {
} catch (error) {
logger.error("News deletion failed", { module: "news", error });
redirect("/admin/articles?error=Delete failed");
}
await refreshNews();
redirect("/admin/articles");
}