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.
This commit is contained in:
parent
3ec364a8b9
commit
3c6a9c8a42
4 changed files with 281 additions and 0 deletions
267
src/app/(frontend)/wiki/actions.ts
Normal file
267
src/app/(frontend)/wiki/actions.ts
Normal file
|
|
@ -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<T = undefined> {
|
||||||
|
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<ReturnType<typeof getPayload>>,
|
||||||
|
id: number,
|
||||||
|
): Promise<WikiPage | null> {
|
||||||
|
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<ReturnType<typeof getPayload>>,
|
||||||
|
user: User,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return hasIntelligenceQualification(payload, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWikiPage(input: {
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
tags?: string[];
|
||||||
|
body: string;
|
||||||
|
summary?: string;
|
||||||
|
}): Promise<ActionResult<{ id: number; slug: string }>> {
|
||||||
|
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<ActionResult<{ id: number; slug: string }>> {
|
||||||
|
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<ActionResult<{ id: number; slug: string }>> {
|
||||||
|
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<ActionResult<{ id: number; slug: string; lockdown: boolean }>> {
|
||||||
|
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<ActionResult<{ ok: true }>> {
|
||||||
|
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<ActionResult<{ url: string; filename: string; alt: string }>> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,9 @@ const TARGET_COLLECTIONS = [
|
||||||
{ label: "Market Negotiations", value: "market-negotiations" },
|
{ label: "Market Negotiations", value: "market-negotiations" },
|
||||||
{ label: "Evaluations", value: "evaluations" },
|
{ label: "Evaluations", value: "evaluations" },
|
||||||
{ label: "Tickets", value: "tickets" },
|
{ 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: "Game Servers", value: "game-servers" },
|
||||||
{ label: "Assignment Transfers", value: "assignment-transfers" },
|
{ label: "Assignment Transfers", value: "assignment-transfers" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ interface GameEventInput {
|
||||||
| "market-negotiations"
|
| "market-negotiations"
|
||||||
| "evaluations"
|
| "evaluations"
|
||||||
| "tickets"
|
| "tickets"
|
||||||
|
| "wiki-pages"
|
||||||
|
| "wiki-revisions"
|
||||||
|
| "wiki-templates"
|
||||||
| "game-servers"
|
| "game-servers"
|
||||||
| "assignment-transfers";
|
| "assignment-transfers";
|
||||||
targetId?: number;
|
targetId?: number;
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,14 @@ export const EventTypes = {
|
||||||
TicketVote: "ticket:vote",
|
TicketVote: "ticket:vote",
|
||||||
TicketUnvote: "ticket:unvote",
|
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
|
// Projects
|
||||||
ProjectCreate: "project:create",
|
ProjectCreate: "project:create",
|
||||||
ProjectUpdate: "project:update",
|
ProjectUpdate: "project:update",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue