Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
This reverts commit488b6e57c4, reversing changes made tob506b4499a.
This commit is contained in:
1 parent
488b6e57c4
commit
b1ddda66ff
802 files changed
+61370
-76659
No files matched your search
@@ -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
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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}`;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: {} };
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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"');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user