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

+62 -388
View File
@@ -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,
},
};
}