From 9c4b973faf09dfdbf615b04c44f395ea3470f00e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 30 Aug 2026 13:47:06 +0200 Subject: [PATCH] feat(housekeeping): deliver economy vertical --- src/actions/admin-badge-upload.ts | 70 +- src/actions/admin-badges.ts | 44 +- src/actions/admin-marketplace.ts | 35 +- src/actions/admin-rare-values.ts | 121 +- src/actions/admin-shop.ts | 153 +-- src/actions/admin-vouchers.ts | 61 +- src/actions/catalog-bc.ts | 261 +--- src/actions/catalog-items.ts | 450 +------ src/actions/catalog.ts | 149 +-- src/actions/furni-maintenance.ts | 47 +- src/actions/items-base.ts | 105 +- src/actions/soundtracks.ts | 28 +- .../catalog/builder-club/bc-manager.test.tsx | 51 + .../admin/catalog/builder-club/bc-manager.tsx | 10 +- .../economy/commands/economy-commands.test.ts | 156 +++ .../economy/commands/economy-commands.ts | 145 +++ .../economy-providers-production.test.ts | 126 ++ .../domains/economy/economy-providers.test.ts | 103 ++ .../domains/economy/inbox-production.ts | 81 ++ .../housekeeping/domains/economy/inbox.ts | 94 ++ .../housekeeping/domains/economy/manifest.ts | 12 +- .../domains/economy/pages/catalog.tsx | 115 ++ .../domains/economy/pages/commerce.tsx | 128 ++ .../economy/pages/economy-command-form.tsx | 199 +++ .../economy/pages/economy-page-frame.tsx | 82 ++ .../economy/pages/economy-pages.test.tsx | 159 +++ .../domains/economy/pages/history.tsx | 33 + .../domains/economy/pages/items.tsx | 59 + .../domains/economy/pages/rewards.tsx | 119 ++ .../pages/specialized-catalog-editor.tsx | 136 +++ .../domains/economy/pages/value.tsx | 102 ++ .../domains/economy/queries/catalog.ts | 150 +++ .../domains/economy/queries/commerce.ts | 19 + .../queries/economy-production.test.ts | 88 ++ .../economy/queries/economy-production.ts | 257 ++++ .../economy/queries/economy-queries.test.ts | 114 ++ .../domains/economy/queries/value.ts | 15 + .../domains/economy/route-handlers.ts | 31 + .../domains/economy/routes.test.ts | 119 ++ .../housekeeping/domains/economy/routes.ts | 149 +++ .../domains/economy/search-production.ts | 51 + .../housekeeping/domains/economy/search.ts | 120 ++ .../services/mutation-runtime-catalog.ts | 1067 +++++++++++++++++ .../services/mutation-runtime-commerce.ts | 515 ++++++++ .../services/mutation-runtime-input.ts | 112 ++ .../services/mutation-runtime-rewards.ts | 139 +++ .../services/mutations-production.test.ts | 127 ++ .../economy/services/mutations-production.ts | 212 ++++ .../economy/services/mutations-runtime.ts | 42 + .../economy/services/mutations.test.ts | 100 ++ .../domains/economy/services/mutations.ts | 215 ++++ .../domains/economy/widgets-production.ts | 50 + .../housekeeping/domains/economy/widgets.ts | 94 ++ .../foundation/commands/bootstrap.ts | 2 + .../foundation-source-contract.test.ts | 225 +++- .../foundation/localization-contract.test.ts | 28 + .../foundation/preview-route-contract.test.ts | 22 + .../housekeeping/foundation/registry.test.ts | 24 +- .../housekeeping/route-handlers.test.ts | 2 + src/features/housekeeping/route-handlers.ts | 2 + src/messages/en.json | 33 + src/messages/it.json | 33 + 62 files changed, 6306 insertions(+), 1285 deletions(-) create mode 100644 src/app/admin/catalog/builder-club/bc-manager.test.tsx create mode 100644 src/features/housekeeping/domains/economy/commands/economy-commands.test.ts create mode 100644 src/features/housekeeping/domains/economy/commands/economy-commands.ts create mode 100644 src/features/housekeeping/domains/economy/economy-providers-production.test.ts create mode 100644 src/features/housekeeping/domains/economy/economy-providers.test.ts create mode 100644 src/features/housekeeping/domains/economy/inbox-production.ts create mode 100644 src/features/housekeeping/domains/economy/inbox.ts create mode 100644 src/features/housekeeping/domains/economy/pages/catalog.tsx create mode 100644 src/features/housekeeping/domains/economy/pages/commerce.tsx create mode 100644 src/features/housekeeping/domains/economy/pages/economy-command-form.tsx create mode 100644 src/features/housekeeping/domains/economy/pages/economy-page-frame.tsx create mode 100644 src/features/housekeeping/domains/economy/pages/economy-pages.test.tsx create mode 100644 src/features/housekeeping/domains/economy/pages/history.tsx create mode 100644 src/features/housekeeping/domains/economy/pages/items.tsx create mode 100644 src/features/housekeeping/domains/economy/pages/rewards.tsx create mode 100644 src/features/housekeeping/domains/economy/pages/specialized-catalog-editor.tsx create mode 100644 src/features/housekeeping/domains/economy/pages/value.tsx create mode 100644 src/features/housekeeping/domains/economy/queries/catalog.ts create mode 100644 src/features/housekeeping/domains/economy/queries/commerce.ts create mode 100644 src/features/housekeeping/domains/economy/queries/economy-production.test.ts create mode 100644 src/features/housekeeping/domains/economy/queries/economy-production.ts create mode 100644 src/features/housekeeping/domains/economy/queries/economy-queries.test.ts create mode 100644 src/features/housekeeping/domains/economy/queries/value.ts create mode 100644 src/features/housekeeping/domains/economy/route-handlers.ts create mode 100644 src/features/housekeeping/domains/economy/routes.test.ts create mode 100644 src/features/housekeeping/domains/economy/routes.ts create mode 100644 src/features/housekeeping/domains/economy/search-production.ts create mode 100644 src/features/housekeeping/domains/economy/search.ts create mode 100644 src/features/housekeeping/domains/economy/services/mutation-runtime-catalog.ts create mode 100644 src/features/housekeeping/domains/economy/services/mutation-runtime-commerce.ts create mode 100644 src/features/housekeeping/domains/economy/services/mutation-runtime-input.ts create mode 100644 src/features/housekeeping/domains/economy/services/mutation-runtime-rewards.ts create mode 100644 src/features/housekeeping/domains/economy/services/mutations-production.test.ts create mode 100644 src/features/housekeeping/domains/economy/services/mutations-production.ts create mode 100644 src/features/housekeeping/domains/economy/services/mutations-runtime.ts create mode 100644 src/features/housekeeping/domains/economy/services/mutations.test.ts create mode 100644 src/features/housekeeping/domains/economy/services/mutations.ts create mode 100644 src/features/housekeeping/domains/economy/widgets-production.ts create mode 100644 src/features/housekeeping/domains/economy/widgets.ts diff --git a/src/actions/admin-badge-upload.ts b/src/actions/admin-badge-upload.ts index 8445c4cefb..ba0de0be8d 100644 --- a/src/actions/admin-badge-upload.ts +++ b/src/actions/admin-badge-upload.ts @@ -1,76 +1,20 @@ "use server"; -import { writeFile } from "node:fs/promises"; -import path from "node:path"; import { redirect } from "next/navigation"; +import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations"; import { requirePermission } from "@/lib/admin/guard"; -import { toBadgeGif } from "@/lib/images/badge-gif"; import { PERMS } from "@/lib/permissions"; -import { logStaffActivity } from "@/lib/services/staff-activity"; - -// Writes a badge image to the configured emulator badge directory. The path is -// read from BADGE_UPLOAD_DIR so deployments can point it at their emulator's -// `swf/c_images/album1584` (or equivalent) without code changes. AtomCMS only -// ever stores .gif badges, so every upload is normalised to `.gif`. - -const CODE_RE = /^[A-Za-z0-9_-]{1,64}$/; -const MAX_BYTES = 1024 * 1024; // 1MB -const ALLOWED_TYPES = new Set(["image/gif", "image/png"]); - -function back(param: string, value: string): never { - redirect(`/admin/badges?${param}=${encodeURIComponent(value)}`); -} export async function uploadBadge(formData: FormData): Promise { const staff = await requirePermission(PERMS.CATALOG_EDIT); - - const dir = process.env.BADGE_UPLOAD_DIR; - if (!dir) { - back("error", "Badge upload directory not configured"); - } - - const code = String(formData.get("code") ?? "") - .normalize("NFC") - .trim(); - if (!CODE_RE.test(code)) { - back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)"); - } - - const file = formData.get("file"); - if (!(file instanceof File)) { - back("error", "No file uploaded"); - } - - if (file.size === 0) { - back("error", "Uploaded file is empty"); - } - if (file.size > MAX_BYTES) { - back("error", "File too large (max 1MB)"); - } - if (!ALLOWED_TYPES.has(file.type)) { - back("error", "File must be a GIF or PNG image"); - } - + const code = String(formData.get("code") ?? ""); try { - const buffer = Buffer.from(await file.arrayBuffer()); - const gif = await toBadgeGif(buffer); - const baseDir = path.resolve(dir); - const target = path.resolve(baseDir, `${code}.gif`); - if (!target.startsWith(baseDir + path.sep)) { - back("error", "Invalid path"); - } - // eslint-disable-next-line security/detect-non-literal-fs-filename - await writeFile(target, gif); + await executeLegacyEconomyMutation(staff, "badge.upload", { + code, + file: formData.get("file"), + }); } catch { - back("error", "Could not process or write the badge file"); + redirect("/admin/badges?error=Badge%20upload%20failed"); } - - await logStaffActivity({ - staffId: staff.id, - action: "badge_upload", - description: `Uploaded badge image "${code}.gif"`, - targetType: "badge", - }); - redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`); } diff --git a/src/actions/admin-badges.ts b/src/actions/admin-badges.ts index 1f24bb9faf..fde24920ad 100644 --- a/src/actions/admin-badges.ts +++ b/src/actions/admin-badges.ts @@ -1,47 +1,15 @@ "use server"; -import { and, eq, max } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations"; import { requirePermission } from "@/lib/admin/guard"; -import { db, UsersBadges } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { rcon } from "@/lib/services/rcon"; export async function giveBadge(formData: FormData): Promise { - await requirePermission(PERMS.CATALOG_EDIT); - - const userId = Number(formData.get("userId")); - const code = String(formData.get("code") ?? "") - .normalize("NFC") - .trim() - .slice(0, 32); - if (!(userId > 0) || code.length === 0) return; - - // Fire the emulator command so the badge appears live for online users. - await rcon.giveBadge(userId, code); - - // Persist the badge directly so it survives a relog / offline grant. - // users_badges has no unique (user_id, badge_code) constraint, so guard - // against duplicates and compute the next free slot ourselves. - try { - const [existing] = await db - .select({ id: UsersBadges.id }) - .from(UsersBadges) - .where( - and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)), - ) - .limit(1); - if (!existing) { - const [agg] = await db - .select({ maxSlot: max(UsersBadges.slotId) }) - .from(UsersBadges) - .where(eq(UsersBadges.userId, userId)); - const slotId = (agg?.maxSlot ?? 0) + 1; - await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code }); - } - } catch { - // Best-effort: the RCON grant already succeeded for online users. - } - + 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-marketplace.ts b/src/actions/admin-marketplace.ts index 13639e18c3..fc116f4416 100644 --- a/src/actions/admin-marketplace.ts +++ b/src/actions/admin-marketplace.ts @@ -1,44 +1,17 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations"; import { requirePermissionRateLimited } from "@/lib/admin/guard"; -import { db, MarketplaceItems } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { logStaffActivity } from "@/lib/services/staff-activity"; -/** Cancel an active marketplace listing (state 1 → 0). */ +/** Cancel an active marketplace listing (state 1 to 0). */ export async function cancelMarketplaceListing( formData: FormData, ): Promise { const staff = await requirePermissionRateLimited(PERMS.SHOP_EDIT); - const id = Number(formData.get("id")); - if (!(id > 0)) return; - - const [listing] = await db - .select({ - id: MarketplaceItems.id, - state: MarketplaceItems.state, - userId: MarketplaceItems.userId, - itemId: MarketplaceItems.itemId, - price: MarketplaceItems.price, - }) - .from(MarketplaceItems) - .where(eq(MarketplaceItems.id, id)) - .limit(1); - if (listing?.state !== 1) return; - - await db - .update(MarketplaceItems) - .set({ state: 0 }) - .where(eq(MarketplaceItems.id, id)); - - await logStaffActivity({ - staffId: staff.id, - action: "marketplace_cancel", - description: `Cancelled marketplace listing #${id} (item ${listing.itemId}, user ${listing.userId}, price ${listing.price})`, - targetType: "marketplace", - targetId: id, + await executeLegacyEconomyMutation(staff, "marketplace.cancel", { + id: formData.get("id"), }); revalidatePath("/admin/marketplace"); } diff --git a/src/actions/admin-rare-values.ts b/src/actions/admin-rare-values.ts index 8eac1cdb4a..8dc59922e6 100644 --- a/src/actions/admin-rare-values.ts +++ b/src/actions/admin-rare-values.ts @@ -1,117 +1,46 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations"; import { requirePermission } from "@/lib/admin/guard"; -import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db"; -import { formPositiveBigInt } from "@/lib/form-data"; import { PERMS } from "@/lib/permissions"; -export async function createCategory(formData: FormData): Promise { - await requirePermission(PERMS.SHOP_EDIT); - const name = String(formData.get("name") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - const badge = String(formData.get("badge") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - const priorityRaw = Number(formData.get("priority")); - const priority = - Number.isFinite(priorityRaw) && priorityRaw > 0 - ? Math.floor(priorityRaw) - : 1; - if (!name || !badge) return; +function formInput(formData: FormData): Record { + return Object.fromEntries(formData.entries()); +} - try { - await db.insert(WebsiteRareValueCategories).values({ - name, - badge, - priority, - }); - } catch { - // Unique name collision or DB error — ignore, page will re-render unchanged. - } +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 { - await requirePermission(PERMS.SHOP_EDIT); - const id = formPositiveBigInt(formData, "id"); - if (!id) return; - - try { - // Remove the category's values first to avoid orphaned rows. - await db - .delete(WebsiteRareValues) - .where(eq(WebsiteRareValues.categoryId, id)); - await db - .delete(WebsiteRareValueCategories) - .where(eq(WebsiteRareValueCategories.id, id)); - } catch { - // Not found or DB error — ignore. - } + 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 { - await requirePermission(PERMS.SHOP_EDIT); - const categoryId = formPositiveBigInt(formData, "categoryId"); - if (!categoryId) return; - - const name = String(formData.get("name") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - const furnitureIcon = String(formData.get("furnitureIcon") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - if (!name || !furnitureIcon) return; - - const itemIdRaw = Number(formData.get("itemId")); - const itemId = - Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null; - - const creditValueRaw = String(formData.get("creditValue") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - const currencyValueRaw = String(formData.get("currencyValue") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - const currencyType = - String(formData.get("currencyType") ?? "diamonds") - .trim() - .slice(0, 255) || "diamonds"; - - try { - await db.insert(WebsiteRareValues).values({ - categoryId, - itemId, - name, - creditValue: creditValueRaw || null, - currencyValue: currencyValueRaw || null, - currencyType, - furnitureIcon, - }); - } catch { - // DB error — ignore. - } + 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 { - await requirePermission(PERMS.SHOP_EDIT); - const id = formPositiveBigInt(formData, "id"); - if (!id) return; - - try { - await db.delete(WebsiteRareValues).where(eq(WebsiteRareValues.id, id)); - } catch { - // Not found or DB error — ignore. - } + 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-shop.ts b/src/actions/admin-shop.ts index aa8feb917e..3b208f9ed3 100644 --- a/src/actions/admin-shop.ts +++ b/src/actions/admin-shop.ts @@ -1,180 +1,63 @@ "use server"; -import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; 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 { db, WebsiteShopArticles } from "@/lib/db"; -import { formPositiveBigInt } from "@/lib/form-data"; import { PERMS } from "@/lib/permissions"; import { logServerError } from "@/lib/server-log"; -import { logStaffActivity } from "@/lib/services/staff-activity"; -// Website store packages (website_shop_articles). This CMS-owned table backs -// the public store; rows here are the buyable packages, not orders. The closest -// "orders" record is website_paypal_transactions, exposed read-only by the page. - -/** Parse an UnsignedInt form value, returning null when blank/invalid/negative. */ -function optUInt(formData: FormData, key: string): number | null { - const raw = String(formData.get(key) ?? "") - .normalize("NFC") - .trim(); - if (raw === "") return null; - const n = Number(raw); - if (!Number.isFinite(n) || n < 0) return null; - return Math.floor(n); -} - -/** Parse a required non-negative UnsignedInt, falling back to 0. */ -function reqUInt(formData: FormData, key: string): number { - const n = optUInt(formData, key); - return n ?? 0; +function formInput(formData: FormData): Record { + return Object.fromEntries(formData.entries()); } export async function createShopArticle(formData: FormData): Promise { const staff = await requirePermission(PERMS.SHOP_EDIT); - - const name = String(formData.get("name") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - if (!name) return; - - const now = new Date(); - const costs = reqUInt(formData, "costs"); try { - const [result] = (await db.insert(WebsiteShopArticles).values({ - name, - info: String(formData.get("info") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255), - iconUrl: String(formData.get("icon") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255), - color: String(formData.get("color") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255), - costs, - giveRank: optUInt(formData, "giveRank"), - credits: optUInt(formData, "credits"), - duckets: optUInt(formData, "duckets"), - diamonds: optUInt(formData, "diamonds"), - badges: - String(formData.get("badges") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255) || null, - position: reqUInt(formData, "position"), - createdAt: now, - updatedAt: now, - })) as unknown as [ResultSetHeader]; - await logStaffActivity({ - staffId: staff.id, - action: "shop_create", - description: `Created shop package "${name}" (${costs} costs)`, - targetType: "shop_article", - targetId: Number(result.insertId), + await executeLegacyEconomyMutation(staff, "shop-article.change", { + action: "create", + ...formInput(formData), }); } catch (error) { - logServerError("admin.shop_create_failed", error, { - staffId: staff.id, - name, - }); - // Unique constraint on `name` (or DB unavailable) — swallow and re-render. + 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 id = formPositiveBigInt(formData, "id"); - if (!id) return; - - const name = String(formData.get("name") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - if (!name) return; - + const input = formInput(formData); try { - await db - .update(WebsiteShopArticles) - .set({ - name, - info: String(formData.get("info") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255), - iconUrl: String(formData.get("icon") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255), - color: String(formData.get("color") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255), - costs: reqUInt(formData, "costs"), - giveRank: optUInt(formData, "giveRank"), - credits: optUInt(formData, "credits"), - duckets: optUInt(formData, "duckets"), - diamonds: optUInt(formData, "diamonds"), - badges: - String(formData.get("badges") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255) || null, - position: reqUInt(formData, "position"), - updatedAt: new Date(), - }) - .where(eq(WebsiteShopArticles.id, id)); - await logStaffActivity({ - staffId: staff.id, - action: "shop_update", - description: `Updated shop package #${id} ("${name}")`, - targetType: "shop_article", - targetId: Number(id), + await executeLegacyEconomyMutation(staff, "shop-article.change", { + action: "update", + ...input, }); } catch (error) { logServerError("admin.shop_update_failed", error, { staffId: staff.id, - articleId: String(id), + articleId: String(input.id ?? ""), }); return; } - - revalidatePath(`/admin/shop/${id}`); + revalidatePath(`/admin/shop/${String(input.id ?? "")}`); redirect("/admin/shop"); } export async function deleteShopArticle(formData: FormData): Promise { const staff = await requirePermission(PERMS.SHOP_EDIT); - - const id = formPositiveBigInt(formData, "id"); - if (!id) return; - + const input = formInput(formData); try { - await db.delete(WebsiteShopArticles).where(eq(WebsiteShopArticles.id, id)); - await logStaffActivity({ - staffId: staff.id, - action: "shop_delete", - description: `Deleted shop package #${id}`, - targetType: "shop_article", - targetId: Number(id), + await executeLegacyEconomyMutation(staff, "shop-article.change", { + action: "delete", + ...input, }); } catch (error) { logServerError("admin.shop_delete_failed", error, { staffId: staff.id, - articleId: String(id), + articleId: String(input.id ?? ""), }); return; } - redirect("/admin/shop"); } diff --git a/src/actions/admin-vouchers.ts b/src/actions/admin-vouchers.ts index 28feeb585e..fcce94a335 100644 --- a/src/actions/admin-vouchers.ts +++ b/src/actions/admin-vouchers.ts @@ -1,11 +1,8 @@ "use server"; -import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; +import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations"; import { requirePermission } from "@/lib/admin/guard"; -import { positiveBigInt } from "@/lib/api"; -import { db, WebsiteShopVouchers } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { type ActionResult, @@ -20,44 +17,15 @@ export async function createVoucher(input: { maxUses: number; expiresAt?: string; }): Promise> { - await requirePermission(PERMS.SHOP_EDIT); - - const code = String(input.code ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - const amount = Number(input.amount); - const maxUsesRaw = Number(input.maxUses); - const maxUses = - Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1; - - if (!code || !(amount > 0)) { - return actionError("Code and a positive amount are required"); - } - - let expiresAt: Date | null = null; - const expiresRaw = String(input.expiresAt ?? "") - .normalize("NFC") - .trim(); - if (expiresRaw) { - const parsed = new Date(expiresRaw); - if (!Number.isNaN(parsed.getTime())) expiresAt = parsed; - } - - const now = new Date(); - + const staff = await requirePermission(PERMS.SHOP_EDIT); try { - const [result] = (await db.insert(WebsiteShopVouchers).values({ - code, - amount: Math.floor(amount), - maxUses, - useCount: 0, - expiresAt, - createdAt: now, - updatedAt: now, - })) as unknown as [ResultSetHeader]; + const snapshot = await executeLegacyEconomyMutation( + staff, + "voucher.change", + { action: "create", ...input }, + ); revalidatePath("/admin/vouchers"); - return actionOk({ id: String(result.insertId) }); + 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)"); @@ -67,18 +35,17 @@ export async function createVoucher(input: { export async function deleteVoucher(input: { id: string; }): Promise { - await requirePermission(PERMS.SHOP_EDIT); - - const id = positiveBigInt(String(input.id ?? "").trim()); - if (!id) return actionError("Missing voucher id"); - + const staff = await requirePermission(PERMS.SHOP_EDIT); try { - await db.delete(WebsiteShopVouchers).where(eq(WebsiteShopVouchers.id, id)); + await executeLegacyEconomyMutation(staff, "voucher.change", { + action: "delete", + id: input.id, + }); revalidatePath("/admin/vouchers"); return actionOk(); } catch (error) { logServerError("admin.voucher_delete_failed", error, { - voucherId: String(id), + voucherId: input.id, }); return actionError("Could not delete voucher"); } diff --git a/src/actions/catalog-bc.ts b/src/actions/catalog-bc.ts index de924b9f1a..a76256dee5 100644 --- a/src/actions/catalog-bc.ts +++ b/src/actions/catalog-bc.ts @@ -1,50 +1,13 @@ "use server"; -import { eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations"; import { requirePermission } from "@/lib/admin/guard"; -import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { rcon } from "@/lib/services/rcon"; -import { logStaffActivity } from "@/lib/services/staff-activity"; -const BC_PAGE_FIELDS = [ - "caption", - "parentId", - "pageLayout", - "enabled", - "visible", - "orderNum", - "iconImage", - "iconColor", - "pageHeadline", - "pageTeaser", - "pageSpecial", - "pageText1", - "pageText2", - "pageTextDetails", - "pageTextTeaser", -] as const; - -const BC_ITEM_FIELDS = [ - "itemIds", - "catalogName", - "orderNumber", - "extradata", - "pageId", -] as const; - -function pickAllowed( - fields: Record, - allowed: readonly string[], -) { - const out: Record = {}; - for (const key of allowed) { - if (Object.hasOwn(fields, key) && fields[key] !== undefined) { - out[key] = fields[key]; - } - } - return out; +function revalidateBuilderClub(): void { + revalidatePath("/admin/catalog"); + revalidatePath("/admin/catalog/builder-club"); } export async function updateBcPage({ @@ -52,38 +15,22 @@ export async function updateBcPage({ ...fields }: { id: number } & Record) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const data = pickAllowed(fields, BC_PAGE_FIELDS); - if (Object.keys(data).length === 0) { - return { ok: false as const, error: "No valid fields to update" }; - } - await db - .update(CatalogPagesBc) - .set(data as Partial) - .where(eq(CatalogPagesBc.id, id)); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "bc_page_update", - description: `Updated BC catalog page #${id}`, - targetType: "catalog_page_bc", - targetId: id, + await executeLegacyEconomyMutation(staff, "bc-page.change", { + action: "update", + id, + ...fields, }); - revalidatePath("/admin/catalog/builder-club"); + revalidateBuilderClub(); return { ok: true as const }; } export async function deleteBcItem({ id }: { id: number }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id)); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "bc_item_delete", - description: `Deleted BC catalog item #${id}`, - targetType: "catalog_item_bc", - targetId: id, + await executeLegacyEconomyMutation(staff, "bc-item.change", { + action: "delete", + id, }); - revalidatePath("/admin/catalog/builder-club"); + revalidateBuilderClub(); return { ok: true as const }; } @@ -98,23 +45,12 @@ export async function updateBcItem({ extradata?: string; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const safe = pickAllowed(data as Record, BC_ITEM_FIELDS); - if (Object.keys(safe).length === 0) { - return { ok: false as const, error: "No valid fields to update" }; - } - await db - .update(CatalogItemsBc) - .set(safe as Partial) - .where(eq(CatalogItemsBc.id, id)); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "bc_item_update", - description: `Updated BC catalog item #${id}`, - targetType: "catalog_item_bc", - targetId: id, + await executeLegacyEconomyMutation(staff, "bc-item.change", { + action: "update", + id, + ...data, }); - revalidatePath("/admin/catalog/builder-club"); + revalidateBuilderClub(); return { ok: true as const }; } @@ -129,18 +65,13 @@ export async function createBcItem({ extradata: string; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data }); - const createdId = Number(result.insertId); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "bc_item_create", - description: `Created BC catalog item #${createdId}`, - targetType: "catalog_item_bc", - targetId: createdId, + const snapshot = await executeLegacyEconomyMutation(staff, "bc-item.change", { + action: "create", + pageId, + ...data, }); - revalidatePath("/admin/catalog/builder-club"); - return { ok: true as const, data: { id: createdId } }; + revalidateBuilderClub(); + return { ok: true as const, data: { id: Number(snapshot.output?.id) } }; } export async function toggleBcPage({ @@ -150,22 +81,12 @@ export async function toggleBcPage({ id: number; field: "enabled" | "visible"; }) { - await requirePermission(PERMS.CATALOG_EDIT); - const [page] = await db - .select({ - enabled: CatalogPagesBc.enabled, - visible: CatalogPagesBc.visible, - }) - .from(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, id)) - .limit(1); - if (!page) return { ok: false as const, error: "Page not found" }; - await db - .update(CatalogPagesBc) - .set({ [field]: page[field] === "1" ? "0" : "1" }) - .where(eq(CatalogPagesBc.id, id)); - await rcon.updateCatalog(); - revalidatePath("/admin/catalog/builder-club"); + const staff = await requirePermission(PERMS.CATALOG_EDIT); + await executeLegacyEconomyMutation(staff, "bc-page.change", { + action: field === "enabled" ? "toggle-enabled" : "toggle-visible", + id, + }); + revalidateBuilderClub(); return { ok: true as const }; } @@ -180,52 +101,12 @@ export async function createBcPage(input: { orderNum?: number; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const [result] = await db.insert(CatalogPagesBc).values({ - caption: input.caption, - parentId: input.parentId, - pageLayout: input.pageLayout ?? "default_3x3", - iconColor: input.iconColor ?? 0, - iconImage: input.iconImage ?? 0, - orderNum: input.orderNum ?? 0, - visible: input.visible ?? "1", - enabled: input.enabled ?? "1", - pageHeadline: "", - pageTeaser: "", + const snapshot = await executeLegacyEconomyMutation(staff, "bc-page.change", { + action: "create", + ...input, }); - const createdId = Number(result.insertId); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "bc_page_create", - description: `Created BC catalog page "${input.caption}"`, - targetType: "catalog_page_bc", - targetId: createdId, - }); - revalidatePath("/admin/catalog"); - revalidatePath("/admin/catalog/builder-club"); - return { ok: true as const, data: { id: createdId } }; -} - -async function moveBcPage(pageId: number, newParentId: number): Promise { - if (newParentId > 0) { - let currentId = newParentId; - for (let i = 0; i < 50; i++) { - if (currentId === pageId) { - throw new Error("Cannot move page: would create a circular hierarchy"); - } - const [parent] = await db - .select({ parentId: CatalogPagesBc.parentId }) - .from(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, currentId)) - .limit(1); - if (!parent || parent.parentId <= 0) break; - currentId = parent.parentId; - } - } - await db - .update(CatalogPagesBc) - .set({ parentId: newParentId }) - .where(eq(CatalogPagesBc.id, pageId)); + revalidateBuilderClub(); + return { ok: true as const, data: { id: Number(snapshot.output?.id) } }; } export async function reorderBcTreePage(input: { @@ -233,24 +114,9 @@ export async function reorderBcTreePage(input: { newParentId?: number; newOrderNum: number; }) { - await requirePermission(PERMS.CATALOG_EDIT); - if (input.newParentId !== undefined) { - try { - await moveBcPage(input.pageId, input.newParentId); - } catch (err) { - return { - ok: false as const, - error: err instanceof Error ? err.message : "Invalid move", - }; - } - } - await db - .update(CatalogPagesBc) - .set({ orderNum: input.newOrderNum }) - .where(eq(CatalogPagesBc.id, input.pageId)); - await rcon.updateCatalog(); - revalidatePath("/admin/catalog"); - revalidatePath("/admin/catalog/builder-club"); + const staff = await requirePermission(PERMS.CATALOG_EDIT); + await executeLegacyEconomyMutation(staff, "bc-page.reorder", input); + revalidateBuilderClub(); return { ok: true as const, data: {} }; } @@ -258,53 +124,8 @@ export async function deleteBcTreePage(input: { pageId: number; mode: "reparent" | "cascade"; }) { - await requirePermission(PERMS.CATALOG_EDIT); - const [page] = await db - .select({ parentId: CatalogPagesBc.parentId }) - .from(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, input.pageId)) - .limit(1); - if (!page) return { ok: false as const, error: "Page not found" }; - - if (input.mode === "reparent") { - await db.transaction(async (tx) => { - await tx - .update(CatalogPagesBc) - .set({ parentId: page.parentId }) - .where(eq(CatalogPagesBc.parentId, input.pageId)); - await tx - .delete(CatalogItemsBc) - .where(eq(CatalogItemsBc.pageId, input.pageId)); - await tx - .delete(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, input.pageId)); - }); - } else { - const toDelete: number[] = [input.pageId]; - const queue: number[] = [input.pageId]; - while (queue.length > 0) { - const children = await db - .select({ id: CatalogPagesBc.id }) - .from(CatalogPagesBc) - .where(inArray(CatalogPagesBc.parentId, queue)); - queue.length = 0; - for (const child of children) { - toDelete.push(child.id); - queue.push(child.id); - } - } - await db.transaction(async (tx) => { - await tx - .delete(CatalogItemsBc) - .where(inArray(CatalogItemsBc.pageId, toDelete)); - await tx - .delete(CatalogPagesBc) - .where(inArray(CatalogPagesBc.id, toDelete)); - }); - } - - await rcon.updateCatalog(); - revalidatePath("/admin/catalog"); - revalidatePath("/admin/catalog/builder-club"); + const staff = await requirePermission(PERMS.CATALOG_EDIT); + await executeLegacyEconomyMutation(staff, "bc-page.delete-tree", input); + revalidateBuilderClub(); return { ok: true as const, data: {} }; } diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index b4b2c069ef..ff7ffe569d 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -1,108 +1,15 @@ "use server"; -import { eq, inArray, like, or, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + type EconomyMutationSnapshot, + executeLegacyEconomyMutation, +} from "@/features/housekeeping/domains/economy/services/mutations"; import { requirePermission } from "@/lib/admin/guard"; -import { CatalogItems, db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { logAudit } from "@/lib/services/audit"; -import { allocateCatalogItemId } from "@/lib/services/furni-import"; -import { rcon } from "@/lib/services/rcon"; -import { logStaffActivity } from "@/lib/services/staff-activity"; -import { translateItemsSchema } from "@/lib/validators/catalog"; -const CATALOG_ITEM_FIELDS = [ - "pageId", - "itemIds", - "catalogName", - "costCredits", - "costPoints", - "pointsType", - "amount", - "orderNumber", - "offerId", - "songId", - "limitedSells", - "limitedStack", - "extradata", - "haveOffer", - "clubOnly", -] as const; - -const ITEMS_BASE_FIELDS = [ - "publicName", - "itemName", - "type", - "width", - "length", - "stackHeight", - "allowStack", - "allowSit", - "allowLay", - "allowWalk", - "allowGift", - "allowTrade", - "allowRecycle", - "allowMarketplaceSell", - "allowInventoryStack", - "interactionType", - "interactionModesCount", - "vendingIds", - "customparams", - "effectIdMale", - "effectIdFemale", - "clothingOnWalk", -] as const; - -function pickAllowed( - fields: Record, - allowed: readonly string[], -): Record { - const out: Record = {}; - for (const key of allowed) { - if (Object.hasOwn(fields, key) && fields[key] !== undefined) { - out[key] = fields[key]; - } - } - return out; -} - -/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */ -async function insertCatalogItemRow(data: { - pageId: number; - itemIds: string; - catalogName: string; - costCredits: number; - costPoints: number; - pointsType: number; - amount: number; - orderNumber: number; - offerId: number; - songId: number; - limitedSells: number; - limitedStack: number; - extradata: string; - haveOffer: string; - clubOnly: string; -}): Promise { - const pageIdStr = String(data.pageId); - return allocateCatalogItemId(async (nextId) => { - await db.execute(sql` - INSERT INTO catalog_items ( - id, page_id, item_ids, catalog_name, - cost_credits, cost_points, points_type, amount, - order_number, offer_id, song_id, - limited_sells, limited_stack, extradata, have_offer, club_only - ) VALUES ( - ${nextId}, ${pageIdStr}, ${data.itemIds}, ${data.catalogName}, - ${data.costCredits}, ${data.costPoints}, ${data.pointsType}, ${data.amount}, - ${data.orderNumber}, ${data.offerId}, ${data.songId}, - ${data.limitedSells}, ${data.limitedStack}, ${data.extradata}, - ${data.haveOffer}, ${data.clubOnly} - ) - `); - return nextId; - }); +function revalidateCatalog(): void { + revalidatePath("/admin/catalog"); } export async function createCatalogItem(data: { @@ -123,39 +30,16 @@ export async function createCatalogItem(data: { clubOnly: "0" | "1"; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - let catalogName = data.catalogName.trim(); - if (!catalogName) { - const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10); - if (firstId > 0) { - const [base] = await db - .select({ - publicName: ItemsBase.publicName, - itemName: ItemsBase.itemName, - }) - .from(ItemsBase) - .where(eq(ItemsBase.id, firstId)) - .limit(1); - catalogName = base?.publicName || base?.itemName || String(firstId); - } - } - const id = await insertCatalogItemRow({ ...data, catalogName }); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_item_create", - description: `Created catalog item #${id}`, - targetType: "catalog_item", - targetId: id, - }); - revalidatePath("/admin/catalog"); - return { ok: true as const, data: { id } }; + const snapshot = await executeLegacyEconomyMutation( + staff, + "catalog-item.change", + { action: "create", ...data }, + ); + revalidateCatalog(); + return { ok: true as const, data: { id: Number(snapshot.output?.id) } }; } -/** Bulk create with one RCON refresh at the end. */ -export async function bulkCreateCatalogItems({ - pageId, - rows, -}: { +export async function bulkCreateCatalogItems(input: { pageId: number; rows: Array<{ baseId: number; @@ -163,302 +47,92 @@ export async function bulkCreateCatalogItems({ points?: number; pointsType?: number; }>; -}) { +}): Promise< + | { ok: true; data: { created: number; failed: number } } + | { ok: false; error: string } +> { const staff = await requirePermission(PERMS.CATALOG_EDIT); - if (rows.length === 0) { - return { ok: true as const, data: { created: 0, failed: 0 } }; + let snapshot: EconomyMutationSnapshot; + try { + snapshot = await executeLegacyEconomyMutation( + staff, + "catalog-item.bulk-create", + input, + ); + } catch { + return { ok: false, error: "Bulk import failed" }; } - if (rows.length > 500) { - return { ok: false as const, error: "Max 500 items per bulk import" }; - } - - const baseIds = [...new Set(rows.map((r) => r.baseId))]; - const bases = await db - .select({ - id: ItemsBase.id, - publicName: ItemsBase.publicName, - itemName: ItemsBase.itemName, - }) - .from(ItemsBase) - .where(inArray(ItemsBase.id, baseIds)); - const baseMap = new Map(bases.map((b) => [b.id, b])); - - let created = 0; - let failed = 0; - - for (const row of rows) { - const base = baseMap.get(row.baseId); - if (!base) { - failed++; - continue; - } - try { - await insertCatalogItemRow({ - pageId, - itemIds: String(row.baseId), - catalogName: base.publicName || base.itemName || String(row.baseId), - costCredits: row.credits ?? 0, - costPoints: row.points ?? 0, - pointsType: row.pointsType ?? 0, - amount: 1, - limitedSells: 0, - limitedStack: 0, - orderNumber: 1, - offerId: -1, - songId: 0, - haveOffer: "1", - clubOnly: "0", - extradata: "", - }); - created++; - } catch { - failed++; - } - } - - if (created > 0) { - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_items_bulk_create", - description: `Bulk imported ${created} catalog item(s) on page #${pageId}`, - targetType: "catalog_page", - targetId: pageId, - }); - revalidatePath("/admin/catalog"); - } - - return { ok: true as const, data: { created, failed } }; + revalidateCatalog(); + return { + ok: true as const, + data: { + created: Number(snapshot.output?.created ?? 0), + failed: Number(snapshot.output?.failed ?? 0), + }, + }; } export async function deleteCatalogItems({ ids }: { ids: number[] }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids)); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_items_delete", - description: `Deleted catalog items: ${ids.join(", ")}`, - targetType: "catalog_item", + await executeLegacyEconomyMutation(staff, "catalog-item.change", { + action: "delete", + ids, }); - revalidatePath("/admin/catalog"); + revalidateCatalog(); return { ok: true as const, data: {} }; } -export async function moveCatalogItems({ - ids, - targetPageId, -}: { +export async function moveCatalogItems(input: { ids: number[]; targetPageId: number; }) { - await requirePermission(PERMS.CATALOG_EDIT); - if (ids.length === 0) { - return { ok: true as const, data: {} }; - } - const pageIdStr = String(targetPageId); - await db.execute(sql` - UPDATE catalog_items - SET page_id = ${pageIdStr} - WHERE id IN (${sql.join( - ids.map((id) => sql`${id}`), - sql`, `, - )}) - `); - await rcon.updateCatalog(); - revalidatePath("/admin/catalog"); + const staff = await requirePermission(PERMS.CATALOG_EDIT); + await executeLegacyEconomyMutation(staff, "catalog-item.move", input); + revalidateCatalog(); return { ok: true as const, data: {} }; } -export async function reorderCatalogItems({ - orders, -}: { +export async function reorderCatalogItems(input: { orders: Array<{ id: number; orderNumber: number }>; }) { - await requirePermission(PERMS.CATALOG_EDIT); - for (const { id, orderNumber } of orders) { - await db - .update(CatalogItems) - .set({ orderNumber }) - .where(eq(CatalogItems.id, id)); - } - await rcon.updateCatalog(); - revalidatePath("/admin/catalog"); + const staff = await requirePermission(PERMS.CATALOG_EDIT); + await executeLegacyEconomyMutation(staff, "catalog-item.reorder", input); + revalidateCatalog(); return { ok: true as const, data: {} }; } -export async function updateCatalogItem({ - id, - catalogFields, - baseItem, -}: { +export async function updateCatalogItem(input: { id: number; catalogFields: Record; baseItem?: { id: number; fields: Record }; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS); - if (Object.keys(safeCatalog).length === 0 && !baseItem) { - return { ok: false as const, error: "No valid fields to update" }; - } - - // page_id is often VARCHAR — update it via raw SQL when present. - const pageIdRaw = safeCatalog.pageId; - if (pageIdRaw !== undefined) { - const pageIdStr = String(pageIdRaw); - await db.execute(sql` - UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id} - `); - delete safeCatalog.pageId; - } - - if (Object.keys(safeCatalog).length > 0) { - await db - .update(CatalogItems) - .set(safeCatalog as Partial) - .where(eq(CatalogItems.id, id)); - } - if (baseItem) { - const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS); - if (Object.keys(safeBase).length > 0) { - await db - .update(ItemsBase) - .set(safeBase as Partial) - .where(eq(ItemsBase.id, baseItem.id)); - } - } - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_item_update", - description: `Updated catalog item #${id}`, - targetType: "catalog_item", - targetId: id, + await executeLegacyEconomyMutation(staff, "catalog-item.change", { + action: "update", + ...input, }); - revalidatePath("/admin/catalog"); + revalidateCatalog(); return { ok: true as const, data: {} }; } export async function translateCatalogItems(input: { - /** `id` is items_base.id (not catalog_items.id) */ items: Array<{ id: number; publicName: string; description?: string }>; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const parsed = translateItemsSchema.safeParse(input); - if (!parsed.success) { - return { - ok: false as const, - error: parsed.error.issues[0]?.message ?? "Invalid translate payload", - }; - } - const { items } = parsed.data; - const { invalidateFurniDataCache } = await import( - "@/lib/services/catalog-items-loader" + const snapshot = await executeLegacyEconomyMutation( + staff, + "catalog-item.translate", + input, ); - const { patchFurniEntryNames } = await import("@/lib/services/furni-data"); - - let namesUpdated = 0; - let descriptionsUpdated = 0; - const furniPatches: Array<{ - classname: string; - itemType: string; - name?: string; - description?: string; - spriteId?: number; - createIfMissing?: boolean; - }> = []; - - for (const item of items) { - const [base] = await db - .select({ - id: ItemsBase.id, - publicName: ItemsBase.publicName, - itemName: ItemsBase.itemName, - type: ItemsBase.type, - spriteId: ItemsBase.spriteId, - }) - .from(ItemsBase) - .where(eq(ItemsBase.id, item.id)) - .limit(1); - if (!base) continue; - - const nextName = item.publicName?.trim() ?? ""; - const nextDesc = item.description ?? ""; - const nameChanged = nextName !== "" && nextName !== (base.publicName ?? ""); - - if (nameChanged) { - await db - .update(ItemsBase) - .set({ publicName: nextName }) - .where(eq(ItemsBase.id, base.id)); - const idStr = String(base.id); - const related = await db - .select({ - id: CatalogItems.id, - catalogName: CatalogItems.catalogName, - }) - .from(CatalogItems) - .where( - or( - eq(CatalogItems.itemIds, idStr), - like(CatalogItems.itemIds, `${idStr};%`), - like(CatalogItems.itemIds, `%;${idStr};%`), - like(CatalogItems.itemIds, `%;${idStr}`), - ), - ); - for (const row of related) { - if (row.catalogName !== nextName) { - await db - .update(CatalogItems) - .set({ catalogName: nextName }) - .where(eq(CatalogItems.id, row.id)); - } - } - namesUpdated++; - } - - if (nextDesc !== "" || nameChanged) { - descriptionsUpdated += nextDesc !== "" ? 1 : 0; - furniPatches.push({ - classname: base.itemName, - itemType: base.type || "s", - name: nextName || base.publicName || base.itemName, - description: nextDesc, - spriteId: base.spriteId, - createIfMissing: true, - }); - } - } - - const furniResult = - furniPatches.length > 0 - ? await patchFurniEntryNames(furniPatches) - : { updated: 0, inserted: 0 }; - if (furniResult.updated > 0 || furniResult.inserted > 0) { - invalidateFurniDataCache(); - } - - await rcon.updateCatalog(); - await logAudit({ - userId: staff.id, - action: "items_base_translate", - target: "ItemsBase", - after: { - namesUpdated, - descriptionsUpdated, - furniDataUpdated: furniResult.updated > 0, - furniDataInserted: furniResult.inserted, - }, - }); - revalidatePath("/admin/catalog"); + revalidateCatalog(); return { ok: true as const, data: { - namesUpdated, - descriptionsUpdated, - furniDataUpdated: furniResult.updated, - furniDataInserted: furniResult.inserted, - updated: items.length, + namesUpdated: Number(snapshot.output?.namesUpdated ?? 0), + descriptionsUpdated: Number(snapshot.output?.descriptionsUpdated ?? 0), + furniDataUpdated: Number(snapshot.output?.furniDataUpdated ?? 0), + furniDataInserted: Number(snapshot.output?.furniDataInserted ?? 0), + updated: input.items.length, }, }; } diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index 8a4b14bd6a..99315e1d10 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -1,71 +1,20 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations"; import { requirePermission } from "@/lib/admin/guard"; -import { CatalogPages, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import type { ActionResult } from "@/lib/safe-action-shared"; -import { deletePage, movePage } from "@/lib/services/catalog-tree"; -import { rcon } from "@/lib/services/rcon"; -import { logStaffActivity } from "@/lib/services/staff-activity"; - -const CATALOG_PAGE_FIELDS = [ - "caption", - "parentId", - "pageLayout", - "enabled", - "visible", - "minRank", - "clubOnly", - "vipOnly", - "orderNum", - "iconImage", - "iconColor", - "pageHeadline", - "pageTeaser", - "pageSpecial", - "pageText1", - "pageText2", - "pageTextDetails", - "pageTextTeaser", - "includes", - "captionSave", -] as const; - -function pickPageFields(fields: Record) { - const out: Record = {}; - for (const key of CATALOG_PAGE_FIELDS) { - if (Object.hasOwn(fields, key) && fields[key] !== undefined) { - out[key] = fields[key]; - } - } - return out; -} export async function updateCatalogPage({ id, ...fields }: { id: number } & Record): Promise { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const data = pickPageFields(fields); - if (Object.keys(data).length === 0) { - return { ok: false as const, error: "No valid fields to update" }; - } - if (typeof data.caption === "string" && !data.captionSave) { - data.captionSave = data.caption.slice(0, 25); - } - await db - .update(CatalogPages) - .set(data as Partial) - .where(eq(CatalogPages.id, id)); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_page_update", - description: `Updated catalog page #${id}`, - targetType: "catalog_page", - targetId: id, + await executeLegacyEconomyMutation(staff, "catalog-page.change", { + action: "update", + id, + ...fields, }); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; @@ -73,14 +22,9 @@ export async function updateCatalogPage({ export async function deleteCatalogPage({ id }: { id: number }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); - await deletePage(id, "reparent"); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_page_delete", - description: `Deleted catalog page #${id}`, - targetType: "catalog_page", - targetId: id, + await executeLegacyEconomyMutation(staff, "catalog-page.change", { + action: "delete", + id, }); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; @@ -93,23 +37,11 @@ export async function toggleCatalogPage({ id: number; action: "toggleEnabled" | "toggleVisible"; }) { - await requirePermission(PERMS.CATALOG_EDIT); - const [page] = await db - .select({ - enabled: CatalogPages.enabled, - visible: CatalogPages.visible, - }) - .from(CatalogPages) - .where(eq(CatalogPages.id, id)) - .limit(1); - if (!page) return { ok: false as const, error: "Catalog page not found" }; - const field = action === "toggleEnabled" ? "enabled" : "visible"; - const current = action === "toggleEnabled" ? page.enabled : page.visible; - await db - .update(CatalogPages) - .set({ [field]: current === "1" ? "0" : "1" }) - .where(eq(CatalogPages.id, id)); - await rcon.updateCatalog(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); + await executeLegacyEconomyMutation(staff, "catalog-page.change", { + action: action === "toggleEnabled" ? "toggle-enabled" : "toggle-visible", + id, + }); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; } @@ -126,32 +58,12 @@ export async function createCatalogPage(input: { orderNum?: number; }): Promise> { const staff = await requirePermission(PERMS.CATALOG_EDIT); - const [result] = await db.insert(CatalogPages).values({ - caption: input.caption, - parentId: input.parentId, - pageLayout: input.pageLayout ?? "default_3x3", - captionSave: input.caption.slice(0, 25), - iconColor: input.iconColor ?? 0, - iconImage: input.iconImage ?? 0, - minRank: input.minRank ?? 1, - orderNum: input.orderNum ?? 0, - visible: input.visible ?? "1", - enabled: input.enabled ?? "1", - clubOnly: "0", - vipOnly: "0", - pageHeadline: "", - pageTeaser: "", - includes: "", - }); - const createdId = Number(result.insertId); - await rcon.updateCatalog(); - await logStaffActivity({ - staffId: staff.id, - action: "catalog_page_create", - description: `Created catalog page "${input.caption}"`, - targetType: "catalog_page", - targetId: createdId, - }); + const snapshot = await executeLegacyEconomyMutation( + staff, + "catalog-page.change", + { action: "create", ...input }, + ); + const createdId = Number(snapshot.output?.id); revalidatePath("/admin/catalog"); return { ok: true as const, data: { id: createdId } }; } @@ -161,22 +73,8 @@ export async function reorderTreePage(input: { newParentId?: number; newOrderNum: number; }) { - await requirePermission(PERMS.CATALOG_EDIT); - if (input.newParentId !== undefined) { - try { - await movePage(input.pageId, input.newParentId); - } catch (err) { - return { - ok: false as const, - error: err instanceof Error ? err.message : "Invalid move", - }; - } - } - await db - .update(CatalogPages) - .set({ orderNum: input.newOrderNum }) - .where(eq(CatalogPages.id, input.pageId)); - await rcon.updateCatalog(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); + await executeLegacyEconomyMutation(staff, "catalog-page.reorder", input); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; } @@ -185,9 +83,8 @@ export async function deleteTreePage(input: { pageId: number; mode: "reparent" | "cascade"; }) { - await requirePermission(PERMS.CATALOG_EDIT); - await deletePage(input.pageId, input.mode); - await rcon.updateCatalog(); + const staff = await requirePermission(PERMS.CATALOG_EDIT); + await executeLegacyEconomyMutation(staff, "catalog-page.delete-tree", input); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; } diff --git a/src/actions/furni-maintenance.ts b/src/actions/furni-maintenance.ts index 2cbea7ac3b..662da4e1b3 100644 --- a/src/actions/furni-maintenance.ts +++ b/src/actions/furni-maintenance.ts @@ -1,5 +1,9 @@ "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 { @@ -18,7 +22,16 @@ import type { import * as maintenance from "@/lib/services/furni-maintenance"; async function guard() { - await requirePermission(PERMS.CATALOG_EDIT); + 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< @@ -37,8 +50,9 @@ export async function fixSpriteIdsAction(): Promise< ActionResult > { try { - await guard(); - return actionOk(await maintenance.fixSpriteIds()); + return actionOk( + await mutate("maintenance.fix-sprite-ids"), + ); } catch (e) { return handleActionError(e); } @@ -48,8 +62,7 @@ export async function fixCatalogOffersAction(): Promise< ActionResult > { try { - await guard(); - return actionOk(await maintenance.fixCatalogOffers()); + return actionOk(await mutate("maintenance.fix-offers")); } catch (e) { return handleActionError(e); } @@ -59,8 +72,7 @@ export async function reconcileIdsAction(): Promise< ActionResult > { try { - await guard(); - return actionOk(await maintenance.reconcileIds()); + return actionOk(await mutate("maintenance.reconcile-ids")); } catch (e) { return handleActionError(e); } @@ -70,8 +82,7 @@ export async function removeDuplicateItemsBaseAction(): Promise< ActionResult > { try { - await guard(); - return actionOk(await maintenance.removeDuplicates()); + return actionOk(await mutate("maintenance.remove-duplicates")); } catch (e) { return handleActionError(e); } @@ -81,8 +92,9 @@ export async function previewAlignIdsAction(): Promise< ActionResult > { try { - await guard(); - return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(false)); + return actionOk( + await mutate("maintenance.align-ids", { apply: false }), + ); } catch (e) { return handleActionError(e); } @@ -92,8 +104,9 @@ export async function applyAlignIdsAction(): Promise< ActionResult > { try { - await guard(); - return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true)); + return actionOk( + await mutate("maintenance.align-ids", { apply: true }), + ); } catch (e) { return handleActionError(e); } @@ -103,8 +116,12 @@ export async function fixEverythingAction(input?: { dedupePages?: boolean; }): Promise> { try { - await guard(); - return actionOk(await maintenance.fixEverything(input ?? {})); + return actionOk( + await mutate( + "maintenance.fix-everything", + input ?? {}, + ), + ); } catch (e) { return handleActionError(e); } diff --git a/src/actions/items-base.ts b/src/actions/items-base.ts index f99d203c1f..9e7207445e 100644 --- a/src/actions/items-base.ts +++ b/src/actions/items-base.ts @@ -1,113 +1,26 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { db, ItemsBase } from "@/lib/db"; +import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; -import { ActionError, actionOk } from "@/lib/safe-action-shared"; -import { rcon } from "@/lib/services/rcon"; -import { logStaffActivity } from "@/lib/services/staff-activity"; - -const ITEMS_BASE_FIELDS = [ - "publicName", - "itemName", - "type", - "spriteId", - "width", - "length", - "stackHeight", - "allowStack", - "allowSit", - "allowLay", - "allowWalk", - "allowGift", - "allowTrade", - "allowRecycle", - "allowMarketplaceSell", - "allowInventoryStack", - "interactionType", - "interactionModesCount", - "vendingIds", - "multiheight", - "customparams", - "effectIdMale", - "effectIdFemale", - "clothingOnWalk", -] as const; +import { actionOk } from "@/lib/safe-action-shared"; const updateSchema = z.object({ id: z.coerce.number().int().positive(), fields: z.record(z.string(), z.unknown()), }); -function pickAllowed( - fields: Record, - allowed: readonly string[], -): Record { - const out: Record = {}; - for (const key of allowed) { - if (Object.hasOwn(fields, key) && fields[key] !== undefined) { - out[key] = fields[key]; - } - } - return out; -} - export const updateItemsBase = adminAction( { permission: PERMS.CATALOG_EDIT, schema: updateSchema }, - async (ctx) => { - const { id, fields } = ctx.data; - const safe = pickAllowed(fields, ITEMS_BASE_FIELDS); - if (Object.keys(safe).length === 0) { - throw new ActionError("No valid fields to update"); - } - - const [existing] = await db - .select({ id: ItemsBase.id }) - .from(ItemsBase) - .where(eq(ItemsBase.id, id)) - .limit(1); - if (!existing) throw new ActionError("Item not found"); - - // Coerce common numeric / decimal fields from form strings. - const data: Record = { ...safe }; - for (const key of [ - "spriteId", - "width", - "length", - "allowStack", - "allowSit", - "allowLay", - "allowWalk", - "allowGift", - "allowTrade", - "allowRecycle", - "allowMarketplaceSell", - "allowInventoryStack", - "interactionModesCount", - "effectIdMale", - "effectIdFemale", - ]) { - if (data[key] !== undefined) data[key] = Number(data[key]); - } - if (data.stackHeight !== undefined) { - data.stackHeight = Number(data.stackHeight); - } - - await db - .update(ItemsBase) - .set(data as Partial) - .where(eq(ItemsBase.id, id)); - await rcon.updateCatalog().catch(() => false); - await logStaffActivity({ - staffId: Number(ctx.session.user.id), - action: "items_base_update", - description: `Updated items_base #${id}`, - targetType: "items_base", - targetId: id, - }); + 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"); diff --git a/src/actions/soundtracks.ts b/src/actions/soundtracks.ts index 74456f10e0..949831b2e5 100644 --- a/src/actions/soundtracks.ts +++ b/src/actions/soundtracks.ts @@ -1,34 +1,30 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { executeLegacyEconomyMutation } from "@/features/housekeeping/domains/economy/services/mutations"; import { requirePermission } from "@/lib/admin/guard"; -import { db, Soundtracks } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; export async function deleteSoundtrack({ id }: { id: number }) { - await requirePermission(PERMS.CATALOG_EDIT); - await db.delete(Soundtracks).where(eq(Soundtracks.id, id)); + const staff = await requirePermission(PERMS.CATALOG_EDIT); + await executeLegacyEconomyMutation(staff, "soundtrack.change", { + action: "delete", + id, + }); revalidatePath("/admin/sounds"); } -export async function updateSoundtrack({ - id, - name, - author, - track, - length, -}: { +export async function updateSoundtrack(input: { id: number; name: string; author: string; track: string; length: number; }) { - await requirePermission(PERMS.CATALOG_EDIT); - await db - .update(Soundtracks) - .set({ name, author, track, length }) - .where(eq(Soundtracks.id, id)); + const staff = await requirePermission(PERMS.CATALOG_EDIT); + await executeLegacyEconomyMutation(staff, "soundtrack.change", { + action: "update", + ...input, + }); revalidatePath("/admin/sounds"); } diff --git a/src/app/admin/catalog/builder-club/bc-manager.test.tsx b/src/app/admin/catalog/builder-club/bc-manager.test.tsx new file mode 100644 index 0000000000..3d4f826a5e --- /dev/null +++ b/src/app/admin/catalog/builder-club/bc-manager.test.tsx @@ -0,0 +1,51 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { type BcPageData, BcPageDetail } from "./bc-manager"; + +vi.mock("@/actions/catalog-bc", () => ({ + createBcItem: vi.fn(), + deleteBcItem: vi.fn(), + updateBcItem: vi.fn(), + updateBcPage: vi.fn(), +})); +vi.mock("@/components/admin/confirm-dialog", () => ({ + useConfirmDialog: () => ({ confirm: vi.fn(), dialog: null }), +})); +vi.mock("@/hooks/use-server-action", () => ({ + useServerAction: () => ({ isPending: false, run: vi.fn() }), +})); + +const page: BcPageData = { + id: 7, + parentId: -1, + caption: "Builder Club", + pageLayout: "default_3x3", + iconColor: 0, + iconImage: 1, + orderNum: 1, + visible: "1", + enabled: "1", + pageHeadline: "", + pageTeaser: "", + pageSpecial: "", + pageText1: "", + pageText2: "", + pageTextDetails: "", + pageTextTeaser: "", +}; + +describe("BcPageDetail", () => { + it("uses the canonical return route when embedded in Housekeeping", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('href="/ase/economy/catalog"'); + expect(markup).not.toContain('href="/admin/catalog?catalog=bc"'); + }); +}); diff --git a/src/app/admin/catalog/builder-club/bc-manager.tsx b/src/app/admin/catalog/builder-club/bc-manager.tsx index 226811ae3e..9de2914b18 100644 --- a/src/app/admin/catalog/builder-club/bc-manager.tsx +++ b/src/app/admin/catalog/builder-club/bc-manager.tsx @@ -77,11 +77,17 @@ interface BcPageDetailProps { page: BcPageData; items: BcItemData[]; canEdit: boolean; + returnHref?: string; } // ── Main Component ─────────────────────────────────────────────────── -export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) { +export function BcPageDetail({ + page, + items, + canEdit, + returnHref = "/admin/catalog?catalog=bc", +}: BcPageDetailProps) { const { isPending, run } = useServerAction(); const { confirm, dialog: confirmDialog } = useConfirmDialog(); const formRef = useRef(null); @@ -136,7 +142,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) { {/* Header */}
diff --git a/src/features/housekeeping/domains/economy/commands/economy-commands.test.ts b/src/features/housekeeping/domains/economy/commands/economy-commands.test.ts new file mode 100644 index 0000000000..ebb77e43e4 --- /dev/null +++ b/src/features/housekeeping/domains/economy/commands/economy-commands.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; +import { createEconomyCommands, ECONOMY_COMMAND_IDS } from "./economy-commands"; + +const expected = [ + ["economy.catalog.page.change", "catalog-page.change", PERMS.CATALOG_EDIT], + ["economy.catalog.page.reorder", "catalog-page.reorder", PERMS.CATALOG_EDIT], + [ + "economy.catalog.page.delete-tree", + "catalog-page.delete-tree", + PERMS.CATALOG_EDIT, + ], + ["economy.catalog.bc-page.change", "bc-page.change", PERMS.CATALOG_EDIT], + ["economy.catalog.bc-page.reorder", "bc-page.reorder", PERMS.CATALOG_EDIT], + [ + "economy.catalog.bc-page.delete-tree", + "bc-page.delete-tree", + PERMS.CATALOG_EDIT, + ], + ["economy.catalog.bc-item.change", "bc-item.change", PERMS.CATALOG_EDIT], + ["economy.catalog.item.change", "catalog-item.change", PERMS.CATALOG_EDIT], + [ + "economy.catalog.item.bulk-create", + "catalog-item.bulk-create", + PERMS.CATALOG_EDIT, + ], + ["economy.catalog.item.move", "catalog-item.move", PERMS.CATALOG_EDIT], + ["economy.catalog.item.reorder", "catalog-item.reorder", PERMS.CATALOG_EDIT], + [ + "economy.catalog.item.translate", + "catalog-item.translate", + PERMS.CATALOG_EDIT, + ], + [ + "economy.catalog.maintenance.fix-offers", + "maintenance.fix-offers", + PERMS.CATALOG_EDIT, + ], + [ + "economy.catalog.maintenance.fix-everything", + "maintenance.fix-everything", + PERMS.CATALOG_EDIT, + ], + [ + "economy.catalog.maintenance.fix-sprite-ids", + "maintenance.fix-sprite-ids", + PERMS.CATALOG_EDIT, + ], + [ + "economy.catalog.maintenance.reconcile-ids", + "maintenance.reconcile-ids", + PERMS.CATALOG_EDIT, + ], + [ + "economy.catalog.maintenance.align-ids", + "maintenance.align-ids", + PERMS.CATALOG_EDIT, + ], + [ + "economy.catalog.maintenance.remove-duplicates", + "maintenance.remove-duplicates", + PERMS.CATALOG_EDIT, + ], + ["economy.items.base.update", "items-base.update", PERMS.CATALOG_EDIT], + [ + "economy.commerce.shop-article.change", + "shop-article.change", + PERMS.SHOP_EDIT, + ], + [ + "economy.commerce.marketplace.cancel", + "marketplace.cancel", + PERMS.SHOP_EDIT, + ], + ["economy.commerce.voucher.change", "voucher.change", PERMS.SHOP_EDIT], + ["economy.value.category.change", "rare-category.change", PERMS.SHOP_EDIT], + ["economy.value.rare.change", "rare-value.change", PERMS.SHOP_EDIT], + ["economy.rewards.badge.give", "badge.give", PERMS.CATALOG_EDIT], + ["economy.rewards.badge.upload", "badge.upload", PERMS.CATALOG_EDIT], + [ + "economy.rewards.soundtrack.change", + "soundtrack.change", + PERMS.CATALOG_EDIT, + ], +] as const; + +function context(): HousekeepingCapabilityContext { + return { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: () => true, + hasAny: () => true, + hasAll: () => true, + }; +} + +describe("Economy commands", () => { + it("registers the exact 27-operation matrix with existing ACLs", () => { + const commands = createEconomyCommands({ execute: vi.fn() } as never); + expect(ECONOMY_COMMAND_IDS).toEqual(expected.map(([id]) => id)); + expect( + commands.map((command) => [ + command.id, + command.operation, + command.capability.slugs[0], + ]), + ).toEqual(expected); + expect(commands.every((command) => command.owner === "economy")).toBe(true); + expect(commands.every((command) => command.risk === "sensitive")).toBe( + true, + ); + }); + + it("requires a reason for destructive, maintenance, cancellation, and grant operations", () => { + const commands = createEconomyCommands({ execute: vi.fn() } as never); + const required = commands + .filter((command) => command.requiresReason) + .map((command) => command.id); + expect(required).toEqual([ + "economy.catalog.page.delete-tree", + "economy.catalog.bc-page.delete-tree", + "economy.catalog.maintenance.fix-offers", + "economy.catalog.maintenance.fix-everything", + "economy.catalog.maintenance.fix-sprite-ids", + "economy.catalog.maintenance.reconcile-ids", + "economy.catalog.maintenance.align-ids", + "economy.catalog.maintenance.remove-duplicates", + "economy.commerce.marketplace.cancel", + "economy.rewards.badge.give", + "economy.rewards.badge.upload", + ]); + }); + + it("binds real execution to the server-authorized actor", async () => { + const execute = vi.fn(async () => ({ + ok: true as const, + data: { before: null, after: { id: "1" } }, + correlationId: "economy-command", + })); + const [command] = createEconomyCommands({ execute } as never); + await command.execute( + { + capability: context(), + correlationId: "economy-command", + ipAddress: "127.0.0.1", + }, + { action: "update", id: "1", caption: "Seasonal" }, + ); + expect(execute).toHaveBeenCalledWith( + { correlationId: "economy-command", expectedActorId: 42 }, + "catalog-page.change", + { action: "update", id: "1", caption: "Seasonal" }, + ); + }); +}); diff --git a/src/features/housekeeping/domains/economy/commands/economy-commands.ts b/src/features/housekeeping/domains/economy/commands/economy-commands.ts new file mode 100644 index 0000000000..0ea7e9b4c9 --- /dev/null +++ b/src/features/housekeeping/domains/economy/commands/economy-commands.ts @@ -0,0 +1,145 @@ +import "server-only"; + +import { z } from "zod"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCommand } from "../../../foundation/commands/registry"; +import { anyCapability } from "../../../foundation/contracts"; +import { + type EconomyMutationOperation, + type EconomyMutationService, + economyMutationService, +} from "../services/mutations"; + +const ECONOMY_COMMAND_DEFINITIONS = [ + ["economy.catalog.page.change", "catalog-page.change", PERMS.CATALOG_EDIT], + ["economy.catalog.page.reorder", "catalog-page.reorder", PERMS.CATALOG_EDIT], + [ + "economy.catalog.page.delete-tree", + "catalog-page.delete-tree", + PERMS.CATALOG_EDIT, + ], + ["economy.catalog.bc-page.change", "bc-page.change", PERMS.CATALOG_EDIT], + ["economy.catalog.bc-page.reorder", "bc-page.reorder", PERMS.CATALOG_EDIT], + [ + "economy.catalog.bc-page.delete-tree", + "bc-page.delete-tree", + PERMS.CATALOG_EDIT, + ], + ["economy.catalog.bc-item.change", "bc-item.change", PERMS.CATALOG_EDIT], + ["economy.catalog.item.change", "catalog-item.change", PERMS.CATALOG_EDIT], + [ + "economy.catalog.item.bulk-create", + "catalog-item.bulk-create", + PERMS.CATALOG_EDIT, + ], + ["economy.catalog.item.move", "catalog-item.move", PERMS.CATALOG_EDIT], + ["economy.catalog.item.reorder", "catalog-item.reorder", PERMS.CATALOG_EDIT], + [ + "economy.catalog.item.translate", + "catalog-item.translate", + PERMS.CATALOG_EDIT, + ], + [ + "economy.catalog.maintenance.fix-offers", + "maintenance.fix-offers", + PERMS.CATALOG_EDIT, + ], + [ + "economy.catalog.maintenance.fix-everything", + "maintenance.fix-everything", + PERMS.CATALOG_EDIT, + ], + [ + "economy.catalog.maintenance.fix-sprite-ids", + "maintenance.fix-sprite-ids", + PERMS.CATALOG_EDIT, + ], + [ + "economy.catalog.maintenance.reconcile-ids", + "maintenance.reconcile-ids", + PERMS.CATALOG_EDIT, + ], + [ + "economy.catalog.maintenance.align-ids", + "maintenance.align-ids", + PERMS.CATALOG_EDIT, + ], + [ + "economy.catalog.maintenance.remove-duplicates", + "maintenance.remove-duplicates", + PERMS.CATALOG_EDIT, + ], + ["economy.items.base.update", "items-base.update", PERMS.CATALOG_EDIT], + [ + "economy.commerce.shop-article.change", + "shop-article.change", + PERMS.SHOP_EDIT, + ], + [ + "economy.commerce.marketplace.cancel", + "marketplace.cancel", + PERMS.SHOP_EDIT, + ], + ["economy.commerce.voucher.change", "voucher.change", PERMS.SHOP_EDIT], + ["economy.value.category.change", "rare-category.change", PERMS.SHOP_EDIT], + ["economy.value.rare.change", "rare-value.change", PERMS.SHOP_EDIT], + ["economy.rewards.badge.give", "badge.give", PERMS.CATALOG_EDIT], + ["economy.rewards.badge.upload", "badge.upload", PERMS.CATALOG_EDIT], + [ + "economy.rewards.soundtrack.change", + "soundtrack.change", + PERMS.CATALOG_EDIT, + ], +] as const; + +export const ECONOMY_COMMAND_IDS = ECONOMY_COMMAND_DEFINITIONS.map( + ([id]) => id, +) as ReadonlyArray<(typeof ECONOMY_COMMAND_DEFINITIONS)[number][0]>; + +const REASON_COMMANDS = new Set([ + "economy.catalog.page.delete-tree", + "economy.catalog.bc-page.delete-tree", + "economy.catalog.maintenance.fix-offers", + "economy.catalog.maintenance.fix-everything", + "economy.catalog.maintenance.fix-sprite-ids", + "economy.catalog.maintenance.reconcile-ids", + "economy.catalog.maintenance.align-ids", + "economy.catalog.maintenance.remove-duplicates", + "economy.commerce.marketplace.cancel", + "economy.rewards.badge.give", + "economy.rewards.badge.upload", +]); + +type EconomyCommand = HousekeepingCommand, unknown> & { + readonly operation: EconomyMutationOperation; +}; + +const commandInput = z.object({}).catchall(z.unknown()); + +export function createEconomyCommands( + service: Pick, +): readonly EconomyCommand[] { + return ECONOMY_COMMAND_DEFINITIONS.map( + ([id, operation, permission]): EconomyCommand => ({ + id, + owner: "economy", + operation, + risk: "sensitive", + capability: anyCapability(permission), + input: commandInput, + requiresReason: REASON_COMMANDS.has(id), + rateLimit: { attempts: 10, windowMs: 60_000 }, + execute: (context, input) => + service.execute( + { + correlationId: context.correlationId, + expectedActorId: context.capability.actor.id, + }, + operation, + input, + ), + }), + ); +} + +export const ECONOMY_COMMANDS = createEconomyCommands(economyMutationService); diff --git a/src/features/housekeeping/domains/economy/economy-providers-production.test.ts b/src/features/housekeeping/domains/economy/economy-providers-production.test.ts new file mode 100644 index 0000000000..2bdc7ce38b --- /dev/null +++ b/src/features/housekeeping/domains/economy/economy-providers-production.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../foundation/contracts"; +import { fail, ok } from "../../foundation/contracts"; +import { loadEconomyInboxItems } from "./inbox-production"; +import { loadEconomySearchCandidates } from "./search-production"; +import { loadEconomyWidget } from "./widgets-production"; + +const context: HousekeepingCapabilityContext = { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: () => true, + hasAny: () => true, + hasAll: () => true, +}; + +function queryResult( + items: readonly { + id: string; + title: string; + description?: string; + status?: string; + updatedAt?: string | null; + href?: `/ase/${string}`; + }[], + total = items.length, +) { + return ok( + { + kind: "catalog" as const, + items, + total, + partialDependencies: [], + }, + "economy-provider-production", + ); +} + +describe("Economy production providers", () => { + it("routes search through the bounded production query", async () => { + const run = vi.fn().mockResolvedValue( + queryResult([ + { + id: "7", + title: "Root catalog", + description: "parent -1", + href: "/ase/economy/catalog/7", + }, + ]), + ); + const candidates = await loadEconomySearchCandidates( + "catalog-pages", + context, + "root", + 25, + { run }, + ); + expect(run).toHaveBeenCalledWith(context, { + routeId: "economy.catalog.overview", + list: { search: "root", pageSize: 25, offset: 0 }, + }); + expect(candidates[0]).toMatchObject({ + id: "economy.catalog.overview:7", + href: "/ase/economy/catalog/7", + capability: { mode: "any", slugs: [PERMS.CATALOG_VIEW] }, + }); + }); + + it("returns truthful widget totals and never invents zero on failure", async () => { + const run = vi + .fn() + .mockResolvedValueOnce(queryResult([], 12)) + .mockResolvedValueOnce(queryResult([], 340)); + await expect( + loadEconomyWidget("catalog", context, new AbortController().signal, { + run, + }), + ).resolves.toEqual({ pages: 12, items: 340 }); + const unavailable = vi + .fn() + .mockResolvedValue( + fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + "offline", + ), + ); + await expect( + loadEconomyWidget("value", context, new AbortController().signal, { + run: unavailable, + }), + ).rejects.toThrow("Economy widget query unavailable"); + }); + + it("emits only actionable commerce anomalies", async () => { + const run = vi.fn().mockResolvedValue( + queryResult([ + { + id: "1", + title: "Pending order", + status: "pending", + updatedAt: "2026-08-30T10:00:00.000Z", + href: "/ase/economy/history/transactions", + }, + { + id: "2", + title: "Completed order", + status: "completed", + href: "/ase/economy/history/transactions", + }, + ]), + ); + const items = await loadEconomyInboxItems( + "commerce", + context, + new AbortController().signal, + { run }, + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + sourceId: "economy.commerce-anomalies", + itemId: "1", + state: "pending", + }); + }); +}); diff --git a/src/features/housekeeping/domains/economy/economy-providers.test.ts b/src/features/housekeeping/domains/economy/economy-providers.test.ts new file mode 100644 index 0000000000..3d74781f54 --- /dev/null +++ b/src/features/housekeeping/domains/economy/economy-providers.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import { + anyCapability, + type HousekeepingCapabilityContext, +} from "../../foundation/contracts"; +import { createEconomyInboxSources, ECONOMY_INBOX_SOURCE_IDS } from "./inbox"; +import { + createEconomySearchProviders, + ECONOMY_SEARCH_PROVIDER_IDS, +} from "./search"; +import { createEconomyWidgets, ECONOMY_WIDGET_IDS } from "./widgets"; + +function context(granted: readonly string[]): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +describe("Economy providers", () => { + it("registers exact bounded catalog, item, and transaction search providers", async () => { + const candidates = Array.from({ length: 30 }, (_, index) => ({ + id: `item-${index}`, + title: `Item ${index}`, + href: `/ase/economy/items/${index + 1}`, + capability: anyCapability(PERMS.CATALOG_VIEW), + })); + const adapters = { + catalogPages: vi.fn(async () => []), + items: vi.fn(async () => candidates), + transactions: vi.fn(async () => []), + }; + const providers = createEconomySearchProviders(adapters); + expect(ECONOMY_SEARCH_PROVIDER_IDS).toEqual([ + "economy.catalog-pages", + "economy.items", + "economy.transactions", + ]); + expect(providers.map((provider) => provider.id)).toEqual( + ECONOMY_SEARCH_PROVIDER_IDS, + ); + const result = await providers[1].search(context([PERMS.CATALOG_VIEW]), { + term: " sofa ", + limit: 999, + }); + expect(adapters.items).toHaveBeenCalledWith(expect.anything(), "sofa", 25); + expect(result).toMatchObject({ ok: true }); + if (result.ok) expect(result.data).toHaveLength(25); + }); + + it("exposes capability-selective catalog and commerce anomaly sources", async () => { + const catalog = vi.fn(async () => []); + const commerce = vi.fn(async () => []); + const sources = createEconomyInboxSources({ catalog, commerce }); + expect(ECONOMY_INBOX_SOURCE_IDS).toEqual([ + "economy.catalog-attention", + "economy.commerce-anomalies", + ]); + await sources[0].getItems( + context([PERMS.CATALOG_VIEW]), + new AbortController().signal, + ); + const forbidden = await sources[1].getItems( + context([PERMS.CATALOG_VIEW]), + new AbortController().signal, + ); + expect(catalog).toHaveBeenCalledOnce(); + expect(commerce).not.toHaveBeenCalled(); + expect(forbidden).toMatchObject({ + ok: false, + error: { code: "FORBIDDEN" }, + }); + }); + + it("keeps catalog mandatory and commerce/value widgets optional", async () => { + const widgets = createEconomyWidgets({ + catalog: vi.fn(async () => ({ pages: 12, items: 40 })), + commerce: vi.fn(async () => ({ orders: 4 })), + value: vi.fn(async () => ({ rares: 9 })), + }); + expect(ECONOMY_WIDGET_IDS).toEqual([ + "economy.catalog-summary", + "economy.commerce-summary", + "economy.value-summary", + ]); + expect(widgets.map((widget) => widget.kind)).toEqual([ + "mandatory", + "optional", + "optional", + ]); + expect( + await widgets[0].load( + context([PERMS.CATALOG_VIEW]), + new AbortController().signal, + ), + ).toMatchObject({ ok: true, data: { pages: 12, items: 40 } }); + }); +}); diff --git a/src/features/housekeeping/domains/economy/inbox-production.ts b/src/features/housekeeping/domains/economy/inbox-production.ts new file mode 100644 index 0000000000..63af2123a9 --- /dev/null +++ b/src/features/housekeeping/domains/economy/inbox-production.ts @@ -0,0 +1,81 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { + anyCapability, + type HousekeepingCapabilityContext, + type HousekeepingWorkItem, +} from "../../foundation/contracts"; +import { economyQuery } from "./queries/catalog"; + +type EconomyInboxKind = "catalog" | "commerce"; + +function age(value: string | null | undefined, fallback: number) { + const parsed = value ? Date.parse(value) : fallback; + const timestamp = Number.isFinite(parsed) ? parsed : fallback; + const ageMs = Math.max(0, Date.now() - timestamp); + return { + occurredAt: new Date(timestamp).toISOString(), + ageMs, + freshness: ageMs > 86_400_000 ? ("stale" as const) : ("fresh" as const), + }; +} + +export async function loadEconomyInboxItems( + kind: EconomyInboxKind, + context: HousekeepingCapabilityContext, + signal: AbortSignal, + query: Pick = economyQuery, +): Promise { + if (signal.aborted) throw new Error("aborted Economy inbox"); + const routeId = + kind === "catalog" + ? ("economy.catalog.overview" as const) + : ("economy.history.transactions" as const); + const result = await query.run(context, { + routeId, + list: { pageSize: 25, offset: 0 }, + }); + if (!result.ok) throw new Error("Economy inbox query unavailable"); + const observedAt = Date.now(); + const actionable = + kind === "catalog" + ? new Set(["hidden"]) + : new Set([ + "failed", + "denied", + "refunded", + "cancelled", + "canceled", + "pending", + ]); + return result.data.items.flatMap((item) => { + const status = item.status?.toLocaleLowerCase() ?? ""; + if (!actionable.has(status) || !item.href) return []; + const sourceId = + kind === "catalog" + ? "economy.catalog-attention" + : "economy.commerce-anomalies"; + const permission = + kind === "catalog" ? PERMS.CATALOG_VIEW : PERMS.SHOP_VIEW; + return [ + { + sourceId, + itemId: item.id, + deduplicationKey: `${sourceId}:${routeId}:${item.id}`, + domain: "economy" as const, + capability: anyCapability(permission), + severity: + status === "pending" ? ("info" as const) : ("warning" as const), + priority: + status === "pending" ? ("normal" as const) : ("high" as const), + ...age(item.updatedAt, observedAt), + state: status, + titleKey: "pages.housekeeping.items.economy", + context: { title: item.title }, + href: item.href as `/ase/${string}`, + actions: [], + }, + ]; + }); +} diff --git a/src/features/housekeeping/domains/economy/inbox.ts b/src/features/housekeeping/domains/economy/inbox.ts new file mode 100644 index 0000000000..1392d67854 --- /dev/null +++ b/src/features/housekeeping/domains/economy/inbox.ts @@ -0,0 +1,94 @@ +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../foundation/authorization"; +import { satisfiesCapability } from "../../foundation/capability-context"; +import { + anyCapability, + type CapabilityRequirement, + fail, + type HousekeepingCapabilityContext, + type HousekeepingInboxSource, + type HousekeepingWorkItem, + ok, +} from "../../foundation/contracts"; +import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href"; + +export const ECONOMY_INBOX_SOURCE_IDS = [ + "economy.catalog-attention", + "economy.commerce-anomalies", +] as const; + +type EconomyInboxLoader = ( + context: HousekeepingCapabilityContext, + signal: AbortSignal, +) => Promise; + +export interface EconomyInboxAdapters { + readonly catalog: EconomyInboxLoader; + readonly commerce: EconomyInboxLoader; +} + +function createSource( + id: (typeof ECONOMY_INBOX_SOURCE_IDS)[number], + capability: CapabilityRequirement, + load: EconomyInboxLoader, +): HousekeepingInboxSource { + return { + id, + owner: "economy", + capability, + async getItems(context, signal) { + const authorization = authorizeHousekeeping(context, capability); + if (!authorization.ok) return authorization; + try { + const items = await load(context, signal); + return ok( + { + availability: "available" as const, + items: items + .filter( + (item) => + isSafeHousekeepingHref(item.href) && + satisfiesCapability(context, item.capability), + ) + .slice(0, 25), + }, + authorization.correlationId, + ); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + authorization.correlationId, + ); + } + }, + }; +} + +export function createEconomyInboxSources( + adapters: EconomyInboxAdapters, +): readonly HousekeepingInboxSource[] { + return [ + createSource( + "economy.catalog-attention", + anyCapability(PERMS.CATALOG_VIEW), + adapters.catalog, + ), + createSource( + "economy.commerce-anomalies", + anyCapability(PERMS.SHOP_VIEW), + adapters.commerce, + ), + ]; +} + +export const ECONOMY_INBOX_SOURCES = createEconomyInboxSources({ + async catalog(context, signal) { + const { loadEconomyInboxItems } = await import("./inbox-production"); + return loadEconomyInboxItems("catalog", context, signal); + }, + async commerce(context, signal) { + const { loadEconomyInboxItems } = await import("./inbox-production"); + return loadEconomyInboxItems("commerce", context, signal); + }, +}); diff --git a/src/features/housekeeping/domains/economy/manifest.ts b/src/features/housekeeping/domains/economy/manifest.ts index d500e1e02d..bc89693e0c 100644 --- a/src/features/housekeeping/domains/economy/manifest.ts +++ b/src/features/housekeeping/domains/economy/manifest.ts @@ -3,6 +3,10 @@ import { anyCapability, type HousekeepingDomainManifest, } from "../../foundation/contracts"; +import { ECONOMY_INBOX_SOURCES } from "./inbox"; +import { ECONOMY_ROUTES } from "./routes"; +import { ECONOMY_SEARCH_PROVIDERS } from "./search"; +import { ECONOMY_WIDGETS } from "./widgets"; export const economyManifest = { id: "economy", @@ -16,8 +20,8 @@ export const economyManifest = { PERMS.CATALOG_EDIT, PERMS.SHOP_EDIT, ), - routes: [], - searchProviders: [], - inboxSources: [], - widgets: [], + routes: ECONOMY_ROUTES, + searchProviders: ECONOMY_SEARCH_PROVIDERS, + inboxSources: ECONOMY_INBOX_SOURCES, + widgets: ECONOMY_WIDGETS, } satisfies HousekeepingDomainManifest; diff --git a/src/features/housekeeping/domains/economy/pages/catalog.tsx b/src/features/housekeeping/domains/economy/pages/catalog.tsx new file mode 100644 index 0000000000..638057e0d4 --- /dev/null +++ b/src/features/housekeeping/domains/economy/pages/catalog.tsx @@ -0,0 +1,115 @@ +import type { ReactNode } from "react"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { economyQuery } from "../queries/catalog"; +import { EconomyCommandForm } from "./economy-command-form"; +import { + EconomyPageFrame, + type EconomyPageProps, + parseEconomyListInput, +} from "./economy-page-frame"; + +function forms({ context, routeId }: EconomyPageProps) { + if (!context.has(PERMS.CATALOG_EDIT)) return null; + if (routeId === "economy.catalog.maintenance") { + return ( +
+ {[ + ["fix-offers", "Fix catalog offers"], + ["fix-everything", "Run complete catalog repair"], + ["fix-sprite-ids", "Fix sprite identifiers"], + ["reconcile-ids", "Reconcile identifiers"], + ["align-ids", "Align identifiers"], + ["remove-duplicates", "Remove duplicate base items"], + ].map(([operation, label]) => ( + + ))} +
+ ); + } + const builderClub = routeId === "economy.catalog.builder-club-detail"; + return ( + + ); +} + +export function EconomyCatalogPage( + props: EconomyPageProps & { readonly editor?: ReactNode }, +) { + return ( + + ); +} + +export async function renderEconomyCatalogPage(input: HousekeepingPageInput) { + const routeId = input.match.routeId as EconomyPageProps["routeId"]; + const result = await economyQuery.run(input.context, { + routeId: routeId ?? "economy.catalog.overview", + params: input.match.params, + list: parseEconomyListInput(input.searchParams ?? {}), + }); + const editor = + result.ok && + (routeId === "economy.catalog.detail" || + routeId === "economy.catalog.builder-club-detail") + ? await ( + await import("./specialized-catalog-editor") + ).renderSpecializedCatalogEditor( + routeId, + input.match.params, + input.context, + ) + : null; + return ( + + ); +} diff --git a/src/features/housekeeping/domains/economy/pages/commerce.tsx b/src/features/housekeeping/domains/economy/pages/commerce.tsx new file mode 100644 index 0000000000..7b2fd9e46a --- /dev/null +++ b/src/features/housekeeping/domains/economy/pages/commerce.tsx @@ -0,0 +1,128 @@ +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { economyQuery } from "../queries/catalog"; +import { EconomyCommandForm } from "./economy-command-form"; +import { + EconomyPageFrame, + type EconomyPageProps, + parseEconomyListInput, +} from "./economy-page-frame"; + +function commerceForm({ context, routeId }: EconomyPageProps) { + if (!context.has(PERMS.SHOP_EDIT)) return null; + if (routeId === "economy.commerce.vouchers") { + return ( + + ); + } + if (routeId === "economy.commerce.marketplace") { + return ( + + ); + } + if (routeId?.startsWith("economy.commerce.shop")) { + return ( + + ); + } + return null; +} + +export function EconomyCommercePage(props: EconomyPageProps) { + return ( + + ); +} + +export async function renderEconomyCommercePage(input: HousekeepingPageInput) { + const routeId = input.match.routeId as EconomyPageProps["routeId"]; + const result = await economyQuery.run(input.context, { + routeId: routeId ?? "economy.commerce.shop", + params: input.match.params, + list: parseEconomyListInput(input.searchParams ?? {}), + }); + return ( + + ); +} diff --git a/src/features/housekeeping/domains/economy/pages/economy-command-form.tsx b/src/features/housekeeping/domains/economy/pages/economy-command-form.tsx new file mode 100644 index 0000000000..1452cb2bd5 --- /dev/null +++ b/src/features/housekeeping/domains/economy/pages/economy-command-form.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useActionState } from "react"; +import type { HousekeepingResult } from "../../../foundation/contracts"; + +export interface EconomyCommandField { + readonly name: string; + readonly label: string; + readonly type: + | "identifier" + | "text" + | "textarea" + | "number" + | "checkbox" + | "file"; + readonly required?: boolean; + readonly min?: number; + readonly max?: number; + readonly maxLength?: number; + readonly defaultValue?: string | number | boolean; + readonly allowUnchanged?: boolean; +} + +export interface EconomyCommandSubmission { + readonly commandId: string; + readonly input: Readonly>; + readonly fields?: readonly EconomyCommandField[]; + readonly requiresReason?: boolean; +} + +interface EconomyCommandFormProps extends EconomyCommandSubmission { + readonly buttonLabel: string; +} + +const OMIT_FIELD = Symbol("omit optional Economy command field"); + +function parseField(field: EconomyCommandField, formData: FormData): unknown { + const rawValue = formData.get(field.name); + if (field.type === "checkbox") { + if (field.allowUnchanged) { + if (rawValue === null || rawValue === "") return OMIT_FIELD; + if (rawValue === "true") return true; + if (rawValue === "false") return false; + return OMIT_FIELD; + } + return rawValue === "on"; + } + if (rawValue === null && !field.required) return OMIT_FIELD; + if (field.type === "file") return rawValue instanceof File ? rawValue : null; + const raw = String(rawValue ?? "") + .normalize("NFC") + .trim(); + if (!raw && !field.required) return OMIT_FIELD; + if (field.type === "number") { + const value = Number(raw); + if (!Number.isSafeInteger(value)) return 0; + return Math.min(field.max ?? value, Math.max(field.min ?? value, value)); + } + return raw.slice( + 0, + field.maxLength ?? (field.type === "textarea" ? 20_000 : 500), + ); +} + +const initialState: HousekeepingResult | null = null; + +export async function submitEconomyCommandForm( + configuration: EconomyCommandSubmission, + _previous: HousekeepingResult | null, + formData: FormData, +): Promise> { + const submitted = (configuration.fields ?? []).flatMap((field) => { + const value = parseField(field, formData); + return value === OMIT_FIELD ? [] : [[field.name, value] as const]; + }); + const reason = String(formData.get("reason") ?? "") + .normalize("NFC") + .trim() + .slice(0, 1000); + const { executeHousekeepingCommand } = await import( + "@/actions/housekeeping-command" + ); + return executeHousekeepingCommand({ + commandId: configuration.commandId, + input: { ...configuration.input, ...Object.fromEntries(submitted) }, + ...(configuration.requiresReason ? { reason } : {}), + }); +} + +export function EconomyCommandForm({ + commandId, + buttonLabel, + input, + fields = [], + requiresReason = false, +}: EconomyCommandFormProps) { + const [result, submit, pending] = useActionState( + submitEconomyCommandForm.bind(null, { + commandId, + input, + fields, + requiresReason, + }), + initialState, + ); + return ( +
+ {fields.map((field) => { + const fieldId = `economy-${commandId}-${field.name}`; + return ( +