feat(housekeeping): deliver economy vertical
This commit is contained in:
1 parent
1ec05cda39
commit
9c4b973faf
62 files changed
+6306
-1285
No files matched your search
@@ -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 `<code>.gif`.
|
||||
|
||||
const CODE_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const MAX_BYTES = 1024 * 1024; // 1MB
|
||||
const ALLOWED_TYPES = new Set(["image/gif", "image/png"]);
|
||||
|
||||
function back(param: string, value: string): never {
|
||||
redirect(`/admin/badges?${param}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
|
||||
export async function uploadBadge(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
|
||||
const dir = process.env.BADGE_UPLOAD_DIR;
|
||||
if (!dir) {
|
||||
back("error", "Badge upload directory not configured");
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!CODE_RE.test(code)) {
|
||||
back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)");
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
back("error", "No file uploaded");
|
||||
}
|
||||
|
||||
if (file.size === 0) {
|
||||
back("error", "Uploaded file is empty");
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
back("error", "File too large (max 1MB)");
|
||||
}
|
||||
if (!ALLOWED_TYPES.has(file.type)) {
|
||||
back("error", "File must be a GIF or PNG image");
|
||||
}
|
||||
|
||||
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)}`);
|
||||
}
|
||||
@@ -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<void> {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
|
||||
const userId = Number(formData.get("userId"));
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 32);
|
||||
if (!(userId > 0) || code.length === 0) return;
|
||||
|
||||
// Fire the emulator command so the badge appears live for online users.
|
||||
await rcon.giveBadge(userId, code);
|
||||
|
||||
// Persist the badge directly so it survives a relog / offline grant.
|
||||
// users_badges has no unique (user_id, badge_code) constraint, so guard
|
||||
// against duplicates and compute the next free slot ourselves.
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({ id: UsersBadges.id })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) {
|
||||
const [agg] = await db
|
||||
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, userId));
|
||||
const slotId = (agg?.maxSlot ?? 0) + 1;
|
||||
await db.insert(UsersBadges).values({ userId, slotId, badgeCode: code });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: the RCON grant already succeeded for online users.
|
||||
}
|
||||
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "badge.give", {
|
||||
userId: formData.get("userId"),
|
||||
badge: formData.get("code"),
|
||||
});
|
||||
revalidatePath("/admin/badges");
|
||||
}
|
||||
@@ -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<void> {
|
||||
const staff = await requirePermissionRateLimited(PERMS.SHOP_EDIT);
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
const [listing] = await db
|
||||
.select({
|
||||
id: MarketplaceItems.id,
|
||||
state: MarketplaceItems.state,
|
||||
userId: MarketplaceItems.userId,
|
||||
itemId: MarketplaceItems.itemId,
|
||||
price: MarketplaceItems.price,
|
||||
})
|
||||
.from(MarketplaceItems)
|
||||
.where(eq(MarketplaceItems.id, id))
|
||||
.limit(1);
|
||||
if (listing?.state !== 1) return;
|
||||
|
||||
await db
|
||||
.update(MarketplaceItems)
|
||||
.set({ state: 0 })
|
||||
.where(eq(MarketplaceItems.id, id));
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "marketplace_cancel",
|
||||
description: `Cancelled marketplace listing #${id} (item ${listing.itemId}, user ${listing.userId}, price ${listing.price})`,
|
||||
targetType: "marketplace",
|
||||
targetId: id,
|
||||
await executeLegacyEconomyMutation(staff, "marketplace.cancel", {
|
||||
id: formData.get("id"),
|
||||
});
|
||||
revalidatePath("/admin/marketplace");
|
||||
}
|
||||
@@ -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<void> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const priorityRaw = Number(formData.get("priority"));
|
||||
const priority =
|
||||
Number.isFinite(priorityRaw) && priorityRaw > 0
|
||||
? Math.floor(priorityRaw)
|
||||
: 1;
|
||||
if (!name || !badge) return;
|
||||
function formInput(formData: FormData): Record<string, FormDataEntryValue> {
|
||||
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<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "rare-category.change", {
|
||||
action: "create",
|
||||
...formInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
|
||||
export async function deleteCategory(formData: FormData): Promise<void> {
|
||||
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<void> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
const categoryId = formPositiveBigInt(formData, "categoryId");
|
||||
if (!categoryId) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name || !furnitureIcon) return;
|
||||
|
||||
const itemIdRaw = Number(formData.get("itemId"));
|
||||
const itemId =
|
||||
Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
|
||||
|
||||
const creditValueRaw = String(formData.get("creditValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyType =
|
||||
String(formData.get("currencyType") ?? "diamonds")
|
||||
.trim()
|
||||
.slice(0, 255) || "diamonds";
|
||||
|
||||
try {
|
||||
await db.insert(WebsiteRareValues).values({
|
||||
categoryId,
|
||||
itemId,
|
||||
name,
|
||||
creditValue: creditValueRaw || null,
|
||||
currencyValue: currencyValueRaw || null,
|
||||
currencyType,
|
||||
furnitureIcon,
|
||||
});
|
||||
} catch {
|
||||
// DB error — ignore.
|
||||
}
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
await executeLegacyEconomyMutation(staff, "rare-value.change", {
|
||||
action: "create",
|
||||
...formInput(formData),
|
||||
});
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
|
||||
export async function deleteValue(formData: FormData): Promise<void> {
|
||||
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");
|
||||
}
|
||||
+18
-135
@@ -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<string, FormDataEntryValue> {
|
||||
return Object.fromEntries(formData.entries());
|
||||
}
|
||||
|
||||
export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const now = new Date();
|
||||
const costs = reqUInt(formData, "costs");
|
||||
try {
|
||||
const [result] = (await db.insert(WebsiteShopArticles).values({
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs,
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_create",
|
||||
description: `Created shop package "${name}" (${costs} costs)`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(result.insertId),
|
||||
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<void> {
|
||||
const staff = await requirePermission(PERMS.SHOP_EDIT);
|
||||
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
@@ -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<ActionResult<{ id: string }>> {
|
||||
await requirePermission(PERMS.SHOP_EDIT);
|
||||
|
||||
const code = String(input.code ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const amount = Number(input.amount);
|
||||
const maxUsesRaw = Number(input.maxUses);
|
||||
const maxUses =
|
||||
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
|
||||
|
||||
if (!code || !(amount > 0)) {
|
||||
return actionError("Code and a positive amount are required");
|
||||
}
|
||||
|
||||
let expiresAt: Date | null = null;
|
||||
const expiresRaw = String(input.expiresAt ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (expiresRaw) {
|
||||
const parsed = new Date(expiresRaw);
|
||||
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
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<ActionResult> {
|
||||
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");
|
||||
}
|
||||
|
||||
+41
-220
@@ -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<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||
out[key] = fields[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
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<string, unknown>) {
|
||||
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<typeof CatalogPagesBc.$inferInsert>)
|
||||
.where(eq(CatalogPagesBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_update",
|
||||
description: `Updated BC catalog page #${id}`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: id,
|
||||
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<string, unknown>, BC_ITEM_FIELDS);
|
||||
if (Object.keys(safe).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
await db
|
||||
.update(CatalogItemsBc)
|
||||
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
|
||||
.where(eq(CatalogItemsBc.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_update",
|
||||
description: `Updated BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
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<void> {
|
||||
if (newParentId > 0) {
|
||||
let currentId = newParentId;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (currentId === pageId) {
|
||||
throw new Error("Cannot move page: would create a circular hierarchy");
|
||||
}
|
||||
const [parent] = await db
|
||||
.select({ parentId: CatalogPagesBc.parentId })
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, currentId))
|
||||
.limit(1);
|
||||
if (!parent || parent.parentId <= 0) break;
|
||||
currentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
await db
|
||||
.update(CatalogPagesBc)
|
||||
.set({ parentId: newParentId })
|
||||
.where(eq(CatalogPagesBc.id, pageId));
|
||||
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: {} };
|
||||
}
|
||||
+62
-388
@@ -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<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||
out[key] = fields[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */
|
||||
async function insertCatalogItemRow(data: {
|
||||
pageId: number;
|
||||
itemIds: string;
|
||||
catalogName: string;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
orderNumber: number;
|
||||
offerId: number;
|
||||
songId: number;
|
||||
limitedSells: number;
|
||||
limitedStack: number;
|
||||
extradata: string;
|
||||
haveOffer: string;
|
||||
clubOnly: string;
|
||||
}): Promise<number> {
|
||||
const pageIdStr = String(data.pageId);
|
||||
return allocateCatalogItemId(async (nextId) => {
|
||||
await db.execute(sql`
|
||||
INSERT INTO catalog_items (
|
||||
id, page_id, item_ids, catalog_name,
|
||||
cost_credits, cost_points, points_type, amount,
|
||||
order_number, offer_id, song_id,
|
||||
limited_sells, limited_stack, extradata, have_offer, club_only
|
||||
) VALUES (
|
||||
${nextId}, ${pageIdStr}, ${data.itemIds}, ${data.catalogName},
|
||||
${data.costCredits}, ${data.costPoints}, ${data.pointsType}, ${data.amount},
|
||||
${data.orderNumber}, ${data.offerId}, ${data.songId},
|
||||
${data.limitedSells}, ${data.limitedStack}, ${data.extradata},
|
||||
${data.haveOffer}, ${data.clubOnly}
|
||||
)
|
||||
`);
|
||||
return nextId;
|
||||
});
|
||||
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<string, unknown>;
|
||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||
}) {
|
||||
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<typeof CatalogItems.$inferInsert>)
|
||||
.where(eq(CatalogItems.id, id));
|
||||
}
|
||||
if (baseItem) {
|
||||
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
|
||||
if (Object.keys(safeBase).length > 0) {
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
|
||||
.where(eq(ItemsBase.id, baseItem.id));
|
||||
}
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_update",
|
||||
description: `Updated catalog item #${id}`,
|
||||
targetType: "catalog_item",
|
||||
targetId: id,
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
+23
-126
@@ -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<string, unknown>) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of CATALOG_PAGE_FIELDS) {
|
||||
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||
out[key] = fields[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function updateCatalogPage({
|
||||
id,
|
||||
...fields
|
||||
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const data = pickPageFields(fields);
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
if (typeof data.caption === "string" && !data.captionSave) {
|
||||
data.captionSave = data.caption.slice(0, 25);
|
||||
}
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set(data as Partial<typeof CatalogPages.$inferInsert>)
|
||||
.where(eq(CatalogPages.id, id));
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_update",
|
||||
description: `Updated catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
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<ActionResult<{ id: number }>> {
|
||||
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: {} };
|
||||
}
|
||||
@@ -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<T>(
|
||||
operation: EconomyMutationOperation,
|
||||
input: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const staff = await guard();
|
||||
const snapshot = await executeLegacyEconomyMutation(staff, operation, input);
|
||||
return snapshot.output as T;
|
||||
}
|
||||
|
||||
export async function getFurniHealthAction(): Promise<
|
||||
@@ -37,8 +50,9 @@ export async function fixSpriteIdsAction(): Promise<
|
||||
ActionResult<FixSpriteResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.fixSpriteIds());
|
||||
return actionOk(
|
||||
await mutate<FixSpriteResult>("maintenance.fix-sprite-ids"),
|
||||
);
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -48,8 +62,7 @@ export async function fixCatalogOffersAction(): Promise<
|
||||
ActionResult<FixOfferResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.fixCatalogOffers());
|
||||
return actionOk(await mutate<FixOfferResult>("maintenance.fix-offers"));
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -59,8 +72,7 @@ export async function reconcileIdsAction(): Promise<
|
||||
ActionResult<ReconcileResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.reconcileIds());
|
||||
return actionOk(await mutate<ReconcileResult>("maintenance.reconcile-ids"));
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -70,8 +82,7 @@ export async function removeDuplicateItemsBaseAction(): Promise<
|
||||
ActionResult<DedupResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.removeDuplicates());
|
||||
return actionOk(await mutate<DedupResult>("maintenance.remove-duplicates"));
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -81,8 +92,9 @@ export async function previewAlignIdsAction(): Promise<
|
||||
ActionResult<AlignResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(false));
|
||||
return actionOk(
|
||||
await mutate<AlignResult>("maintenance.align-ids", { apply: false }),
|
||||
);
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -92,8 +104,9 @@ export async function applyAlignIdsAction(): Promise<
|
||||
ActionResult<AlignResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true));
|
||||
return actionOk(
|
||||
await mutate<AlignResult>("maintenance.align-ids", { apply: true }),
|
||||
);
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
@@ -103,8 +116,12 @@ export async function fixEverythingAction(input?: {
|
||||
dedupePages?: boolean;
|
||||
}): Promise<ActionResult<maintenance.FixAllResult>> {
|
||||
try {
|
||||
await guard();
|
||||
return actionOk(await maintenance.fixEverything(input ?? {}));
|
||||
return actionOk(
|
||||
await mutate<maintenance.FixAllResult>(
|
||||
"maintenance.fix-everything",
|
||||
input ?? {},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||
out[key] = fields[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const updateItemsBase = adminAction(
|
||||
{ permission: PERMS.CATALOG_EDIT, schema: updateSchema },
|
||||
async (ctx) => {
|
||||
const { id, fields } = ctx.data;
|
||||
const safe = pickAllowed(fields, ITEMS_BASE_FIELDS);
|
||||
if (Object.keys(safe).length === 0) {
|
||||
throw new ActionError("No valid fields to update");
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: ItemsBase.id })
|
||||
.from(ItemsBase)
|
||||
.where(eq(ItemsBase.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Item not found");
|
||||
|
||||
// Coerce common numeric / decimal fields from form strings.
|
||||
const data: Record<string, unknown> = { ...safe };
|
||||
for (const key of [
|
||||
"spriteId",
|
||||
"width",
|
||||
"length",
|
||||
"allowStack",
|
||||
"allowSit",
|
||||
"allowLay",
|
||||
"allowWalk",
|
||||
"allowGift",
|
||||
"allowTrade",
|
||||
"allowRecycle",
|
||||
"allowMarketplaceSell",
|
||||
"allowInventoryStack",
|
||||
"interactionModesCount",
|
||||
"effectIdMale",
|
||||
"effectIdFemale",
|
||||
]) {
|
||||
if (data[key] !== undefined) data[key] = Number(data[key]);
|
||||
}
|
||||
if (data.stackHeight !== undefined) {
|
||||
data.stackHeight = Number(data.stackHeight);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(ItemsBase)
|
||||
.set(data as Partial<typeof ItemsBase.$inferInsert>)
|
||||
.where(eq(ItemsBase.id, id));
|
||||
await rcon.updateCatalog().catch(() => false);
|
||||
await logStaffActivity({
|
||||
staffId: Number(ctx.session.user.id),
|
||||
action: "items_base_update",
|
||||
description: `Updated items_base #${id}`,
|
||||
targetType: "items_base",
|
||||
targetId: id,
|
||||
});
|
||||
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");
|
||||
|
||||
+12
-16
@@ -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");
|
||||
}
|
||||
@@ -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(
|
||||
<BcPageDetail
|
||||
page={page}
|
||||
items={[]}
|
||||
canEdit={false}
|
||||
returnHref="/ase/economy/catalog"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('href="/ase/economy/catalog"');
|
||||
expect(markup).not.toContain('href="/admin/catalog?catalog=bc"');
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLFormElement>(null);
|
||||
@@ -136,7 +142,7 @@ export function BcPageDetail({ page, items, canEdit }: BcPageDetailProps) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/admin/catalog?catalog=bc"
|
||||
href={returnHref}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string>([
|
||||
"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<Record<string, unknown>, unknown> & {
|
||||
readonly operation: EconomyMutationOperation;
|
||||
};
|
||||
|
||||
const commandInput = z.object({}).catchall(z.unknown());
|
||||
|
||||
export function createEconomyCommands(
|
||||
service: Pick<EconomyMutationService, "execute">,
|
||||
): 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);
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
@@ -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<typeof economyQuery, "run"> = economyQuery,
|
||||
): Promise<readonly HousekeepingWorkItem[]> {
|
||||
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: [],
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -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<readonly HousekeepingWorkItem[]>;
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{[
|
||||
["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]) => (
|
||||
<EconomyCommandForm
|
||||
key={operation}
|
||||
commandId={`economy.catalog.maintenance.${operation}`}
|
||||
buttonLabel={label}
|
||||
input={{}}
|
||||
requiresReason
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const builderClub = routeId === "economy.catalog.builder-club-detail";
|
||||
return (
|
||||
<EconomyCommandForm
|
||||
commandId={
|
||||
builderClub
|
||||
? "economy.catalog.bc-page.change"
|
||||
: "economy.catalog.page.change"
|
||||
}
|
||||
buttonLabel="Save catalog page"
|
||||
input={{
|
||||
action: routeId === "economy.catalog.overview" ? "create" : "update",
|
||||
}}
|
||||
fields={[
|
||||
{ name: "id", label: "ID", type: "identifier" },
|
||||
{
|
||||
name: "caption",
|
||||
label: "Caption",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 128,
|
||||
},
|
||||
{
|
||||
name: "parentId",
|
||||
label: "Parent ID",
|
||||
type: "number",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "pageLayout",
|
||||
label: "Page layout",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 26,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function EconomyCatalogPage(
|
||||
props: EconomyPageProps & { readonly editor?: ReactNode },
|
||||
) {
|
||||
return (
|
||||
<EconomyPageFrame
|
||||
title="Catalog"
|
||||
description="Catalog pages, Builder Club and maintenance workflows."
|
||||
result={props.result}
|
||||
forms={props.editor ?? forms(props)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<EconomyCatalogPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
editor={editor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<EconomyCommandForm
|
||||
commandId="economy.commerce.voucher.change"
|
||||
buttonLabel="Create voucher"
|
||||
input={{ action: "create" }}
|
||||
fields={[
|
||||
{
|
||||
name: "code",
|
||||
label: "Code",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "amount",
|
||||
label: "Amount",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 1,
|
||||
},
|
||||
{
|
||||
name: "maxUses",
|
||||
label: "Maximum uses",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 1,
|
||||
},
|
||||
{ name: "expiresAt", label: "Expires at", type: "text" },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (routeId === "economy.commerce.marketplace") {
|
||||
return (
|
||||
<EconomyCommandForm
|
||||
commandId="economy.commerce.marketplace.cancel"
|
||||
buttonLabel="Cancel listing"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
label: "Listing ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (routeId?.startsWith("economy.commerce.shop")) {
|
||||
return (
|
||||
<EconomyCommandForm
|
||||
commandId="economy.commerce.shop-article.change"
|
||||
buttonLabel="Save shop package"
|
||||
input={{
|
||||
action:
|
||||
routeId === "economy.commerce.shop-detail" ? "update" : "create",
|
||||
}}
|
||||
fields={[
|
||||
{ name: "id", label: "ID", type: "identifier" },
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "info",
|
||||
label: "Information",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "costs",
|
||||
label: "Costs",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function EconomyCommercePage(props: EconomyPageProps) {
|
||||
return (
|
||||
<EconomyPageFrame
|
||||
title="Commerce"
|
||||
description="Shop, marketplace, vouchers and subscriptions."
|
||||
result={props.result}
|
||||
forms={commerceForm(props)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<EconomyCommercePage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, unknown>>;
|
||||
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<unknown> | null = null;
|
||||
|
||||
export async function submitEconomyCommandForm(
|
||||
configuration: EconomyCommandSubmission,
|
||||
_previous: HousekeepingResult<unknown> | null,
|
||||
formData: FormData,
|
||||
): Promise<HousekeepingResult<unknown>> {
|
||||
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 (
|
||||
<form
|
||||
action={submit}
|
||||
data-housekeeping-command={commandId}
|
||||
className="space-y-3 rounded border border-[var(--admin-border)] p-3"
|
||||
>
|
||||
{fields.map((field) => {
|
||||
const fieldId = `economy-${commandId}-${field.name}`;
|
||||
return (
|
||||
<label key={field.name} htmlFor={fieldId} className="block text-sm">
|
||||
{field.type === "checkbox" && field.allowUnchanged ? (
|
||||
<>
|
||||
{field.label}
|
||||
<select
|
||||
id={fieldId}
|
||||
name={field.name}
|
||||
defaultValue=""
|
||||
className="mt-1 block w-full"
|
||||
>
|
||||
<option value="">No change</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</>
|
||||
) : field.type === "checkbox" ? (
|
||||
<>
|
||||
<input
|
||||
id={fieldId}
|
||||
name={field.name}
|
||||
type="checkbox"
|
||||
defaultChecked={field.defaultValue === true}
|
||||
/>{" "}
|
||||
{field.label}
|
||||
</>
|
||||
) : field.type === "textarea" ? (
|
||||
<>
|
||||
{field.label}
|
||||
<textarea
|
||||
id={fieldId}
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
maxLength={field.maxLength}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{field.label}
|
||||
<input
|
||||
id={fieldId}
|
||||
name={field.name}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "file"
|
||||
? "file"
|
||||
: "text"
|
||||
}
|
||||
required={field.required}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
maxLength={field.maxLength}
|
||||
className="mt-1 block w-full"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{requiresReason ? (
|
||||
<label
|
||||
htmlFor={`economy-${commandId}-reason`}
|
||||
className="block text-sm"
|
||||
>
|
||||
Reason
|
||||
<textarea
|
||||
id={`economy-${commandId}-reason`}
|
||||
name="reason"
|
||||
required
|
||||
maxLength={1000}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? "Working..." : buttonLabel}
|
||||
</button>
|
||||
{result ? (
|
||||
<p role="status">
|
||||
{result.ok ? "Completed" : "Failed"} ({result.correlationId})
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingResult,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { EconomyQueryData } from "../queries/catalog";
|
||||
import type { EconomyRouteId } from "../routes";
|
||||
|
||||
export interface EconomyPageProps {
|
||||
readonly context: HousekeepingCapabilityContext;
|
||||
readonly result?: HousekeepingResult<EconomyQueryData>;
|
||||
readonly routeId: EconomyRouteId | null;
|
||||
}
|
||||
|
||||
export function EconomyPageFrame({
|
||||
title,
|
||||
description,
|
||||
result,
|
||||
forms,
|
||||
}: {
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly result?: HousekeepingResult<EconomyQueryData>;
|
||||
readonly forms?: ReactNode;
|
||||
}) {
|
||||
if (!result) {
|
||||
return (
|
||||
<section data-housekeeping-state="loading">
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
<p>Loading economy data…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (!result.ok) {
|
||||
const state = result.error.code === "FORBIDDEN" ? "forbidden" : "error";
|
||||
return (
|
||||
<section data-housekeeping-state={state} role="alert">
|
||||
<h1>{title}</h1>
|
||||
<p>{result.error.messageKey}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const state = result.data.items.length === 0 ? "empty" : "ready";
|
||||
return (
|
||||
<section data-housekeeping-state={state} className="space-y-4">
|
||||
<header>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</header>
|
||||
{forms}
|
||||
{result.data.items.length === 0 ? (
|
||||
<p>No matching economy records.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--admin-border)]">
|
||||
{result.data.items.map((item) => (
|
||||
<li key={item.id} className="py-2">
|
||||
{item.href ? <a href={item.href}>{item.title}</a> : item.title}
|
||||
{item.status ? <span> — {item.status}</span> : null}
|
||||
{item.amount ? <span> — {item.amount}</span> : null}
|
||||
{item.description ? <p>{item.description}</p> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function parseEconomyListInput(
|
||||
searchParams: Readonly<
|
||||
Record<string, string | readonly string[] | undefined>
|
||||
>,
|
||||
) {
|
||||
const first = (value: string | readonly string[] | undefined) =>
|
||||
Array.isArray(value) ? value[0] : value;
|
||||
return {
|
||||
search: first(searchParams.search),
|
||||
pageSize: Number(first(searchParams.pageSize) ?? 25),
|
||||
offset: Number(first(searchParams.offset) ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import { EconomyCatalogPage } from "./catalog";
|
||||
import { EconomyCommercePage } from "./commerce";
|
||||
import { EconomyHistoryPage } from "./history";
|
||||
import { EconomyItemsPage } from "./items";
|
||||
import { EconomyRewardsPage } from "./rewards";
|
||||
import { EconomyValuePage } from "./value";
|
||||
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
const cases = [
|
||||
["catalog", EconomyCatalogPage],
|
||||
["items", EconomyItemsPage],
|
||||
["commerce", EconomyCommercePage],
|
||||
["history", EconomyHistoryPage],
|
||||
["value", EconomyValuePage],
|
||||
["rewards", EconomyRewardsPage],
|
||||
] as const;
|
||||
|
||||
describe.each(cases)("Economy %s page", (kind, Component) => {
|
||||
it("renders loading, forbidden, empty, and canonical ready states", () => {
|
||||
const render = (result?: unknown) =>
|
||||
renderToStaticMarkup(
|
||||
<Component
|
||||
context={context(Object.values(PERMS))}
|
||||
result={result as never}
|
||||
routeId={null}
|
||||
/>,
|
||||
);
|
||||
expect(render()).toContain('data-housekeeping-state="loading"');
|
||||
expect(
|
||||
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", "denied")),
|
||||
).toContain('data-housekeeping-state="forbidden"');
|
||||
expect(
|
||||
render(
|
||||
ok({ kind, items: [], total: 0, partialDependencies: [] }, "empty"),
|
||||
),
|
||||
).toContain('data-housekeeping-state="empty"');
|
||||
const ready = render(
|
||||
ok(
|
||||
{
|
||||
kind,
|
||||
items: [
|
||||
{
|
||||
id: "18446744073709551615",
|
||||
title: "Canonical economy item",
|
||||
status: "active",
|
||||
href: `/ase/economy/${kind}`,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
},
|
||||
"ready",
|
||||
),
|
||||
);
|
||||
expect(ready).toContain('data-housekeeping-state="ready"');
|
||||
expect(ready).toContain("Canonical economy item");
|
||||
expect(ready).not.toContain("/admin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Economy mutation forms", () => {
|
||||
it("renders edit controls only with the exact mutation capability", () => {
|
||||
const result = ok(
|
||||
{
|
||||
kind: "commerce" as const,
|
||||
items: [{ id: "1", title: "Package", status: "active" }],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
},
|
||||
"shop",
|
||||
);
|
||||
const readOnly = renderToStaticMarkup(
|
||||
<EconomyCommercePage
|
||||
context={context([PERMS.SHOP_VIEW])}
|
||||
result={result}
|
||||
routeId="economy.commerce.shop"
|
||||
/>,
|
||||
);
|
||||
const editor = renderToStaticMarkup(
|
||||
<EconomyCommercePage
|
||||
context={context([PERMS.SHOP_VIEW, PERMS.SHOP_EDIT])}
|
||||
result={result}
|
||||
routeId="economy.commerce.shop"
|
||||
/>,
|
||||
);
|
||||
expect(readOnly).not.toContain("economy.commerce.shop-article.change");
|
||||
expect(editor).toContain("economy.commerce.shop-article.change");
|
||||
expect(editor).toContain("<form");
|
||||
});
|
||||
|
||||
it("renders validator-shaped catalog, voucher, value, and soundtrack fields", () => {
|
||||
const all = context(Object.values(PERMS));
|
||||
const empty = (kind: "catalog" | "commerce" | "value" | "rewards") =>
|
||||
ok({ kind, items: [], total: 0, partialDependencies: [] }, kind);
|
||||
const catalog = renderToStaticMarkup(
|
||||
<EconomyCatalogPage
|
||||
context={all}
|
||||
result={empty("catalog")}
|
||||
routeId="economy.catalog.detail"
|
||||
/>,
|
||||
);
|
||||
for (const field of ["id", "caption", "parentId", "pageLayout"]) {
|
||||
expect(catalog).toContain(`name="${field}"`);
|
||||
}
|
||||
const commerce = renderToStaticMarkup(
|
||||
<EconomyCommercePage
|
||||
context={all}
|
||||
result={empty("commerce")}
|
||||
routeId="economy.commerce.vouchers"
|
||||
/>,
|
||||
);
|
||||
for (const field of ["code", "amount", "maxUses", "expiresAt"]) {
|
||||
expect(commerce).toContain(`name="${field}"`);
|
||||
}
|
||||
const value = renderToStaticMarkup(
|
||||
<EconomyValuePage
|
||||
context={all}
|
||||
result={empty("value")}
|
||||
routeId="economy.value.rare-values"
|
||||
/>,
|
||||
);
|
||||
for (const field of [
|
||||
"categoryId",
|
||||
"name",
|
||||
"currencyValue",
|
||||
"currencyType",
|
||||
]) {
|
||||
expect(value).toContain(`name="${field}"`);
|
||||
}
|
||||
const rewards = renderToStaticMarkup(
|
||||
<EconomyRewardsPage
|
||||
context={all}
|
||||
result={empty("rewards")}
|
||||
routeId="economy.rewards.sounds"
|
||||
/>,
|
||||
);
|
||||
for (const field of ["id", "name", "author", "track", "length"]) {
|
||||
expect(rewards).toContain(`name="${field}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||
import { economyQuery } from "../queries/catalog";
|
||||
import {
|
||||
EconomyPageFrame,
|
||||
type EconomyPageProps,
|
||||
parseEconomyListInput,
|
||||
} from "./economy-page-frame";
|
||||
|
||||
export function EconomyHistoryPage(props: EconomyPageProps) {
|
||||
return (
|
||||
<EconomyPageFrame
|
||||
title="Transaction history"
|
||||
description="Stable, read-only commerce history."
|
||||
result={props.result}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderEconomyHistoryPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as EconomyPageProps["routeId"];
|
||||
const result = await economyQuery.run(input.context, {
|
||||
routeId: routeId ?? "economy.history.transactions",
|
||||
params: input.match.params,
|
||||
list: parseEconomyListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<EconomyHistoryPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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";
|
||||
|
||||
export function EconomyItemsPage(props: EconomyPageProps) {
|
||||
const edit = props.context.has(PERMS.CATALOG_EDIT) ? (
|
||||
<EconomyCommandForm
|
||||
commandId="economy.items.base.update"
|
||||
buttonLabel="Save base item"
|
||||
input={{}}
|
||||
fields={[
|
||||
{ name: "id", label: "ID", type: "identifier", required: true },
|
||||
{
|
||||
name: "publicName",
|
||||
label: "Public name",
|
||||
type: "text",
|
||||
maxLength: 56,
|
||||
},
|
||||
{ name: "itemName", label: "Item name", type: "text", maxLength: 70 },
|
||||
{
|
||||
name: "interactionType",
|
||||
label: "Interaction",
|
||||
type: "text",
|
||||
maxLength: 500,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null;
|
||||
return (
|
||||
<EconomyPageFrame
|
||||
title="Items"
|
||||
description="Base item definitions and catalog linkage."
|
||||
result={props.result}
|
||||
forms={edit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderEconomyItemsPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as EconomyPageProps["routeId"];
|
||||
const result = await economyQuery.run(input.context, {
|
||||
routeId: routeId ?? "economy.items.overview",
|
||||
params: input.match.params,
|
||||
list: parseEconomyListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<EconomyItemsPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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 rewardForms({ context, routeId }: EconomyPageProps) {
|
||||
if (!context.has(PERMS.CATALOG_EDIT)) return null;
|
||||
if (routeId === "economy.rewards.sounds") {
|
||||
return (
|
||||
<EconomyCommandForm
|
||||
commandId="economy.rewards.soundtrack.change"
|
||||
buttonLabel="Save soundtrack"
|
||||
input={{ action: "update" }}
|
||||
fields={[
|
||||
{ name: "id", label: "ID", type: "identifier", required: true },
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
label: "Author",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 50,
|
||||
},
|
||||
{ name: "track", label: "Track", type: "textarea", required: true },
|
||||
{
|
||||
name: "length",
|
||||
label: "Length",
|
||||
type: "number",
|
||||
required: true,
|
||||
min: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (routeId === "economy.rewards.badges") {
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<EconomyCommandForm
|
||||
commandId="economy.rewards.badge.give"
|
||||
buttonLabel="Give badge"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "username",
|
||||
label: "Username",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 25,
|
||||
},
|
||||
{
|
||||
name: "badge",
|
||||
label: "Badge code",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 32,
|
||||
},
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
<EconomyCommandForm
|
||||
commandId="economy.rewards.badge.upload"
|
||||
buttonLabel="Upload badge"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "code",
|
||||
label: "Badge code",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 32,
|
||||
},
|
||||
{ name: "file", label: "Badge file", type: "file", required: true },
|
||||
]}
|
||||
requiresReason
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function EconomyRewardsPage(props: EconomyPageProps) {
|
||||
return (
|
||||
<EconomyPageFrame
|
||||
title="Rewards"
|
||||
description="Badges, achievements, sounds and calendar rewards."
|
||||
result={props.result}
|
||||
forms={rewardForms(props)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderEconomyRewardsPage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as EconomyPageProps["routeId"];
|
||||
const result = await economyQuery.run(input.context, {
|
||||
routeId: routeId ?? "economy.rewards.badges",
|
||||
params: input.match.params,
|
||||
list: parseEconomyListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<EconomyRewardsPage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import "server-only";
|
||||
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { ReactNode } from "react";
|
||||
import { CatalogDetailTabs } from "@/app/admin/catalog/[id]/catalog-detail-tabs";
|
||||
import { BcPageDetail } from "@/app/admin/catalog/builder-club/bc-manager";
|
||||
import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import type { EconomyRouteId } from "../routes";
|
||||
|
||||
async function normalCatalogEditor(
|
||||
id: number,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<ReactNode> {
|
||||
const [catalogPage] = await db
|
||||
.select()
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, id))
|
||||
.limit(1);
|
||||
if (!catalogPage) return null;
|
||||
const [children, itemsData] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: CatalogPages.id,
|
||||
caption: CatalogPages.caption,
|
||||
enabled: CatalogPages.enabled,
|
||||
})
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.parentId, id))
|
||||
.orderBy(asc(CatalogPages.orderNum)),
|
||||
loadCatalogItemsData(id),
|
||||
]);
|
||||
return (
|
||||
<CatalogDetailTabs
|
||||
catalogPage={{
|
||||
id: catalogPage.id,
|
||||
caption: catalogPage.caption,
|
||||
parentId: catalogPage.parentId,
|
||||
pageLayout: catalogPage.pageLayout,
|
||||
enabled: catalogPage.enabled,
|
||||
visible: catalogPage.visible,
|
||||
minRank: catalogPage.minRank,
|
||||
clubOnly: catalogPage.clubOnly,
|
||||
orderNum: catalogPage.orderNum,
|
||||
iconImage: catalogPage.iconImage,
|
||||
iconColor: catalogPage.iconColor,
|
||||
pageHeadline: catalogPage.pageHeadline,
|
||||
pageTeaser: catalogPage.pageTeaser,
|
||||
pageSpecial: catalogPage.pageSpecial,
|
||||
pageText1: catalogPage.pageText1,
|
||||
pageText2: catalogPage.pageText2,
|
||||
pageTextDetails: catalogPage.pageTextDetails,
|
||||
pageTextTeaser: catalogPage.pageTextTeaser,
|
||||
}}
|
||||
items={itemsData.items}
|
||||
baseItems={itemsData.baseItems}
|
||||
catalogNameMap={itemsData.catalogNameMap}
|
||||
childPages={children}
|
||||
pageId={catalogPage.id}
|
||||
canEdit={context.has(PERMS.CATALOG_EDIT)}
|
||||
furniDataIdList={itemsData.furniDataIdList}
|
||||
furniDescriptionMap={itemsData.furniDescriptionMap}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
allPages={itemsData.allPages}
|
||||
interactionTypes={itemsData.interactionTypes}
|
||||
gamedataHotel={itemsData.gamedataHotel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function builderClubEditor(
|
||||
id: number,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<ReactNode> {
|
||||
const [page] = await db
|
||||
.select()
|
||||
.from(CatalogPagesBc)
|
||||
.where(eq(CatalogPagesBc.id, id))
|
||||
.limit(1);
|
||||
if (!page) return null;
|
||||
const items = await db
|
||||
.select()
|
||||
.from(CatalogItemsBc)
|
||||
.where(eq(CatalogItemsBc.pageId, id))
|
||||
.orderBy(asc(CatalogItemsBc.orderNumber));
|
||||
return (
|
||||
<BcPageDetail
|
||||
page={{
|
||||
id: page.id,
|
||||
parentId: page.parentId,
|
||||
caption: page.caption,
|
||||
pageLayout: page.pageLayout,
|
||||
iconColor: page.iconColor,
|
||||
iconImage: page.iconImage,
|
||||
orderNum: page.orderNum,
|
||||
visible: page.visible,
|
||||
enabled: page.enabled,
|
||||
pageHeadline: page.pageHeadline,
|
||||
pageTeaser: page.pageTeaser,
|
||||
pageSpecial: page.pageSpecial ?? "",
|
||||
pageText1: page.pageText1 ?? "",
|
||||
pageText2: page.pageText2 ?? "",
|
||||
pageTextDetails: page.pageTextDetails ?? "",
|
||||
pageTextTeaser: page.pageTextTeaser ?? "",
|
||||
}}
|
||||
items={items.map((item) => ({
|
||||
id: item.id,
|
||||
pageId: item.pageId,
|
||||
itemIds: item.itemIds,
|
||||
catalogName: item.catalogName,
|
||||
orderNumber: item.orderNumber,
|
||||
extradata: item.extradata,
|
||||
}))}
|
||||
canEdit={context.has(PERMS.CATALOG_EDIT)}
|
||||
returnHref="/ase/economy/catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderSpecializedCatalogEditor(
|
||||
routeId: EconomyRouteId,
|
||||
params: Readonly<Record<string, string>>,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<ReactNode> {
|
||||
const id = Number(params.id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return null;
|
||||
if (routeId === "economy.catalog.detail") {
|
||||
return normalCatalogEditor(id, context);
|
||||
}
|
||||
if (routeId === "economy.catalog.builder-club-detail") {
|
||||
return builderClubEditor(id, context);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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";
|
||||
|
||||
export function EconomyValuePage(props: EconomyPageProps) {
|
||||
const forms = props.context.has(PERMS.SHOP_EDIT) ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<EconomyCommandForm
|
||||
commandId="economy.value.category.change"
|
||||
buttonLabel="Create value category"
|
||||
input={{ action: "create" }}
|
||||
fields={[
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "badge",
|
||||
label: "Badge",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{ name: "priority", label: "Priority", type: "number", min: 1 },
|
||||
]}
|
||||
/>
|
||||
<EconomyCommandForm
|
||||
commandId="economy.value.rare.change"
|
||||
buttonLabel="Create rare value"
|
||||
input={{ action: "create" }}
|
||||
fields={[
|
||||
{
|
||||
name: "categoryId",
|
||||
label: "Category ID",
|
||||
type: "identifier",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
label: "Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "currencyValue",
|
||||
label: "Currency value",
|
||||
type: "text",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "currencyType",
|
||||
label: "Currency type",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "furnitureIcon",
|
||||
label: "Furniture icon",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<EconomyPageFrame
|
||||
title="Value tools"
|
||||
description="Rare-value categories and valuations."
|
||||
result={props.result}
|
||||
forms={forms}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderEconomyValuePage(input: HousekeepingPageInput) {
|
||||
const routeId = input.match.routeId as EconomyPageProps["routeId"];
|
||||
const result = await economyQuery.run(input.context, {
|
||||
routeId: routeId ?? "economy.value.rare-values",
|
||||
params: input.match.params,
|
||||
list: parseEconomyListInput(input.searchParams ?? {}),
|
||||
});
|
||||
return (
|
||||
<EconomyValuePage
|
||||
context={input.context}
|
||||
result={result}
|
||||
routeId={routeId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { authorizeHousekeeping } from "../../../foundation/authorization";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingQuery,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import { isSafeHousekeepingHref } from "../../../foundation/housekeeping-href";
|
||||
import {
|
||||
type EconomyRouteGroup,
|
||||
type EconomyRouteId,
|
||||
economyRouteById,
|
||||
economyRouteGroup,
|
||||
} from "../routes";
|
||||
|
||||
export interface EconomyQueryInput {
|
||||
readonly routeId: EconomyRouteId;
|
||||
readonly params?: Readonly<Record<string, string>>;
|
||||
readonly list?: Readonly<{
|
||||
search?: string;
|
||||
pageSize?: number;
|
||||
offset?: number;
|
||||
}>;
|
||||
}
|
||||
export interface EconomyQueryItem {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly status?: string;
|
||||
readonly amount?: string;
|
||||
readonly href?: string;
|
||||
readonly description?: string;
|
||||
readonly updatedAt?: string | null;
|
||||
}
|
||||
export interface EconomyQueryData {
|
||||
readonly kind: EconomyRouteGroup;
|
||||
readonly items: readonly EconomyQueryItem[];
|
||||
readonly total: number;
|
||||
readonly partialDependencies: readonly string[];
|
||||
}
|
||||
export interface EconomyQueryAdapters {
|
||||
load(input: NormalizedEconomyQueryInput): Promise<EconomyQueryData>;
|
||||
}
|
||||
|
||||
export interface NormalizedEconomyQueryInput {
|
||||
readonly routeId: EconomyRouteId;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
readonly list: Readonly<{ search: string; pageSize: number; offset: number }>;
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, parsed));
|
||||
}
|
||||
|
||||
function normalizeInput(input: EconomyQueryInput): NormalizedEconomyQueryInput {
|
||||
return {
|
||||
routeId: input.routeId,
|
||||
params: Object.fromEntries(
|
||||
Object.entries(input.params ?? {}).map(([key, value]) => [
|
||||
key.normalize("NFC").trim().slice(0, 128),
|
||||
value.normalize("NFC").trim().slice(0, 128),
|
||||
]),
|
||||
),
|
||||
list: {
|
||||
search: String(input.list?.search ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 128),
|
||||
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
|
||||
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isValidData(
|
||||
data: EconomyQueryData,
|
||||
input: NormalizedEconomyQueryInput,
|
||||
): boolean {
|
||||
if (
|
||||
data.kind !== economyRouteGroup(input.routeId) ||
|
||||
!Array.isArray(data.items) ||
|
||||
!Number.isSafeInteger(data.total) ||
|
||||
data.total < 0 ||
|
||||
!Array.isArray(data.partialDependencies) ||
|
||||
data.partialDependencies.length !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return data.items.every(
|
||||
(item) =>
|
||||
typeof item.id === "string" &&
|
||||
item.id.length > 0 &&
|
||||
typeof item.title === "string" &&
|
||||
item.title.length > 0 &&
|
||||
(item.href === undefined || isSafeHousekeepingHref(item.href)),
|
||||
);
|
||||
}
|
||||
export function createEconomyQuery(
|
||||
adapters: EconomyQueryAdapters,
|
||||
): HousekeepingQuery<EconomyQueryInput, EconomyQueryData> {
|
||||
return {
|
||||
id: "economy.query",
|
||||
owner: "economy",
|
||||
capability: economyRouteById("economy.catalog.overview").capability,
|
||||
async run(context, rawInput) {
|
||||
const route = economyRouteById(rawInput.routeId);
|
||||
const authorization = authorizeHousekeeping(context, route.capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
const input = normalizeInput(rawInput);
|
||||
try {
|
||||
const data = await adapters.load(input);
|
||||
if (!isValidData(data, input)) throw new Error("invalid Economy query");
|
||||
return ok(data, authorization.correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadEconomyCatalogQuery(
|
||||
input: NormalizedEconomyQueryInput,
|
||||
): Promise<EconomyQueryData> {
|
||||
const { loadEconomyDatabaseQuery } = await import("./economy-production");
|
||||
return loadEconomyDatabaseQuery(input);
|
||||
}
|
||||
|
||||
export const economyQuery = createEconomyQuery({
|
||||
async load(input) {
|
||||
const group = economyRouteGroup(input.routeId);
|
||||
if (group === "catalog" || group === "items") {
|
||||
return loadEconomyCatalogQuery(input);
|
||||
}
|
||||
if (group === "commerce" || group === "history") {
|
||||
const { loadEconomyCommerceQuery } = await import("./commerce");
|
||||
return loadEconomyCommerceQuery(input);
|
||||
}
|
||||
const { loadEconomyValueQuery } = await import("./value");
|
||||
return loadEconomyValueQuery(input);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { EconomyQueryData, NormalizedEconomyQueryInput } from "./catalog";
|
||||
|
||||
export function serializeEconomyAmount(
|
||||
value: string | number,
|
||||
currency: string,
|
||||
): string {
|
||||
const raw =
|
||||
typeof value === "number" && Number.isFinite(value)
|
||||
? value.toFixed(2)
|
||||
: String(value).trim();
|
||||
return `${currency.normalize("NFC").trim().toUpperCase().slice(0, 8)} ${raw}`;
|
||||
}
|
||||
|
||||
export async function loadEconomyCommerceQuery(
|
||||
input: NormalizedEconomyQueryInput,
|
||||
): Promise<EconomyQueryData> {
|
||||
const { loadEconomyDatabaseQuery } = await import("./economy-production");
|
||||
return loadEconomyDatabaseQuery(input);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { EconomyRouteId } from "../routes";
|
||||
import type { NormalizedEconomyQueryInput } from "./catalog";
|
||||
import {
|
||||
ECONOMY_QUERY_DEFINITIONS,
|
||||
loadEconomyDatabaseQueryWith,
|
||||
} from "./economy-production";
|
||||
|
||||
function input(
|
||||
routeId: EconomyRouteId,
|
||||
params: Readonly<Record<string, string>> = {},
|
||||
): NormalizedEconomyQueryInput {
|
||||
return {
|
||||
routeId,
|
||||
params,
|
||||
list: { search: "", pageSize: 25, offset: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("Economy production query adapter", () => {
|
||||
it("maps voucher rows without exposing codes and serializes money", async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([[{ total: 1 }], []])
|
||||
.mockResolvedValueOnce([
|
||||
[
|
||||
{
|
||||
id: 18446744073709551615n,
|
||||
title: "ABC...XYZ",
|
||||
description: "0/5 uses",
|
||||
status: "active",
|
||||
updated_at: new Date("2026-08-30T10:00:00.000Z"),
|
||||
amount: 50,
|
||||
currency: "credits",
|
||||
},
|
||||
],
|
||||
[],
|
||||
]);
|
||||
const result = await loadEconomyDatabaseQueryWith(
|
||||
input("economy.commerce.vouchers"),
|
||||
execute,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
kind: "commerce",
|
||||
total: 1,
|
||||
items: [
|
||||
{
|
||||
id: "18446744073709551615",
|
||||
title: "ABC...XYZ",
|
||||
amount: "CREDITS 50.00",
|
||||
href: "/ase/economy/commerce/vouchers",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("SECRET-CODE");
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses the empty adapter for create-only routes", async () => {
|
||||
const execute = vi.fn();
|
||||
await expect(
|
||||
loadEconomyDatabaseQueryWith(
|
||||
input("economy.commerce.shop-create"),
|
||||
execute,
|
||||
),
|
||||
).resolves.toMatchObject({ items: [], total: 0, kind: "commerce" });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed on invalid totals returned by the dependency", async () => {
|
||||
const execute = vi.fn().mockResolvedValueOnce([[{ total: -1 }], []]);
|
||||
await expect(
|
||||
loadEconomyDatabaseQueryWith(
|
||||
input("economy.history.transactions"),
|
||||
execute,
|
||||
),
|
||||
).rejects.toThrow("invalid Economy query total");
|
||||
});
|
||||
|
||||
it("defines deterministic ordering for every list query", () => {
|
||||
for (const [routeId, definition] of Object.entries(
|
||||
ECONOMY_QUERY_DEFINITIONS,
|
||||
)) {
|
||||
if (routeId === "economy.catalog.maintenance") continue;
|
||||
expect(definition.statement, routeId).toMatch(/\bORDER BY\b/iu);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import "server-only";
|
||||
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { type EconomyRouteId, economyRouteGroup } from "../routes";
|
||||
import type {
|
||||
EconomyQueryData,
|
||||
EconomyQueryItem,
|
||||
NormalizedEconomyQueryInput,
|
||||
} from "./catalog";
|
||||
import { serializeEconomyAmount } from "./commerce";
|
||||
|
||||
type RawRow = Readonly<Record<string, unknown>>;
|
||||
|
||||
interface QueryDefinition {
|
||||
readonly statement: string;
|
||||
readonly href: string;
|
||||
readonly appendId?: boolean;
|
||||
readonly parameterAlias?: "id" | "campaign_id";
|
||||
}
|
||||
|
||||
export const ECONOMY_QUERY_DEFINITIONS = {
|
||||
"economy.catalog.overview": {
|
||||
statement:
|
||||
"SELECT id, caption AS title, CONCAT('parent-', parent_id) AS description, CASE WHEN enabled = '1' AND visible = '1' THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM catalog_pages ORDER BY order_num ASC, id ASC",
|
||||
href: "/ase/economy/catalog/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.catalog.detail": {
|
||||
statement:
|
||||
"SELECT id, caption AS title, CONCAT('layout-', page_layout) AS description, CASE WHEN enabled = '1' AND visible = '1' THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM catalog_pages ORDER BY order_num ASC, id ASC",
|
||||
href: "/ase/economy/catalog/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.catalog.builder-club-detail": {
|
||||
statement:
|
||||
"SELECT id, caption AS title, CONCAT('parent-', parent_id) AS description, CASE WHEN enabled = '1' AND visible = '1' THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM catalog_pages_bc ORDER BY order_num ASC, id ASC",
|
||||
href: "/ase/economy/catalog/builder-club/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.catalog.maintenance": {
|
||||
statement:
|
||||
"SELECT 1 AS id, 'Catalog maintenance' AS title, CONCAT(COUNT(*), ' catalog pages available') AS description, 'ready' AS status, NULL AS updated_at FROM catalog_pages",
|
||||
href: "/ase/economy/catalog/maintenance",
|
||||
},
|
||||
"economy.items.overview": {
|
||||
statement:
|
||||
"SELECT id, COALESCE(NULLIF(public_name, ''), item_name) AS title, item_name AS description, interaction_type AS status, NULL AS updated_at FROM items_base ORDER BY id DESC",
|
||||
href: "/ase/economy/items/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.items.detail": {
|
||||
statement:
|
||||
"SELECT id, COALESCE(NULLIF(public_name, ''), item_name) AS title, item_name AS description, interaction_type AS status, NULL AS updated_at FROM items_base ORDER BY id DESC",
|
||||
href: "/ase/economy/items/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.commerce.shop": {
|
||||
statement:
|
||||
"SELECT id, name AS title, info AS description, 'active' AS status, updated_at, costs AS amount, 'credits' AS currency FROM website_shop_articles ORDER BY position ASC, id DESC",
|
||||
href: "/ase/economy/commerce/shop/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.commerce.shop-detail": {
|
||||
statement:
|
||||
"SELECT id, name AS title, info AS description, 'active' AS status, updated_at, costs AS amount, 'credits' AS currency FROM website_shop_articles ORDER BY position ASC, id DESC",
|
||||
href: "/ase/economy/commerce/shop/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.commerce.marketplace": {
|
||||
statement:
|
||||
"SELECT id, CONCAT('Listing #', id) AS title, CONCAT('user-', user_id, ' item-', item_id) AS description, CASE state WHEN 1 THEN 'active' WHEN 2 THEN 'sold' ELSE 'cancelled' END AS status, FROM_UNIXTIME(timestamp) AS updated_at, price AS amount, 'credits' AS currency FROM marketplace_items ORDER BY timestamp DESC, id DESC",
|
||||
href: "/ase/economy/commerce/marketplace",
|
||||
},
|
||||
"economy.commerce.vouchers": {
|
||||
statement:
|
||||
"SELECT id, CONCAT(LEFT(code, 3), '...', RIGHT(code, 3)) AS title, CONCAT(use_count, '/', max_uses, ' uses') AS description, CASE WHEN expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP THEN 'expired' WHEN use_count >= max_uses THEN 'exhausted' ELSE 'active' END AS status, updated_at, amount, 'credits' AS currency FROM website_shop_vouchers ORDER BY id DESC",
|
||||
href: "/ase/economy/commerce/vouchers",
|
||||
},
|
||||
"economy.commerce.subscriptions": {
|
||||
statement:
|
||||
"SELECT id, CONCAT('User #', COALESCE(user_id, 0)) AS title, subscription_type AS description, CASE WHEN COALESCE(timestamp_start, 0) + COALESCE(duration, 0) > UNIX_TIMESTAMP() THEN 'active' ELSE 'expired' END AS status, FROM_UNIXTIME(COALESCE(timestamp_start, 0) + COALESCE(duration, 0)) AS updated_at FROM users_subscriptions WHERE active = 1 ORDER BY (COALESCE(timestamp_start, 0) + COALESCE(duration, 0)) ASC, id ASC",
|
||||
href: "/ase/economy/commerce/subscriptions",
|
||||
},
|
||||
"economy.history.transactions": {
|
||||
statement:
|
||||
"SELECT id, transaction_id AS title, description, COALESCE(status, 'unknown') AS status, updated_at, amount, currency FROM website_paypal_transactions ORDER BY created_at DESC, id DESC",
|
||||
href: "/ase/economy/history/transactions",
|
||||
},
|
||||
"economy.value.rare-values": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT(COALESCE(credit_value, '0'), ' credits; ', COALESCE(currency_value, '0'), ' ', currency_type) AS description, currency_type AS status, updated_at FROM website_rare_values ORDER BY category_id ASC, id ASC",
|
||||
href: "/ase/economy/value/rare-values",
|
||||
},
|
||||
"economy.rewards.badges": {
|
||||
statement:
|
||||
"SELECT id, badge_name AS title, badge_key AS description, 'available' AS status, updated_at FROM website_badges ORDER BY badge_name ASC, id ASC",
|
||||
href: "/ase/economy/rewards/badges",
|
||||
},
|
||||
"economy.rewards.achievements": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT(category, ' level ', level) AS description, CONCAT(reward_amount, ':', reward_type) AS status, NULL AS updated_at FROM achievements ORDER BY name ASC, level ASC, id ASC",
|
||||
href: "/ase/economy/rewards/achievements",
|
||||
},
|
||||
"economy.rewards.sounds": {
|
||||
statement:
|
||||
"SELECT id, name AS title, author AS description, CONCAT(length, 'ms') AS status, NULL AS updated_at FROM soundtracks ORDER BY id ASC",
|
||||
href: "/ase/economy/rewards/sounds",
|
||||
},
|
||||
"economy.rewards.calendar": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT(total_days, ' days') AS description, CASE WHEN enabled = '1' THEN 'enabled' ELSE 'disabled' END AS status, FROM_UNIXTIME(start_timestamp) AS updated_at FROM calendar_campaigns ORDER BY id DESC",
|
||||
href: "/ase/economy/rewards/calendar/",
|
||||
appendId: true,
|
||||
},
|
||||
"economy.rewards.calendar-detail": {
|
||||
statement:
|
||||
"SELECT id, product_name AS title, CONCAT('item-', item_id, ' badge-', badge) AS description, CASE WHEN subscription_days > 0 THEN CONCAT('subscription-', subscription_days, 'd') ELSE 'reward' END AS status, NULL AS updated_at, campaign_id FROM calendar_rewards ORDER BY id ASC",
|
||||
href: "/ase/economy/rewards/calendar",
|
||||
parameterAlias: "campaign_id",
|
||||
},
|
||||
} as const satisfies Partial<Record<EconomyRouteId, QueryDefinition>>;
|
||||
|
||||
const EMPTY_ROUTES = new Set<EconomyRouteId>(["economy.commerce.shop-create"]);
|
||||
|
||||
function rows(result: unknown): readonly RawRow[] {
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid Economy query result");
|
||||
}
|
||||
return result[0] as readonly RawRow[];
|
||||
}
|
||||
|
||||
function value(input: unknown, fallback = ""): string {
|
||||
return typeof input === "string"
|
||||
? input
|
||||
: input == null
|
||||
? fallback
|
||||
: String(input);
|
||||
}
|
||||
|
||||
function date(valueToParse: unknown): string | null {
|
||||
if (valueToParse == null) return null;
|
||||
const parsed =
|
||||
valueToParse instanceof Date
|
||||
? valueToParse
|
||||
: new Date(String(valueToParse));
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
||||
}
|
||||
|
||||
function statementParts(statement: string) {
|
||||
const match = /^(.*?)(\s+ORDER BY\s+.+)$/iu.exec(statement);
|
||||
return match
|
||||
? { selection: match[1], ordering: match[2] }
|
||||
: { selection: statement, ordering: "" };
|
||||
}
|
||||
|
||||
function totalFromRows(rawRows: readonly RawRow[]): number {
|
||||
const total = Number(rawRows[0]?.total ?? 0);
|
||||
if (!Number.isSafeInteger(total) || total < 0) {
|
||||
throw new Error("invalid Economy query total");
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function mapRows(
|
||||
definition: QueryDefinition,
|
||||
rawRows: readonly RawRow[],
|
||||
): readonly EconomyQueryItem[] {
|
||||
return rawRows.map((row) => {
|
||||
const id = value(row.id);
|
||||
if (!id) throw new Error("invalid Economy identifier");
|
||||
const amount =
|
||||
row.amount == null
|
||||
? undefined
|
||||
: serializeEconomyAmount(
|
||||
row.amount as string | number,
|
||||
value(row.currency, "credits"),
|
||||
);
|
||||
return {
|
||||
id,
|
||||
title: value(row.title, "Untitled economy entry"),
|
||||
description: value(row.description) || undefined,
|
||||
status: value(row.status) || undefined,
|
||||
updatedAt: date(row.updated_at),
|
||||
amount,
|
||||
href: definition.appendId ? definition.href + id : definition.href,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function empty(input: NormalizedEconomyQueryInput): EconomyQueryData {
|
||||
return {
|
||||
kind: economyRouteGroup(input.routeId),
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadEconomyDatabaseQuery(
|
||||
input: NormalizedEconomyQueryInput,
|
||||
): Promise<EconomyQueryData> {
|
||||
const { db } = await import("@/lib/db");
|
||||
return loadEconomyDatabaseQueryWith(input, (query) => db.execute(query));
|
||||
}
|
||||
|
||||
export async function loadEconomyDatabaseQueryWith(
|
||||
input: NormalizedEconomyQueryInput,
|
||||
execute: (query: SQL) => Promise<unknown>,
|
||||
): Promise<EconomyQueryData> {
|
||||
if (EMPTY_ROUTES.has(input.routeId)) return empty(input);
|
||||
const definition = (
|
||||
ECONOMY_QUERY_DEFINITIONS as Partial<
|
||||
Record<EconomyRouteId, QueryDefinition>
|
||||
>
|
||||
)[input.routeId];
|
||||
if (!definition) throw new Error("missing Economy query adapter");
|
||||
const { sql } = await import("drizzle-orm");
|
||||
const { selection, ordering } = statementParts(definition.statement);
|
||||
const filters: SQL[] = [];
|
||||
if (input.list.search) {
|
||||
const pattern = `%${input.list.search.toLocaleLowerCase()}%`;
|
||||
filters.push(
|
||||
/\bAS\s+description\b/iu.test(selection) ||
|
||||
/\bdescription\b/iu.test(selection)
|
||||
? sql`(LOWER(COALESCE(title, '')) LIKE ${pattern} OR LOWER(COALESCE(description, '')) LIKE ${pattern})`
|
||||
: sql`LOWER(COALESCE(title, '')) LIKE ${pattern}`,
|
||||
);
|
||||
}
|
||||
if (input.params.id) {
|
||||
const alias = definition.parameterAlias ?? "id";
|
||||
filters.push(sql`CAST(${sql.raw(alias)} AS CHAR) = ${input.params.id}`);
|
||||
}
|
||||
const filtered = filters.length
|
||||
? sql`${sql.raw(selection)} HAVING ${sql.join(filters, sql` AND `)}`
|
||||
: sql.raw(selection);
|
||||
const total = totalFromRows(
|
||||
rows(
|
||||
await execute(
|
||||
sql`SELECT COUNT(*) AS total FROM (${filtered}) AS economy_rows`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const items = mapRows(
|
||||
definition,
|
||||
rows(
|
||||
await execute(
|
||||
sql`${filtered} ${sql.raw(ordering)} LIMIT ${input.list.pageSize} OFFSET ${input.list.offset}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
kind: economyRouteGroup(input.routeId),
|
||||
items,
|
||||
total,
|
||||
partialDependencies: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { ECONOMY_ROUTE_IDS } from "../routes";
|
||||
import { createEconomyQuery, type EconomyQueryData } from "./catalog";
|
||||
import { serializeEconomyAmount } from "./commerce";
|
||||
import { serializeEconomyValue } from "./value";
|
||||
|
||||
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 query adapters", () => {
|
||||
it("fails closed before invoking an unauthorized adapter", async () => {
|
||||
const load = vi.fn();
|
||||
const query = createEconomyQuery({ load });
|
||||
const result = await query.run(context([]), {
|
||||
routeId: "economy.catalog.overview",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes stable pagination and preserves BIGINT identifiers", async () => {
|
||||
const load = vi.fn(
|
||||
async (): Promise<EconomyQueryData> => ({
|
||||
kind: "history",
|
||||
items: [
|
||||
{
|
||||
id: "18446744073709551615",
|
||||
title: "Completed order",
|
||||
amount: "EUR 12.50",
|
||||
href: "/ase/economy/history/transactions",
|
||||
},
|
||||
],
|
||||
total: 501,
|
||||
partialDependencies: [],
|
||||
}),
|
||||
);
|
||||
const query = createEconomyQuery({ load });
|
||||
const result = await query.run(context([PERMS.SHOP_VIEW]), {
|
||||
routeId: "economy.history.transactions",
|
||||
list: { search: " paid ", pageSize: 999, offset: 999_999 },
|
||||
});
|
||||
expect(load).toHaveBeenCalledWith({
|
||||
routeId: "economy.history.transactions",
|
||||
params: {},
|
||||
list: { search: "paid", pageSize: 100, offset: 100_000 },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { items: [{ id: "18446744073709551615" }], total: 501 },
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes money and rare values without locale-dependent rounding", () => {
|
||||
expect(serializeEconomyAmount(12.5, "eur")).toBe("EUR 12.50");
|
||||
expect(serializeEconomyAmount("9007199254740993", "usd")).toBe(
|
||||
"USD 9007199254740993",
|
||||
);
|
||||
expect(serializeEconomyValue("1250.50", "diamonds")).toEqual({
|
||||
value: "1250.50",
|
||||
currency: "diamonds",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects fabricated partial dependencies and unsafe hrefs", async () => {
|
||||
const query = createEconomyQuery({
|
||||
load: async () => ({
|
||||
kind: "catalog",
|
||||
items: [
|
||||
{ id: "1", title: "Unsafe", href: "/ase/economy/%2e%2e/system" },
|
||||
],
|
||||
total: 1,
|
||||
partialDependencies: ["imaginary"],
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
await query.run(context([PERMS.CATALOG_VIEW]), {
|
||||
routeId: "economy.catalog.overview",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("has an authorized adapter path for every Economy route", async () => {
|
||||
const load = vi.fn(async (input) => ({
|
||||
kind: input.routeId.split(".")[1] as EconomyQueryData["kind"],
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: [],
|
||||
}));
|
||||
const query = createEconomyQuery({ load });
|
||||
for (const routeId of ECONOMY_ROUTE_IDS) {
|
||||
const result = await query.run(context(Object.values(PERMS)), {
|
||||
routeId,
|
||||
});
|
||||
expect(result.ok, routeId).toBe(true);
|
||||
}
|
||||
expect(load).toHaveBeenCalledTimes(ECONOMY_ROUTE_IDS.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { EconomyQueryData, NormalizedEconomyQueryInput } from "./catalog";
|
||||
|
||||
export function serializeEconomyValue(value: string, currency: string) {
|
||||
return {
|
||||
value: String(value).normalize("NFC").trim().slice(0, 255),
|
||||
currency: String(currency).normalize("NFC").trim().slice(0, 255),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadEconomyValueQuery(
|
||||
input: NormalizedEconomyQueryInput,
|
||||
): Promise<EconomyQueryData> {
|
||||
const { loadEconomyDatabaseQuery } = await import("./economy-production");
|
||||
return loadEconomyDatabaseQuery(input);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { HousekeepingRouteHandler } from "../../route-handlers";
|
||||
import { renderEconomyCatalogPage } from "./pages/catalog";
|
||||
import { renderEconomyCommercePage } from "./pages/commerce";
|
||||
import { renderEconomyHistoryPage } from "./pages/history";
|
||||
import { renderEconomyItemsPage } from "./pages/items";
|
||||
import { renderEconomyRewardsPage } from "./pages/rewards";
|
||||
import { renderEconomyValuePage } from "./pages/value";
|
||||
import {
|
||||
ECONOMY_ROUTE_IDS,
|
||||
type EconomyRouteId,
|
||||
economyRouteGroup,
|
||||
} from "./routes";
|
||||
|
||||
function rendererFor(
|
||||
routeId: EconomyRouteId,
|
||||
): HousekeepingRouteHandler["render"] {
|
||||
const group = economyRouteGroup(routeId);
|
||||
if (group === "catalog") return renderEconomyCatalogPage;
|
||||
if (group === "items") return renderEconomyItemsPage;
|
||||
if (group === "commerce") return renderEconomyCommercePage;
|
||||
if (group === "history") return renderEconomyHistoryPage;
|
||||
if (group === "value") return renderEconomyValuePage;
|
||||
return renderEconomyRewardsPage;
|
||||
}
|
||||
|
||||
export const ECONOMY_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||
Object.freeze(
|
||||
ECONOMY_ROUTE_IDS.map((routeId) =>
|
||||
Object.freeze({ routeId, render: rendererFor(routeId) }),
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
|
||||
import { economyMigrationEntries } from "../../migration/economy";
|
||||
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
|
||||
import { economyManifest } from "./manifest";
|
||||
import {
|
||||
ECONOMY_ROUTE_GROUPS,
|
||||
ECONOMY_ROUTE_IDS,
|
||||
ECONOMY_ROUTES,
|
||||
type EconomyRouteId,
|
||||
economyRouteGroup,
|
||||
} from "./routes";
|
||||
|
||||
const expected = [
|
||||
["economy.catalog.overview", "/ase/economy/catalog", "catalog"],
|
||||
["economy.catalog.detail", "/ase/economy/catalog/:id", "catalog"],
|
||||
[
|
||||
"economy.catalog.builder-club-detail",
|
||||
"/ase/economy/catalog/builder-club/:id",
|
||||
"catalog",
|
||||
],
|
||||
[
|
||||
"economy.catalog.maintenance",
|
||||
"/ase/economy/catalog/maintenance",
|
||||
"catalog",
|
||||
],
|
||||
["economy.items.overview", "/ase/economy/items", "items"],
|
||||
["economy.items.detail", "/ase/economy/items/:id", "items"],
|
||||
["economy.commerce.shop", "/ase/economy/commerce/shop", "commerce"],
|
||||
[
|
||||
"economy.commerce.shop-create",
|
||||
"/ase/economy/commerce/shop/new",
|
||||
"commerce",
|
||||
],
|
||||
[
|
||||
"economy.commerce.shop-detail",
|
||||
"/ase/economy/commerce/shop/:id",
|
||||
"commerce",
|
||||
],
|
||||
[
|
||||
"economy.commerce.marketplace",
|
||||
"/ase/economy/commerce/marketplace",
|
||||
"commerce",
|
||||
],
|
||||
["economy.commerce.vouchers", "/ase/economy/commerce/vouchers", "commerce"],
|
||||
[
|
||||
"economy.commerce.subscriptions",
|
||||
"/ase/economy/commerce/subscriptions",
|
||||
"commerce",
|
||||
],
|
||||
[
|
||||
"economy.history.transactions",
|
||||
"/ase/economy/history/transactions",
|
||||
"history",
|
||||
],
|
||||
["economy.value.rare-values", "/ase/economy/value/rare-values", "value"],
|
||||
["economy.rewards.badges", "/ase/economy/rewards/badges", "rewards"],
|
||||
[
|
||||
"economy.rewards.achievements",
|
||||
"/ase/economy/rewards/achievements",
|
||||
"rewards",
|
||||
],
|
||||
["economy.rewards.sounds", "/ase/economy/rewards/sounds", "rewards"],
|
||||
["economy.rewards.calendar", "/ase/economy/rewards/calendar", "rewards"],
|
||||
[
|
||||
"economy.rewards.calendar-detail",
|
||||
"/ase/economy/rewards/calendar/:id",
|
||||
"rewards",
|
||||
],
|
||||
] as const;
|
||||
|
||||
describe("Economy routes", () => {
|
||||
it("declares the six exact route groups", () => {
|
||||
expect(ECONOMY_ROUTE_GROUPS).toEqual([
|
||||
"catalog",
|
||||
"items",
|
||||
"commerce",
|
||||
"history",
|
||||
"value",
|
||||
"rewards",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps all 20 matrix rows into 19 canonical workflows", () => {
|
||||
expect(
|
||||
ECONOMY_ROUTES.map((route) => [
|
||||
route.id,
|
||||
route.href,
|
||||
economyRouteGroup(route.id as EconomyRouteId),
|
||||
]),
|
||||
).toEqual(expected);
|
||||
expect(ECONOMY_ROUTE_IDS).toEqual(expected.map(([id]) => id));
|
||||
expect(
|
||||
[...new Set(ECONOMY_ROUTES.map((route) => route.href))].sort(),
|
||||
).toEqual(
|
||||
[
|
||||
...new Set(
|
||||
economyMigrationEntries
|
||||
.filter((entry) => entry.targetPath !== null)
|
||||
.map((entry) => entry.targetPath),
|
||||
),
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps manifest routes and real handlers in exact equality", () => {
|
||||
expect(economyManifest.routes).toBe(ECONOMY_ROUTES);
|
||||
expect(economyManifest.routes.map((route) => route.id)).toEqual(
|
||||
HOUSEKEEPING_ROUTE_HANDLERS.filter((handler) =>
|
||||
handler.routeId.startsWith("economy."),
|
||||
).map((handler) => handler.routeId),
|
||||
);
|
||||
expect(
|
||||
HOUSEKEEPING_MANIFESTS.flatMap((manifest) =>
|
||||
manifest.routes.map((route) => route.id),
|
||||
),
|
||||
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type CanonicalHousekeepingHref,
|
||||
type HousekeepingRouteDefinition,
|
||||
} from "../../foundation/contracts";
|
||||
|
||||
export const ECONOMY_ROUTE_GROUPS = [
|
||||
"catalog",
|
||||
"items",
|
||||
"commerce",
|
||||
"history",
|
||||
"value",
|
||||
"rewards",
|
||||
] as const;
|
||||
export type EconomyRouteGroup = (typeof ECONOMY_ROUTE_GROUPS)[number];
|
||||
|
||||
export const ECONOMY_ROUTE_IDS = [
|
||||
"economy.catalog.overview",
|
||||
"economy.catalog.detail",
|
||||
"economy.catalog.builder-club-detail",
|
||||
"economy.catalog.maintenance",
|
||||
"economy.items.overview",
|
||||
"economy.items.detail",
|
||||
"economy.commerce.shop",
|
||||
"economy.commerce.shop-create",
|
||||
"economy.commerce.shop-detail",
|
||||
"economy.commerce.marketplace",
|
||||
"economy.commerce.vouchers",
|
||||
"economy.commerce.subscriptions",
|
||||
"economy.history.transactions",
|
||||
"economy.value.rare-values",
|
||||
"economy.rewards.badges",
|
||||
"economy.rewards.achievements",
|
||||
"economy.rewards.sounds",
|
||||
"economy.rewards.calendar",
|
||||
"economy.rewards.calendar-detail",
|
||||
] as const;
|
||||
export type EconomyRouteId = (typeof ECONOMY_ROUTE_IDS)[number];
|
||||
|
||||
function economyRoute(
|
||||
id: EconomyRouteId,
|
||||
href: CanonicalHousekeepingHref,
|
||||
permissions: readonly string[],
|
||||
): HousekeepingRouteDefinition {
|
||||
return {
|
||||
id,
|
||||
labelKey: `pages.housekeeping.routes.${id}`,
|
||||
href,
|
||||
capability: anyCapability(...permissions),
|
||||
};
|
||||
}
|
||||
|
||||
export const ECONOMY_ROUTES = [
|
||||
economyRoute("economy.catalog.overview", "/ase/economy/catalog", [
|
||||
PERMS.CATALOG_VIEW,
|
||||
]),
|
||||
economyRoute("economy.catalog.detail", "/ase/economy/catalog/:id", [
|
||||
PERMS.CATALOG_VIEW,
|
||||
]),
|
||||
economyRoute(
|
||||
"economy.catalog.builder-club-detail",
|
||||
"/ase/economy/catalog/builder-club/:id",
|
||||
[PERMS.CATALOG_VIEW],
|
||||
),
|
||||
economyRoute(
|
||||
"economy.catalog.maintenance",
|
||||
"/ase/economy/catalog/maintenance",
|
||||
[PERMS.CATALOG_VIEW],
|
||||
),
|
||||
economyRoute("economy.items.overview", "/ase/economy/items", [
|
||||
PERMS.CATALOG_VIEW,
|
||||
]),
|
||||
economyRoute("economy.items.detail", "/ase/economy/items/:id", [
|
||||
PERMS.CATALOG_VIEW,
|
||||
]),
|
||||
economyRoute("economy.commerce.shop", "/ase/economy/commerce/shop", [
|
||||
PERMS.SHOP_VIEW,
|
||||
]),
|
||||
economyRoute(
|
||||
"economy.commerce.shop-create",
|
||||
"/ase/economy/commerce/shop/new",
|
||||
[PERMS.SHOP_EDIT],
|
||||
),
|
||||
economyRoute(
|
||||
"economy.commerce.shop-detail",
|
||||
"/ase/economy/commerce/shop/:id",
|
||||
[PERMS.SHOP_VIEW],
|
||||
),
|
||||
economyRoute(
|
||||
"economy.commerce.marketplace",
|
||||
"/ase/economy/commerce/marketplace",
|
||||
[PERMS.SHOP_VIEW],
|
||||
),
|
||||
economyRoute("economy.commerce.vouchers", "/ase/economy/commerce/vouchers", [
|
||||
PERMS.SHOP_VIEW,
|
||||
]),
|
||||
economyRoute(
|
||||
"economy.commerce.subscriptions",
|
||||
"/ase/economy/commerce/subscriptions",
|
||||
[PERMS.SHOP_VIEW],
|
||||
),
|
||||
economyRoute(
|
||||
"economy.history.transactions",
|
||||
"/ase/economy/history/transactions",
|
||||
[PERMS.SHOP_VIEW],
|
||||
),
|
||||
economyRoute("economy.value.rare-values", "/ase/economy/value/rare-values", [
|
||||
PERMS.SHOP_VIEW,
|
||||
]),
|
||||
economyRoute("economy.rewards.badges", "/ase/economy/rewards/badges", [
|
||||
PERMS.CATALOG_VIEW,
|
||||
]),
|
||||
economyRoute(
|
||||
"economy.rewards.achievements",
|
||||
"/ase/economy/rewards/achievements",
|
||||
[PERMS.CATALOG_VIEW],
|
||||
),
|
||||
economyRoute("economy.rewards.sounds", "/ase/economy/rewards/sounds", [
|
||||
PERMS.CATALOG_VIEW,
|
||||
]),
|
||||
economyRoute("economy.rewards.calendar", "/ase/economy/rewards/calendar", [
|
||||
PERMS.SHOP_VIEW,
|
||||
]),
|
||||
economyRoute(
|
||||
"economy.rewards.calendar-detail",
|
||||
"/ase/economy/rewards/calendar/:id",
|
||||
[PERMS.SHOP_VIEW],
|
||||
),
|
||||
] as const satisfies readonly HousekeepingRouteDefinition[];
|
||||
|
||||
const groups = new Map<EconomyRouteId, EconomyRouteGroup>(
|
||||
ECONOMY_ROUTE_IDS.map((routeId) => [
|
||||
routeId,
|
||||
routeId.split(".")[1] as EconomyRouteGroup,
|
||||
]),
|
||||
);
|
||||
|
||||
export function economyRouteGroup(routeId: EconomyRouteId): EconomyRouteGroup {
|
||||
const group = groups.get(routeId);
|
||||
if (!group) throw new Error(`unknown Economy route: ${routeId}`);
|
||||
return group;
|
||||
}
|
||||
|
||||
export function economyRouteById(routeId: EconomyRouteId) {
|
||||
const route = ECONOMY_ROUTES.find((candidate) => candidate.id === routeId);
|
||||
if (!route) throw new Error(`unknown Economy route: ${routeId}`);
|
||||
return route;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import "server-only";
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingCapabilityContext,
|
||||
} from "../../foundation/contracts";
|
||||
import { economyQuery } from "./queries/catalog";
|
||||
import type { EconomySearchCandidate } from "./search";
|
||||
|
||||
type EconomySearchKind = "catalog-pages" | "items" | "transactions";
|
||||
|
||||
const ROUTES = {
|
||||
"catalog-pages": [
|
||||
"economy.catalog.overview",
|
||||
anyCapability(PERMS.CATALOG_VIEW),
|
||||
],
|
||||
items: ["economy.items.overview", anyCapability(PERMS.CATALOG_VIEW)],
|
||||
transactions: [
|
||||
"economy.history.transactions",
|
||||
anyCapability(PERMS.SHOP_VIEW),
|
||||
],
|
||||
} as const;
|
||||
|
||||
export async function loadEconomySearchCandidates(
|
||||
kind: EconomySearchKind,
|
||||
context: HousekeepingCapabilityContext,
|
||||
term: string,
|
||||
limit: number,
|
||||
query: Pick<typeof economyQuery, "run"> = economyQuery,
|
||||
): Promise<readonly EconomySearchCandidate[]> {
|
||||
const [routeId, capability] = ROUTES[kind];
|
||||
const result = await query.run(context, {
|
||||
routeId,
|
||||
list: { search: term, pageSize: limit, offset: 0 },
|
||||
});
|
||||
if (!result.ok) throw new Error("Economy search query unavailable");
|
||||
return result.data.items.flatMap((item) =>
|
||||
item.href
|
||||
? [
|
||||
{
|
||||
id: `${routeId}:${item.id}`,
|
||||
title: item.title,
|
||||
description: item.description ?? item.status ?? item.amount,
|
||||
href: item.href,
|
||||
capability,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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 HousekeepingSearchProvider,
|
||||
ok,
|
||||
} from "../../foundation/contracts";
|
||||
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
|
||||
|
||||
export const ECONOMY_SEARCH_PROVIDER_IDS = [
|
||||
"economy.catalog-pages",
|
||||
"economy.items",
|
||||
"economy.transactions",
|
||||
] as const;
|
||||
|
||||
export interface EconomySearchCandidate {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly href: string;
|
||||
readonly capability: CapabilityRequirement;
|
||||
}
|
||||
|
||||
type EconomySearchLoader = (
|
||||
context: HousekeepingCapabilityContext,
|
||||
term: string,
|
||||
limit: number,
|
||||
) => Promise<readonly EconomySearchCandidate[]>;
|
||||
|
||||
export interface EconomySearchAdapters {
|
||||
readonly catalogPages: EconomySearchLoader;
|
||||
readonly items: EconomySearchLoader;
|
||||
readonly transactions: EconomySearchLoader;
|
||||
}
|
||||
|
||||
function createProvider(
|
||||
id: (typeof ECONOMY_SEARCH_PROVIDER_IDS)[number],
|
||||
capability: CapabilityRequirement,
|
||||
load: EconomySearchLoader,
|
||||
): HousekeepingSearchProvider {
|
||||
return {
|
||||
id,
|
||||
owner: "economy",
|
||||
capability,
|
||||
async search(context, input) {
|
||||
const authorization = authorizeHousekeeping(context, capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
const limit = Number.isFinite(input.limit)
|
||||
? Math.min(25, Math.max(1, Math.trunc(input.limit)))
|
||||
: 25;
|
||||
const term = input.term.normalize("NFC").trim().slice(0, 128);
|
||||
try {
|
||||
const candidates = await load(context, term, limit);
|
||||
return ok(
|
||||
candidates
|
||||
.filter(
|
||||
(candidate) =>
|
||||
isSafeHousekeepingHref(candidate.href) &&
|
||||
satisfiesCapability(context, candidate.capability),
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((candidate) => ({
|
||||
...candidate,
|
||||
domain: "economy" as const,
|
||||
type: "entity" as const,
|
||||
href: candidate.href as `/ase/${string}`,
|
||||
})),
|
||||
authorization.correlationId,
|
||||
);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createEconomySearchProviders(
|
||||
adapters: EconomySearchAdapters,
|
||||
): readonly HousekeepingSearchProvider[] {
|
||||
return [
|
||||
createProvider(
|
||||
"economy.catalog-pages",
|
||||
anyCapability(PERMS.CATALOG_VIEW),
|
||||
adapters.catalogPages,
|
||||
),
|
||||
createProvider(
|
||||
"economy.items",
|
||||
anyCapability(PERMS.CATALOG_VIEW),
|
||||
adapters.items,
|
||||
),
|
||||
createProvider(
|
||||
"economy.transactions",
|
||||
anyCapability(PERMS.SHOP_VIEW),
|
||||
adapters.transactions,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const ECONOMY_SEARCH_PROVIDERS = createEconomySearchProviders({
|
||||
async catalogPages(context, term, limit) {
|
||||
const { loadEconomySearchCandidates } = await import("./search-production");
|
||||
return loadEconomySearchCandidates("catalog-pages", context, term, limit);
|
||||
},
|
||||
async items(context, term, limit) {
|
||||
const { loadEconomySearchCandidates } = await import("./search-production");
|
||||
return loadEconomySearchCandidates("items", context, term, limit);
|
||||
},
|
||||
async transactions(context, term, limit) {
|
||||
const { loadEconomySearchCandidates } = await import("./search-production");
|
||||
return loadEconomySearchCandidates("transactions", context, term, limit);
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,515 @@
|
||||
import "server-only";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import {
|
||||
db,
|
||||
MarketplaceItems,
|
||||
Soundtracks,
|
||||
WebsiteRareValueCategories,
|
||||
WebsiteRareValues,
|
||||
WebsiteShopArticles,
|
||||
WebsiteShopVouchers,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
action,
|
||||
flag,
|
||||
hasOwn,
|
||||
integer,
|
||||
notFound,
|
||||
positiveBigInt,
|
||||
positiveInteger,
|
||||
rawText,
|
||||
record,
|
||||
requirePatch,
|
||||
text,
|
||||
validation,
|
||||
} from "./mutation-runtime-input";
|
||||
import type {
|
||||
EconomyMutationContext,
|
||||
EconomyMutationOperation,
|
||||
EconomyMutationSnapshot,
|
||||
} from "./mutations";
|
||||
|
||||
type EconomyDatabase = typeof db;
|
||||
|
||||
function database(transaction: unknown): EconomyDatabase {
|
||||
return (transaction ?? db) as EconomyDatabase;
|
||||
}
|
||||
|
||||
function insertedId(result: unknown): string {
|
||||
return positiveBigInt(
|
||||
(result as ResultSetHeader | undefined)?.insertId,
|
||||
).toString();
|
||||
}
|
||||
|
||||
function optionalUInt(value: unknown): number | null {
|
||||
if (value === undefined || value === null || String(value).trim() === "") {
|
||||
return null;
|
||||
}
|
||||
return integer(value);
|
||||
}
|
||||
|
||||
async function shopArticleChange(
|
||||
input: unknown,
|
||||
transaction: unknown,
|
||||
): Promise<EconomyMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const selectedAction = action(data, ["create", "update", "delete"]);
|
||||
const connection = database(transaction);
|
||||
if (selectedAction === "create") {
|
||||
const name = text(data.name, 255, true);
|
||||
const now = new Date();
|
||||
const [result] = (await connection.insert(WebsiteShopArticles).values({
|
||||
name,
|
||||
info: text(data.info, 255),
|
||||
iconUrl: text(data.icon ?? data.iconUrl, 255),
|
||||
color: text(data.color, 255),
|
||||
costs: integer(data.costs),
|
||||
giveRank: optionalUInt(data.giveRank),
|
||||
isGiftable: hasOwn(data, "isGiftable")
|
||||
? flag(data.isGiftable) === "1"
|
||||
: false,
|
||||
credits: optionalUInt(data.credits),
|
||||
duckets: optionalUInt(data.duckets),
|
||||
diamonds: optionalUInt(data.diamonds),
|
||||
badges: text(data.badges, 255) || null,
|
||||
furniture: rawText(data.furniture, 20_000) || null,
|
||||
position: integer(data.position ?? 0),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
const id = insertedId(result);
|
||||
return { before: null, after: { id, name }, output: { id } };
|
||||
}
|
||||
const id = positiveBigInt(data.id);
|
||||
const [existing] = await connection
|
||||
.select({
|
||||
id: WebsiteShopArticles.id,
|
||||
name: WebsiteShopArticles.name,
|
||||
costs: WebsiteShopArticles.costs,
|
||||
})
|
||||
.from(WebsiteShopArticles)
|
||||
.where(eq(WebsiteShopArticles.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw notFound();
|
||||
if (selectedAction === "delete") {
|
||||
await connection
|
||||
.delete(WebsiteShopArticles)
|
||||
.where(eq(WebsiteShopArticles.id, id));
|
||||
return {
|
||||
before: { id: id.toString(), name: existing.name, costs: existing.costs },
|
||||
after: null,
|
||||
};
|
||||
}
|
||||
const values: Partial<typeof WebsiteShopArticles.$inferInsert> = {};
|
||||
if (hasOwn(data, "name")) values.name = text(data.name, 255, true);
|
||||
if (hasOwn(data, "info")) values.info = text(data.info, 255);
|
||||
if (hasOwn(data, "icon") || hasOwn(data, "iconUrl")) {
|
||||
values.iconUrl = text(data.icon ?? data.iconUrl, 255);
|
||||
}
|
||||
if (hasOwn(data, "color")) values.color = text(data.color, 255);
|
||||
if (hasOwn(data, "costs")) values.costs = integer(data.costs);
|
||||
if (hasOwn(data, "giveRank")) values.giveRank = optionalUInt(data.giveRank);
|
||||
if (hasOwn(data, "isGiftable"))
|
||||
values.isGiftable = flag(data.isGiftable) === "1";
|
||||
if (hasOwn(data, "credits")) values.credits = optionalUInt(data.credits);
|
||||
if (hasOwn(data, "duckets")) values.duckets = optionalUInt(data.duckets);
|
||||
if (hasOwn(data, "diamonds")) values.diamonds = optionalUInt(data.diamonds);
|
||||
if (hasOwn(data, "badges")) values.badges = text(data.badges, 255) || null;
|
||||
if (hasOwn(data, "furniture"))
|
||||
values.furniture = rawText(data.furniture, 20_000) || null;
|
||||
if (hasOwn(data, "position")) values.position = integer(data.position);
|
||||
requirePatch(values as Record<string, unknown>);
|
||||
values.updatedAt = new Date();
|
||||
await connection
|
||||
.update(WebsiteShopArticles)
|
||||
.set(values)
|
||||
.where(eq(WebsiteShopArticles.id, id));
|
||||
return {
|
||||
before: { id: id.toString(), name: existing.name, costs: existing.costs },
|
||||
after: {
|
||||
id: id.toString(),
|
||||
name: values.name ?? existing.name,
|
||||
costs: values.costs ?? existing.costs,
|
||||
},
|
||||
output: { id: id.toString() },
|
||||
};
|
||||
}
|
||||
|
||||
async function marketplaceCancel(
|
||||
input: unknown,
|
||||
transaction: unknown,
|
||||
): Promise<EconomyMutationSnapshot> {
|
||||
const id = positiveInteger(record(input).id);
|
||||
const connection = database(transaction);
|
||||
const [listing] = await connection
|
||||
.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) throw notFound();
|
||||
if (listing.state !== 1) throw validation({ id: ["listing is not active"] });
|
||||
await connection
|
||||
.update(MarketplaceItems)
|
||||
.set({ state: 0 })
|
||||
.where(eq(MarketplaceItems.id, id));
|
||||
return {
|
||||
before: {
|
||||
id: String(id),
|
||||
state: 1,
|
||||
userId: listing.userId,
|
||||
itemId: listing.itemId,
|
||||
price: listing.price,
|
||||
},
|
||||
after: {
|
||||
id: String(id),
|
||||
state: 0,
|
||||
userId: listing.userId,
|
||||
itemId: listing.itemId,
|
||||
price: listing.price,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function voucherChange(
|
||||
input: unknown,
|
||||
transaction: unknown,
|
||||
): Promise<EconomyMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const selectedAction = action(data, ["create", "update", "delete"]);
|
||||
const connection = database(transaction);
|
||||
if (selectedAction === "create") {
|
||||
const code = text(data.code, 255, true);
|
||||
const amount = positiveInteger(data.amount);
|
||||
const maxUses = positiveInteger(data.maxUses ?? 1);
|
||||
const expiresAt = data.expiresAt
|
||||
? new Date(text(data.expiresAt, 64, true))
|
||||
: null;
|
||||
if (expiresAt && !Number.isFinite(expiresAt.getTime())) throw validation();
|
||||
const now = new Date();
|
||||
const [result] = (await connection.insert(WebsiteShopVouchers).values({
|
||||
code,
|
||||
amount,
|
||||
maxUses,
|
||||
useCount: 0,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
const id = insertedId(result);
|
||||
return { before: null, after: { id, amount, maxUses }, output: { id } };
|
||||
}
|
||||
const id = positiveBigInt(data.id);
|
||||
const [existing] = await connection
|
||||
.select({
|
||||
id: WebsiteShopVouchers.id,
|
||||
amount: WebsiteShopVouchers.amount,
|
||||
maxUses: WebsiteShopVouchers.maxUses,
|
||||
useCount: WebsiteShopVouchers.useCount,
|
||||
})
|
||||
.from(WebsiteShopVouchers)
|
||||
.where(eq(WebsiteShopVouchers.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw notFound();
|
||||
const before = {
|
||||
id: id.toString(),
|
||||
amount: existing.amount,
|
||||
maxUses: existing.maxUses,
|
||||
useCount: existing.useCount,
|
||||
};
|
||||
if (selectedAction === "delete") {
|
||||
await connection
|
||||
.delete(WebsiteShopVouchers)
|
||||
.where(eq(WebsiteShopVouchers.id, id));
|
||||
return { before, after: null };
|
||||
}
|
||||
const values: Partial<typeof WebsiteShopVouchers.$inferInsert> = {};
|
||||
if (hasOwn(data, "amount")) values.amount = positiveInteger(data.amount);
|
||||
if (hasOwn(data, "maxUses")) values.maxUses = positiveInteger(data.maxUses);
|
||||
if (hasOwn(data, "expiresAt")) {
|
||||
const parsed = data.expiresAt
|
||||
? new Date(text(data.expiresAt, 64, true))
|
||||
: null;
|
||||
if (parsed && !Number.isFinite(parsed.getTime())) throw validation();
|
||||
values.expiresAt = parsed;
|
||||
}
|
||||
requirePatch(values as Record<string, unknown>);
|
||||
values.updatedAt = new Date();
|
||||
await connection
|
||||
.update(WebsiteShopVouchers)
|
||||
.set(values)
|
||||
.where(eq(WebsiteShopVouchers.id, id));
|
||||
return {
|
||||
before,
|
||||
after: {
|
||||
...before,
|
||||
amount: values.amount ?? existing.amount,
|
||||
maxUses: values.maxUses ?? existing.maxUses,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function rareCategoryChange(
|
||||
input: unknown,
|
||||
transaction: unknown,
|
||||
): Promise<EconomyMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const selectedAction = action(data, ["create", "update", "delete"]);
|
||||
const connection = database(transaction);
|
||||
if (selectedAction === "create") {
|
||||
const name = text(data.name, 255, true);
|
||||
const badge = text(data.badge, 255, true);
|
||||
const priority = positiveInteger(data.priority ?? 1);
|
||||
const now = new Date();
|
||||
const [result] = (await connection
|
||||
.insert(WebsiteRareValueCategories)
|
||||
.values({
|
||||
name,
|
||||
badge,
|
||||
priority,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
const id = insertedId(result);
|
||||
return {
|
||||
before: null,
|
||||
after: { id, name, badge, priority },
|
||||
output: { id },
|
||||
};
|
||||
}
|
||||
const id = positiveBigInt(data.id);
|
||||
const [existing] = await connection
|
||||
.select({
|
||||
id: WebsiteRareValueCategories.id,
|
||||
name: WebsiteRareValueCategories.name,
|
||||
badge: WebsiteRareValueCategories.badge,
|
||||
priority: WebsiteRareValueCategories.priority,
|
||||
})
|
||||
.from(WebsiteRareValueCategories)
|
||||
.where(eq(WebsiteRareValueCategories.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw notFound();
|
||||
const before = {
|
||||
id: id.toString(),
|
||||
name: existing.name,
|
||||
badge: existing.badge,
|
||||
priority: existing.priority,
|
||||
};
|
||||
if (selectedAction === "delete") {
|
||||
await connection
|
||||
.delete(WebsiteRareValues)
|
||||
.where(eq(WebsiteRareValues.categoryId, id));
|
||||
await connection
|
||||
.delete(WebsiteRareValueCategories)
|
||||
.where(eq(WebsiteRareValueCategories.id, id));
|
||||
return { before, after: null };
|
||||
}
|
||||
const values: Partial<typeof WebsiteRareValueCategories.$inferInsert> = {};
|
||||
if (hasOwn(data, "name")) values.name = text(data.name, 255, true);
|
||||
if (hasOwn(data, "badge")) values.badge = text(data.badge, 255, true);
|
||||
if (hasOwn(data, "priority"))
|
||||
values.priority = positiveInteger(data.priority);
|
||||
requirePatch(values as Record<string, unknown>);
|
||||
values.updatedAt = new Date();
|
||||
await connection
|
||||
.update(WebsiteRareValueCategories)
|
||||
.set(values)
|
||||
.where(eq(WebsiteRareValueCategories.id, id));
|
||||
return { before, after: { ...before, ...values, updatedAt: undefined } };
|
||||
}
|
||||
|
||||
async function rareValueChange(
|
||||
input: unknown,
|
||||
transaction: unknown,
|
||||
): Promise<EconomyMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const selectedAction = action(data, ["create", "update", "delete"]);
|
||||
const connection = database(transaction);
|
||||
if (selectedAction === "create") {
|
||||
const values = {
|
||||
categoryId: positiveBigInt(data.categoryId),
|
||||
itemId: data.itemId ? positiveInteger(data.itemId) : null,
|
||||
name: text(data.name, 255, true),
|
||||
creditValue: text(data.creditValue, 255) || null,
|
||||
currencyValue: text(data.currencyValue, 255) || null,
|
||||
currencyType: text(data.currencyType ?? "diamonds", 255, true),
|
||||
furnitureIcon: text(data.furnitureIcon, 255, true),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const [result] = (await connection
|
||||
.insert(WebsiteRareValues)
|
||||
.values(values)) as unknown as [ResultSetHeader];
|
||||
const id = insertedId(result);
|
||||
return {
|
||||
before: null,
|
||||
after: {
|
||||
id,
|
||||
categoryId: values.categoryId.toString(),
|
||||
name: values.name,
|
||||
currencyValue: values.currencyValue,
|
||||
currencyType: values.currencyType,
|
||||
},
|
||||
output: { id },
|
||||
};
|
||||
}
|
||||
const id = positiveBigInt(data.id);
|
||||
const [existing] = await connection
|
||||
.select({
|
||||
id: WebsiteRareValues.id,
|
||||
categoryId: WebsiteRareValues.categoryId,
|
||||
name: WebsiteRareValues.name,
|
||||
creditValue: WebsiteRareValues.creditValue,
|
||||
currencyValue: WebsiteRareValues.currencyValue,
|
||||
currencyType: WebsiteRareValues.currencyType,
|
||||
furnitureIcon: WebsiteRareValues.furnitureIcon,
|
||||
})
|
||||
.from(WebsiteRareValues)
|
||||
.where(eq(WebsiteRareValues.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw notFound();
|
||||
const before = {
|
||||
id: id.toString(),
|
||||
categoryId: existing.categoryId.toString(),
|
||||
name: existing.name,
|
||||
creditValue: existing.creditValue,
|
||||
currencyValue: existing.currencyValue,
|
||||
currencyType: existing.currencyType,
|
||||
furnitureIcon: existing.furnitureIcon,
|
||||
};
|
||||
if (selectedAction === "delete") {
|
||||
await connection
|
||||
.delete(WebsiteRareValues)
|
||||
.where(eq(WebsiteRareValues.id, id));
|
||||
return { before, after: null };
|
||||
}
|
||||
const values: Partial<typeof WebsiteRareValues.$inferInsert> = {};
|
||||
if (hasOwn(data, "categoryId"))
|
||||
values.categoryId = positiveBigInt(data.categoryId);
|
||||
if (hasOwn(data, "itemId"))
|
||||
values.itemId = data.itemId ? positiveInteger(data.itemId) : null;
|
||||
if (hasOwn(data, "name")) values.name = text(data.name, 255, true);
|
||||
if (hasOwn(data, "creditValue"))
|
||||
values.creditValue = text(data.creditValue, 255) || null;
|
||||
if (hasOwn(data, "currencyValue"))
|
||||
values.currencyValue = text(data.currencyValue, 255) || null;
|
||||
if (hasOwn(data, "currencyType"))
|
||||
values.currencyType = text(data.currencyType, 255, true);
|
||||
if (hasOwn(data, "furnitureIcon"))
|
||||
values.furnitureIcon = text(data.furnitureIcon, 255, true);
|
||||
requirePatch(values as Record<string, unknown>);
|
||||
values.updatedAt = new Date();
|
||||
await connection
|
||||
.update(WebsiteRareValues)
|
||||
.set(values)
|
||||
.where(eq(WebsiteRareValues.id, id));
|
||||
return {
|
||||
before,
|
||||
after: {
|
||||
...before,
|
||||
...values,
|
||||
categoryId: values.categoryId?.toString() ?? before.categoryId,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function soundtrackChange(
|
||||
input: unknown,
|
||||
transaction: unknown,
|
||||
): Promise<EconomyMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const selectedAction = action(data, ["create", "update", "delete"]);
|
||||
const connection = database(transaction);
|
||||
if (selectedAction === "create") {
|
||||
const values = {
|
||||
code: text(data.code, 32, true),
|
||||
name: text(data.name, 100, true),
|
||||
author: text(data.author, 50, true),
|
||||
track: rawText(data.track, 100_000),
|
||||
length: integer(data.length),
|
||||
};
|
||||
const [result] = (await connection
|
||||
.insert(Soundtracks)
|
||||
.values(values)) as unknown as [ResultSetHeader];
|
||||
const id = insertedId(result);
|
||||
return {
|
||||
before: null,
|
||||
after: {
|
||||
id,
|
||||
code: values.code,
|
||||
name: values.name,
|
||||
author: values.author,
|
||||
length: values.length,
|
||||
},
|
||||
output: { id },
|
||||
};
|
||||
}
|
||||
const id = positiveInteger(data.id);
|
||||
const [existing] = await connection
|
||||
.select({
|
||||
id: Soundtracks.id,
|
||||
code: Soundtracks.code,
|
||||
name: Soundtracks.name,
|
||||
author: Soundtracks.author,
|
||||
length: Soundtracks.length,
|
||||
})
|
||||
.from(Soundtracks)
|
||||
.where(eq(Soundtracks.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw notFound();
|
||||
const before = {
|
||||
id: String(id),
|
||||
code: existing.code,
|
||||
name: existing.name,
|
||||
author: existing.author,
|
||||
length: existing.length,
|
||||
};
|
||||
if (selectedAction === "delete") {
|
||||
await connection.delete(Soundtracks).where(eq(Soundtracks.id, id));
|
||||
return { before, after: null };
|
||||
}
|
||||
const values: Partial<typeof Soundtracks.$inferInsert> = {};
|
||||
if (hasOwn(data, "code")) values.code = text(data.code, 32, true);
|
||||
if (hasOwn(data, "name")) values.name = text(data.name, 100, true);
|
||||
if (hasOwn(data, "author")) values.author = text(data.author, 50, true);
|
||||
if (hasOwn(data, "track")) values.track = rawText(data.track, 100_000);
|
||||
if (hasOwn(data, "length")) values.length = integer(data.length);
|
||||
requirePatch(values as Record<string, unknown>);
|
||||
await connection
|
||||
.update(Soundtracks)
|
||||
.set(values)
|
||||
.where(eq(Soundtracks.id, id));
|
||||
return { before, after: { ...before, ...values } };
|
||||
}
|
||||
|
||||
const HANDLERS: Partial<
|
||||
Record<
|
||||
EconomyMutationOperation,
|
||||
(input: unknown, transaction: unknown) => Promise<EconomyMutationSnapshot>
|
||||
>
|
||||
> = {
|
||||
"shop-article.change": shopArticleChange,
|
||||
"marketplace.cancel": marketplaceCancel,
|
||||
"voucher.change": voucherChange,
|
||||
"rare-category.change": rareCategoryChange,
|
||||
"rare-value.change": rareValueChange,
|
||||
"soundtrack.change": soundtrackChange,
|
||||
};
|
||||
|
||||
export async function executeEconomyDatabaseMutation(
|
||||
operation: EconomyMutationOperation,
|
||||
input: unknown,
|
||||
_context: EconomyMutationContext,
|
||||
transaction?: unknown,
|
||||
): Promise<EconomyMutationSnapshot | null> {
|
||||
const handler = HANDLERS[operation];
|
||||
return handler ? handler(input, transaction) : null;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { EconomyMutationFailure } from "./mutations";
|
||||
|
||||
export function validation(
|
||||
fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
||||
): EconomyMutationFailure {
|
||||
return new EconomyMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
fieldErrors,
|
||||
);
|
||||
}
|
||||
|
||||
export function notFound(): EconomyMutationFailure {
|
||||
return new EconomyMutationFailure(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
);
|
||||
}
|
||||
|
||||
export function record(input: unknown): Record<string, unknown> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
throw validation();
|
||||
}
|
||||
return input as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function text(
|
||||
value: unknown,
|
||||
maximum: number,
|
||||
required = false,
|
||||
): string {
|
||||
const normalized = String(value ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, maximum);
|
||||
if (required && !normalized) throw validation();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function rawText(value: unknown, maximum = 100_000): string {
|
||||
return String(value ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, maximum);
|
||||
}
|
||||
|
||||
export function positiveInteger(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw validation();
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function integer(
|
||||
value: unknown,
|
||||
minimum = 0,
|
||||
maximum = Number.MAX_SAFE_INTEGER,
|
||||
): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw validation();
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function positiveBigInt(value: unknown): bigint {
|
||||
const normalized =
|
||||
typeof value === "bigint" ? value.toString() : String(value ?? "").trim();
|
||||
if (!/^[1-9]\d*$/u.test(normalized)) throw validation();
|
||||
return BigInt(normalized);
|
||||
}
|
||||
|
||||
export function action(
|
||||
data: Record<string, unknown>,
|
||||
allowed: readonly string[],
|
||||
fallback = "update",
|
||||
): string {
|
||||
const normalized = text(data.action ?? fallback, 32, true);
|
||||
if (!allowed.includes(normalized)) throw validation();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function flag(value: unknown): "0" | "1" {
|
||||
if (value === true || value === 1 || value === "1" || value === "on") {
|
||||
return "1";
|
||||
}
|
||||
if (value === false || value === 0 || value === "0" || value === "") {
|
||||
return "0";
|
||||
}
|
||||
throw validation();
|
||||
}
|
||||
|
||||
export function hasOwn(data: Record<string, unknown>, key: string): boolean {
|
||||
return Object.hasOwn(data, key);
|
||||
}
|
||||
|
||||
export function requirePatch(data: Record<string, unknown>): void {
|
||||
if (Object.keys(data).length === 0) throw validation();
|
||||
}
|
||||
|
||||
export function integerArray(value: unknown, maximum = 500): number[] {
|
||||
if (!Array.isArray(value) || value.length > maximum) throw validation();
|
||||
return [...new Set(value.map(positiveInteger))];
|
||||
}
|
||||
|
||||
export function safeOutput(value: unknown): Readonly<Record<string, unknown>> {
|
||||
const serialized = JSON.stringify(value, (_key, item) =>
|
||||
typeof item === "bigint" ? item.toString() : item,
|
||||
);
|
||||
const parsed = JSON.parse(serialized ?? "{}") as unknown;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
||||
? (parsed as Readonly<Record<string, unknown>>)
|
||||
: { value: parsed };
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import "server-only";
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { and, eq, max } from "drizzle-orm";
|
||||
import { db, User, UsersBadges } from "@/lib/db";
|
||||
import { toBadgeGif } from "@/lib/images/badge-gif";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import {
|
||||
notFound,
|
||||
positiveInteger,
|
||||
record,
|
||||
text,
|
||||
validation,
|
||||
} from "./mutation-runtime-input";
|
||||
import type {
|
||||
EconomyMutationContext,
|
||||
EconomyMutationOperation,
|
||||
EconomyMutationSnapshot,
|
||||
} from "./mutations";
|
||||
import { EconomyCommittedExternalFailure } from "./mutations-production";
|
||||
|
||||
const BADGE_CODE = /^[A-Za-z0-9_-]{1,32}$/u;
|
||||
const BADGE_TYPES = new Set(["image/gif", "image/png"]);
|
||||
const MAX_BADGE_BYTES = 1024 * 1024;
|
||||
|
||||
async function badgeGive(input: unknown): Promise<EconomyMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const code = text(data.badge ?? data.code, 32, true);
|
||||
if (!BADGE_CODE.test(code))
|
||||
throw validation({ badge: ["invalid badge code"] });
|
||||
let userId: number;
|
||||
let username: string;
|
||||
if (data.userId) {
|
||||
userId = positiveInteger(data.userId);
|
||||
const [user] = await db
|
||||
.select({ id: User.id, username: User.username })
|
||||
.from(User)
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1);
|
||||
if (!user) throw notFound();
|
||||
username = user.username;
|
||||
} else {
|
||||
username = text(data.username, 25, true);
|
||||
const [user] = await db
|
||||
.select({ id: User.id, username: User.username })
|
||||
.from(User)
|
||||
.where(eq(User.username, username))
|
||||
.limit(1);
|
||||
if (!user) throw notFound();
|
||||
userId = user.id;
|
||||
}
|
||||
|
||||
const inserted = await db.transaction(async (transaction) => {
|
||||
const [existing] = await transaction
|
||||
.select({ id: UsersBadges.id, slotId: UsersBadges.slotId })
|
||||
.from(UsersBadges)
|
||||
.where(
|
||||
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) return false;
|
||||
const [aggregate] = await transaction
|
||||
.select({ maxSlot: max(UsersBadges.slotId) })
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, userId));
|
||||
await transaction.insert(UsersBadges).values({
|
||||
userId,
|
||||
slotId: (aggregate?.maxSlot ?? 0) + 1,
|
||||
badgeCode: code,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
const snapshot: EconomyMutationSnapshot = {
|
||||
before: { userId: String(userId), badge: code, present: !inserted },
|
||||
after: { userId: String(userId), username, badge: code, present: true },
|
||||
output: { userId: String(userId), badge: code, inserted },
|
||||
};
|
||||
try {
|
||||
const delivered = await rcon.giveBadge(userId, code);
|
||||
if (!delivered) throw new Error("badge grant rejected");
|
||||
return snapshot;
|
||||
} catch {
|
||||
throw new EconomyCommittedExternalFailure(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
async function badgeUpload(input: unknown): Promise<EconomyMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const code = text(data.code, 32, true);
|
||||
if (!BADGE_CODE.test(code))
|
||||
throw validation({ code: ["invalid badge code"] });
|
||||
const file = data.file;
|
||||
if (!(file instanceof File)) throw validation({ file: ["file required"] });
|
||||
if (
|
||||
file.size <= 0 ||
|
||||
file.size > MAX_BADGE_BYTES ||
|
||||
!BADGE_TYPES.has(file.type)
|
||||
) {
|
||||
throw validation({ file: ["GIF or PNG up to 1 MB required"] });
|
||||
}
|
||||
const configuredDirectory = process.env.BADGE_UPLOAD_DIR;
|
||||
if (!configuredDirectory) {
|
||||
throw new Error("badge upload directory unavailable");
|
||||
}
|
||||
const baseDirectory = path.resolve(configuredDirectory);
|
||||
const target = path.resolve(baseDirectory, `${code}.gif`);
|
||||
if (!target.startsWith(baseDirectory + path.sep)) throw validation();
|
||||
const gif = await toBadgeGif(Buffer.from(await file.arrayBuffer()));
|
||||
await mkdir(baseDirectory, { recursive: true });
|
||||
await writeFile(target, gif);
|
||||
return {
|
||||
before: null,
|
||||
after: { code, filename: `${code}.gif`, size: gif.byteLength },
|
||||
output: { code },
|
||||
};
|
||||
}
|
||||
|
||||
const HANDLERS: Partial<
|
||||
Record<
|
||||
EconomyMutationOperation,
|
||||
(
|
||||
input: unknown,
|
||||
context: EconomyMutationContext,
|
||||
) => Promise<EconomyMutationSnapshot>
|
||||
>
|
||||
> = {
|
||||
"badge.give": (input) => badgeGive(input),
|
||||
"badge.upload": (input) => badgeUpload(input),
|
||||
};
|
||||
|
||||
export async function executeEconomyRewardsMutation(
|
||||
operation: EconomyMutationOperation,
|
||||
input: unknown,
|
||||
context: EconomyMutationContext,
|
||||
): Promise<EconomyMutationSnapshot | null> {
|
||||
const handler = HANDLERS[operation];
|
||||
return handler ? handler(input, context) : null;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AuditEntry } from "@/lib/services/audit";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { ECONOMY_MUTATION_OPERATIONS } from "./mutations";
|
||||
import {
|
||||
createEconomyProductionMutationAdapter,
|
||||
ECONOMY_DATABASE_OPERATIONS,
|
||||
ECONOMY_EXTERNAL_OPERATIONS,
|
||||
ECONOMY_MIXED_OPERATIONS,
|
||||
EconomyCommittedExternalFailure,
|
||||
} from "./mutations-production";
|
||||
|
||||
const capability: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
};
|
||||
const context = {
|
||||
capability,
|
||||
correlationId: "economy-production",
|
||||
legacy: false,
|
||||
};
|
||||
|
||||
function dependencies() {
|
||||
const transactionToken = { transaction: true };
|
||||
const writeAudit = vi.fn(
|
||||
async (_entry: AuditEntry, _transaction?: unknown) => undefined,
|
||||
);
|
||||
const executeOperation = vi.fn(async (operation: string) => ({
|
||||
before: { operation, state: "before" },
|
||||
after: { operation, state: "after" },
|
||||
output: { id: "18446744073709551615" },
|
||||
}));
|
||||
const transaction = vi.fn(async (run) => run(transactionToken));
|
||||
return {
|
||||
transactionToken,
|
||||
writeAudit,
|
||||
executeOperation,
|
||||
transaction,
|
||||
adapter: createEconomyProductionMutationAdapter({
|
||||
transaction,
|
||||
writeAudit,
|
||||
executeOperation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Economy production mutation adapter", () => {
|
||||
it("classifies every operation exactly once", () => {
|
||||
const classified = [
|
||||
...ECONOMY_DATABASE_OPERATIONS,
|
||||
...ECONOMY_EXTERNAL_OPERATIONS,
|
||||
...ECONOMY_MIXED_OPERATIONS,
|
||||
];
|
||||
expect([...classified].sort()).toEqual(
|
||||
[...ECONOMY_MUTATION_OPERATIONS].sort(),
|
||||
);
|
||||
expect(new Set(classified).size).toBe(classified.length);
|
||||
});
|
||||
|
||||
it("keeps database changes and their success audit in one transaction", async () => {
|
||||
const deps = dependencies();
|
||||
await deps.adapter.execute("voucher.change", { action: "create" }, context);
|
||||
expect(deps.executeOperation).toHaveBeenCalledWith(
|
||||
"voucher.change",
|
||||
expect.anything(),
|
||||
context,
|
||||
deps.transactionToken,
|
||||
);
|
||||
expect(deps.writeAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "economy.voucher.change",
|
||||
domain: "economy",
|
||||
outcome: "success",
|
||||
correlationId: "economy-production",
|
||||
}),
|
||||
deps.transactionToken,
|
||||
);
|
||||
});
|
||||
|
||||
it("persists correlated intent and outcome around mixed changes", async () => {
|
||||
const deps = dependencies();
|
||||
await deps.adapter.execute("catalog-page.change", { id: 1 }, context);
|
||||
expect(deps.transaction).not.toHaveBeenCalled();
|
||||
expect(deps.writeAudit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
|
||||
"intent",
|
||||
"success",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a truthful partial when the database committed but RCON failed", async () => {
|
||||
const deps = dependencies();
|
||||
deps.executeOperation.mockRejectedValueOnce(
|
||||
new EconomyCommittedExternalFailure({
|
||||
before: { id: "1", enabled: "0" },
|
||||
after: { id: "1", enabled: "1" },
|
||||
}),
|
||||
);
|
||||
const snapshot = await deps.adapter.execute(
|
||||
"catalog-page.change",
|
||||
{ id: 1 },
|
||||
context,
|
||||
);
|
||||
expect(snapshot.completion).toEqual({
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
});
|
||||
expect(deps.writeAudit.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
outcome: "partial",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not put voucher codes or uploaded bytes in audit records", async () => {
|
||||
const deps = dependencies();
|
||||
await deps.adapter.execute(
|
||||
"voucher.change",
|
||||
{ code: "SECRET-CODE", amount: 50 },
|
||||
context,
|
||||
);
|
||||
expect(JSON.stringify(deps.writeAudit.mock.calls)).not.toContain(
|
||||
"SECRET-CODE",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { AuditEntry } from "@/lib/services/audit";
|
||||
import type {
|
||||
EconomyMutationAdapter,
|
||||
EconomyMutationContext,
|
||||
EconomyMutationOperation,
|
||||
EconomyMutationSnapshot,
|
||||
} from "./mutations";
|
||||
|
||||
export const ECONOMY_DATABASE_OPERATIONS = [
|
||||
"shop-article.change",
|
||||
"marketplace.cancel",
|
||||
"voucher.change",
|
||||
"rare-category.change",
|
||||
"rare-value.change",
|
||||
"soundtrack.change",
|
||||
] as const satisfies readonly EconomyMutationOperation[];
|
||||
|
||||
export const ECONOMY_EXTERNAL_OPERATIONS = [
|
||||
"badge.upload",
|
||||
] as const satisfies readonly EconomyMutationOperation[];
|
||||
|
||||
export const ECONOMY_MIXED_OPERATIONS = [
|
||||
"catalog-page.change",
|
||||
"catalog-page.reorder",
|
||||
"catalog-page.delete-tree",
|
||||
"bc-page.change",
|
||||
"bc-page.reorder",
|
||||
"bc-page.delete-tree",
|
||||
"bc-item.change",
|
||||
"catalog-item.change",
|
||||
"catalog-item.bulk-create",
|
||||
"catalog-item.move",
|
||||
"catalog-item.reorder",
|
||||
"catalog-item.translate",
|
||||
"maintenance.fix-offers",
|
||||
"maintenance.fix-everything",
|
||||
"maintenance.fix-sprite-ids",
|
||||
"maintenance.reconcile-ids",
|
||||
"maintenance.align-ids",
|
||||
"maintenance.remove-duplicates",
|
||||
"items-base.update",
|
||||
"badge.give",
|
||||
] as const satisfies readonly EconomyMutationOperation[];
|
||||
|
||||
type TransactionToken = unknown;
|
||||
|
||||
export interface EconomyProductionMutationDependencies {
|
||||
transaction<T>(
|
||||
run: (transaction: TransactionToken) => Promise<T>,
|
||||
): Promise<T>;
|
||||
writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise<void>;
|
||||
executeOperation(
|
||||
operation: EconomyMutationOperation,
|
||||
input: unknown,
|
||||
context: EconomyMutationContext,
|
||||
transaction?: TransactionToken,
|
||||
): Promise<EconomyMutationSnapshot>;
|
||||
}
|
||||
|
||||
export class EconomyCommittedExternalFailure extends Error {
|
||||
constructor(readonly snapshot: EconomyMutationSnapshot) {
|
||||
super("Economy database change committed but external effect failed");
|
||||
this.name = "EconomyCommittedExternalFailure";
|
||||
}
|
||||
}
|
||||
|
||||
function auditEntry(
|
||||
operation: EconomyMutationOperation,
|
||||
context: EconomyMutationContext,
|
||||
outcome: NonNullable<AuditEntry["outcome"]>,
|
||||
snapshot?: EconomyMutationSnapshot,
|
||||
): AuditEntry {
|
||||
return {
|
||||
userId: context.capability.actor.id,
|
||||
action: `economy.${operation}`,
|
||||
target: "Economy",
|
||||
correlationId: context.correlationId,
|
||||
domain: "economy",
|
||||
outcome,
|
||||
before:
|
||||
snapshot?.before === null || snapshot?.before === undefined
|
||||
? undefined
|
||||
: { ...snapshot.before },
|
||||
after:
|
||||
snapshot?.after === null || snapshot?.after === undefined
|
||||
? undefined
|
||||
: { ...snapshot.after },
|
||||
};
|
||||
}
|
||||
|
||||
function includesOperation(
|
||||
operations: readonly EconomyMutationOperation[],
|
||||
operation: EconomyMutationOperation,
|
||||
): boolean {
|
||||
return operations.includes(operation);
|
||||
}
|
||||
|
||||
async function writeOutcomeOrMarkUnavailable(
|
||||
dependencies: EconomyProductionMutationDependencies,
|
||||
operation: EconomyMutationOperation,
|
||||
context: EconomyMutationContext,
|
||||
snapshot: EconomyMutationSnapshot,
|
||||
outcome: "success" | "partial",
|
||||
): Promise<EconomyMutationSnapshot> {
|
||||
try {
|
||||
await dependencies.writeAudit(
|
||||
auditEntry(operation, context, outcome, snapshot),
|
||||
);
|
||||
return snapshot;
|
||||
} catch {
|
||||
return {
|
||||
...snapshot,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: outcome === "partial" ? "failed" : "completed",
|
||||
audit: "unavailable",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createEconomyProductionMutationAdapter(
|
||||
dependencies: EconomyProductionMutationDependencies,
|
||||
): EconomyMutationAdapter {
|
||||
return {
|
||||
async execute(operation, input, context) {
|
||||
if (includesOperation(ECONOMY_DATABASE_OPERATIONS, operation)) {
|
||||
return dependencies.transaction(async (transaction) => {
|
||||
const snapshot = await dependencies.executeOperation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
transaction,
|
||||
);
|
||||
await dependencies.writeAudit(
|
||||
auditEntry(operation, context, "success", snapshot),
|
||||
transaction,
|
||||
);
|
||||
return snapshot;
|
||||
});
|
||||
}
|
||||
|
||||
await dependencies.writeAudit(auditEntry(operation, context, "intent"));
|
||||
try {
|
||||
const snapshot = await dependencies.executeOperation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
);
|
||||
return writeOutcomeOrMarkUnavailable(
|
||||
dependencies,
|
||||
operation,
|
||||
context,
|
||||
snapshot,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
includesOperation(ECONOMY_MIXED_OPERATIONS, operation) &&
|
||||
error instanceof EconomyCommittedExternalFailure
|
||||
) {
|
||||
const snapshot: EconomyMutationSnapshot = {
|
||||
...error.snapshot,
|
||||
completion: {
|
||||
status: "partial",
|
||||
external: "failed",
|
||||
audit: "persisted",
|
||||
},
|
||||
};
|
||||
return writeOutcomeOrMarkUnavailable(
|
||||
dependencies,
|
||||
operation,
|
||||
context,
|
||||
snapshot,
|
||||
"partial",
|
||||
);
|
||||
}
|
||||
try {
|
||||
await dependencies.writeAudit(
|
||||
auditEntry(operation, context, "failure"),
|
||||
);
|
||||
} catch {
|
||||
// The durable intent remains correlated when the outcome store is down.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const economyProductionMutationAdapter =
|
||||
createEconomyProductionMutationAdapter({
|
||||
async transaction(run) {
|
||||
const { db } = await import("@/lib/db");
|
||||
return db.transaction((transaction) => run(transaction));
|
||||
},
|
||||
async writeAudit(entry, transaction) {
|
||||
const { logAudit } = await import("@/lib/services/audit");
|
||||
await logAudit(entry, transaction as never);
|
||||
},
|
||||
async executeOperation(operation, input, context, transaction) {
|
||||
const { executeEconomyMutationOperation } = await import(
|
||||
"./mutations-runtime"
|
||||
);
|
||||
return executeEconomyMutationOperation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
transaction,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import "server-only";
|
||||
|
||||
import { executeEconomyCatalogMutation } from "./mutation-runtime-catalog";
|
||||
import { executeEconomyDatabaseMutation } from "./mutation-runtime-commerce";
|
||||
import { executeEconomyRewardsMutation } from "./mutation-runtime-rewards";
|
||||
import type {
|
||||
EconomyMutationContext,
|
||||
EconomyMutationOperation,
|
||||
EconomyMutationSnapshot,
|
||||
} from "./mutations";
|
||||
import { EconomyMutationFailure } from "./mutations";
|
||||
|
||||
export async function executeEconomyMutationOperation(
|
||||
operation: EconomyMutationOperation,
|
||||
input: unknown,
|
||||
context: EconomyMutationContext,
|
||||
transaction?: unknown,
|
||||
): Promise<EconomyMutationSnapshot> {
|
||||
const databaseResult = await executeEconomyDatabaseMutation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
transaction,
|
||||
);
|
||||
if (databaseResult) return databaseResult;
|
||||
const catalogResult = await executeEconomyCatalogMutation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
);
|
||||
if (catalogResult) return catalogResult;
|
||||
const rewardsResult = await executeEconomyRewardsMutation(
|
||||
operation,
|
||||
input,
|
||||
context,
|
||||
);
|
||||
if (rewardsResult) return rewardsResult;
|
||||
throw new EconomyMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import {
|
||||
createEconomyMutationService,
|
||||
EconomyMutationFailure,
|
||||
} from "./mutations";
|
||||
|
||||
function capability(
|
||||
overrides: Partial<HousekeepingCapabilityContext> = {},
|
||||
): HousekeepingCapabilityContext {
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => true,
|
||||
hasAny: () => true,
|
||||
hasAll: () => true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Economy mutation service", () => {
|
||||
it("rechecks the actor and capability before invoking the adapter", async () => {
|
||||
const execute = vi.fn();
|
||||
const service = createEconomyMutationService({ execute }, async () =>
|
||||
capability({ hasAny: () => false }),
|
||||
);
|
||||
const result = await service.execute(
|
||||
{ correlationId: "forbidden", expectedActorId: 42 },
|
||||
"catalog-page.change",
|
||||
{},
|
||||
);
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a stale actor-bound invocation", async () => {
|
||||
const execute = vi.fn();
|
||||
const service = createEconomyMutationService({ execute }, async () =>
|
||||
capability(),
|
||||
);
|
||||
const result = await service.execute(
|
||||
{ correlationId: "stale", expectedActorId: 7 },
|
||||
"voucher.change",
|
||||
{},
|
||||
);
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves typed validation failures and correlation ids", async () => {
|
||||
const service = createEconomyMutationService(
|
||||
{
|
||||
execute: async () => {
|
||||
throw new EconomyMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
{ code: ["invalid"] },
|
||||
);
|
||||
},
|
||||
},
|
||||
async () => capability(),
|
||||
);
|
||||
const result = await service.execute(
|
||||
{ correlationId: "validation", expectedActorId: 42 },
|
||||
"badge.upload",
|
||||
{},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
correlationId: "validation",
|
||||
error: { code: "VALIDATION", fieldErrors: { code: ["invalid"] } },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns snapshots and partial completion from the adapter", async () => {
|
||||
const service = createEconomyMutationService(
|
||||
{
|
||||
execute: async () => ({
|
||||
before: { id: "1" },
|
||||
after: { id: "1", state: "updated" },
|
||||
completion: {
|
||||
status: "partial" as const,
|
||||
external: "failed" as const,
|
||||
audit: "persisted" as const,
|
||||
},
|
||||
}),
|
||||
},
|
||||
async () => capability(),
|
||||
);
|
||||
const result = await service.execute(
|
||||
{ correlationId: "partial", expectedActorId: 42 },
|
||||
"catalog-item.translate",
|
||||
{},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
completion: { status: "partial", external: "failed" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { satisfiesCapability } from "../../../foundation/capability-context";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingErrorCode,
|
||||
type HousekeepingPartialCompletion,
|
||||
type HousekeepingResult,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
|
||||
export const ECONOMY_MUTATION_OPERATIONS = [
|
||||
"catalog-page.change",
|
||||
"catalog-page.reorder",
|
||||
"catalog-page.delete-tree",
|
||||
"bc-page.change",
|
||||
"bc-page.reorder",
|
||||
"bc-page.delete-tree",
|
||||
"bc-item.change",
|
||||
"catalog-item.change",
|
||||
"catalog-item.bulk-create",
|
||||
"catalog-item.move",
|
||||
"catalog-item.reorder",
|
||||
"catalog-item.translate",
|
||||
"maintenance.fix-offers",
|
||||
"maintenance.fix-everything",
|
||||
"maintenance.fix-sprite-ids",
|
||||
"maintenance.reconcile-ids",
|
||||
"maintenance.align-ids",
|
||||
"maintenance.remove-duplicates",
|
||||
"items-base.update",
|
||||
"shop-article.change",
|
||||
"marketplace.cancel",
|
||||
"voucher.change",
|
||||
"rare-category.change",
|
||||
"rare-value.change",
|
||||
"badge.give",
|
||||
"badge.upload",
|
||||
"soundtrack.change",
|
||||
] as const;
|
||||
|
||||
export type EconomyMutationOperation =
|
||||
(typeof ECONOMY_MUTATION_OPERATIONS)[number];
|
||||
|
||||
export interface EconomyMutationSnapshot {
|
||||
readonly before: Readonly<Record<string, unknown>> | null;
|
||||
readonly after: Readonly<Record<string, unknown>> | null;
|
||||
readonly output?: Readonly<Record<string, unknown>>;
|
||||
readonly completion?: HousekeepingPartialCompletion;
|
||||
}
|
||||
export interface EconomyMutationInvocation {
|
||||
readonly correlationId: string;
|
||||
readonly expectedActorId: number;
|
||||
readonly legacy?: boolean;
|
||||
}
|
||||
export interface EconomyMutationContext {
|
||||
readonly capability: HousekeepingCapabilityContext;
|
||||
readonly correlationId: string;
|
||||
readonly legacy: boolean;
|
||||
}
|
||||
export interface EconomyMutationAdapter {
|
||||
execute(
|
||||
operation: EconomyMutationOperation,
|
||||
input: unknown,
|
||||
context: EconomyMutationContext,
|
||||
): Promise<EconomyMutationSnapshot>;
|
||||
}
|
||||
export interface EconomyMutationService {
|
||||
execute(
|
||||
invocation: EconomyMutationInvocation,
|
||||
operation: EconomyMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<HousekeepingResult<EconomyMutationSnapshot>>;
|
||||
}
|
||||
|
||||
export class EconomyMutationFailure extends Error {
|
||||
constructor(
|
||||
readonly code: HousekeepingErrorCode,
|
||||
readonly messageKey: string,
|
||||
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
||||
) {
|
||||
super(messageKey);
|
||||
this.name = "EconomyMutationFailure";
|
||||
}
|
||||
}
|
||||
|
||||
const OPERATION_PERMISSION = Object.freeze(
|
||||
Object.fromEntries(
|
||||
ECONOMY_MUTATION_OPERATIONS.map((operation) => [
|
||||
operation,
|
||||
operation.startsWith("shop-") ||
|
||||
operation.startsWith("marketplace.") ||
|
||||
operation.startsWith("voucher.") ||
|
||||
operation.startsWith("rare-")
|
||||
? PERMS.SHOP_EDIT
|
||||
: PERMS.CATALOG_EDIT,
|
||||
]),
|
||||
) as Record<EconomyMutationOperation, string>,
|
||||
);
|
||||
|
||||
export function economyMutationCapability(operation: EconomyMutationOperation) {
|
||||
return anyCapability(OPERATION_PERMISSION[operation]);
|
||||
}
|
||||
|
||||
export function createEconomyMutationService(
|
||||
adapter: EconomyMutationAdapter,
|
||||
resolveCapabilityContext: () => Promise<HousekeepingCapabilityContext>,
|
||||
): EconomyMutationService {
|
||||
return {
|
||||
async execute(invocation, operation, input) {
|
||||
let capability: HousekeepingCapabilityContext;
|
||||
try {
|
||||
capability = await resolveCapabilityContext();
|
||||
} catch {
|
||||
return fail(
|
||||
"UNAUTHENTICATED",
|
||||
"errors.housekeeping.unauthenticated",
|
||||
invocation.correlationId,
|
||||
);
|
||||
}
|
||||
if (
|
||||
capability.actor.id !== invocation.expectedActorId ||
|
||||
!satisfiesCapability(capability, economyMutationCapability(operation))
|
||||
) {
|
||||
return fail(
|
||||
"FORBIDDEN",
|
||||
"errors.housekeeping.forbidden",
|
||||
invocation.correlationId,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const snapshot = await adapter.execute(operation, input, {
|
||||
capability,
|
||||
correlationId: invocation.correlationId,
|
||||
legacy: invocation.legacy === true,
|
||||
});
|
||||
return ok(snapshot, invocation.correlationId, snapshot.completion);
|
||||
} catch (error) {
|
||||
if (error instanceof EconomyMutationFailure) {
|
||||
return fail(
|
||||
error.code,
|
||||
error.messageKey,
|
||||
invocation.correlationId,
|
||||
error.fieldErrors,
|
||||
);
|
||||
}
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
invocation.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createEconomyMutationInvocation(
|
||||
actor: { readonly id: number },
|
||||
correlationId: string,
|
||||
): EconomyMutationInvocation {
|
||||
return { correlationId, expectedActorId: actor.id, legacy: true };
|
||||
}
|
||||
|
||||
const productionAdapter: EconomyMutationAdapter = {
|
||||
async execute(operation, input, context) {
|
||||
const { economyProductionMutationAdapter } = await import(
|
||||
"./mutations-production"
|
||||
);
|
||||
return economyProductionMutationAdapter.execute(operation, input, context);
|
||||
},
|
||||
};
|
||||
|
||||
export const economyMutationService = createEconomyMutationService(
|
||||
productionAdapter,
|
||||
async () => {
|
||||
const { getHousekeepingCapabilityContext } = await import(
|
||||
"../../../foundation/server-capability-context"
|
||||
);
|
||||
return getHousekeepingCapabilityContext();
|
||||
},
|
||||
);
|
||||
|
||||
export async function executeLegacyEconomyMutation(
|
||||
actor: { readonly id: number },
|
||||
operation: EconomyMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<EconomyMutationSnapshot> {
|
||||
const { createCorrelationId } = await import(
|
||||
"../../../foundation/correlation"
|
||||
);
|
||||
const result = await economyMutationService.execute(
|
||||
{
|
||||
correlationId: createCorrelationId(),
|
||||
expectedActorId: actor.id,
|
||||
legacy: true,
|
||||
},
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw new EconomyMutationFailure(
|
||||
result.error.code,
|
||||
result.error.messageKey,
|
||||
result.error.fieldErrors,
|
||||
);
|
||||
}
|
||||
if (result.completion?.external === "failed") {
|
||||
throw new EconomyMutationFailure(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import "server-only";
|
||||
|
||||
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||
import { economyQuery } from "./queries/catalog";
|
||||
|
||||
type EconomyWidgetKind = "catalog" | "commerce" | "value";
|
||||
|
||||
async function total(
|
||||
context: HousekeepingCapabilityContext,
|
||||
routeId:
|
||||
| "economy.catalog.overview"
|
||||
| "economy.items.overview"
|
||||
| "economy.commerce.shop"
|
||||
| "economy.history.transactions"
|
||||
| "economy.value.rare-values",
|
||||
query: Pick<typeof economyQuery, "run">,
|
||||
): Promise<number> {
|
||||
const result = await query.run(context, {
|
||||
routeId,
|
||||
list: { pageSize: 1, offset: 0 },
|
||||
});
|
||||
if (!result.ok) throw new Error("Economy widget query unavailable");
|
||||
return result.data.total;
|
||||
}
|
||||
|
||||
export async function loadEconomyWidget(
|
||||
kind: EconomyWidgetKind,
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
query: Pick<typeof economyQuery, "run"> = economyQuery,
|
||||
): Promise<Readonly<Record<string, number>>> {
|
||||
if (signal.aborted) throw new Error("aborted Economy widget");
|
||||
if (kind === "catalog") {
|
||||
const [pages, items] = await Promise.all([
|
||||
total(context, "economy.catalog.overview", query),
|
||||
total(context, "economy.items.overview", query),
|
||||
]);
|
||||
return { pages, items };
|
||||
}
|
||||
if (kind === "commerce") {
|
||||
const [packages, transactions] = await Promise.all([
|
||||
total(context, "economy.commerce.shop", query),
|
||||
total(context, "economy.history.transactions", query),
|
||||
]);
|
||||
return { packages, transactions };
|
||||
}
|
||||
return {
|
||||
rares: await total(context, "economy.value.rare-values", query),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||
import {
|
||||
anyCapability,
|
||||
type CapabilityRequirement,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingWidgetDefinition,
|
||||
ok,
|
||||
} from "../../foundation/contracts";
|
||||
|
||||
export const ECONOMY_WIDGET_IDS = [
|
||||
"economy.catalog-summary",
|
||||
"economy.commerce-summary",
|
||||
"economy.value-summary",
|
||||
] as const;
|
||||
|
||||
type EconomyWidgetLoader = (
|
||||
context: HousekeepingCapabilityContext,
|
||||
signal: AbortSignal,
|
||||
) => Promise<Readonly<Record<string, number>>>;
|
||||
|
||||
export interface EconomyWidgetAdapters {
|
||||
readonly catalog: EconomyWidgetLoader;
|
||||
readonly commerce: EconomyWidgetLoader;
|
||||
readonly value: EconomyWidgetLoader;
|
||||
}
|
||||
|
||||
function createWidget(
|
||||
id: (typeof ECONOMY_WIDGET_IDS)[number],
|
||||
kind: "mandatory" | "optional",
|
||||
capability: CapabilityRequirement,
|
||||
load: EconomyWidgetLoader,
|
||||
): HousekeepingWidgetDefinition {
|
||||
return {
|
||||
id,
|
||||
owner: "economy",
|
||||
kind,
|
||||
capability,
|
||||
async load(context, signal) {
|
||||
const authorization = authorizeHousekeeping(context, capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
try {
|
||||
return ok(await load(context, signal), authorization.correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createEconomyWidgets(
|
||||
adapters: EconomyWidgetAdapters,
|
||||
): readonly HousekeepingWidgetDefinition[] {
|
||||
return [
|
||||
createWidget(
|
||||
"economy.catalog-summary",
|
||||
"mandatory",
|
||||
anyCapability(PERMS.CATALOG_VIEW),
|
||||
adapters.catalog,
|
||||
),
|
||||
createWidget(
|
||||
"economy.commerce-summary",
|
||||
"optional",
|
||||
anyCapability(PERMS.SHOP_VIEW),
|
||||
adapters.commerce,
|
||||
),
|
||||
createWidget(
|
||||
"economy.value-summary",
|
||||
"optional",
|
||||
anyCapability(PERMS.SHOP_VIEW),
|
||||
adapters.value,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export const ECONOMY_WIDGETS = createEconomyWidgets({
|
||||
async catalog(context, signal) {
|
||||
const { loadEconomyWidget } = await import("./widgets-production");
|
||||
return loadEconomyWidget("catalog", context, signal);
|
||||
},
|
||||
async commerce(context, signal) {
|
||||
const { loadEconomyWidget } = await import("./widgets-production");
|
||||
return loadEconomyWidget("commerce", context, signal);
|
||||
},
|
||||
async value(context, signal) {
|
||||
const { loadEconomyWidget } = await import("./widgets-production");
|
||||
return loadEconomyWidget("value", context, signal);
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { CONTENT_COMMANDS } from "../../domains/content/commands/content-commands";
|
||||
import { ECONOMY_COMMANDS } from "../../domains/economy/commands/economy-commands";
|
||||
import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands";
|
||||
import { MODERATION_COMMANDS } from "../../domains/people/commands/moderation-commands";
|
||||
import { SUPPORT_COMMANDS } from "../../domains/people/commands/support-commands";
|
||||
@@ -42,6 +43,7 @@ export function registerHousekeepingCommands<
|
||||
|
||||
const currentHousekeepingCommands = defineHousekeepingCommands(
|
||||
...CONTENT_COMMANDS,
|
||||
...ECONOMY_COMMANDS,
|
||||
...USER_COMMANDS,
|
||||
...COMMUNITY_COMMANDS,
|
||||
...SUPPORT_COMMANDS,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createElement, type ReactElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONTENT_ROUTE_IDS } from "../domains/content/routes";
|
||||
import { ECONOMY_ROUTE_IDS } from "../domains/economy/routes";
|
||||
import { PEOPLE_ROUTE_IDS } from "../domains/people/routes";
|
||||
import { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
@@ -380,6 +381,218 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
"src/features/housekeeping/domains/content/widgets.ts",
|
||||
new Set(["src/features/housekeeping/domains/content/widgets-production"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/commands/economy-commands.ts",
|
||||
new Set(["src/features/housekeeping/domains/economy/services/mutations"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/pages/catalog.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/pages/economy-command-form",
|
||||
"src/features/housekeeping/domains/economy/pages/economy-page-frame",
|
||||
"src/features/housekeeping/domains/economy/pages/specialized-catalog-editor",
|
||||
"src/features/housekeeping/domains/economy/queries/catalog",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/pages/commerce.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/pages/economy-command-form",
|
||||
"src/features/housekeeping/domains/economy/pages/economy-page-frame",
|
||||
"src/features/housekeeping/domains/economy/queries/catalog",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/pages/economy-command-form.tsx",
|
||||
new Set(["src/actions/housekeeping-command"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/pages/economy-page-frame.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/queries/catalog",
|
||||
"src/features/housekeeping/domains/economy/routes",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/pages/history.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/pages/economy-page-frame",
|
||||
"src/features/housekeeping/domains/economy/queries/catalog",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/pages/items.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/pages/economy-command-form",
|
||||
"src/features/housekeeping/domains/economy/pages/economy-page-frame",
|
||||
"src/features/housekeeping/domains/economy/queries/catalog",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/pages/rewards.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/pages/economy-command-form",
|
||||
"src/features/housekeeping/domains/economy/pages/economy-page-frame",
|
||||
"src/features/housekeeping/domains/economy/queries/catalog",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/pages/specialized-catalog-editor.tsx",
|
||||
new Set([
|
||||
"src/app/admin/catalog/[id]/catalog-detail-tabs",
|
||||
"src/app/admin/catalog/builder-club/bc-manager",
|
||||
"src/features/housekeeping/domains/economy/routes",
|
||||
"src/lib/db",
|
||||
"drizzle-orm",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/pages/value.tsx",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/pages/economy-command-form",
|
||||
"src/features/housekeeping/domains/economy/pages/economy-page-frame",
|
||||
"src/features/housekeeping/domains/economy/queries/catalog",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/queries/catalog.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/queries/commerce",
|
||||
"src/features/housekeeping/domains/economy/queries/economy-production",
|
||||
"src/features/housekeeping/domains/economy/queries/value",
|
||||
"src/features/housekeeping/domains/economy/routes",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/queries/commerce.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/queries/catalog",
|
||||
"src/features/housekeeping/domains/economy/queries/economy-production",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/queries/economy-production.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/queries/catalog",
|
||||
"src/features/housekeeping/domains/economy/queries/commerce",
|
||||
"src/features/housekeeping/domains/economy/routes",
|
||||
"src/lib/db",
|
||||
"drizzle-orm",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/queries/value.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/queries/catalog",
|
||||
"src/features/housekeeping/domains/economy/queries/economy-production",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/services/mutation-runtime-catalog.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/services/mutation-runtime-input",
|
||||
"src/features/housekeeping/domains/economy/services/mutations",
|
||||
"src/features/housekeeping/domains/economy/services/mutations-production",
|
||||
"src/lib/db",
|
||||
"drizzle-orm",
|
||||
"mysql2",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/services/mutation-runtime-commerce.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/services/mutation-runtime-input",
|
||||
"src/features/housekeeping/domains/economy/services/mutations",
|
||||
"src/lib/db",
|
||||
"drizzle-orm",
|
||||
"mysql2",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/services/mutation-runtime-input.ts",
|
||||
new Set(["src/features/housekeeping/domains/economy/services/mutations"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/services/mutation-runtime-rewards.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/services/mutation-runtime-input",
|
||||
"src/features/housekeeping/domains/economy/services/mutations",
|
||||
"src/features/housekeeping/domains/economy/services/mutations-production",
|
||||
"src/lib/db",
|
||||
"drizzle-orm",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/services/mutations-production.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/services/mutations",
|
||||
"src/features/housekeeping/domains/economy/services/mutations-runtime",
|
||||
"src/lib/db",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/services/mutations-runtime.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/services/mutation-runtime-catalog",
|
||||
"src/features/housekeeping/domains/economy/services/mutation-runtime-commerce",
|
||||
"src/features/housekeeping/domains/economy/services/mutation-runtime-rewards",
|
||||
"src/features/housekeeping/domains/economy/services/mutations",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/services/mutations.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/services/mutations-production",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/inbox-production.ts",
|
||||
new Set(["src/features/housekeeping/domains/economy/queries/catalog"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/inbox.ts",
|
||||
new Set(["src/features/housekeeping/domains/economy/inbox-production"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/manifest.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/inbox",
|
||||
"src/features/housekeeping/domains/economy/routes",
|
||||
"src/features/housekeeping/domains/economy/search",
|
||||
"src/features/housekeeping/domains/economy/widgets",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/route-handlers.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/pages/catalog",
|
||||
"src/features/housekeeping/domains/economy/pages/commerce",
|
||||
"src/features/housekeeping/domains/economy/pages/history",
|
||||
"src/features/housekeeping/domains/economy/pages/items",
|
||||
"src/features/housekeeping/domains/economy/pages/rewards",
|
||||
"src/features/housekeeping/domains/economy/pages/value",
|
||||
"src/features/housekeeping/domains/economy/routes",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/search-production.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/economy/queries/catalog",
|
||||
"src/features/housekeeping/domains/economy/search",
|
||||
]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/search.ts",
|
||||
new Set(["src/features/housekeeping/domains/economy/search-production"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/widgets-production.ts",
|
||||
new Set(["src/features/housekeeping/domains/economy/queries/catalog"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/widgets.ts",
|
||||
new Set(["src/features/housekeeping/domains/economy/widgets-production"]),
|
||||
],
|
||||
[
|
||||
"src/features/housekeeping/domains/system/commands/system-commands.ts",
|
||||
new Set(["src/features/housekeeping/domains/system/services/mutations"]),
|
||||
@@ -489,6 +702,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
"src/features/housekeeping/foundation/commands/bootstrap.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/content/commands/content-commands",
|
||||
"src/features/housekeeping/domains/economy/commands/economy-commands",
|
||||
"src/features/housekeeping/domains/people/commands/community-commands",
|
||||
"src/features/housekeeping/domains/people/commands/moderation-commands",
|
||||
"src/features/housekeeping/domains/people/commands/support-commands",
|
||||
@@ -500,6 +714,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
"src/features/housekeeping/route-handlers.ts",
|
||||
new Set([
|
||||
"src/features/housekeeping/domains/content/route-handlers",
|
||||
"src/features/housekeeping/domains/economy/route-handlers",
|
||||
"src/features/housekeeping/domains/people/route-handlers",
|
||||
"src/features/housekeeping/domains/system/route-handlers",
|
||||
]),
|
||||
@@ -1100,7 +1315,7 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("creates the real six-domain registry with People, Content, and System routes enabled", () => {
|
||||
it("creates the real six-domain registry with People, Content, Economy, and System routes enabled", () => {
|
||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||
|
||||
expect(registry.domains.map((domain) => domain.id)).toEqual([
|
||||
@@ -1114,7 +1329,8 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
expect(
|
||||
registry.domains
|
||||
.filter(
|
||||
(domain) => !["people", "content", "system"].includes(domain.id),
|
||||
(domain) =>
|
||||
!["people", "content", "economy", "system"].includes(domain.id),
|
||||
)
|
||||
.every((domain) => domain.routes.length === 0),
|
||||
).toBe(true);
|
||||
@@ -1128,6 +1344,11 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
.find((domain) => domain.id === "content")
|
||||
?.routes.map((route) => route.id),
|
||||
).toEqual(CONTENT_ROUTE_IDS);
|
||||
expect(
|
||||
registry.domains
|
||||
.find((domain) => domain.id === "economy")
|
||||
?.routes.map((route) => route.id),
|
||||
).toEqual(ECONOMY_ROUTE_IDS);
|
||||
expect(
|
||||
registry.domains
|
||||
.find((domain) => domain.id === "system")
|
||||
|
||||
@@ -41,6 +41,28 @@ const systemRouteMessageKeys = [
|
||||
"pages.housekeeping.routes.system.operations.maintenance",
|
||||
] as const;
|
||||
|
||||
const economyRouteMessageKeys = [
|
||||
"pages.housekeeping.routes.economy.catalog.overview",
|
||||
"pages.housekeeping.routes.economy.catalog.detail",
|
||||
"pages.housekeeping.routes.economy.catalog.builder-club-detail",
|
||||
"pages.housekeeping.routes.economy.catalog.maintenance",
|
||||
"pages.housekeeping.routes.economy.items.overview",
|
||||
"pages.housekeeping.routes.economy.items.detail",
|
||||
"pages.housekeeping.routes.economy.commerce.shop",
|
||||
"pages.housekeeping.routes.economy.commerce.shop-create",
|
||||
"pages.housekeeping.routes.economy.commerce.shop-detail",
|
||||
"pages.housekeeping.routes.economy.commerce.marketplace",
|
||||
"pages.housekeeping.routes.economy.commerce.vouchers",
|
||||
"pages.housekeeping.routes.economy.commerce.subscriptions",
|
||||
"pages.housekeeping.routes.economy.history.transactions",
|
||||
"pages.housekeeping.routes.economy.value.rare-values",
|
||||
"pages.housekeeping.routes.economy.rewards.badges",
|
||||
"pages.housekeeping.routes.economy.rewards.achievements",
|
||||
"pages.housekeeping.routes.economy.rewards.sounds",
|
||||
"pages.housekeeping.routes.economy.rewards.calendar",
|
||||
"pages.housekeeping.routes.economy.rewards.calendar-detail",
|
||||
] as const;
|
||||
|
||||
const expectedDomainMessages = [
|
||||
{
|
||||
id: "operations",
|
||||
@@ -108,6 +130,12 @@ describe("housekeeping localization contract", () => {
|
||||
expect((message as string).trim(), key).not.toBe("");
|
||||
}
|
||||
|
||||
for (const key of economyRouteMessageKeys) {
|
||||
const message = resolveMessage(messages, key);
|
||||
expect(message, key).toEqual(expect.any(String));
|
||||
expect((message as string).trim(), key).not.toBe("");
|
||||
}
|
||||
|
||||
for (const [index, expected] of expectedDomainMessages.entries()) {
|
||||
const manifest = HOUSEKEEPING_MANIFESTS[index];
|
||||
|
||||
|
||||
@@ -92,6 +92,28 @@ const routeMocks = vi.hoisted(() => {
|
||||
"routes.system.operations.command-center":
|
||||
"HK::system-operations-command-center",
|
||||
"routes.system.operations.maintenance": "HK::system-operations-maintenance",
|
||||
"routes.economy.catalog.overview": "HK::economy-catalog-overview",
|
||||
"routes.economy.catalog.detail": "HK::economy-catalog-detail",
|
||||
"routes.economy.catalog.builder-club-detail":
|
||||
"HK::economy-catalog-builder-club-detail",
|
||||
"routes.economy.catalog.maintenance": "HK::economy-catalog-maintenance",
|
||||
"routes.economy.items.overview": "HK::economy-items-overview",
|
||||
"routes.economy.items.detail": "HK::economy-items-detail",
|
||||
"routes.economy.commerce.shop": "HK::economy-commerce-shop",
|
||||
"routes.economy.commerce.shop-create": "HK::economy-commerce-shop-create",
|
||||
"routes.economy.commerce.shop-detail": "HK::economy-commerce-shop-detail",
|
||||
"routes.economy.commerce.marketplace": "HK::economy-commerce-marketplace",
|
||||
"routes.economy.commerce.vouchers": "HK::economy-commerce-vouchers",
|
||||
"routes.economy.commerce.subscriptions":
|
||||
"HK::economy-commerce-subscriptions",
|
||||
"routes.economy.history.transactions": "HK::economy-history-transactions",
|
||||
"routes.economy.value.rare-values": "HK::economy-value-rare-values",
|
||||
"routes.economy.rewards.badges": "HK::economy-rewards-badges",
|
||||
"routes.economy.rewards.achievements": "HK::economy-rewards-achievements",
|
||||
"routes.economy.rewards.sounds": "HK::economy-rewards-sounds",
|
||||
"routes.economy.rewards.calendar": "HK::economy-rewards-calendar",
|
||||
"routes.economy.rewards.calendar-detail":
|
||||
"HK::economy-rewards-calendar-detail",
|
||||
"states.empty.title": "Localized empty title",
|
||||
"states.empty.description": "Localized empty description",
|
||||
};
|
||||
|
||||
@@ -4,6 +4,10 @@ import { CONTENT_INBOX_SOURCES } from "../domains/content/inbox";
|
||||
import { CONTENT_ROUTES } from "../domains/content/routes";
|
||||
import { CONTENT_SEARCH_PROVIDERS } from "../domains/content/search";
|
||||
import { CONTENT_WIDGETS } from "../domains/content/widgets";
|
||||
import { ECONOMY_INBOX_SOURCES } from "../domains/economy/inbox";
|
||||
import { ECONOMY_ROUTES } from "../domains/economy/routes";
|
||||
import { ECONOMY_SEARCH_PROVIDERS } from "../domains/economy/search";
|
||||
import { ECONOMY_WIDGETS } from "../domains/economy/widgets";
|
||||
import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
|
||||
import { PEOPLE_ROUTES } from "../domains/people/routes";
|
||||
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
||||
@@ -367,30 +371,38 @@ describe("housekeeping registry", () => {
|
||||
? PEOPLE_ROUTES
|
||||
: expected.id === "content"
|
||||
? CONTENT_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
);
|
||||
expect(actual.searchProviders).toEqual(
|
||||
expected.id === "people"
|
||||
? PEOPLE_SEARCH_PROVIDERS
|
||||
: expected.id === "content"
|
||||
? CONTENT_SEARCH_PROVIDERS
|
||||
: [],
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_SEARCH_PROVIDERS
|
||||
: [],
|
||||
);
|
||||
expect(actual.inboxSources).toEqual(
|
||||
expected.id === "people"
|
||||
? PEOPLE_INBOX_SOURCES
|
||||
: expected.id === "content"
|
||||
? CONTENT_INBOX_SOURCES
|
||||
: [],
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_INBOX_SOURCES
|
||||
: [],
|
||||
);
|
||||
expect(actual.widgets).toEqual(
|
||||
expected.id === "people"
|
||||
? PEOPLE_WIDGETS
|
||||
: expected.id === "content"
|
||||
? CONTENT_WIDGETS
|
||||
: [],
|
||||
: expected.id === "economy"
|
||||
? ECONOMY_WIDGETS
|
||||
: [],
|
||||
);
|
||||
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONTENT_ROUTE_IDS } from "./domains/content/routes";
|
||||
import { ECONOMY_ROUTE_IDS } from "./domains/economy/routes";
|
||||
import { PEOPLE_ROUTE_IDS } from "./domains/people/routes";
|
||||
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
|
||||
import { createHousekeepingRegistry } from "./foundation/registry";
|
||||
@@ -21,6 +22,7 @@ describe("housekeeping route handlers", () => {
|
||||
expect(handlerIds).toEqual([
|
||||
...PEOPLE_ROUTE_IDS,
|
||||
...CONTENT_ROUTE_IDS,
|
||||
...ECONOMY_ROUTE_IDS,
|
||||
...SYSTEM_ROUTE_IDS,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { CONTENT_ROUTE_HANDLERS } from "./domains/content/route-handlers";
|
||||
import { ECONOMY_ROUTE_HANDLERS } from "./domains/economy/route-handlers";
|
||||
import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
|
||||
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
|
||||
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
||||
@@ -22,5 +23,6 @@ export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||
Object.freeze([
|
||||
...PEOPLE_ROUTE_HANDLERS,
|
||||
...CONTENT_ROUTE_HANDLERS,
|
||||
...ECONOMY_ROUTE_HANDLERS,
|
||||
...SYSTEM_ROUTE_HANDLERS,
|
||||
]);
|
||||
@@ -3318,6 +3318,39 @@
|
||||
"teams": "Staff teams"
|
||||
}
|
||||
},
|
||||
"economy": {
|
||||
"catalog": {
|
||||
"overview": "Catalog",
|
||||
"detail": "Catalog page",
|
||||
"builder-club-detail": "Builder Club page",
|
||||
"maintenance": "Catalog maintenance"
|
||||
},
|
||||
"items": {
|
||||
"overview": "Items",
|
||||
"detail": "Item details"
|
||||
},
|
||||
"commerce": {
|
||||
"shop": "Shop",
|
||||
"shop-create": "Create shop package",
|
||||
"shop-detail": "Shop package details",
|
||||
"marketplace": "Marketplace",
|
||||
"vouchers": "Vouchers",
|
||||
"subscriptions": "Subscriptions"
|
||||
},
|
||||
"history": {
|
||||
"transactions": "Transactions"
|
||||
},
|
||||
"value": {
|
||||
"rare-values": "Rare values"
|
||||
},
|
||||
"rewards": {
|
||||
"badges": "Badges",
|
||||
"achievements": "Achievements",
|
||||
"sounds": "Sounds",
|
||||
"calendar": "Calendar rewards",
|
||||
"calendar-detail": "Calendar campaign"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"access": {
|
||||
"permissions": "Permissions",
|
||||
|
||||
@@ -3318,6 +3318,39 @@
|
||||
"teams": "Team staff"
|
||||
}
|
||||
},
|
||||
"economy": {
|
||||
"catalog": {
|
||||
"overview": "Catalogo",
|
||||
"detail": "Pagina catalogo",
|
||||
"builder-club-detail": "Pagina Builder Club",
|
||||
"maintenance": "Manutenzione catalogo"
|
||||
},
|
||||
"items": {
|
||||
"overview": "Oggetti",
|
||||
"detail": "Dettagli oggetto"
|
||||
},
|
||||
"commerce": {
|
||||
"shop": "Negozio",
|
||||
"shop-create": "Crea pacchetto negozio",
|
||||
"shop-detail": "Dettagli pacchetto negozio",
|
||||
"marketplace": "Mercatino",
|
||||
"vouchers": "Voucher",
|
||||
"subscriptions": "Abbonamenti"
|
||||
},
|
||||
"history": {
|
||||
"transactions": "Transazioni"
|
||||
},
|
||||
"value": {
|
||||
"rare-values": "Valori rari"
|
||||
},
|
||||
"rewards": {
|
||||
"badges": "Badge",
|
||||
"achievements": "Obiettivi",
|
||||
"sounds": "Suoni",
|
||||
"calendar": "Ricompense calendario",
|
||||
"calendar-detail": "Campagna calendario"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"access": {
|
||||
"permissions": "Permessi",
|
||||
|
||||
Reference in new issue
Block a user