Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 43s

This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
This commit is contained in:
Simo committed 2026-08-30 21:31:34 +02:00
1 parent 488b6e57c4
commit b1ddda66ff
802 files changed
+61370 -76659

No files matched your search

+67 -84
View File
@@ -1,115 +1,98 @@
// @ts-nocheck
import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger";
import { ActionError } from "@/lib/safe-action-shared";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { createAd, deleteAd } from "./admin-ads";
const { execute } = vi.hoisted(() => ({
execute: vi.fn(async () => ({
ok: true,
data: { before: null, after: { id: "1" }, output: { id: "1" } },
correlationId: "legacy",
})),
}));
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
contentMutationService: { execute },
createContentMutationInvocation: (actor, correlationId) => ({
expectedActorId: actor.id,
correlationId,
legacy: true,
}),
}));
const { insertValues, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
})),
})),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteAds: { id: "id" },
}));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("@/lib/safe-action", () => ({
adminAction: (_options, handler) => handler,
adminAction: vi.fn((_o: unknown, f: (...args: unknown[]) => unknown) => f),
}));
vi.mock("@/lib/safe-action-shared", () => ({
ActionError: class ActionError extends Error {},
actionOk: () => "ok",
ActionError: class extends Error {},
actionOk: vi.fn(() => "ok"),
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data) => ({ get: (key) => data[key] ?? null });
const fakeForm = (data: Record<string, string>) => ({
get: (k: string) => data[k] ?? null,
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff);
execute.mockResolvedValue({
ok: true,
data: { before: null, after: { id: "1" }, output: { id: "1" } },
correlationId: "legacy",
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("createAd", () => {
it("creates ad and redirects", async () => {
await createAd(
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalled();
expect(logStaffActivity).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/ads");
});
it("returns early when image empty", async () => {
await createAd(fakeForm({ image: "" }) as unknown as FormData);
expect(insertValues).not.toHaveBeenCalled();
});
it("logs error on db failure", async () => {
insertValues.mockRejectedValue(new Error("db"));
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
expect(logger.error).toHaveBeenCalled();
});
});
describe("Content advertisement legacy wrappers", () => {
it("delegates creation and preserves redirect", async () => {
await createAd(fakeForm({ image: "https://example.com/ad.png" }));
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 1, legacy: true }),
"ad.change",
{ action: "create", image: "https://example.com/ad.png" },
);
expect(redirect).toHaveBeenCalledWith("/ase/content/media/ads");
describe("deleteAd", () => {
it("deletes ad and returns ok", async () => {
const h = deleteAd as unknown as (ctx: {
data: { id: bigint };
session: { user: { id: string } };
}) => Promise<string>;
expect(
await h({ data: { id: BigInt(99) }, session: { user: { id: "1" } } }),
).toBe("ok");
});
it("returns early when image is empty", async () => {
await createAd(fakeForm({ image: "" }));
expect(execute).not.toHaveBeenCalled();
});
it("logs a redacted service failure", async () => {
execute.mockResolvedValue({
ok: false,
error: {
code: "DEPENDENCY_UNAVAILABLE",
messageKey: "errors.housekeeping.dependencyUnavailable",
},
correlationId: "legacy",
});
await createAd(fakeForm({ image: "x" }));
expect(logger.error).toHaveBeenCalledWith(
"Action failed: createAd",
expect.objectContaining({
error: "errors.housekeeping.dependencyUnavailable",
}),
);
});
it("delegates deletion and preserves action result", async () => {
const handler = deleteAd as unknown as (ctx: unknown) => Promise<string>;
it("throws ActionError when not found", async () => {
deleteWhere.mockResolvedValue([{ affectedRows: 0 }]);
const h = deleteAd as unknown as (ctx: {
data: { id: bigint };
session: { user: { id: string } };
}) => Promise<string>;
await expect(
handler({
data: { id: 99n },
session: { user: { id: "1" } },
requestId: "delete",
}),
).resolves.toBe("ok");
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ correlationId: "delete" }),
"ad.change",
{ action: "delete", id: "99" },
);
});
it("preserves not-found ActionError", async () => {
execute.mockResolvedValue({
ok: false,
error: { code: "NOT_FOUND", messageKey: "errors.housekeeping.notFound" },
correlationId: "legacy",
});
const handler = deleteAd as unknown as (ctx: unknown) => Promise<string>;
await expect(
handler({
data: { id: 999n },
session: { user: { id: "1" } },
requestId: "missing",
}),
h({ data: { id: BigInt(999) }, session: { user: { id: "1" } } }),
).rejects.toThrow(ActionError);
});
});
+67 -37
View File
@@ -1,18 +1,20 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import {
contentMutationService,
createContentMutationInvocation,
} from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteAds } from "@/lib/db";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logStaffActivity } from "@/lib/services/staff-activity";
// CRUD for website advertisements (website_ads). Emulator does not own this
// table; it only stores an image URL rendered in the site layout/widgets.
export async function createAd(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
@@ -21,68 +23,96 @@ export async function createAd(formData: FormData): Promise<void> {
.trim()
.slice(0, 255);
if (!image) return;
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"ad.change",
{ action: "create", image },
);
if (!result.ok) {
const now = new Date();
try {
const [result] = (await db.insert(WebsiteAds).values({
image,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
await logStaffActivity({
staffId: staff.id,
action: "ad_create",
description: `Created advertisement #${result.insertId} (${image})`,
targetType: "website_ad",
targetId: Number(result.insertId),
});
} catch (err) {
logger.error("Action failed: createAd", {
action: "createAd",
error: result.error.messageKey,
error: err instanceof Error ? err.message : "DB error",
});
revalidatePath("/ase/content/media/ads");
revalidatePath("/admin/ads");
return;
}
redirect("/ase/content/media/ads");
redirect("/admin/ads");
}
export async function updateAd(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const raw = String(formData.get("id") ?? "").normalize("NFC");
if (!/^\d+$/u.test(raw)) return;
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
const image = String(formData.get("image") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!image) return;
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"ad.change",
{ action: "update", id: raw, image },
);
if (!result.ok) {
try {
await db
.update(WebsiteAds)
.set({ image, updatedAt: new Date() })
.where(eq(WebsiteAds.id, id));
await logStaffActivity({
staffId: staff.id,
action: "ad_update",
description: `Updated advertisement #${id} (${image})`,
targetType: "website_ad",
targetId: Number(id),
});
} catch (err) {
logger.error("Action failed: updateAd", {
action: "updateAd",
id: Number(raw),
error: result.error.messageKey,
id: Number(id),
error: err instanceof Error ? err.message : "DB error",
});
revalidatePath(`/ase/content/media/ads/${raw}`);
revalidatePath(`/admin/ads/${id}`);
return;
}
redirect("/ase/content/media/ads");
redirect("/admin/ads");
}
const deleteAdInput = z.object({
id: z
.union([z.string(), z.number(), z.bigint()])
.transform((value) => BigInt(String(value))),
.transform((v) => BigInt(String(v))),
});
export const deleteAd = adminAction(
{ permission: PERMS.PAGES_EDIT, schema: deleteAdInput },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"ad.change",
{ action: "delete", id: ctx.data.id.toString() },
);
if (!result.ok) throw new ActionError("Advertisement not found");
revalidatePath("/ase/content/media/ads");
const id = ctx.data.id;
try {
const [result] = (await db
.delete(WebsiteAds)
.where(eq(WebsiteAds.id, id))) as unknown as [ResultSetHeader];
if (!result.affectedRows) {
throw new ActionError("Advertisement not found");
}
} catch (err) {
if (err instanceof ActionError) throw err;
throw new ActionError("Advertisement not found");
}
await logStaffActivity({
staffId: Number(ctx.session.user.id),
action: "ad_delete",
description: `Deleted advertisement #${id}`,
targetType: "website_ad",
targetId: Number(id),
});
revalidatePath("/admin/ads");
return actionOk();
},
);
+2 -4
View File
@@ -7,7 +7,7 @@ import { sendHotelAlert } from "./admin-alerts";
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { NOTIFICATIONS_EDIT: "admin.notifications.edit" },
PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" },
}));
vi.mock("@/lib/db", () => ({
db: {
@@ -37,9 +37,7 @@ describe("sendHotelAlert", () => {
fakeForm({ message: "Hello!" }) as unknown as FormData,
);
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
expect(revalidatePath).toHaveBeenCalledWith(
"/ase/system/operations/alerts",
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
});
it("returns early when message is empty", async () => {
+12 -36
View File
@@ -1,30 +1,11 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
type SystemMutationContext,
systemMutationService,
} from "@/features/housekeeping/domains/system/services/mutations";
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import { requirePermission } from "@/lib/admin/guard";
import { AlertLogs, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
function grantedMutationContext(
staff: { id: number; rank: number; username: string },
permission: string,
): SystemMutationContext {
const matches = (slug: string) => slug === permission;
return {
capability: createHousekeepingCapabilityContext(staff, {
isSuperAdmin: false,
has: matches,
hasAny: (...slugs) => slugs.some(matches),
hasAll: (...slugs) => slugs.every(matches),
}),
correlationId: createCorrelationId(),
};
}
import { rcon } from "@/lib/services/rcon";
/**
* Broadcast a hotel-wide alert to every online user via RCON.
@@ -33,7 +14,7 @@ function grantedMutationContext(
* `message` payload. Staff-gated; the message is trimmed/bounded before send.
*/
export async function sendHotelAlert(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.NOTIFICATIONS_EDIT);
await requirePermission(PERMS.NOTIFICATIONS_EDIT);
const message = String(formData.get("message") ?? "")
.normalize("NFC")
@@ -42,30 +23,25 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
if (!message) return;
try {
await systemMutationService.execute(
grantedMutationContext(staff, PERMS.NOTIFICATIONS_EDIT),
"operations.alert.broadcast",
{ message },
);
await rcon.send("hotelalert", { message });
} catch {
// Best-effort delivery (dead socket / emulator offline) — never 500 the
// admin page. The emulator writes its own alert_logs row on receipt.
}
revalidatePath("/ase/system/operations/alerts");
revalidatePath("/admin/alerts");
}
/** Mark every unread ops alert as read. */
export async function markAllAlertsRead(): Promise<void> {
const staff = await requirePermission(PERMS.NOTIFICATIONS_VIEW);
await requirePermission(PERMS.NOTIFICATIONS_VIEW);
try {
await systemMutationService.execute(
grantedMutationContext(staff, PERMS.NOTIFICATIONS_VIEW),
"operations.alerts.mark-read",
{},
);
await db
.update(AlertLogs)
.set({ isRead: true, updatedAt: new Date() })
.where(eq(AlertLogs.isRead, false));
} catch {
/* ignore */
}
revalidatePath("/ase/system/operations/alerts");
revalidatePath("/admin/alerts");
}
+14 -16
View File
@@ -1,26 +1,24 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteStaffApplications } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
export async function dismissApplication(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const rawId = formPositiveBigInt(formData, "id");
if (!rawId) return;
const applicationId = rawId.toString();
await requirePermission(PERMS.USERS_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"application.decide",
{ applicationId, decision: "dismiss" },
);
// Preserve the tolerant legacy action: already-gone/DB failure still refreshes.
revalidatePath("/ase/people/staff/applications");
try {
await db
.delete(WebsiteStaffApplications)
.where(eq(WebsiteStaffApplications.id, id));
} catch {
// already gone / no DB — nothing to do
}
revalidatePath("/admin/applications");
}
+99 -52
View File
@@ -1,73 +1,120 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import {
contentMutationService,
createContentMutationInvocation,
} from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import {
db,
WebsiteArticleComments,
WebsiteArticleReactions,
WebsiteArticles,
} from "@/lib/db";
import { slugify } from "@/lib/format";
import { PERMS } from "@/lib/permissions";
function articleInput(formData: FormData) {
return {
title: String(formData.get("title") ?? "")
.normalize("NFC")
.trim(),
shortStory: String(formData.get("shortStory") ?? "")
.normalize("NFC")
.trim(),
fullStory: String(formData.get("fullStory") ?? "")
.normalize("NFC")
.trim(),
image: String(formData.get("image") ?? "")
.normalize("NFC")
.trim(),
slug: String(formData.get("slug") ?? "").trim(),
};
async function uniqueSlug(title: string): Promise<string> {
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<void> {
const staff = await requirePermission(PERMS.NEWS_EDIT);
const input = articleInput(formData);
if (!input.title) return;
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"article.change",
{ action: "create", ...input },
);
if (!result.ok) {
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();
if (!title) return;
try {
const now = new Date();
await db.insert(WebsiteArticles).values({
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
title: title.slice(0, 255),
shortStory: shortStory.slice(0, 255),
fullStory,
image: image.slice(0, 255),
userId: staff.id,
createdAt: now,
updatedAt: now,
});
} catch {
// Database error — re-render unchanged with error.
redirect(
"/ase/content/editorial/articles/new?error=Database error while creating article. Please try again.",
"/admin/articles/new?error=Database error while creating article. Please try again.",
);
}
redirect("/ase/content/editorial/articles");
redirect("/admin/articles");
}
export async function updateArticle(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.NEWS_EDIT);
const id = String(formData.get("id") ?? "");
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"article.change",
{ action: "update", id, ...articleInput(formData) },
);
if (!result.ok)
redirect("/ase/content/editorial/articles?error=Update failed");
revalidatePath(`/ase/content/editorial/articles/${id}`);
redirect("/ase/content/editorial/articles");
await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id")));
const rawSlug = String(formData.get("slug") ?? "").trim();
try {
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),
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<void> {
const staff = await requirePermission(PERMS.NEWS_EDIT);
const id = String(formData.get("id") ?? "");
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"article.change",
{ action: "delete", id },
);
if (!result.ok)
redirect("/ase/content/editorial/articles?error=Delete failed");
redirect("/ase/content/editorial/articles");
await requirePermission(PERMS.NEWS_EDIT);
const id = BigInt(String(formData.get("id")));
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));
});
} catch {
redirect("/admin/articles?error=Delete failed");
}
redirect("/admin/articles");
}
+76
View File
@@ -0,0 +1,76 @@
"use server";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { toBadgeGif } from "@/lib/images/badge-gif";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Writes a badge image to the configured emulator badge directory. The path is
// read from BADGE_UPLOAD_DIR so deployments can point it at their emulator's
// `swf/c_images/album1584` (or equivalent) without code changes. AtomCMS only
// ever stores .gif badges, so every upload is normalised to `<code>.gif`.
const CODE_RE = /^[A-Za-z0-9_-]{1,64}$/;
const MAX_BYTES = 1024 * 1024; // 1MB
const ALLOWED_TYPES = new Set(["image/gif", "image/png"]);
function back(param: string, value: string): never {
redirect(`/admin/badges?${param}=${encodeURIComponent(value)}`);
}
export async function uploadBadge(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const dir = process.env.BADGE_UPLOAD_DIR;
if (!dir) {
back("error", "Badge upload directory not configured");
}
const code = String(formData.get("code") ?? "")
.normalize("NFC")
.trim();
if (!CODE_RE.test(code)) {
back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)");
}
const file = formData.get("file");
if (!(file instanceof File)) {
back("error", "No file uploaded");
}
if (file.size === 0) {
back("error", "Uploaded file is empty");
}
if (file.size > MAX_BYTES) {
back("error", "File too large (max 1MB)");
}
if (!ALLOWED_TYPES.has(file.type)) {
back("error", "File must be a GIF or PNG image");
}
try {
const buffer = Buffer.from(await file.arrayBuffer());
const gif = await toBadgeGif(buffer);
const baseDir = path.resolve(dir);
const target = path.resolve(baseDir, `${code}.gif`);
if (!target.startsWith(baseDir + path.sep)) {
back("error", "Invalid path");
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(target, gif);
} catch {
back("error", "Could not process or write the badge file");
}
await logStaffActivity({
staffId: staff.id,
action: "badge_upload",
description: `Uploaded badge image "${code}.gif"`,
targetType: "badge",
});
redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`);
}
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { and, eq, max } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, UsersBadges } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
export async function giveBadge(formData: FormData): Promise<void> {
await requirePermission(PERMS.CATALOG_EDIT);
const userId = Number(formData.get("userId"));
const code = String(formData.get("code") ?? "")
.normalize("NFC")
.trim()
.slice(0, 32);
if (!(userId > 0) || code.length === 0) return;
// Fire the emulator command so the badge appears live for online users.
await rcon.giveBadge(userId, code);
// Persist the badge directly so it survives a relog / offline grant.
// users_badges has no unique (user_id, badge_code) constraint, so guard
// against duplicates and compute the next free slot ourselves.
try {
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
)
.limit(1);
if (!existing) {
const [agg] = await db
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
}
} catch {
// Best-effort: the RCON grant already succeeded for online users.
}
revalidatePath("/admin/badges");
}
+58 -41
View File
@@ -1,67 +1,84 @@
// @ts-nocheck
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { rcon } from "@/lib/services/rcon";
import { createBan, liftBan } from "./admin-bans";
const { selectLimit, insertValues, deleteWhere } = vi.hoisted(() => {
const selectLimit = vi.fn();
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { selectLimit, insertValues, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
Ban: { id: "id", userId: "userId" },
User: { id: "id", username: "username" },
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } }));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as unknown as FormData;
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
execute.mockImplementation(async (invocation) => ({
ok: true,
data: { before: null, after: {} },
correlationId: invocation.correlationId,
}));
selectLimit.mockResolvedValue([{ username: "baduser" }]);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("legacy admin ban wrappers", () => {
it("preserves parsed create input, service delegation, and revalidation", async () => {
describe("createBan", () => {
it("creates a ban for valid inputs", async () => {
await createBan(
fakeForm({
userId: "42",
reason: "Spam",
hours: "24",
type: "account",
}),
}) as unknown as FormData,
);
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 1, legacy: true }),
"ban.create",
{ userId: 42, reason: "Spam", hours: 24, type: "account" },
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ userId: 42, type: "account" }),
);
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
});
it("returns early when userId is invalid", async () => {
await createBan(fakeForm({ userId: "0", hours: "1", type: "account" }));
expect(execute).not.toHaveBeenCalled();
});
it("delegates lift by exact ban id and preserves revalidation", async () => {
await liftBan(fakeForm({ id: "42" }));
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 1, legacy: true }),
"ban.lift",
{ id: 42 },
await createBan(
fakeForm({
userId: "0",
hours: "1",
type: "account",
}) as unknown as FormData,
);
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
expect(insertValues).not.toHaveBeenCalled();
});
});
describe("liftBan", () => {
it("deletes ban and revalidates", async () => {
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
});
});
+43 -24
View File
@@ -1,13 +1,12 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { Ban, db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const BAN_TYPES: ReadonlySet<string> = new Set([
"account",
@@ -26,30 +25,50 @@ export async function createBan(formData: FormData): Promise<void> {
const hours = Number(formData.get("hours"));
const type = String(formData.get("type"));
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"ban.create",
{
userId,
reason,
hours: Number.isFinite(hours) && hours > 0 ? Math.floor(hours) : 0,
type,
},
);
if (!result.ok) throw new Error("Could not create ban");
revalidatePath("/ase/people/moderation/bans");
const now = Math.floor(Date.now() / 1000);
// Emulator convention: banExpire 0 = permanent (not a far-future timestamp).
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : 0;
const [user] = await db
.select({ username: User.username })
.from(User)
.where(eq(User.id, userId))
.limit(1);
await db.insert(Ban).values({
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire,
banReason: reason,
type: type as "account" | "ip" | "machine" | "super",
cfhTopic: -1,
});
if (user) await rcon.disconnectUser(userId, user.username);
await logStaffActivity({
staffId: staff.id,
action: "user_ban",
description: `Banned user #${userId} (${type}, ${hours > 0 ? `${hours}h` : "permanent"}): ${reason}`,
targetType: "user",
targetId: userId,
});
revalidatePath("/admin/bans");
}
export async function liftBan(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_BAN);
const id = Number(formData.get("id"));
if (id > 0) {
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"ban.lift",
{ id },
);
if (!result.ok) throw new Error("Could not lift ban");
await db.delete(Ban).where(eq(Ban.id, id));
await logStaffActivity({
staffId: staff.id,
action: "ban_lift",
description: `Lifted ban #${id}`,
});
}
revalidatePath("/ase/people/moderation/bans");
revalidatePath("/admin/bans");
}
+76
View File
@@ -0,0 +1,76 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, EmailTemplates } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
export async function createEmailTemplate(formData: FormData): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT);
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const subject = String(formData.get("subject") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const body = String(formData.get("body") ?? "").normalize("NFC");
const variablesRaw = String(formData.get("variables") ?? "")
.normalize("NFC")
.trim();
const isActive = formData.get("isActive") != null;
if (!name || !subject || !body) return;
await db.insert(EmailTemplates).values({
name,
subject,
body,
variables: variablesRaw || null,
isActive,
});
revalidatePath("/admin/email-templates");
}
export async function updateEmailTemplate(formData: FormData): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT);
const raw = String(formData.get("id") ?? "").normalize("NFC");
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
const subject = String(formData.get("subject") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const body = String(formData.get("body") ?? "").normalize("NFC");
const variablesRaw = String(formData.get("variables") ?? "")
.normalize("NFC")
.trim();
const isActive = formData.get("isActive") != null;
if (!subject || !body) return;
await db
.update(EmailTemplates)
.set({
subject,
body,
variables: variablesRaw || null,
isActive,
})
.where(eq(EmailTemplates.id, id));
revalidatePath("/admin/email-templates");
}
export async function deleteEmailTemplate(formData: FormData): Promise<void> {
await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
await db.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
revalidatePath("/admin/email-templates");
}
+45
View File
@@ -0,0 +1,45 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
// emulator_texts: PK is the string column `key`, payload is `value` (VarChar 4096).
// Both tables are emulator-owned; we only ever read/update existing rows or add new
// keys via upsert. We never migrate or drop them.
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 100);
const value = String(formData.get("value") ?? "")
.normalize("NFC")
.slice(0, 512);
if (!key) return;
await db
.insert(EmulatorSettings)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
revalidatePath("/admin/emulator");
}
export async function updateEmulatorText(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 100);
const value = String(formData.get("value") ?? "")
.normalize("NFC")
.slice(0, 4096);
if (!key) return;
await db
.insert(EmulatorTexts)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
revalidatePath("/admin/emulator");
}
+72 -29
View File
@@ -1,48 +1,91 @@
// @ts-nocheck
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { disbandGuild } from "./admin-guilds";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
const { selectLimit, transactionFn, deleteWhere, updateSet } = vi.hoisted(
() => {
const selectLimit = vi.fn();
const transactionFn = vi.fn();
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const updateSet = vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) }));
return { selectLimit, transactionFn, deleteWhere, updateSet };
},
);
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { USERS_EDIT: "admin.users.edit" },
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
})),
})),
})),
transaction: transactionFn,
delete: vi.fn(() => ({ where: deleteWhere })),
update: vi.fn(() => ({ set: updateSet })),
},
Guilds: { id: "id", name: "name", userId: "userId" },
GuildsForumsThreads: { id: "id", guildId: "guildId" },
GuildsForumsComments: { threadId: "threadId" },
GuildForumViews: { guildId: "guildId" },
GuildsMembers: { guildId: "guildId" },
Rooms: { guildId: "guildId" },
Items: { guildId: "guildId" },
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" };
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
execute.mockResolvedValue({
ok: true,
data: { before: { id: 1 }, after: null },
correlationId: "guild",
});
});
describe("disbandGuild legacy wrapper", () => {
it("keeps rate-limited ACL, service input, and ASE revalidation", async () => {
await disbandGuild(form({ id: "9" }));
expect(execute).toHaveBeenCalledWith(expect.anything(), "guild.disband", {
guildId: 9,
});
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/community/guilds");
describe("disbandGuild", () => {
it("disbands guild and cleans related data", async () => {
selectLimit.mockResolvedValue([{ id: 1, name: "TestGuild", userId: 42 }]);
transactionFn.mockImplementation(
async (fn: (tx: unknown) => Promise<void>) => {
const txSelectLimit = vi.fn().mockResolvedValue([{ id: 10 }]);
const tx = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: txSelectLimit,
})),
})),
})),
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
})),
};
// For threads findMany (no limit) — make where resolve to array
tx.select = vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ id: 10 }]),
})),
}));
await fn(tx);
},
);
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
});
it("keeps invalid IDs as a no-op", async () => {
await disbandGuild(form({ id: "0" }));
expect(execute).not.toHaveBeenCalled();
expect(revalidatePath).not.toHaveBeenCalled();
it("returns early when id is not positive", async () => {
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData);
expect(selectLimit).not.toHaveBeenCalled();
});
});
+54 -17
View File
@@ -1,28 +1,65 @@
"use server";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import {
db,
GuildForumViews,
Guilds,
GuildsForumsComments,
GuildsForumsThreads,
GuildsMembers,
Items,
Rooms,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Disband a guild and clean related membership/forum rows. */
export async function disbandGuild(formData: FormData): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
const guildId = Number(formData.get("id"));
if (!Number.isSafeInteger(guildId) || guildId <= 0) return;
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"guild.disband",
{ guildId },
);
if (!result.ok) {
if (result.error.code === "NOT_FOUND") return;
throw new Error("Could not disband guild");
}
revalidatePath("/ase/people/community/guilds");
const [guild] = await db
.select({
id: Guilds.id,
name: Guilds.name,
userId: Guilds.userId,
})
.from(Guilds)
.where(eq(Guilds.id, id))
.limit(1);
if (!guild) return;
await db.transaction(async (tx) => {
const threads = await tx
.select({ id: GuildsForumsThreads.id })
.from(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
const threadIds = threads.map((t) => t.id);
if (threadIds.length > 0) {
await tx
.delete(GuildsForumsComments)
.where(inArray(GuildsForumsComments.threadId, threadIds));
await tx
.delete(GuildsForumsThreads)
.where(eq(GuildsForumsThreads.guildId, id));
}
await tx.delete(GuildForumViews).where(eq(GuildForumViews.guildId, id));
await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, id));
await tx.update(Rooms).set({ guildId: 0 }).where(eq(Rooms.guildId, id));
await tx.update(Items).set({ guildId: 0 }).where(eq(Items.guildId, id));
await tx.delete(Guilds).where(eq(Guilds.id, id));
});
await logStaffActivity({
staffId: staff.id,
action: "guild_disband",
description: `Disbanded guild #${id} (${guild.name}), owner #${guild.userId}`,
targetType: "guild",
targetId: id,
});
revalidatePath("/admin/guilds");
}
+136 -68
View File
@@ -1,30 +1,22 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
Ban,
db,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { canonicalTicketId } from "@/lib/services/ticket-replies";
import { logAudit } from "@/lib/services/audit";
const ticketIdField = z
.union([z.string(), z.number(), z.bigint()])
.transform((value, context) => {
try {
return canonicalTicketId(value);
} catch {
context.addIssue({
code: "custom",
message: "Invalid ticket identifier",
});
return z.NEVER;
}
});
.transform((v) => BigInt(String(v)));
const replyHelpCenterTicketSchema = z.object({
ticketId: ticketIdField,
@@ -37,24 +29,14 @@ const helpCenterTicketIdSchema = z.object({
function revalidateHelpCenterTicketPaths(ticketId: bigint) {
const id = String(ticketId);
revalidatePath("/ase/people/support/help-tickets");
revalidatePath(`/ase/people/support/help-tickets/${id}`);
revalidatePath("/admin/help-tickets");
revalidatePath(`/admin/help-tickets/${id}`);
revalidatePath("/mod/help-tickets");
revalidatePath(`/mod/help-tickets/${id}`);
revalidatePath("/help/tickets");
revalidatePath(`/help/tickets/${id}`);
}
async function execute(
staff: { readonly id: number },
operation: "help-ticket.reply" | "help-ticket.status" | "help-ticket.unban",
input: unknown,
) {
return peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
}
export const liftBanFromHelpTicket = adminAction(
{
permission: PERMS.USERS_BAN,
@@ -62,23 +44,50 @@ export const liftBanFromHelpTicket = adminAction(
},
async (ctx) => {
const ticketId = ctx.data.ticketId;
const result = await execute(ctx.session.user, "help-ticket.unban", {
ticketId: ticketId.toString(),
});
if (!result.ok) {
throw new ActionError(
result.error.code === "CONFLICT"
? "Ticket has no requester to unban"
: "Ticket not found",
);
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
title: WebsiteHelpCenterTickets.title,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.userId == null) {
throw new ActionError("Ticket has no requester to unban");
}
const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
const removed = Number(
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
const now = new Date();
if (ticket.open) {
await db
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
}
logAudit({
userId: ctx.session.user.id,
action: "unban_via_help_ticket",
target: "User",
targetId: ticket.userId,
after: {
ticketId: String(ticketId),
removedBans: removed,
title: ticket.title,
},
});
revalidateHelpCenterTicketPaths(ticketId);
revalidatePath("/ase/people/moderation/bans");
const removed = Number(result.data.output?.removed ?? 0);
const userId = Number(result.data.output?.userId);
revalidatePath(`/ase/people/users/${userId}`);
return actionOk({ removed, userId });
revalidatePath("/admin/bans");
revalidatePath(`/admin/users/show/${ticket.userId}`);
return actionOk({ removed, userId: ticket.userId });
},
);
@@ -88,11 +97,40 @@ export const replyHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const result = await execute(ctx.session.user, "help-ticket.reply", {
ticketId: ticketId.toString(),
content: ctx.data.content.trim(),
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
const now = new Date();
const staffId = Number(ctx.session.user.id);
await db.transaction(async (tx) => {
await tx.insert(WebsiteHelpCenterTicketReplies).values({
ticketId,
userId: staffId,
content: ctx.data.content.trim(),
createdAt: now,
updatedAt: now,
});
await tx
.update(WebsiteHelpCenterTickets)
.set({ updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
});
logAudit({
userId: staffId,
action: "help_center_ticket_reply",
target: "WebsiteHelpCenterTickets",
targetId: Number(ticketId),
});
if (!result.ok) throw new ActionError("Ticket not found");
revalidateHelpCenterTicketPaths(ticketId);
return actionOk();
@@ -103,17 +141,32 @@ export const closeHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const result = await execute(ctx.session.user, "help-ticket.status", {
ticketId: ticketId.toString(),
status: "close",
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
if (!ticket.open) throw new ActionError("Ticket is already closed");
const now = new Date();
await db
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
logAudit({
userId: Number(ctx.session.user.id),
action: "help_center_ticket_close",
target: "WebsiteHelpCenterTickets",
targetId: Number(ticketId),
before: { open: true },
after: { open: false },
});
if (!result.ok) {
throw new ActionError(
result.error.code === "CONFLICT"
? "Ticket is already closed"
: "Ticket not found",
);
}
revalidateHelpCenterTicketPaths(ticketId);
return actionOk();
@@ -124,17 +177,32 @@ export const reopenHelpCenterTicket = adminAction(
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
async (ctx) => {
const ticketId = ctx.data.ticketId;
const result = await execute(ctx.session.user, "help-ticket.status", {
ticketId: ticketId.toString(),
status: "reopen",
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
open: WebsiteHelpCenterTickets.open,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
if (ticket.open) throw new ActionError("Ticket is already open");
const now = new Date();
await db
.update(WebsiteHelpCenterTickets)
.set({ open: true, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
logAudit({
userId: Number(ctx.session.user.id),
action: "help_center_ticket_reopen",
target: "WebsiteHelpCenterTickets",
targetId: Number(ticketId),
before: { open: false },
after: { open: true },
});
if (!result.ok) {
throw new ActionError(
result.error.code === "CONFLICT"
? "Ticket is already open"
: "Ticket not found",
);
}
revalidateHelpCenterTicketPaths(ticketId);
return actionOk();
+47 -40
View File
@@ -7,16 +7,24 @@ import {
updateHelpQuestion,
} from "./admin-help";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
contentMutationService: { execute },
createContentMutationInvocation: (
actor: { id: number },
correlationId: string,
) => ({ expectedActorId: actor.id, correlationId, legacy: true }),
}));
const { insertValues, updateWhere, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 5 }]);
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, updateWhere, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteHelpCenterCategories: { id: "id" },
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
@@ -28,43 +36,42 @@ const fakeForm = (data: Record<string, string | null>) => ({
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
execute.mockResolvedValue({
ok: true,
data: { before: null, after: { id: "5" } },
correlationId: "legacy",
insertValues.mockResolvedValue([{ insertId: 5 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("createHelpQuestion", () => {
it("creates a help question and redirects", async () => {
await createHelpQuestion(
fakeForm({
name: "FAQ",
content: "<p>Answer</p>",
}) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
});
});
describe("Content help legacy wrappers", () => {
it("delegates create and redirects", async () => {
await createHelpQuestion(
fakeForm({ name: "FAQ", content: "<p>Answer</p>" }) as FormData,
);
expect(execute).toHaveBeenCalledWith(
expect.anything(),
"help-question.change",
expect.objectContaining({ action: "create", name: "FAQ" }),
);
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
});
it("delegates update and redirects", async () => {
describe("updateHelpQuestion", () => {
it("updates and redirects", async () => {
await updateHelpQuestion(
fakeForm({ id: "42", name: "Updated", content: "New" }) as FormData,
fakeForm({
id: "42",
name: "Updated",
content: "New",
}) as unknown as FormData,
);
expect(execute).toHaveBeenCalledWith(
expect.anything(),
"help-question.change",
expect.objectContaining({ action: "update", id: "42" }),
);
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
expect(updateWhere).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
});
it("delegates delete and redirects", async () => {
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
expect(execute).toHaveBeenCalledWith(
expect.anything(),
"help-question.change",
{ action: "delete", id: "42" },
);
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
});
describe("deleteHelpQuestion", () => {
it("deletes and redirects", async () => {
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
});
});
+107 -53
View File
@@ -1,79 +1,133 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import {
contentMutationService,
createContentMutationInvocation,
} from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
function helpInput(formData: FormData) {
return {
name: sanitizeField(formData.get("name")),
content: canonicalize(String(formData.get("content") ?? "")),
position:
Number(formData.get("position")) > 0
? Math.floor(Number(formData.get("position")))
: 1,
imageUrl: sanitizeField(formData.get("imageUrl")),
buttonText: sanitizeField(formData.get("buttonText")),
buttonUrl: sanitizeField(formData.get("buttonUrl")),
buttonColor: sanitizeField(formData.get("buttonColor"), 16) || "#eeb425",
buttonBorderColor:
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15",
smallBox: formData.get("smallBox") != null,
};
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
// is a titled content block with an optional image and call-to-action button.
function parsePosition(value: FormDataEntryValue | null): number {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
}
export async function createHelpQuestion(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const input = helpInput(formData);
if (!input.name || !input.content) return;
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"help-question.change",
{ action: "create", ...input },
);
if (!result.ok) {
revalidatePath("/ase/content/help/questions");
const name = sanitizeField(formData.get("name"));
const content = canonicalize(String(formData.get("content") ?? ""));
if (!name || !content) return;
const imageUrl = sanitizeField(formData.get("imageUrl"));
const buttonText = sanitizeField(formData.get("buttonText"));
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
const buttonColor =
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
const buttonBorderColor =
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
try {
const [result] = (await db.insert(WebsiteHelpCenterCategories).values({
name,
content,
position: parsePosition(formData.get("position")),
imageUrl: imageUrl || null,
buttonText: buttonText || null,
buttonUrl: buttonUrl || null,
buttonColor,
buttonBorderColor,
smallBox: formData.get("smallBox") != null,
})) as unknown as [ResultSetHeader];
await logStaffActivity({
staffId: staff.id,
action: "help_create",
description: `Created help-center entry #${result.insertId} (${name})`,
targetType: "help_center_category",
targetId: Number(result.insertId),
});
} catch {
// Unique name collision or DB error — re-render unchanged with error.
revalidatePath("/admin/help-questions");
redirect(
"/ase/content/help/questions/new?error=Unique name collision or database error. Please try again.",
"/admin/help-questions/new?error=Unique name collision or database error. Please try again.",
);
}
revalidatePath("/ase/content/help/questions");
redirect("/ase/content/help/questions");
revalidatePath("/admin/help-questions");
redirect("/admin/help-questions");
}
export async function updateHelpQuestion(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = String(formData.get("id") ?? "").trim();
if (!/^[1-9]\d*$/u.test(id)) return;
const input = helpInput(formData);
if (!input.name || !input.content) return;
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"help-question.change",
{ action: "update", id, ...input },
);
if (!result.ok) {
revalidatePath(`/ase/content/help/questions/${id}`);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
const name = sanitizeField(formData.get("name"));
const content = canonicalize(String(formData.get("content") ?? ""));
if (!name || !content) return;
const imageUrl = sanitizeField(formData.get("imageUrl"));
const buttonText = sanitizeField(formData.get("buttonText"));
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
const buttonColor =
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
const buttonBorderColor =
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
try {
await db
.update(WebsiteHelpCenterCategories)
.set({
name,
content,
position: parsePosition(formData.get("position")),
imageUrl: imageUrl || null,
buttonText: buttonText || null,
buttonUrl: buttonUrl || null,
buttonColor,
buttonBorderColor,
smallBox: formData.get("smallBox") != null,
})
.where(eq(WebsiteHelpCenterCategories.id, id));
await logStaffActivity({
staffId: staff.id,
action: "help_update",
description: `Updated help-center entry #${id} (${name})`,
targetType: "help_center_category",
targetId: Number(id),
});
} catch {
// Not found, unique collision, or DB error — ignore.
revalidatePath(`/admin/help-questions/${id}`);
return;
}
redirect("/ase/content/help/questions");
redirect("/admin/help-questions");
}
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = String(formData.get("id") ?? "").trim();
if (!/^[1-9]\d*$/u.test(id)) return;
await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"help-question.change",
{ action: "delete", id },
);
redirect("/ase/content/help/questions");
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await db
.delete(WebsiteHelpCenterCategories)
.where(eq(WebsiteHelpCenterCategories.id, id));
await logStaffActivity({
staffId: staff.id,
action: "help_delete",
description: `Deleted help-center entry #${id}`,
targetType: "help_center_category",
targetId: Number(id),
});
} catch {
// Not found or DB error — ignore.
}
redirect("/admin/help-questions");
}
+26
View File
@@ -0,0 +1,26 @@
"use server";
import { asc } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteHousekeepingPermissions } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
export async function exportPermissions(): Promise<string> {
await requirePermission(PERMS.SETTINGS_VIEW);
const perms = await db
.select({
permission: WebsiteHousekeepingPermissions.permission,
minRank: WebsiteHousekeepingPermissions.minRank,
description: WebsiteHousekeepingPermissions.description,
groupName: WebsiteHousekeepingPermissions.groupName,
dependsOn: WebsiteHousekeepingPermissions.dependsOn,
})
.from(WebsiteHousekeepingPermissions)
.orderBy(
asc(WebsiteHousekeepingPermissions.groupName),
asc(WebsiteHousekeepingPermissions.permission),
);
return JSON.stringify(perms, null, 2);
}
+60 -44
View File
@@ -1,5 +1,6 @@
// @ts-nocheck
import { revalidatePath } from "next/cache";
import { beforeEach, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import {
addBlacklist,
@@ -8,65 +9,80 @@ import {
deleteWhitelist,
} from "./admin-ip";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
const { insertValues, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
PERMS: { SETTINGS_EDIT: "settings.edit" },
}));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteIpWhitelist: { id: "id" },
WebsiteIpBlacklist: { id: "id" },
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" };
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
execute.mockResolvedValue({
ok: true,
data: { before: null, after: {} },
correlationId: "ip",
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("addWhitelist", () => {
it("creates whitelist entry", async () => {
await addWhitelist(
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith({
ipAddress: "192.168.1.1",
asn: null,
whitelistAsn: false,
});
expect(revalidatePath).toHaveBeenCalledWith("/admin/ip");
});
it("returns early when ip is empty", async () => {
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
expect(insertValues).not.toHaveBeenCalled();
});
});
it("preserves all four IP actions and /admin revalidation", async () => {
await addWhitelist(form({ ipAddress: "192.0.2.1", asn: "AS1" }));
await addBlacklist(form({ ipAddress: "198.51.100.1" }));
await deleteWhitelist(form({ id: "42" }));
await deleteBlacklist(form({ id: "99" }));
expect(execute.mock.calls.map((call) => [call[1], call[2]])).toEqual([
[
"ip.action",
{ action: "add-whitelist", ipAddress: "192.0.2.1", asn: "AS1" },
],
[
"ip.action",
{ action: "add-blacklist", ipAddress: "198.51.100.1", asn: "" },
],
["ip.action", { action: "delete-whitelist", id: "42" }],
["ip.action", { action: "delete-blacklist", id: "99" }],
]);
expect(revalidatePath).toHaveBeenCalledTimes(4);
describe("deleteWhitelist", () => {
it("deletes whitelist entry", async () => {
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
});
});
it("keeps empty IP input as a no-op after authorization", async () => {
await addWhitelist(form({ ipAddress: "" }));
expect(requirePermission).toHaveBeenCalledWith("admin.settings.edit");
expect(execute).not.toHaveBeenCalled();
describe("addBlacklist", () => {
it("creates blacklist entry", async () => {
await addBlacklist(
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith({
ipAddress: "203.0.113.1",
asn: null,
blacklistAsn: false,
});
});
});
it("preserves an IP rule ID above Number.MAX_SAFE_INTEGER", async () => {
await deleteBlacklist(form({ id: "9007199254740993" }));
expect(execute).toHaveBeenCalledWith(expect.anything(), "ip.action", {
action: "delete-blacklist",
id: "9007199254740993",
describe("deleteBlacklist", () => {
it("deletes blacklist entry", async () => {
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
});
});
+51 -39
View File
@@ -1,60 +1,72 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { formPositiveBigInt } from "@/lib/form-data";
import { db, WebsiteIpBlacklist, WebsiteIpWhitelist } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
function parse(formData: FormData, key: string): string {
return String(formData.get(key) ?? "")
function parseIp(formData: FormData): string {
return String(formData.get("ipAddress") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
}
async function run(
formData: FormData,
action:
| "add-whitelist"
| "delete-whitelist"
| "add-blacklist"
| "delete-blacklist",
): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const adding = action.startsWith("add-");
const rawId = adding ? null : formPositiveBigInt(formData, "id");
const input = adding
? {
action,
ipAddress: parse(formData, "ipAddress"),
asn: parse(formData, "asn"),
}
: { action, id: rawId?.toString() ?? "" };
if (adding && !("ipAddress" in input && input.ipAddress)) return;
if (!adding && !rawId) return;
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"ip.action",
input,
);
if (!result.ok) throw new Error("Could not update IP rules");
revalidatePath("/ase/people/moderation/ip");
function parseAsn(formData: FormData): string | null {
const asn = String(formData.get("asn") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
return asn || null;
}
export async function addWhitelist(formData: FormData): Promise<void> {
return run(formData, "add-whitelist");
await requirePermission(PERMS.SETTINGS_EDIT);
const ipAddress = parseIp(formData);
if (!ipAddress) return;
const asn = parseAsn(formData);
await db.insert(WebsiteIpWhitelist).values({
ipAddress,
asn,
whitelistAsn: asn != null,
});
revalidatePath("/admin/ip");
}
export async function deleteWhitelist(formData: FormData): Promise<void> {
return run(formData, "delete-whitelist");
await requirePermission(PERMS.SETTINGS_EDIT);
const raw = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (!raw) return;
await db
.delete(WebsiteIpWhitelist)
.where(eq(WebsiteIpWhitelist.id, BigInt(raw)));
revalidatePath("/admin/ip");
}
export async function addBlacklist(formData: FormData): Promise<void> {
return run(formData, "add-blacklist");
await requirePermission(PERMS.SETTINGS_EDIT);
const ipAddress = parseIp(formData);
if (!ipAddress) return;
const asn = parseAsn(formData);
await db.insert(WebsiteIpBlacklist).values({
ipAddress,
asn,
blacklistAsn: asn != null,
});
revalidatePath("/admin/ip");
}
export async function deleteBlacklist(formData: FormData): Promise<void> {
return run(formData, "delete-blacklist");
await requirePermission(PERMS.SETTINGS_EDIT);
const raw = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (!raw) return;
await db
.delete(WebsiteIpBlacklist)
.where(eq(WebsiteIpBlacklist.id, BigInt(raw)));
revalidatePath("/admin/ip");
}
+1 -3
View File
@@ -96,9 +96,7 @@ describe("saveMaintenance", () => {
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
expect(mockReload).toHaveBeenCalledOnce();
expect(mockRevalidatePath).toHaveBeenCalledWith(
"/ase/system/operations/maintenance",
);
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
});
it("disables maintenance mode", async () => {
+31 -30
View File
@@ -1,11 +1,10 @@
"use server";
import { revalidatePath } from "next/cache";
import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations";
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
// Maintenance mode lives in three CMS-owned website_settings rows (mirrors
// AtomCMS's MaintenanceToggle Livewire component):
@@ -15,25 +14,32 @@ import { PERMS } from "@/lib/permissions";
// The Laravel login flow reads these via setting() to gate non-staff logins
// while maintenance is on, so the website_settings keys are the source of truth.
function mutationContext(staff: {
id: number;
rank: number;
username: string;
}) {
const matches = (slug: string) => slug === PERMS.SETTINGS_EDIT;
return {
capability: createHousekeepingCapabilityContext(staff, {
isSuperAdmin: false,
has: matches,
hasAny: (...slugs: string[]) => slugs.some(matches),
hasAll: (...slugs: string[]) => slugs.every(matches),
}),
correlationId: createCorrelationId(),
};
const KEY_ENABLED = "maintenance_enabled";
const KEY_MESSAGE = "maintenance_message";
const KEY_MIN_RANK = "min_maintenance_login_rank";
const COMMENTS: Record<string, string> = {
[KEY_ENABLED]: "Determines whether maintenance is enabled or not",
[KEY_MESSAGE]:
"The maintenance message displayed to users while maintenance is activated",
[KEY_MIN_RANK]:
"The minimum rank required to login to the hotel during maintenance",
};
async function upsertSetting(key: string, value: string): Promise<void> {
await db
.insert(WebsiteSetting)
.values({
key,
value,
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
comment: COMMENTS[key] ?? null,
})
.onDuplicateKeyUpdate({ set: { value } });
}
export async function saveMaintenance(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
await requirePermission(PERMS.SETTINGS_EDIT);
// Checkbox: present only when ticked. Normalise to the '1'/'0' string the
// emulator/Laravel side expects.
@@ -50,15 +56,10 @@ export async function saveMaintenance(formData: FormData): Promise<void> {
const minRank =
Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5;
const result = await systemMutationService.execute(
mutationContext(staff),
"operations.maintenance.update",
{
enabled: enabled === "1",
message,
minimumLoginRank: minRank,
},
);
if (!result.ok) throw new Error(result.error.messageKey);
revalidatePath("/ase/system/operations/maintenance");
await upsertSetting(KEY_ENABLED, enabled);
await upsertSetting(KEY_MESSAGE, message);
await upsertSetting(KEY_MIN_RANK, String(minRank));
siteSettings.reload();
revalidatePath("/admin/maintenance");
}
+44
View File
@@ -0,0 +1,44 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { db, MarketplaceItems } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Cancel an active marketplace listing (state 1 → 0). */
export async function cancelMarketplaceListing(
formData: FormData,
): Promise<void> {
const staff = await requirePermissionRateLimited(PERMS.SHOP_EDIT);
const id = Number(formData.get("id"));
if (!(id > 0)) return;
const [listing] = await db
.select({
id: MarketplaceItems.id,
state: MarketplaceItems.state,
userId: MarketplaceItems.userId,
itemId: MarketplaceItems.itemId,
price: MarketplaceItems.price,
})
.from(MarketplaceItems)
.where(eq(MarketplaceItems.id, id))
.limit(1);
if (listing?.state !== 1) return;
await db
.update(MarketplaceItems)
.set({ state: 0 })
.where(eq(MarketplaceItems.id, id));
await logStaffActivity({
staffId: staff.id,
action: "marketplace_cancel",
description: `Cancelled marketplace listing #${id} (item ${listing.itemId}, user ${listing.userId}, price ${listing.price})`,
targetType: "marketplace",
targetId: id,
});
revalidatePath("/admin/marketplace");
}
+38 -44
View File
@@ -1,65 +1,59 @@
// @ts-nocheck
import path from "node:path";
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { resolveMediaPath } from "@/lib/media-storage";
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
contentMutationService: { execute },
createContentMutationInvocation: (actor, correlationId) => ({
expectedActorId: actor.id,
correlationId,
legacy: true,
}),
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/media-storage", () => {
const root = path.join("/tmp", "nexst-test-media");
return {
MEDIA_ROOT: root,
resolveMediaPath: vi.fn((name: string) => path.join(root, name)),
};
});
vi.mock("node:fs/promises", () => ({
mkdir: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
execute.mockResolvedValue({
ok: true,
data: {
before: null,
after: { name: "photo.png" },
output: { url: "/api/media/photo.png" },
},
correlationId: "legacy",
vi.mocked(requirePermission).mockResolvedValue(staff as never);
});
describe("uploadMedia", () => {
it("returns error when no file provided", async () => {
const result = await uploadMedia(new FormData());
expect(result.ok).toBe(false);
expect(result.error).toBe("No file provided");
});
});
describe("Content media legacy wrappers", () => {
it("retains no-file validation", async () => {
expect(await uploadMedia(new FormData())).toEqual({
ok: false,
error: "No file provided",
});
describe("uploadMediaAndReturn", () => {
it("returns empty string when no file", async () => {
expect(await uploadMediaAndReturn(new FormData())).toBe("");
expect(execute).not.toHaveBeenCalled();
});
it("delegates a valid upload and preserves both result shapes", async () => {
const file = new File(["bytes"], "photo.png", { type: "image/png" });
const form = new FormData();
form.set("file", file);
expect(await uploadMedia(form)).toEqual({ ok: true });
expect(await uploadMediaAndReturn(form)).toBe("/api/media/photo.png");
expect(execute).toHaveBeenCalledWith(expect.anything(), "media.upload", {
file,
});
});
it("delegates deletion and preserves revalidation", async () => {
});
describe("deleteMedia", () => {
it("deletes media file and revalidates", async () => {
await deleteMedia("photo.png");
expect(execute).toHaveBeenCalledWith(expect.anything(), "media.delete", {
filename: "photo.png",
});
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/media/library");
});
it("skips deletion when path is outside media root", async () => {
vi.mocked(resolveMediaPath).mockReturnValue("/etc/passwd");
await deleteMedia("../../../etc/passwd");
const { unlink } = await import("node:fs/promises");
expect(unlink).not.toHaveBeenCalled();
});
});
+59 -50
View File
@@ -1,74 +1,83 @@
"use server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import {
contentMutationService,
createContentMutationInvocation,
} from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
import { PERMS } from "@/lib/permissions";
const MAX_SIZE = 5 * 1024 * 1024;
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
function mediaFile(formData: FormData): File | null {
const value = formData.get("file");
return value && typeof value === "object" ? (value as File) : null;
}
function validateMediaFile(file: File | null): string | null {
if (!file || file.size === 0) return "No file provided";
if (file.size > MAX_SIZE) return "File too large (max 5MB)";
if (!ALLOWED.includes(file.type))
return "Invalid file type. Allowed: PNG, JPEG, GIF, WebP";
return null;
}
export async function uploadMedia(
formData: FormData,
): Promise<{ ok: boolean; error?: string }> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const file = mediaFile(formData);
const error = validateMediaFile(file);
if (error) return { ok: false, error };
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"media.upload",
{ file },
);
if (!result.ok) throw new Error("Media upload failed");
await requirePermission(PERMS.PAGES_EDIT);
const file = formData.get("file") as File | null;
if (!file || file.size === 0) return { ok: false, error: "No file provided" };
if (file.size > MAX_SIZE)
return { ok: false, error: "File too large (max 5MB)" };
if (!ALLOWED.includes(file.type))
return {
ok: false,
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP",
};
const baseDir = MEDIA_ROOT;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
const ext = file.name.split(".").pop() ?? "png";
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const bytes = await file.arrayBuffer();
const filePath = resolveMediaPath(name);
if (!filePath.startsWith(baseDir + path.sep)) throw new Error("Invalid path");
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes));
revalidatePath("/api/media");
revalidatePath("/ase/content/media/library");
revalidatePath("/admin/media");
return { ok: true };
}
export async function deleteMedia(name: string): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"media.delete",
{ filename: name },
);
await requirePermission(PERMS.PAGES_EDIT);
const { unlink } = await import("node:fs/promises");
const baseDir = MEDIA_ROOT;
const filePath = resolveMediaPath(name);
if (!filePath.startsWith(baseDir + path.sep)) return;
try {
await unlink(filePath);
} catch {
// File may not exist
}
revalidatePath("/api/media");
revalidatePath("/ase/content/media/library");
revalidatePath("/admin/media");
}
export async function uploadMediaAndReturn(
formData: FormData,
): Promise<string> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const file = mediaFile(formData);
if (validateMediaFile(file)) return "";
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"media.upload",
{ file },
);
if (!result.ok) return "";
await requirePermission(PERMS.PAGES_EDIT);
const file = formData.get("file") as File | null;
if (!file || file.size === 0) return "";
if (file.size > MAX_SIZE) return "";
if (!ALLOWED.includes(file.type)) return "";
const baseDir = MEDIA_ROOT;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
const ext = file.name.split(".").pop() ?? "png";
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const bytes = await file.arrayBuffer();
const filePath = resolveMediaPath(name);
if (!filePath.startsWith(baseDir + path.sep)) return "";
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes));
revalidatePath("/api/media");
revalidatePath("/ase/content/media/library");
return typeof result.data.output?.url === "string"
? result.data.output.url
: "";
revalidatePath("/admin/media");
return `/api/media/${name}`;
}
+43
View File
@@ -0,0 +1,43 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
ADMIN_NAV_CONFIG_KEY,
type AdminNavConfig,
serializeAdminNavConfig,
} from "@/lib/admin-nav-config";
import { actionOk, adminAction } from "@/lib/foundation/action";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
const schema = z.object({
groupOrder: z.array(z.string()),
hiddenGroups: z.array(z.string()),
hiddenItems: z.array(z.string()),
itemOrder: z.record(z.string(), z.array(z.string())),
});
export const saveAdminNavConfig = adminAction(
{
permission: PERMS.SETTINGS_EDIT,
schema,
rateLimitKey: "admin-nav-config-save",
rateLimitMax: 30,
},
async (ctx) => {
const config: AdminNavConfig = {
groupOrder: ctx.data.groupOrder,
hiddenGroups: ctx.data.hiddenGroups,
hiddenItems: ctx.data.hiddenItems,
itemOrder: ctx.data.itemOrder,
};
await siteSettings.update(
ADMIN_NAV_CONFIG_KEY,
serializeAdminNavConfig(config),
);
revalidatePath("/admin", "layout");
revalidatePath("/admin/menu");
return actionOk({ saved: true });
},
);
+46 -46
View File
@@ -1,72 +1,72 @@
// @ts-nocheck
import { readFileSync } from "node:fs";
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { deletePhoto } from "./admin-photos";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
contentMutationService: { execute },
createContentMutationInvocation: (actor, correlationId) => ({
expectedActorId: actor.id,
correlationId,
legacy: true,
}),
}));
const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => {
const limit = vi.fn();
const whereSelect = vi.fn(() => ({ limit }));
const from = vi.fn(() => ({ where: whereSelect }));
const select = vi.fn(() => ({ from }));
const whereDelete = vi.fn();
const deleteFn = vi.fn(() => ({ where: whereDelete }));
return { select, deleteFn, limit, whereDelete, whereSelect, from };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/admin/photo-files", () => ({
tryRemoveLocalPhotoFile: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/db", () => ({
db: {
select: (...args) => select(...args),
delete: (...args) => deleteFn(...args),
},
CameraWeb: { id: "id", url: "url" },
}));
const fakeForm = (data) => ({
get: (key) => data[key] ?? null,
});
beforeEach(() => {
vi.clearAllMocks();
limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]);
whereDelete.mockResolvedValue(undefined);
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
execute.mockResolvedValue({
ok: true,
data: { before: { id: 42 }, after: null },
correlationId: "legacy",
});
});
describe("deletePhoto", () => {
it("delegates deletion and preserves both revalidations", async () => {
await deletePhoto({ get: (key) => (key === "id" ? "42" : null) });
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", {
id: 42,
});
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/media/photos");
it("deletes a photo and revalidates", async () => {
await deletePhoto(fakeForm({ id: "42" }));
expect(select).toHaveBeenCalled();
expect(deleteFn).toHaveBeenCalled();
expect(tryRemoveLocalPhotoFile).toHaveBeenCalledWith("/uploads/cam/42.png");
expect(logStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: "photo_delete",
targetId: 42,
}),
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
expect(revalidatePath).toHaveBeenCalledWith("/photos");
});
it("returns early when id is not positive", async () => {
await deletePhoto({ get: () => "0" });
expect(execute).not.toHaveBeenCalled();
});
});
describe("admin-photos extracted runtime contract", () => {
it("keeps the wrapper and owning runtime responsible for purge and audit", () => {
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
const runtime = readFileSync(
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
"utf8",
);
expect(wrapper).toContain('"photo.delete"');
expect(wrapper).toContain('revalidatePath("/photos")');
expect(runtime).toContain("CameraWeb");
expect(runtime).toContain("tryRemoveLocalPhotoFile");
expect(runtime).toContain("logStaffActivity");
});
it("rejects traversal and remote photo purge targets", async () => {
expect(await tryRemoveLocalPhotoFile("https://cdn.example/photo.png")).toBe(
false,
);
expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false);
expect(await tryRemoveLocalPhotoFile("")).toBe(false);
await deletePhoto(fakeForm({ id: "0" }));
expect(select).not.toHaveBeenCalled();
expect(deleteFn).not.toHaveBeenCalled();
});
});
+24 -11
View File
@@ -1,23 +1,36 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
contentMutationService,
createContentMutationInvocation,
} from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
import { CameraWeb, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function deletePhoto(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = Number(formData.get("id"));
if (!(id > 0)) return;
await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"photo.delete",
{ id },
);
revalidatePath("/ase/content/media/photos");
const [row] = await db
.select({ id: CameraWeb.id, url: CameraWeb.url })
.from(CameraWeb)
.where(eq(CameraWeb.id, id))
.limit(1);
if (row) {
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
await tryRemoveLocalPhotoFile(row.url);
await logStaffActivity({
staffId: staff.id,
action: "photo_delete",
description: `Deleted camera photo #${id}`,
targetType: "camera_web",
targetId: id,
});
}
revalidatePath("/admin/photos");
revalidatePath("/photos");
}
+116 -20
View File
@@ -1,40 +1,136 @@
"use server";
import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioApiKeys } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
function text(formData: FormData, key: string): string {
return String(formData.get(key) ?? "")
.normalize("NFC")
.trim();
// Radio API keys (radio_api_keys). External integrations (AzureCast bridges,
// widgets, bots) authenticate with a server-generated key. The key itself is
// minted here with crypto.randomBytes — never accepted from the form — and the
// `permissions` JSON column is intentionally left untouched by this CMS slice.
function str(raw: FormDataEntryValue | null): string {
return typeof raw === "string" ? raw : "";
}
/** Parse a BigInt id from a form value, or null when blank/invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
const s = str(raw).trim();
if (!s) return null;
try {
return BigInt(s);
} catch {
return null;
}
}
/** Clamp a form value to a non-negative integer (defaulting to `fallback`). */
function intOr(raw: FormDataEntryValue | null, fallback: number): number {
const n = Number(str(raw).trim());
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.floor(n);
}
export async function createApiKey(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.RADIO_EDIT);
await executeLegacyHotelMutation(staff, "radio.api-key.create", {
name: text(formData, "name"),
allowedIps: text(formData, "allowedIps") || undefined,
rateLimit: Number(text(formData, "rateLimit") || 300),
});
revalidatePath("/ase/hotel/radio/api-keys");
redirect("/ase/hotel/radio/api-keys?created=1");
const name = str(formData.get("name")).trim().slice(0, 255);
if (!name) return;
const rateLimit = intOr(formData.get("rateLimit"), 300);
const allowedIps =
str(formData.get("allowedIps")).trim().slice(0, 255) || null;
// Server-side key generation — 24 random bytes → 48 hex chars (fits VarChar(64)).
const key = randomBytes(24).toString("hex");
const now = new Date();
try {
const [result] = await db.insert(RadioApiKeys).values({
name,
key,
allowedIps,
rateLimit,
isActive: true,
createdAt: now,
updatedAt: now,
});
const createdId = BigInt(result.insertId);
await logStaffActivity({
staffId: staff.id,
action: "radio_api_key_create",
description: `Created radio API key "${name}" (#${createdId}, rate limit ${rateLimit})`,
targetType: "radio_api_key",
targetId: Number(createdId),
});
} catch {
// Unique-key collision (astronomically unlikely) or DB down — fail soft.
return;
}
revalidatePath("/admin/radio/api-keys");
redirect("/admin/radio/api-keys?created=1");
}
export async function toggleApiKey(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.RADIO_EDIT);
await executeLegacyHotelMutation(staff, "radio.api-key.toggle", {
id: text(formData, "id"),
});
revalidatePath("/ase/hotel/radio/api-keys");
const id = parseId(formData.get("id"));
if (id == null) return;
try {
const [existing] = await db
.select({
name: RadioApiKeys.name,
isActive: RadioApiKeys.isActive,
})
.from(RadioApiKeys)
.where(eq(RadioApiKeys.id, id))
.limit(1);
if (!existing) return;
const next = !existing.isActive;
await db
.update(RadioApiKeys)
.set({ isActive: next, updatedAt: new Date() })
.where(eq(RadioApiKeys.id, id));
await logStaffActivity({
staffId: staff.id,
action: "radio_api_key_toggle",
description: `${next ? "Activated" : "Deactivated"} radio API key "${existing.name}" (#${id})`,
targetType: "radio_api_key",
targetId: Number(id),
});
} catch {
return;
}
revalidatePath("/admin/radio/api-keys");
}
export async function deleteApiKey(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.RADIO_EDIT);
await executeLegacyHotelMutation(staff, "radio.api-key.delete", {
id: text(formData, "id"),
});
revalidatePath("/ase/hotel/radio/api-keys");
const id = parseId(formData.get("id"));
if (id == null) return;
try {
await db.delete(RadioApiKeys).where(eq(RadioApiKeys.id, id));
await logStaffActivity({
staffId: staff.id,
action: "radio_api_key_delete",
description: `Deleted radio API key #${id}`,
targetType: "radio_api_key",
targetId: Number(id),
});
} catch {
return;
}
revalidatePath("/admin/radio/api-keys");
}
+117 -27
View File
@@ -1,48 +1,138 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioAutoDjPlaylist } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
function text(formData: FormData, key: string): string {
return String(formData.get(key) ?? "")
.normalize("NFC")
.trim();
// AutoDJ playlist CRUD (radio_auto_dj_playlist). CMS-owned table backing the
// fallback playlist the radio rotates through when no live DJ is streaming.
// Faithful to AtomCMS: a flat list of tracks ordered by sort_order then title.
// ── Helpers ──────────────────────────────────────────────────────────────
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== "string" || raw.trim() === "") return null;
try {
const id = BigInt(raw.trim());
return id > 0n ? id : null;
} catch {
return null;
}
}
function enabled(formData: FormData, key: string): boolean {
return ["1", "true", "on"].includes(text(formData, key).toLowerCase());
function str(raw: FormDataEntryValue | null): string {
return typeof raw === "string" ? raw : "";
}
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */
function bool(raw: FormDataEntryValue | null): boolean {
const v = str(raw).trim().toLowerCase();
return v === "1" || v === "true" || v === "on";
}
/** Parse a non-negative UnsignedInt, falling back to 0. */
function reqUInt(raw: FormDataEntryValue | null): number {
const n = Number(str(raw).trim());
if (!Number.isFinite(n) || n < 0) return 0;
return Math.trunc(n);
}
/** Parse an optional non-negative UnsignedInt; blank/invalid/negative → null. */
function optUInt(raw: FormDataEntryValue | null): number | null {
const s = str(raw).trim();
if (s === "") return null;
const n = Number(s);
if (!Number.isFinite(n) || n < 0) return null;
return Math.trunc(n);
}
// ── AutoDJ playlist CRUD (radio_auto_dj_playlist) ────────────────────────
export async function createTrack(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.RADIO_EDIT);
const duration = text(formData, "duration");
await executeLegacyHotelMutation(staff, "radio.autodj.create", {
title: text(formData, "title"),
artist: text(formData, "artist") || undefined,
album: text(formData, "album") || undefined,
artworkUrl: text(formData, "artworkUrl") || undefined,
duration: duration ? Number(duration) : null,
sortOrder: Number(text(formData, "sortOrder") || 0),
isActive: enabled(formData, "isActive"),
});
revalidatePath("/ase/hotel/radio/autodj");
const title = str(formData.get("title")).trim().slice(0, 255);
if (!title) return;
const artist = str(formData.get("artist")).trim().slice(0, 255);
const album = str(formData.get("album")).trim().slice(0, 255);
const artworkUrl = str(formData.get("artworkUrl")).trim().slice(0, 255);
const duration = optUInt(formData.get("duration"));
const sortOrder = reqUInt(formData.get("sortOrder"));
const isActive = bool(formData.get("isActive"));
const now = new Date();
try {
const [result] = await db.insert(RadioAutoDjPlaylist).values({
title,
artist: artist || null,
album: album || null,
artworkUrl: artworkUrl || null,
duration,
sortOrder,
isActive,
createdAt: now,
updatedAt: now,
});
const createdId = Number(result.insertId);
await logStaffActivity({
staffId: staff.id,
action: "radio_autodj_create",
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
targetType: "radio_auto_dj_track",
targetId: createdId,
});
} catch {
// Fail soft — DB unavailable; re-render without throwing.
}
revalidatePath("/admin/radio/autodj");
}
export async function toggleTrack(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.RADIO_EDIT);
await executeLegacyHotelMutation(staff, "radio.autodj.toggle", {
id: text(formData, "id"),
isActive: enabled(formData, "isActive"),
});
revalidatePath("/ase/hotel/radio/autodj");
const id = parseId(formData.get("id"));
if (id === null) return;
// The form posts the desired next state so the toggle is idempotent.
const isActive = bool(formData.get("isActive"));
try {
await db
.update(RadioAutoDjPlaylist)
.set({ isActive, updatedAt: new Date() })
.where(eq(RadioAutoDjPlaylist.id, id));
await logStaffActivity({
staffId: staff.id,
action: "radio_autodj_toggle",
description: `${isActive ? "Activated" : "Deactivated"} AutoDJ track #${id}`,
targetType: "radio_auto_dj_track",
targetId: Number(id),
});
} catch {
// Row may be gone; ignore.
}
revalidatePath("/admin/radio/autodj");
}
export async function deleteTrack(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.RADIO_EDIT);
await executeLegacyHotelMutation(staff, "radio.autodj.delete", {
id: text(formData, "id"),
});
revalidatePath("/ase/hotel/radio/autodj");
const id = parseId(formData.get("id"));
if (id === null) return;
try {
await db.delete(RadioAutoDjPlaylist).where(eq(RadioAutoDjPlaylist.id, id));
await logStaffActivity({
staffId: staff.id,
action: "radio_autodj_delete",
description: `Deleted AutoDJ track #${id}`,
targetType: "radio_auto_dj_track",
targetId: Number(id),
});
} catch {
// Already deleted; ignore.
}
revalidatePath("/admin/radio/autodj");
}
+195 -80
View File
@@ -1,119 +1,234 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioBanners, RadioRanks, WebsiteSetting } from "@/lib/db";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
function text(formData: FormData, key: string): string {
return String(formData.get(key) ?? "")
.normalize("NFC")
.trim();
// ── Helpers ────────────────────────────────────────────────────────────────
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== "string" || raw.trim() === "") return null;
try {
const id = BigInt(raw.trim());
return id > 0n ? id : null;
} catch {
return null;
}
}
function enabled(formData: FormData, key: string): boolean {
return ["1", "true", "on"].includes(text(formData, key).toLowerCase());
function str(raw: FormDataEntryValue | null): string {
return typeof raw === "string" ? raw : "";
}
async function actor() {
return requirePermission(PERMS.RADIO_EDIT);
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */
function bool(raw: FormDataEntryValue | null): boolean {
const v = str(raw).trim().toLowerCase();
return v === "1" || v === "true" || v === "on";
}
// ── Radio settings (website_settings radio_* keys) ─────────────────────────
/**
* Upsert one radio_* website_settings key. Mirrors AtomCMS's
* RadioSettings Filament page (key/value rows in website_settings). Busts the
* siteSettings cache so the public radio pages pick the change up immediately.
*/
export async function saveRadioSetting(formData: FormData): Promise<void> {
const staff = await actor();
await executeLegacyHotelMutation(staff, "radio.settings.save-one", {
key: text(formData, "key"),
value: String(formData.get("value") ?? ""),
comment: text(formData, "comment") || undefined,
});
siteSettings.reload();
revalidatePath("/ase/hotel/radio/settings");
await requirePermission(PERMS.RADIO_EDIT);
const key = str(formData.get("key")).trim().slice(0, 255);
const value = str(formData.get("value"));
const comment = str(formData.get("comment")).trim().slice(0, 255);
if (!key) return;
try {
await db
.insert(WebsiteSetting)
.values({ key, value, comment: comment || null })
.onDuplicateKeyUpdate({ set: { value } });
siteSettings.reload();
} catch (err) {
logger.error("Failed to save radio setting", { err, key });
}
revalidatePath("/admin/radio/settings");
}
/**
* Bulk-save every radio_* field submitted by the settings form in one pass.
* The form posts a hidden `__keys` field listing the keys it rendered so we
* only touch those (and never wipe unrelated settings).
*/
export async function saveRadioSettings(formData: FormData): Promise<void> {
const staff = await actor();
const entries = text(formData, "__keys")
await requirePermission(PERMS.RADIO_EDIT);
const keysRaw = str(formData.get("__keys"));
const keys = keysRaw
.split(",")
.map((key) => key.trim())
.filter(Boolean)
.map((key) => ({ key, value: String(formData.get(key) ?? "") }));
await executeLegacyHotelMutation(staff, "radio.settings.save-many", {
entries,
});
siteSettings.reload();
revalidatePath("/ase/hotel/radio/settings");
.map((k) => k.trim())
.filter((k) => k.startsWith("radio_") || k.startsWith("auto_dj_"));
if (keys.length === 0) return;
try {
await Promise.all(
keys.map((key) => {
const value = str(formData.get(key));
return db
.insert(WebsiteSetting)
.values({ key, value, comment: null })
.onDuplicateKeyUpdate({ set: { value } });
}),
);
siteSettings.reload();
} catch (err) {
logger.error("Failed to bulk-save radio settings", { err, keys });
}
revalidatePath("/admin/radio/settings");
}
function bannerInput(formData: FormData) {
return {
imagePath: text(formData, "imagePath"),
title: text(formData, "title") || undefined,
description: text(formData, "description") || undefined,
sortOrder: Number(text(formData, "sortOrder") || 0),
isActive: enabled(formData, "isActive"),
};
}
// ── Radio banners CRUD (radio_banners) ─────────────────────────────────────
export async function createRadioBanner(formData: FormData): Promise<void> {
const staff = await actor();
await executeLegacyHotelMutation(
staff,
"radio.banner.create",
bannerInput(formData),
);
revalidatePath("/ase/hotel/radio/banners");
const staff = await requirePermission(PERMS.RADIO_EDIT);
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
if (!imagePath) return;
const title = str(formData.get("title")).trim().slice(0, 255);
const description = str(formData.get("description")).trim();
const sortOrderNum = Number(str(formData.get("sortOrder")));
const sortOrder = Number.isFinite(sortOrderNum)
? Math.trunc(sortOrderNum)
: 0;
const isActive = bool(formData.get("isActive"));
const now = new Date();
try {
await db.insert(RadioBanners).values({
userId: BigInt(staff.id),
imagePath,
title: title || null,
description: description || null,
sortOrder,
isActive,
createdAt: now,
updatedAt: now,
});
} catch (err) {
logger.error("Failed to create radio banner", { err, imagePath });
}
revalidatePath("/admin/radio/banners");
}
export async function updateRadioBanner(formData: FormData): Promise<void> {
const staff = await actor();
await executeLegacyHotelMutation(staff, "radio.banner.update", {
id: text(formData, "id"),
...bannerInput(formData),
});
revalidatePath("/ase/hotel/radio/banners");
await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id"));
if (id === null) return;
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
const title = str(formData.get("title")).trim().slice(0, 255);
const description = str(formData.get("description")).trim();
const sortOrderNum = Number(str(formData.get("sortOrder")));
const sortOrder = Number.isFinite(sortOrderNum)
? Math.trunc(sortOrderNum)
: 0;
const isActive = bool(formData.get("isActive"));
if (!imagePath) return;
try {
await db
.update(RadioBanners)
.set({
imagePath,
title: title || null,
description: description || null,
sortOrder,
isActive,
updatedAt: new Date(),
})
.where(eq(RadioBanners.id, id));
} catch (err) {
logger.error("Failed to update radio banner", { err, id: String(id) });
}
revalidatePath("/admin/radio/banners");
}
export async function deleteRadioBanner(formData: FormData): Promise<void> {
const staff = await actor();
await executeLegacyHotelMutation(staff, "radio.banner.delete", {
id: text(formData, "id"),
});
revalidatePath("/ase/hotel/radio/banners");
await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id"));
if (id === null) return;
try {
await db.delete(RadioBanners).where(eq(RadioBanners.id, id));
} catch (err) {
logger.error("Failed to delete radio banner", { err, id: String(id) });
}
revalidatePath("/admin/radio/banners");
}
function rankInput(formData: FormData) {
return {
name: text(formData, "name"),
description: text(formData, "description") || undefined,
badgeCode: text(formData, "badgeCode") || undefined,
isActive: enabled(formData, "isActive"),
};
}
// ── Radio ranks CRUD (radio_ranks) ─────────────────────────────────────────
export async function createRadioRank(formData: FormData): Promise<void> {
const staff = await actor();
await executeLegacyHotelMutation(
staff,
"radio.rank.create",
rankInput(formData),
);
revalidatePath("/ase/hotel/radio/ranks");
await requirePermission(PERMS.RADIO_EDIT);
const name = str(formData.get("name")).trim().slice(0, 255);
if (!name) return;
const description = str(formData.get("description")).trim().slice(0, 255);
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
const isActive = bool(formData.get("isActive"));
const now = new Date();
try {
await db.insert(RadioRanks).values({
name,
description: description || null,
badgeCode: badgeCode || null,
isActive,
createdAt: now,
updatedAt: now,
});
} catch (err) {
logger.error("Failed to create radio rank", { err, name });
}
revalidatePath("/admin/radio/ranks");
}
export async function updateRadioRank(formData: FormData): Promise<void> {
const staff = await actor();
await executeLegacyHotelMutation(staff, "radio.rank.update", {
id: text(formData, "id"),
...rankInput(formData),
});
revalidatePath("/ase/hotel/radio/ranks");
await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id"));
if (id === null) return;
const name = str(formData.get("name")).trim().slice(0, 255);
const description = str(formData.get("description")).trim().slice(0, 255);
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
const isActive = bool(formData.get("isActive"));
if (!name) return;
try {
await db
.update(RadioRanks)
.set({
name,
description: description || null,
badgeCode: badgeCode || null,
isActive,
updatedAt: new Date(),
})
.where(eq(RadioRanks.id, id));
} catch (err) {
logger.error("Failed to update radio rank", { err, id: String(id) });
}
revalidatePath("/admin/radio/ranks");
}
export async function deleteRadioRank(formData: FormData): Promise<void> {
const staff = await actor();
await executeLegacyHotelMutation(staff, "radio.rank.delete", {
id: text(formData, "id"),
});
revalidatePath("/ase/hotel/radio/ranks");
await requirePermission(PERMS.RADIO_EDIT);
const id = parseId(formData.get("id"));
if (id === null) return;
try {
await db.delete(RadioRanks).where(eq(RadioRanks.id, id));
} catch (err) {
logger.error("Failed to delete radio rank", { err, id: String(id) });
}
revalidatePath("/admin/radio/ranks");
}
+36 -5
View File
@@ -1,14 +1,45 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { db, RadioShouts } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== "string" || raw.trim() === "") return null;
try {
const id = BigInt(raw.trim());
return id > 0n ? id : null;
} catch {
return null;
}
}
/**
* Delete a radio shout from the DJ moderation page. Re-reads auth via
* requireStaff, writes a staff-activity audit entry and revalidates the
* moderation route. Fails soft if the row is already gone.
*/
export async function deleteShout(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.RADIO_EDIT);
await executeLegacyHotelMutation(staff, "radio.shout.delete", {
id: String(formData.get("id") ?? "").trim(),
});
revalidatePath("/ase/hotel/radio/moderation");
const id = parseId(formData.get("id"));
if (id === null) return;
try {
await db.delete(RadioShouts).where(eq(RadioShouts.id, id));
await logStaffActivity({
staffId: staff.id,
action: "radio.shout.delete",
description: `Deleted radio shout #${id}`,
targetType: "radio_shout",
targetId: Number(id),
});
} catch {
// Row may already be gone; ignore so the action does not throw.
}
revalidatePath("/admin/radio/moderation");
}
+80 -26
View File
@@ -2,39 +2,93 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
function text(formData: FormData, key: string): string {
return String(formData.get(key) ?? "")
.normalize("NFC")
.trim();
// Radio listener-points settings (website_settings radio_points_* keys).
// Mirrors AtomCMS's RadioPoints Filament page: key/value rows in
// website_settings that reward listeners for time spent on the radio. Booleans
// use the string '0' / '1'. Busts the siteSettings cache so the public radio
// pages pick the change up immediately.
const POINTS_KEYS = [
"radio_points_enabled",
"radio_points_per_minute",
"radio_points_currency",
"radio_points_max_per_day",
"radio_points_min_listeners",
] as const;
const ALLOWED_CURRENCIES = new Set([
"credits",
"duckets",
"diamonds",
"points",
]);
function str(raw: FormDataEntryValue | null): string {
return typeof raw === "string" ? raw : "";
}
/** Checkbox/select truthiness → '1' / '0'. */
function boolStr(raw: FormDataEntryValue | null): "0" | "1" {
const v = str(raw).trim().toLowerCase();
return v === "1" || v === "true" || v === "on" ? "1" : "0";
}
/** Clamp a form value to a non-negative integer string (defaulting to 0). */
function intStr(raw: FormDataEntryValue | null): string {
const n = Number(str(raw).trim());
if (!Number.isFinite(n) || n < 0) return "0";
return String(Math.floor(n));
}
export async function savePoints(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.RADIO_EDIT);
const enabled = ["1", "true", "on"].includes(
text(formData, "radio_points_enabled").toLowerCase(),
);
await executeLegacyHotelMutation(staff, "radio.points.save", {
radio_points_enabled: enabled,
radio_points_per_minute: Number(
text(formData, "radio_points_per_minute") || 0,
const currencyRaw = str(formData.get("radio_points_currency"))
.trim()
.toLowerCase();
const currency = ALLOWED_CURRENCIES.has(currencyRaw)
? currencyRaw
: "credits";
const values: Record<(typeof POINTS_KEYS)[number], string> = {
radio_points_enabled: boolStr(formData.get("radio_points_enabled")),
radio_points_per_minute: intStr(formData.get("radio_points_per_minute")),
radio_points_currency: currency,
radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")),
radio_points_min_listeners: intStr(
formData.get("radio_points_min_listeners"),
),
radio_points_currency: text(
formData,
"radio_points_currency",
).toLowerCase(),
radio_points_max_per_day: Number(
text(formData, "radio_points_max_per_day") || 0,
),
radio_points_min_listeners: Number(
text(formData, "radio_points_min_listeners") || 0,
),
});
siteSettings.reload();
revalidatePath("/ase/hotel/radio/points");
redirect("/ase/hotel/radio/points?saved=1");
};
try {
await Promise.all(
POINTS_KEYS.map((key) =>
db
.insert(WebsiteSetting)
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
.values({ key, value: values[key], comment: "Radio points" })
.onDuplicateKeyUpdate({
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
set: { value: values[key] },
}),
),
);
siteSettings.reload();
await logStaffActivity({
staffId: staff.id,
action: "radio_points_update",
description: `Updated radio listener-points settings (enabled=${values.radio_points_enabled}, ${values.radio_points_per_minute}/min ${currency})`,
});
} catch {
// DB unavailable — fail soft so the action does not throw.
}
revalidatePath("/admin/radio/points");
redirect("/admin/radio/points?saved=1");
}
+117
View File
@@ -0,0 +1,117 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
export async function createCategory(formData: FormData): Promise<void> {
await requirePermission(PERMS.SHOP_EDIT);
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const badge = String(formData.get("badge") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const priorityRaw = Number(formData.get("priority"));
const priority =
Number.isFinite(priorityRaw) && priorityRaw > 0
? Math.floor(priorityRaw)
: 1;
if (!name || !badge) return;
try {
await db.insert(WebsiteRareValueCategories).values({
name,
badge,
priority,
});
} catch {
// Unique name collision or DB error — ignore, page will re-render unchanged.
}
revalidatePath("/admin/rare-values");
}
export async function deleteCategory(formData: FormData): Promise<void> {
await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
// Remove the category's values first to avoid orphaned rows.
await db
.delete(WebsiteRareValues)
.where(eq(WebsiteRareValues.categoryId, id));
await db
.delete(WebsiteRareValueCategories)
.where(eq(WebsiteRareValueCategories.id, id));
} catch {
// Not found or DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
export async function createValue(formData: FormData): Promise<void> {
await requirePermission(PERMS.SHOP_EDIT);
const categoryId = formPositiveBigInt(formData, "categoryId");
if (!categoryId) return;
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!name || !furnitureIcon) return;
const itemIdRaw = Number(formData.get("itemId"));
const itemId =
Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
const creditValueRaw = String(formData.get("creditValue") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const currencyType =
String(formData.get("currencyType") ?? "diamonds")
.trim()
.slice(0, 255) || "diamonds";
try {
await db.insert(WebsiteRareValues).values({
categoryId,
itemId,
name,
creditValue: creditValueRaw || null,
currencyValue: currencyValueRaw || null,
currencyType,
furnitureIcon,
});
} catch {
// DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
export async function deleteValue(formData: FormData): Promise<void> {
await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await db.delete(WebsiteRareValues).where(eq(WebsiteRareValues.id, id));
} catch {
// Not found or DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
+122
View File
@@ -0,0 +1,122 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { actionOk, adminAction } from "@/lib/foundation/action";
import {
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
normalizeHabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel";
import { PERMS } from "@/lib/permissions";
import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache";
import { clearBadgeCache } from "@/lib/services/habboassets";
import { siteSettings } from "@/lib/services/site-settings";
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
function normalizeSettingValue(key: string, value: string): string {
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
return normalizeHabboGamedataHotel(value);
}
return value;
}
function bustGamedataCachesIfNeeded(key: string): void {
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
clearOfficialHabboFurnidataCache();
clearBadgeCache();
}
}
const saveManagedSchema = z.object({
settings: z.record(z.string(), z.string()),
});
export const saveManagedSettings = adminAction(
{
permission: PERMS.SETTINGS_EDIT,
schema: saveManagedSchema,
rateLimitKey: "admin-settings-save",
rateLimitMax: 30,
},
async (ctx) => {
const entries = Object.entries(ctx.data.settings)
.filter(([key]) => managedKeySet.has(key))
.map(([key, value]) => [key, normalizeSettingValue(key, value)] as const);
await Promise.all(
entries.map(([key, value]) =>
db
.insert(WebsiteSetting)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } }),
),
);
await siteSettings.reload();
if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) {
clearOfficialHabboFurnidataCache();
clearBadgeCache();
}
revalidatePath("/admin/settings");
revalidatePath("/admin/catalog");
return actionOk({ saved: entries.length });
},
);
export async function updateSetting(formData: FormData): Promise<void> {
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim();
const value = normalizeSettingValue(
key,
String(formData.get("value") ?? "").normalize("NFC"),
);
if (!key) return;
await db
.insert(WebsiteSetting)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
await siteSettings.reload();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings");
}
export async function createSetting(formData: FormData): Promise<void> {
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const value = normalizeSettingValue(
key,
String(formData.get("value") ?? "").normalize("NFC"),
);
const comment = String(formData.get("comment") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!key) return;
await db
.insert(WebsiteSetting)
.values({ key, value, comment: comment || null })
.onDuplicateKeyUpdate({ set: { value } });
await siteSettings.reload();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings");
}
export async function deleteSetting(formData: FormData): Promise<void> {
await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
const key = String(formData.get("key") ?? "")
.normalize("NFC")
.trim();
if (!key) return;
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key));
await siteSettings.reload();
bustGamedataCachesIfNeeded(key);
revalidatePath("/admin/settings");
}
+180
View File
@@ -0,0 +1,180 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteShopArticles } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { PERMS } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Website store packages (website_shop_articles). This CMS-owned table backs
// the public store; rows here are the buyable packages, not orders. The closest
// "orders" record is website_paypal_transactions, exposed read-only by the page.
/** Parse an UnsignedInt form value, returning null when blank/invalid/negative. */
function optUInt(formData: FormData, key: string): number | null {
const raw = String(formData.get(key) ?? "")
.normalize("NFC")
.trim();
if (raw === "") return null;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) return null;
return Math.floor(n);
}
/** Parse a required non-negative UnsignedInt, falling back to 0. */
function reqUInt(formData: FormData, key: string): number {
const n = optUInt(formData, key);
return n ?? 0;
}
export async function createShopArticle(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SHOP_EDIT);
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!name) return;
const now = new Date();
const costs = reqUInt(formData, "costs");
try {
const [result] = (await db.insert(WebsiteShopArticles).values({
name,
info: String(formData.get("info") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
iconUrl: String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
color: String(formData.get("color") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
costs,
giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"),
diamonds: optUInt(formData, "diamonds"),
badges:
String(formData.get("badges") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255) || null,
position: reqUInt(formData, "position"),
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
await logStaffActivity({
staffId: staff.id,
action: "shop_create",
description: `Created shop package "${name}" (${costs} costs)`,
targetType: "shop_article",
targetId: Number(result.insertId),
});
} catch (error) {
logServerError("admin.shop_create_failed", error, {
staffId: staff.id,
name,
});
// Unique constraint on `name` (or DB unavailable) — swallow and re-render.
return;
}
redirect("/admin/shop");
}
export async function updateShopArticle(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!name) return;
try {
await db
.update(WebsiteShopArticles)
.set({
name,
info: String(formData.get("info") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
iconUrl: String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
color: String(formData.get("color") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
costs: reqUInt(formData, "costs"),
giveRank: optUInt(formData, "giveRank"),
credits: optUInt(formData, "credits"),
duckets: optUInt(formData, "duckets"),
diamonds: optUInt(formData, "diamonds"),
badges:
String(formData.get("badges") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255) || null,
position: reqUInt(formData, "position"),
updatedAt: new Date(),
})
.where(eq(WebsiteShopArticles.id, id));
await logStaffActivity({
staffId: staff.id,
action: "shop_update",
description: `Updated shop package #${id} ("${name}")`,
targetType: "shop_article",
targetId: Number(id),
});
} catch (error) {
logServerError("admin.shop_update_failed", error, {
staffId: staff.id,
articleId: String(id),
});
return;
}
revalidatePath(`/admin/shop/${id}`);
redirect("/admin/shop");
}
export async function deleteShopArticle(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SHOP_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await db.delete(WebsiteShopArticles).where(eq(WebsiteShopArticles.id, id));
await logStaffActivity({
staffId: staff.id,
action: "shop_delete",
description: `Deleted shop package #${id}`,
targetType: "shop_article",
targetId: Number(id),
});
} catch (error) {
logServerError("admin.shop_delete_failed", error, {
staffId: staff.id,
articleId: String(id),
});
return;
}
redirect("/admin/shop");
}
+110 -71
View File
@@ -2,94 +2,133 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { createTag, deleteTag, updateTag } from "./admin-tags";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
contentMutationService: { execute },
createContentMutationInvocation: (actor, correlationId) => ({
expectedActorId: actor.id,
correlationId,
legacy: true,
}),
}));
const { insertValues, updateWhere, deleteWhere, transaction } = vi.hoisted(
() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const transaction = vi.fn(async (fn) =>
fn({
delete: vi.fn(() => ({ where: deleteWhere })),
}),
);
return { insertValues, updateWhere, deleteWhere, transaction };
},
);
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
delete: vi.fn(() => ({ where: deleteWhere })),
transaction,
},
Tags: { id: "id", name: "name", backgroundColor: "backgroundColor" },
Taggables: { tagId: "tagId" },
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const form = (data) => ({ get: (key) => data[key] ?? null });
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
execute.mockResolvedValue({
ok: true,
data: { before: null, after: { id: "1" } },
correlationId: "legacy",
});
const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
describe("Content tag legacy wrappers", () => {
it("delegates create with normalized values", async () => {
await createTag(form({ name: "News", backgroundColor: "#ff0000" }));
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", {
action: "create",
name: "News",
backgroundColor: "#ff0000",
});
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/editorial/tags");
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
transaction.mockImplementation(async (fn) =>
fn({
delete: vi.fn(() => ({ where: deleteWhere })),
}),
);
});
describe("createTag", () => {
it("creates a tag and revalidates", async () => {
await createTag(
fakeForm({
name: "News",
backgroundColor: "#ff0000",
}) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ name: "News" }),
);
expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
});
it("uses the legacy default color", async () => {
await createTag(form({ name: "Test" }));
expect(execute).toHaveBeenCalledWith(
expect.anything(),
"tag.change",
it("returns early when name is empty", async () => {
await createTag(fakeForm({ name: "" }) as unknown as FormData);
expect(insertValues).not.toHaveBeenCalled();
});
it("uses default color when not provided", async () => {
await createTag(fakeForm({ name: "Test" }) as unknown as FormData);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ backgroundColor: "#888888" }),
);
});
it("returns early for an empty name", async () => {
await createTag(form({ name: "" }));
expect(execute).not.toHaveBeenCalled();
it("handles db error gracefully", async () => {
insertValues.mockRejectedValue(new Error("DB error"));
await expect(
createTag(fakeForm({ name: "News" }) as unknown as FormData),
).resolves.toBeUndefined();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
});
it("revalidates after a fail-soft dependency result", async () => {
execute.mockResolvedValue({
ok: false,
error: {
code: "DEPENDENCY_UNAVAILABLE",
messageKey: "errors.housekeeping.dependencyUnavailable",
},
correlationId: "legacy",
});
await createTag(form({ name: "News" }));
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/editorial/tags");
});
it("delegates update", async () => {
});
describe("updateTag", () => {
it("updates a tag and revalidates", async () => {
await updateTag(
form({ id: "42", name: "Updated", backgroundColor: "#00ff00" }),
fakeForm({
id: "42",
name: "Updated",
backgroundColor: "#00ff00",
}) as unknown as FormData,
);
expect(execute).toHaveBeenCalledWith(
expect.anything(),
"tag.change",
expect.objectContaining({ action: "update", id: "42" }),
);
expect(updateWhere).toHaveBeenCalled();
expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
});
it("returns early when id is invalid", async () => {
await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData);
expect(updateWhere).not.toHaveBeenCalled();
});
it("rejects invalid update id or name", async () => {
await updateTag(form({ id: "", name: "Test" }));
await updateTag(form({ id: "42", name: "" }));
expect(execute).not.toHaveBeenCalled();
it("returns early when name is empty after update", async () => {
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
expect(updateWhere).not.toHaveBeenCalled();
});
it("delegates delete", async () => {
await deleteTag(form({ id: "42" }));
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", {
action: "delete",
id: "42",
});
});
describe("deleteTag", () => {
it("deletes a tag and its taggables", async () => {
await deleteTag(fakeForm({ id: "42" }) as unknown as FormData);
expect(transaction).toHaveBeenCalled();
expect(deleteWhere).toHaveBeenCalled();
expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
});
it("rejects invalid delete id", async () => {
await deleteTag(form({ id: "" }));
expect(execute).not.toHaveBeenCalled();
it("returns early when id is invalid", async () => {
await deleteTag(fakeForm({ id: "" }) as unknown as FormData);
expect(transaction).not.toHaveBeenCalled();
});
});
+94 -41
View File
@@ -1,60 +1,113 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import {
contentMutationService,
createContentMutationInvocation,
} from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { db, Taggables, Tags } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
function tagInput(formData: FormData) {
return {
name: String(formData.get("name") ?? "")
.trim()
.slice(0, 255),
backgroundColor:
String(formData.get("backgroundColor") ?? "")
.trim()
.slice(0, 10) || "#888888",
};
// ── Helpers ────────────────────────────────────────────────────────────────
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
function parseId(raw: FormDataEntryValue | null): bigint | null {
if (typeof raw !== "string" || raw.trim() === "") return null;
try {
const id = BigInt(raw.trim());
return id > 0n ? id : null;
} catch {
return null;
}
}
function str(raw: FormDataEntryValue | null): string {
return typeof raw === "string" ? raw : "";
}
/** Normalise a hex-ish colour into the 10-char background_color column. */
function normaliseColor(raw: string): string {
const v = raw.trim().slice(0, 10);
return v || "#888888";
}
// ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ──────────
export async function createTag(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const input = tagInput(formData);
if (!input.name) return;
await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"tag.change",
{ action: "create", ...input },
);
revalidatePath("/ase/content/editorial/tags");
const name = str(formData.get("name")).trim().slice(0, 255);
if (!name) return;
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
const now = new Date();
try {
const [result] = (await db.insert(Tags).values({
name,
backgroundColor,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
await logStaffActivity({
staffId: staff.id,
action: "tag_create",
description: `Created tag "${name}" (#${result.insertId})`,
targetType: "tag",
targetId: Number(result.insertId),
});
} catch {
// Fail soft — DB unavailable or duplicate.
}
revalidatePath("/admin/tags");
}
export async function updateTag(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = String(formData.get("id") ?? "").trim();
if (!/^[1-9]\d*$/u.test(id)) return;
const input = tagInput(formData);
if (!input.name) return;
await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"tag.change",
{ action: "update", id, ...input },
);
revalidatePath("/ase/content/editorial/tags");
const id = parseId(formData.get("id"));
if (id === null) return;
const name = str(formData.get("name")).trim().slice(0, 255);
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
if (!name) return;
try {
await db
.update(Tags)
.set({ name, backgroundColor, updatedAt: new Date() })
.where(eq(Tags.id, id));
await logStaffActivity({
staffId: staff.id,
action: "tag_update",
description: `Updated tag #${id} → "${name}"`,
targetType: "tag",
targetId: Number(id),
});
} catch {
// Row may be gone; ignore.
}
revalidatePath("/admin/tags");
}
export async function deleteTag(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = String(formData.get("id") ?? "").trim();
if (!/^[1-9]\d*$/u.test(id)) return;
await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"tag.change",
{ action: "delete", id },
);
revalidatePath("/ase/content/editorial/tags");
const id = parseId(formData.get("id"));
if (id === null) return;
try {
// Remove the tag and any taggable links pointing at it.
await db.transaction(async (tx) => {
await tx.delete(Taggables).where(eq(Taggables.tagId, id));
await tx.delete(Tags).where(eq(Tags.id, id));
});
await logStaffActivity({
staffId: staff.id,
action: "tag_delete",
description: `Deleted tag #${id}`,
targetType: "tag",
targetId: Number(id),
});
} catch {
// Already deleted; ignore.
}
revalidatePath("/admin/tags");
}
+42 -52
View File
@@ -1,69 +1,59 @@
// @ts-nocheck
import { revalidatePath } from "next/cache";
import { beforeEach, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { createTeam, deleteTeam } from "./admin-teams";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
const { insertValues, deleteWhere } = vi.hoisted(() => {
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
return { insertValues, deleteWhere };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { USERS_EDIT: "admin.users.edit" },
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
delete: vi.fn(() => ({ where: deleteWhere })),
},
WebsiteTeams: { id: "id" },
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string | null>) => ({
get: (key: string) => (key in data ? data[key] : null),
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
vi.mocked(requirePermission).mockResolvedValue(staff as never);
insertValues.mockResolvedValue([{ insertId: 1 }]);
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
});
describe("createTeam", () => {
it("creates a team entry", async () => {
await createTeam(
fakeForm({ rankName: "Moderator" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ rankName: "Moderator" }),
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
});
execute.mockResolvedValue({
ok: true,
data: { before: null, after: {} },
correlationId: "team",
it("returns early when rankName is empty", async () => {
await createTeam(fakeForm({ rankName: "" }) as unknown as FormData);
expect(insertValues).not.toHaveBeenCalled();
});
});
it("preserves create and delete team payloads plus /admin revalidation", async () => {
await createTeam(form({ rankName: "Moderator" }));
await deleteTeam(form({ id: "42" }));
expect(execute.mock.calls.map((call) => [call[1], call[2]])).toEqual([
[
"team.change",
{
action: "create",
rankName: "Moderator",
badge: "",
jobDescription: "",
staffColor: "#327fa8",
hiddenRank: false,
},
],
["team.change", { action: "delete", teamId: "42" }],
]);
expect(revalidatePath).toHaveBeenCalledTimes(2);
});
it("preserves empty rank name as a no-op", async () => {
await createTeam(form({ rankName: "" }));
expect(execute).not.toHaveBeenCalled();
});
it("preserves a team ID above Number.MAX_SAFE_INTEGER", async () => {
await deleteTeam(form({ id: "9007199254740993" }));
expect(execute).toHaveBeenCalledWith(expect.anything(), "team.change", {
action: "delete",
teamId: "9007199254740993",
describe("deleteTeam", () => {
it("deletes a team entry", async () => {
await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData);
expect(deleteWhere).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
});
});
+37 -40
View File
@@ -1,53 +1,50 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { formPositiveBigInt } from "@/lib/form-data";
import { db, WebsiteTeams } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
function text(formData: FormData, key: string): string {
return String(formData.get(key) ?? "")
export async function createTeam(formData: FormData): Promise<void> {
await requirePermission(PERMS.USERS_EDIT);
const rankName = String(formData.get("rankName") ?? "")
.normalize("NFC")
.trim();
}
export async function createTeam(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const rankName = text(formData, "rankName");
if (!rankName) return;
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"team.change",
{
action: "create",
rankName,
badge: text(formData, "badge"),
jobDescription: text(formData, "jobDescription"),
staffColor: text(formData, "staffColor") || "#327fa8",
hiddenRank: formData.get("hiddenRank") === "on",
},
);
if (!result.ok) throw new Error("Could not create team");
revalidatePath("/ase/people/staff/teams");
const badge = String(formData.get("badge") ?? "")
.normalize("NFC")
.trim();
const jobDescription = String(formData.get("jobDescription") ?? "")
.normalize("NFC")
.trim();
const staffColor =
String(formData.get("staffColor") ?? "")
.normalize("NFC")
.trim() || "#327fa8";
const hiddenRank = formData.get("hiddenRank") === "on";
const now = new Date();
await db.insert(WebsiteTeams).values({
rankName: rankName.slice(0, 255),
badge: badge ? badge.slice(0, 255) : null,
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
staffColor: staffColor.slice(0, 255),
hiddenRank,
createdAt: now,
updatedAt: now,
});
revalidatePath("/admin/teams");
}
export async function deleteTeam(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const rawTeamId = formPositiveBigInt(formData, "id");
if (!rawTeamId) return;
const teamId = rawTeamId.toString();
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"team.change",
{ action: "delete", teamId },
);
if (!result.ok && result.error.code !== "NOT_FOUND") {
throw new Error("Could not delete team");
}
revalidatePath("/ase/people/staff/teams");
await requirePermission(PERMS.USERS_EDIT);
const id = BigInt(String(formData.get("id")));
await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id));
revalidatePath("/admin/teams");
}
+215
View File
@@ -0,0 +1,215 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
import {
deleteCustomThemeStore,
getCustomTheme,
snapshotCurrentTheme,
upsertCustomTheme,
} from "@/lib/theme-custom-store";
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
import { presetSettings, settingKey } from "@/lib/theme-settings";
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
// Extra colour settings beyond the preset palette (buttons + links + gradients).
const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"];
const CUSTOM_CSS_MAX = 20000;
async function writeSetting(key: string, value: string): Promise<void> {
await db
.insert(WebsiteSetting)
.values({ key, value, comment: "Theme (housekeeping)" })
.onDuplicateKeyUpdate({ set: { value } });
}
export async function saveTheme(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
try {
for (const mode of ["light", "dark"] as const) {
const bag: Record<string, string> = {};
for (const key of THEME_COLOR_KEYS) {
const dbKey = settingKey(key, mode);
const raw = String(formData.get(dbKey) ?? "")
.normalize("NFC")
.trim();
if (raw && COLOR_RE.test(raw)) bag[key] = raw;
}
const fixed = ensureReadableThemeColors(bag);
for (const [key, value] of Object.entries(fixed)) {
await writeSetting(
settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode),
value,
);
}
}
const ADMIN_KEYS = [
"admin_canvas",
"admin_surface",
"admin_text",
"admin_text_muted",
"admin_border",
"admin_sidebar_bg",
] as const;
const adminBag: Record<string, string> = {};
for (const key of ADMIN_KEYS) {
const raw = String(formData.get(key) ?? "")
.normalize("NFC")
.trim();
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
}
const adminFixed = ensureReadableThemeColors(adminBag);
for (const [key, value] of Object.entries(adminFixed)) {
await writeSetting(key, value);
}
const radius = String(formData.get("border_radius") ?? "")
.normalize("NFC")
.trim();
if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius);
// Typography
const font = String(formData.get("font_family") ?? "")
.normalize("NFC")
.trim();
if (font in FONTS) await writeSetting("font_family", font);
for (const key of HEADING_KEYS) {
const v = String(formData.get(key) ?? "")
.normalize("NFC")
.trim();
if (/^\d{1,3}$/.test(v)) await writeSetting(key, v);
}
// Raw custom CSS (staff-trusted; length-capped, ThemeVars injects it as-is).
if (formData.has("custom_css")) {
const cssRaw = String(formData.get("custom_css") ?? "")
.normalize("NFC")
.slice(0, CUSTOM_CSS_MAX);
await writeSetting("custom_css", cssRaw);
}
siteSettings.reload();
await logStaffActivity({
staffId: staff.id,
action: "theme_update",
description: "Updated theme settings",
});
revalidatePath("/", "layout");
} catch {
// ignore — page re-renders current state
}
redirect("/admin/theme?saved=1");
}
export async function applyPreset(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const name = String(formData.get("preset") ?? "").normalize("NFC");
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
const preset = PRESETS[name];
if (!preset) redirect("/admin/theme");
try {
for (const [key, value] of presetSettings(preset))
await writeSetting(key, value);
await writeSetting("theme_preset", name);
siteSettings.reload();
await logStaffActivity({
staffId: staff.id,
action: "theme_preset",
description: `Applied theme preset "${name}"`,
});
revalidatePath("/", "layout");
} catch {
// ignore
}
redirect(`/admin/theme?preset=${encodeURIComponent(name)}`);
}
export async function saveCustomTheme(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim();
if (!name) redirect("/admin/theme");
const snapshot = await snapshotCurrentTheme();
try {
await upsertCustomTheme(name, snapshot);
await logStaffActivity({
staffId: staff.id,
action: "theme_preset",
description: `Saved custom theme "${name}"`,
});
revalidatePath("/admin/theme");
} catch {
// ignore
}
redirect("/admin/theme?savedTheme=1");
}
export async function applyCustomTheme(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (!id) redirect("/admin/theme");
const theme = await getCustomTheme(id);
if (!theme) redirect("/admin/theme");
try {
for (const [key, value] of Object.entries(theme.settings)) {
if (value) await writeSetting(key, value);
}
await writeSetting("theme_preset", theme.name);
siteSettings.reload();
await logStaffActivity({
staffId: staff.id,
action: "theme_preset",
description: `Applied custom theme "${theme.name}"`,
});
revalidatePath("/", "layout");
} catch {
// ignore
}
redirect(`/admin/theme?theme=${encodeURIComponent(theme.name)}`);
}
export async function renameCustomTheme(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim();
if (!id || !name) redirect("/admin/theme");
const snapshot = await snapshotCurrentTheme();
try {
await upsertCustomTheme(name, snapshot, id);
revalidatePath("/admin/theme");
} catch {
// ignore
}
redirect("/admin/theme?renamed=1");
}
export async function deleteCustomTheme(formData: FormData): Promise<void> {
await requirePermission(PERMS.SETTINGS_EDIT);
const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (!id) redirect("/admin/theme");
try {
await deleteCustomThemeStore(id);
revalidatePath("/admin/theme");
} catch {
// ignore
}
redirect("/admin/theme?deletedTheme=1");
}
+85
View File
@@ -0,0 +1,85 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { positiveBigInt } from "@/lib/api";
import { db, WebsiteShopVouchers } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import {
type ActionResult,
actionError,
actionOk,
} from "@/lib/safe-action-shared";
import { logServerError } from "@/lib/server-log";
export async function createVoucher(input: {
code: string;
amount: number;
maxUses: number;
expiresAt?: string;
}): Promise<ActionResult<{ id: string }>> {
await requirePermission(PERMS.SHOP_EDIT);
const code = String(input.code ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const amount = Number(input.amount);
const maxUsesRaw = Number(input.maxUses);
const maxUses =
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
if (!code || !(amount > 0)) {
return actionError("Code and a positive amount are required");
}
let expiresAt: Date | null = null;
const expiresRaw = String(input.expiresAt ?? "")
.normalize("NFC")
.trim();
if (expiresRaw) {
const parsed = new Date(expiresRaw);
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
}
const now = new Date();
try {
const [result] = (await db.insert(WebsiteShopVouchers).values({
code,
amount: Math.floor(amount),
maxUses,
useCount: 0,
expiresAt,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
revalidatePath("/admin/vouchers");
return actionOk({ id: String(result.insertId) });
} catch (error) {
logServerError("admin.voucher_create_failed", error);
return actionError("Could not create voucher (code may already exist)");
}
}
export async function deleteVoucher(input: {
id: string;
}): Promise<ActionResult> {
await requirePermission(PERMS.SHOP_EDIT);
const id = positiveBigInt(String(input.id ?? "").trim());
if (!id) return actionError("Missing voucher id");
try {
await db.delete(WebsiteShopVouchers).where(eq(WebsiteShopVouchers.id, id));
revalidatePath("/admin/vouchers");
return actionOk();
} catch (error) {
logServerError("admin.voucher_delete_failed", error, {
voucherId: String(id),
});
return actionError("Could not delete voucher");
}
}
+42 -52
View File
@@ -1,70 +1,60 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { beforeEach, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { siteSettings } from "@/lib/services/site-settings";
import { saveVpn } from "./admin-vpn";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
const { mockValues, mockOnDuplicateKeyUpdate } = vi.hoisted(() => {
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined);
const mockValues = vi.fn(() => ({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
}));
return { mockValues, mockOnDuplicateKeyUpdate };
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
PERMS: { SETTINGS_EDIT: "settings.edit" },
}));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: mockValues })),
},
WebsiteSetting: { key: "key", value: "value" },
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { reload: vi.fn() },
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string | null>) => ({
get: (key: string) => (key in data ? data[key] : null),
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
execute.mockResolvedValue({
ok: true,
data: { before: {}, after: {} },
correlationId: "vpn",
vi.mocked(requirePermission).mockResolvedValue(staff as never);
mockValues.mockReturnValue({
onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate,
});
mockOnDuplicateKeyUpdate.mockResolvedValue(undefined);
});
it("preserves VPN payload, ASE revalidation, and redirect", async () => {
await saveVpn(
form({
vpn_block_enabled: "1",
vpn_provider: "proxycheck",
vpn_api_key: "abc123",
}),
);
expect(execute).toHaveBeenCalledWith(expect.anything(), "vpn.configure", {
enabled: true,
provider: "proxycheck",
apiKey: "abc123",
blockMessage: "",
describe("saveVpn", () => {
it("saves VPN settings and redirects", async () => {
await saveVpn(
fakeForm({
vpn_block_enabled: "1",
vpn_provider: "proxycheck",
vpn_api_key: "abc123",
}) as unknown as FormData,
);
expect(mockValues).toHaveBeenCalledTimes(4);
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(4);
expect(siteSettings.reload).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
});
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/vpn");
expect(redirect).toHaveBeenCalledWith("/ase/people/moderation/vpn?saved=1");
});
it("preserves fail-soft redirect without claiming a saved revalidation", async () => {
execute.mockResolvedValue({
ok: false,
error: {
code: "DEPENDENCY_UNAVAILABLE",
messageKey: "errors.housekeeping.dependencyUnavailable",
},
correlationId: "vpn-fail",
});
await saveVpn(form({ vpn_provider: "none" }));
expect(revalidatePath).not.toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/ase/people/moderation/vpn?saved=1");
});
+73 -25
View File
@@ -2,40 +2,88 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
// VPN / proxy detection config. Stored as website_settings key/value rows
// (CMS-owned, BigInt id). Booleans use the strings "0" / "1", faithful to
// AtomCMS's setting() convention. This is registration-time protection only;
// the raw IP allow/deny list lives under /admin/ip (website_ip_*).
const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]);
/** Upsert one website_settings key with a stable housekeeping comment. */
async function writeSetting(
key: string,
value: string,
comment: string,
): Promise<void> {
await db
.insert(WebsiteSetting)
.values({ key, value, comment })
.onDuplicateKeyUpdate({ set: { value } });
}
export async function saveVpn(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const rawProvider = String(formData.get("vpn_provider") ?? "")
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
const enabled =
String(formData.get("vpn_block_enabled") ?? "")
.normalize("NFC")
.trim() !== "";
const providerRaw = String(formData.get("vpn_provider") ?? "")
.normalize("NFC")
.trim()
.toLowerCase();
const provider = ALLOWED_PROVIDERS.has(rawProvider) ? rawProvider : "none";
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"vpn.configure",
{
enabled: String(formData.get("vpn_block_enabled") ?? "").trim() !== "",
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
const apiKey = String(formData.get("vpn_api_key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
const blockMessage = String(formData.get("vpn_block_message") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
try {
await writeSetting(
"vpn_block_enabled",
enabled ? "1" : "0",
"Block registrations from detected VPN/proxy IPs (0=no, 1=yes)",
);
await writeSetting(
"vpn_provider",
provider,
apiKey: String(formData.get("vpn_api_key") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
blockMessage: String(formData.get("vpn_block_message") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
},
);
if (result.ok) revalidatePath("/ase/people/moderation/vpn");
// Preserve fail-soft legacy navigation even when persistence is unavailable.
redirect("/ase/people/moderation/vpn?saved=1");
"VPN/proxy detection provider (none/proxycheck/ipqualityscore)",
);
await writeSetting(
"vpn_api_key",
apiKey,
"API key for the VPN/proxy detection provider",
);
await writeSetting(
"vpn_block_message",
blockMessage,
"Message shown to users blocked for using a VPN/proxy",
);
siteSettings.reload();
await logStaffActivity({
staffId: staff.id,
action: "vpn_update",
description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`,
});
revalidatePath("/admin/vpn");
} catch {
// DB unavailable — fail soft so the action does not throw; the page
// re-renders the current (stored) state.
}
redirect("/admin/vpn?saved=1");
}
+30 -27
View File
@@ -1,53 +1,56 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission } from "@/lib/admin/guard";
import { positiveBigInt } from "@/lib/api";
import { db, WebsiteWordfilter } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import {
type ActionResult,
actionError,
actionOk,
} from "@/lib/safe-action-shared";
import { reloadWordFilter } from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon";
export async function addWord(input: {
word: string;
}): Promise<ActionResult<{ id: string }>> {
const staff = await requirePermission(PERMS.WORDFILTER_EDIT);
await requirePermission(PERMS.WORDFILTER_EDIT);
const word = String(input.word ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!word) return actionError("Word is required");
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"word-filter.update",
{ action: "add", word },
);
if (!result.ok)
try {
const [result] = (await db
.insert(WebsiteWordfilter)
.values({ word })) as unknown as [ResultSetHeader];
reloadWordFilter();
await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
return actionOk({ id: String(result.insertId) });
} catch {
return actionError("Could not add word (it may already exist)");
revalidatePath("/ase/people/moderation/word-filter");
return actionOk({ id: String(result.data.after?.id ?? "") });
}
}
export async function deleteWord(input: { id: string }): Promise<ActionResult> {
const staff = await requirePermission(PERMS.WORDFILTER_EDIT);
const parsedId = positiveBigInt(String(input.id ?? "").normalize("NFC"));
if (!parsedId) return actionError("Missing word id");
const id = parsedId.toString();
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"word-filter.update",
{ action: "delete", id },
);
if (!result.ok && result.error.code !== "NOT_FOUND") {
await requirePermission(PERMS.WORDFILTER_EDIT);
const raw = String(input.id ?? "").normalize("NFC");
if (!raw) return actionError("Missing word id");
try {
await db
.delete(WebsiteWordfilter)
.where(eq(WebsiteWordfilter.id, BigInt(raw)));
reloadWordFilter();
await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter");
return actionOk();
} catch {
return actionError("Could not remove word");
}
revalidatePath("/ase/people/moderation/word-filter");
return actionOk();
}
+177
View File
@@ -0,0 +1,177 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteWriteableBoxes } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Writeable boxes (website_writeable_boxes). CMS-owned table backing the
// content panels rendered on the public home page. Active boxes (is_active)
// are the ones shown publicly, ordered by `position`.
/** Parse a non-negative Int form value, falling back to 0. */
function reqInt(formData: FormData, key: string): number {
const raw = String(formData.get(key) ?? "")
.normalize("NFC")
.trim();
if (raw === "") return 0;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) return 0;
return Math.floor(n);
}
/** Parse the BigInt `id` form value, returning null when blank/invalid. */
function parseId(formData: FormData): bigint | null {
const raw = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (!raw) return null;
try {
return BigInt(raw);
} catch {
return null;
}
}
function revalidate(): void {
revalidatePath("/admin/writeable-boxes");
// Active boxes render on the public home page (root layout).
revalidatePath("/", "layout");
}
export async function createBox(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const title = String(formData.get("title") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!title) return;
const now = new Date();
try {
const [result] = (await db.insert(WebsiteWriteableBoxes).values({
title,
icon:
String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255) || null,
content: String(formData.get("content") ?? "").normalize("NFC"),
position: reqInt(formData, "position"),
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
await logStaffActivity({
staffId: staff.id,
action: "writeable_box_create",
description: `Created writeable box "${title}" (#${result.insertId})`,
targetType: "writeable_box",
targetId: Number(result.insertId),
});
} catch {
// DB unavailable — swallow and re-render.
return;
}
revalidate();
}
export async function updateBox(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = parseId(formData);
if (id == null) return;
const title = String(formData.get("title") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255);
if (!title) return;
try {
await db
.update(WebsiteWriteableBoxes)
.set({
title,
icon:
String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255) || null,
content: String(formData.get("content") ?? "").normalize("NFC"),
position: reqInt(formData, "position"),
isActive:
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
updatedAt: new Date(),
})
.where(eq(WebsiteWriteableBoxes.id, id));
await logStaffActivity({
staffId: staff.id,
action: "writeable_box_update",
description: `Updated writeable box #${id} ("${title}")`,
targetType: "writeable_box",
targetId: Number(id),
});
} catch {
return;
}
revalidate();
}
export async function deleteBox(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = parseId(formData);
if (id == null) return;
try {
await db
.delete(WebsiteWriteableBoxes)
.where(eq(WebsiteWriteableBoxes.id, id));
await logStaffActivity({
staffId: staff.id,
action: "writeable_box_delete",
description: `Deleted writeable box #${id}`,
targetType: "writeable_box",
targetId: Number(id),
});
} catch {
return;
}
revalidate();
}
export async function toggleBox(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = parseId(formData);
if (id == null) return;
// `next` carries the desired state ("1" to activate, anything else to hide).
const next = String(formData.get("next") ?? "").normalize("NFC") === "1";
try {
await db
.update(WebsiteWriteableBoxes)
.set({ isActive: next, updatedAt: new Date() })
.where(eq(WebsiteWriteableBoxes.id, id));
await logStaffActivity({
staffId: staff.id,
action: "writeable_box_toggle",
description: `${next ? "Activated" : "Hid"} writeable box #${id}`,
targetType: "writeable_box",
targetId: Number(id),
});
} catch {
return;
}
revalidate();
}
+54
View File
@@ -0,0 +1,54 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteBadges } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
export async function getBadgeData({ code }: { code: string }) {
await requirePermission(PERMS.CATALOG_EDIT);
const [badge] = await db
.select({
badgeName: WebsiteBadges.badgeName,
badgeDescription: WebsiteBadges.badgeDescription,
})
.from(WebsiteBadges)
.where(eq(WebsiteBadges.badgeKey, code))
.limit(1);
if (!badge) return { ok: false as const, data: null };
return {
ok: true as const,
data: { name: badge.badgeName, desc: badge.badgeDescription },
};
}
export async function updateBadge({
code,
name,
desc,
}: {
code: string;
name: string;
desc: string;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
const now = new Date();
await db
.insert(WebsiteBadges)
.values({
badgeKey: code,
badgeName: name,
badgeDescription: desc,
createdAt: now,
updatedAt: now,
})
.onDuplicateKeyUpdate({
set: {
badgeName: name,
badgeDescription: desc,
updatedAt: now,
},
});
revalidatePath("/admin/import/badges");
}
+81
View File
@@ -0,0 +1,81 @@
"use server";
import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { z } from "zod";
import { db, WebsiteBanner } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
const bannerSchema = z.object({
title: z.string().min(1).max(255),
subtitle: z.string().max(500).optional().default(""),
image: z.string().max(500),
link: z.string().max(500).optional().default(""),
color: z.string().max(20).optional().default(""),
isActive: z.coerce.number().int().min(0).max(1).default(1),
sortOrder: z.coerce.number().int().min(0).default(0),
startDate: z.string().max(50).nullable().optional(),
endDate: z.string().max(50).nullable().optional(),
});
export const createBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
async (ctx) => {
const [result] = (await db
.insert(WebsiteBanner)
.values(ctx.data)) as unknown as [ResultSetHeader];
const id = Number(result.insertId);
logAudit({
userId: ctx.session.user.id,
action: "banner_create",
target: "WebsiteBanner",
targetId: id,
after: { title: ctx.data.title },
});
return actionOk({ id });
},
);
const updateBannerInput = bannerSchema
.partial()
.extend({ id: z.coerce.number().int().positive() });
export const updateBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
async (ctx) => {
const { id, ...data } = ctx.data;
const [existing] = await db
.select({ id: WebsiteBanner.id })
.from(WebsiteBanner)
.where(eq(WebsiteBanner.id, id))
.limit(1);
if (!existing) throw new ActionError("Banner not found");
await db.update(WebsiteBanner).set(data).where(eq(WebsiteBanner.id, id));
logAudit({
userId: ctx.session.user.id,
action: "banner_update",
target: "WebsiteBanner",
targetId: id,
});
return actionOk({ id });
},
);
const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
async (ctx) => {
await db.delete(WebsiteBanner).where(eq(WebsiteBanner.id, ctx.data.id));
logAudit({
userId: ctx.session.user.id,
action: "banner_delete",
target: "WebsiteBanner",
targetId: ctx.data.id,
});
return actionOk();
},
);
-99
View File
@@ -1,99 +0,0 @@
import { beforeEach, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { USERS_EDIT: "admin.users.edit" },
}));
vi.mock("@/lib/db", () => ({ db: {}, User: {}, UsersCurrency: {} }));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
import { bulkAdjustCurrency } from "./bulk-users";
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
} as never);
execute.mockResolvedValue({
ok: true,
data: {
before: { userIds: [7, 8] },
after: { completed: 2, total: 2, failedIds: [] },
},
correlationId: "bulk-adjust",
});
});
it("keeps one ACL check while delegating a positive bulk adjustment", async () => {
await expect(
bulkAdjustCurrency({
userIds: [7, 8],
amount: 25,
type: "credits",
}),
).resolves.toEqual({
ok: true,
data: { adjusted: 2, total: 2, failedIds: [] },
});
expect(requirePermission).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledWith(
expect.anything(),
"users.bulk-currency",
{ userIds: [7, 8], amount: 25, type: "credits" },
);
});
it("keeps the pre-Task12 positive-adjust result when currency RCON throws after the database commit", async () => {
execute.mockResolvedValueOnce({
ok: true,
data: {
before: { completed: 0, total: 1, failedIds: [] },
after: {
completed: 1,
total: 1,
failedIds: [],
externalSyncFailures: [
{
userId: 7,
reason:
"Database applied; emulator sync failed. Do not retry automatically.",
},
],
},
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
},
correlationId: "legacy-positive-adjust",
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
});
await expect(
bulkAdjustCurrency({ userIds: [7], amount: 25, type: "credits" }),
).resolves.toEqual({
ok: true,
data: {
adjusted: 0,
total: 1,
failedIds: [{ userId: 7, reason: "Database error" }],
},
});
});
+151 -125
View File
@@ -1,30 +1,95 @@
// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { rcon } from "@/lib/services/rcon";
import {
bulkBan,
bulkGiveBadge,
bulkGiveCurrency,
bulkUnban,
setTradeLock,
} from "./bulk-users";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
const {
deleteWhere,
insertValues,
updateWhere,
selectLimit,
selectWhereResolved,
onDuplicateKeyUpdate,
} = vi.hoisted(() => {
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 3 }]);
const onDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const insertValues = vi.fn(() => ({
onDuplicateKeyUpdate,
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(resolve, reject) {
return Promise.resolve([{ insertId: 1 }]).then(resolve, reject);
},
}));
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
const selectLimit = vi.fn().mockResolvedValue([]);
/** Rows returned when a select chain is awaited without `.limit()`. */
const selectWhereResolved = vi.fn().mockResolvedValue([]);
return {
deleteWhere,
insertValues,
updateWhere,
selectLimit,
selectWhereResolved,
onDuplicateKeyUpdate,
};
});
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: { USERS_EDIT: "admin.users.edit" },
}));
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
vi.mock("@/lib/db", () => ({
db: {},
User: {},
UsersCurrency: {},
db: {
delete: vi.fn(() => ({ where: deleteWhere })),
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: updateWhere })),
})),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: selectLimit,
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(resolve, reject) {
return selectWhereResolved().then(resolve, reject);
},
})),
})),
})),
transaction: vi.fn(),
},
Ban: { userId: "userId", id: "id" },
User: {
id: "id",
credits: "credits",
username: "username",
online: "online",
},
UsersCurrency: { userId: "userId", type: "type", amount: "amount" },
UsersBadges: {
id: "id",
userId: "userId",
badgeCode: "badgeCode",
slotId: "slotId",
},
Sanctions: { id: "id", habboId: "habboId" },
UsersSettings: {
userId: "userId",
canTrade: "canTrade",
tradelockAmount: "tradelockAmount",
},
}));
vi.mock("@/lib/services/rcon", () => ({
rcon: {
giveCredits: vi.fn(),
giveDuckets: vi.fn(),
givePointsGotw: vi.fn(),
giveBadge: vi.fn(),
},
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
@@ -33,122 +98,83 @@ const staff = { id: 1, rank: 7, username: "admin" };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
execute.mockImplementation(async (context, operation, input) => ({
ok: true,
data: {
before: { input },
after: {
completed: operation === "users.bulk-unban" ? 3 : 2,
total: Array.isArray(input.userIds) ? input.userIds.length : 1,
failedIds: [],
},
output: operation === "user.trade-lock" ? input : undefined,
deleteWhere.mockResolvedValue([{ affectedRows: 3 }]);
insertValues.mockImplementation(() => ({
onDuplicateKeyUpdate,
// biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock
then(resolve, reject) {
return Promise.resolve([{ insertId: 1 }]).then(resolve, reject);
},
correlationId: context.correlationId,
}));
onDuplicateKeyUpdate.mockResolvedValue([{ affectedRows: 1 }]);
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
selectLimit.mockResolvedValue([]);
selectWhereResolved.mockResolvedValue([]);
});
describe("legacy bulk user wrappers", () => {
it("preserves result shapes while delegating the exact operations", async () => {
await expect(bulkUnban({ userIds: [1, 2, 3] })).resolves.toEqual({
ok: true,
data: { unbanned: 3, total: 3 },
describe("bulkUnban", () => {
it("unbans users", async () => {
const r = await bulkUnban({ userIds: [1, 2, 3] });
expect(r.ok).toBe(true);
expect(r.data).toEqual({ unbanned: 3, total: 3 });
});
});
describe("bulkBan", () => {
it("bans users", async () => {
const r = await bulkBan({
userIds: [1, 2],
reason: "Spam",
duration: 3600,
});
await expect(
bulkBan({ userIds: [1, 2], reason: "Spam", duration: 3600 }),
).resolves.toEqual({ ok: true, data: { banned: 2 } });
await expect(
bulkGiveCurrency({ userIds: [1], amount: 100, type: "credits" }),
).resolves.toEqual({
ok: true,
data: { given: 2, total: 1, failedIds: [] },
expect(r.ok).toBe(true);
expect(r.data.banned).toBe(2);
expect(insertValues).toHaveBeenCalledTimes(2);
});
});
describe("bulkGiveCurrency", () => {
it("gives credits", async () => {
const r = await bulkGiveCurrency({
userIds: [1],
amount: 100,
type: "credits",
});
await expect(
bulkGiveBadge({ userIds: [1], badgeCode: "ADM" }),
).resolves.toEqual({
ok: true,
data: { given: 2, total: 1, failedIds: [] },
expect(r.data.given).toBe(1);
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
expect(updateWhere).toHaveBeenCalled();
});
it("gives pixels", async () => {
const r = await bulkGiveCurrency({
userIds: [2],
amount: 50,
type: "pixels",
});
expect(r.data.given).toBe(1);
expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50);
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
});
it("gives points", async () => {
const r = await bulkGiveCurrency({
userIds: [3],
amount: 25,
type: "points",
});
expect(r.data.given).toBe(1);
expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25);
expect(onDuplicateKeyUpdate).toHaveBeenCalled();
});
});
expect(execute.mock.calls.map((call) => call[1])).toEqual([
"users.bulk-unban",
"users.bulk-ban",
"users.bulk-currency",
"users.bulk-badge",
]);
describe("bulkGiveBadge", () => {
it("gives badge to user", async () => {
selectLimit.mockResolvedValueOnce([]);
selectWhereResolved.mockResolvedValueOnce([{ maxSlot: 5 }]);
const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" });
expect(r.data.given).toBe(1);
expect(insertValues).toHaveBeenCalled();
expect(rcon.giveBadge).toHaveBeenCalledWith(1, "ADM");
});
it("preserves the trade-lock API and exact normalized payload", async () => {
await expect(
setTradeLock({ userId: 9, untilUnix: 1234.8 }),
).resolves.toEqual({
ok: true,
data: { userId: 9, untilUnix: 1234 },
});
expect(execute).toHaveBeenLastCalledWith(
expect.objectContaining({ expectedActorId: 1 }),
"user.trade-lock",
{ userId: 9, untilUnix: 1234 },
);
});
it.each([
[
"currency",
"users.bulk-currency",
() => bulkGiveCurrency({ userIds: [7], amount: 100, type: "credits" }),
],
[
"badge",
"users.bulk-badge",
() => bulkGiveBadge({ userIds: [7], badgeCode: "ADM" }),
],
] as const)(
"restores the pre-Task12 legacy result when %s RCON throws after the database commit",
async (_kind, operation, invoke) => {
execute.mockResolvedValueOnce({
ok: true,
data: {
before: { completed: 0, total: 1, failedIds: [] },
after: {
completed: 1,
total: 1,
failedIds: [],
externalSyncFailures: [
{
userId: 7,
reason:
"Database applied; emulator sync failed. Do not retry automatically.",
},
],
},
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
},
correlationId: "legacy-external-sync-failure",
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
});
await expect(invoke()).resolves.toEqual({
ok: true,
data: {
given: 0,
total: 1,
failedIds: [{ userId: 7, reason: "Database error" }],
},
});
expect(execute).toHaveBeenCalledWith(
expect.anything(),
operation,
expect.anything(),
);
},
);
});
+226 -151
View File
@@ -1,100 +1,40 @@
"use server";
import { and, eq } from "drizzle-orm";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { and, eq, inArray, max, sql } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard";
import { db, User, UsersCurrency } from "@/lib/db";
import {
Ban,
db,
Sanctions,
User,
UsersBadges,
UsersCurrency,
UsersSettings,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import type { ActionResult } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
async function executeLegacy(
staff: {
readonly id: number;
readonly username: string;
readonly rank: number;
},
operation:
| "users.bulk-ban"
| "users.bulk-unban"
| "users.bulk-currency"
| "users.bulk-badge"
| "user.trade-lock",
input: unknown,
) {
return peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
}
function numberValue(value: unknown): number {
return Number.isSafeInteger(Number(value)) ? Number(value) : 0;
}
function failedIds(value: unknown): Array<{ userId: number; reason: string }> {
return Array.isArray(value)
? value.flatMap((item) =>
typeof item === "object" && item !== null
? [
{
userId: numberValue(Reflect.get(item, "userId")),
reason: String(Reflect.get(item, "reason") ?? "Database error"),
},
]
: [],
)
: [];
}
function legacyBulkOutcome(
after: Readonly<Record<string, unknown>> | null,
userIds: readonly number[],
): {
readonly completed: number;
readonly total: number;
readonly failedIds: Array<{ userId: number; reason: string }>;
} {
const databaseFailures = failedIds(after?.failedIds);
const externalSyncFailures = failedIds(after?.externalSyncFailures).map(
({ userId }) => ({ userId, reason: "Database error" }),
);
const pendingFailures = [...databaseFailures, ...externalSyncFailures];
const orderedFailures = userIds.flatMap((userId) => {
const index = pendingFailures.findIndex(
(failure) => failure.userId === userId,
);
return index === -1 ? [] : pendingFailures.splice(index, 1);
});
return {
completed: Math.max(
0,
numberValue(after?.completed) - externalSyncFailures.length,
),
total: numberValue(after?.total),
failedIds: [...orderedFailures, ...pendingFailures],
};
}
export async function bulkUnban({
userIds,
}: {
userIds: number[];
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const result = await executeLegacy(staff, "users.bulk-unban", { userIds });
if (!result.ok) return { ok: false, error: "Bulk unban failed" };
const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
const unbanned = Number(
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
await logStaffActivity({
staffId: staff.id,
action: "bulk_unban",
description: `Unbanned ${unbanned} user(s)`,
targetType: "user",
});
return {
ok: true,
data: {
unbanned: numberValue(result.data.after?.completed),
total: numberValue(result.data.after?.total),
},
ok: true as const,
data: { unbanned, total: userIds.length },
};
}
@@ -108,16 +48,34 @@ export async function bulkBan({
duration: number;
}): Promise<ActionResult<{ banned: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const result = await executeLegacy(staff, "users.bulk-ban", {
userIds,
reason,
duration,
const now = Math.floor(Date.now() / 1000);
let banned = 0;
for (const userId of userIds) {
try {
await db.insert(Ban).values({
userId,
ip: "",
machineId: "",
userStaffId: staff.id,
timestamp: now,
banExpire: duration > 0 ? now + duration : 0,
banReason: reason,
type: "account",
});
banned++;
} catch {
// skip duplicates
}
}
await logStaffActivity({
staffId: staff.id,
action: "bulk_ban",
description: `Banned ${banned} user(s)`,
targetType: "user",
});
if (!result.ok) return { ok: false, error: "Bulk ban failed" };
return {
ok: true,
data: { banned: numberValue(result.data.after?.completed) },
};
return { ok: true as const, data: { banned } };
}
export async function bulkGiveCurrency({
@@ -136,20 +94,49 @@ export async function bulkGiveCurrency({
}>
> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const result = await executeLegacy(staff, "users.bulk-currency", {
userIds,
amount,
type,
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of userIds) {
try {
if (type === "credits") {
await db
.update(User)
.set({ credits: sql`${User.credits} + ${amount}` })
.where(eq(User.id, userId));
await rcon.giveCredits(userId, amount);
} else if (type === "pixels") {
await db
.insert(UsersCurrency)
.values({ userId, type: 0, amount })
.onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
});
await rcon.giveDuckets(userId, amount);
} else if (type === "points") {
await db
.insert(UsersCurrency)
.values({ userId, type: 101, amount })
.onDuplicateKeyUpdate({
set: { amount: sql`${UsersCurrency.amount} + ${amount}` },
});
await rcon.givePointsGotw(userId, amount);
}
given++;
} catch {
failedIds.push({ userId, reason: "Database error" });
}
}
await logStaffActivity({
staffId: staff.id,
action: "bulk_give_currency",
description: `Gave ${amount} ${type} to ${given} user(s)`,
targetType: "user",
});
if (!result.ok) return { ok: false, error: "Bulk currency failed" };
const outcome = legacyBulkOutcome(result.data.after, userIds);
return {
ok: true,
data: {
given: outcome.completed,
total: outcome.total,
failedIds: outcome.failedIds,
},
ok: true as const,
data: { given, total: userIds.length, failedIds },
};
}
@@ -167,19 +154,45 @@ export async function bulkGiveBadge({
}>
> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const result = await executeLegacy(staff, "users.bulk-badge", {
userIds,
badgeCode,
let given = 0;
const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of userIds) {
try {
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
.where(
and(
eq(UsersBadges.userId, userId),
eq(UsersBadges.badgeCode, badgeCode),
),
)
.limit(1);
if (!existing) {
const [agg] = await db
.select({ maxSlot: max(UsersBadges.slotId) })
.from(UsersBadges)
.where(eq(UsersBadges.userId, userId));
const slotId = (agg?.maxSlot ?? 0) + 1;
await db.insert(UsersBadges).values({ userId, slotId, badgeCode });
await rcon.giveBadge(userId, badgeCode);
}
given++;
} catch {
failedIds.push({ userId, reason: "Database error" });
}
}
await logStaffActivity({
staffId: staff.id,
action: "bulk_give_badge",
description: `Gave badge "${badgeCode}" to ${given} user(s)`,
targetType: "user",
});
if (!result.ok) return { ok: false, error: "Bulk badge failed" };
const outcome = legacyBulkOutcome(result.data.after, userIds);
return {
ok: true,
data: {
given: outcome.completed,
total: outcome.total,
failedIds: outcome.failedIds,
},
ok: true as const,
data: { given, total: userIds.length, failedIds },
};
}
@@ -189,6 +202,7 @@ export async function bulkAdjustCurrency({
type,
}: {
userIds: number[];
/** Positive = give, negative = take. Balances clamped at 0. */
amount: number;
type: "credits" | "pixels" | "points";
}): Promise<
@@ -200,29 +214,29 @@ export async function bulkAdjustCurrency({
> {
const staff = await requirePermission(PERMS.USERS_EDIT);
if (!Number.isFinite(amount) || amount === 0) {
return { ok: false, error: "Amount must be a non-zero number" };
return { ok: false as const, error: "Amount must be a non-zero number" };
}
if (amount > 0) {
const result = await executeLegacy(staff, "users.bulk-currency", {
userIds,
amount,
type,
});
if (!result.ok) return { ok: false, error: "Currency adjustment failed" };
const outcome = legacyBulkOutcome(result.data.after, userIds);
const given = await bulkGiveCurrency({ userIds, amount, type });
if (!given.ok) return given;
if (!given.data) {
return { ok: false as const, error: "Currency adjustment failed" };
}
return {
ok: true,
ok: true as const,
data: {
adjusted: outcome.completed,
total: outcome.total,
failedIds: outcome.failedIds,
adjusted: given.data.given,
total: given.data.total,
failedIds: given.data.failedIds,
},
};
}
const take = Math.abs(Math.trunc(amount));
let adjusted = 0;
const failures: Array<{ userId: number; reason: string }> = [];
const failedIds: Array<{ userId: number; reason: string }> = [];
for (const userId of userIds) {
try {
if (type === "credits") {
@@ -232,13 +246,11 @@ export async function bulkAdjustCurrency({
.where(eq(User.id, userId))
.limit(1);
if (!user) {
failures.push({ userId, reason: "Not found" });
failedIds.push({ userId, reason: "Not found" });
continue;
}
await db
.update(User)
.set({ credits: Math.max(0, user.credits - take) })
.where(eq(User.id, userId));
const next = Math.max(0, user.credits - take);
await db.update(User).set({ credits: next }).where(eq(User.id, userId));
} else {
const currencyType = type === "pixels" ? 0 : 101;
const [row] = await db
@@ -251,17 +263,19 @@ export async function bulkAdjustCurrency({
),
)
.limit(1);
const next = Math.max(0, (row?.amount ?? 0) - take);
const current = row?.amount ?? 0;
const next = Math.max(0, current - take);
await db
.insert(UsersCurrency)
.values({ userId, type: currencyType, amount: next })
.onDuplicateKeyUpdate({ set: { amount: next } });
}
adjusted += 1;
adjusted++;
} catch {
failures.push({ userId, reason: "Database error" });
failedIds.push({ userId, reason: "Database error" });
}
}
await logStaffActivity({
staffId: staff.id,
action: "bulk_adjust_currency",
@@ -269,32 +283,93 @@ export async function bulkAdjustCurrency({
targetType: "user",
});
return {
ok: true,
data: { adjusted, total: userIds.length, failedIds: failures },
ok: true as const,
data: { adjusted, total: userIds.length, failedIds },
};
}
/**
* Persist trade lock on `sanctions.trade_locked_until` + `users_settings.can_trade`
* via Drizzle, then best-effort RCON sync (settradelock + alert + disconnect if online).
*/
export async function setTradeLock({
userId,
untilUnix,
}: {
userId: number;
/** Unix seconds; 0 clears the lock. */
untilUnix: number;
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT);
const until = Math.max(0, Math.trunc(untilUnix));
const result = await executeLegacy(staff, "user.trade-lock", {
userId,
untilUnix: until,
});
if (!result.ok) {
return {
ok: false,
error:
result.error.code === "NOT_FOUND"
? "User not found"
: "Trade lock update failed",
};
const locked = until > 0;
const [user] = await db
.select({
id: User.id,
username: User.username,
online: User.online,
})
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!user) {
return { ok: false as const, error: "User not found" };
}
return { ok: true, data: { userId, untilUnix: until } };
await db.transaction(async (tx) => {
const [existing] = await tx
.select({ id: Sanctions.id })
.from(Sanctions)
.where(eq(Sanctions.habboId, userId))
.limit(1);
if (existing) {
await tx
.update(Sanctions)
.set({
tradeLockedUntil: until,
...(locked ? { reason: "Trade lock (CMS)" } : {}),
})
.where(eq(Sanctions.id, existing.id));
} else {
await tx.insert(Sanctions).values({
habboId: userId,
tradeLockedUntil: until,
reason: locked ? "Trade lock (CMS)" : "",
});
}
await tx
.update(UsersSettings)
.set({
canTrade: locked ? "0" : "1",
...(locked
? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` }
: {}),
})
.where(eq(UsersSettings.userId, userId));
});
await rcon.setTradeLock(userId, locked);
await rcon.alertUser(
userId,
locked
? "Trading has been disabled by staff."
: "Trading has been re-enabled by staff.",
);
if (user.online === "1") {
await rcon.disconnectUser(userId, user.username);
}
await logStaffActivity({
staffId: staff.id,
action: locked ? "trade_lock" : "trade_unlock",
description: locked
? `Trade-locked ${user.username} (#${userId}) until ${until}`
: `Cleared trade lock for ${user.username} (#${userId})`,
targetType: "user",
targetId: userId,
});
return { ok: true as const, data: { userId, untilUnix: until } };
}
+220 -40
View File
@@ -1,12 +1,50 @@
"use server";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
function revalidateBuilderClub(): void {
revalidatePath("/ase/economy/catalog");
const BC_PAGE_FIELDS = [
"caption",
"parentId",
"pageLayout",
"enabled",
"visible",
"orderNum",
"iconImage",
"iconColor",
"pageHeadline",
"pageTeaser",
"pageSpecial",
"pageText1",
"pageText2",
"pageTextDetails",
"pageTextTeaser",
] as const;
const BC_ITEM_FIELDS = [
"itemIds",
"catalogName",
"orderNumber",
"extradata",
"pageId",
] as const;
function pickAllowed(
fields: Record<string, unknown>,
allowed: readonly string[],
) {
const out: Record<string, unknown> = {};
for (const key of allowed) {
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
out[key] = fields[key];
}
}
return out;
}
export async function updateBcPage({
@@ -14,22 +52,38 @@ export async function updateBcPage({
...fields
}: { id: number } & Record<string, unknown>) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "bc-page.change", {
action: "update",
id,
...fields,
const data = pickAllowed(fields, BC_PAGE_FIELDS);
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
await db
.update(CatalogPagesBc)
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
.where(eq(CatalogPagesBc.id, id));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_page_update",
description: `Updated BC catalog page #${id}`,
targetType: "catalog_page_bc",
targetId: id,
});
revalidateBuilderClub();
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
}
export async function deleteBcItem({ id }: { id: number }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "bc-item.change", {
action: "delete",
id,
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_delete",
description: `Deleted BC catalog item #${id}`,
targetType: "catalog_item_bc",
targetId: id,
});
revalidateBuilderClub();
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
}
@@ -44,12 +98,23 @@ export async function updateBcItem({
extradata?: string;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "bc-item.change", {
action: "update",
id,
...data,
const safe = pickAllowed(data as Record<string, unknown>, BC_ITEM_FIELDS);
if (Object.keys(safe).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
await db
.update(CatalogItemsBc)
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
.where(eq(CatalogItemsBc.id, id));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_update",
description: `Updated BC catalog item #${id}`,
targetType: "catalog_item_bc",
targetId: id,
});
revalidateBuilderClub();
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
}
@@ -64,13 +129,18 @@ export async function createBcItem({
extradata: string;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const snapshot = await executeLegacyEconomyMutation(staff, "bc-item.change", {
action: "create",
pageId,
...data,
const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data });
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_item_create",
description: `Created BC catalog item #${createdId}`,
targetType: "catalog_item_bc",
targetId: createdId,
});
revalidateBuilderClub();
return { ok: true as const, data: { id: Number(snapshot.output?.id) } };
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: createdId } };
}
export async function toggleBcPage({
@@ -80,12 +150,22 @@ export async function toggleBcPage({
id: number;
field: "enabled" | "visible";
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "bc-page.change", {
action: field === "enabled" ? "toggle-enabled" : "toggle-visible",
id,
});
revalidateBuilderClub();
await requirePermission(PERMS.CATALOG_EDIT);
const [page] = await db
.select({
enabled: CatalogPagesBc.enabled,
visible: CatalogPagesBc.visible,
})
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, id))
.limit(1);
if (!page) return { ok: false as const, error: "Page not found" };
await db
.update(CatalogPagesBc)
.set({ [field]: page[field] === "1" ? "0" : "1" })
.where(eq(CatalogPagesBc.id, id));
await rcon.updateCatalog();
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
}
@@ -100,12 +180,52 @@ export async function createBcPage(input: {
orderNum?: number;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const snapshot = await executeLegacyEconomyMutation(staff, "bc-page.change", {
action: "create",
...input,
const [result] = await db.insert(CatalogPagesBc).values({
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
pageHeadline: "",
pageTeaser: "",
});
revalidateBuilderClub();
return { ok: true as const, data: { id: Number(snapshot.output?.id) } };
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "bc_page_create",
description: `Created BC catalog page "${input.caption}"`,
targetType: "catalog_page_bc",
targetId: createdId,
});
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: createdId } };
}
async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
if (newParentId > 0) {
let currentId = newParentId;
for (let i = 0; i < 50; i++) {
if (currentId === pageId) {
throw new Error("Cannot move page: would create a circular hierarchy");
}
const [parent] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, currentId))
.limit(1);
if (!parent || parent.parentId <= 0) break;
currentId = parent.parentId;
}
}
await db
.update(CatalogPagesBc)
.set({ parentId: newParentId })
.where(eq(CatalogPagesBc.id, pageId));
}
export async function reorderBcTreePage(input: {
@@ -113,9 +233,24 @@ export async function reorderBcTreePage(input: {
newParentId?: number;
newOrderNum: number;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "bc-page.reorder", input);
revalidateBuilderClub();
await requirePermission(PERMS.CATALOG_EDIT);
if (input.newParentId !== undefined) {
try {
await moveBcPage(input.pageId, input.newParentId);
} catch (err) {
return {
ok: false as const,
error: err instanceof Error ? err.message : "Invalid move",
};
}
}
await db
.update(CatalogPagesBc)
.set({ orderNum: input.newOrderNum })
.where(eq(CatalogPagesBc.id, input.pageId));
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
}
@@ -123,8 +258,53 @@ export async function deleteBcTreePage(input: {
pageId: number;
mode: "reparent" | "cascade";
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "bc-page.delete-tree", input);
revalidateBuilderClub();
await requirePermission(PERMS.CATALOG_EDIT);
const [page] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, input.pageId))
.limit(1);
if (!page) return { ok: false as const, error: "Page not found" };
if (input.mode === "reparent") {
await db.transaction(async (tx) => {
await tx
.update(CatalogPagesBc)
.set({ parentId: page.parentId })
.where(eq(CatalogPagesBc.parentId, input.pageId));
await tx
.delete(CatalogItemsBc)
.where(eq(CatalogItemsBc.pageId, input.pageId));
await tx
.delete(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, input.pageId));
});
} else {
const toDelete: number[] = [input.pageId];
const queue: number[] = [input.pageId];
while (queue.length > 0) {
const children = await db
.select({ id: CatalogPagesBc.id })
.from(CatalogPagesBc)
.where(inArray(CatalogPagesBc.parentId, queue));
queue.length = 0;
for (const child of children) {
toDelete.push(child.id);
queue.push(child.id);
}
}
await db.transaction(async (tx) => {
await tx
.delete(CatalogItemsBc)
.where(inArray(CatalogItemsBc.pageId, toDelete));
await tx
.delete(CatalogPagesBc)
.where(inArray(CatalogPagesBc.id, toDelete));
});
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
}
+388 -62
View File
@@ -1,15 +1,108 @@
"use server";
import { eq, inArray, like, or, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import {
type EconomyMutationSnapshot,
executeLegacyEconomyMutation,
} from "@/features/housekeeping/domains/economy/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogItems, db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { translateItemsSchema } from "@/lib/validators/catalog";
function revalidateCatalog(): void {
revalidatePath("/ase/economy/catalog");
const CATALOG_ITEM_FIELDS = [
"pageId",
"itemIds",
"catalogName",
"costCredits",
"costPoints",
"pointsType",
"amount",
"orderNumber",
"offerId",
"songId",
"limitedSells",
"limitedStack",
"extradata",
"haveOffer",
"clubOnly",
] as const;
const ITEMS_BASE_FIELDS = [
"publicName",
"itemName",
"type",
"width",
"length",
"stackHeight",
"allowStack",
"allowSit",
"allowLay",
"allowWalk",
"allowGift",
"allowTrade",
"allowRecycle",
"allowMarketplaceSell",
"allowInventoryStack",
"interactionType",
"interactionModesCount",
"vendingIds",
"customparams",
"effectIdMale",
"effectIdFemale",
"clothingOnWalk",
] as const;
function pickAllowed(
fields: Record<string, unknown>,
allowed: readonly string[],
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of allowed) {
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
out[key] = fields[key];
}
}
return out;
}
/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */
async function insertCatalogItemRow(data: {
pageId: number;
itemIds: string;
catalogName: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
orderNumber: number;
offerId: number;
songId: number;
limitedSells: number;
limitedStack: number;
extradata: string;
haveOffer: string;
clubOnly: string;
}): Promise<number> {
const pageIdStr = String(data.pageId);
return allocateCatalogItemId(async (nextId) => {
await db.execute(sql`
INSERT INTO catalog_items (
id, page_id, item_ids, catalog_name,
cost_credits, cost_points, points_type, amount,
order_number, offer_id, song_id,
limited_sells, limited_stack, extradata, have_offer, club_only
) VALUES (
${nextId}, ${pageIdStr}, ${data.itemIds}, ${data.catalogName},
${data.costCredits}, ${data.costPoints}, ${data.pointsType}, ${data.amount},
${data.orderNumber}, ${data.offerId}, ${data.songId},
${data.limitedSells}, ${data.limitedStack}, ${data.extradata},
${data.haveOffer}, ${data.clubOnly}
)
`);
return nextId;
});
}
export async function createCatalogItem(data: {
@@ -30,16 +123,39 @@ export async function createCatalogItem(data: {
clubOnly: "0" | "1";
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const snapshot = await executeLegacyEconomyMutation(
staff,
"catalog-item.change",
{ action: "create", ...data },
);
revalidateCatalog();
return { ok: true as const, data: { id: Number(snapshot.output?.id) } };
let catalogName = data.catalogName.trim();
if (!catalogName) {
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
if (firstId > 0) {
const [base] = await db
.select({
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, firstId))
.limit(1);
catalogName = base?.publicName || base?.itemName || String(firstId);
}
}
const id = await insertCatalogItemRow({ ...data, catalogName });
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_item_create",
description: `Created catalog item #${id}`,
targetType: "catalog_item",
targetId: id,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id } };
}
export async function bulkCreateCatalogItems(input: {
/** Bulk create with one RCON refresh at the end. */
export async function bulkCreateCatalogItems({
pageId,
rows,
}: {
pageId: number;
rows: Array<{
baseId: number;
@@ -47,92 +163,302 @@ export async function bulkCreateCatalogItems(input: {
points?: number;
pointsType?: number;
}>;
}): Promise<
| { ok: true; data: { created: number; failed: number } }
| { ok: false; error: string }
> {
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
let snapshot: EconomyMutationSnapshot;
try {
snapshot = await executeLegacyEconomyMutation(
staff,
"catalog-item.bulk-create",
input,
);
} catch {
return { ok: false, error: "Bulk import failed" };
if (rows.length === 0) {
return { ok: true as const, data: { created: 0, failed: 0 } };
}
revalidateCatalog();
return {
ok: true as const,
data: {
created: Number(snapshot.output?.created ?? 0),
failed: Number(snapshot.output?.failed ?? 0),
},
};
if (rows.length > 500) {
return { ok: false as const, error: "Max 500 items per bulk import" };
}
const baseIds = [...new Set(rows.map((r) => r.baseId))];
const bases = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, baseIds));
const baseMap = new Map(bases.map((b) => [b.id, b]));
let created = 0;
let failed = 0;
for (const row of rows) {
const base = baseMap.get(row.baseId);
if (!base) {
failed++;
continue;
}
try {
await insertCatalogItemRow({
pageId,
itemIds: String(row.baseId),
catalogName: base.publicName || base.itemName || String(row.baseId),
costCredits: row.credits ?? 0,
costPoints: row.points ?? 0,
pointsType: row.pointsType ?? 0,
amount: 1,
limitedSells: 0,
limitedStack: 0,
orderNumber: 1,
offerId: -1,
songId: 0,
haveOffer: "1",
clubOnly: "0",
extradata: "",
});
created++;
} catch {
failed++;
}
}
if (created > 0) {
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_bulk_create",
description: `Bulk imported ${created} catalog item(s) on page #${pageId}`,
targetType: "catalog_page",
targetId: pageId,
});
revalidatePath("/admin/catalog");
}
return { ok: true as const, data: { created, failed } };
}
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "catalog-item.change", {
action: "delete",
ids,
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_delete",
description: `Deleted catalog items: ${ids.join(", ")}`,
targetType: "catalog_item",
});
revalidateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function moveCatalogItems(input: {
export async function moveCatalogItems({
ids,
targetPageId,
}: {
ids: number[];
targetPageId: number;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "catalog-item.move", input);
revalidateCatalog();
await requirePermission(PERMS.CATALOG_EDIT);
if (ids.length === 0) {
return { ok: true as const, data: {} };
}
const pageIdStr = String(targetPageId);
await db.execute(sql`
UPDATE catalog_items
SET page_id = ${pageIdStr}
WHERE id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})
`);
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function reorderCatalogItems(input: {
export async function reorderCatalogItems({
orders,
}: {
orders: Array<{ id: number; orderNumber: number }>;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "catalog-item.reorder", input);
revalidateCatalog();
await requirePermission(PERMS.CATALOG_EDIT);
for (const { id, orderNumber } of orders) {
await db
.update(CatalogItems)
.set({ orderNumber })
.where(eq(CatalogItems.id, id));
}
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function updateCatalogItem(input: {
export async function updateCatalogItem({
id,
catalogFields,
baseItem,
}: {
id: number;
catalogFields: Record<string, unknown>;
baseItem?: { id: number; fields: Record<string, unknown> };
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "catalog-item.change", {
action: "update",
...input,
const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS);
if (Object.keys(safeCatalog).length === 0 && !baseItem) {
return { ok: false as const, error: "No valid fields to update" };
}
// page_id is often VARCHAR — update it via raw SQL when present.
const pageIdRaw = safeCatalog.pageId;
if (pageIdRaw !== undefined) {
const pageIdStr = String(pageIdRaw);
await db.execute(sql`
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
`);
delete safeCatalog.pageId;
}
if (Object.keys(safeCatalog).length > 0) {
await db
.update(CatalogItems)
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
.where(eq(CatalogItems.id, id));
}
if (baseItem) {
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
if (Object.keys(safeBase).length > 0) {
await db
.update(ItemsBase)
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
.where(eq(ItemsBase.id, baseItem.id));
}
}
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_item_update",
description: `Updated catalog item #${id}`,
targetType: "catalog_item",
targetId: id,
});
revalidateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function translateCatalogItems(input: {
/** `id` is items_base.id (not catalog_items.id) */
items: Array<{ id: number; publicName: string; description?: string }>;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const snapshot = await executeLegacyEconomyMutation(
staff,
"catalog-item.translate",
input,
const parsed = translateItemsSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false as const,
error: parsed.error.issues[0]?.message ?? "Invalid translate payload",
};
}
const { items } = parsed.data;
const { invalidateFurniDataCache } = await import(
"@/lib/services/catalog-items-loader"
);
revalidateCatalog();
const { patchFurniEntryNames } = await import("@/lib/services/furni-data");
let namesUpdated = 0;
let descriptionsUpdated = 0;
const furniPatches: Array<{
classname: string;
itemType: string;
name?: string;
description?: string;
spriteId?: number;
createIfMissing?: boolean;
}> = [];
for (const item of items) {
const [base] = await db
.select({
id: ItemsBase.id,
publicName: ItemsBase.publicName,
itemName: ItemsBase.itemName,
type: ItemsBase.type,
spriteId: ItemsBase.spriteId,
})
.from(ItemsBase)
.where(eq(ItemsBase.id, item.id))
.limit(1);
if (!base) continue;
const nextName = item.publicName?.trim() ?? "";
const nextDesc = item.description ?? "";
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
if (nameChanged) {
await db
.update(ItemsBase)
.set({ publicName: nextName })
.where(eq(ItemsBase.id, base.id));
const idStr = String(base.id);
const related = await db
.select({
id: CatalogItems.id,
catalogName: CatalogItems.catalogName,
})
.from(CatalogItems)
.where(
or(
eq(CatalogItems.itemIds, idStr),
like(CatalogItems.itemIds, `${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr};%`),
like(CatalogItems.itemIds, `%;${idStr}`),
),
);
for (const row of related) {
if (row.catalogName !== nextName) {
await db
.update(CatalogItems)
.set({ catalogName: nextName })
.where(eq(CatalogItems.id, row.id));
}
}
namesUpdated++;
}
if (nextDesc !== "" || nameChanged) {
descriptionsUpdated += nextDesc !== "" ? 1 : 0;
furniPatches.push({
classname: base.itemName,
itemType: base.type || "s",
name: nextName || base.publicName || base.itemName,
description: nextDesc,
spriteId: base.spriteId,
createIfMissing: true,
});
}
}
const furniResult =
furniPatches.length > 0
? await patchFurniEntryNames(furniPatches)
: { updated: 0, inserted: 0 };
if (furniResult.updated > 0 || furniResult.inserted > 0) {
invalidateFurniDataCache();
}
await rcon.updateCatalog();
await logAudit({
userId: staff.id,
action: "items_base_translate",
target: "ItemsBase",
after: {
namesUpdated,
descriptionsUpdated,
furniDataUpdated: furniResult.updated > 0,
furniDataInserted: furniResult.inserted,
},
});
revalidatePath("/admin/catalog");
return {
ok: true as const,
data: {
namesUpdated: Number(snapshot.output?.namesUpdated ?? 0),
descriptionsUpdated: Number(snapshot.output?.descriptionsUpdated ?? 0),
furniDataUpdated: Number(snapshot.output?.furniDataUpdated ?? 0),
furniDataInserted: Number(snapshot.output?.furniDataInserted ?? 0),
updated: input.items.length,
namesUpdated,
descriptionsUpdated,
furniDataUpdated: furniResult.updated,
furniDataInserted: furniResult.inserted,
updated: items.length,
},
};
}
+132 -29
View File
@@ -1,32 +1,88 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogPages, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import type { ActionResult } from "@/lib/safe-action-shared";
import { deletePage, movePage } from "@/lib/services/catalog-tree";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const CATALOG_PAGE_FIELDS = [
"caption",
"parentId",
"pageLayout",
"enabled",
"visible",
"minRank",
"clubOnly",
"vipOnly",
"orderNum",
"iconImage",
"iconColor",
"pageHeadline",
"pageTeaser",
"pageSpecial",
"pageText1",
"pageText2",
"pageTextDetails",
"pageTextTeaser",
"includes",
"captionSave",
] as const;
function pickPageFields(fields: Record<string, unknown>) {
const out: Record<string, unknown> = {};
for (const key of CATALOG_PAGE_FIELDS) {
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
out[key] = fields[key];
}
}
return out;
}
export async function updateCatalogPage({
id,
...fields
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "catalog-page.change", {
action: "update",
id,
...fields,
const data = pickPageFields(fields);
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
}
if (typeof data.caption === "string" && !data.captionSave) {
data.captionSave = data.caption.slice(0, 25);
}
await db
.update(CatalogPages)
.set(data as Partial<typeof CatalogPages.$inferInsert>)
.where(eq(CatalogPages.id, id));
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_update",
description: `Updated catalog page #${id}`,
targetType: "catalog_page",
targetId: id,
});
revalidatePath("/ase/economy/catalog");
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
export async function deleteCatalogPage({ id }: { id: number }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "catalog-page.change", {
action: "delete",
id,
await deletePage(id, "reparent");
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_delete",
description: `Deleted catalog page #${id}`,
targetType: "catalog_page",
targetId: id,
});
revalidatePath("/ase/economy/catalog");
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
@@ -37,12 +93,24 @@ export async function toggleCatalogPage({
id: number;
action: "toggleEnabled" | "toggleVisible";
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "catalog-page.change", {
action: action === "toggleEnabled" ? "toggle-enabled" : "toggle-visible",
id,
});
revalidatePath("/ase/economy/catalog");
await requirePermission(PERMS.CATALOG_EDIT);
const [page] = await db
.select({
enabled: CatalogPages.enabled,
visible: CatalogPages.visible,
})
.from(CatalogPages)
.where(eq(CatalogPages.id, id))
.limit(1);
if (!page) return { ok: false as const, error: "Catalog page not found" };
const field = action === "toggleEnabled" ? "enabled" : "visible";
const current = action === "toggleEnabled" ? page.enabled : page.visible;
await db
.update(CatalogPages)
.set({ [field]: current === "1" ? "0" : "1" })
.where(eq(CatalogPages.id, id));
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
@@ -58,13 +126,33 @@ export async function createCatalogPage(input: {
orderNum?: number;
}): Promise<ActionResult<{ id: number }>> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const snapshot = await executeLegacyEconomyMutation(
staff,
"catalog-page.change",
{ action: "create", ...input },
);
const createdId = Number(snapshot.output?.id);
revalidatePath("/ase/economy/catalog");
const [result] = await db.insert(CatalogPages).values({
caption: input.caption,
parentId: input.parentId,
pageLayout: input.pageLayout ?? "default_3x3",
captionSave: input.caption.slice(0, 25),
iconColor: input.iconColor ?? 0,
iconImage: input.iconImage ?? 0,
minRank: input.minRank ?? 1,
orderNum: input.orderNum ?? 0,
visible: input.visible ?? "1",
enabled: input.enabled ?? "1",
clubOnly: "0",
vipOnly: "0",
pageHeadline: "",
pageTeaser: "",
includes: "",
});
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_create",
description: `Created catalog page "${input.caption}"`,
targetType: "catalog_page",
targetId: createdId,
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: createdId } };
}
@@ -73,9 +161,23 @@ export async function reorderTreePage(input: {
newParentId?: number;
newOrderNum: number;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "catalog-page.reorder", input);
revalidatePath("/ase/economy/catalog");
await requirePermission(PERMS.CATALOG_EDIT);
if (input.newParentId !== undefined) {
try {
await movePage(input.pageId, input.newParentId);
} catch (err) {
return {
ok: false as const,
error: err instanceof Error ? err.message : "Invalid move",
};
}
}
await db
.update(CatalogPages)
.set({ orderNum: input.newOrderNum })
.where(eq(CatalogPages.id, input.pageId));
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
@@ -83,8 +185,9 @@ export async function deleteTreePage(input: {
pageId: number;
mode: "reparent" | "cascade";
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
await executeLegacyEconomyMutation(staff, "catalog-page.delete-tree", input);
revalidatePath("/ase/economy/catalog");
await requirePermission(PERMS.CATALOG_EDIT);
await deletePage(input.pageId, input.mode);
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
}
+275
View File
@@ -0,0 +1,275 @@
"use server";
import { eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
const PATH = "/admin/commandocentrum";
const RCON_FAIL = "RCON command failed. Is the emulator running?";
async function requireRconOk(ok: boolean): Promise<void> {
if (!ok) throw new ActionError(RCON_FAIL);
}
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
export const updateCatalog = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.updateCatalog());
revalidatePath(PATH);
return actionOk();
},
);
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
export const updateWordFilter = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.updateWordFilter());
revalidatePath(PATH);
return actionOk();
},
);
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
export const updateNavigator = adminAction(
{ permission: PERMS.RCON_EXECUTE },
async () => {
await requireRconOk(await rcon.send("updatenavigator", null));
revalidatePath(PATH);
return actionOk();
},
);
const hotelAlertSchema = z.object({
message: z.string().trim().min(1).max(512),
});
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
export const hotelAlert = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
async (ctx) => {
const message = ctx.data.message.normalize("NFC");
await requireRconOk(await rcon.send("hotelalert", { message }));
revalidatePath(PATH);
return actionOk();
},
);
const disconnectSchema = z.object({
userId: z.coerce.number().int().positive(),
username: z.string().trim().min(1),
});
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
export const disconnectUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
async (ctx) => {
const username = ctx.data.username.normalize("NFC");
await requireRconOk(await rcon.disconnectUser(ctx.data.userId, username));
revalidatePath(PATH);
return actionOk();
},
);
const alertUserSchema = z.object({
userId: z.coerce.number().int().positive(),
message: z.string().trim().min(1).max(512),
});
/** Send an alert to a specific user (rcon: alertuser). */
export const alertUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
async (ctx) => {
const message = ctx.data.message.normalize("NFC");
await requireRconOk(await rcon.alertUser(ctx.data.userId, message));
revalidatePath(PATH);
return actionOk();
},
);
const forwardUserSchema = z.object({
userId: z.coerce.number().int().positive(),
roomId: z.coerce.number().int().positive(),
});
/** Forward a user to a specific room (rcon: forwarduser). */
export const forwardUser = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
async (ctx) => {
await requireRconOk(
await rcon.forwardUser(ctx.data.userId, ctx.data.roomId),
);
revalidatePath(PATH);
return actionOk();
},
);
const giveCreditsSchema = z.object({
userId: z.coerce.number().int().positive(),
credits: z.coerce.number().int().positive(),
});
/** Give credits to a user (rcon: givecredits). */
export const giveCredits = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveCredits(ctx.data.userId, ctx.data.credits),
);
revalidatePath(PATH);
return actionOk();
},
);
const giveAmountSchema = z.object({
userId: z.coerce.number().int().positive(),
amount: z.coerce.number().int().positive(),
});
/** Give duckets to a user (rcon: givepoints type=duckets). */
export const giveDuckets = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveDuckets(ctx.data.userId, ctx.data.amount),
);
revalidatePath(PATH);
return actionOk();
},
);
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
export const giveDiamonds = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
async (ctx) => {
await requireRconOk(
await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount),
);
revalidatePath(PATH);
return actionOk();
},
);
const giveBadgeSchema = z.object({
userId: z.coerce.number().int().positive(),
badge: z.string().trim().min(1).max(32),
});
/** Give a badge to a user (rcon: givebadge). */
export const giveBadge = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
async (ctx) => {
const badge = ctx.data.badge.normalize("NFC");
await requireRconOk(await rcon.giveBadge(ctx.data.userId, badge));
revalidatePath(PATH);
return actionOk();
},
);
const setMottoSchema = z.object({
userId: z.coerce.number().int().positive(),
motto: z.string().trim().min(1).max(127),
});
/** Set a user's motto (rcon: setmotto). */
export const setMotto = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
async (ctx) => {
const motto = ctx.data.motto.normalize("NFC");
await requireRconOk(await rcon.setMotto(ctx.data.userId, motto));
revalidatePath(PATH);
return actionOk();
},
);
const setRankSchema = z.object({
userId: z.coerce.number().int().positive(),
rank: z.coerce.number().int().min(1).max(9999),
});
/** Set a user's rank (rcon: setrank). */
export const setRank = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
async (ctx) => {
const staffRank = Number(ctx.session.user.rank);
const isSuper = ctx.permissions.isSuperAdmin;
const [target] = await db
.select({ rank: User.rank })
.from(User)
.where(eq(User.id, ctx.data.userId))
.limit(1);
if (!target) throw new ActionError("User not found");
let rankExists: { id: number }[] = [];
try {
const [rows] = await db.execute(
sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`,
);
rankExists = rows as unknown as { id: number }[];
} catch {
rankExists = [];
}
if (rankExists.length === 0) throw new ActionError("Rank does not exist");
if (!isSuper) {
if (target.rank >= staffRank) {
throw new ActionError(
"Cannot change rank of a user at or above your rank",
);
}
if (ctx.data.rank >= staffRank) {
throw new ActionError("Cannot set a rank equal to or above your own");
}
}
await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank));
await db
.update(User)
.set({ rank: ctx.data.rank })
.where(eq(User.id, ctx.data.userId));
revalidatePath(PATH);
return actionOk();
},
);
const executeCommandSchema = z.object({
userId: z.coerce.number().int().positive(),
command: z.string().trim().min(1).max(100),
});
/** Execute a command as a user (rcon: executecommand). */
export const executeCommand = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
async (ctx) => {
const command = ctx.data.command.normalize("NFC");
await requireRconOk(await rcon.executeCommand(ctx.data.userId, command));
revalidatePath(PATH);
return actionOk();
},
);
const sendGiftSchema = z.object({
userId: z.coerce.number().int().positive(),
itemId: z.coerce.number().int().positive(),
message: z.string().trim().max(255).optional().default("Here is a gift."),
});
/** Send a gift to a user (rcon: sendgift). */
export const sendGift = adminAction(
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
async (ctx) => {
const message = ctx.data.message.trim().slice(0, 255) || "Here is a gift.";
await requireRconOk(
await rcon.sendGift(ctx.data.userId, ctx.data.itemId, message),
);
revalidatePath(PATH);
return actionOk();
},
);
-325
View File
@@ -1,325 +0,0 @@
// @ts-nocheck
import { readFileSync } from "node:fs";
import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { requirePermission, requireStaff } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permission-slugs";
import { createAd } from "./admin-ads";
import { createArticle } from "./admin-articles";
import { uploadMedia } from "./admin-media";
import { deleteFavicon, saveFavicon } from "./save-favicon";
import { saveLogo } from "./save-logo";
const { execute, auditedBrandExecute } = vi.hoisted(() => ({
execute: vi.fn(async () => ({
ok: true,
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
correlationId: "legacy",
})),
auditedBrandExecute: vi.fn(async () => ({
before: null,
after: { value: "/api/media/x" },
output: { url: "/api/media/x" },
})),
}));
const { executeLegacyBrandAssetMutation } = vi.hoisted(() => ({
executeLegacyBrandAssetMutation: vi.fn(async () => ({
before: null,
after: { value: "/api/media/x" },
output: { url: "/api/media/x" },
})),
}));
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
contentMutationService: { execute },
createContentMutationInvocation: (actor, correlationId) => ({
expectedActorId: actor.id,
correlationId,
legacy: true,
}),
}));
vi.mock(
"@/features/housekeeping/domains/content/services/mutations-production",
() => ({
contentProductionMutationAdapter: { execute: auditedBrandExecute },
}),
);
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: vi.fn(async () => ({
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => false,
hasAny: () => false,
hasAll: () => false,
})),
}));
vi.mock(
"@/features/housekeeping/domains/content/services/mutation-runtime-external",
() => ({
executeLegacyBrandAssetMutation,
}),
);
vi.mock("@/lib/admin/guard", () => ({
requirePermission: vi.fn(),
requireStaff: vi.fn(),
}));
vi.mock("@/lib/safe-action", () => ({
adminAction: (_options: unknown, handler: unknown) => handler,
}));
vi.mock("@/lib/safe-action-shared", () => ({
ActionError: class ActionError extends Error {},
actionOk: (data: unknown = {}) => ({ ok: true, data }),
}));
vi.mock("@/lib/logger", () => ({
logger: { error: vi.fn() },
}));
vi.mock("@/lib/permissions", () => ({
PERMS: {
NEWS_EDIT: "news.edit",
PAGES_EDIT: "pages.edit",
SETTINGS_EDIT: "settings.edit",
SETTINGS_VIEW: "settings.view",
},
}));
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: vi.fn(async () => []) })),
})),
})),
insert: vi.fn(() => ({
values: vi.fn(async () => [{ insertId: 1 }]),
})),
},
WebsiteArticles: { id: "id", slug: "slug" },
WebsiteAds: { id: "id" },
WebsiteSetting: { key: "key" },
}));
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: vi.fn(),
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { get: vi.fn(), reload: vi.fn() },
}));
vi.mock("@/lib/media-storage", () => ({
MEDIA_ROOT: "C:\\media",
resolveMediaPath: vi.fn((name: string) => `C:\\media\\${name}`),
}));
vi.mock("node:fs/promises", () => ({
mkdir: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
const staff = { id: 42, rank: 7, username: "operator" };
const form = (data: Record<string, FormDataEntryValue>) => ({
get: (key: string) => data[key] ?? null,
has: (key: string) => key in data,
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
vi.mocked(requireStaff).mockResolvedValue(staff as never);
execute.mockResolvedValue({
ok: true,
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
correlationId: "legacy",
});
auditedBrandExecute.mockResolvedValue({
before: null,
after: { value: "/api/media/x" },
output: { url: "/api/media/x" },
});
});
describe("Content compatibility wrappers", () => {
it("delegates article creation and preserves redirect ordering", async () => {
await createArticle(
form({
title: "Launch",
shortStory: "Summary",
fullStory: "Body",
image: "/image.png",
}) as FormData,
);
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 42, legacy: true }),
"article.change",
expect.objectContaining({ action: "create", title: "Launch" }),
);
expect(redirect).toHaveBeenCalledWith("/ase/content/editorial/articles");
});
it("delegates ad creation and keeps the legacy void/redirect contract", async () => {
expect(
await createAd(
form({ image: "https://example.test/ad.png" }) as FormData,
),
).toBeUndefined();
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 42, legacy: true }),
"ad.change",
expect.objectContaining({ action: "create" }),
);
expect(redirect).toHaveBeenCalledWith("/ase/content/media/ads");
});
it("delegates media and favicon uploads while retaining public result shapes", async () => {
const file = new File(["bytes"], "image.png", { type: "image/png" });
const media = await uploadMedia(form({ file }) as FormData);
const favicon = await saveFavicon(form({ file }) as FormData);
expect(media).toEqual({ ok: true });
expect(favicon).toEqual({ success: true, url: "/api/media/x" });
expect(execute).toHaveBeenCalledWith(
expect.anything(),
"media.upload",
expect.objectContaining({ file }),
);
expect(auditedBrandExecute).toHaveBeenCalledWith(
"favicon.save",
{ file },
expect.objectContaining({
capability: expect.objectContaining({
actor: expect.objectContaining({ id: 42 }),
}),
legacy: true,
}),
);
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
});
it("preserves the favicon page gate and requires settings edit for logo mutation", async () => {
vi.clearAllMocks();
const file = new File(["bytes"], "image.png", { type: "image/png" });
await saveFavicon(form({ file }) as FormData);
await deleteFavicon();
await saveLogo(form({ file }) as FormData);
expect(requirePermission).toHaveBeenNthCalledWith(1, "settings.view");
expect(requirePermission).toHaveBeenNthCalledWith(2, "settings.view");
expect(requirePermission).toHaveBeenNthCalledWith(3, PERMS.SETTINGS_EDIT);
expect(requireStaff).not.toHaveBeenCalled();
expect(
auditedBrandExecute.mock.calls.map(([operation]) => operation),
).toEqual(["favicon.save", "favicon.delete", "logo.save"]);
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
});
it("does not mutate brand assets when either legacy guard denies access", async () => {
const file = new File(["bytes"], "image.png", { type: "image/png" });
vi.mocked(requirePermission).mockRejectedValueOnce(
new Error("favicon denied"),
);
await expect(saveFavicon(form({ file }) as FormData)).rejects.toThrow(
"favicon denied",
);
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
expect(auditedBrandExecute).not.toHaveBeenCalled();
vi.mocked(requirePermission).mockRejectedValueOnce(
new Error("logo denied"),
);
await expect(saveLogo(form({ file }) as FormData)).rejects.toThrow(
"logo denied",
);
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
expect(auditedBrandExecute).not.toHaveBeenCalled();
});
it("does not let a staff-only actor bypass the logo settings ACL", async () => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockRejectedValueOnce(
new Error("settings edit denied"),
);
const file = new File(["bytes"], "logo.png", { type: "image/png" });
await expect(saveLogo(form({ file }) as FormData)).rejects.toThrow(
"settings edit denied",
);
expect(requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
expect(requireStaff).not.toHaveBeenCalled();
expect(auditedBrandExecute).not.toHaveBeenCalled();
});
it("refuses a brand mutation when the rehydrated actor changes after the legacy guard", async () => {
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValueOnce({
actor: { id: 99, username: "other", rank: 7 },
isSuperAdmin: false,
has: () => false,
hasAny: () => false,
hasAll: () => false,
} as never);
const file = new File(["bytes"], "logo.png", { type: "image/png" });
await expect(saveLogo(form({ file }) as FormData)).resolves.toEqual({
success: false,
error: "Authenticated staff changed during logo mutation",
});
expect(auditedBrandExecute).not.toHaveBeenCalled();
});
it("maps a favicon audit partial to the truthful legacy result shape", async () => {
auditedBrandExecute.mockResolvedValueOnce({
before: { value: "/old.ico" },
after: { value: "/api/media/favicon/new.ico" },
output: { url: "/api/media/favicon/new.ico" },
completion: {
status: "partial",
external: "completed",
audit: "unavailable",
},
});
const file = new File(["bytes"], "favicon.png", { type: "image/png" });
await expect(saveFavicon(form({ file }) as FormData)).resolves.toEqual({
success: false,
url: "/api/media/favicon/new.ico",
error:
"Favicon change completed partially; verify storage and audit state",
});
});
it("maps a logo audit partial to the truthful legacy result shape", async () => {
auditedBrandExecute.mockResolvedValueOnce({
before: { value: "/old.png" },
after: { value: "/api/media/logo/new.png" },
output: { url: "/api/media/logo/new.png" },
completion: {
status: "partial",
external: "completed",
audit: "unavailable",
},
});
const file = new File(["bytes"], "logo.png", { type: "image/png" });
await expect(saveLogo(form({ file }) as FormData)).resolves.toEqual({
success: false,
url: "/api/media/logo/new.png",
error: "Logo change completed partially; verify storage and audit state",
});
});
it("keeps every listed legacy action as a thin shared-service wrapper", () => {
for (const path of [
"src/actions/admin-ads.ts",
"src/actions/admin-articles.ts",
"src/actions/admin-help.ts",
"src/actions/admin-media.ts",
"src/actions/admin-photos.ts",
"src/actions/admin-tags.ts",
"src/actions/events.ts",
"src/actions/polls.ts",
"src/actions/save-favicon.ts",
"src/actions/save-logo.ts",
"src/actions/emulator.ts",
]) {
const source = readFileSync(path, "utf8");
expect(
source.includes("contentMutationService") ||
source.includes("contentProductionMutationAdapter") ||
source.includes('from "./banners"'),
path,
).toBe(true);
}
});
});
+32 -22
View File
@@ -1,38 +1,48 @@
// @ts-nocheck
import { describe, expect, it, vi } from "vitest";
import { rcon } from "@/lib/services/rcon";
const { insertValues } = vi.hoisted(() => {
const insertValues = vi.fn(() => ({
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
}));
return { insertValues };
});
const { execute } = vi.hoisted(() => ({
execute: vi.fn(async () => ({
ok: true,
data: { before: null, after: { keys: ["key1", "key2"] } },
correlationId: "emulator",
})),
}));
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
contentMutationService: { execute },
}));
vi.mock("@/lib/permissions", () => ({
PERMS: { SETTINGS_EDIT: "settings.edit" },
}));
vi.mock("@/lib/safe-action", () => ({
adminAction: (_options, handler) => handler,
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: insertValues })),
},
EmulatorSettings: { key: "key", value: "value" },
}));
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: () => "ok" }));
vi.mock("@/lib/safe-action", () => ({
adminAction: vi.fn(
(_opts: unknown, fn: (...args: unknown[]) => unknown) => fn,
),
}));
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") }));
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } }));
describe("saveEmulatorSettings", () => {
it("delegates the third translation store and preserves result shape", async () => {
const handler = (await import("./emulator"))
.saveEmulatorSettings as unknown as (ctx: unknown) => Promise<string>;
it("saves settings and calls rcon update", async () => {
const handler = (await import("./emulator").then(
(m) => m.saveEmulatorSettings,
)) as unknown as (ctx: {
data: { settings: Record<string, string> };
session: { user: { id: string } };
}) => Promise<string>;
const result = await handler({
data: { settings: { key1: "val1", key2: "val2" } },
session: { user: { id: "1" } },
requestId: "emulator",
});
expect(execute).toHaveBeenCalledWith(
{ correlationId: "emulator", expectedActorId: 1, legacy: true },
"translation.emulator.save",
{ settings: { key1: "val1", key2: "val2" } },
);
expect(insertValues).toHaveBeenCalledTimes(2);
expect(rcon.updateConfig).toHaveBeenCalled();
expect(result).toBe("ok");
});
});
+21 -11
View File
@@ -1,10 +1,12 @@
"use server";
import { z } from "zod";
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
import { db, EmulatorSettings } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon";
const saveEmulatorSettingsSchema = z.object({
settings: z.record(z.string(), z.string()),
@@ -13,16 +15,24 @@ const saveEmulatorSettingsSchema = z.object({
export const saveEmulatorSettings = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"translation.emulator.save",
ctx.data,
);
if (!result.ok) throw new Error(result.error.messageKey);
const entries = Object.entries(ctx.data.settings);
for (const [key, value] of entries) {
await db
.insert(EmulatorSettings)
.values({ key, value: String(value) })
.onDuplicateKeyUpdate({ set: { value: String(value) } });
}
await rcon.updateConfig();
logAudit({
userId: ctx.session.user.id,
action: "emulator_settings_update",
target: "EmulatorSettings",
after: ctx.data.settings,
});
return actionOk();
},
);
+124 -96
View File
@@ -3,16 +3,18 @@
import { and, count, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
import {
db,
WebsiteEvent,
WebsiteEventPrize,
WebsiteEventRegistration,
WebsiteEventType,
WebsiteEventWinner,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import {
createEventSchema,
eventPrizeSchema,
@@ -27,17 +29,16 @@ import {
export const createEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"event-type.change",
{ action: "create", ...ctx.data },
);
if (!result.ok) throw new ActionError("Event type creation failed");
return actionOk({ id: Number(result.data.output?.id) });
const [result] = await db.insert(WebsiteEventType).values(ctx.data);
const eventTypeId = Number(result.insertId);
logAudit({
userId: ctx.session.user.id,
action: "event_type_create",
target: "WebsiteEventType",
targetId: eventTypeId,
after: { name: ctx.data.name },
});
return actionOk({ id: eventTypeId });
},
);
@@ -48,17 +49,27 @@ const updateEventTypeInput = eventTypeSchema.partial().extend({
export const updateEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"event-type.change",
{ action: "update", ...ctx.data },
);
if (!result.ok) throw new ActionError("Event type not found");
return actionOk({ id: ctx.data.id });
const { id, ...data } = ctx.data;
const [existing] = await db
.select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
.from(WebsiteEventType)
.where(eq(WebsiteEventType.id, id))
.limit(1);
if (!existing) throw new ActionError("Event type not found");
await db
.update(WebsiteEventType)
.set(data)
.where(eq(WebsiteEventType.id, id));
logAudit({
userId: ctx.session.user.id,
action: "event_type_update",
target: "WebsiteEventType",
targetId: id,
before: { name: existing.name },
after: data,
});
return actionOk({ id });
},
);
@@ -69,16 +80,23 @@ const deleteEventTypeInput = z.object({
export const deleteEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"event-type.change",
{ action: "delete", ...ctx.data },
);
if (!result.ok) throw new ActionError("Event type not found");
const [existing] = await db
.select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
.from(WebsiteEventType)
.where(eq(WebsiteEventType.id, ctx.data.id))
.limit(1);
if (!existing) throw new ActionError("Event type not found");
await db
.delete(WebsiteEventType)
.where(eq(WebsiteEventType.id, ctx.data.id));
logAudit({
userId: ctx.session.user.id,
action: "event_type_delete",
target: "WebsiteEventType",
targetId: ctx.data.id,
before: { name: existing.name },
});
return actionOk();
},
);
@@ -88,17 +106,21 @@ export const deleteEventType = adminAction(
export const createEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"event.change",
{ action: "create", ...ctx.data },
);
if (!result.ok) throw new ActionError("Event creation failed");
return actionOk({ id: Number(result.data.output?.id) });
const now = new Date();
const [result] = await db.insert(WebsiteEvent).values({
...ctx.data,
hostUserId: Number(ctx.session.user.id),
updatedAt: now,
});
const eventId = Number(result.insertId);
logAudit({
userId: ctx.session.user.id,
action: "event_create",
target: "WebsiteEvent",
targetId: eventId,
after: { title: ctx.data.title },
});
return actionOk({ id: eventId });
},
);
@@ -109,17 +131,31 @@ const updateEventInput = updateEventSchema.extend({
export const updateEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"event.change",
{ action: "update", ...ctx.data },
);
if (!result.ok) throw new ActionError("Event not found");
return actionOk({ id: ctx.data.id });
const { id, ...data } = ctx.data;
const [existing] = await db
.select({
id: WebsiteEvent.id,
title: WebsiteEvent.title,
status: WebsiteEvent.status,
})
.from(WebsiteEvent)
.where(eq(WebsiteEvent.id, id))
.limit(1);
if (!existing) throw new ActionError("Event not found");
await db
.update(WebsiteEvent)
.set({ ...data, updatedAt: new Date() })
.where(eq(WebsiteEvent.id, id));
logAudit({
userId: ctx.session.user.id,
action: "event_update",
target: "WebsiteEvent",
targetId: id,
before: { title: existing.title, status: existing.status },
after: data,
});
return actionOk({ id });
},
);
@@ -130,16 +166,21 @@ const deleteEventInput = z.object({
export const deleteEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"event.change",
{ action: "delete", ...ctx.data },
);
if (!result.ok) throw new ActionError("Event not found");
const [existing] = await db
.select({ id: WebsiteEvent.id, title: WebsiteEvent.title })
.from(WebsiteEvent)
.where(eq(WebsiteEvent.id, ctx.data.id))
.limit(1);
if (!existing) throw new ActionError("Event not found");
await db.delete(WebsiteEvent).where(eq(WebsiteEvent.id, ctx.data.id));
logAudit({
userId: ctx.session.user.id,
action: "event_delete",
target: "WebsiteEvent",
targetId: ctx.data.id,
before: { title: existing.title },
});
return actionOk();
},
);
@@ -149,17 +190,8 @@ export const deleteEvent = adminAction(
export const addEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"event-prize.change",
{ action: "create", ...ctx.data },
);
if (!result.ok) throw new ActionError("Event prize creation failed");
return actionOk({ id: Number(result.data.output?.id) });
const [result] = await db.insert(WebsiteEventPrize).values(ctx.data);
return actionOk({ id: Number(result.insertId) });
},
);
@@ -168,16 +200,9 @@ const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"event-prize.change",
{ action: "delete", ...ctx.data },
);
if (!result.ok) throw new ActionError("Event prize deletion failed");
await db
.delete(WebsiteEventPrize)
.where(eq(WebsiteEventPrize.id, ctx.data.id));
return actionOk();
},
);
@@ -187,17 +212,20 @@ export const deleteEventPrize = adminAction(
export const addEventWinner = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
const [result] = await db.insert(WebsiteEventWinner).values(ctx.data);
const winnerId = Number(result.insertId);
logAudit({
userId: ctx.session.user.id,
action: "event_winner_add",
target: "WebsiteEventWinner",
targetId: winnerId,
after: {
eventId: ctx.data.eventId,
userId: ctx.data.userId,
position: ctx.data.position,
},
"event-winner.add",
ctx.data,
);
if (!result.ok) throw new ActionError("Event winner creation failed");
return actionOk({ id: Number(result.data.output?.id) });
});
return actionOk({ id: winnerId });
},
);
+111
View File
@@ -0,0 +1,111 @@
"use server";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import {
type ActionResult,
actionOk,
handleActionError,
} from "@/lib/safe-action-shared";
import type {
AlignResult,
DedupResult,
FixOfferResult,
FixSpriteResult,
FurniHealth,
ReconcileResult,
} from "@/lib/services/furni-maintenance";
import * as maintenance from "@/lib/services/furni-maintenance";
async function guard() {
await requirePermission(PERMS.CATALOG_EDIT);
}
export async function getFurniHealthAction(): Promise<
ActionResult<{ health: FurniHealth }>
> {
try {
await guard();
const health = await maintenance.getFurniHealth();
return actionOk({ health });
} catch (e) {
return handleActionError(e);
}
}
export async function fixSpriteIdsAction(): Promise<
ActionResult<FixSpriteResult>
> {
try {
await guard();
return actionOk(await maintenance.fixSpriteIds());
} catch (e) {
return handleActionError(e);
}
}
export async function fixCatalogOffersAction(): Promise<
ActionResult<FixOfferResult>
> {
try {
await guard();
return actionOk(await maintenance.fixCatalogOffers());
} catch (e) {
return handleActionError(e);
}
}
export async function reconcileIdsAction(): Promise<
ActionResult<ReconcileResult>
> {
try {
await guard();
return actionOk(await maintenance.reconcileIds());
} catch (e) {
return handleActionError(e);
}
}
export async function removeDuplicateItemsBaseAction(): Promise<
ActionResult<DedupResult>
> {
try {
await guard();
return actionOk(await maintenance.removeDuplicates());
} catch (e) {
return handleActionError(e);
}
}
export async function previewAlignIdsAction(): Promise<
ActionResult<AlignResult>
> {
try {
await guard();
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(false));
} catch (e) {
return handleActionError(e);
}
}
export async function applyAlignIdsAction(): Promise<
ActionResult<AlignResult>
> {
try {
await guard();
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true));
} catch (e) {
return handleActionError(e);
}
}
export async function fixEverythingAction(input?: {
dedupePages?: boolean;
}): Promise<ActionResult<maintenance.FixAllResult>> {
try {
await guard();
return actionOk(await maintenance.fixEverything(input ?? {}));
} catch (e) {
return handleActionError(e);
}
}
+4 -14
View File
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { positiveBigInt } from "@/lib/api";
import { auth } from "@/lib/auth";
import {
db,
@@ -13,10 +14,7 @@ import {
} from "@/lib/db";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { moderateOrThrow } from "@/lib/services/moderation";
import {
canonicalTicketId,
createOwnedTicketReply,
} from "@/lib/services/ticket-replies";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
const ticketSchema = z.object({
title: z.string().min(1, "Title is required").max(255),
@@ -66,14 +64,6 @@ function isNextRedirect(e: unknown): boolean {
);
}
function helpTicketId(formData: FormData): bigint | null {
try {
return canonicalTicketId(String(formData.get("ticketId") ?? ""));
} catch {
return null;
}
}
export async function createTicket(formData: FormData): Promise<void> {
let outcome: TicketOutcome = "error";
@@ -187,7 +177,7 @@ const replyContentSchema = z.object({
});
export async function replyHelpTicket(formData: FormData): Promise<void> {
const ticketId = helpTicketId(formData);
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
let outcome: TicketDetailOutcome = "error";
try {
@@ -294,7 +284,7 @@ export async function replyHelpTicket(formData: FormData): Promise<void> {
}
export async function closeHelpTicket(formData: FormData): Promise<void> {
const ticketId = helpTicketId(formData);
const ticketId = positiveBigInt(String(formData.get("ticketId") ?? ""));
let outcome: TicketDetailOutcome = "error";
try {
-167
View File
@@ -1,167 +0,0 @@
import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
const { executeLegacyHotelMutation, staff } = vi.hoisted(() => ({
executeLegacyHotelMutation: vi.fn(
async (
_actor: { readonly id: number },
_operation: string,
_input: unknown,
) => ({ before: null, after: {} }),
),
staff: { id: 42, rank: 7, username: "operator" },
}));
vi.mock("@/features/housekeeping/domains/hotel/services/mutations", () => ({
executeLegacyHotelMutation,
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: {
ROOMS_EDIT: "admin.room.edit",
ROOMS_DELETE: "admin.room.delete",
RADIO_EDIT: "admin.radio.edit",
},
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { reload: vi.fn() },
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
import {
createApiKey,
deleteApiKey,
toggleApiKey,
} from "./admin-radio-api-keys";
import { createTrack, deleteTrack, toggleTrack } from "./admin-radio-autodj";
import {
createRadioBanner,
createRadioRank,
deleteRadioBanner,
deleteRadioRank,
saveRadioSetting,
saveRadioSettings,
updateRadioBanner,
updateRadioRank,
} from "./admin-radio-extra";
import { deleteShout } from "./admin-radio-moderation";
import { savePoints } from "./admin-radio-points";
import {
bulkDeleteRoomItems,
deleteRoom,
deleteRoomItem,
roomRconAction,
updateRoom,
updateRoomItem,
} from "./rooms";
function form(data: Readonly<Record<string, string>>): FormData {
return {
get: (key: string) => data[key] ?? null,
} as FormData;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never);
});
describe("Hotel legacy wrappers", () => {
it("delegates every room operation through the actor-bound Hotel service", async () => {
await updateRoom({ id: 7, name: "Lobby" });
await deleteRoom({ id: 7 });
await updateRoomItem({ roomId: 7, itemId: 8, x: 1 });
await deleteRoomItem({ roomId: 7, itemId: 8 });
await bulkDeleteRoomItems({ roomId: 7, itemIds: [8, 9] });
await roomRconAction({ roomId: 7, action: "reload" });
expect(
executeLegacyHotelMutation.mock.calls.map((call) => call[1]),
).toEqual([
"room.update",
"room.delete",
"room-item.update",
"room-item.delete",
"room-item.bulk-delete",
"room.runtime",
]);
expect(
executeLegacyHotelMutation.mock.calls.every(([actor]) => actor === staff),
).toBe(true);
});
it("delegates all radio mutations without accepting a client API-key secret", async () => {
await saveRadioSetting(form({ key: "radio_name", value: "Epic" }));
await saveRadioSettings(
form({
__keys: "radio_name,auto_dj_enabled",
radio_name: "Epic",
auto_dj_enabled: "1",
}),
);
await deleteShout(form({ id: "1" }));
await createApiKey(form({ name: "Bridge", allowedIps: "127.0.0.1" }));
await toggleApiKey(form({ id: "2" }));
await deleteApiKey(form({ id: "2" }));
await createTrack(form({ title: "Song", isActive: "on" }));
await toggleTrack(form({ id: "3", isActive: "on" }));
await deleteTrack(form({ id: "3" }));
await createRadioBanner(form({ imagePath: "/banner.png" }));
await updateRadioBanner(form({ id: "4", imagePath: "/banner.png" }));
await deleteRadioBanner(form({ id: "4" }));
await createRadioRank(form({ name: "DJ" }));
await updateRadioRank(form({ id: "5", name: "DJ" }));
await deleteRadioRank(form({ id: "5" }));
await savePoints(
form({
radio_points_enabled: "on",
radio_points_per_minute: "1",
radio_points_currency: "credits",
radio_points_max_per_day: "100",
radio_points_min_listeners: "2",
}),
);
expect(
executeLegacyHotelMutation.mock.calls.map((call) => call[1]),
).toEqual([
"radio.settings.save-one",
"radio.settings.save-many",
"radio.shout.delete",
"radio.api-key.create",
"radio.api-key.toggle",
"radio.api-key.delete",
"radio.autodj.create",
"radio.autodj.toggle",
"radio.autodj.delete",
"radio.banner.create",
"radio.banner.update",
"radio.banner.delete",
"radio.rank.create",
"radio.rank.update",
"radio.rank.delete",
"radio.points.save",
]);
const createInput = executeLegacyHotelMutation.mock.calls.find(
([, operation]) => operation === "radio.api-key.create",
)?.[2];
expect(createInput).not.toHaveProperty("key");
});
it("keeps the six legacy modules as thin shared-service wrappers", () => {
for (const path of [
"src/actions/rooms.ts",
"src/actions/admin-radio-api-keys.ts",
"src/actions/admin-radio-autodj.ts",
"src/actions/admin-radio-extra.ts",
"src/actions/admin-radio-moderation.ts",
"src/actions/admin-radio-points.ts",
]) {
const source = readFileSync(path, "utf8");
expect(source, path).toContain("executeLegacyHotelMutation");
expect(source, path).not.toContain('from "@/lib/db"');
}
});
});
-244
View File
@@ -1,244 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { registerHousekeepingCommand } from "@/features/housekeeping/foundation/commands/registry";
vi.mock(
"@/features/housekeeping/foundation/commands/registry",
async (importOriginal) => ({
...(await importOriginal<
typeof import("@/features/housekeeping/foundation/commands/registry")
>()),
sealHousekeepingCommandRegistry: sealRegistryMock,
}),
);
vi.mock("@/features/housekeeping/commands", () => ({
housekeepingCommandRegistryReady: true,
}));
import {
anyCapability,
ok,
} from "@/features/housekeeping/foundation/contracts";
import type { AuditEntry } from "@/lib/services/audit";
const {
auditEntries,
auditWriteMock,
commandExecutions,
context,
getContextMock,
getIpMock,
rateLimitCalls,
sealRegistryMock,
} = vi.hoisted(() => ({
auditEntries: [] as AuditEntry[],
auditWriteMock: vi.fn(),
commandExecutions: [] as string[],
context: {
actor: { id: 71, username: "server-operator", rank: 4 },
isSuperAdmin: false,
has: (slug: string) => slug === "admin.settings.edit",
hasAny: (...slugs: string[]) => slugs.includes("admin.settings.edit"),
hasAll: (...slugs: string[]) =>
slugs.every((slug) => slug === "admin.settings.edit"),
},
getContextMock: vi.fn(),
getIpMock: vi.fn(),
rateLimitCalls: [] as Array<[string, number, number]>,
sealRegistryMock: vi.fn(),
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: getContextMock,
}));
vi.mock("@/lib/services/audit", () => ({
housekeepingAuditWriter: { write: auditWriteMock },
}));
vi.mock("@/lib/rate-limit", () => ({
clientIp: getIpMock,
rateLimit: async (key: string, attempts: number, windowMs: number) => {
rateLimitCalls.push([key, attempts, windowMs]);
return { ok: true, retryAfter: 0 };
},
}));
import { executeHousekeepingCommand } from "./housekeeping-command";
registerHousekeepingCommand({
id: "system.server-action.serializable",
owner: "system",
risk: "safe",
capability: anyCapability("admin.settings.edit"),
input: z.object({ value: z.string() }),
requiresReason: false,
rateLimit: { attempts: 5, windowMs: 120_000 },
execute: async (commandContext, input) => {
commandExecutions.push(input.value);
return ok(
{
value: input.value,
actorId: commandContext.capability.actor.id,
ipAddress: commandContext.ipAddress,
},
commandContext.correlationId,
input.value === "partial"
? {
status: "partial",
external: "failed",
audit: "persisted",
}
: undefined,
);
},
});
beforeEach(() => {
auditEntries.length = 0;
commandExecutions.length = 0;
rateLimitCalls.length = 0;
getContextMock.mockReset().mockResolvedValue(context);
getIpMock.mockReset().mockResolvedValue("203.0.113.7");
sealRegistryMock.mockReset();
auditWriteMock.mockReset().mockImplementation(async (entry: AuditEntry) => {
auditEntries.push({ ...entry });
});
});
describe("executeHousekeepingCommand", () => {
it("accepts a plain request and derives all policy metadata server-side", async () => {
const result = await executeHousekeepingCommand({
commandId: "system.server-action.serializable",
input: { value: "saved" },
});
expect(result).toMatchObject({
ok: true,
data: {
value: "saved",
actorId: 71,
ipAddress: "203.0.113.7",
},
});
expect(rateLimitCalls).toEqual([
[
"housekeeping-command:71:203.0.113.7:system.server-action.serializable",
5,
120_000,
],
]);
expect(auditEntries).toMatchObject([
{
userId: 71,
action: "system.server-action.serializable",
target: "system",
domain: "system",
ipAddress: "203.0.113.7",
outcome: "success",
},
]);
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
expect(sealRegistryMock).not.toHaveBeenCalled();
});
it("preserves one returned partial completion and its correlation through the real action dispatcher", async () => {
const result = await executeHousekeepingCommand({
commandId: "system.server-action.serializable",
input: { value: "partial" },
});
expect(result).toMatchObject({
ok: true,
data: { value: "partial" },
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
});
expect(auditEntries).toHaveLength(1);
expect(auditEntries[0]).toMatchObject({
outcome: "partial",
correlationId: result.correlationId,
});
expect(() => JSON.stringify(result)).not.toThrow();
});
it("strictly rejects spoofed server-owned metadata before execution", async () => {
const result = await executeHousekeepingCommand({
commandId: "system.server-action.serializable",
input: { value: "forged" },
risk: "sensitive",
owner: "people",
capability: { mode: "any", slugs: ["forged.permission"] },
actor: { id: 999 },
ipAddress: "198.51.100.9",
rateLimit: { attempts: 999, windowMs: 1 },
audit: { action: "forged.action", target: "forged-target" },
} as never);
expect(result).toMatchObject({
ok: false,
error: { code: "VALIDATION" },
});
expect(commandExecutions).toEqual([]);
expect(rateLimitCalls).toEqual([]);
expect(auditEntries).toMatchObject([
{
userId: 71,
action: "housekeeping.command.dispatch",
target: "request-envelope",
ipAddress: "203.0.113.7",
outcome: "denied",
},
]);
expect(JSON.stringify(auditEntries)).not.toContain("forged");
});
it("sanitizes server context acquisition failures into typed results", async () => {
getContextMock.mockRejectedValue(
new Error("session database secret exposed"),
);
const result = await executeHousekeepingCommand({
commandId: "system.server-action.serializable",
input: { value: "blocked" },
});
expect(result).toMatchObject({
ok: false,
error: { code: "INTERNAL", messageKey: "errors.housekeeping.internal" },
});
expect(JSON.stringify(result)).not.toContain("secret exposed");
expect(commandExecutions).toEqual([]);
});
it("returns one truthful partial completion when outcome audit persistence fails", async () => {
auditWriteMock.mockImplementation(async (entry: AuditEntry) => {
if (entry.outcome === "success") {
throw new Error("success audit unavailable");
}
auditEntries.push({ ...entry });
});
const result = await executeHousekeepingCommand({
commandId: "system.server-action.serializable",
input: { value: "changed" },
});
expect(commandExecutions).toEqual(["changed"]);
expect(result).toMatchObject({
ok: true,
data: { value: "changed" },
completion: {
status: "partial",
external: "not-required",
audit: "persisted",
},
});
expect(auditEntries.map((entry) => entry.outcome)).toEqual(["partial"]);
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
expect(() => JSON.stringify(result)).not.toThrow();
});
});
-32
View File
@@ -1,32 +0,0 @@
"use server";
import "@/features/housekeeping/commands";
import { dispatchHousekeepingCommand } from "@/features/housekeeping/foundation/commands/dispatcher";
import {
type HousekeepingResult,
mapUnknownError,
} from "@/features/housekeeping/foundation/contracts";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { housekeepingAuditWriter } from "@/lib/services/audit";
export async function executeHousekeepingCommand(
request: unknown,
): Promise<HousekeepingResult<unknown>> {
try {
const [context, ipAddress] = await Promise.all([
getHousekeepingCapabilityContext(),
clientIp(),
]);
return await dispatchHousekeepingCommand(request, {
context,
ipAddress,
audit: housekeepingAuditWriter,
rateLimit: async (key, attempts, windowMs) =>
(await rateLimit(key, attempts, windowMs)).ok,
});
} catch (error) {
return mapUnknownError(error);
}
}
-52
View File
@@ -1,52 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "@/features/housekeeping/foundation/contracts";
import { PERMS } from "@/lib/permission-slugs";
const { getContextMock, loadInboxMock } = vi.hoisted(() => ({
getContextMock: vi.fn(),
loadInboxMock: vi.fn(),
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: getContextMock,
}));
vi.mock("@/features/housekeeping/foundation/inbox/inbox-service", () => ({
loadHousekeepingInbox: loadInboxMock,
}));
import { executeHousekeepingInbox } from "./housekeeping-inbox";
const context: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => slug === PERMS.USERS_VIEW,
hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW),
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW),
};
describe("housekeeping inbox action", () => {
beforeEach(() => {
getContextMock.mockReset().mockResolvedValue(context);
loadInboxMock.mockReset().mockResolvedValue({
items: [],
errors: [],
correlationId: "inbox-action",
});
});
it("binds inbox composition to a fresh server capability context", async () => {
const response = await executeHousekeepingInbox();
expect(getContextMock).toHaveBeenCalledOnce();
expect(loadInboxMock).toHaveBeenCalledWith(context);
expect(response.correlationId).toBe("inbox-action");
});
it("returns a typed partial envelope when the boundary throws", async () => {
loadInboxMock.mockRejectedValueOnce(new Error("inbox unavailable"));
const response = await executeHousekeepingInbox();
expect(response.items).toEqual([]);
expect(response.errors).toEqual([{ sourceId: "inbox", code: "INTERNAL" }]);
expect(response.correlationId).toEqual(expect.any(String));
});
});
-25
View File
@@ -1,25 +0,0 @@
"use server";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import {
type HousekeepingInboxResponse,
loadHousekeepingInbox,
} from "@/features/housekeeping/foundation/inbox/inbox-service";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
function failedInbox(): HousekeepingInboxResponse {
return {
items: [],
errors: [{ sourceId: "inbox", code: "INTERNAL" }],
correlationId: createCorrelationId(),
};
}
export async function executeHousekeepingInbox(): Promise<HousekeepingInboxResponse> {
try {
const context = await getHousekeepingCapabilityContext();
return await loadHousekeepingInbox(context);
} catch {
return failedInbox();
}
}
@@ -1,99 +0,0 @@
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: vi.fn(),
}));
const preferenceRepository = vi.hoisted(() => ({
read: vi.fn(),
upsert: vi.fn(),
}));
vi.mock("@/lib/housekeeping-preferences-repository", () => ({
housekeepingPreferencesRepository: preferenceRepository,
}));
import { defaultHousekeepingPreferences } from "@/features/housekeeping/foundation/preferences/schema";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import {
loadHousekeepingPreferences,
saveHousekeepingPreferences,
} from "./housekeeping-preferences";
const allowedContext = {
actor: { id: 42, username: "operator", rank: 0 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValue(allowedContext);
preferenceRepository.read.mockResolvedValue(defaultHousekeepingPreferences());
});
describe("housekeeping preference actions", () => {
it("does not expose dependency or user identity parameters to callers", () => {
expect(loadHousekeepingPreferences).toHaveLength(0);
expect(saveHousekeepingPreferences).toHaveLength(1);
});
it("publishes exact action signatures without caller-controlled dependencies", () => {
expectTypeOf<
Parameters<typeof loadHousekeepingPreferences>
>().toEqualTypeOf<[]>();
expectTypeOf<
Parameters<typeof saveHousekeepingPreferences>
>().toEqualTypeOf<[input: unknown]>();
});
it("derives the read owner from the server capability context", async () => {
const result = await loadHousekeepingPreferences();
expect(result.ok).toBe(true);
expect(preferenceRepository.read).toHaveBeenCalledWith(42);
});
it("rejects a denied operator without reading or writing preferences", async () => {
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValueOnce({
...allowedContext,
hasAny: () => false,
});
const result = await saveHousekeepingPreferences(
defaultHousekeepingPreferences(),
);
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
expect(preferenceRepository.read).not.toHaveBeenCalled();
expect(preferenceRepository.upsert).not.toHaveBeenCalled();
});
it("reconciles input before persisting or returning it", async () => {
const value = {
...defaultHousekeepingPreferences(),
pinnedRouteIds: ["removed.route"],
pinnedCommandIds: ["removed.command"],
};
const result = await saveHousekeepingPreferences(value);
expect(result).toMatchObject({
ok: true,
data: { pinnedRouteIds: [], pinnedCommandIds: [] },
});
expect(preferenceRepository.upsert).toHaveBeenCalledWith(
42,
expect.objectContaining({ pinnedRouteIds: [], pinnedCommandIds: [] }),
);
});
it("returns a validation result before an invalid payload reaches persistence", async () => {
const result = await saveHousekeepingPreferences({ schemaVersion: 2 });
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
expect(preferenceRepository.upsert).not.toHaveBeenCalled();
});
});
-85
View File
@@ -1,85 +0,0 @@
"use server";
import { authorizeHousekeeping } from "@/features/housekeeping/foundation/authorization";
import {
anyCapability,
fail,
type HousekeepingResult,
ok,
} from "@/features/housekeeping/foundation/contracts";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import { reconcilePreferences } from "@/features/housekeeping/foundation/preferences/reconcile";
import {
type HousekeepingPreferences,
housekeepingPreferencesSchema,
} from "@/features/housekeeping/foundation/preferences/schema";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
import { housekeepingPreferencesRepository } from "@/lib/housekeeping-preferences-repository";
import { PERMS } from "@/lib/permission-slugs";
const preferencesCapability = anyCapability(PERMS.ADMIN_DASHBOARD);
const housekeepingRegistry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
export async function loadHousekeepingPreferences(): Promise<
HousekeepingResult<HousekeepingPreferences>
> {
const context = await getHousekeepingCapabilityContext();
const authorization = authorizeHousekeeping(context, preferencesCapability);
if (!authorization.ok) return authorization;
const correlationId = createCorrelationId();
try {
const stored = await housekeepingPreferencesRepository.read(
context.actor.id,
);
return ok(
reconcilePreferences(stored, housekeepingRegistry, context),
correlationId,
);
} catch {
return fail(
"INTERNAL",
"errors.housekeeping.preferences.read",
correlationId,
);
}
}
export async function saveHousekeepingPreferences(
input: unknown,
): Promise<HousekeepingResult<HousekeepingPreferences>> {
const context = await getHousekeepingCapabilityContext();
const authorization = authorizeHousekeeping(context, preferencesCapability);
if (!authorization.ok) return authorization;
const correlationId = createCorrelationId();
const parsed = housekeepingPreferencesSchema.safeParse(input);
if (!parsed.success) {
return fail(
"VALIDATION",
"errors.housekeeping.preferences.invalid",
correlationId,
);
}
const reconciled = reconcilePreferences(
parsed.data,
housekeepingRegistry,
context,
);
try {
await housekeepingPreferencesRepository.upsert(
context.actor.id,
reconciled,
);
return ok(reconciled, correlationId);
} catch {
return fail(
"INTERNAL",
"errors.housekeeping.preferences.save",
correlationId,
);
}
}
-72
View File
@@ -1,72 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getContextMock, loadRecentMock, recordVisitMock } = vi.hoisted(() => ({
getContextMock: vi.fn(),
loadRecentMock: vi.fn(),
recordVisitMock: vi.fn(),
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: getContextMock,
}));
vi.mock("@/lib/housekeeping-recent-work", () => ({
loadHousekeepingRecentWork: loadRecentMock,
recordHousekeepingRouteVisit: recordVisitMock,
}));
import {
executeHousekeepingRecent,
recordHousekeepingRouteVisitAction,
} from "./housekeeping-recent";
const context = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
describe("housekeeping recent actions", () => {
beforeEach(() => {
vi.clearAllMocks();
getContextMock.mockResolvedValue(context);
loadRecentMock.mockResolvedValue({
ok: true,
data: [],
correlationId: "recent-action",
});
recordVisitMock.mockResolvedValue({
ok: true,
data: { routeId: "people.users" },
correlationId: "visit-action",
});
});
it("binds load and visit recording to a fresh server capability context", async () => {
await expect(executeHousekeepingRecent()).resolves.toMatchObject({
ok: true,
});
await expect(
recordHousekeepingRouteVisitAction("people.users"),
).resolves.toMatchObject({ ok: true });
expect(loadRecentMock).toHaveBeenCalledWith(context);
expect(recordVisitMock).toHaveBeenCalledWith("people.users", context);
});
it("rejects a forged route identifier before resolving server context", async () => {
const result = await recordHousekeepingRouteVisitAction({
routeId: "people.users",
});
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } });
expect(getContextMock).not.toHaveBeenCalled();
expect(recordVisitMock).not.toHaveBeenCalled();
});
it("maps unexpected boundary failures to typed internal results", async () => {
loadRecentMock.mockRejectedValueOnce(new Error("audit unavailable"));
const result = await executeHousekeepingRecent();
expect(result).toMatchObject({ ok: false, error: { code: "INTERNAL" } });
});
});
-49
View File
@@ -1,49 +0,0 @@
"use server";
import {
fail,
type HousekeepingResult,
mapUnknownError,
} from "@/features/housekeeping/foundation/contracts";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import type { HousekeepingRecentItem } from "@/features/housekeeping/foundation/recent/recent-work";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import {
loadHousekeepingRecentWork,
recordHousekeepingRouteVisit,
} from "@/lib/housekeeping-recent-work";
export async function executeHousekeepingRecent(): Promise<
HousekeepingResult<readonly HousekeepingRecentItem[]>
> {
try {
const context = await getHousekeepingCapabilityContext();
return await loadHousekeepingRecentWork(context);
} catch (error) {
return mapUnknownError(error);
}
}
export async function recordHousekeepingRouteVisitAction(
routeId: unknown,
): Promise<HousekeepingResult<HousekeepingRecentItem>> {
if (
typeof routeId !== "string" ||
!routeId.trim() ||
routeId !== routeId.trim() ||
routeId.length > 128
) {
return fail(
"VALIDATION",
"errors.housekeeping.recent.invalidRoute",
createCorrelationId(),
);
}
try {
const context = await getHousekeepingCapabilityContext();
return await recordHousekeepingRouteVisit(routeId, context);
} catch (error) {
return mapUnknownError(error);
}
}
-58
View File
@@ -1,58 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "@/features/housekeeping/foundation/contracts";
import { PERMS } from "@/lib/permission-slugs";
const { getContextMock, searchMock } = vi.hoisted(() => ({
getContextMock: vi.fn(),
searchMock: vi.fn(),
}));
vi.mock("@/features/housekeeping/commands", () => ({
housekeepingCommandRegistryReady: true,
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: getContextMock,
}));
vi.mock("@/features/housekeeping/foundation/search/search-service", () => ({
searchHousekeeping: searchMock,
}));
import { executeHousekeepingSearch } from "./housekeeping-search";
const context: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => slug === PERMS.USERS_VIEW,
hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW),
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW),
};
describe("housekeeping search action", () => {
beforeEach(() => {
getContextMock.mockReset().mockResolvedValue(context);
searchMock.mockReset().mockResolvedValue({
navigation: [],
commands: [],
entities: [],
errors: [],
correlationId: "action-search",
});
});
it("binds search to the fresh server capability context", async () => {
const result = await executeHousekeepingSearch(" users ");
expect(searchMock).toHaveBeenCalledWith(" users ", context);
expect(result.correlationId).toBe("action-search");
});
it("rejects forged non-string terms without invoking dependencies", async () => {
const result = await executeHousekeepingSearch({ term: "users" });
expect(getContextMock).not.toHaveBeenCalled();
expect(searchMock).not.toHaveBeenCalled();
expect(result).toMatchObject({
errors: [{ providerId: "search", code: "VALIDATION" }],
});
});
});
-32
View File
@@ -1,32 +0,0 @@
"use server";
import type { HousekeepingErrorCode } from "@/features/housekeeping/foundation/contracts";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import {
type HousekeepingSearchResponse,
searchHousekeeping,
} from "@/features/housekeeping/foundation/search/search-service";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
function failedSearch(code: HousekeepingErrorCode): HousekeepingSearchResponse {
return {
navigation: [],
commands: [],
entities: [],
errors: [{ providerId: "search", code }],
correlationId: createCorrelationId(),
};
}
export async function executeHousekeepingSearch(
term: unknown,
): Promise<HousekeepingSearchResponse> {
if (typeof term !== "string") return failedSearch("VALIDATION");
try {
await import("@/features/housekeeping/commands");
const context = await getHousekeepingCapabilityContext();
return await searchHousekeeping(term, context);
} catch {
return failedSearch("INTERNAL");
}
}
+38
View File
@@ -0,0 +1,38 @@
"use server";
import { z } from "zod";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
import { deleteImportedItem } from "@/lib/services/furni-import";
import { siteSettings } from "@/lib/services/site-settings";
const deleteSchema = z.object({ classname: z.string().trim().min(1) });
export const deleteImportedFurni = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
async (ctx) =>
actionOk(
(await deleteImportedItem(ctx.data.classname)) as unknown as Record<
string,
unknown
>,
),
);
const translateToggleSchema = z.object({ enabled: z.boolean() });
/** Persist the global "translate furniture names" setting from the studio. */
export const setFurnidataTranslateEnabled = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: translateToggleSchema },
async (ctx) => {
const value = ctx.data.enabled ? "1" : "0";
await db
.insert(WebsiteSetting)
.values({ key: "furnidata_translate_enabled", value })
.onDuplicateKeyUpdate({ set: { value } });
await siteSettings.reload();
return actionOk({ enabled: ctx.data.enabled });
},
);
+116
View File
@@ -0,0 +1,116 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const ITEMS_BASE_FIELDS = [
"publicName",
"itemName",
"type",
"spriteId",
"width",
"length",
"stackHeight",
"allowStack",
"allowSit",
"allowLay",
"allowWalk",
"allowGift",
"allowTrade",
"allowRecycle",
"allowMarketplaceSell",
"allowInventoryStack",
"interactionType",
"interactionModesCount",
"vendingIds",
"multiheight",
"customparams",
"effectIdMale",
"effectIdFemale",
"clothingOnWalk",
] as const;
const updateSchema = z.object({
id: z.coerce.number().int().positive(),
fields: z.record(z.string(), z.unknown()),
});
function pickAllowed(
fields: Record<string, unknown>,
allowed: readonly string[],
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of allowed) {
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
out[key] = fields[key];
}
}
return out;
}
export const updateItemsBase = adminAction(
{ permission: PERMS.CATALOG_EDIT, schema: updateSchema },
async (ctx) => {
const { id, fields } = ctx.data;
const safe = pickAllowed(fields, ITEMS_BASE_FIELDS);
if (Object.keys(safe).length === 0) {
throw new ActionError("No valid fields to update");
}
const [existing] = await db
.select({ id: ItemsBase.id })
.from(ItemsBase)
.where(eq(ItemsBase.id, id))
.limit(1);
if (!existing) throw new ActionError("Item not found");
// Coerce common numeric / decimal fields from form strings.
const data: Record<string, unknown> = { ...safe };
for (const key of [
"spriteId",
"width",
"length",
"allowStack",
"allowSit",
"allowLay",
"allowWalk",
"allowGift",
"allowTrade",
"allowRecycle",
"allowMarketplaceSell",
"allowInventoryStack",
"interactionModesCount",
"effectIdMale",
"effectIdFemale",
]) {
if (data[key] !== undefined) data[key] = Number(data[key]);
}
if (data.stackHeight !== undefined) {
data.stackHeight = Number(data.stackHeight);
}
await db
.update(ItemsBase)
.set(data as Partial<typeof ItemsBase.$inferInsert>)
.where(eq(ItemsBase.id, id));
await rcon.updateCatalog().catch(() => false);
await logStaffActivity({
staffId: Number(ctx.session.user.id),
action: "items_base_update",
description: `Updated items_base #${id}`,
targetType: "items_base",
targetId: id,
});
revalidatePath("/admin/items");
revalidatePath(`/admin/items/${id}`);
revalidatePath("/admin/catalog");
return actionOk({ id });
},
);
+135 -80
View File
@@ -1,14 +1,13 @@
"use server";
import { eq } from "drizzle-orm";
import { z } from "zod";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { db, SupportTickets } from "@/lib/db";
import { actionOk, adminAction } from "@/lib/foundation/action";
import { NotFoundError } from "@/lib/foundation/errors";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { rcon } from "@/lib/services/rcon";
// ── CFH Ticket Actions ──────────────────────────────────────────────
@@ -17,42 +16,28 @@ const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
const CFH_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT] as const;
const MOD_ACTION_PERM = [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS] as const;
async function execute(
staff: { readonly id: number },
operation: "cfh.resolve" | "moderation.action",
input: unknown,
) {
return peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
}
async function executeLegacyModerationAction(
staff: { readonly id: number },
input: unknown,
) {
const result = await execute(staff, "moderation.action", input);
if (!result.ok) {
throw new Error("Could not execute moderation action");
}
return actionOk();
}
export const assignCfhTicket = adminAction(
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
const result = await execute(ctx.session.user, "cfh.resolve", {
ticketId: ctx.data.ticketId,
state: 1,
const [ticket] = await db
.select({ id: SupportTickets.id })
.from(SupportTickets)
.where(eq(SupportTickets.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await db
.update(SupportTickets)
.set({ modId: ctx.session.user.id, state: 1 })
.where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "cfh_assign",
target: "support_tickets",
targetId: ctx.data.ticketId,
});
if (!result.ok) {
if (result.error.code === "NOT_FOUND") {
throw new NotFoundError("SupportTicket", ctx.data.ticketId);
}
throw new Error("Could not assign support ticket");
}
return actionOk();
},
);
@@ -65,13 +50,30 @@ const cfhStateSchema = z.object({
export const updateCfhState = adminAction(
{ permission: CFH_PERM, schema: cfhStateSchema },
async (ctx) => {
const result = await execute(ctx.session.user, "cfh.resolve", ctx.data);
if (!result.ok) {
if (result.error.code === "NOT_FOUND") {
throw new NotFoundError("SupportTicket", ctx.data.ticketId);
}
throw new Error("Could not update support ticket");
}
const [ticket] = await db
.select({
id: SupportTickets.id,
state: SupportTickets.state,
})
.from(SupportTickets)
.where(eq(SupportTickets.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
await db
.update(SupportTickets)
.set({ state: ctx.data.state, modId: ctx.session.user.id })
.where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "cfh_state_change",
target: "support_tickets",
targetId: ctx.data.ticketId,
before: { state: ticket.state },
after: { state: ctx.data.state },
});
return actionOk();
},
);
@@ -79,13 +81,18 @@ export const updateCfhState = adminAction(
export const closeCfhTicket = adminAction(
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
const result = await execute(ctx.session.user, "cfh.resolve", {
ticketId: ctx.data.ticketId,
state: 2,
await db
.update(SupportTickets)
.set({ state: 2, modId: ctx.session.user.id })
.where(eq(SupportTickets.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "cfh_close",
target: "support_tickets",
targetId: ctx.data.ticketId,
});
if (!result.ok && result.error.code !== "NOT_FOUND") {
throw new Error("Could not close support ticket");
}
return actionOk();
},
);
@@ -96,11 +103,18 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
export const quickKick = adminAction(
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
(ctx) =>
executeLegacyModerationAction(ctx.session.user, {
action: "kick",
userId: ctx.data.userId,
}),
async (ctx) => {
await rcon.disconnectUser(ctx.data.userId);
logAudit({
userId: ctx.session.user.id,
action: "mod_kick",
target: "User",
targetId: ctx.data.userId,
});
return actionOk();
},
);
const muteSchema = z.object({
@@ -110,20 +124,35 @@ const muteSchema = z.object({
export const quickMute = adminAction(
{ permission: MOD_ACTION_PERM, schema: muteSchema },
(ctx) =>
executeLegacyModerationAction(ctx.session.user, {
action: "mute",
...ctx.data,
}),
async (ctx) => {
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
logAudit({
userId: ctx.session.user.id,
action: "mod_mute",
target: "User",
targetId: ctx.data.userId,
after: { duration: ctx.data.duration },
});
return actionOk();
},
);
export const quickUnmute = adminAction(
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
(ctx) =>
executeLegacyModerationAction(ctx.session.user, {
action: "unmute",
userId: ctx.data.userId,
}),
async (ctx) => {
await rcon.unmuteUser(ctx.data.userId);
logAudit({
userId: ctx.session.user.id,
action: "mod_unmute",
target: "User",
targetId: ctx.data.userId,
});
return actionOk();
},
);
const alertSchema = z.object({
@@ -133,22 +162,37 @@ const alertSchema = z.object({
export const quickAlert = adminAction(
{ permission: MOD_ACTION_PERM, schema: alertSchema },
(ctx) =>
executeLegacyModerationAction(ctx.session.user, {
action: "alert",
...ctx.data,
}),
async (ctx) => {
await rcon.alertUser(ctx.data.userId, ctx.data.message);
logAudit({
userId: ctx.session.user.id,
action: "mod_alert",
target: "User",
targetId: ctx.data.userId,
after: { message: ctx.data.message },
});
return actionOk();
},
);
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
export const quickRoomKick = adminAction(
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
(ctx) =>
executeLegacyModerationAction(ctx.session.user, {
action: "room-kick",
roomId: ctx.data.roomId,
}),
async (ctx) => {
await rcon.kickAll(ctx.data.roomId);
logAudit({
userId: ctx.session.user.id,
action: "mod_room_kick",
target: "Room",
targetId: ctx.data.roomId,
});
return actionOk();
},
);
const broadcastSchema = z.object({
@@ -158,9 +202,20 @@ const broadcastSchema = z.object({
export const broadcastAlert = adminAction(
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
(ctx) =>
executeLegacyModerationAction(ctx.session.user, {
action: "broadcast",
...ctx.data,
}),
async (ctx) => {
if (ctx.data.type === "hotel") {
await rcon.hotelAlert(ctx.data.message);
} else {
await rcon.staffAlert(ctx.data.message);
}
logAudit({
userId: ctx.session.user.id,
action: `mod_broadcast_${ctx.data.type}`,
target: "broadcast",
after: { message: ctx.data.message },
});
return actionOk();
},
);
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { asc, count, desc, eq, gte, ne, sql } from "drizzle-orm";
import { requirePermission } from "@/lib/admin/guard";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
export interface MultiAccountCluster {
key: string;
label: string;
accountCount: number;
accounts: Array<{
id: number;
username: string;
rank: number;
online: string;
}>;
}
export async function detectMultiAccounts({
minAccounts,
limit,
}: {
minAccounts: number;
limit: number;
}) {
await requirePermission(PERMS.USERS_VIEW);
const clusters: MultiAccountCluster[] = [];
const accountCount = count(User.id);
const ipGroups = await db
.select({
ipCurrent: User.ipCurrent,
accountCount,
})
.from(User)
.where(ne(User.ipCurrent, ""))
.groupBy(User.ipCurrent)
.having(gte(accountCount, minAccounts))
.orderBy(desc(sql`COUNT(${User.id})`))
.limit(limit);
for (const group of ipGroups) {
const users = await db
.select({
id: User.id,
username: User.username,
rank: User.rank,
online: User.online,
})
.from(User)
.where(eq(User.ipCurrent, group.ipCurrent))
.orderBy(asc(User.id));
clusters.push({
key: group.ipCurrent,
label: `IP: ${group.ipCurrent}`,
accountCount: Number(group.accountCount),
accounts: users.map((u) => ({
id: u.id,
username: u.username,
rank: u.rank,
online: u.online,
})),
});
}
return { ok: true as const, data: { clusters } };
}
-152
View File
@@ -1,152 +0,0 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
const { execute, staff } = vi.hoisted(() => ({
execute: vi.fn(),
staff: { id: 1, rank: 7, username: "admin" },
}));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
PERMS: {
USERS_EDIT: "admin.users.edit",
USERS_BAN: "admin.users.ban",
USERS_RESET_PASSWORD: "admin.users.reset_password",
WORDFILTER_EDIT: "admin.wordfilter.edit",
},
}));
vi.mock("@/lib/safe-action", () => ({
adminAction:
(_options: unknown, handler: (context: unknown) => unknown) =>
(data: unknown) =>
handler({ data, session: { user: staff } }),
}));
vi.mock("@/lib/safe-action-shared", () => ({
ActionError: class ActionError extends Error {
constructor(message: string) {
super(message);
this.name = "ActionError";
}
},
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
actionError: (error: string) => ({ ok: false, error }),
}));
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
vi.mock("@/lib/db", () => ({
db: {},
User: {},
UsersBadges: {},
UsersCurrency: {},
UsersSettings: {},
}));
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
vi.mock("@/lib/services/rcon", () => ({ rcon: {} }));
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { dismissApplication } from "./admin-applications";
import { addWord, deleteWord } from "./admin-wordfilter";
import { banUser, resetPassword, updateUser } from "./users";
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff);
execute.mockImplementation(async (context, operation) => ({
ok: true,
data: {
before: {},
after: operation === "word-filter.update" ? { id: 12 } : {},
output:
operation === "user.reset-password"
? { newPassword: "temporary-password" }
: undefined,
},
correlationId: context.correlationId,
}));
});
describe("legacy user safe-action wrappers", () => {
it("preserves exact ACL-specific service delegation", async () => {
await (updateUser as never as (input: unknown) => Promise<unknown>)({
id: 7,
motto: "Ready",
});
await (banUser as never as (input: unknown) => Promise<unknown>)({
userId: 7,
reason: "abuse",
duration: 0,
type: "account",
});
const reset = await (
resetPassword as never as (input: unknown) => Promise<{
ok: boolean;
data: { newPassword: string };
}>
)({ userId: 7 });
expect(execute.mock.calls.map((call) => call[1])).toEqual([
"user.update",
"user.ban",
"user.reset-password",
]);
expect(execute.mock.calls.map((call) => call[0].expectedActorId)).toEqual([
1, 1, 1,
]);
expect(reset.data.newPassword).toBe("temporary-password");
});
});
describe("legacy application and word-filter wrappers", () => {
it("keeps tolerant application dismissal and ASE revalidation", async () => {
await dismissApplication(form({ id: "9" }));
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ expectedActorId: 1 }),
"application.decide",
{ applicationId: "9", decision: "dismiss" },
);
expect(revalidatePath).toHaveBeenCalledWith(
"/ase/people/staff/applications",
);
});
it("preserves application and wordfilter IDs above Number.MAX_SAFE_INTEGER", async () => {
await dismissApplication(form({ id: "9007199254740993" }));
await expect(deleteWord({ id: "9007199254740993" })).resolves.toEqual({
ok: true,
data: {},
});
expect(execute.mock.calls.slice(-2).map((call) => call[2])).toEqual([
{ applicationId: "9007199254740993", decision: "dismiss" },
{ action: "delete", id: "9007199254740993" },
]);
});
it("preserves word-filter ActionResult shapes and ASE revalidation", async () => {
await expect(addWord({ word: "spam" })).resolves.toEqual({
ok: true,
data: { id: "12" },
});
await expect(deleteWord({ id: "12" })).resolves.toEqual({
ok: true,
data: {},
});
expect(execute.mock.calls.slice(-2).map((call) => call[2])).toEqual([
{ action: "add", word: "spam" },
{ action: "delete", id: "12" },
]);
expect(revalidatePath).toHaveBeenCalledWith(
"/ase/people/moderation/word-filter",
);
});
});
@@ -1,300 +0,0 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { execute, registrations, staff } = vi.hoisted(() => ({
execute: vi.fn(),
registrations: [] as Array<{ permission: string | readonly string[] }>,
staff: { id: 42, rank: 4, username: "moderator" },
}));
function wrapper(
options: {
permission: string | readonly string[];
schema?: {
safeParse(
value: unknown,
): { success: true; data: unknown } | { success: false; error: unknown };
};
},
handler: (context: {
data: unknown;
session: { user: typeof staff };
}) => unknown,
) {
registrations.push(options);
return async (data: unknown) => {
const parsed = options.schema?.safeParse(data);
if (parsed && !parsed.success) {
return { ok: false, error: "Validation failed" };
}
try {
return await handler({
data: parsed?.data ?? data,
session: { user: staff },
});
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : "Internal server error",
};
}
};
}
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((actor, correlationId) => ({
expectedActorId: actor.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/safe-action", () => ({ adminAction: wrapper }));
vi.mock("@/lib/safe-action-shared", () => ({
ActionError: class ActionError extends Error {},
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
}));
vi.mock("@/lib/foundation/action", () => ({
adminAction: wrapper,
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
}));
vi.mock("@/lib/permissions", () => ({
PERMS: {
TICKETS_EDIT: "admin.tickets.edit",
MOD_TICKETS_EDIT: "mod.tickets.edit",
USERS_BAN: "admin.users.ban",
MODERATION_EDIT: "admin.moderation.edit",
MOD_CFH_EDIT: "mod.cfh.edit",
MOD_ACTIONS: "mod.actions",
},
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import {
closeHelpCenterTicket,
liftBanFromHelpTicket,
reopenHelpCenterTicket,
replyHelpCenterTicket,
} from "./admin-help-tickets";
import {
assignCfhTicket,
broadcastAlert,
closeCfhTicket,
quickAlert,
quickKick,
quickMute,
quickRoomKick,
quickUnmute,
updateCfhState,
} from "./moderation";
import {
createTemplate,
deleteTemplate,
updateTemplate,
} from "./ticket-templates";
import {
adminReplyTicket,
assignTicket,
updateTicketPriority,
updateTicketStatus,
} from "./tickets";
type LegacyAction = (input: unknown) => Promise<unknown>;
const call = (action: unknown, input: unknown) =>
(action as LegacyAction)(input);
beforeEach(() => {
vi.clearAllMocks();
execute.mockImplementation(async (invocation, operation) => ({
ok: true,
data: {
before: null,
after: operation === "ticket-template.change" ? { id: "88" } : {},
output:
operation === "help-ticket.unban"
? { removed: 2, userId: 7 }
: undefined,
},
correlationId: invocation.correlationId,
}));
});
describe("legacy People support and moderation wrappers", () => {
it("keeps mid-rank ACL alternatives without an admin.dashboard dependency", () => {
const permissions = registrations.flatMap((entry) =>
typeof entry.permission === "string"
? [entry.permission]
: entry.permission,
);
expect(permissions).toEqual(
expect.arrayContaining([
"admin.tickets.edit",
"mod.tickets.edit",
"admin.moderation.edit",
"mod.cfh.edit",
"mod.actions",
]),
);
expect(permissions).not.toContain("admin.dashboard");
});
it("delegates tickets and templates with their established result shapes", async () => {
await expect(
call(adminReplyTicket, { ticketId: 7, message: "Handled" }),
).resolves.toEqual({ ok: true, data: {} });
await call(assignTicket, { ticketId: 7, assigneeId: 42 });
await call(updateTicketStatus, { ticketId: 7, status: "closed" });
await call(updateTicketPriority, { ticketId: 7, priority: "urgent" });
await expect(
call(createTemplate, {
title: "Greeting",
content: "Hello",
category: "general",
sortOrder: 0,
}),
).resolves.toEqual({ ok: true, data: { id: 88 } });
await expect(
call(updateTemplate, { id: 88, title: "Updated" }),
).resolves.toEqual({ ok: true, data: { id: 88 } });
await expect(call(deleteTemplate, { id: 88 })).resolves.toEqual({
ok: true,
data: {},
});
expect(execute.mock.calls.map((entry) => entry[1])).toEqual([
"ticket.reply",
"ticket.assign",
"ticket.status",
"ticket.priority",
"ticket-template.change",
"ticket-template.change",
"ticket-template.change",
]);
});
it("preserves BIGINT help-ticket IDs, outputs, and every legacy refresh", async () => {
const ticketId = 9_007_199_254_740_993n;
await call(replyHelpCenterTicket, { ticketId, content: " Handled " });
await call(closeHelpCenterTicket, { ticketId });
await call(reopenHelpCenterTicket, { ticketId });
await expect(call(liftBanFromHelpTicket, { ticketId })).resolves.toEqual({
ok: true,
data: { removed: 2, userId: 7 },
});
expect(execute.mock.calls.map((entry) => entry[2])).toEqual([
{ ticketId: "9007199254740993", content: "Handled" },
{ ticketId: "9007199254740993", status: "close" },
{ ticketId: "9007199254740993", status: "reopen" },
{ ticketId: "9007199254740993" },
]);
expect(revalidatePath).toHaveBeenCalledWith(
"/ase/people/support/help-tickets",
);
expect(revalidatePath).toHaveBeenCalledWith(
"/ase/people/support/help-tickets/9007199254740993",
);
expect(revalidatePath).toHaveBeenCalledWith(
"/help/tickets/9007199254740993",
);
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/users/7");
});
it("delegates every CFH and moderation transport action", async () => {
await call(assignCfhTicket, { ticketId: 9 });
await call(updateCfhState, { ticketId: 9, state: 3 });
await call(closeCfhTicket, { ticketId: 9 });
await call(quickKick, { userId: 7 });
await call(quickMute, { userId: 7, duration: 60 });
await call(quickUnmute, { userId: 7 });
await call(quickAlert, { userId: 7, message: "Stop" });
await call(quickRoomKick, { roomId: 12 });
await call(broadcastAlert, { message: "Notice", type: "staff" });
expect(execute.mock.calls.map((entry) => entry[1])).toEqual([
"cfh.resolve",
"cfh.resolve",
"cfh.resolve",
"moderation.action",
"moderation.action",
"moderation.action",
"moderation.action",
"moderation.action",
"moderation.action",
]);
expect(execute.mock.calls.map((entry) => entry[0].expectedActorId)).toEqual(
Array(9).fill(42),
);
});
it.each([
["kick", quickKick, { userId: 7 }],
["mute", quickMute, { userId: 7, duration: 60 }],
["unmute", quickUnmute, { userId: 7 }],
["alert", quickAlert, { userId: 7, message: "Stop" }],
["room kick", quickRoomKick, { roomId: 12 }],
["broadcast", broadcastAlert, { message: "Notice", type: "staff" }],
] as const)(
"maps a non-ok %s service result to the historical legacy failure boundary",
async (_label, action, input) => {
execute.mockResolvedValueOnce({
ok: false,
error: {
code: "DEPENDENCY_UNAVAILABLE",
messageKey: "errors.housekeeping.dependencyUnavailable",
},
correlationId: "quick-action-failure",
});
await expect(call(action, input)).resolves.toEqual({
ok: false,
error: "Could not execute moderation action",
});
},
);
it("maps a thrown moderation service failure instead of reporting success", async () => {
execute.mockRejectedValueOnce(new Error("RCON unavailable"));
await expect(call(quickKick, { userId: 7 })).resolves.toEqual({
ok: false,
error: "RCON unavailable",
});
});
it.each([
9_007_199_254_740_992,
"01",
"0",
0,
-1,
"18446744073709551616",
] as const)(
"rejects noncanonical help-ticket identifier %s at every legacy action schema",
async (ticketId) => {
for (const [action, input] of [
[replyHelpCenterTicket, { ticketId, content: "Handled" }],
[closeHelpCenterTicket, { ticketId }],
[reopenHelpCenterTicket, { ticketId }],
[liftBanFromHelpTicket, { ticketId }],
] as const) {
await expect(call(action, input)).resolves.toEqual({
ok: false,
error: "Validation failed",
});
}
expect(execute).not.toHaveBeenCalled();
},
);
it("keeps close-CFH missing rows as a successful legacy no-op", async () => {
execute.mockResolvedValueOnce({
ok: false,
error: { code: "NOT_FOUND", messageKey: "errors.housekeeping.notFound" },
correlationId: "missing-cfh",
});
await expect(call(closeCfhTicket, { ticketId: 404 })).resolves.toEqual({
ok: true,
data: {},
});
});
});
-87
View File
@@ -1,87 +0,0 @@
import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { execute, staff } = vi.hoisted(() => ({
execute: vi.fn(),
staff: { id: 1, rank: 7, username: "admin" },
}));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: vi.fn(async () => staff),
requirePermissionRateLimited: vi.fn(async () => staff),
}));
vi.mock("@/lib/permissions", () => ({
PERMS: {
SETTINGS_EDIT: "admin.settings.edit",
USERS_EDIT: "admin.users.edit",
WORDFILTER_EDIT: "admin.wordfilter.edit",
},
}));
vi.mock("@/lib/safe-action-shared", () => ({
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
actionError: (error: string) => ({ ok: false, error }),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { disbandGuild } from "./admin-guilds";
import { addWhitelist } from "./admin-ip";
import { createTeam, deleteTeam } from "./admin-teams";
import { deleteWord } from "./admin-wordfilter";
const form = (data: Record<string, string>) =>
({ get: (key: string) => data[key] ?? null }) as FormData;
const failure = (code: string) => ({
ok: false as const,
error: { code, messageKey: "errors.housekeeping.dependencyUnavailable" },
correlationId: "wrapper-failure",
});
beforeEach(() => {
vi.clearAllMocks();
});
describe("legacy wrapper failure compatibility", () => {
it("keeps guild persistence failures throwing while a missing guild remains a no-op", async () => {
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
await expect(disbandGuild(form({ id: "9" }))).rejects.toThrow();
expect(revalidatePath).not.toHaveBeenCalled();
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
await expect(disbandGuild(form({ id: "9" }))).resolves.toBeUndefined();
expect(revalidatePath).not.toHaveBeenCalled();
});
it("keeps IP and team persistence failures throwing", async () => {
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
await expect(
addWhitelist(form({ ipAddress: "192.0.2.1" })),
).rejects.toThrow();
execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE"));
await expect(createTeam(form({ rankName: "Moderator" }))).rejects.toThrow();
expect(revalidatePath).not.toHaveBeenCalled();
});
it("keeps already-gone team and word-filter deletes successful", async () => {
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
await expect(deleteTeam(form({ id: "42" }))).resolves.toBeUndefined();
execute.mockResolvedValueOnce(failure("NOT_FOUND"));
await expect(deleteWord({ id: "42" })).resolves.toEqual({
ok: true,
data: {},
});
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/staff/teams");
expect(revalidatePath).toHaveBeenCalledWith(
"/ase/people/moderation/word-filter",
);
});
});
-122
View File
@@ -1,122 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const doubles = vi.hoisted(() => ({
canAccess: vi.fn(),
getApiAdminContext: vi.fn(),
mutationExecute: vi.fn(),
reportError: vi.fn(),
revalidateTag: vi.fn(),
}));
vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({
systemMutationService: { execute: doubles.mutationExecute },
}));
vi.mock("@/lib/permissions", () => ({
canAccess: doubles.canAccess,
getApiAdminContext: doubles.getApiAdminContext,
}));
vi.mock("@/lib/admin/authorization-events", () => ({
logAuthorizationEvent: vi.fn(),
}));
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ rateLimit: vi.fn() }));
vi.mock("@/lib/report-error", () => ({ reportError: doubles.reportError }));
vi.mock("@/lib/foundation/security", () => ({
extractClientIpAsync: vi.fn(async () => "198.51.100.8"),
}));
vi.mock("@/lib/foundation/request-context", () => ({
createStore: vi.fn(() => ({})),
getRequestId: vi.fn(() => "legacy-permissions-request"),
runWithStore: vi.fn((_store: unknown, callback: () => Promise<unknown>) =>
callback(),
),
setContextUserId: vi.fn(),
}));
vi.mock("next/cache", () => ({ revalidateTag: doubles.revalidateTag }));
import { deleteRank, setCmsPermissions } from "./permissions";
const permissions = {
has: () => true,
hasAny: () => true,
hasAll: () => true,
isSuperAdmin: false,
};
beforeEach(() => {
vi.clearAllMocks();
doubles.canAccess.mockReturnValue(true);
doubles.getApiAdminContext.mockResolvedValue({
session: {
expires: "2099-01-01T00:00:00.000Z",
user: {
id: 42,
name: "operator",
username: "operator",
rank: 7,
look: "hd-180-1",
mail: "[email protected]",
},
},
permissions,
});
});
describe("legacy permission action error parity", () => {
it("sanitizes typed infrastructure failure through the real adminAction boundary", async () => {
doubles.mutationExecute.mockResolvedValue({
ok: false,
error: {
code: "DEPENDENCY_UNAVAILABLE",
messageKey: "errors.housekeeping.dependencyUnavailable",
},
correlationId: "dependency-correlation",
});
await expect(deleteRank({ id: 7 })).resolves.toEqual({
ok: false,
error: "Internal server error",
fieldErrors: undefined,
});
});
it("preserves the established rank-in-use ActionError text", async () => {
doubles.mutationExecute.mockResolvedValue({
ok: false,
error: {
code: "CONFLICT",
messageKey: "errors.housekeeping.system.rankInUse",
fieldErrors: { rank: ["3"] },
},
correlationId: "rank-in-use-correlation",
});
await expect(deleteRank({ id: 7 })).resolves.toEqual({
ok: false,
error: "Cannot delete: 3 users have this rank",
fieldErrors: undefined,
});
});
it("preserves the established role-not-found ActionError text", async () => {
doubles.mutationExecute.mockResolvedValue({
ok: false,
error: {
code: "NOT_FOUND",
messageKey: "errors.housekeeping.system.roleNotFound",
},
correlationId: "role-not-found-correlation",
});
await expect(
setCmsPermissions({ roleId: 7, permissionSlugs: [] }),
).resolves.toEqual({
ok: false,
error: "Role not found",
fieldErrors: undefined,
});
});
});
+208 -59
View File
@@ -1,56 +1,27 @@
"use server";
import { and, count, eq, inArray, sql } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidateTag } from "next/cache";
import { z } from "zod";
import {
type SystemMutationContext,
type SystemMutationOperation,
systemMutationService,
} from "@/features/housekeeping/domains/system/services/mutations";
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
import type { AdminActionContext } from "@/lib/foundation/types";
AclModelPermission,
AclModelRole,
AclPermission,
AclRole,
db,
User,
} from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
function mutationContext(
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
): SystemMutationContext {
return {
capability: createHousekeepingCapabilityContext(
{
id: Number(ctx.session.user.id),
rank: Number(ctx.session.user.rank),
username: ctx.session.user.username,
},
ctx.permissions,
),
correlationId: String(ctx.requestId),
};
}
async function runAccessMutation(
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
operation: SystemMutationOperation,
input: unknown,
): Promise<unknown> {
const result = await systemMutationService.execute(
mutationContext(ctx),
operation,
input,
);
if (result.ok) return result.data;
if (result.error.messageKey === "errors.housekeeping.system.rankInUse") {
const users = Number(result.error.fieldErrors?.rank?.[0]);
if (Number.isInteger(users) && users > 0) {
throw new ActionError(`Cannot delete: ${users} users have this rank`);
}
}
if (result.error.messageKey === "errors.housekeeping.system.roleNotFound") {
throw new ActionError("Role not found");
}
throw new Error(result.error.messageKey);
}
import {
createEmulatorRank,
deleteEmulatorRank,
updateEmulatorRank,
} from "@/lib/services/permission-ranks";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
const createRankSchema = z.object({
rank_name: z.string().trim().min(1).max(25),
@@ -60,12 +31,25 @@ const createRankSchema = z.object({
export const createRank = adminAction(
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
const result = (await runAccessMutation(ctx, "access.rank.create", {
name: ctx.data.rank_name,
level: ctx.data.level,
})) as { id: number };
const id = await createEmulatorRank(db, ctx.data);
await db
.insert(AclRole)
.values({
slug: `rank_${id}`,
title: ctx.data.rank_name,
description: "CMS role synchronized from permission_ranks",
})
.onDuplicateKeyUpdate({ set: { title: ctx.data.rank_name } });
await logStaffActivity({
staffId: ctx.session.user.id,
action: "rank_create",
description: `Created rank #${id}`,
targetType: "rank",
targetId: id,
});
await rcon.send("updatepermissions");
revalidateTag("permissions", { expire: 0 });
return actionOk({ id: result.id });
return actionOk({ id });
},
);
@@ -74,7 +58,41 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
export const deleteRank = adminAction(
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
await runAccessMutation(ctx, "access.rank.delete", ctx.data);
const [userCount] = await db
.select({ total: count() })
.from(User)
.where(eq(User.rank, ctx.data.id));
const users = userCount?.total ?? 0;
if (users > 0)
throw new ActionError(`Cannot delete: ${users} users have this rank`);
const [role] = await db
.select({ id: AclRole.id })
.from(AclRole)
.where(eq(AclRole.slug, `rank_${ctx.data.id}`))
.limit(1);
await deleteEmulatorRank(db, ctx.data.id);
if (role) {
await db.transaction(async (tx) => {
await tx
.delete(AclModelPermission)
.where(
and(
eq(AclModelPermission.modelId, role.id),
eq(AclModelPermission.modelType, "Role"),
),
);
await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id));
await tx.delete(AclRole).where(eq(AclRole.id, role.id));
});
}
await logStaffActivity({
staffId: ctx.session.user.id,
action: "rank_delete",
description: `Deleted rank #${ctx.data.id}`,
targetType: "rank",
targetId: ctx.data.id,
});
await rcon.send("updatepermissions");
revalidateTag("permissions", { expire: 0 });
return actionOk();
},
@@ -88,7 +106,21 @@ const saveRankSchema = z.object({
export const saveRank = adminAction(
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
await runAccessMutation(ctx, "access.rank.update", ctx.data);
await updateEmulatorRank(db, ctx.data.id, ctx.data.fields);
if (typeof ctx.data.fields.rank_name === "string") {
await db
.update(AclRole)
.set({ title: ctx.data.fields.rank_name })
.where(eq(AclRole.slug, `rank_${ctx.data.id}`));
}
await logStaffActivity({
staffId: ctx.session.user.id,
action: "rank_update",
description: `Updated rank #${ctx.data.id}`,
targetType: "rank",
targetId: ctx.data.id,
});
await rcon.send("updatepermissions");
revalidateTag("permissions", { expire: 0 });
return actionOk();
},
@@ -102,21 +134,138 @@ const setCmsPermsSchema = z.object({
export const setCmsPermissions = adminAction(
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
await runAccessMutation(ctx, "access.permissions.update", ctx.data);
const [role] = await db
.select({ id: AclRole.id, slug: AclRole.slug })
.from(AclRole)
.where(eq(AclRole.id, ctx.data.roleId))
.limit(1);
if (!role) throw new ActionError("Role not found");
const permissions = await db
.select({ id: AclPermission.id })
.from(AclPermission)
.where(inArray(AclPermission.slug, ctx.data.permissionSlugs));
await db.transaction(async (tx) => {
await tx
.delete(AclModelPermission)
.where(
and(
eq(AclModelPermission.modelId, role.id),
eq(AclModelPermission.modelType, "Role"),
),
);
if (permissions.length) {
await tx.insert(AclModelPermission).values(
permissions.map((permission) => ({
modelId: role.id,
modelType: "Role",
permissionId: permission.id,
})),
);
}
});
await logStaffActivity({
staffId: ctx.session.user.id,
action: "acl_role_permissions_update",
description: `Updated ${permissions.length} permissions for ${role.slug}`,
targetType: "acl_role",
targetId: role.id,
});
revalidateTag("permissions", { expire: 0 });
return actionOk();
},
);
/**
* Re-apply the same grant repair as migration 0018:
* - ranks with admin.dashboard get all admin.*
* - ranks >= 6 get admin.*.view + dashboard
* - ranks >= 7 get edit/manage/execute tools used by the sidebar
*/
export const repairAdminNavAclGrants = adminAction(
{ permission: PERMS.PERMISSIONS_MANAGE },
async (ctx) => {
const result = (await runAccessMutation(
ctx,
"access.permissions.repair",
{},
)) as { inserted: number };
const [dashboardFillResult] = await db.execute(sql`
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
SELECT 'Role', ar.id, ap.id
FROM \`acl_roles\` ar
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%'
WHERE EXISTS (
SELECT 1
FROM \`acl_model_permissions\` amp
JOIN \`acl_permissions\` apdash ON apdash.id = amp.permission_id
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND apdash.slug = 'admin.dashboard'
)
AND NOT EXISTS (
SELECT 1
FROM \`acl_model_permissions\` amp2
WHERE amp2.model_type = 'Role'
AND amp2.model_id = ar.id
AND amp2.permission_id = ap.id
)
`);
const [midRankViewsResult] = await db.execute(sql`
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
SELECT 'Role', ar.id, ap.id
FROM \`permission_ranks\` pr
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
JOIN \`acl_permissions\` ap ON (
ap.slug = 'admin.dashboard'
OR (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view')
)
WHERE pr.id >= 6
AND NOT EXISTS (
SELECT 1
FROM \`acl_model_permissions\` amp
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND amp.permission_id = ap.id
)
`);
const [highRankToolsResult] = await db.execute(sql`
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
SELECT 'Role', ar.id, ap.id
FROM \`permission_ranks\` pr
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
JOIN \`acl_permissions\` ap ON (
(ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.edit')
OR ap.slug IN (
'admin.permissions.manage',
'admin.rcon.execute',
'admin.assets.import',
'admin.export',
'admin.analytics.export',
'admin.users.ban',
'admin.users.reset_password',
'admin.room.delete'
)
)
WHERE pr.id >= 7
AND NOT EXISTS (
SELECT 1
FROM \`acl_model_permissions\` amp
WHERE amp.model_type = 'Role'
AND amp.model_id = ar.id
AND amp.permission_id = ap.id
)
`);
const inserted =
Number((dashboardFillResult as ResultSetHeader).affectedRows) +
Number((midRankViewsResult as ResultSetHeader).affectedRows) +
Number((highRankToolsResult as ResultSetHeader).affectedRows);
await logStaffActivity({
staffId: ctx.session.user.id,
action: "acl_nav_grants_repair",
description: `Repaired admin nav ACL grants (${inserted} rows inserted)`,
targetType: "acl",
targetId: 0,
});
revalidateTag("permissions", { expire: 0 });
return actionOk({ inserted: result.inserted });
return actionOk({ inserted });
},
);
+66 -65
View File
@@ -3,7 +3,6 @@
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
import {
db,
WebsitePoll,
@@ -13,6 +12,7 @@ import {
import { PERMS } from "@/lib/permissions";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import {
createPollSchema,
pollQuestionSchema,
@@ -25,17 +25,20 @@ import {
export const createPoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"poll.change",
{ action: "create", ...ctx.data },
);
if (!result.ok) throw new ActionError("Poll creation failed");
return actionOk({ id: Number(result.data.output?.id) });
const now = new Date();
const [result] = await db.insert(WebsitePoll).values({
...ctx.data,
updatedAt: now,
});
const pollId = Number(result.insertId);
logAudit({
userId: ctx.session.user.id,
action: "poll_create",
target: "WebsitePoll",
targetId: pollId,
after: { title: ctx.data.title },
});
return actionOk({ id: pollId });
},
);
@@ -46,17 +49,31 @@ const updatePollInput = updatePollSchema.extend({
export const updatePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"poll.change",
{ action: "update", ...ctx.data },
);
if (!result.ok) throw new ActionError("Poll not found");
return actionOk({ id: ctx.data.id });
const { id, ...data } = ctx.data;
const [existing] = await db
.select({
id: WebsitePoll.id,
title: WebsitePoll.title,
status: WebsitePoll.status,
})
.from(WebsitePoll)
.where(eq(WebsitePoll.id, id))
.limit(1);
if (!existing) throw new ActionError("Poll not found");
await db
.update(WebsitePoll)
.set({ ...data, updatedAt: new Date() })
.where(eq(WebsitePoll.id, id));
logAudit({
userId: ctx.session.user.id,
action: "poll_update",
target: "WebsitePoll",
targetId: id,
before: { title: existing.title, status: existing.status },
after: data,
});
return actionOk({ id });
},
);
@@ -67,16 +84,21 @@ const deletePollInput = z.object({
export const deletePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"poll.change",
{ action: "delete", ...ctx.data },
);
if (!result.ok) throw new ActionError("Poll not found");
const [existing] = await db
.select({ id: WebsitePoll.id, title: WebsitePoll.title })
.from(WebsitePoll)
.where(eq(WebsitePoll.id, ctx.data.id))
.limit(1);
if (!existing) throw new ActionError("Poll not found");
await db.delete(WebsitePoll).where(eq(WebsitePoll.id, ctx.data.id));
logAudit({
userId: ctx.session.user.id,
action: "poll_delete",
target: "WebsitePoll",
targetId: ctx.data.id,
before: { title: existing.title },
});
return actionOk();
},
);
@@ -86,17 +108,8 @@ export const deletePoll = adminAction(
export const addPollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"poll-question.change",
{ action: "create", ...ctx.data },
);
if (!result.ok) throw new ActionError("Poll question creation failed");
return actionOk({ id: Number(result.data.output?.id) });
const [result] = await db.insert(WebsitePollQuestion).values(ctx.data);
return actionOk({ id: Number(result.insertId) });
},
);
@@ -107,17 +120,12 @@ const updateQuestionInput = pollQuestionSchema.partial().extend({
export const updatePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"poll-question.change",
{ action: "update", ...ctx.data },
);
if (!result.ok) throw new ActionError("Poll question update failed");
return actionOk({ id: ctx.data.id });
const { id, ...data } = ctx.data;
await db
.update(WebsitePollQuestion)
.set(data)
.where(eq(WebsitePollQuestion.id, id));
return actionOk({ id });
},
);
@@ -128,16 +136,9 @@ const deleteQuestionInput = z.object({
export const deletePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
async (ctx) => {
const result = await contentMutationService.execute(
{
correlationId: String(ctx.requestId),
expectedActorId: Number(ctx.session.user.id),
legacy: true,
},
"poll-question.change",
{ action: "delete", ...ctx.data },
);
if (!result.ok) throw new ActionError("Poll question deletion failed");
await db
.delete(WebsitePollQuestion)
.where(eq(WebsitePollQuestion.id, ctx.data.id));
return actionOk();
},
);
+152
View File
@@ -0,0 +1,152 @@
"use server";
import { eq, sql } from "drizzle-orm";
import { z } from "zod";
import { db, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings
// are not represented in src/db/schema.ts yet — we use parameterized raw SQL.
// ── Create prefix ───────────────────────────────────────────────────
const createPrefixSchema = z.object({
username: z.string().min(1),
text: z.string().min(1),
color: z.string().min(1),
icon: z.string().optional(),
effect: z.string().optional(),
active: z.coerce.number().int().min(0).max(1).default(1),
});
export const createPrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
async (ctx) => {
const { username, text, color, icon, effect, active } = ctx.data;
const [user] = await db
.select({ id: User.id })
.from(User)
.where(eq(User.username, username))
.limit(1);
if (!user) throw new ActionError("User not found");
await db.execute(sql`
INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active)
VALUES (${user.id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active})
`);
return actionOk();
},
);
// ── Update prefix ───────────────────────────────────────────────────
const updatePrefixSchema = z.object({
id: z.coerce.number().int().positive(),
text: z.string().min(1),
color: z.string().min(1),
icon: z.string().optional(),
effect: z.string().optional(),
active: z.coerce.number().int().min(0).max(1).optional(),
});
export const updatePrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
async (ctx) => {
const { id, text, color, icon, effect, active } = ctx.data;
await db.execute(sql`
UPDATE custom_prefixes
SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1}
WHERE id = ${id}
`);
return actionOk();
},
);
// ── Delete prefix ───────────────────────────────────────────────────
const deletePrefixSchema = z.object({
id: z.coerce.number().int().positive(),
});
export const deletePrefix = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
async (ctx) => {
await db.execute(
sql`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`,
);
return actionOk();
},
);
// ── Add blacklist word ──────────────────────────────────────────────
const addBlacklistWordSchema = z.object({
word: z.string().min(1).max(100),
});
export const addBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
async (ctx) => {
await db.execute(sql`
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
`);
return actionOk();
},
);
// ── Remove blacklist word ───────────────────────────────────────────
const removeBlacklistWordSchema = z.object({
id: z.coerce.number().int().positive(),
});
export const removeBlacklistWord = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
async (ctx) => {
await db.execute(
sql`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`,
);
return actionOk();
},
);
// ── Update prefix settings ──────────────────────────────────────────
const SETTINGS_WHITELIST = new Set([
"enabled",
"max_length",
"min_rank",
"min_rank_to_buy",
"allow_colors",
"allow_bold",
"allow_italic",
"default_color",
"price_credits",
"price_points",
"points_type",
]);
const updatePrefixSettingsSchema = z.object({
settings: z.record(z.string(), z.string()),
});
export const updatePrefixSettings = adminAction(
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
async (ctx) => {
for (const [key, value] of Object.entries(ctx.data.settings)) {
if (!SETTINGS_WHITELIST.has(key)) continue;
await db.execute(sql`
INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
VALUES (${key}, ${value})
ON DUPLICATE KEY UPDATE \`value\` = ${value}
`);
}
return actionOk();
},
);
+85 -19
View File
@@ -1,50 +1,109 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
import { requirePermission } from "@/lib/admin/guard";
import { db, Items, Rooms } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function updateRoomItem(payload: Record<string, unknown>) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
await executeLegacyHotelMutation(staff, "room-item.update", payload);
const roomId = Number(payload.roomId);
revalidatePath(`/ase/hotel/rooms/${roomId}/furni`);
const { roomId, itemId, ...data } = payload as {
roomId: number;
itemId: number;
[key: string]: unknown;
};
await db
.update(Items)
.set(data as Partial<typeof Items.$inferInsert>)
.where(eq(Items.id, itemId));
await logStaffActivity({
staffId: staff.id,
action: "room_item_update",
description: `Updated item #${itemId} in room #${roomId}`,
targetType: "room_item",
targetId: itemId,
});
revalidatePath(`/admin/rooms/${roomId}/furni`);
}
export async function bulkDeleteRoomItems(input: {
export async function bulkDeleteRoomItems({
roomId,
itemIds,
}: {
roomId: number;
itemIds: number[];
}) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
await executeLegacyHotelMutation(staff, "room-item.bulk-delete", input);
revalidatePath(`/ase/hotel/rooms/${input.roomId}/furni`);
await db
.delete(Items)
.where(and(inArray(Items.id, itemIds), eq(Items.roomId, roomId)));
await logStaffActivity({
staffId: staff.id,
action: "room_items_bulk_delete",
description: `Deleted ${itemIds.length} item(s) from room #${roomId}`,
targetType: "room_item",
});
revalidatePath(`/admin/rooms/${roomId}/furni`);
}
export async function deleteRoomItem(input: {
export async function deleteRoomItem({
roomId,
itemId,
}: {
roomId: number;
itemId: number;
}) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
await executeLegacyHotelMutation(staff, "room-item.delete", input);
revalidatePath(`/ase/hotel/rooms/${input.roomId}/furni`);
await db.delete(Items).where(eq(Items.id, itemId));
await logStaffActivity({
staffId: staff.id,
action: "room_item_delete",
description: `Deleted item #${itemId} from room #${roomId}`,
targetType: "room_item",
targetId: itemId,
});
revalidatePath(`/admin/rooms/${roomId}/furni`);
}
export async function roomRconAction(input: {
export async function roomRconAction({
roomId,
action,
}: {
roomId: number;
action: string;
}) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
await executeLegacyHotelMutation(staff, "room.runtime", input);
await requirePermission(PERMS.ROOMS_EDIT);
if (action === "reload") {
await rcon.send("reloadroom", { room_id: roomId });
} else if (action === "kick") {
await rcon.send("kickall", { room_id: roomId });
} else if (action === "lock") {
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
} else if (action === "unlock") {
await rcon.send("updateroom", { room_id: roomId, state: "open" });
}
}
export async function deleteRoom(input: { id: number }) {
export async function deleteRoom({ id }: { id: number }) {
const staff = await requirePermission(PERMS.ROOMS_DELETE);
await executeLegacyHotelMutation(staff, "room.delete", input);
revalidatePath("/ase/hotel/rooms");
await db.delete(Rooms).where(eq(Rooms.id, id));
await logStaffActivity({
staffId: staff.id,
action: "room_delete",
description: `Deleted room #${id}`,
targetType: "room",
targetId: id,
});
revalidatePath("/admin/rooms");
}
export async function updateRoom(input: {
export async function updateRoom({
id,
...data
}: {
id: number;
name?: string;
description?: string;
@@ -52,6 +111,13 @@ export async function updateRoom(input: {
usersMax?: number;
}) {
const staff = await requirePermission(PERMS.ROOMS_EDIT);
await executeLegacyHotelMutation(staff, "room.update", input);
revalidatePath(`/ase/hotel/rooms/${input.id}`);
await db.update(Rooms).set(data).where(eq(Rooms.id, id));
await logStaffActivity({
staffId: staff.id,
action: "room_update",
description: `Updated room #${id}`,
targetType: "room",
targetId: id,
});
revalidatePath(`/admin/rooms/${id}`);
}
+91 -66
View File
@@ -1,12 +1,15 @@
"use server";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import type { ContentMutationSnapshot } from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission, type StaffUser } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { db, WebsiteSetting } from "@/lib/db";
import { resolveMediaPath } from "@/lib/media-storage";
import { siteSettings } from "@/lib/services/site-settings";
const MAX_SIZE = 2 * 1024 * 1024;
const FAVICON_DIR = resolveMediaPath("favicon");
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
const ALLOWED = [
"image/png",
"image/jpeg",
@@ -15,37 +18,10 @@ const ALLOWED = [
"image/x-icon",
"image/svg+xml",
];
const PARTIAL_ERROR =
"Favicon change completed partially; verify storage and audit state";
async function executeAuditedFaviconMutation(
staff: StaffUser,
operation: "favicon.save" | "favicon.delete",
input: unknown,
): Promise<ContentMutationSnapshot> {
const [
{ contentProductionMutationAdapter },
{ getHousekeepingCapabilityContext },
] = await Promise.all([
import(
"@/features/housekeeping/domains/content/services/mutations-production"
),
import("@/features/housekeeping/foundation/server-capability-context"),
]);
const capability = await getHousekeepingCapabilityContext();
if (capability.actor.id !== staff.id)
throw new Error("Authenticated staff changed during favicon mutation");
return contentProductionMutationAdapter.execute(operation, input, {
capability,
correlationId: createCorrelationId(),
legacy: true,
});
}
export async function saveFavicon(
formData: FormData,
): Promise<{ success: boolean; url?: string; error?: string }> {
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
try {
const file = formData.get("file") as File | null;
if (!file || file.size === 0)
@@ -57,27 +33,62 @@ export async function saveFavicon(
success: false,
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
};
const result = await executeAuditedFaviconMutation(staff, "favicon.save", {
file,
});
siteRevalidate();
const url =
typeof result.output?.url === "string" ? result.output.url : undefined;
if (result.completion?.status === "partial") {
return {
success: false,
...(url ? { url } : {}),
error: PARTIAL_ERROR,
};
}
return {
success: true,
...(url ? { url } : {}),
const mimeExt: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/x-icon": "ico",
"image/svg+xml": "svg",
};
} catch (error) {
const ext = mimeExt[file.type] ?? "png";
const filename = `favicon-${Date.now()}.${ext}`;
const baseDir = FAVICON_DIR;
const filePath = path.resolve(baseDir, filename);
if (!filePath.startsWith(baseDir + path.sep)) {
return { success: false, error: "Invalid path" };
}
const buffer = Buffer.from(await file.arrayBuffer());
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer);
const url = `/api/media/favicon/${filename}`;
// Remove old favicon file if it exists
const oldUrl = await siteSettings.get("cms_favicon");
if (oldUrl?.startsWith("/api/media/favicon/")) {
const oldName = oldUrl.replace("/api/media/favicon/", "");
if (!oldName.includes("..") && !oldName.includes("/")) {
const oldPath = path.resolve(baseDir, oldName);
if (oldPath.startsWith(baseDir + path.sep)) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath);
} catch {
/* ignore if file doesn't exist */
}
}
}
}
await db
.insert(WebsiteSetting)
.values({ key: "cms_favicon", value: url, comment: "Favicon URL" })
.onDuplicateKeyUpdate({ set: { value: url } });
siteSettings.reload();
revalidatePath("/", "layout");
revalidatePath("/admin/favicon");
return { success: true, url };
} catch (e) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: e instanceof Error ? e.message : "Unknown error",
};
}
}
@@ -86,26 +97,40 @@ export async function deleteFavicon(): Promise<{
success: boolean;
error?: string;
}> {
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
try {
const result = await executeAuditedFaviconMutation(
staff,
"favicon.delete",
{},
);
siteRevalidate();
if (result.completion?.status === "partial")
return { success: false, error: PARTIAL_ERROR };
const oldUrl = await siteSettings.get("cms_favicon");
if (oldUrl?.startsWith("/api/media/favicon/")) {
const baseDir = FAVICON_DIR;
const oldName = oldUrl.replace("/api/media/favicon/", "");
if (!oldName.includes("..") && !oldName.includes("/")) {
const oldPath = path.resolve(baseDir, oldName);
if (oldPath.startsWith(baseDir + path.sep)) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath);
} catch {
/* ignore */
}
}
}
}
try {
await db
.delete(WebsiteSetting)
.where(eq(WebsiteSetting.key, "cms_favicon"));
} catch {
/* ignore missing row */
}
siteSettings.reload();
revalidatePath("/", "layout");
revalidatePath("/admin/favicon");
return { success: true };
} catch (error) {
} catch (e) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: e instanceof Error ? e.message : "Unknown error",
};
}
}
function siteRevalidate(): void {
revalidatePath("/", "layout");
revalidatePath("/ase/content/brand/favicon");
}
+42 -46
View File
@@ -1,63 +1,59 @@
"use server";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import type { ContentMutationSnapshot } from "@/features/housekeeping/domains/content/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { requirePermission, type StaffUser } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permission-slugs";
import { db, WebsiteSetting } from "@/lib/db";
import { resolveMediaPath } from "@/lib/media-storage";
import { siteSettings } from "@/lib/services/site-settings";
const PARTIAL_ERROR =
"Logo change completed partially; verify storage and audit state";
async function executeAuditedLogoMutation(
staff: StaffUser,
input: unknown,
): Promise<ContentMutationSnapshot> {
const [
{ contentProductionMutationAdapter },
{ getHousekeepingCapabilityContext },
] = await Promise.all([
import(
"@/features/housekeeping/domains/content/services/mutations-production"
),
import("@/features/housekeeping/foundation/server-capability-context"),
]);
const capability = await getHousekeepingCapabilityContext();
if (capability.actor.id !== staff.id)
throw new Error("Authenticated staff changed during logo mutation");
return contentProductionMutationAdapter.execute("logo.save", input, {
capability,
correlationId: createCorrelationId(),
legacy: true,
});
}
const MEDIA_DIR = resolveMediaPath("logo");
export async function saveLogo(
formData: FormData,
): Promise<{ success: boolean; url?: string; error?: string }> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
try {
const file = formData.get("file") as File | null;
if (!file) return { success: false, error: "No file provided" };
const result = await executeAuditedLogoMutation(staff, { file });
revalidatePath("/", "layout");
const url =
typeof result.output?.url === "string" ? result.output.url : undefined;
if (result.completion?.status === "partial") {
return {
success: false,
...(url ? { url } : {}),
error: PARTIAL_ERROR,
};
const ext =
file.type === "image/png"
? "png"
: file.type === "image/gif"
? "gif"
: file.type === "image/jpeg"
? "jpg"
: file.type === "image/webp"
? "webp"
: "png";
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const baseDir = MEDIA_DIR;
const filePath = path.resolve(baseDir, filename);
if (!filePath.startsWith(baseDir + path.sep)) {
return { success: false, error: "Invalid path" };
}
return {
success: true,
...(url ? { url } : {}),
};
} catch (error) {
const buffer = Buffer.from(await file.arrayBuffer());
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer);
const url = `/api/media/logo/${filename}`;
await db
.insert(WebsiteSetting)
.values({ key: "cms_logo", value: url, comment: "Logo (generator)" })
.onDuplicateKeyUpdate({ set: { value: url } });
siteSettings.reload();
revalidatePath("/", "layout");
return { success: true, url };
} catch (e) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: e instanceof Error ? e.message : "Unknown error",
};
}
}
+41 -20
View File
@@ -1,28 +1,49 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
describe("setTradeLock database and live-sync contract", () => {
const wrapper = readFileSync("src/actions/bulk-users.ts", "utf8");
const service = readFileSync(
"src/features/housekeeping/domains/people/services/mutations.ts",
"utf8",
);
const rcon = readFileSync("src/lib/services/rcon.ts", "utf8");
describe("setTradeLock drizzle + RCON contract", () => {
const src = readFileSync("src/actions/bulk-users.ts", "utf8");
const rconSrc = readFileSync("src/lib/services/rcon.ts", "utf8");
it("retains the legacy action while the owning service writes both trade-lock stores", () => {
expect(wrapper).toMatch(/export async function setTradeLock/u);
expect(wrapper).toContain('"user.trade-lock"');
expect(service).toContain("UsersSettings");
expect(service).toContain("Sanctions");
expect(service).toContain("canTrade");
expect(service).toContain("tradeLockedUntil");
it("writes sanctions + users_settings via Drizzle", () => {
expect(src).toContain("@/lib/db");
expect(src).toContain("UsersSettings");
expect(src).toContain("Sanctions");
expect(src).toContain("canTrade");
expect(src).toContain("tradeLockedUntil");
expect(src).toMatch(/export async function setTradeLock/);
const fn = src.slice(src.indexOf("export async function setTradeLock"));
expect(fn).toContain("db.");
});
it("keeps live RCON lock, alert, and disconnect behavior", () => {
expect(rcon).toContain("settradelock");
expect(rcon).toContain("setTradeLock(userId: number, locked: boolean)");
expect(service).toContain("rcon.setTradeLock");
expect(service).toContain("rcon.alertUser");
expect(service).toContain("rcon.disconnectUser");
it("syncs live hotel via RCON settradelock + alert + disconnect", () => {
expect(rconSrc).toContain("settradelock");
expect(rconSrc).toContain("setTradeLock(userId: number, locked: boolean)");
expect(src).toContain("rcon.setTradeLock");
expect(src).toContain("rcon.alertUser");
expect(src).toContain("rcon.disconnectUser");
});
});
describe("admin-photos drizzle contract", () => {
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
it("deletes via Drizzle CameraWeb and attempts local file purge", () => {
expect(src).toContain("@/lib/db");
expect(src).toContain("CameraWeb");
expect(src).toContain("tryRemoveLocalPhotoFile");
expect(src).toContain("@/lib/db");
expect(src).toContain('revalidatePath("/photos")');
});
});
describe("tryRemoveLocalPhotoFile", () => {
it("rejects path traversal and remote CDN urls", async () => {
expect(await tryRemoveLocalPhotoFile("https://cdn.example/photo.png")).toBe(
false,
);
expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false);
expect(await tryRemoveLocalPhotoFile("")).toBe(false);
});
});
+34
View File
@@ -0,0 +1,34 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, Soundtracks } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
export async function deleteSoundtrack({ id }: { id: number }) {
await requirePermission(PERMS.CATALOG_EDIT);
await db.delete(Soundtracks).where(eq(Soundtracks.id, id));
revalidatePath("/admin/sounds");
}
export async function updateSoundtrack({
id,
name,
author,
track,
length,
}: {
id: number;
name: string;
author: string;
track: string;
length: number;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
await db
.update(Soundtracks)
.set({ name, author, track, length })
.where(eq(Soundtracks.id, id));
revalidatePath("/admin/sounds");
}
+17 -28
View File
@@ -1,11 +1,8 @@
"use server";
import { eq } from "drizzle-orm";
import { z } from "zod";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { db, WebsiteTicketTemplate } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
@@ -17,30 +14,11 @@ const templateSchema = z.object({
sortOrder: z.coerce.number().int().min(0).default(0),
});
async function execute(staff: { readonly id: number }, input: unknown) {
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
"ticket-template.change",
input,
);
if (!result.ok) {
throw new ActionError(
result.error.code === "NOT_FOUND"
? "Template not found"
: "Template update failed",
);
}
return result.data;
}
export const createTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
async (ctx) => {
const snapshot = await execute(ctx.session.user, {
action: "create",
...ctx.data,
});
return actionOk({ id: Number(snapshot.after?.id) });
const [result] = await db.insert(WebsiteTicketTemplate).values(ctx.data);
return actionOk({ id: Number(result.insertId) });
},
);
@@ -52,7 +30,16 @@ export const updateTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
async (ctx) => {
const { id, ...data } = ctx.data;
await execute(ctx.session.user, { action: "update", id, ...data });
const [existing] = await db
.select({ id: WebsiteTicketTemplate.id })
.from(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, id))
.limit(1);
if (!existing) throw new ActionError("Template not found");
await db
.update(WebsiteTicketTemplate)
.set(data)
.where(eq(WebsiteTicketTemplate.id, id));
return actionOk({ id });
},
);
@@ -64,7 +51,9 @@ const deleteTemplateInput = z.object({
export const deleteTemplate = adminAction(
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
async (ctx) => {
await execute(ctx.session.user, { action: "delete", id: ctx.data.id });
await db
.delete(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, ctx.data.id));
return actionOk();
},
);
+133 -32
View File
@@ -1,13 +1,11 @@
"use server";
import {
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { eq } from "drizzle-orm";
import { db, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import {
assignTicketSchema,
replyTicketSchema,
@@ -15,29 +13,6 @@ import {
updateTicketStatusSchema,
} from "@/lib/validators/ticket";
async function execute(
staff: { readonly id: number },
operation:
| "ticket.reply"
| "ticket.assign"
| "ticket.status"
| "ticket.priority",
input: unknown,
) {
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
if (!result.ok) {
throw new ActionError(
result.error.code === "NOT_FOUND"
? "Ticket not found"
: "Ticket update failed",
);
}
}
// ── User actions (authenticated, no admin perms needed) ──────────────
export const adminReplyTicket = adminAction(
@@ -46,7 +21,45 @@ export const adminReplyTicket = adminAction(
schema: replyTicketSchema,
},
async (ctx) => {
await execute(ctx.session.user, "ticket.reply", ctx.data);
const [ticket] = await db
.select({
id: WebsiteTicket.id,
assigneeId: WebsiteTicket.assigneeId,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
await db.insert(WebsiteTicketMessage).values({
ticketId: ctx.data.ticketId,
userId: ctx.session.user.id,
message: ctx.data.message,
isStaff: 1,
});
// Auto-assign if not assigned yet
const updates: Partial<typeof WebsiteTicket.$inferInsert> = {
status: "waiting",
updatedAt: new Date(),
};
if (!ticket.assigneeId) {
updates.assigneeId = ctx.session.user.id;
}
await db
.update(WebsiteTicket)
.set(updates)
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "ticket_reply",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
});
return actionOk();
},
);
@@ -57,7 +70,43 @@ export const updateTicketStatus = adminAction(
schema: updateTicketStatusSchema,
},
async (ctx) => {
await execute(ctx.session.user, "ticket.status", ctx.data);
const [ticket] = await db
.select({
id: WebsiteTicket.id,
assigneeId: WebsiteTicket.assigneeId,
status: WebsiteTicket.status,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
const data: Partial<typeof WebsiteTicket.$inferInsert> = {
status: ctx.data.status,
updatedAt: new Date(),
};
if (ctx.data.status === "closed") {
data.closedAt = new Date();
}
if (ctx.data.status === "in_progress" && !ticket.assigneeId) {
data.assigneeId = ctx.session.user.id;
}
await db
.update(WebsiteTicket)
.set(data)
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "ticket_status_change",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
before: { status: ticket.status },
after: { status: ctx.data.status },
});
return actionOk();
},
);
@@ -68,7 +117,35 @@ export const assignTicket = adminAction(
schema: assignTicketSchema,
},
async (ctx) => {
await execute(ctx.session.user, "ticket.assign", ctx.data);
const [ticket] = await db
.select({
id: WebsiteTicket.id,
assigneeId: WebsiteTicket.assigneeId,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
await db
.update(WebsiteTicket)
.set({
assigneeId: ctx.data.assigneeId,
status: ctx.data.assigneeId ? "in_progress" : "open",
updatedAt: new Date(),
})
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "ticket_assign",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
before: { assigneeId: ticket.assigneeId },
after: { assigneeId: ctx.data.assigneeId },
});
return actionOk();
},
);
@@ -79,7 +156,31 @@ export const updateTicketPriority = adminAction(
schema: updateTicketPrioritySchema,
},
async (ctx) => {
await execute(ctx.session.user, "ticket.priority", ctx.data);
const [ticket] = await db
.select({
id: WebsiteTicket.id,
priority: WebsiteTicket.priority,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
.limit(1);
if (!ticket) throw new ActionError("Ticket not found");
await db
.update(WebsiteTicket)
.set({ priority: ctx.data.priority, updatedAt: new Date() })
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
logAudit({
userId: ctx.session.user.id,
action: "ticket_priority_change",
target: "WebsiteTicket",
targetId: ctx.data.ticketId,
before: { priority: ticket.priority },
after: { priority: ctx.data.priority },
});
return actionOk();
},
);
+124
View File
@@ -0,0 +1,124 @@
"use server";
import fs from "node:fs/promises";
import path from "node:path";
import * as JSONC from "jsonc-parser";
import { z } from "zod";
import {
CLIENT_TRANSLATION_FILES,
getClientTranslationFile,
} from "@/lib/client-translation-files";
import { patchJson5 } from "@/lib/json5-patch";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
const saveTranslationsSchema = z.object({
locale: z.enum([
"en",
"it",
"nl",
"de",
"fr",
"es",
"pt",
"pl",
"sv",
"tr",
"ro",
"hu",
"cs",
"sk",
"da",
"no",
"el",
"bg",
"hr",
"sr",
"uk",
"ru",
]),
data: z.record(z.string(), z.unknown()),
});
export const saveTranslations = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
async (ctx) => {
const filePath = path.join(
process.cwd(),
"src",
"messages",
`${ctx.data.locale}.json`,
);
await fs.writeFile(
filePath,
JSON.stringify(ctx.data.data, null, 2),
"utf-8",
);
return actionOk();
},
);
const saveClientTranslationsSchema = z.object({
fileId: z.enum(
CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]],
),
data: z.record(z.string(), z.string()),
});
export const saveClientTranslations = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema },
async (ctx) => {
const file = getClientTranslationFile(ctx.data.fileId);
if (!file) throw new ActionError("Unknown file");
if (file.readOnly) throw new ActionError("File is read-only");
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
// Turbopack's static tracer can't prove that — without the hint it
// pulls the entire project into the NFT list.
const absPath = path.join(
/*turbopackIgnore: true*/ process.cwd(),
file.relPath,
);
const raw = await fs.readFile(absPath, "utf-8");
if (file.format === "json") {
// Plain JSON — no comments to preserve, just round-trip.
await fs.writeFile(
absPath,
JSON.stringify(ctx.data.data, null, 4),
"utf-8",
);
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] });
}
// JSON5: surgical line-level patch keeps headers and section comments
// intact. Falls back to a full re-serialization (which DOES drop comments)
// only when an edited key cannot be located via the patch contract.
const original: Record<string, string> = {};
const parsed = JSONC.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed)) {
original[k] = v == null ? "" : String(v);
}
}
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data);
if (unpatchedKeys.length === 0) {
await fs.writeFile(absPath, content, "utf-8");
return actionOk({ commentsLost: false, unpatchedKeys });
}
// At least one key could not be patched surgically (e.g. unusual
// formatting or a brand-new key). Fall back to a full re-serialization
// and warn the caller that comments were lost.
await fs.writeFile(
absPath,
JSON.stringify(ctx.data.data, null, 4),
"utf-8",
);
return actionOk({ commentsLost: true, unpatchedKeys });
},
);
+350 -132
View File
@@ -1,15 +1,18 @@
"use server";
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import {
createPeopleMutationInvocation,
type PeopleMutationOperation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
import { invalidateLoginCache } from "@/lib/auth";
import { hashPassword } from "@/lib/auth/password";
import { db, User, UsersBadges, UsersCurrency, UsersSettings } from "@/lib/db";
import {
Ban,
db,
User,
UsersBadges,
UsersCurrency,
UsersSettings,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
@@ -28,23 +31,22 @@ const DEFAULT_LOOK =
function isDuplicateKey(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const error = err as { code?: string | number; errno?: number };
return (
error.code === "P2002" ||
error.code === "ER_DUP_ENTRY" ||
error.errno === 1062
);
const e = err as { code?: string | number; errno?: number };
return e.code === "P2002" || e.code === "ER_DUP_ENTRY" || e.errno === 1062;
}
function duplicateField(err: unknown): "username" | "mail" | null {
if (!isDuplicateKey(err)) return null;
const error = err as { message?: string; meta?: { target?: string[] } };
const target = error.meta?.target ?? [];
const e = err as {
message?: string;
meta?: { target?: string[] };
};
const target = e.meta?.target ?? [];
if (target.includes("username")) return "username";
if (target.includes("mail")) return "mail";
const message = error.message ?? "";
if (message.includes("username")) return "username";
if (message.includes("mail")) return "mail";
const msg = e.message ?? "";
if (msg.includes("username")) return "username";
if (msg.includes("mail")) return "mail";
return null;
}
@@ -52,11 +54,14 @@ export const createUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
async (ctx) => {
const { username, mail, password, rank, motto } = ctx.data;
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
throw new ActionError("Cannot assign rank equal or higher than your own");
}
const hashedPassword = await hashPassword(password);
const now = Math.floor(Date.now() / 1000);
try {
const user = await db.transaction(async (tx) => {
const [result] = await tx.insert(User).values({
@@ -73,68 +78,44 @@ export const createUser = adminAction(
ipCurrent: "0.0.0.0",
});
const id = Number(result.insertId);
await tx.insert(UsersSettings).values({ userId: id });
await tx.insert(UsersCurrency).values([
{ userId: id, type: 0, amount: 5000 },
{ userId: id, type: 5, amount: 5000 },
]);
return { id, username };
});
void logAudit({
logAudit({
userId: ctx.session.user.id,
action: "user_create",
target: "User",
targetId: user.id,
after: { username, mail, rank },
});
void notify({
notify({
action: "user_edit",
actor: ctx.session.user.username,
target: username,
targetId: user.id,
details: "Account created by admin",
});
return actionOk(user);
} catch (error) {
const field = duplicateField(error);
return actionOk({ id: user.id, username: user.username });
} catch (err) {
const field = duplicateField(err);
if (field === "username") throw new ActionError("Username already taken");
if (field === "mail") throw new ActionError("Email already registered");
if (isDuplicateKey(error))
if (isDuplicateKey(err))
throw new ActionError("Username or email already in use");
throw error;
throw err;
}
},
);
const legacyMessages: Partial<Record<PeopleMutationOperation, string>> = {
"user.alert": "Failed to send alert. Is the emulator running?",
"user.disconnect": "Failed to disconnect. Is the emulator running?",
"user.mute": "Failed to mute. Is the emulator running?",
"user.unmute": "Failed to unmute. Is the emulator running?",
"user.send-currency": "Failed to send credits. Is the emulator running?",
};
async function executeLegacy(
ctx: { session: { user: { id: number; username: string; rank: number } } },
operation: PeopleMutationOperation,
input: unknown,
) {
const result = await peopleMutationService.execute(
createPeopleMutationInvocation(ctx.session.user, createCorrelationId()),
operation,
input,
);
if (!result.ok) {
if (result.error.code === "NOT_FOUND")
throw new ActionError("User not found");
if (result.error.code === "FORBIDDEN") {
throw new ActionError("Cannot modify user with equal or higher rank");
}
throw new ActionError(legacyMessages[operation] ?? "User action failed");
}
return result.data;
}
const updateUserInput = updateUserSchema.extend({
id: z.coerce.number().int().positive(),
});
@@ -142,110 +123,149 @@ const updateUserInput = updateUserSchema.extend({
export const updateUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
async (ctx) => {
const { id: userId, ...fields } = ctx.data;
await executeLegacy(ctx, "user.update", {
userId,
fields,
const { id, diamonds, duckets, ...userData } = ctx.data;
const targetUser = await guardRank(id, ctx.session.user.rank);
if (
userData.rank !== undefined &&
userData.rank >= ctx.session.user.rank &&
ctx.session.user.rank < 7
) {
throw new ActionError("Cannot assign rank equal or higher than your own");
}
const patch = Object.fromEntries(
Object.entries(userData).filter(([, v]) => v !== undefined),
) as Partial<{
username: string;
mail: string;
rank: number;
motto: string;
credits: number;
pixels: number;
}>;
if (Object.keys(patch).length > 0) {
await db.update(User).set(patch).where(eq(User.id, id));
}
invalidateLoginCache(targetUser.username);
if (diamonds !== undefined) {
await db
.insert(UsersCurrency)
.values({ userId: id, type: 5, amount: diamonds })
.onDuplicateKeyUpdate({ set: { amount: diamonds } });
}
if (duckets !== undefined) {
await db
.insert(UsersCurrency)
.values({ userId: id, type: 0, amount: duckets })
.onDuplicateKeyUpdate({ set: { amount: duckets } });
}
logAudit({
userId: ctx.session.user.id,
action: "user_edit",
target: "User",
targetId: id,
before: {
username: targetUser.username,
mail: targetUser.mail,
rank: targetUser.rank,
},
after: userData,
});
notify({
action: "user_edit",
actor: ctx.session.user.username,
target: targetUser.username,
targetId: id,
});
return actionOk();
},
);
const banInput = banUserSchema.extend({});
export const banUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: banUserSchema },
{ permission: PERMS.USERS_BAN, schema: banInput },
async (ctx) => {
await executeLegacy(ctx, "user.ban", ctx.data);
const { userId, reason, duration, type, ip } = ctx.data;
const targetUser = await guardRank(userId, ctx.session.user.rank);
const now = Math.floor(Date.now() / 1000);
const banExpire = duration > 0 ? now + duration * 3600 : 0;
await db.insert(Ban).values({
userId,
userStaffId: ctx.session.user.id,
timestamp: now,
banExpire,
banReason: reason,
type: type || "account",
ip: ip || "",
machineId: "",
});
await rcon.disconnectUser(userId);
logAudit({
userId: ctx.session.user.id,
action: "ban",
target: "User",
targetId: userId,
after: { reason, type, duration },
});
notify({
action: "ban",
actor: ctx.session.user.username,
target: targetUser.username,
details: reason,
});
return actionOk();
},
);
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
const unbanInput = z.object({ userId: z.coerce.number().int().positive() });
export const unbanUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: userIdSchema },
{ permission: PERMS.USERS_BAN, schema: unbanInput },
async (ctx) => {
await executeLegacy(ctx, "user.unban", ctx.data);
return actionOk();
},
);
const { userId } = ctx.data;
export const resetPassword = adminAction(
{ permission: PERMS.USERS_RESET_PASSWORD, schema: userIdSchema },
async (ctx) => {
const snapshot = await executeLegacy(ctx, "user.reset-password", ctx.data);
return actionOk({
newPassword: String(snapshot.output?.newPassword ?? ""),
const targetUser = await guardRank(userId, ctx.session.user.rank);
await db.delete(Ban).where(eq(Ban.userId, userId));
logAudit({
userId: ctx.session.user.id,
action: "unban",
target: "User",
targetId: userId,
});
notify({
action: "unban",
actor: ctx.session.user.username,
target: targetUser.username,
});
},
);
export const disconnectUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
async (ctx) => {
await executeLegacy(ctx, "user.disconnect", ctx.data);
return actionOk();
},
);
const alertUserSchema = userIdSchema.extend({
message: z.string().min(1).max(500),
});
export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => {
await executeLegacy(ctx, "user.alert", ctx.data);
return actionOk();
},
);
const muteSchema = userIdSchema.extend({
duration: z.coerce.number().int().min(0).default(0),
});
export const muteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
async (ctx) => {
await executeLegacy(ctx, "user.mute", ctx.data);
return actionOk();
},
);
export const unmuteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
async (ctx) => {
await executeLegacy(ctx, "user.unmute", ctx.data);
return actionOk();
},
);
const sendCreditsSchema = userIdSchema.extend({
amount: z.coerce.number().int().min(1).max(1_000_000),
});
export const sendCredits = adminAction(
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
async (ctx) => {
await executeLegacy(ctx, "user.send-currency", ctx.data);
return actionOk();
},
);
async function guardRank(targetUserId: number, sessionRank: number) {
const [target] = await db
.select({ username: User.username, rank: User.rank, mail: User.mail })
.from(User)
.where(eq(User.id, targetUserId))
.limit(1);
if (!target) throw new ActionError("User not found");
if (target.rank >= sessionRank && sessionRank < 7) {
throw new ActionError("Cannot modify user with equal or higher rank");
}
return target;
}
export const giveBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
async (ctx) => {
const { userId, badgeCode } = ctx.data;
await guardRank(userId, ctx.session.user.rank);
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
@@ -257,21 +277,28 @@ export const giveBadge = adminAction(
)
.limit(1);
if (existing) throw new ActionError("Badge already assigned");
await db.insert(UsersBadges).values({ userId, badgeCode });
await rcon.giveBadge(userId, badgeCode);
return actionOk();
},
);
// ── Remove Badge ────────────────────────────────────────────────────
const removeBadgeSchema = z.object({
userId: z.coerce.number().int().positive(),
badgeCode: z.string().min(1),
});
export const removeBadge = adminAction(
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
async (ctx) => {
const { userId, badgeCode } = ctx.data;
await guardRank(userId, ctx.session.user.rank);
const [existing] = await db
.select({ id: UsersBadges.id })
.from(UsersBadges)
@@ -283,8 +310,199 @@ export const removeBadge = adminAction(
)
.limit(1);
if (!existing) throw new ActionError("Badge not found");
await db.delete(UsersBadges).where(eq(UsersBadges.id, existing.id));
await rcon.removeBadge(userId, badgeCode);
return actionOk();
},
);
// ── Rank guard helper ───────────────────────────────────────────────
async function guardRank(targetUserId: number, sessionRank: number) {
const [target] = await db
.select({
username: User.username,
rank: User.rank,
mail: User.mail,
})
.from(User)
.where(eq(User.id, targetUserId))
.limit(1);
if (!target) throw new ActionError("User not found");
if (target.rank >= sessionRank && sessionRank < 7) {
throw new ActionError("Cannot modify user with equal or higher rank");
}
return target;
}
// ── Reset Password ──────────────────────────────────────────────────
const resetPasswordSchema = z.object({
userId: z.coerce.number().int().positive(),
});
export const resetPassword = adminAction(
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const newPassword = crypto
.randomBytes(12)
.toString("base64url")
.slice(0, 16);
const hashed = await hashPassword(newPassword);
await db
.update(User)
.set({ password: hashed })
.where(eq(User.id, ctx.data.userId));
invalidateLoginCache(target.username);
logAudit({
userId: ctx.session.user.id,
action: "reset_password",
target: "User",
targetId: ctx.data.userId,
});
notify({
action: "user_edit",
actor: ctx.session.user.username,
target: target.username,
details: "Password reset",
});
return actionOk({ newPassword });
},
);
// ── Disconnect User ─────────────────────────────────────────────────
const disconnectSchema = z.object({
userId: z.coerce.number().int().positive(),
});
export const disconnectUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema },
async (ctx) => {
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.disconnectUser(ctx.data.userId);
if (!success)
throw new ActionError("Failed to disconnect. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: "user_disconnect",
target: "User",
targetId: ctx.data.userId,
});
notify({
action: "disconnect",
actor: ctx.session.user.username,
target: target.username,
});
return actionOk();
},
);
// ── Alert User (in-game message) ────────────────────────────────────
const alertUserSchema = z.object({
userId: z.coerce.number().int().positive(),
message: z.string().min(1).max(500),
});
export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => {
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
if (!success)
throw new ActionError("Failed to send alert. Is the emulator running?");
return actionOk();
},
);
// ── Mute User ───────────────────────────────────────────────────────
const muteSchema = z.object({
userId: z.coerce.number().int().positive(),
duration: z.coerce.number().int().min(0).default(0),
});
export const muteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
void _target;
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
if (!success)
throw new ActionError("Failed to mute. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: "user_mute",
target: "User",
targetId: ctx.data.userId,
after: { duration: ctx.data.duration },
});
return actionOk();
},
);
// ── Unmute User ─────────────────────────────────────────────────────
const unmuteSchema = z.object({
userId: z.coerce.number().int().positive(),
});
export const unmuteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
async (ctx) => {
await guardRank(ctx.data.userId, ctx.session.user.rank);
const success = await rcon.unmuteUser(ctx.data.userId);
if (!success)
throw new ActionError("Failed to unmute. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: "user_unmute",
target: "User",
targetId: ctx.data.userId,
});
return actionOk();
},
);
// ── Send Credits via RCON ───────────────────────────────────────────
const sendCreditsSchema = z.object({
userId: z.coerce.number().int().positive(),
amount: z.coerce.number().int().min(1).max(1000000),
});
export const sendCredits = adminAction(
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
async (ctx) => {
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
void _target;
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
if (!success)
throw new ActionError("Failed to send credits. Is the emulator running?");
logAudit({
userId: ctx.session.user.id,
action: "user_send_credits",
target: "User",
targetId: ctx.data.userId,
after: { amount: ctx.data.amount },
});
return actionOk();
},
);
+53
View File
@@ -0,0 +1,53 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidateTag } from "next/cache";
import { z } from "zod";
import { db, UserWatch } from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
const toggleWatchSchema = z.object({
targetUserId: z.coerce.number().int().positive(),
reason: z.string().max(255).optional(),
});
/**
* Toggle a watch entry for the calling staff on the given target user.
* If a row already exists it's removed; otherwise it's created with the
* provided reason (defaulting to empty). Returns the resulting state so
* the UI can flip the badge without re-fetching.
*/
export const toggleUserWatch = adminAction(
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
async (ctx) => {
const staffId = ctx.session.user.id;
const { targetUserId, reason } = ctx.data;
const [existing] = await db
.select({ id: UserWatch.id })
.from(UserWatch)
.where(
and(
eq(UserWatch.staffId, staffId),
eq(UserWatch.targetUserId, targetUserId),
),
)
.limit(1);
if (existing) {
await db.delete(UserWatch).where(eq(UserWatch.id, existing.id));
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: false });
}
await db.insert(UserWatch).values({
staffId,
targetUserId,
reason: reason ?? "",
});
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
return actionOk({ watching: true });
},
);
+1 -1
View File
@@ -8,7 +8,7 @@ import { TopHeader } from "@/components/top-header";
import { auth } from "@/lib/auth";
/**
* Public site chrome. Route group `(site)` keeps this off `/ase` and `/client`,
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
* so housekeeping is never constrained by the public max-w-7xl grid.
*/
export default async function SiteLayout({
@@ -5,8 +5,8 @@ import { satisfiesCapability } from "@/features/housekeeping/foundation/capabili
import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HousekeepingShell } from "@/features/housekeeping/foundation/shell/housekeeping-shell";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
import { HousekeepingShell } from "@/features/housekeeping/shell";
const MESSAGE_PREFIX = "pages.housekeeping.";
@@ -18,7 +18,7 @@ function namespaceKey(key: string): string {
return key.slice(MESSAGE_PREFIX.length);
}
export default async function HousekeepingDomainLayout({
export default async function AdminNextDomainLayout({
children,
params,
}: {
@@ -49,20 +49,8 @@ export default async function HousekeepingDomainLayout({
primaryNavigation: translate("navigation.primary"),
contextualNavigation: translate("navigation.contextual"),
command: translate("preview.commandDisabled"),
preview: translate("preview.badge"),
backToSite: translate("preview.backToSite"),
operatorRank: translate("navigation.operatorRank", {
rank: context.actor.rank,
}),
commandDeck: {
placeholder: translate("commandDeck.placeholder"),
navigation: translate("commandDeck.navigation"),
commands: translate("commandDeck.commands"),
entities: translate("commandDeck.entities"),
loading: translate("commandDeck.loading"),
empty: translate("commandDeck.empty"),
partial: translate("commandDeck.partial"),
close: translate("commandDeck.close"),
},
}}
>
{children}
+45
View File
@@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { HousekeepingPageShell } from "@/features/housekeeping/foundation/page/housekeeping-page-shell";
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
const MESSAGE_PREFIX = "pages.housekeeping.";
function namespaceKey(key: string): string {
if (!key.startsWith(MESSAGE_PREFIX)) {
throw new Error(`invalid housekeeping message key: ${key}`);
}
return key.slice(MESSAGE_PREFIX.length);
}
export default async function AdminNextDomainPage({
params,
}: {
params: Promise<{ domain: string }>;
}) {
const { domain } = await params;
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const activeDomain = registry.domains.find((entry) => entry.id === domain);
if (!activeDomain) notFound();
const translate = await getTranslations("pages.housekeeping");
return (
<HousekeepingPageShell
title={translate(namespaceKey(activeDomain.labelKey) as never)}
description={translate(
namespaceKey(activeDomain.descriptionKey) as never,
)}
>
<HousekeepingPageState
state="empty"
title={translate("states.empty.title")}
description={translate("states.empty.description")}
/>
</HousekeepingPageShell>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { env } from "@/env";
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
export default function AdminNextLayout({ children }: { children: ReactNode }) {
if (
!isHousekeepingPreviewEnabled({
nodeEnv: env.NODE_ENV,
flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
})
) {
notFound();
}
return children;
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound, redirect } from "next/navigation";
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
export default async function AdminNextPage() {
const context = await getHousekeepingCapabilityContext();
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const firstVisibleDomain = registry.domains.find((domain) =>
satisfiesCapability(context, domain.capability),
);
if (!firstVisibleDomain) notFound();
redirect(firstVisibleDomain.previewHref);
}
+117
View File
@@ -0,0 +1,117 @@
import { asc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { Achievements, db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
type Achievement = {
name: string;
category: string;
level: number;
rewardAmount: number;
rewardType: number;
points: number | null;
progressNeeded: number;
};
export default async function AdminAchievements() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.achievements");
let achievements: Achievement[];
try {
achievements = await db
.select({
name: Achievements.name,
category: Achievements.category,
level: Achievements.level,
rewardAmount: Achievements.rewardAmount,
rewardType: Achievements.rewardType,
points: Achievements.points,
progressNeeded: Achievements.progressNeeded,
})
.from(Achievements)
.orderBy(
asc(Achievements.category),
asc(Achievements.name),
asc(Achievements.level),
);
} catch {
achievements = [];
}
const groups = new Map<string, Achievement[]>();
for (const a of achievements) {
const list = groups.get(a.category) ?? [];
list.push(a);
groups.set(a.category, list);
}
const distinctNames = new Set(achievements.map((a) => a.name)).size;
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("achievementRows")}
value={achievements.length}
icon="🏆"
/>
<StatusCard
label={t("distinctAchievements")}
value={distinctNames}
icon="🎖️"
/>
<StatusCard label={t("categories")} value={groups.size} icon="🗂️" />
</div>
{groups.size === 0 ? (
<div className="admin-empty">{t("noAchievements")}</div>
) : (
[...groups.entries()].map(([category, rows]) => (
<section key={category} className="mt-6">
<h2 className="admin-section-title">
{category}{" "}
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{rows.length}
</span>
</h2>
<div className="admin-card p-0 overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colName")}</th>
<th>{t("colLevel")}</th>
<th>{t("colProgress")}</th>
<th>{t("colRewardType")}</th>
<th>{t("colRewardAmount")}</th>
<th>{t("colPoints")}</th>
</tr>
</thead>
<tbody>
{rows.map((a) => (
<tr key={`${a.name}-${a.level}`}>
<td>
<strong>{a.name}</strong>
</td>
<td>{a.level}</td>
<td>{a.progressNeeded}</td>
<td>{a.rewardType}</td>
<td>{a.rewardAmount}</td>
<td>{a.points ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))
)}
</main>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { eq } from "drizzle-orm";
import { Megaphone } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { updateAd } from "@/actions/admin-ads";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { db, WebsiteAds } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { EditAdDeleteButton } from "../edit-ad-delete-button";
export default async function EditAd({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
redirect("/admin");
}
const { id } = await params;
let ad: typeof WebsiteAds.$inferSelect | null = null;
try {
const [row] = await db
.select()
.from(WebsiteAds)
.where(eq(WebsiteAds.id, BigInt(id)))
.limit(1);
ad = row ?? null;
} catch {
notFound();
}
if (!ad) notFound();
const t = await getTranslations("pages.admin.ads");
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Megaphone size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("edit")} #{String(ad.id)}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
<Link href="/admin/ads">← {t("title")}</Link>
</p>
</div>
</div>
<form action={updateAd} className="admin-card">
<input type="hidden" name="id" value={String(ad.id)} />
<input
name="image"
defaultValue={ad.image}
placeholder={t("form.imagePlaceholder")}
required
maxLength={255}
/>
<Button type="submit" variant="default">
{t("form.save")}
</Button>
</form>
<div style={{ marginTop: "1rem" }}>
<EditAdDeleteButton id={String(ad.id)} />
</div>
</main>
);
}
Loaded 100 of 802 files, more files were not shown because too many files have changed in this diff. Show more