feat(catalog): prevent and repair Arcturus emulator data errors
CI / check (push) Failing after 26s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

Block invalid catalog_items writes at the API level (points currency
allowlist, non-negative prices, positive amount, limited stack >= sold
count, unique sibling order numbers) and auto-assign unique order numbers
on bulk create. Add a catalog-maintenance scan + transactional repair that
fixes pre-existing rows: resets unsupported points_type, clamps negative
costs, sets amount to 1, raises limited_stack, renumbers duplicate orders
and deletes offers with missing page/item references. Surface the issue
count and a fix button in the admin maintenance panel.

Also: add enabled/retired flag to clone sources, classify poster and
currency furniture in item-kind, and remove the obsolete update-Nitrov3.sh.
This commit is contained in:
openhands committed 2026-09-10 11:29:28 +02:00
1 parent ad65d80d41
commit 3e69b72513
26 files changed
+17651 -3844

No files matched your search

+66 -7
View File
@@ -1,10 +1,13 @@
import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { z } from "zod";
import { historySnapshot, recordHistory } from "@/features/history/server";
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
import { CatalogInputError } from "../domain/hierarchy";
import {
distinctOfferIds,
furniturePatchSchema,
itemIds,
offerIdSchema,
offerInteger,
offerPatchSchema,
@@ -80,6 +83,30 @@ function assignments(
return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`;
});
}
/** Emulator: "limited stack N is lower than the live slot count M". */
function assertLimitedStackOk(limitedStack: number, limitedSells: number) {
if (limitedStack < limitedSells)
throw new CatalogInputError(
`Limited stack (${limitedStack}) is lower than the number already sold (${limitedSells})`,
);
}
/** Emulator: "sibling order N is used more than once". */
async function assertOrderUnique(
tx: Transaction,
pageId: string | number,
orderNumber: number,
excludeId: number,
) {
const [dups] = await tx.execute(
sql`SELECT id FROM catalog_items WHERE page_id = ${String(pageId)} AND order_number = ${orderNumber} AND id != ${excludeId} LIMIT 1`,
);
if ((dups as unknown as unknown[]).length > 0)
throw new CatalogInputError(
`Order number ${orderNumber} is already used by another offer on this page`,
);
}
export async function updateOfferCommand(
input: UpdateOfferInput,
userId?: number,
@@ -118,6 +145,29 @@ export async function updateOfferCommand(
fields.itemIds === undefined ? [] : parseFurnitureIds(fields.itemIds);
if (input.baseItem) references.push(input.baseItem.id);
if (references.length) await lockFurniture(tx, references);
// ── Emulator constraints ──────────────────────────────────────
if (fields.orderNumber !== undefined || fields.pageId !== undefined) {
const targetPageId = fields.pageId ?? offer.pageId;
const targetOrder = fields.orderNumber ?? 0;
if (fields.orderNumber !== undefined)
await assertOrderUnique(tx, targetPageId, targetOrder, input.id);
}
if (
fields.limitedStack !== undefined ||
fields.limitedSells !== undefined
) {
const [current] = await tx.execute(
sql`SELECT limited_stack AS limitedStack, limited_sells AS limitedSells FROM catalog_items WHERE id=${input.id}`,
);
const row = (
current as unknown as { limitedStack: number; limitedSells: number }[]
)[0];
if (row) {
const stack = fields.limitedStack ?? Number(row.limitedStack);
const sells = fields.limitedSells ?? Number(row.limitedSells);
assertLimitedStackOk(stack, sells);
}
}
const offerAssignments = assignments(CatalogItems, fields);
if (offerAssignments.length)
await tx.execute(
@@ -139,7 +189,16 @@ export async function reorderOffersCommand(
for (const row of orders) offerInteger.parse(row.orderNumber);
if (!ids.length) return;
return db.transaction(async (tx) => {
await lockedOffers(tx, ids);
const offers = await lockedOffers(tx, ids);
// Validate uniqueness of the new order numbers within each page.
const pageId = offers[0]?.pageId;
if (pageId !== undefined) {
const nums = orders.map((r) => r.orderNumber);
if (new Set(nums).size !== nums.length)
throw new CatalogInputError(
"Different offers on the same page cannot share an order number",
);
}
for (const row of orders)
await tx.execute(
sql`UPDATE ${CatalogItems} SET order_number=${row.orderNumber} WHERE id=${row.id}`,
@@ -175,12 +234,12 @@ export async function createOfferCommand<T>(
});
}
// BC deliberately has no currencies, amount, LTD values or shared-furniture mutation.
const bcOfferPatchSchema = offerPatchSchema.pick({
pageId: true,
itemIds: true,
catalogName: true,
orderNumber: true,
extradata: true,
const bcOfferPatchSchema = z.object({
pageId: offerIdSchema.optional(),
itemIds: itemIds.optional(),
catalogName: z.string().max(100).optional(),
orderNumber: offerInteger.optional(),
extradata: z.string().max(500).optional(),
});
export async function updateBcOfferCommand(
id: number,