From 3c6a9c8a4270f976a1513e8f0a524d377acf59f0 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sat, 5 Sep 2026 00:02:04 -0400 Subject: [PATCH] feat(wiki): add wiki server actions and event logging Server actions for page create/update, revision restore, lockdown, and delete (moderator-gated via intelligence qualification) plus wiki image uploads. Adds wiki event types (wiki:page-create/edit/restore/lock/ unlock/delete) and registers wiki-pages/revisions/templates as valid event log targets. --- src/app/(frontend)/wiki/actions.ts | 267 ++++++++++++++++++++++++++ src/collections/game/GameEventLogs.ts | 3 + src/utils/event-log/emit.ts | 3 + src/utils/event-log/eventTypes.ts | 8 + 4 files changed, 281 insertions(+) create mode 100644 src/app/(frontend)/wiki/actions.ts diff --git a/src/app/(frontend)/wiki/actions.ts b/src/app/(frontend)/wiki/actions.ts new file mode 100644 index 0000000..3809153 --- /dev/null +++ b/src/app/(frontend)/wiki/actions.ts @@ -0,0 +1,267 @@ +"use server"; + +import config from "@payload-config"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { getPayload } from "payload"; +import type { User, WikiPage } from "@/payload-types"; +import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; +import { + createPage, + deletePage, + restoreRevision, + setPageLockdown as setPageLockdownService, + updatePage, +} from "@/lib/wiki/service"; + +interface ActionResult { + success: boolean; + error?: string; + data?: T; +} + +async function authenticate() { + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { headers } = await import("next/headers"); + const hdrs = await headers(); + const { user } = await payload.auth({ + headers: hdrs, + canSetHeaders: false, + }); + + if (!isPayloadUser(user)) { + throw new Error("Unauthorized"); + } + + return { payload, user }; +} + +async function fetchPage( + payload: Awaited>, + id: number, +): Promise { + try { + return (await payload.findByID({ + collection: "wiki-pages", + id, + depth: 0, + overrideAccess: true, + })) as unknown as WikiPage; + } catch { + return null; + } +} + +async function isWikiModerator( + payload: Awaited>, + user: User, +): Promise { + return hasIntelligenceQualification(payload, user); +} + +export async function createWikiPage(input: { + title: string; + category: string; + tags?: string[]; + body: string; + summary?: string; +}): Promise> { + try { + const { payload, user } = await authenticate(); + const page = await createPage(payload, user, input); + await emitGameEvent(payload, { + type: EventTypes.WikiPageCreate, + message: `${user.displayName ?? user.username} created wiki page "${input.title}"`, + actor: user.id, + targetCollection: "wiki-pages", + targetId: page.id, + data: { slug: page.slug }, + }); + return { success: true, data: { id: page.id, slug: page.slug } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} + +export async function updateWikiPage(input: { + id: number; + title?: string; + body?: string; + category?: string; + tags?: string[]; + summary?: string; +}): Promise> { + try { + const { payload, user } = await authenticate(); + const page = await fetchPage(payload, input.id); + if (!page) return { success: false, error: "Wiki page not found." }; + const updated = await updatePage(payload, user, page, input); + await emitGameEvent(payload, { + type: EventTypes.WikiPageEdit, + message: `${user.displayName ?? user.username} edited wiki page "${updated.title}"`, + actor: user.id, + targetCollection: "wiki-pages", + targetId: updated.id, + data: { slug: updated.slug }, + }); + return { success: true, data: { id: updated.id, slug: updated.slug } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} + +export async function restoreWikiRevision(input: { + id: number; + revisionNumber: number; +}): Promise> { + try { + const { payload, user } = await authenticate(); + if (!(await isWikiModerator(payload, user))) { + return { + success: false, + error: "You need intelligence division clearance to restore wiki pages.", + }; + } + const page = await fetchPage(payload, input.id); + if (!page) return { success: false, error: "Wiki page not found." }; + const updated = await restoreRevision(payload, user, page, input.revisionNumber); + await emitGameEvent(payload, { + type: EventTypes.WikiPageRestore, + message: `${user.displayName ?? user.username} restored wiki page "${updated.title}" to revision ${input.revisionNumber}`, + actor: user.id, + targetCollection: "wiki-pages", + targetId: updated.id, + data: { slug: updated.slug, revisionNumber: input.revisionNumber }, + }); + return { success: true, data: { id: updated.id, slug: updated.slug } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} + +export async function setPageLockdown(input: { + id: number; + lockdown: boolean; +}): Promise> { + try { + const { payload, user } = await authenticate(); + if (!(await isWikiModerator(payload, user))) { + return { + success: false, + error: "You need intelligence division clearance to restore wiki pages.", + }; + } + const page = await fetchPage(payload, input.id); + if (!page) return { success: false, error: "Wiki page not found." }; + const updated = await setPageLockdownService(payload, user, page, input.lockdown); + await emitGameEvent(payload, { + type: input.lockdown ? EventTypes.WikiPageLock : EventTypes.WikiPageUnlock, + message: `${user.displayName ?? user.username} ${input.lockdown ? "locked" : "unlocked"} wiki page "${updated.title}"`, + actor: user.id, + targetCollection: "wiki-pages", + targetId: updated.id, + data: { slug: updated.slug, lockdown: input.lockdown }, + }); + return { + success: true, + data: { id: updated.id, slug: updated.slug, lockdown: input.lockdown }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} + +export async function deleteWikiPage(input: { id: number }): Promise> { + try { + const { payload, user } = await authenticate(); + if (!(await isWikiModerator(payload, user))) { + return { + success: false, + error: "You need intelligence division clearance to restore wiki pages.", + }; + } + const page = await fetchPage(payload, input.id); + if (!page) return { success: false, error: "Wiki page not found." }; + await deletePage(payload, user, page); + await emitGameEvent(payload, { + type: EventTypes.WikiPageDelete, + message: `${user.displayName ?? user.username} deleted wiki page "${page.title}"`, + actor: user.id, + targetCollection: "wiki-pages", + targetId: page.id, + data: { slug: page.slug }, + }); + return { success: true, data: { ok: true } }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} + +export async function uploadWikiImage( + formData: FormData, +): Promise> { + try { + const { payload } = await authenticate(); + const file = formData.get("file"); + if (!(file instanceof File)) { + return { success: false, error: "No image file provided." }; + } + if (!file.type.startsWith("image/")) { + return { success: false, error: "Only image files can be uploaded." }; + } + if (file.size > 8 * 1024 * 1024) { + return { success: false, error: "Images must be 8 MB or smaller." }; + } + const alt = + file.name + .replace(/\.[^/.]+$/, "") + .replace(/[^a-zA-Z0-9 _-]/g, "") + .trim() || "Image"; + const doc = await payload.create({ + collection: "media", + data: { alt }, + file: { + data: Buffer.from(await file.arrayBuffer()), + mimetype: file.type, + name: file.name, + size: file.size, + }, + overrideAccess: true, + }); + if (!doc.filename) { + return { success: false, error: "Upload failed — no file was stored." }; + } + return { + success: true, + data: { url: doc.url ?? `/api/media/file/${doc.filename}`, filename: doc.filename, alt }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (message === "Unauthorized") { + return { success: false, error: "You must be logged in." }; + } + return { success: false, error: message }; + } +} diff --git a/src/collections/game/GameEventLogs.ts b/src/collections/game/GameEventLogs.ts index 5308ab2..29a3eb3 100644 --- a/src/collections/game/GameEventLogs.ts +++ b/src/collections/game/GameEventLogs.ts @@ -25,6 +25,9 @@ const TARGET_COLLECTIONS = [ { label: "Market Negotiations", value: "market-negotiations" }, { label: "Evaluations", value: "evaluations" }, { label: "Tickets", value: "tickets" }, + { label: "Wiki Pages", value: "wiki-pages" }, + { label: "Wiki Revisions", value: "wiki-revisions" }, + { label: "Wiki Templates", value: "wiki-templates" }, { label: "Game Servers", value: "game-servers" }, { label: "Assignment Transfers", value: "assignment-transfers" }, ] as const; diff --git a/src/utils/event-log/emit.ts b/src/utils/event-log/emit.ts index 19d7c8c..c6189b3 100644 --- a/src/utils/event-log/emit.ts +++ b/src/utils/event-log/emit.ts @@ -30,6 +30,9 @@ interface GameEventInput { | "market-negotiations" | "evaluations" | "tickets" + | "wiki-pages" + | "wiki-revisions" + | "wiki-templates" | "game-servers" | "assignment-transfers"; targetId?: number; diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index affa04f..36fe2c8 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -114,6 +114,14 @@ export const EventTypes = { TicketVote: "ticket:vote", TicketUnvote: "ticket:unvote", + // Wiki + WikiPageCreate: "wiki:page-create", + WikiPageEdit: "wiki:page-edit", + WikiPageRestore: "wiki:page-restore", + WikiPageLock: "wiki:page-lock", + WikiPageUnlock: "wiki:page-unlock", + WikiPageDelete: "wiki:page-delete", + // Projects ProjectCreate: "project:create", ProjectUpdate: "project:update",