feat(housekeeping): cut over administration to ase
This commit is contained in:
1 parent
c9e35cf602
commit
2b8f73a91d
457 files changed
+589
-55936
No files matched your search
@@ -55,7 +55,7 @@ describe("Content advertisement legacy wrappers", () => {
|
||||
"ad.change",
|
||||
{ action: "create", image: "https://example.com/ad.png" },
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/content/media/ads");
|
||||
});
|
||||
|
||||
it("returns early when image is empty", async () => {
|
||||
|
||||
@@ -31,10 +31,10 @@ export async function createAd(formData: FormData): Promise<void> {
|
||||
action: "createAd",
|
||||
error: result.error.messageKey,
|
||||
});
|
||||
revalidatePath("/admin/ads");
|
||||
revalidatePath("/ase/content/media/ads");
|
||||
return;
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
redirect("/ase/content/media/ads");
|
||||
}
|
||||
|
||||
export async function updateAd(formData: FormData): Promise<void> {
|
||||
@@ -57,10 +57,10 @@ export async function updateAd(formData: FormData): Promise<void> {
|
||||
id: Number(raw),
|
||||
error: result.error.messageKey,
|
||||
});
|
||||
revalidatePath(`/admin/ads/${raw}`);
|
||||
revalidatePath(`/ase/content/media/ads/${raw}`);
|
||||
return;
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
redirect("/ase/content/media/ads");
|
||||
}
|
||||
|
||||
const deleteAdInput = z.object({
|
||||
@@ -82,7 +82,7 @@ export const deleteAd = adminAction(
|
||||
{ action: "delete", id: ctx.data.id.toString() },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Advertisement not found");
|
||||
revalidatePath("/admin/ads");
|
||||
revalidatePath("/ase/content/media/ads");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -37,7 +37,9 @@ describe("sendHotelAlert", () => {
|
||||
fakeForm({ message: "Hello!" }) as unknown as FormData,
|
||||
);
|
||||
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/ase/system/operations/alerts",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns early when message is empty", async () => {
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
|
||||
// admin page. The emulator writes its own alert_logs row on receipt.
|
||||
}
|
||||
|
||||
revalidatePath("/admin/alerts");
|
||||
revalidatePath("/ase/system/operations/alerts");
|
||||
}
|
||||
|
||||
/** Mark every unread ops alert as read. */
|
||||
@@ -67,5 +67,5 @@ export async function markAllAlertsRead(): Promise<void> {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
revalidatePath("/admin/alerts");
|
||||
revalidatePath("/ase/system/operations/alerts");
|
||||
}
|
||||
@@ -22,5 +22,5 @@ export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
{ applicationId, decision: "dismiss" },
|
||||
);
|
||||
// Preserve the tolerant legacy action: already-gone/DB failure still refreshes.
|
||||
revalidatePath("/admin/applications");
|
||||
revalidatePath("/ase/people/staff/applications");
|
||||
}
|
||||
@@ -39,10 +39,10 @@ export async function createArticle(formData: FormData): Promise<void> {
|
||||
);
|
||||
if (!result.ok) {
|
||||
redirect(
|
||||
"/admin/articles/new?error=Database error while creating article. Please try again.",
|
||||
"/ase/content/editorial/articles/new?error=Database error while creating article. Please try again.",
|
||||
);
|
||||
}
|
||||
redirect("/admin/articles");
|
||||
redirect("/ase/content/editorial/articles");
|
||||
}
|
||||
|
||||
export async function updateArticle(formData: FormData): Promise<void> {
|
||||
@@ -53,9 +53,10 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
"article.change",
|
||||
{ action: "update", id, ...articleInput(formData) },
|
||||
);
|
||||
if (!result.ok) redirect("/admin/articles?error=Update failed");
|
||||
revalidatePath(`/admin/articles/${id}`);
|
||||
redirect("/admin/articles");
|
||||
if (!result.ok)
|
||||
redirect("/ase/content/editorial/articles?error=Update failed");
|
||||
revalidatePath(`/ase/content/editorial/articles/${id}`);
|
||||
redirect("/ase/content/editorial/articles");
|
||||
}
|
||||
|
||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
@@ -66,6 +67,7 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
"article.change",
|
||||
{ action: "delete", id },
|
||||
);
|
||||
if (!result.ok) redirect("/admin/articles?error=Delete failed");
|
||||
redirect("/admin/articles");
|
||||
if (!result.ok)
|
||||
redirect("/ase/content/editorial/articles?error=Delete failed");
|
||||
redirect("/ase/content/editorial/articles");
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function uploadBadge(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const code = String(formData.get("code") ?? "");
|
||||
try {
|
||||
await executeLegacyEconomyMutation(staff, "badge.upload", {
|
||||
code,
|
||||
file: formData.get("file"),
|
||||
});
|
||||
} catch {
|
||||
redirect("/admin/badges?error=Badge%20upload%20failed");
|
||||
}
|
||||
redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function giveBadge(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "badge.give", {
|
||||
userId: formData.get("userId"),
|
||||
badge: formData.get("code"),
|
||||
});
|
||||
revalidatePath("/admin/badges");
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
createBanner as createBannerAction,
|
||||
deleteBanner as deleteBannerAction,
|
||||
updateBanner as updateBannerAction,
|
||||
} from "./banners";
|
||||
|
||||
export async function createBanner(
|
||||
input: Parameters<typeof createBannerAction>[0],
|
||||
) {
|
||||
return createBannerAction(input);
|
||||
}
|
||||
|
||||
export async function updateBanner(
|
||||
input: Parameters<typeof updateBannerAction>[0],
|
||||
) {
|
||||
return updateBannerAction(input);
|
||||
}
|
||||
|
||||
export async function deleteBanner(
|
||||
input: Parameters<typeof deleteBannerAction>[0],
|
||||
) {
|
||||
return deleteBannerAction(input);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ describe("legacy admin ban wrappers", () => {
|
||||
"ban.create",
|
||||
{ userId: 42, reason: "Spam", hours: 24, type: "account" },
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
|
||||
});
|
||||
|
||||
it("returns early when userId is invalid", async () => {
|
||||
@@ -62,6 +62,6 @@ describe("legacy admin ban wrappers", () => {
|
||||
"ban.lift",
|
||||
{ id: 42 },
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export async function createBan(formData: FormData): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not create ban");
|
||||
revalidatePath("/admin/bans");
|
||||
revalidatePath("/ase/people/moderation/bans");
|
||||
}
|
||||
|
||||
export async function liftBan(formData: FormData): Promise<void> {
|
||||
@@ -51,5 +51,5 @@ export async function liftBan(formData: FormData): Promise<void> {
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not lift ban");
|
||||
}
|
||||
revalidatePath("/admin/bans");
|
||||
revalidatePath("/ase/people/moderation/bans");
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
"use server";
|
||||
|
||||
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 { PERMS } from "@/lib/permissions";
|
||||
|
||||
function templateInput(formData: FormData) {
|
||||
return {
|
||||
name: String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
subject: String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
body: String(formData.get("body") ?? "").normalize("NFC"),
|
||||
variables: String(formData.get("variables") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
isActive: formData.get("isActive") != null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const input = templateInput(formData);
|
||||
if (!input.name || !input.subject || !input.body) return;
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"email-template.change",
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
if (!result.ok) throw new Error("Email template creation failed");
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
|
||||
export async function updateEmailTemplate(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!/^\d+$/u.test(id)) return;
|
||||
const input = templateInput(formData);
|
||||
if (!input.subject || !input.body) return;
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"email-template.change",
|
||||
{ action: "update", id, ...input },
|
||||
);
|
||||
if (!result.ok) throw new Error("Email template update failed");
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
|
||||
export async function deleteEmailTemplate(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^[1-9]\d*$/u.test(id)) return;
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"email-template.change",
|
||||
{ action: "delete", id },
|
||||
);
|
||||
if (!result.ok) throw new Error("Email template deletion failed");
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"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 { 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.
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
async function requireMutation(
|
||||
staff: { id: number; rank: number; username: string },
|
||||
operation:
|
||||
| "configuration.emulator-setting.update"
|
||||
| "configuration.emulator-text.update",
|
||||
input: { key: string; value: string },
|
||||
): Promise<void> {
|
||||
const result = await systemMutationService.execute(
|
||||
mutationContext(staff),
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
}
|
||||
|
||||
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
|
||||
const staff = 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 requireMutation(staff, "configuration.emulator-setting.update", {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
revalidatePath("/admin/emulator");
|
||||
}
|
||||
|
||||
export async function updateEmulatorText(formData: FormData): Promise<void> {
|
||||
const staff = 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 requireMutation(staff, "configuration.emulator-text.update", {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
revalidatePath("/admin/emulator");
|
||||
}
|
||||
@@ -33,12 +33,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("disbandGuild legacy wrapper", () => {
|
||||
it("keeps rate-limited ACL, service input, and /admin revalidation", async () => {
|
||||
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("/admin/guilds");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/community/guilds");
|
||||
});
|
||||
it("keeps invalid IDs as a no-op", async () => {
|
||||
await disbandGuild(form({ id: "0" }));
|
||||
|
||||
@@ -24,5 +24,5 @@ export async function disbandGuild(formData: FormData): Promise<void> {
|
||||
if (result.error.code === "NOT_FOUND") return;
|
||||
throw new Error("Could not disband guild");
|
||||
}
|
||||
revalidatePath("/admin/guilds");
|
||||
revalidatePath("/ase/people/community/guilds");
|
||||
}
|
||||
@@ -37,10 +37,8 @@ const helpCenterTicketIdSchema = z.object({
|
||||
|
||||
function revalidateHelpCenterTicketPaths(ticketId: bigint) {
|
||||
const id = String(ticketId);
|
||||
revalidatePath("/admin/help-tickets");
|
||||
revalidatePath(`/admin/help-tickets/${id}`);
|
||||
revalidatePath("/mod/help-tickets");
|
||||
revalidatePath(`/mod/help-tickets/${id}`);
|
||||
revalidatePath("/ase/people/support/help-tickets");
|
||||
revalidatePath(`/ase/people/support/help-tickets/${id}`);
|
||||
revalidatePath("/help/tickets");
|
||||
revalidatePath(`/help/tickets/${id}`);
|
||||
}
|
||||
@@ -76,10 +74,10 @@ export const liftBanFromHelpTicket = adminAction(
|
||||
}
|
||||
|
||||
revalidateHelpCenterTicketPaths(ticketId);
|
||||
revalidatePath("/admin/bans");
|
||||
revalidatePath("/ase/people/moderation/bans");
|
||||
const removed = Number(result.data.output?.removed ?? 0);
|
||||
const userId = Number(result.data.output?.userId);
|
||||
revalidatePath(`/admin/users/show/${userId}`);
|
||||
revalidatePath(`/ase/people/users/${userId}`);
|
||||
return actionOk({ removed, userId });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("Content help legacy wrappers", () => {
|
||||
"help-question.change",
|
||||
expect.objectContaining({ action: "create", name: "FAQ" }),
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
|
||||
});
|
||||
it("delegates update and redirects", async () => {
|
||||
await updateHelpQuestion(
|
||||
@@ -56,7 +56,7 @@ describe("Content help legacy wrappers", () => {
|
||||
"help-question.change",
|
||||
expect.objectContaining({ action: "update", id: "42" }),
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
|
||||
});
|
||||
it("delegates delete and redirects", async () => {
|
||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
|
||||
@@ -65,6 +65,6 @@ describe("Content help legacy wrappers", () => {
|
||||
"help-question.change",
|
||||
{ action: "delete", id: "42" },
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/content/help/questions");
|
||||
});
|
||||
});
|
||||
@@ -39,13 +39,13 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
if (!result.ok) {
|
||||
revalidatePath("/admin/help-questions");
|
||||
revalidatePath("/ase/content/help/questions");
|
||||
redirect(
|
||||
"/admin/help-questions/new?error=Unique name collision or database error. Please try again.",
|
||||
"/ase/content/help/questions/new?error=Unique name collision or database error. Please try again.",
|
||||
);
|
||||
}
|
||||
revalidatePath("/admin/help-questions");
|
||||
redirect("/admin/help-questions");
|
||||
revalidatePath("/ase/content/help/questions");
|
||||
redirect("/ase/content/help/questions");
|
||||
}
|
||||
|
||||
export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
@@ -60,10 +60,10 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
{ action: "update", id, ...input },
|
||||
);
|
||||
if (!result.ok) {
|
||||
revalidatePath(`/admin/help-questions/${id}`);
|
||||
revalidatePath(`/ase/content/help/questions/${id}`);
|
||||
return;
|
||||
}
|
||||
redirect("/admin/help-questions");
|
||||
redirect("/ase/content/help/questions");
|
||||
}
|
||||
|
||||
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
||||
@@ -75,5 +75,5 @@ export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
||||
"help-question.change",
|
||||
{ action: "delete", id },
|
||||
);
|
||||
redirect("/admin/help-questions");
|
||||
redirect("/ase/content/help/questions");
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"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);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ async function run(
|
||||
input,
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not update IP rules");
|
||||
revalidatePath("/admin/ip");
|
||||
revalidatePath("/ase/people/moderation/ip");
|
||||
}
|
||||
|
||||
export async function addWhitelist(formData: FormData): Promise<void> {
|
||||
|
||||
@@ -96,7 +96,9 @@ describe("saveMaintenance", () => {
|
||||
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(mockReload).toHaveBeenCalledOnce();
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith(
|
||||
"/ase/system/operations/maintenance",
|
||||
);
|
||||
});
|
||||
|
||||
it("disables maintenance mode", async () => {
|
||||
|
||||
@@ -60,5 +60,5 @@ export async function saveMaintenance(formData: FormData): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
revalidatePath("/admin/maintenance");
|
||||
revalidatePath("/ase/system/operations/maintenance");
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
/** Cancel an active marketplace listing (state 1 to 0). */
|
||||
export async function cancelMarketplaceListing(
|
||||
formData: FormData,
|
||||
): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "marketplace.cancel", {
|
||||
id: formData.get("id"),
|
||||
});
|
||||
revalidatePath("/admin/marketplace");
|
||||
}
|
||||
@@ -60,6 +60,6 @@ describe("Content media legacy wrappers", () => {
|
||||
filename: "photo.png",
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/media");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/media/library");
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ export async function uploadMedia(
|
||||
);
|
||||
if (!result.ok) throw new Error("Media upload failed");
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
revalidatePath("/ase/content/media/library");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function deleteMedia(name: string): Promise<void> {
|
||||
{ filename: name },
|
||||
);
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
revalidatePath("/ase/content/media/library");
|
||||
}
|
||||
|
||||
export async function uploadMediaAndReturn(
|
||||
@@ -67,7 +67,7 @@ export async function uploadMediaAndReturn(
|
||||
);
|
||||
if (!result.ok) return "";
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
revalidatePath("/ase/content/media/library");
|
||||
return typeof result.data.output?.url === "string"
|
||||
? result.data.output.url
|
||||
: "";
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
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 result = await contentMutationService.execute(
|
||||
{
|
||||
correlationId: String(ctx.requestId),
|
||||
expectedActorId: Number(ctx.session.user.id),
|
||||
legacy: true,
|
||||
},
|
||||
"navigation.update",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new Error("Navigation update failed");
|
||||
revalidatePath("/admin", "layout");
|
||||
revalidatePath("/admin/menu");
|
||||
return actionOk({ saved: true });
|
||||
},
|
||||
);
|
||||
@@ -39,7 +39,7 @@ describe("deletePhoto", () => {
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", {
|
||||
id: 42,
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/media/photos");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||
});
|
||||
it("returns early when id is not positive", async () => {
|
||||
|
||||
@@ -18,6 +18,6 @@ export async function deletePhoto(formData: FormData): Promise<void> {
|
||||
"photo.delete",
|
||||
{ id },
|
||||
);
|
||||
revalidatePath("/admin/photos");
|
||||
revalidatePath("/ase/content/media/photos");
|
||||
revalidatePath("/photos");
|
||||
}
|
||||
@@ -19,8 +19,8 @@ export async function createApiKey(formData: FormData): Promise<void> {
|
||||
allowedIps: text(formData, "allowedIps") || undefined,
|
||||
rateLimit: Number(text(formData, "rateLimit") || 300),
|
||||
});
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
redirect("/admin/radio/api-keys?created=1");
|
||||
revalidatePath("/ase/hotel/radio/api-keys");
|
||||
redirect("/ase/hotel/radio/api-keys?created=1");
|
||||
}
|
||||
|
||||
export async function toggleApiKey(formData: FormData): Promise<void> {
|
||||
@@ -28,7 +28,7 @@ export async function toggleApiKey(formData: FormData): Promise<void> {
|
||||
await executeLegacyHotelMutation(staff, "radio.api-key.toggle", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
revalidatePath("/ase/hotel/radio/api-keys");
|
||||
}
|
||||
|
||||
export async function deleteApiKey(formData: FormData): Promise<void> {
|
||||
@@ -36,5 +36,5 @@ export async function deleteApiKey(formData: FormData): Promise<void> {
|
||||
await executeLegacyHotelMutation(staff, "radio.api-key.delete", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
revalidatePath("/ase/hotel/radio/api-keys");
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export async function createTrack(formData: FormData): Promise<void> {
|
||||
sortOrder: Number(text(formData, "sortOrder") || 0),
|
||||
isActive: enabled(formData, "isActive"),
|
||||
});
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
revalidatePath("/ase/hotel/radio/autodj");
|
||||
}
|
||||
|
||||
export async function toggleTrack(formData: FormData): Promise<void> {
|
||||
@@ -36,7 +36,7 @@ export async function toggleTrack(formData: FormData): Promise<void> {
|
||||
id: text(formData, "id"),
|
||||
isActive: enabled(formData, "isActive"),
|
||||
});
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
revalidatePath("/ase/hotel/radio/autodj");
|
||||
}
|
||||
|
||||
export async function deleteTrack(formData: FormData): Promise<void> {
|
||||
@@ -44,5 +44,5 @@ export async function deleteTrack(formData: FormData): Promise<void> {
|
||||
await executeLegacyHotelMutation(staff, "radio.autodj.delete", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
revalidatePath("/ase/hotel/radio/autodj");
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
|
||||
comment: text(formData, "comment") || undefined,
|
||||
});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/radio/settings");
|
||||
revalidatePath("/ase/hotel/radio/settings");
|
||||
}
|
||||
|
||||
export async function saveRadioSettings(formData: FormData): Promise<void> {
|
||||
@@ -42,7 +42,7 @@ export async function saveRadioSettings(formData: FormData): Promise<void> {
|
||||
entries,
|
||||
});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/radio/settings");
|
||||
revalidatePath("/ase/hotel/radio/settings");
|
||||
}
|
||||
|
||||
function bannerInput(formData: FormData) {
|
||||
@@ -62,7 +62,7 @@ export async function createRadioBanner(formData: FormData): Promise<void> {
|
||||
"radio.banner.create",
|
||||
bannerInput(formData),
|
||||
);
|
||||
revalidatePath("/admin/radio/banners");
|
||||
revalidatePath("/ase/hotel/radio/banners");
|
||||
}
|
||||
|
||||
export async function updateRadioBanner(formData: FormData): Promise<void> {
|
||||
@@ -71,7 +71,7 @@ export async function updateRadioBanner(formData: FormData): Promise<void> {
|
||||
id: text(formData, "id"),
|
||||
...bannerInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/radio/banners");
|
||||
revalidatePath("/ase/hotel/radio/banners");
|
||||
}
|
||||
|
||||
export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
||||
@@ -79,7 +79,7 @@ export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
||||
await executeLegacyHotelMutation(staff, "radio.banner.delete", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/banners");
|
||||
revalidatePath("/ase/hotel/radio/banners");
|
||||
}
|
||||
|
||||
function rankInput(formData: FormData) {
|
||||
@@ -98,7 +98,7 @@ export async function createRadioRank(formData: FormData): Promise<void> {
|
||||
"radio.rank.create",
|
||||
rankInput(formData),
|
||||
);
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
revalidatePath("/ase/hotel/radio/ranks");
|
||||
}
|
||||
|
||||
export async function updateRadioRank(formData: FormData): Promise<void> {
|
||||
@@ -107,7 +107,7 @@ export async function updateRadioRank(formData: FormData): Promise<void> {
|
||||
id: text(formData, "id"),
|
||||
...rankInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
revalidatePath("/ase/hotel/radio/ranks");
|
||||
}
|
||||
|
||||
export async function deleteRadioRank(formData: FormData): Promise<void> {
|
||||
@@ -115,5 +115,5 @@ export async function deleteRadioRank(formData: FormData): Promise<void> {
|
||||
await executeLegacyHotelMutation(staff, "radio.rank.delete", {
|
||||
id: text(formData, "id"),
|
||||
});
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
revalidatePath("/ase/hotel/radio/ranks");
|
||||
}
|
||||
@@ -10,5 +10,5 @@ export async function deleteShout(formData: FormData): Promise<void> {
|
||||
await executeLegacyHotelMutation(staff, "radio.shout.delete", {
|
||||
id: String(formData.get("id") ?? "").trim(),
|
||||
});
|
||||
revalidatePath("/admin/radio/moderation");
|
||||
revalidatePath("/ase/hotel/radio/moderation");
|
||||
}
|
||||
@@ -35,6 +35,6 @@ export async function savePoints(formData: FormData): Promise<void> {
|
||||
),
|
||||
});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/radio/points");
|
||||
redirect("/admin/radio/points?saved=1");
|
||||
revalidatePath("/ase/hotel/radio/points");
|
||||
redirect("/ase/hotel/radio/points?saved=1");
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
function formInput(formData: FormData): Record<string, FormDataEntryValue> {
|
||||
return Object.fromEntries(formData.entries());
|
||||
}
|
||||
|
||||
export async function createCategory(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "rare-category.change", {
|
||||
action: "create",
|
||||
...formInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
|
||||
export async function deleteCategory(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "rare-category.change", {
|
||||
action: "delete",
|
||||
...formInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
|
||||
export async function createValue(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "rare-value.change", {
|
||||
action: "create",
|
||||
...formInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
|
||||
export async function deleteValue(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "rare-value.change", {
|
||||
action: "delete",
|
||||
...formInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
|
||||
import {
|
||||
type SystemMutationContext,
|
||||
type SystemMutationOperation,
|
||||
systemMutationService,
|
||||
} from "@/features/housekeeping/domains/system/services/mutations";
|
||||
import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import type { AdminActionContext } from "@/lib/foundation/types";
|
||||
import {
|
||||
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
|
||||
normalizeHabboGamedataHotel,
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
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 actionMutationContext(
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
function checkedMutationContext(staff: {
|
||||
id: number;
|
||||
rank: number;
|
||||
username: string;
|
||||
}): SystemMutationContext {
|
||||
const matches = (slug: string) => slug === PERMS.SETTINGS_EDIT;
|
||||
return {
|
||||
capability: createHousekeepingCapabilityContext(staff, {
|
||||
isSuperAdmin: false,
|
||||
has: matches,
|
||||
hasAny: (...slugs) => slugs.some(matches),
|
||||
hasAll: (...slugs) => slugs.every(matches),
|
||||
}),
|
||||
correlationId: createCorrelationId(),
|
||||
};
|
||||
}
|
||||
|
||||
async function runMutation(
|
||||
context: SystemMutationContext,
|
||||
operation: SystemMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<unknown> {
|
||||
const result = await systemMutationService.execute(context, operation, input);
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
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);
|
||||
const mutation = (await runMutation(
|
||||
actionMutationContext(ctx),
|
||||
"configuration.settings.save",
|
||||
{ settings: Object.fromEntries(entries) },
|
||||
)) as { saved: number };
|
||||
revalidatePath("/admin/settings");
|
||||
revalidatePath("/admin/catalog");
|
||||
return actionOk({ saved: mutation.saved });
|
||||
},
|
||||
);
|
||||
|
||||
export async function updateSetting(formData: FormData): Promise<void> {
|
||||
const staff = 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 runMutation(
|
||||
checkedMutationContext(staff),
|
||||
"configuration.setting.update",
|
||||
{ key, value },
|
||||
);
|
||||
revalidatePath("/admin/settings");
|
||||
}
|
||||
|
||||
export async function createSetting(formData: FormData): Promise<void> {
|
||||
const staff = 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 runMutation(
|
||||
checkedMutationContext(staff),
|
||||
"configuration.setting.create",
|
||||
{ key, value, comment },
|
||||
);
|
||||
revalidatePath("/admin/settings");
|
||||
}
|
||||
|
||||
export async function deleteSetting(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT);
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!key) return;
|
||||
await runMutation(
|
||||
checkedMutationContext(staff),
|
||||
"configuration.setting.delete",
|
||||
{ key },
|
||||
);
|
||||
revalidatePath("/admin/settings");
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
function formInput(formData: FormData): Record<string, FormDataEntryValue> {
|
||||
return Object.fromEntries(formData.entries());
|
||||
}
|
||||
|
||||
export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
try {
|
||||
await executeLegacyEconomyMutation(staff, "shop-article.change", {
|
||||
action: "create",
|
||||
...formInput(formData),
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_create_failed", error, { staffId: staff.id });
|
||||
return;
|
||||
}
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
|
||||
export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
const input = formInput(formData);
|
||||
try {
|
||||
await executeLegacyEconomyMutation(staff, "shop-article.change", {
|
||||
action: "update",
|
||||
...input,
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_update_failed", error, {
|
||||
staffId: staff.id,
|
||||
articleId: String(input.id ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
revalidatePath(`/admin/shop/${String(input.id ?? "")}`);
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
|
||||
export async function deleteShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
const input = formInput(formData);
|
||||
try {
|
||||
await executeLegacyEconomyMutation(staff, "shop-article.change", {
|
||||
action: "delete",
|
||||
...input,
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_delete_failed", error, {
|
||||
staffId: staff.id,
|
||||
articleId: String(input.id ?? ""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
@@ -40,7 +40,7 @@ describe("Content tag legacy wrappers", () => {
|
||||
name: "News",
|
||||
backgroundColor: "#ff0000",
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/editorial/tags");
|
||||
});
|
||||
it("uses the legacy default color", async () => {
|
||||
await createTag(form({ name: "Test" }));
|
||||
@@ -64,7 +64,7 @@ describe("Content tag legacy wrappers", () => {
|
||||
correlationId: "legacy",
|
||||
});
|
||||
await createTag(form({ name: "News" }));
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/content/editorial/tags");
|
||||
});
|
||||
it("delegates update", async () => {
|
||||
await updateTag(
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function createTag(formData: FormData): Promise<void> {
|
||||
"tag.change",
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
revalidatePath("/admin/tags");
|
||||
revalidatePath("/ase/content/editorial/tags");
|
||||
}
|
||||
|
||||
export async function updateTag(formData: FormData): Promise<void> {
|
||||
@@ -44,7 +44,7 @@ export async function updateTag(formData: FormData): Promise<void> {
|
||||
"tag.change",
|
||||
{ action: "update", id, ...input },
|
||||
);
|
||||
revalidatePath("/admin/tags");
|
||||
revalidatePath("/ase/content/editorial/tags");
|
||||
}
|
||||
|
||||
export async function deleteTag(formData: FormData): Promise<void> {
|
||||
@@ -56,5 +56,5 @@ export async function deleteTag(formData: FormData): Promise<void> {
|
||||
"tag.change",
|
||||
{ action: "delete", id },
|
||||
);
|
||||
revalidatePath("/admin/tags");
|
||||
revalidatePath("/ase/content/editorial/tags");
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export async function createTeam(formData: FormData): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw new Error("Could not create team");
|
||||
revalidatePath("/admin/teams");
|
||||
revalidatePath("/ase/people/staff/teams");
|
||||
}
|
||||
|
||||
export async function deleteTeam(formData: FormData): Promise<void> {
|
||||
@@ -49,5 +49,5 @@ export async function deleteTeam(formData: FormData): Promise<void> {
|
||||
if (!result.ok && result.error.code !== "NOT_FOUND") {
|
||||
throw new Error("Could not delete team");
|
||||
}
|
||||
revalidatePath("/admin/teams");
|
||||
revalidatePath("/ase/people/staff/teams");
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
"use server";
|
||||
|
||||
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 { PERMS } from "@/lib/permissions";
|
||||
|
||||
function formValues(formData: FormData): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Array.from(formData.entries(), ([key, value]) => [
|
||||
key,
|
||||
typeof value === "string" ? value : value.name,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function executeTheme(
|
||||
operation:
|
||||
| "theme.update"
|
||||
| "theme.apply-preset"
|
||||
| "theme.custom-change"
|
||||
| "theme.apply-custom",
|
||||
input: Record<string, unknown>,
|
||||
) {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
return contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveTheme(formData: FormData): Promise<void> {
|
||||
const result = await executeTheme("theme.update", {
|
||||
values: formValues(formData),
|
||||
});
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
redirect("/admin/theme?saved=1");
|
||||
}
|
||||
|
||||
export async function applyPreset(formData: FormData): Promise<void> {
|
||||
const name = String(formData.get("preset") ?? "").normalize("NFC");
|
||||
const result = await executeTheme("theme.apply-preset", { preset: name });
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
redirect(
|
||||
result.ok
|
||||
? `/admin/theme?preset=${encodeURIComponent(name)}`
|
||||
: "/admin/theme",
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveCustomTheme(formData: FormData): Promise<void> {
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!name) redirect("/admin/theme");
|
||||
const result = await executeTheme("theme.custom-change", {
|
||||
action: "create",
|
||||
name,
|
||||
});
|
||||
if (result.ok) revalidatePath("/admin/theme");
|
||||
redirect("/admin/theme?savedTheme=1");
|
||||
}
|
||||
|
||||
export async function applyCustomTheme(formData: FormData): Promise<void> {
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!id) redirect("/admin/theme");
|
||||
const result = await executeTheme("theme.apply-custom", { id });
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
const name =
|
||||
result.ok && typeof result.data.output?.name === "string"
|
||||
? result.data.output.name
|
||||
: "";
|
||||
redirect(
|
||||
result.ok
|
||||
? `/admin/theme?theme=${encodeURIComponent(name)}`
|
||||
: "/admin/theme",
|
||||
);
|
||||
}
|
||||
|
||||
export async function renameCustomTheme(formData: FormData): Promise<void> {
|
||||
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 result = await executeTheme("theme.custom-change", {
|
||||
action: "rename",
|
||||
id,
|
||||
name,
|
||||
});
|
||||
if (result.ok) revalidatePath("/admin/theme");
|
||||
redirect("/admin/theme?renamed=1");
|
||||
}
|
||||
|
||||
export async function deleteCustomTheme(formData: FormData): Promise<void> {
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!id) redirect("/admin/theme");
|
||||
const result = await executeTheme("theme.custom-change", {
|
||||
action: "delete",
|
||||
id,
|
||||
});
|
||||
if (result.ok) revalidatePath("/admin/theme");
|
||||
redirect("/admin/theme?deletedTheme=1");
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
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 }>> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
try {
|
||||
const snapshot = await executeLegacyEconomyMutation(
|
||||
staff,
|
||||
"voucher.change",
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
revalidatePath("/admin/vouchers");
|
||||
return actionOk({ id: String(snapshot.output?.id) });
|
||||
} 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> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
try {
|
||||
await executeLegacyEconomyMutation(staff, "voucher.change", {
|
||||
action: "delete",
|
||||
id: input.id,
|
||||
});
|
||||
revalidatePath("/admin/vouchers");
|
||||
return actionOk();
|
||||
} catch (error) {
|
||||
logServerError("admin.voucher_delete_failed", error, {
|
||||
voucherId: input.id,
|
||||
});
|
||||
return actionError("Could not delete voucher");
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves VPN payload, /admin revalidation, and redirect", async () => {
|
||||
it("preserves VPN payload, ASE revalidation, and redirect", async () => {
|
||||
await saveVpn(
|
||||
form({
|
||||
vpn_block_enabled: "1",
|
||||
@@ -51,8 +51,8 @@ it("preserves VPN payload, /admin revalidation, and redirect", async () => {
|
||||
apiKey: "abc123",
|
||||
blockMessage: "",
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/vpn");
|
||||
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 () => {
|
||||
@@ -66,5 +66,5 @@ it("preserves fail-soft redirect without claiming a saved revalidation", async (
|
||||
});
|
||||
await saveVpn(form({ vpn_provider: "none" }));
|
||||
expect(revalidatePath).not.toHaveBeenCalled();
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/people/moderation/vpn?saved=1");
|
||||
});
|
||||
@@ -35,7 +35,7 @@ export async function saveVpn(formData: FormData): Promise<void> {
|
||||
.slice(0, 255),
|
||||
},
|
||||
);
|
||||
if (result.ok) revalidatePath("/admin/vpn");
|
||||
if (result.ok) revalidatePath("/ase/people/moderation/vpn");
|
||||
// Preserve fail-soft legacy navigation even when persistence is unavailable.
|
||||
redirect("/admin/vpn?saved=1");
|
||||
redirect("/ase/people/moderation/vpn?saved=1");
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export async function addWord(input: {
|
||||
);
|
||||
if (!result.ok)
|
||||
return actionError("Could not add word (it may already exist)");
|
||||
revalidatePath("/admin/wordfilter");
|
||||
revalidatePath("/ase/people/moderation/word-filter");
|
||||
return actionOk({ id: String(result.data.after?.id ?? "") });
|
||||
}
|
||||
|
||||
@@ -48,6 +48,6 @@ export async function deleteWord(input: { id: string }): Promise<ActionResult> {
|
||||
if (!result.ok && result.error.code !== "NOT_FOUND") {
|
||||
return actionError("Could not remove word");
|
||||
}
|
||||
revalidatePath("/admin/wordfilter");
|
||||
revalidatePath("/ase/people/moderation/word-filter");
|
||||
return actionOk();
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use server";
|
||||
|
||||
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 { PERMS } from "@/lib/permissions";
|
||||
|
||||
function boxInput(formData: FormData) {
|
||||
const position = Number(formData.get("position"));
|
||||
return {
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
icon: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
position:
|
||||
Number.isFinite(position) && position >= 0 ? Math.floor(position) : 0,
|
||||
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
};
|
||||
}
|
||||
|
||||
function refreshBoxes(): void {
|
||||
revalidatePath("/admin/writeable-boxes");
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
async function executeBox(
|
||||
formData: FormData,
|
||||
action: "create" | "update" | "delete" | "toggle",
|
||||
): Promise<boolean> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (action !== "create" && !/^\d+$/u.test(id)) return false;
|
||||
const input = boxInput(formData);
|
||||
if ((action === "create" || action === "update") && !input.title)
|
||||
return false;
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"writeable-box.change",
|
||||
{ action, ...(id ? { id } : {}), ...input, next: formData.get("next") },
|
||||
);
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
export async function createBox(formData: FormData): Promise<void> {
|
||||
if (await executeBox(formData, "create")) refreshBoxes();
|
||||
}
|
||||
export async function updateBox(formData: FormData): Promise<void> {
|
||||
if (await executeBox(formData, "update")) refreshBoxes();
|
||||
}
|
||||
export async function deleteBox(formData: FormData): Promise<void> {
|
||||
if (await executeBox(formData, "delete")) refreshBoxes();
|
||||
}
|
||||
export async function toggleBox(formData: FormData): Promise<void> {
|
||||
if (await executeBox(formData, "toggle")) refreshBoxes();
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
"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");
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
async function runBanner(
|
||||
ctx: {
|
||||
data: Record<string, unknown>;
|
||||
requestId: unknown;
|
||||
session: { user: { id: number } };
|
||||
},
|
||||
action: "create" | "update" | "delete",
|
||||
) {
|
||||
return contentMutationService.execute(
|
||||
{
|
||||
correlationId: String(ctx.requestId),
|
||||
expectedActorId: Number(ctx.session.user.id),
|
||||
legacy: true,
|
||||
},
|
||||
"banner.change",
|
||||
{ action, ...ctx.data },
|
||||
);
|
||||
}
|
||||
|
||||
export const createBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
|
||||
async (ctx) => {
|
||||
const result = await runBanner(ctx, "create");
|
||||
if (!result.ok) throw new ActionError("Banner creation failed");
|
||||
return actionOk({ id: Number(result.data.output?.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 result = await runBanner(ctx, "update");
|
||||
if (!result.ok) throw new ActionError("Banner not found");
|
||||
return actionOk({ id: ctx.data.id });
|
||||
},
|
||||
);
|
||||
|
||||
const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
|
||||
|
||||
export const deleteBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
|
||||
async (ctx) => {
|
||||
const result = await runBanner(ctx, "delete");
|
||||
if (!result.ok) throw new ActionError("Banner not found");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -6,8 +6,7 @@ import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
function revalidateBuilderClub(): void {
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
}
|
||||
|
||||
export async function updateBcPage({
|
||||
|
||||
@@ -9,7 +9,7 @@ import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
function revalidateCatalog(): void {
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
}
|
||||
|
||||
export async function createCatalogItem(data: {
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function updateCatalogPage({
|
||||
id,
|
||||
...fields,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function deleteCatalogPage({ id }: { id: number }) {
|
||||
action: "delete",
|
||||
id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function toggleCatalogPage({
|
||||
action: action === "toggleEnabled" ? "toggle-enabled" : "toggle-visible",
|
||||
id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function createCatalogPage(input: {
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
const createdId = Number(snapshot.output?.id);
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
return { ok: true as const, data: { id: createdId } };
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function reorderTreePage(input: {
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "catalog-page.reorder", input);
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
@@ -85,6 +85,6 @@ export async function deleteTreePage(input: {
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "catalog-page.delete-tree", input);
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/ase/economy/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } 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";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const PATH = "/admin/commandocentrum";
|
||||
const RCON_FAIL = "RCON command failed. Is the emulator running?";
|
||||
|
||||
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 runRconMutation(
|
||||
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
|
||||
operation: SystemMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<void> {
|
||||
const result = await systemMutationService.execute(
|
||||
mutationContext(ctx),
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (result.ok) return;
|
||||
if (result.error.messageKey === "errors.housekeeping.system.userNotFound") {
|
||||
throw new ActionError("User not found");
|
||||
}
|
||||
if (result.error.messageKey === "errors.housekeeping.system.rankNotFound") {
|
||||
throw new ActionError("Rank does not exist");
|
||||
}
|
||||
if (
|
||||
result.error.messageKey ===
|
||||
"errors.housekeeping.system.cannotChangePeerRank"
|
||||
) {
|
||||
throw new ActionError("Cannot change rank of a user at or above your rank");
|
||||
}
|
||||
if (
|
||||
result.error.messageKey ===
|
||||
"errors.housekeeping.system.cannotAssignPeerRank"
|
||||
) {
|
||||
throw new ActionError("Cannot set a rank equal to or above your own");
|
||||
}
|
||||
throw new ActionError(RCON_FAIL);
|
||||
}
|
||||
|
||||
function revalidate(): void {
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
export const updateCatalog = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.update-catalog", {});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const updateWordFilter = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.update-word-filter", {});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const updateNavigator = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.update-navigator", {});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const hotelAlertSchema = z.object({
|
||||
message: z.string().trim().min(1).max(512),
|
||||
});
|
||||
|
||||
export const hotelAlert = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.hotel-alert", {
|
||||
message: ctx.data.message.normalize("NFC"),
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const disconnectSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
username: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const disconnectUser = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: disconnectSchema },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.disconnect-user", {
|
||||
userId: ctx.data.userId,
|
||||
username: ctx.data.username.normalize("NFC"),
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const alertUserSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
message: z.string().trim().min(1).max(512),
|
||||
});
|
||||
|
||||
export const alertUser = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: alertUserSchema },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.alert-user", {
|
||||
userId: ctx.data.userId,
|
||||
message: ctx.data.message.normalize("NFC"),
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const forwardUserSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
roomId: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const forwardUser = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.forward-user", ctx.data);
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const giveCreditsSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
credits: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const giveCredits = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.give-credits", {
|
||||
userId: ctx.data.userId,
|
||||
amount: ctx.data.credits,
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const giveAmountSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
amount: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const giveDuckets = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.give-duckets", ctx.data);
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const giveDiamonds = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.give-diamonds", ctx.data);
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const giveBadgeSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
badge: z.string().trim().min(1).max(32),
|
||||
});
|
||||
|
||||
export const giveBadge = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.give-badge", {
|
||||
userId: ctx.data.userId,
|
||||
badge: ctx.data.badge.normalize("NFC"),
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const setMottoSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
motto: z.string().trim().min(1).max(127),
|
||||
});
|
||||
|
||||
export const setMotto = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: setMottoSchema },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.set-motto", {
|
||||
userId: ctx.data.userId,
|
||||
motto: ctx.data.motto.normalize("NFC"),
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const setRankSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
rank: z.coerce.number().int().min(1).max(9999),
|
||||
});
|
||||
|
||||
export const setRank = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: setRankSchema },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.set-rank", ctx.data);
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const executeCommandSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
command: z.string().trim().min(1).max(100),
|
||||
});
|
||||
|
||||
export const executeCommand = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.execute-command", {
|
||||
userId: ctx.data.userId,
|
||||
command: ctx.data.command.normalize("NFC"),
|
||||
});
|
||||
revalidate();
|
||||
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."),
|
||||
});
|
||||
|
||||
export const sendGift = adminAction(
|
||||
{ permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema },
|
||||
async (ctx) => {
|
||||
await runRconMutation(ctx, "rcon.send-gift", {
|
||||
userId: ctx.data.userId,
|
||||
itemId: ctx.data.itemId,
|
||||
message: ctx.data.message.trim().slice(0, 255) || "Here is a gift.",
|
||||
});
|
||||
revalidate();
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -136,7 +136,7 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content legacy wrappers", () => {
|
||||
describe("Content compatibility wrappers", () => {
|
||||
it("delegates article creation and preserves redirect ordering", async () => {
|
||||
await createArticle(
|
||||
form({
|
||||
@@ -151,7 +151,7 @@ describe("Content legacy wrappers", () => {
|
||||
"article.change",
|
||||
expect.objectContaining({ action: "create", title: "Launch" }),
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/articles");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/content/editorial/articles");
|
||||
});
|
||||
|
||||
it("delegates ad creation and keeps the legacy void/redirect contract", async () => {
|
||||
@@ -165,7 +165,7 @@ describe("Content legacy wrappers", () => {
|
||||
"ad.change",
|
||||
expect.objectContaining({ action: "create" }),
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||
expect(redirect).toHaveBeenCalledWith("/ase/content/media/ads");
|
||||
});
|
||||
|
||||
it("delegates media and favicon uploads while retaining public result shapes", async () => {
|
||||
@@ -308,22 +308,14 @@ describe("Content legacy wrappers", () => {
|
||||
for (const path of [
|
||||
"src/actions/admin-ads.ts",
|
||||
"src/actions/admin-articles.ts",
|
||||
"src/actions/admin-banners.ts",
|
||||
"src/actions/admin-email-templates.ts",
|
||||
"src/actions/admin-help.ts",
|
||||
"src/actions/admin-media.ts",
|
||||
"src/actions/admin-nav-menu.ts",
|
||||
"src/actions/admin-photos.ts",
|
||||
"src/actions/admin-tags.ts",
|
||||
"src/actions/admin-theme.ts",
|
||||
"src/actions/admin-writeable-boxes.ts",
|
||||
"src/actions/banners.ts",
|
||||
"src/actions/events.ts",
|
||||
"src/actions/polls.ts",
|
||||
"src/actions/prefixes.ts",
|
||||
"src/actions/save-favicon.ts",
|
||||
"src/actions/save-logo.ts",
|
||||
"src/actions/translations.ts",
|
||||
"src/actions/emulator.ts",
|
||||
]) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
@@ -335,21 +327,4 @@ describe("Content legacy wrappers", () => {
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes the real banner manager through the required compatibility action", () => {
|
||||
const source = readFileSync(
|
||||
"src/app/admin/banners/banners-manager.tsx",
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toContain('from "@/actions/admin-banners"');
|
||||
expect(source).not.toContain('from "@/actions/banners"');
|
||||
});
|
||||
|
||||
it("exports explicit async banner wrappers accepted by Next server actions", () => {
|
||||
const source = readFileSync("src/actions/admin-banners.ts", "utf8");
|
||||
expect(source).not.toMatch(/export\s*\{/u);
|
||||
for (const action of ["createBanner", "updateBanner", "deleteBanner"]) {
|
||||
expect(source).toContain(`export async function ${action}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
type EconomyMutationOperation,
|
||||
executeLegacyEconomyMutation,
|
||||
} from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
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() {
|
||||
return requirePermission(PERMS.CATALOG_EDIT);
|
||||
}
|
||||
|
||||
async function mutate<T>(
|
||||
operation: EconomyMutationOperation,
|
||||
input: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const staff = await guard();
|
||||
const snapshot = await executeLegacyEconomyMutation(staff, operation, input);
|
||||
return snapshot.output as T;
|
||||
}
|
||||
|
||||
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 {
|
||||
return actionOk(
|
||||
await mutate<FixSpriteResult>("maintenance.fix-sprite-ids"),
|
||||
);
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fixCatalogOffersAction(): Promise<
|
||||
ActionResult<FixOfferResult>
|
||||
> {
|
||||
try {
|
||||
return actionOk(await mutate<FixOfferResult>("maintenance.fix-offers"));
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconcileIdsAction(): Promise<
|
||||
ActionResult<ReconcileResult>
|
||||
> {
|
||||
try {
|
||||
return actionOk(await mutate<ReconcileResult>("maintenance.reconcile-ids"));
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDuplicateItemsBaseAction(): Promise<
|
||||
ActionResult<DedupResult>
|
||||
> {
|
||||
try {
|
||||
return actionOk(await mutate<DedupResult>("maintenance.remove-duplicates"));
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewAlignIdsAction(): Promise<
|
||||
ActionResult<AlignResult>
|
||||
> {
|
||||
try {
|
||||
return actionOk(
|
||||
await mutate<AlignResult>("maintenance.align-ids", { apply: false }),
|
||||
);
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyAlignIdsAction(): Promise<
|
||||
ActionResult<AlignResult>
|
||||
> {
|
||||
try {
|
||||
return actionOk(
|
||||
await mutate<AlignResult>("maintenance.align-ids", { apply: true }),
|
||||
);
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fixEverythingAction(input?: {
|
||||
dedupePages?: boolean;
|
||||
}): Promise<ActionResult<maintenance.FixAllResult>> {
|
||||
try {
|
||||
return actionOk(
|
||||
await mutate<maintenance.FixAllResult>(
|
||||
"maintenance.fix-everything",
|
||||
input ?? {},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"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 });
|
||||
},
|
||||
);
|
||||
@@ -1,29 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const updateSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export const updateItemsBase = adminAction(
|
||||
{ permission: PERMS.CATALOG_EDIT, schema: updateSchema },
|
||||
async (context) => {
|
||||
const { id, fields } = context.data;
|
||||
await executeLegacyEconomyMutation(
|
||||
{ id: Number(context.session.user.id) },
|
||||
"items-base.update",
|
||||
{ id, fields },
|
||||
);
|
||||
revalidatePath("/admin/items");
|
||||
revalidatePath(`/admin/items/${id}`);
|
||||
revalidatePath("/admin/catalog");
|
||||
return actionOk({ id });
|
||||
},
|
||||
);
|
||||
@@ -1,68 +0,0 @@
|
||||
"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 } };
|
||||
}
|
||||
@@ -109,14 +109,16 @@ describe("legacy user safe-action wrappers", () => {
|
||||
});
|
||||
|
||||
describe("legacy application and word-filter wrappers", () => {
|
||||
it("keeps tolerant application dismissal and /admin revalidation", async () => {
|
||||
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("/admin/applications");
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/ase/people/staff/applications",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves application and wordfilter IDs above Number.MAX_SAFE_INTEGER", async () => {
|
||||
@@ -130,7 +132,7 @@ describe("legacy application and word-filter wrappers", () => {
|
||||
{ action: "delete", id: "9007199254740993" },
|
||||
]);
|
||||
});
|
||||
it("preserves word-filter ActionResult shapes and /admin revalidation", async () => {
|
||||
it("preserves word-filter ActionResult shapes and ASE revalidation", async () => {
|
||||
await expect(addWord({ word: "spam" })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: { id: "12" },
|
||||
@@ -143,6 +145,8 @@ describe("legacy application and word-filter wrappers", () => {
|
||||
{ action: "add", word: "spam" },
|
||||
{ action: "delete", id: "12" },
|
||||
]);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/ase/people/moderation/word-filter",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -188,18 +188,17 @@ describe("legacy People support and moderation wrappers", () => {
|
||||
{ ticketId: "9007199254740993", status: "reopen" },
|
||||
{ ticketId: "9007199254740993" },
|
||||
]);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/help-tickets");
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/admin/help-tickets/9007199254740993",
|
||||
"/ase/people/support/help-tickets",
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/mod/help-tickets/9007199254740993",
|
||||
"/ase/people/support/help-tickets/9007199254740993",
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/help/tickets/9007199254740993",
|
||||
);
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/users/show/7");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/users/7");
|
||||
});
|
||||
|
||||
it("delegates every CFH and moderation transport action", async () => {
|
||||
|
||||
@@ -79,7 +79,9 @@ describe("legacy wrapper failure compatibility", () => {
|
||||
ok: true,
|
||||
data: {},
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/staff/teams");
|
||||
expect(revalidatePath).toHaveBeenCalledWith(
|
||||
"/ase/people/moderation/word-filter",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
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),
|
||||
});
|
||||
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(),
|
||||
});
|
||||
const deletePrefixSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
const addBlacklistWordSchema = z.object({ word: z.string().min(1).max(100) });
|
||||
const removeBlacklistWordSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
const updatePrefixSettingsSchema = z.object({
|
||||
settings: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
async function run(
|
||||
ctx: {
|
||||
data: Record<string, unknown>;
|
||||
requestId: unknown;
|
||||
session: { user: { id: number } };
|
||||
},
|
||||
operation:
|
||||
| "prefix.change"
|
||||
| "prefix-blacklist.change"
|
||||
| "prefix-settings.update",
|
||||
input: Record<string, unknown>,
|
||||
) {
|
||||
const result = await contentMutationService.execute(
|
||||
{
|
||||
correlationId: String(ctx.requestId),
|
||||
expectedActorId: Number(ctx.session.user.id),
|
||||
legacy: true,
|
||||
},
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (!result.ok && result.error.code === "NOT_FOUND")
|
||||
throw new ActionError("User not found");
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export const createPrefix = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
|
||||
(ctx) => run(ctx, "prefix.change", { action: "create", ...ctx.data }),
|
||||
);
|
||||
export const updatePrefix = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
|
||||
(ctx) => run(ctx, "prefix.change", { action: "update", ...ctx.data }),
|
||||
);
|
||||
export const deletePrefix = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
|
||||
(ctx) => run(ctx, "prefix.change", { action: "delete", ...ctx.data }),
|
||||
);
|
||||
export const addBlacklistWord = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
|
||||
(ctx) => run(ctx, "prefix-blacklist.change", { action: "add", ...ctx.data }),
|
||||
);
|
||||
export const removeBlacklistWord = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
|
||||
(ctx) =>
|
||||
run(ctx, "prefix-blacklist.change", { action: "remove", ...ctx.data }),
|
||||
);
|
||||
export const updatePrefixSettings = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
|
||||
(ctx) => run(ctx, "prefix-settings.update", ctx.data),
|
||||
);
|
||||
@@ -9,7 +9,7 @@ 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(`/admin/rooms/${roomId}/furni`);
|
||||
revalidatePath(`/ase/hotel/rooms/${roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function bulkDeleteRoomItems(input: {
|
||||
@@ -18,7 +18,7 @@ export async function bulkDeleteRoomItems(input: {
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await executeLegacyHotelMutation(staff, "room-item.bulk-delete", input);
|
||||
revalidatePath(`/admin/rooms/${input.roomId}/furni`);
|
||||
revalidatePath(`/ase/hotel/rooms/${input.roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function deleteRoomItem(input: {
|
||||
@@ -27,7 +27,7 @@ export async function deleteRoomItem(input: {
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await executeLegacyHotelMutation(staff, "room-item.delete", input);
|
||||
revalidatePath(`/admin/rooms/${input.roomId}/furni`);
|
||||
revalidatePath(`/ase/hotel/rooms/${input.roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function roomRconAction(input: {
|
||||
@@ -41,7 +41,7 @@ export async function roomRconAction(input: {
|
||||
export async function deleteRoom(input: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
||||
await executeLegacyHotelMutation(staff, "room.delete", input);
|
||||
revalidatePath("/admin/rooms");
|
||||
revalidatePath("/ase/hotel/rooms");
|
||||
}
|
||||
|
||||
export async function updateRoom(input: {
|
||||
@@ -53,5 +53,5 @@ export async function updateRoom(input: {
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||
await executeLegacyHotelMutation(staff, "room.update", input);
|
||||
revalidatePath(`/admin/rooms/${input.id}`);
|
||||
revalidatePath(`/ase/hotel/rooms/${input.id}`);
|
||||
}
|
||||
@@ -107,5 +107,5 @@ export async function deleteFavicon(): Promise<{
|
||||
|
||||
function siteRevalidate(): void {
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/favicon");
|
||||
revalidatePath("/ase/content/brand/favicon");
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function deleteSoundtrack({ id }: { id: number }) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "soundtrack.change", {
|
||||
action: "delete",
|
||||
id,
|
||||
});
|
||||
revalidatePath("/admin/sounds");
|
||||
}
|
||||
|
||||
export async function updateSoundtrack(input: {
|
||||
id: number;
|
||||
name: string;
|
||||
author: string;
|
||||
track: string;
|
||||
length: number;
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "soundtrack.change", {
|
||||
action: "update",
|
||||
...input,
|
||||
});
|
||||
revalidatePath("/admin/sounds");
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { CLIENT_TRANSLATION_FILES } from "@/lib/client-translation-files";
|
||||
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()),
|
||||
});
|
||||
const saveClientTranslationsSchema = z.object({
|
||||
fileId: z.enum(
|
||||
CLIENT_TRANSLATION_FILES.map((file) => file.id) as [string, ...string[]],
|
||||
),
|
||||
data: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export const saveTranslations = adminAction(
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
|
||||
async (ctx) => {
|
||||
const result = await contentMutationService.execute(
|
||||
{
|
||||
correlationId: String(ctx.requestId),
|
||||
expectedActorId: Number(ctx.session.user.id),
|
||||
legacy: true,
|
||||
},
|
||||
"translation.cms.save",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Translation save failed");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const saveClientTranslations = adminAction(
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema },
|
||||
async (ctx) => {
|
||||
const result = await contentMutationService.execute(
|
||||
{
|
||||
correlationId: String(ctx.requestId),
|
||||
expectedActorId: Number(ctx.session.user.id),
|
||||
legacy: true,
|
||||
},
|
||||
"translation.client.save",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Translation save failed");
|
||||
return actionOk({
|
||||
commentsLost: result.data.output?.commentsLost === true,
|
||||
unpatchedKeys: Array.isArray(result.data.output?.unpatchedKeys)
|
||||
? result.data.output.unpatchedKeys
|
||||
: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,53 +0,0 @@
|
||||
"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 });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user