Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 28s
CI / tests-unit (push) Successful in 1m39s
CI / tests-integration (push) Successful in 1m42s
CI / tests-ui (push) Successful in 2m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m6s
The previous commit taught bulk editing and delete-with-restore about the BC catalog. Neither actually worked, and one of them was destructive. `catalog_items_bc` has six columns: id, item_ids, page_id, catalog_name, order_number, extradata. There is no price, points, currency, offer_id, limit or membership column on it. The bulk path read and wrote columns that do not exist, and the UPDATE was aimed at catalog_items while the SELECT came from catalog_items_bc — so a BC category move wrote into the normal catalog. Two tests now pin that pairing: reads and writes have to stay in the same table. Underneath it the BC table was never being read at all. The inline editor fetched `/api/admin/catalog/items?pageId=N` without the catalog, so opening a BC category showed the normal catalog's offers, and the route selected BC rows directly instead of going through the loader, skipping the furni enrichment the table needs to render anything but a bare caption. Both catalogs now take the same path, and the catalog is in the fetch callback's dependencies — without that, a switch keeps reading the previous catalog's rows through a stale closure. Because a BC offer has no price, the editor no longer offers one. The server refuses price, points and currency changes with a readable message instead of letting them reach the database as an unknown-column error, and a BC bulk edit is what it can actually be: a category move. BC deletions also went through a bare DELETE, which made them the one catalog mutation with no way back. They now keep their rows and hand back a restoreId like the normal ones. The catalog is recorded in the audit target rather than in the payload, so a restore can never put a BC row into the normal offers table.
340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
import { promises as fs } from "node:fs";
|
|
import { asc, sql } from "drizzle-orm";
|
|
import { numericValue } from "@/features/catalog/domain/offer-input";
|
|
import { CatalogPages, CatalogPagesBc, db, queryRows } from "@/lib/db";
|
|
import { getFurnitureDataPath } from "@/lib/services/furni-data";
|
|
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
|
|
|
// Cache FurnitureData.json in memory with TTL to avoid repeated disk I/O
|
|
let furniDataCache: {
|
|
roomitemtypes?: {
|
|
furnitype?: Array<{
|
|
id: number;
|
|
description?: string;
|
|
classname?: string;
|
|
revision?: number;
|
|
}>;
|
|
};
|
|
wallitemtypes?: {
|
|
furnitype?: Array<{
|
|
id: number;
|
|
description?: string;
|
|
classname?: string;
|
|
revision?: number;
|
|
}>;
|
|
};
|
|
} | null = null;
|
|
let furniDataCacheTime = 0;
|
|
const FURNI_CACHE_TTL = 30_000; // 30 seconds
|
|
|
|
async function getFurnitureData() {
|
|
if (furniDataCache && Date.now() - furniDataCacheTime < FURNI_CACHE_TTL)
|
|
return furniDataCache;
|
|
const furniDataPath = await getFurnitureDataPath();
|
|
try {
|
|
const raw = await fs.readFile(furniDataPath, "utf-8");
|
|
furniDataCache = JSON.parse(raw);
|
|
furniDataCacheTime = Date.now();
|
|
return furniDataCache;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Invalidate the FurnitureData cache after writes */
|
|
export function invalidateFurniDataCache() {
|
|
furniDataCache = null;
|
|
furniDataCacheTime = 0;
|
|
}
|
|
|
|
interface RawItem {
|
|
id: number;
|
|
itemIds: string;
|
|
pageId: number;
|
|
offerId: number;
|
|
songId: number;
|
|
orderNumber: number;
|
|
catalogName: string;
|
|
costCredits: number;
|
|
costPoints: number;
|
|
pointsType: number;
|
|
amount: number;
|
|
limitedSells: number;
|
|
limitedStack: number;
|
|
extradata: string;
|
|
haveOffer: string;
|
|
clubOnly: string;
|
|
}
|
|
|
|
interface BaseItem {
|
|
id: number;
|
|
spriteId: number;
|
|
publicName: string;
|
|
itemName: string;
|
|
type: string;
|
|
width: number;
|
|
length: number;
|
|
stackHeight: number;
|
|
allowStack: number;
|
|
allowSit: number;
|
|
allowLay: number;
|
|
allowWalk: number;
|
|
allowGift: number;
|
|
allowTrade: number;
|
|
allowRecycle: number;
|
|
allowMarketplaceSell: number;
|
|
allowInventoryStack: number;
|
|
interactionType: string;
|
|
interactionModesCount: number;
|
|
vendingIds: string;
|
|
customparams: string;
|
|
effectIdMale: number;
|
|
effectIdFemale: number;
|
|
clothingOnWalk: string;
|
|
}
|
|
|
|
export interface CatalogItemEnriched {
|
|
id: number;
|
|
catalogName: string;
|
|
itemIds: string;
|
|
costCredits: number;
|
|
costPoints: number;
|
|
pointsType: number;
|
|
amount: number;
|
|
limitedSells: number;
|
|
limitedStack: number;
|
|
orderNumber: number;
|
|
offerId: number;
|
|
songId: number;
|
|
haveOffer: string;
|
|
clubOnly: string;
|
|
extradata: string;
|
|
baseName: string;
|
|
baseItemName: string;
|
|
spriteId: number;
|
|
baseItem: BaseItem | null;
|
|
}
|
|
|
|
export interface CatalogItemsData {
|
|
items: CatalogItemEnriched[];
|
|
baseItems: {
|
|
id: number;
|
|
publicName: string;
|
|
itemName: string;
|
|
spriteId: number;
|
|
type: string;
|
|
}[];
|
|
catalogNameMap: Record<number, string>;
|
|
furniDataIdList: number[];
|
|
furniDescriptionMap: Record<number, string>;
|
|
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
|
interactionTypes: string[];
|
|
allPages: { id: number; caption: string }[];
|
|
/** CMS `habbo_gamedata_hotel` — locale used for Suggest names */
|
|
gamedataHotel: string;
|
|
/** Which catalog these offers came from; BC rows carry no prices. */
|
|
catalog: "normal" | "bc";
|
|
}
|
|
|
|
export async function loadCatalogItemsData(
|
|
pageId: number,
|
|
catalog: "normal" | "bc" = "normal",
|
|
): Promise<CatalogItemsData> {
|
|
// Load items via raw query to work around pageId Int vs VARCHAR mismatch
|
|
const pageIdStr = String(pageId);
|
|
// CAST: live Habbo DBs often store page_id as VARCHAR while schema maps Int.
|
|
const offers = catalog === "bc" ? "catalog_items_bc" : "catalog_items";
|
|
const rawItems = await queryRows<Record<string, unknown>>(sql`
|
|
SELECT * FROM ${sql.raw(offers)}
|
|
WHERE CAST(page_id AS CHAR) = ${pageIdStr}
|
|
ORDER BY order_number ASC, id ASC
|
|
`);
|
|
// The BC table carries only a reference to the furniture: there are no price,
|
|
// limit or membership columns to read. Every other field is filled from the
|
|
// column default so the table renders, and stays read-only for those fields
|
|
// rather than pretending a price exists.
|
|
const items: RawItem[] = rawItems.map((r) => ({
|
|
id: Number(r.id),
|
|
itemIds: String(r.item_ids ?? ""),
|
|
pageId: numericValue(r.page_id, 0),
|
|
offerId: numericValue(r.offer_id, -1),
|
|
songId: numericValue(r.song_id, 0),
|
|
orderNumber: numericValue(r.order_number, 99),
|
|
catalogName: String(r.catalog_name ?? ""),
|
|
costCredits: numericValue(r.cost_credits, 0),
|
|
costPoints: numericValue(r.cost_points, 0),
|
|
pointsType: numericValue(r.points_type, 0),
|
|
amount: numericValue(r.amount, 1),
|
|
limitedSells: numericValue(r.limited_sells, 0),
|
|
limitedStack: numericValue(r.limited_stack, 0),
|
|
extradata: String(r.extradata ?? ""),
|
|
haveOffer: String(r.have_offer ?? "1"),
|
|
clubOnly: String(r.club_only ?? "0"),
|
|
}));
|
|
|
|
const pagesTable = catalog === "bc" ? CatalogPagesBc : CatalogPages;
|
|
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
|
|
db
|
|
.select({ id: pagesTable.id, caption: pagesTable.caption })
|
|
.from(pagesTable)
|
|
.orderBy(asc(pagesTable.caption)),
|
|
queryRows<{ interaction_type: string }>(sql`
|
|
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
|
|
`),
|
|
getHabboGamedataHotel(),
|
|
]);
|
|
|
|
const interactionTypes = interactionTypesRaw.map((r) =>
|
|
String(r.interaction_type),
|
|
);
|
|
|
|
// Resolve item names from items_base for enrichment + translate
|
|
const itemIdStrings = items.map((i) => i.itemIds).filter(Boolean);
|
|
const baseItemIds = [
|
|
...new Set(
|
|
itemIdStrings.flatMap((s) =>
|
|
s
|
|
.split(";")
|
|
.map(Number)
|
|
.filter((n) => n > 0),
|
|
),
|
|
),
|
|
];
|
|
|
|
// Raw query to work around ENUM('0','1') columns returning numeric values.
|
|
// baseItemIds is built from numeric splits + filter(n > 0); joined via sql.join.
|
|
const baseItemRows =
|
|
baseItemIds.length > 0
|
|
? await queryRows<Record<string, unknown>>(sql`
|
|
SELECT id, sprite_id, public_name, item_name, type, width, length,
|
|
stack_height, allow_stack, allow_sit, allow_lay, allow_walk,
|
|
allow_gift, allow_trade, allow_recycle, allow_marketplace_sell,
|
|
allow_inventory_stack, interaction_type, interaction_modes_count,
|
|
vending_ids, customparams, effect_id_male, effect_id_female,
|
|
clothing_on_walk
|
|
FROM items_base WHERE id IN (${sql.join(baseItemIds, sql`, `)})
|
|
`)
|
|
: [];
|
|
|
|
const baseItems: BaseItem[] = baseItemRows.map((r) => ({
|
|
id: Number(r.id),
|
|
spriteId: Number(r.sprite_id),
|
|
publicName: String(r.public_name ?? ""),
|
|
itemName: String(r.item_name ?? ""),
|
|
type: String(r.type ?? "s"),
|
|
width: numericValue(r.width, 1),
|
|
length: numericValue(r.length, 1),
|
|
stackHeight: numericValue(r.stack_height, 0),
|
|
allowStack: Number(r.allow_stack ?? 0),
|
|
allowSit: Number(r.allow_sit ?? 0),
|
|
allowLay: Number(r.allow_lay ?? 0),
|
|
allowWalk: Number(r.allow_walk ?? 0),
|
|
allowGift: Number(r.allow_gift ?? 1),
|
|
allowTrade: Number(r.allow_trade ?? 1),
|
|
allowRecycle: Number(r.allow_recycle ?? 0),
|
|
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
|
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
|
interactionType: String(r.interaction_type ?? "default"),
|
|
interactionModesCount: numericValue(r.interaction_modes_count, 2),
|
|
vendingIds: String(r.vending_ids ?? "0"),
|
|
customparams: String(r.customparams ?? ""),
|
|
effectIdMale: numericValue(r.effect_id_male, 0),
|
|
effectIdFemale: numericValue(r.effect_id_female, 0),
|
|
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
|
}));
|
|
|
|
const baseItemMap = Object.fromEntries(baseItems.map((b) => [b.id, b]));
|
|
|
|
// Build mapping: baseItemId -> catalogName
|
|
const catalogNameMap: Record<number, string> = {};
|
|
for (const item of items) {
|
|
const firstBaseId = item.itemIds
|
|
.split(";")
|
|
.map(Number)
|
|
.find((n) => n > 0);
|
|
if (firstBaseId && !catalogNameMap[firstBaseId]) {
|
|
catalogNameMap[firstBaseId] = item.catalogName;
|
|
}
|
|
}
|
|
|
|
const catalogItems: CatalogItemEnriched[] = items.map((item) => {
|
|
const firstBaseId = item.itemIds
|
|
.split(";")
|
|
.map(Number)
|
|
.find((n) => n > 0);
|
|
const base = firstBaseId ? baseItemMap[firstBaseId] : undefined;
|
|
return {
|
|
id: item.id,
|
|
catalogName: item.catalogName,
|
|
itemIds: item.itemIds,
|
|
costCredits: item.costCredits,
|
|
costPoints: item.costPoints,
|
|
pointsType: item.pointsType,
|
|
amount: item.amount,
|
|
limitedSells: item.limitedSells,
|
|
limitedStack: item.limitedStack,
|
|
orderNumber: item.orderNumber,
|
|
offerId: item.offerId,
|
|
songId: item.songId,
|
|
haveOffer: item.haveOffer,
|
|
clubOnly: item.clubOnly,
|
|
extradata: item.extradata,
|
|
baseName: base?.publicName || base?.itemName || "",
|
|
baseItemName: base?.itemName || "",
|
|
spriteId: base?.spriteId ?? 0,
|
|
baseItem: base ? { ...base } : null,
|
|
};
|
|
});
|
|
|
|
// Build spriteId -> items_base.id mapping for FurnitureData.json lookup
|
|
const spriteToBaseId = new Map<number, number>();
|
|
for (const b of baseItems) {
|
|
spriteToBaseId.set(b.spriteId, b.id);
|
|
}
|
|
|
|
// Read FurnitureData.json (cached in memory)
|
|
const foundBaseIds = new Set<number>();
|
|
const furniDescriptionMap: Record<number, string> = {};
|
|
const furniRevisionMap: Record<
|
|
number,
|
|
{ classname: string; revision: number }
|
|
> = {};
|
|
const furniData = await getFurnitureData();
|
|
if (furniData) {
|
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
|
if (furniData[section]?.furnitype) {
|
|
for (const item of furniData[section].furnitype) {
|
|
const baseId = spriteToBaseId.get(item.id);
|
|
if (baseId !== undefined) {
|
|
foundBaseIds.add(baseId);
|
|
furniDescriptionMap[baseId] = item.description ?? "";
|
|
furniRevisionMap[baseId] = {
|
|
classname: item.classname ?? "",
|
|
revision: item.revision ?? 0,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
items: catalogItems,
|
|
baseItems: baseItems.map((b) => ({
|
|
id: b.id,
|
|
publicName: b.publicName,
|
|
itemName: b.itemName,
|
|
spriteId: b.spriteId,
|
|
type: b.type,
|
|
})),
|
|
catalogNameMap,
|
|
furniDataIdList: [...foundBaseIds],
|
|
furniDescriptionMap,
|
|
furniRevisionMap,
|
|
interactionTypes,
|
|
allPages,
|
|
gamedataHotel,
|
|
catalog,
|
|
};
|
|
}
|