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.
275 lines
9.7 KiB
TypeScript
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;
|
|
});
|
|
}
|