refactor(catalog): unify commands and embed guarded catalog workspace
This commit is contained in:
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;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user