refactor(catalog): unify commands and embed guarded catalog workspace
CI / check (push) Successful in 1m16s
CI / deploy (push) Successful in 28s

This commit is contained in:
Simo committed 2026-09-06 19:40:52 +02:00
1 parent 16f35568f5
commit 193b6686a9
57 files changed
+3268 -728

No files matched your search

@@ -0,0 +1,206 @@
import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
import {
distinctOfferIds,
furniturePatchSchema,
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}`;
});
}
export async function updateOfferCommand(input: UpdateOfferInput) {
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]);
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);
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}`,
);
});
}
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) => {
await lockedOffers(tx, ids);
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 = offerPatchSchema.pick({
pageId: true,
itemIds: true,
catalogName: true,
orderNumber: true,
extradata: true,
});
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;
});
}