feat(housekeeping): deliver economy vertical
CI / check (pull_request) Successful in 40s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

This commit is contained in:
Simo committed 2026-08-30 13:47:06 +02:00
1 parent 1ec05cda39
commit 9c4b973faf
62 files changed
+6306 -1285

No files matched your search

+7 -63
View File
@@ -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)}`);
}
+6 -38
View File
@@ -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");
}
+4 -31
View File
@@ -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");
}
File diff suppressed because it is too large. Load diff
+18 -135
View File
File diff suppressed because it is too large. Load diff
+14 -47
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+23 -126
View File
File diff suppressed because it is too large. Load diff
+32 -15
View File
@@ -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);
}
File diff suppressed because it is too large. Load diff
+12 -16
View File
@@ -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" />
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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),
};
}
File diff suppressed because it is too large. Load diff
@@ -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}
/>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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);
}
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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) }),
),
);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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,
},
]
: [],
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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" },
});
});
});
File diff suppressed because it is too large. Load diff
@@ -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,
File diff suppressed because it is too large. Load diff
@@ -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,
]);
+33
View File
@@ -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",
+33
View File
@@ -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",