fix(catalog): make the Builder Club catalog read and write its own offers
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.
This commit is contained in:
openhands committed 2026-09-30 19:17:20 +02:00
1 parent cebcf440c5
commit e0efbef30d
12 files changed
+548 -218

No files matched your search

+15 -5
View File
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs";
import { asc, sql } from "drizzle-orm";
import { numericValue } from "@/features/catalog/domain/offer-input";
import { CatalogPages, db, queryRows } from "@/lib/db";
import { CatalogPages, CatalogPagesBc, db, queryRows } from "@/lib/db";
import { getFurnitureDataPath } from "@/lib/services/furni-data";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
@@ -132,19 +132,27 @@ export interface CatalogItemsData {
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 catalog_items
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 ?? ""),
@@ -164,11 +172,12 @@ export async function loadCatalogItemsData(
clubOnly: String(r.club_only ?? "0"),
}));
const pagesTable = catalog === "bc" ? CatalogPagesBc : CatalogPages;
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
db
.select({ id: CatalogPages.id, caption: CatalogPages.caption })
.from(CatalogPages)
.orderBy(asc(CatalogPages.caption)),
.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
`),
@@ -325,5 +334,6 @@ export async function loadCatalogItemsData(
interactionTypes,
allPages,
gamedataHotel,
catalog,
};
}