refactor(catalog): unify commands and embed guarded catalog workspace
CI / check (push) Successful in 1m16s
CI / deploy (push) Successful in 28s

This commit is contained in:
Simo committed 2026-09-06 19:40:52 +02:00
1 parent 16f35568f5
commit 193b6686a9
57 files changed
+3268 -728

No files matched your search

+34 -112
View File
@@ -2,72 +2,23 @@
import { eq, inArray, like, or, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { offerPatchSchema } from "@/features/catalog/domain/offer-input";
import {
createOfferCommand,
moveOffersCommand,
reorderOffersCommand,
updateOfferCommand,
} from "@/features/catalog/server/offer-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
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 { withCatalogExport } from "@/lib/services/catalog-git-queue";
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;
@@ -87,8 +38,10 @@ async function insertCatalogItemRow(data: {
clubOnly: string;
}): Promise<number> {
const pageIdStr = String(data.pageId);
return allocateCatalogItemId(async (nextId) => {
await db.execute(sql`
offerPatchSchema.required().parse(data);
return allocateCatalogItemId((nextId) =>
createOfferCommand("normal", data, async (tx) => {
await tx.execute(sql`
INSERT INTO catalog_items (
id, page_id, item_ids, catalog_name,
cost_credits, cost_points, points_type, amount,
@@ -102,8 +55,9 @@ async function insertCatalogItemRow(data: {
${data.haveOffer}, ${data.clubOnly}
)
`);
return nextId;
});
return nextId;
}),
);
}
export async function createCatalogItem(data: {
@@ -141,7 +95,7 @@ export async function createCatalogItem(data: {
}
}
const id = await insertCatalogItemRow({ ...data, catalogName });
await rcon.updateCatalog();
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_item_create",
@@ -221,7 +175,7 @@ export async function bulkCreateCatalogItems({
}
if (created > 0) {
await rcon.updateCatalog();
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_bulk_create",
@@ -240,7 +194,7 @@ export async function deleteCatalogItems({ ids }: { ids: number[] }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
await rcon.updateCatalog();
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_delete",
@@ -264,16 +218,8 @@ export async function moveCatalogItems({
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();
await moveOffersCommand(ids, targetPageId);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
@@ -286,13 +232,8 @@ export async function reorderCatalogItems({
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
for (const { id, orderNumber } of orders) {
await db
.update(CatalogItems)
.set({ orderNumber })
.where(eq(CatalogItems.id, id));
}
await rcon.updateCatalog();
await reorderOffersCommand(orders);
await sendCatalogUpdate();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
@@ -309,37 +250,18 @@ export async function updateCatalogItem({
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
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" };
try {
await updateOfferCommand({ id, catalogFields, baseItem });
} catch (error) {
return {
ok: false as const,
error:
error instanceof Error
? error.message
: "Unable to update catalog item",
};
}
// 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 sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_item_update",
@@ -453,7 +375,7 @@ export async function translateCatalogItems(input: {
invalidateFurniDataCache();
}
await rcon.updateCatalog();
await sendCatalogUpdate();
await logAudit({
userId: staff.id,
action: "items_base_translate",