diff --git a/.env.example b/.env.example index 869e715e..08c5be07 100644 --- a/.env.example +++ b/.env.example @@ -17,9 +17,6 @@ NODE_ENV=production PORT=3002 NEXT_TELEMETRY_DISABLED=1 UV_THREADPOOL_SIZE=16 -# Non-production preview only; production always returns 404. -HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false - # --- HOTEL & URLS --- HOTEL_NAME=EPIC WEB CONTROL APP_URL=http://localhost:3002 diff --git a/.husky/pre-commit b/.husky/pre-commit index 5ee7abd8..f07372ad 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,3 @@ +#!/usr/bin/env sh + pnpm exec lint-staged diff --git a/.husky/pre-push b/.husky/pre-push index 42267220..8da45aae 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,2 +1,4 @@ +#!/usr/bin/env sh + pnpm typecheck pnpm test diff --git a/next.config.ts b/next.config.ts index 618d221b..b2b8cfaa 100644 --- a/next.config.ts +++ b/next.config.ts @@ -34,66 +34,6 @@ const nextConfig: NextConfig = { productionBrowserSourceMaps: false, serverExternalPackages: ["lzma-wasm", "sharp", "pino", "pino-pretty"], - async redirects() { - return [ - { - source: "/admin/import", - destination: "/admin/studio/furni", - permanent: true, - }, - { - source: "/admin/import/badges", - destination: "/admin/studio/badges", - permanent: true, - }, - { - source: "/admin/import/furni", - destination: "/admin/studio/furni", - permanent: true, - }, - { - source: "/admin/import/furni/upload", - destination: "/admin/studio/upload", - permanent: true, - }, - { - source: "/admin/import/clothing", - destination: "/admin/studio/clothing", - permanent: true, - }, - { - source: "/admin/import/effects", - destination: "/admin/studio/effects", - permanent: true, - }, - { - source: "/admin/import/pets", - destination: "/admin/studio/pets", - permanent: true, - }, - { - source: "/admin/import/clone", - destination: "/admin/studio/clone", - permanent: true, - }, - { - source: "/admin/import/sync", - destination: "/admin/studio/sync", - permanent: true, - }, - { - source: "/admin/import/repair-icons", - destination: "/admin/studio/repair-icons", - permanent: true, - }, - { - source: "/admin/import/audit", - destination: "/admin/studio/audit", - permanent: true, - }, - ]; - }, - turbopack: { ignoreIssue: [ { diff --git a/package.json b/package.json index 04f43826..cbaf4078 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,6 @@ "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", - "@hookform/resolvers": "5.9.1", - "@tanstack/react-virtual": "3.14.10", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", @@ -60,7 +58,6 @@ "pino": "10.3.1", "react": "19.2.8", "react-dom": "19.2.8", - "react-hook-form": "7.85.0", "resend": "6.21.0", "server-only": "0.0.1", "sharp": "^0.35.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c628e4a2..1e760957 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,12 +34,6 @@ importers: '@dnd-kit/utilities': specifier: 3.2.2 version: 3.2.2(react@19.2.8) - '@hookform/resolvers': - specifier: 5.9.1 - version: 5.9.1(@standard-schema/spec@1.1.0)(react-hook-form@7.85.0(react@19.2.8))(zod@4.4.3) - '@tanstack/react-virtual': - specifier: 3.14.10 - version: 3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -112,9 +106,6 @@ importers: react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) - react-hook-form: - specifier: 7.85.0 - version: 7.85.0(react@19.2.8) resend: specifier: 6.21.0 version: 6.21.0 @@ -549,84 +540,6 @@ packages: '@formatjs/intl-localematcher@0.8.13': resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==} - '@hookform/resolvers@5.9.1': - resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==} - peerDependencies: - '@sinclair/typebox': '>=0.25.24' - '@standard-schema/spec': ^1.0.0 - '@typeschema/main': '>=0.13.7' - '@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0 - ajv: ^8.12.0 - ajv-errors: ^3.0.0 - ajv-formats: ^2.1.1 - arktype: ^2.0.0 - ata-validator: ^1.2.0 - class-transformer: '>=0.4.0' - class-validator: '>=0.12.0' - computed-types: ^1.0.0 - effect: ^3.10.3 - fluentvalidation-ts: ^3.0.0 - fp-ts: ^2.7.0 - io-ts: ^2.0.0 - joi: ^17.0.0 || ^18.0.0 - nope-validator: '>=0.12.0' - react-hook-form: ^7.55.0 - superstruct: '>=0.12.0' - typanion: ^3.3.2 - valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc' - vest: '>=6.0.0' - yup: ^1.0.0 - zod: ^3.25.0 || ^4.0.0 - peerDependenciesMeta: - '@sinclair/typebox': - optional: true - '@standard-schema/spec': - optional: true - '@typeschema/main': - optional: true - '@vinejs/vine': - optional: true - ajv: - optional: true - ajv-errors: - optional: true - ajv-formats: - optional: true - arktype: - optional: true - ata-validator: - optional: true - class-transformer: - optional: true - class-validator: - optional: true - computed-types: - optional: true - effect: - optional: true - fluentvalidation-ts: - optional: true - fp-ts: - optional: true - io-ts: - optional: true - joi: - optional: true - nope-validator: - optional: true - superstruct: - optional: true - typanion: - optional: true - valibot: - optional: true - vest: - optional: true - yup: - optional: true - zod: - optional: true - '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1484,9 +1397,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@standard-schema/utils@0.3.0': - resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@swc/core-darwin-arm64@1.15.47': resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==} engines: {node: '>=10'} @@ -1685,15 +1595,6 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || >=4.0.0 || insiders' - '@tanstack/react-virtual@3.14.10': - resolution: {integrity: sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - '@tanstack/virtual-core@3.17.8': - resolution: {integrity: sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==} - '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -2658,12 +2559,6 @@ packages: peerDependencies: react: ^19.2.8 - react-hook-form@7.85.0: - resolution: {integrity: sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw==} - engines: {node: '>=18.0.0'} - peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 - react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -3302,14 +3197,6 @@ snapshots: dependencies: '@formatjs/fast-memoize': 3.1.7 - '@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(react-hook-form@7.85.0(react@19.2.8))(zod@4.4.3)': - dependencies: - '@standard-schema/utils': 0.3.0 - react-hook-form: 7.85.0(react@19.2.8) - optionalDependencies: - '@standard-schema/spec': 1.1.0 - zod: 4.4.3 - '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.3': @@ -3873,8 +3760,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@standard-schema/utils@0.3.0': {} - '@swc/core-darwin-arm64@1.15.47': optional: true @@ -4019,14 +3904,6 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.3.3 - '@tanstack/react-virtual@3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@tanstack/virtual-core': 3.17.8 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - - '@tanstack/virtual-core@3.17.8': {} - '@tokenizer/inflate@0.4.1(supports-color@7.2.0)': dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -4862,10 +4739,6 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 - react-hook-form@7.85.0(react@19.2.8): - dependencies: - react: 19.2.8 - react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 diff --git a/scripts/verify-housekeeping-matrix.ts b/scripts/verify-housekeeping-matrix.ts index a994aac3..60e3867e 100644 --- a/scripts/verify-housekeeping-matrix.ts +++ b/scripts/verify-housekeeping-matrix.ts @@ -1,14 +1,23 @@ import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; -import { discoverLegacyPages } from "../src/features/housekeeping/migration/discover-legacy-pages"; +import { + discoverLegacyPages, + recordedLegacyPages, +} from "../src/features/housekeeping/migration/discover-legacy-pages"; import { HOUSEKEEPING_MIGRATION_MATRIX } from "../src/features/housekeeping/migration/matrix"; import { validateMigrationEntries } from "../src/features/housekeeping/migration/validate-matrix"; const discovered = discoverLegacyPages(); +const recorded = recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX); const issues = validateMigrationEntries( - discovered, + recorded, HOUSEKEEPING_MIGRATION_MATRIX, ); +if (discovered.length > 0) { + issues.push( + `legacy UI routes remain after cutover: ${discovered.map((page) => page.legacyPath).join(", ")}`, + ); +} const parityTest = spawnSync( process.execPath, @@ -31,7 +40,7 @@ if (issues.length > 0 || !parityPassed) { process.exitCode = 1; } else { console.log( - `Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${discovered.length} valid`, + `Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${recorded.length} historical rows valid; legacy UI pages present=${discovered.length}`, ); const removed = HOUSEKEEPING_MIGRATION_MATRIX.filter( (row) => row.status === "REMOVED", diff --git a/src/actions/admin-ads.test.ts b/src/actions/admin-ads.test.ts index c36275c0..9569b748 100644 --- a/src/actions/admin-ads.test.ts +++ b/src/actions/admin-ads.test.ts @@ -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 () => { diff --git a/src/actions/admin-ads.ts b/src/actions/admin-ads.ts index e86399de..5632a5c6 100644 --- a/src/actions/admin-ads.ts +++ b/src/actions/admin-ads.ts @@ -31,10 +31,10 @@ export async function createAd(formData: FormData): Promise { 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 { @@ -57,10 +57,10 @@ export async function updateAd(formData: FormData): Promise { 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(); }, ); diff --git a/src/actions/admin-alerts.test.ts b/src/actions/admin-alerts.test.ts index 943c2a57..0f5d61cf 100644 --- a/src/actions/admin-alerts.test.ts +++ b/src/actions/admin-alerts.test.ts @@ -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 () => { diff --git a/src/actions/admin-alerts.ts b/src/actions/admin-alerts.ts index ee34c24e..620f0e8e 100644 --- a/src/actions/admin-alerts.ts +++ b/src/actions/admin-alerts.ts @@ -52,7 +52,7 @@ export async function sendHotelAlert(formData: FormData): Promise { // 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 { } catch { /* ignore */ } - revalidatePath("/admin/alerts"); + revalidatePath("/ase/system/operations/alerts"); } diff --git a/src/actions/admin-applications.ts b/src/actions/admin-applications.ts index c2906f26..c8c7704c 100644 --- a/src/actions/admin-applications.ts +++ b/src/actions/admin-applications.ts @@ -22,5 +22,5 @@ export async function dismissApplication(formData: FormData): Promise { { applicationId, decision: "dismiss" }, ); // Preserve the tolerant legacy action: already-gone/DB failure still refreshes. - revalidatePath("/admin/applications"); + revalidatePath("/ase/people/staff/applications"); } diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 52dd87bb..24eb3e2d 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -39,10 +39,10 @@ export async function createArticle(formData: FormData): Promise { ); 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 { @@ -53,9 +53,10 @@ export async function updateArticle(formData: FormData): Promise { "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 { @@ -66,6 +67,7 @@ export async function deleteArticle(formData: FormData): Promise { "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"); } diff --git a/src/actions/admin-badge-upload.ts b/src/actions/admin-badge-upload.ts deleted file mode 100644 index ba0de0be..00000000 --- a/src/actions/admin-badge-upload.ts +++ /dev/null @@ -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 { - 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)}`); -} diff --git a/src/actions/admin-badges.ts b/src/actions/admin-badges.ts deleted file mode 100644 index fde24920..00000000 --- a/src/actions/admin-badges.ts +++ /dev/null @@ -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 { - const staff = await requirePermission(PERMS.CATALOG_EDIT); - await executeLegacyEconomyMutation(staff, "badge.give", { - userId: formData.get("userId"), - badge: formData.get("code"), - }); - revalidatePath("/admin/badges"); -} diff --git a/src/actions/admin-banners.ts b/src/actions/admin-banners.ts deleted file mode 100644 index 0f3bffad..00000000 --- a/src/actions/admin-banners.ts +++ /dev/null @@ -1,25 +0,0 @@ -"use server"; - -import { - createBanner as createBannerAction, - deleteBanner as deleteBannerAction, - updateBanner as updateBannerAction, -} from "./banners"; - -export async function createBanner( - input: Parameters[0], -) { - return createBannerAction(input); -} - -export async function updateBanner( - input: Parameters[0], -) { - return updateBannerAction(input); -} - -export async function deleteBanner( - input: Parameters[0], -) { - return deleteBannerAction(input); -} diff --git a/src/actions/admin-bans.test.ts b/src/actions/admin-bans.test.ts index 0de0f700..c8844f0d 100644 --- a/src/actions/admin-bans.test.ts +++ b/src/actions/admin-bans.test.ts @@ -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"); }); }); diff --git a/src/actions/admin-bans.ts b/src/actions/admin-bans.ts index 05ef181b..da241f8f 100644 --- a/src/actions/admin-bans.ts +++ b/src/actions/admin-bans.ts @@ -37,7 +37,7 @@ export async function createBan(formData: FormData): Promise { }, ); 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 { @@ -51,5 +51,5 @@ export async function liftBan(formData: FormData): Promise { ); if (!result.ok) throw new Error("Could not lift ban"); } - revalidatePath("/admin/bans"); + revalidatePath("/ase/people/moderation/bans"); } diff --git a/src/actions/admin-email-templates.ts b/src/actions/admin-email-templates.ts deleted file mode 100644 index a01d5c57..00000000 --- a/src/actions/admin-email-templates.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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"); -} diff --git a/src/actions/admin-emulator.ts b/src/actions/admin-emulator.ts deleted file mode 100644 index 88660daa..00000000 --- a/src/actions/admin-emulator.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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"); -} diff --git a/src/actions/admin-guilds.test.ts b/src/actions/admin-guilds.test.ts index a8f0ce8e..fad8302b 100644 --- a/src/actions/admin-guilds.test.ts +++ b/src/actions/admin-guilds.test.ts @@ -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" })); diff --git a/src/actions/admin-guilds.ts b/src/actions/admin-guilds.ts index 9ed33159..51061d89 100644 --- a/src/actions/admin-guilds.ts +++ b/src/actions/admin-guilds.ts @@ -24,5 +24,5 @@ export async function disbandGuild(formData: FormData): Promise { if (result.error.code === "NOT_FOUND") return; throw new Error("Could not disband guild"); } - revalidatePath("/admin/guilds"); + revalidatePath("/ase/people/community/guilds"); } diff --git a/src/actions/admin-help-tickets.ts b/src/actions/admin-help-tickets.ts index df93ef9f..79192d4c 100644 --- a/src/actions/admin-help-tickets.ts +++ b/src/actions/admin-help-tickets.ts @@ -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 }); }, ); diff --git a/src/actions/admin-help.test.ts b/src/actions/admin-help.test.ts index d824417e..bf7bc9db 100644 --- a/src/actions/admin-help.test.ts +++ b/src/actions/admin-help.test.ts @@ -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"); }); }); diff --git a/src/actions/admin-help.ts b/src/actions/admin-help.ts index 280652ed..e0647115 100644 --- a/src/actions/admin-help.ts +++ b/src/actions/admin-help.ts @@ -39,13 +39,13 @@ export async function createHelpQuestion(formData: FormData): Promise { { 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 { @@ -60,10 +60,10 @@ export async function updateHelpQuestion(formData: FormData): Promise { { 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 { @@ -75,5 +75,5 @@ export async function deleteHelpQuestion(formData: FormData): Promise { "help-question.change", { action: "delete", id }, ); - redirect("/admin/help-questions"); + redirect("/ase/content/help/questions"); } diff --git a/src/actions/admin-housekeeping.ts b/src/actions/admin-housekeeping.ts deleted file mode 100644 index d2bed7d6..00000000 --- a/src/actions/admin-housekeeping.ts +++ /dev/null @@ -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 { - 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); -} diff --git a/src/actions/admin-ip.ts b/src/actions/admin-ip.ts index a69dee37..86a8ec8c 100644 --- a/src/actions/admin-ip.ts +++ b/src/actions/admin-ip.ts @@ -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 { diff --git a/src/actions/admin-maintenance.test.ts b/src/actions/admin-maintenance.test.ts index 27bbd97f..4c0f9bbc 100644 --- a/src/actions/admin-maintenance.test.ts +++ b/src/actions/admin-maintenance.test.ts @@ -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 () => { diff --git a/src/actions/admin-maintenance.ts b/src/actions/admin-maintenance.ts index ab947593..78218426 100644 --- a/src/actions/admin-maintenance.ts +++ b/src/actions/admin-maintenance.ts @@ -60,5 +60,5 @@ export async function saveMaintenance(formData: FormData): Promise { }, ); if (!result.ok) throw new Error(result.error.messageKey); - revalidatePath("/admin/maintenance"); + revalidatePath("/ase/system/operations/maintenance"); } diff --git a/src/actions/admin-marketplace.ts b/src/actions/admin-marketplace.ts deleted file mode 100644 index fc116f44..00000000 --- a/src/actions/admin-marketplace.ts +++ /dev/null @@ -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 { - const staff = await requirePermissionRateLimited(PERMS.SHOP_EDIT); - await executeLegacyEconomyMutation(staff, "marketplace.cancel", { - id: formData.get("id"), - }); - revalidatePath("/admin/marketplace"); -} diff --git a/src/actions/admin-media.test.ts b/src/actions/admin-media.test.ts index 7e79441b..85f8ff5b 100644 --- a/src/actions/admin-media.test.ts +++ b/src/actions/admin-media.test.ts @@ -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"); }); }); diff --git a/src/actions/admin-media.ts b/src/actions/admin-media.ts index d6a71688..32328d7b 100644 --- a/src/actions/admin-media.ts +++ b/src/actions/admin-media.ts @@ -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 { { 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 : ""; diff --git a/src/actions/admin-nav-menu.ts b/src/actions/admin-nav-menu.ts deleted file mode 100644 index fce650d3..00000000 --- a/src/actions/admin-nav-menu.ts +++ /dev/null @@ -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 }); - }, -); diff --git a/src/actions/admin-photos.test.ts b/src/actions/admin-photos.test.ts index 048a859f..b3149fd1 100644 --- a/src/actions/admin-photos.test.ts +++ b/src/actions/admin-photos.test.ts @@ -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 () => { diff --git a/src/actions/admin-photos.ts b/src/actions/admin-photos.ts index 76f9bfe5..b6e41866 100644 --- a/src/actions/admin-photos.ts +++ b/src/actions/admin-photos.ts @@ -18,6 +18,6 @@ export async function deletePhoto(formData: FormData): Promise { "photo.delete", { id }, ); - revalidatePath("/admin/photos"); + revalidatePath("/ase/content/media/photos"); revalidatePath("/photos"); } diff --git a/src/actions/admin-radio-api-keys.ts b/src/actions/admin-radio-api-keys.ts index 98d67c3e..6aaf526f 100644 --- a/src/actions/admin-radio-api-keys.ts +++ b/src/actions/admin-radio-api-keys.ts @@ -19,8 +19,8 @@ export async function createApiKey(formData: FormData): Promise { 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 { @@ -28,7 +28,7 @@ export async function toggleApiKey(formData: FormData): Promise { 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 { @@ -36,5 +36,5 @@ export async function deleteApiKey(formData: FormData): Promise { await executeLegacyHotelMutation(staff, "radio.api-key.delete", { id: text(formData, "id"), }); - revalidatePath("/admin/radio/api-keys"); + revalidatePath("/ase/hotel/radio/api-keys"); } diff --git a/src/actions/admin-radio-autodj.ts b/src/actions/admin-radio-autodj.ts index 4867e567..79f9c268 100644 --- a/src/actions/admin-radio-autodj.ts +++ b/src/actions/admin-radio-autodj.ts @@ -27,7 +27,7 @@ export async function createTrack(formData: FormData): Promise { 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 { @@ -36,7 +36,7 @@ export async function toggleTrack(formData: FormData): Promise { id: text(formData, "id"), isActive: enabled(formData, "isActive"), }); - revalidatePath("/admin/radio/autodj"); + revalidatePath("/ase/hotel/radio/autodj"); } export async function deleteTrack(formData: FormData): Promise { @@ -44,5 +44,5 @@ export async function deleteTrack(formData: FormData): Promise { await executeLegacyHotelMutation(staff, "radio.autodj.delete", { id: text(formData, "id"), }); - revalidatePath("/admin/radio/autodj"); + revalidatePath("/ase/hotel/radio/autodj"); } diff --git a/src/actions/admin-radio-extra.ts b/src/actions/admin-radio-extra.ts index a851aebd..8c1a27a8 100644 --- a/src/actions/admin-radio-extra.ts +++ b/src/actions/admin-radio-extra.ts @@ -28,7 +28,7 @@ export async function saveRadioSetting(formData: FormData): Promise { comment: text(formData, "comment") || undefined, }); siteSettings.reload(); - revalidatePath("/admin/radio/settings"); + revalidatePath("/ase/hotel/radio/settings"); } export async function saveRadioSettings(formData: FormData): Promise { @@ -42,7 +42,7 @@ export async function saveRadioSettings(formData: FormData): Promise { 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 { "radio.banner.create", bannerInput(formData), ); - revalidatePath("/admin/radio/banners"); + revalidatePath("/ase/hotel/radio/banners"); } export async function updateRadioBanner(formData: FormData): Promise { @@ -71,7 +71,7 @@ export async function updateRadioBanner(formData: FormData): Promise { id: text(formData, "id"), ...bannerInput(formData), }); - revalidatePath("/admin/radio/banners"); + revalidatePath("/ase/hotel/radio/banners"); } export async function deleteRadioBanner(formData: FormData): Promise { @@ -79,7 +79,7 @@ export async function deleteRadioBanner(formData: FormData): Promise { 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 { "radio.rank.create", rankInput(formData), ); - revalidatePath("/admin/radio/ranks"); + revalidatePath("/ase/hotel/radio/ranks"); } export async function updateRadioRank(formData: FormData): Promise { @@ -107,7 +107,7 @@ export async function updateRadioRank(formData: FormData): Promise { id: text(formData, "id"), ...rankInput(formData), }); - revalidatePath("/admin/radio/ranks"); + revalidatePath("/ase/hotel/radio/ranks"); } export async function deleteRadioRank(formData: FormData): Promise { @@ -115,5 +115,5 @@ export async function deleteRadioRank(formData: FormData): Promise { await executeLegacyHotelMutation(staff, "radio.rank.delete", { id: text(formData, "id"), }); - revalidatePath("/admin/radio/ranks"); + revalidatePath("/ase/hotel/radio/ranks"); } diff --git a/src/actions/admin-radio-moderation.ts b/src/actions/admin-radio-moderation.ts index 9c26c407..dffbd078 100644 --- a/src/actions/admin-radio-moderation.ts +++ b/src/actions/admin-radio-moderation.ts @@ -10,5 +10,5 @@ export async function deleteShout(formData: FormData): Promise { await executeLegacyHotelMutation(staff, "radio.shout.delete", { id: String(formData.get("id") ?? "").trim(), }); - revalidatePath("/admin/radio/moderation"); + revalidatePath("/ase/hotel/radio/moderation"); } diff --git a/src/actions/admin-radio-points.ts b/src/actions/admin-radio-points.ts index 345da209..e02275a2 100644 --- a/src/actions/admin-radio-points.ts +++ b/src/actions/admin-radio-points.ts @@ -35,6 +35,6 @@ export async function savePoints(formData: FormData): Promise { ), }); siteSettings.reload(); - revalidatePath("/admin/radio/points"); - redirect("/admin/radio/points?saved=1"); + revalidatePath("/ase/hotel/radio/points"); + redirect("/ase/hotel/radio/points?saved=1"); } diff --git a/src/actions/admin-rare-values.ts b/src/actions/admin-rare-values.ts deleted file mode 100644 index 8dc59922..00000000 --- a/src/actions/admin-rare-values.ts +++ /dev/null @@ -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 { - return Object.fromEntries(formData.entries()); -} - -export async function createCategory(formData: FormData): Promise { - 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 { - 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 { - 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 { - const staff = await requirePermission(PERMS.SHOP_EDIT); - await executeLegacyEconomyMutation(staff, "rare-value.change", { - action: "delete", - ...formInput(formData), - }); - revalidatePath("/admin/rare-values"); -} diff --git a/src/actions/admin-settings.ts b/src/actions/admin-settings.ts deleted file mode 100644 index f132c40e..00000000 --- a/src/actions/admin-settings.ts +++ /dev/null @@ -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, -): 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 { - 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 { - 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 { - 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 { - 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"); -} diff --git a/src/actions/admin-shop.ts b/src/actions/admin-shop.ts deleted file mode 100644 index 3b208f9e..00000000 --- a/src/actions/admin-shop.ts +++ /dev/null @@ -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 { - return Object.fromEntries(formData.entries()); -} - -export async function createShopArticle(formData: FormData): Promise { - 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 { - 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 { - 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"); -} diff --git a/src/actions/admin-tags.test.ts b/src/actions/admin-tags.test.ts index 87562053..4961fa18 100644 --- a/src/actions/admin-tags.test.ts +++ b/src/actions/admin-tags.test.ts @@ -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( diff --git a/src/actions/admin-tags.ts b/src/actions/admin-tags.ts index 235de3e9..1f1499f0 100644 --- a/src/actions/admin-tags.ts +++ b/src/actions/admin-tags.ts @@ -30,7 +30,7 @@ export async function createTag(formData: FormData): Promise { "tag.change", { action: "create", ...input }, ); - revalidatePath("/admin/tags"); + revalidatePath("/ase/content/editorial/tags"); } export async function updateTag(formData: FormData): Promise { @@ -44,7 +44,7 @@ export async function updateTag(formData: FormData): Promise { "tag.change", { action: "update", id, ...input }, ); - revalidatePath("/admin/tags"); + revalidatePath("/ase/content/editorial/tags"); } export async function deleteTag(formData: FormData): Promise { @@ -56,5 +56,5 @@ export async function deleteTag(formData: FormData): Promise { "tag.change", { action: "delete", id }, ); - revalidatePath("/admin/tags"); + revalidatePath("/ase/content/editorial/tags"); } diff --git a/src/actions/admin-teams.ts b/src/actions/admin-teams.ts index 92583625..2cc60000 100644 --- a/src/actions/admin-teams.ts +++ b/src/actions/admin-teams.ts @@ -33,7 +33,7 @@ export async function createTeam(formData: FormData): Promise { }, ); 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 { @@ -49,5 +49,5 @@ export async function deleteTeam(formData: FormData): Promise { if (!result.ok && result.error.code !== "NOT_FOUND") { throw new Error("Could not delete team"); } - revalidatePath("/admin/teams"); + revalidatePath("/ase/people/staff/teams"); } diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts deleted file mode 100644 index a0a0f578..00000000 --- a/src/actions/admin-theme.ts +++ /dev/null @@ -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 { - 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, -) { - const staff = await requirePermission(PERMS.SETTINGS_EDIT); - return contentMutationService.execute( - createContentMutationInvocation(staff, createCorrelationId()), - operation, - input, - ); -} - -export async function saveTheme(formData: FormData): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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"); -} diff --git a/src/actions/admin-vouchers.ts b/src/actions/admin-vouchers.ts deleted file mode 100644 index fcce94a3..00000000 --- a/src/actions/admin-vouchers.ts +++ /dev/null @@ -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> { - 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 { - 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"); - } -} diff --git a/src/actions/admin-vpn.test.ts b/src/actions/admin-vpn.test.ts index 6f94ea50..e6ec7245 100644 --- a/src/actions/admin-vpn.test.ts +++ b/src/actions/admin-vpn.test.ts @@ -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"); }); diff --git a/src/actions/admin-vpn.ts b/src/actions/admin-vpn.ts index 1f0c99a5..b0c6c164 100644 --- a/src/actions/admin-vpn.ts +++ b/src/actions/admin-vpn.ts @@ -35,7 +35,7 @@ export async function saveVpn(formData: FormData): Promise { .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"); } diff --git a/src/actions/admin-wordfilter.ts b/src/actions/admin-wordfilter.ts index ce6da022..e9da1adc 100644 --- a/src/actions/admin-wordfilter.ts +++ b/src/actions/admin-wordfilter.ts @@ -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 { 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(); } diff --git a/src/actions/admin-writeable-boxes.ts b/src/actions/admin-writeable-boxes.ts deleted file mode 100644 index f8643324..00000000 --- a/src/actions/admin-writeable-boxes.ts +++ /dev/null @@ -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 { - 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 { - if (await executeBox(formData, "create")) refreshBoxes(); -} -export async function updateBox(formData: FormData): Promise { - if (await executeBox(formData, "update")) refreshBoxes(); -} -export async function deleteBox(formData: FormData): Promise { - if (await executeBox(formData, "delete")) refreshBoxes(); -} -export async function toggleBox(formData: FormData): Promise { - if (await executeBox(formData, "toggle")) refreshBoxes(); -} diff --git a/src/actions/badges.ts b/src/actions/badges.ts deleted file mode 100644 index e0f947ba..00000000 --- a/src/actions/badges.ts +++ /dev/null @@ -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"); -} diff --git a/src/actions/banners.ts b/src/actions/banners.ts deleted file mode 100644 index 60685b68..00000000 --- a/src/actions/banners.ts +++ /dev/null @@ -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; - 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(); - }, -); diff --git a/src/actions/catalog-bc.ts b/src/actions/catalog-bc.ts index a76256de..902de0ba 100644 --- a/src/actions/catalog-bc.ts +++ b/src/actions/catalog-bc.ts @@ -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({ diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index ff7ffe56..5dfed91b 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -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: { diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index 99315e1d..34512537 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -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: {} }; } diff --git a/src/actions/commandocentrum.ts b/src/actions/commandocentrum.ts deleted file mode 100644 index bb580dfd..00000000 --- a/src/actions/commandocentrum.ts +++ /dev/null @@ -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, -): 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, - operation: SystemMutationOperation, - input: unknown, -): Promise { - 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(); - }, -); diff --git a/src/actions/content-legacy-parity.test.ts b/src/actions/content-legacy-parity.test.ts index a2a34bbe..d0e09022 100644 --- a/src/actions/content-legacy-parity.test.ts +++ b/src/actions/content-legacy-parity.test.ts @@ -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}`); - } - }); }); diff --git a/src/actions/furni-maintenance.ts b/src/actions/furni-maintenance.ts deleted file mode 100644 index 662da4e1..00000000 --- a/src/actions/furni-maintenance.ts +++ /dev/null @@ -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( - operation: EconomyMutationOperation, - input: Record = {}, -): Promise { - 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 -> { - try { - return actionOk( - await mutate("maintenance.fix-sprite-ids"), - ); - } catch (e) { - return handleActionError(e); - } -} - -export async function fixCatalogOffersAction(): Promise< - ActionResult -> { - try { - return actionOk(await mutate("maintenance.fix-offers")); - } catch (e) { - return handleActionError(e); - } -} - -export async function reconcileIdsAction(): Promise< - ActionResult -> { - try { - return actionOk(await mutate("maintenance.reconcile-ids")); - } catch (e) { - return handleActionError(e); - } -} - -export async function removeDuplicateItemsBaseAction(): Promise< - ActionResult -> { - try { - return actionOk(await mutate("maintenance.remove-duplicates")); - } catch (e) { - return handleActionError(e); - } -} - -export async function previewAlignIdsAction(): Promise< - ActionResult -> { - try { - return actionOk( - await mutate("maintenance.align-ids", { apply: false }), - ); - } catch (e) { - return handleActionError(e); - } -} - -export async function applyAlignIdsAction(): Promise< - ActionResult -> { - try { - return actionOk( - await mutate("maintenance.align-ids", { apply: true }), - ); - } catch (e) { - return handleActionError(e); - } -} - -export async function fixEverythingAction(input?: { - dedupePages?: boolean; -}): Promise> { - try { - return actionOk( - await mutate( - "maintenance.fix-everything", - input ?? {}, - ), - ); - } catch (e) { - return handleActionError(e); - } -} diff --git a/src/actions/import-furni.ts b/src/actions/import-furni.ts deleted file mode 100644 index f41b1a28..00000000 --- a/src/actions/import-furni.ts +++ /dev/null @@ -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 }); - }, -); diff --git a/src/actions/items-base.ts b/src/actions/items-base.ts deleted file mode 100644 index 9e720744..00000000 --- a/src/actions/items-base.ts +++ /dev/null @@ -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 }); - }, -); diff --git a/src/actions/multi-account-detect.ts b/src/actions/multi-account-detect.ts deleted file mode 100644 index 605bf101..00000000 --- a/src/actions/multi-account-detect.ts +++ /dev/null @@ -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 } }; -} diff --git a/src/actions/people-shared-wrappers.test.ts b/src/actions/people-shared-wrappers.test.ts index 6b7807f4..c14565e3 100644 --- a/src/actions/people-shared-wrappers.test.ts +++ b/src/actions/people-shared-wrappers.test.ts @@ -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", + ); }); }); diff --git a/src/actions/people-support-moderation-wrappers.test.ts b/src/actions/people-support-moderation-wrappers.test.ts index 858a0753..d55c401b 100644 --- a/src/actions/people-support-moderation-wrappers.test.ts +++ b/src/actions/people-support-moderation-wrappers.test.ts @@ -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 () => { diff --git a/src/actions/people-wrapper-errors.test.ts b/src/actions/people-wrapper-errors.test.ts index f4d3103b..696d5f07 100644 --- a/src/actions/people-wrapper-errors.test.ts +++ b/src/actions/people-wrapper-errors.test.ts @@ -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", + ); }); }); diff --git a/src/actions/prefixes.ts b/src/actions/prefixes.ts deleted file mode 100644 index a1e44807..00000000 --- a/src/actions/prefixes.ts +++ /dev/null @@ -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; - requestId: unknown; - session: { user: { id: number } }; - }, - operation: - | "prefix.change" - | "prefix-blacklist.change" - | "prefix-settings.update", - input: Record, -) { - 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), -); diff --git a/src/actions/rooms.ts b/src/actions/rooms.ts index 231af9af..4662c729 100644 --- a/src/actions/rooms.ts +++ b/src/actions/rooms.ts @@ -9,7 +9,7 @@ export async function updateRoomItem(payload: Record) { 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}`); } diff --git a/src/actions/save-favicon.ts b/src/actions/save-favicon.ts index 63178019..b0c828c8 100644 --- a/src/actions/save-favicon.ts +++ b/src/actions/save-favicon.ts @@ -107,5 +107,5 @@ export async function deleteFavicon(): Promise<{ function siteRevalidate(): void { revalidatePath("/", "layout"); - revalidatePath("/admin/favicon"); + revalidatePath("/ase/content/brand/favicon"); } diff --git a/src/actions/soundtracks.ts b/src/actions/soundtracks.ts deleted file mode 100644 index 949831b2..00000000 --- a/src/actions/soundtracks.ts +++ /dev/null @@ -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"); -} diff --git a/src/actions/translations.ts b/src/actions/translations.ts deleted file mode 100644 index 919f0a16..00000000 --- a/src/actions/translations.ts +++ /dev/null @@ -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 - : [], - }); - }, -); diff --git a/src/actions/watch.ts b/src/actions/watch.ts deleted file mode 100644 index 464a76a8..00000000 --- a/src/actions/watch.ts +++ /dev/null @@ -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 }); - }, -); diff --git a/src/app/(site)/layout.tsx b/src/app/(site)/layout.tsx index c151250f..377029f8 100644 --- a/src/app/(site)/layout.tsx +++ b/src/app/(site)/layout.tsx @@ -8,7 +8,7 @@ import { TopHeader } from "@/components/top-header"; import { auth } from "@/lib/auth"; /** - * Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`, + * Public site chrome. Route group `(site)` keeps this off `/ase` and `/client`, * so housekeeping is never constrained by the public max-w-7xl grid. */ export default async function SiteLayout({ diff --git a/src/app/admin/achievements/page.tsx b/src/app/admin/achievements/page.tsx deleted file mode 100644 index 9c7db09f..00000000 --- a/src/app/admin/achievements/page.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { asc } from "drizzle-orm"; -import { redirect } from "next/navigation"; -import { getTranslations } from "next-intl/server"; -import { StatusCard } from "@/components/admin/dashboard"; -import { Achievements, db } from "@/lib/db"; -import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; - -type Achievement = { - name: string; - category: string; - level: number; - rewardAmount: number; - rewardType: number; - points: number | null; - progressNeeded: number; -}; - -export default async function AdminAchievements() { - const { session, permissions } = await getAdminContext(); - if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) { - redirect("/admin"); - } - - const t = await getTranslations("pages.admin.achievements"); - - let achievements: Achievement[]; - try { - achievements = await db - .select({ - name: Achievements.name, - category: Achievements.category, - level: Achievements.level, - rewardAmount: Achievements.rewardAmount, - rewardType: Achievements.rewardType, - points: Achievements.points, - progressNeeded: Achievements.progressNeeded, - }) - .from(Achievements) - .orderBy( - asc(Achievements.category), - asc(Achievements.name), - asc(Achievements.level), - ); - } catch { - achievements = []; - } - - const groups = new Map(); - for (const a of achievements) { - const list = groups.get(a.category) ?? []; - list.push(a); - groups.set(a.category, list); - } - - const distinctNames = new Set(achievements.map((a) => a.name)).size; - - return ( -
-
- - - -
- - {groups.size === 0 ? ( -
{t("noAchievements")}
- ) : ( - [...groups.entries()].map(([category, rows]) => ( -
-

- {category}{" "} - - {rows.length} - -

-
- - - - - - - - - - - - - {rows.map((a) => ( - - - - - - - - - ))} - -
{t("colName")}{t("colLevel")}{t("colProgress")}{t("colRewardType")}{t("colRewardAmount")}{t("colPoints")}
- {a.name} - {a.level}{a.progressNeeded}{a.rewardType}{a.rewardAmount}{a.points ?? 0}
-
-
- )) - )} -
- ); -} diff --git a/src/app/admin/ads/[id]/page.tsx b/src/app/admin/ads/[id]/page.tsx deleted file mode 100644 index 8658a0df..00000000 --- a/src/app/admin/ads/[id]/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { eq } from "drizzle-orm"; -import { Megaphone } from "lucide-react"; -import { notFound, redirect } from "next/navigation"; -import { getTranslations } from "next-intl/server"; -import { updateAd } from "@/actions/admin-ads"; -import Link from "@/components/link"; -import { Button } from "@/components/ui/button"; -import { db, WebsiteAds } from "@/lib/db"; -import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { EditAdDeleteButton } from "../edit-ad-delete-button"; - -export default async function EditAd({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { session, permissions } = await getAdminContext(); - if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { - redirect("/admin"); - } - - const { id } = await params; - let ad: typeof WebsiteAds.$inferSelect | null = null; - try { - const [row] = await db - .select() - .from(WebsiteAds) - .where(eq(WebsiteAds.id, BigInt(id))) - .limit(1); - ad = row ?? null; - } catch { - notFound(); - } - if (!ad) notFound(); - - const t = await getTranslations("pages.admin.ads"); - - return ( -
-
-
- -
-
-

- {t("edit")} #{String(ad.id)} -

-

- ← {t("title")} -

-
-
-
- - - -
-
- -
-
- ); -} diff --git a/src/app/admin/ads/ads-table.tsx b/src/app/admin/ads/ads-table.tsx deleted file mode 100644 index 5d906b7a..00000000 --- a/src/app/admin/ads/ads-table.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { Trash2 } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { deleteAd } from "@/actions/admin-ads"; -import { useConfirmDialog } from "@/components/admin/confirm-dialog"; -import Link from "@/components/link"; -import { Button } from "@/components/ui/button"; -import { useServerAction } from "@/hooks/use-server-action"; - -export type AdRow = { - id: string; - image: string; - createdAt: string | null; -}; - -export function AdsTable({ ads }: { ads: AdRow[] }) { - const t = useTranslations("pages.admin.ads"); - const { run, isPending } = useServerAction(); - const { confirm, dialog: confirmDialog } = useConfirmDialog(); - - async function handleDelete(row: AdRow) { - const ok = await confirm({ - title: t("delete"), - description: t("confirmDelete"), - confirmLabel: t("delete"), - cancelLabel: t("form.cancel"), - }); - if (!ok) return; - run(() => deleteAd({ id: row.id }), { - successMessage: t("deleteSuccess"), - errorMessage: t("deleteError"), - }); - } - - return ( - <> - {confirmDialog} - - - - - - - - - - - {ads.map((ad) => ( - - - - - - - ))} - -
{t("colPreview")}{t("colImage")}{t("colCreated")}{t("colActions")}
- {/* eslint-disable-next-line @next/next/no-img-element */} - - - {ad.image} - - {ad.createdAt ?? "—"} - -
- {t("edit")} - -
-
- - ); -} diff --git a/src/app/admin/ads/edit-ad-delete-button.tsx b/src/app/admin/ads/edit-ad-delete-button.tsx deleted file mode 100644 index 15fd7c92..00000000 --- a/src/app/admin/ads/edit-ad-delete-button.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import { Trash2 } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { deleteAd } from "@/actions/admin-ads"; -import { useConfirmDialog } from "@/components/admin/confirm-dialog"; -import { Button } from "@/components/ui/button"; -import { useServerAction } from "@/hooks/use-server-action"; - -export function EditAdDeleteButton({ id }: { id: string }) { - const t = useTranslations("pages.admin.ads"); - const { run, isPending } = useServerAction(); - const { confirm, dialog: confirmDialog } = useConfirmDialog(); - - async function handleDelete() { - const ok = await confirm({ - title: t("delete"), - description: t("confirmDelete"), - confirmLabel: t("delete"), - cancelLabel: t("form.cancel"), - }); - if (!ok) return; - run(() => deleteAd({ id }), { - successMessage: t("deleteSuccess"), - errorMessage: t("deleteError"), - redirectTo: "/admin/ads", - }); - } - - return ( - <> - {confirmDialog} - - - ); -} diff --git a/src/app/admin/ads/new/page.tsx b/src/app/admin/ads/new/page.tsx deleted file mode 100644 index 1c7b4984..00000000 --- a/src/app/admin/ads/new/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Megaphone } from "lucide-react"; -import { redirect } from "next/navigation"; -import { createAd } from "@/actions/admin-ads"; -import Link from "@/components/link"; -import { Button } from "@/components/ui/button"; -import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; - -export default async function NewAd() { - const { session, permissions } = await getAdminContext(); - if (!canAccess(permissions, PERMS.PAGES_EDIT, session.user.rank)) { - redirect("/admin"); - } - - return ( -
-
-
- -
-
-

- New advertisement -

-

- ← Advertisements -

-
-
-
- - -
-
- ); -} diff --git a/src/app/admin/ads/page.tsx b/src/app/admin/ads/page.tsx deleted file mode 100644 index 6249ddce..00000000 --- a/src/app/admin/ads/page.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { desc } from "drizzle-orm"; -import { redirect } from "next/navigation"; -import { getTranslations } from "next-intl/server"; -import { StatusCard } from "@/components/admin/dashboard"; -import Link from "@/components/link"; -import { Button } from "@/components/ui/button"; -import { db, WebsiteAds } from "@/lib/db"; -import { formatDate } from "@/lib/format-date"; -import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { AdsTable } from "./ads-table"; - -export default async function AdminAds() { - const { session, permissions } = await getAdminContext(); - if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { - redirect("/admin"); - } - - const t = await getTranslations("pages.admin.ads"); - let ads: (typeof WebsiteAds.$inferSelect)[] = []; - try { - ads = await db - .select() - .from(WebsiteAds) - .orderBy(desc(WebsiteAds.id)) - .limit(500); - } catch { - ads = []; - } - - const latest = ads[0]?.createdAt ? formatDate(ads[0].createdAt, "date") : "—"; - - return ( -
-
- -
- -
- - -
- -
-

{t("allAds")}

- {ads.length === 0 ? ( -
{t("noAds")}
- ) : ( - ({ - id: String(ad.id), - image: ad.image, - createdAt: ad.createdAt ? formatDate(ad.createdAt, "date") : null, - }))} - /> - )} -
-
- ); -} diff --git a/src/app/admin/alerts/loading.tsx b/src/app/admin/alerts/loading.tsx deleted file mode 100644 index f9eecfe7..00000000 --- a/src/app/admin/alerts/loading.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../loading"; diff --git a/src/app/admin/alerts/page.tsx b/src/app/admin/alerts/page.tsx deleted file mode 100644 index e4f6c2df..00000000 --- a/src/app/admin/alerts/page.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { desc } from "drizzle-orm"; -import { redirect } from "next/navigation"; -import { getTranslations } from "next-intl/server"; -import { markAllAlertsRead, sendHotelAlert } from "@/actions/admin-alerts"; -import { StatusCard } from "@/components/admin/dashboard"; -import { Button } from "@/components/ui/button"; -import { AlertLogs, db } from "@/lib/db"; -import { formatDate } from "@/lib/format-date"; -import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; - -const CRITICAL = new Set(["critical", "error", "danger"]); -const WARNING = new Set(["warning", "warn"]); - -function severityColor(severity: string): string { - switch (severity.toLowerCase()) { - case "critical": - case "error": - case "danger": - return "var(--color-danger)"; - case "warning": - case "warn": - return "var(--admin-accent)"; - case "success": - return "var(--color-secondary)"; - case "info": - case "notice": - return "var(--color-accent)"; - default: - return "var(--admin-text-muted)"; - } -} - -function Badge({ label, color }: { label: string; color: string }) { - return ( - - {label} - - ); -} - -export default async function AdminAlerts() { - const { session, permissions } = await getAdminContext(); - if (!canAccess(permissions, PERMS.NOTIFICATIONS_VIEW, session.user.rank)) { - redirect("/admin"); - } - - const t = await getTranslations("pages.admin.alerts"); - - let alerts: (typeof AlertLogs.$inferSelect)[] = []; - try { - alerts = await db - .select() - .from(AlertLogs) - .orderBy(desc(AlertLogs.id)) - .limit(100); - } catch { - alerts = []; - } - - const critical = alerts.filter((a) => - CRITICAL.has((a.severity || "").toLowerCase()), - ).length; - const warnings = alerts.filter((a) => - WARNING.has((a.severity || "").toLowerCase()), - ).length; - const unread = alerts.filter((a) => !a.isRead).length; - - return ( -
-
- - 0 ? "danger" : "ok"} - icon="🚨" - /> - 0 ? "warn" : "neutral"} - icon="⚠️" - /> - 0 ? "warn" : "ok"} - icon="📨" - /> -
- -
-

{t("form.send")}

-

- {t.rich("form.description", { - code: (chunks) => {chunks}, - })} -

-
-