Files
EpicNext-Cms/src/features/catalog/server/offer-commands.ts
T
openhands 3e69b72513
CI / check (push) Failing after 26s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped
feat(catalog): prevent and repair Arcturus emulator data errors
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.
2026-09-10 11:29:28 +02:00

275 lines
9.7 KiB
TypeScript

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,
parseFurnitureIds,
} from "../domain/offer-input";
type OfferKind = "normal" | "bc";
function offerTable(kind: OfferKind) {
if (kind !== "normal" && kind !== "bc") throw Error("Invalid catalog type");
return kind === "bc" ? CatalogItemsBc : CatalogItems;
}
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
export type UpdateOfferInput = {
id: number;
catalogFields: Record<string, unknown>;
baseItem?: { id: number; fields: Record<string, unknown> };
};
type OfferRow = { id: number; pageId: number | string; itemIds: string };
async function lockedOffers(
tx: Transaction,
ids: number[],
kind: OfferKind = "normal",
): Promise<OfferRow[]> {
const [rows] = await tx.execute(
sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join(
[...ids].sort((a, b) => a - b),
sql`, `,
)}) ORDER BY id FOR UPDATE`,
);
const offers = rows as unknown as OfferRow[];
if (
offers.length !== ids.length ||
ids.some((id) => !offers.some((offer) => Number(offer.id) === id))
)
throw Error("Catalog item not found");
return offers;
}
async function lockPage(
tx: Transaction,
id: number,
kind: OfferKind = "normal",
) {
offerTable(kind);
const [rows] = await tx.execute(
sql`SELECT id FROM ${sql.identifier(kind === "bc" ? "catalog_pages_bc" : "catalog_pages")} WHERE id=${id} FOR UPDATE`,
);
if (
!(rows as unknown as { id: number }[]).some((row) => Number(row.id) === id)
)
throw Error("Catalog page not found");
}
async function lockFurniture(tx: Transaction, ids: number[]) {
const unique = [...new Set(ids)].sort((a, b) => a - b);
const [rows] = await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(unique, sql`, `)}) ORDER BY id FOR UPDATE`,
);
const found = new Set(
(rows as unknown as { id: number }[]).map((row) => Number(row.id)),
);
if (unique.some((id) => !found.has(id)))
throw Error("Furniture reference not found");
}
function assignments(
table: typeof CatalogItems | typeof CatalogItemsBc | typeof ItemsBase,
fields: Record<string, unknown>,
): SQL[] {
const columns = getTableColumns(table);
return Object.entries(fields)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => {
const column = columns[key as keyof typeof columns];
// SQL string parameters work with both deployed page_id column types.
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,
) {
offerIdSchema.parse(input.id);
const fields = offerPatchSchema.parse(input.catalogFields);
const baseFields = input.baseItem
? furniturePatchSchema.parse(input.baseItem.fields)
: {};
if (input.baseItem) offerIdSchema.parse(input.baseItem.id);
if (
!Object.values(fields).some((value) => value !== undefined) &&
!Object.values(baseFields).some((value) => value !== undefined)
)
throw Error("No valid fields to update");
return db.transaction(async (tx) => {
// Page locks precede offer locks, matching category deletion's lock order.
if (fields.pageId !== undefined) await lockPage(tx, fields.pageId);
const [offer] = await lockedOffers(tx, [input.id]);
const before = userId
? await historySnapshot(tx, "prices", input.id)
: null;
if (input.baseItem) {
const currentIds = String(offer.itemIds).split(";").map(Number);
const nextIds =
fields.itemIds === undefined
? currentIds
: parseFurnitureIds(fields.itemIds);
if (
!currentIds.includes(input.baseItem.id) ||
!nextIds.includes(input.baseItem.id)
)
throw Error("Shared furniture must belong to the catalog offer");
}
const references =
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(
sql`UPDATE ${CatalogItems} SET ${sql.join(offerAssignments, sql`, `)} WHERE id=${input.id}`,
);
const baseAssignments = assignments(ItemsBase, baseFields);
if (input.baseItem && baseAssignments.length)
await tx.execute(
sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${input.baseItem.id}`,
);
if (before && userId)
await recordHistory(tx, "prices", input.id, userId, before);
});
}
export async function reorderOffersCommand(
orders: Array<{ id: number; orderNumber: number }>,
) {
const ids = distinctOfferIds(orders.map((row) => row.id));
for (const row of orders) offerInteger.parse(row.orderNumber);
if (!ids.length) return;
return db.transaction(async (tx) => {
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}`,
);
});
}
export async function moveOffersCommand(ids: number[], targetPageId: number) {
distinctOfferIds(ids);
offerIdSchema.parse(targetPageId);
if (!ids.length) return;
return db.transaction(async (tx) => {
await lockPage(tx, targetPageId);
await lockedOffers(tx, ids);
await tx.execute(
sql`UPDATE ${CatalogItems} SET page_id=${String(targetPageId)} WHERE id IN (${sql.join(ids, sql`, `)})`,
);
});
}
/** Call the allocator before entering this command; only the insert uses its transaction. */
export async function createOfferCommand<T>(
kind: OfferKind,
references: { pageId: number; itemIds: string },
insert: (tx: Transaction) => Promise<T>,
): Promise<T> {
offerTable(kind);
offerIdSchema.parse(references.pageId);
const ids = parseFurnitureIds(references.itemIds);
return db.transaction(async (tx) => {
await lockPage(tx, references.pageId, kind);
await lockFurniture(tx, ids);
return insert(tx);
});
}
// BC deliberately has no currencies, amount, LTD values or shared-furniture mutation.
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,
input: Record<string, unknown>,
) {
offerIdSchema.parse(id);
const fields = bcOfferPatchSchema.parse(input);
const updates = assignments(CatalogItemsBc, fields);
if (!updates.length) throw Error("No valid fields to update");
return db.transaction(async (tx) => {
if (fields.pageId !== undefined) await lockPage(tx, fields.pageId, "bc");
await lockedOffers(tx, [id], "bc");
if (fields.itemIds !== undefined)
await lockFurniture(tx, parseFurnitureIds(fields.itemIds));
await tx.execute(
sql`UPDATE ${CatalogItemsBc} SET ${sql.join(updates, sql`, `)} WHERE id=${id}`,
);
});
}
export async function createBcOfferCommand(
input: Record<string, unknown>,
): Promise<number> {
const fields = bcOfferPatchSchema.required().parse(input);
return createOfferCommand("bc", fields, async (tx) => {
const [result] = await tx.execute(
sql`INSERT INTO ${CatalogItemsBc} SET ${sql.join(assignments(CatalogItemsBc, fields), sql`, `)}`,
);
const id = Number((result as unknown as { insertId: number }).insertId);
offerIdSchema.parse(id);
return id;
});
}