feat(housekeeping): cut over administration to ase

This commit is contained in:
Simo committed 2026-08-30 20:35:22 +02:00
1 parent c9e35cf602
commit 2b8f73a91d
457 files changed
+589 -55936

No files matched your search

+1 -1
View File
@@ -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 () => {
+5 -5
View File
@@ -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();
},
);
+3 -1
View File
@@ -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 () => {
+2 -2
View File
@@ -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");
}
+1 -1
View File
@@ -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");
}
+9 -7
View File
@@ -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");
}
-20
View File
@@ -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)}`);
}
-15
View File
@@ -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");
}
-25
View File
@@ -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);
}
+2 -2
View File
@@ -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");
});
});
+2 -2
View File
@@ -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");
}
-71
View File
@@ -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");
}
-79
View File
@@ -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");
}
+2 -2
View File
@@ -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" }));
+1 -1
View File
@@ -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");
}
+4 -6
View File
@@ -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 });
},
);
+3 -3
View File
@@ -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");
});
});
+7 -7
View File
@@ -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");
}
-26
View File
@@ -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);
}
+1 -1
View File
@@ -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> {
+3 -1
View File
@@ -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 () => {
+1 -1
View File
@@ -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");
}
-17
View File
@@ -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");
}
+1 -1
View File
@@ -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");
});
});
+3 -3
View File
@@ -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
: "";
-38
View File
@@ -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 });
},
);
+1 -1
View File
@@ -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 () => {
+1 -1
View File
@@ -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");
}
+4 -4
View File
@@ -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");
}
+3 -3
View File
@@ -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");
}
+8 -8
View File
@@ -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");
}
+1 -1
View File
@@ -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");
}
+2 -2
View File
@@ -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");
}
-46
View File
@@ -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");
}
-153
View File
@@ -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");
}
-63
View File
@@ -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");
}
+2 -2
View File
@@ -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(
+3 -3
View File
@@ -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");
}
+2 -2
View File
@@ -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");
}
-116
View File
@@ -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");
}
-52
View File
@@ -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");
}
}
+4 -4
View File
@@ -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");
});
+2 -2
View File
@@ -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");
}
+2 -2
View File
@@ -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();
}
-66
View File
@@ -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();
}
-54
View File
@@ -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");
}
-71
View File
@@ -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();
},
);
+1 -2
View File
@@ -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({
+1 -1
View File
@@ -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: {
+6 -6
View File
@@ -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: {} };
}
-283
View File
@@ -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();
},
);
+3 -28
View File
@@ -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}`);
}
});
});
-128
View File
@@ -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);
}
}
-38
View File
@@ -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 });
},
);
-29
View File
@@ -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 });
},
);
-68
View File
@@ -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 } };
}
+8 -4
View File
@@ -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 () => {
+4 -2
View File
@@ -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",
);
});
});
-85
View File
@@ -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),
);
+5 -5
View File
@@ -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}`);
}
+1 -1
View File
@@ -107,5 +107,5 @@ export async function deleteFavicon(): Promise<{
function siteRevalidate(): void {
revalidatePath("/", "layout");
revalidatePath("/admin/favicon");
revalidatePath("/ase/content/brand/favicon");
}
-30
View File
@@ -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");
}
-81
View File
@@ -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
: [],
});
},
);
-53
View File
@@ -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 });
},
);