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[0]>[0]; export type UpdateOfferInput = { id: number; catalogFields: Record; baseItem?: { id: number; fields: Record }; }; type OfferRow = { id: number; pageId: number | string; itemIds: string }; async function lockedOffers( tx: Transaction, ids: number[], kind: OfferKind = "normal", ): Promise { 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, ): 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( kind: OfferKind, references: { pageId: number; itemIds: string }, insert: (tx: Transaction) => Promise, ): Promise { 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, ) { 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, ): Promise { 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; }); }