feat(catalog): add reviewed bulk edits and complete category duplication
CI / check (push) Successful in 1m14s
CI / deploy (push) Successful in 1m11s

This commit is contained in:
Simo committed 2026-09-06 20:25:19 +02:00
1 parent 0bedc04692
commit 55a47949f1
26 files changed
+2504 -85

No files matched your search

@@ -0,0 +1,89 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
permission: vi.fn(),
preview: vi.fn(),
destinations: vi.fn(),
apply: vi.fn(),
export: vi.fn(),
send: vi.fn(),
audit: vi.fn(),
refresh: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.permission }));
vi.mock("@/lib/permissions", () => ({
PERMS: { CATALOG_VIEW: "view", CATALOG_EDIT: "edit" },
}));
vi.mock("@/features/catalog/server/bulk-offers", () => ({
previewBulkOffersCommand: state.preview,
listBulkOfferDestinationsCommand: state.destinations,
applyBulkOffersCommand: state.apply,
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: state.export,
}));
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: state.send,
}));
vi.mock("@/lib/services/audit", () => ({ logAudit: state.audit }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("next/cache", () => ({ revalidatePath: state.refresh }));
import {
applyBulkOffers,
getBulkOfferDestinations,
previewBulkOffers,
} from "@/actions/catalog-bulk";
const input = { ids: [1], changes: { pageId: 2 } };
beforeEach(() => {
vi.clearAllMocks();
state.permission.mockResolvedValue({ id: 1 });
state.export.mockImplementation((fn) => fn());
state.apply.mockResolvedValue({ changedCount: 1 });
state.preview.mockResolvedValue({ fingerprint: "x" });
state.audit.mockResolvedValue(undefined);
});
it("gates read preview without scheduling any mutation", async () => {
await previewBulkOffers(input);
expect(state.permission).toHaveBeenCalledWith("view");
expect(state.export).not.toHaveBeenCalled();
expect(state.apply).not.toHaveBeenCalled();
});
it("gates edits before database and sends one update after commit", async () => {
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
expect(state.permission).toHaveBeenCalledWith("edit");
expect(state.export).toHaveBeenCalledTimes(1);
expect(state.send).toHaveBeenCalledTimes(1);
expect(state.apply.mock.invocationCallOrder[0]).toBeLessThan(
state.send.mock.invocationCallOrder[0],
);
});
it("stops denied permission before export or data access", async () => {
state.permission.mockRejectedValueOnce(Error("Denied"));
await expect(applyBulkOffers(input, "x")).rejects.toThrow("Denied");
expect(state.export).not.toHaveBeenCalled();
});
it("does not notify hotel when a transaction fails", async () => {
state.apply.mockRejectedValueOnce(Error("failed"));
expect((await applyBulkOffers(input, "x")).ok).toBe(false);
expect(state.send).not.toHaveBeenCalled();
expect(state.audit).not.toHaveBeenCalled();
});
it("does not report a committed edit as failed on audit failure", async () => {
state.audit.mockRejectedValueOnce(Error("log failed"));
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
expect(state.send).toHaveBeenCalledTimes(1);
});
it("loads destination choices with view permission only", async () => {
state.destinations.mockResolvedValueOnce({
pages: [{ id: 9, caption: "Target" }],
});
expect(await getBulkOfferDestinations()).toEqual({
ok: true,
data: { pages: [{ id: 9, caption: "Target" }] },
});
expect(state.permission).toHaveBeenCalledWith("view");
expect(state.export).not.toHaveBeenCalled();
});
@@ -0,0 +1,194 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
rows: [
{
id: 1,
catalogName: "Chair",
pageId: 4,
costCredits: 3,
costPoints: 10,
pointsType: 5,
},
{
id: 2,
catalogName: "Table",
pageId: 4,
costCredits: 8,
costPoints: 0,
pointsType: 0,
},
],
pages: [
{ id: 4, caption: "Source" },
{ id: 9, caption: "Target" },
],
queries: [] as string[],
writes: 0,
failAt: 0,
commits: 0,
rollbacks: 0,
}));
vi.mock("@/lib/db", () => ({
db: {
execute: async () => [state.pages, []],
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const before = state.writes;
try {
const result = await fn({
execute: async (query: SQL) => {
const text = new MySqlDialect().sqlToQuery(query).sql;
state.queries.push(text);
if (text.startsWith("SELECT"))
return [
text.includes("catalog_pages") ? state.pages : state.rows,
[],
];
state.writes++;
if (state.writes === state.failAt) throw Error("write failed");
return [{ affectedRows: 1 }, []];
},
});
state.commits++;
return result;
} catch (error) {
state.writes = before;
state.rollbacks++;
throw error;
}
},
},
}));
import {
applyBulkOffersCommand,
listBulkOfferDestinationsCommand,
previewBulkOffersCommand,
} from "./bulk-offers";
const input = {
ids: [1, 2],
changes: { costCredits: { mode: "add" as const, value: 1 }, pageId: 9 },
};
beforeEach(() => {
state.rows = [
{
id: 1,
catalogName: "Chair",
pageId: 4,
costCredits: 3,
costPoints: 10,
pointsType: 5,
},
{
id: 2,
catalogName: "Table",
pageId: 4,
costCredits: 8,
costPoints: 0,
pointsType: 0,
},
];
state.pages = [
{ id: 4, caption: "Source" },
{ id: 9, caption: "Target" },
];
state.queries = [];
state.writes = 0;
state.failAt = 0;
state.commits = 0;
state.rollbacks = 0;
});
it("previews all changes without a write", async () => {
const p = await previewBulkOffersCommand(input);
expect(p.changedCount).toBe(2);
expect(p.rows[0].after).toEqual({
pageId: 9,
costCredits: 4,
costPoints: 10,
pointsType: 5,
});
expect(state.writes).toBe(0);
});
it("locks pages before offers and commits the entire batch", async () => {
const p = await previewBulkOffersCommand(input);
state.queries = [];
await applyBulkOffersCommand(input, p.fingerprint);
expect(state.writes).toBe(2);
const locks = state.queries.filter((q) => q.includes("FOR UPDATE"));
expect(locks[0]).toContain("catalog_pages");
expect(locks[1]).toContain("catalog_items");
expect(
state.queries
.filter((q) => q.startsWith("UPDATE"))
.every((q) => !q.includes("cost_points")),
).toBe(true);
});
it("refuses stale prices and changed intent before writing", async () => {
const p = await previewBulkOffersCommand(input);
state.rows[0].costCredits = 100;
await expect(applyBulkOffersCommand(input, p.fingerprint)).rejects.toThrow(
/changed/i,
);
expect(state.writes).toBe(0);
state.rows[0].costCredits = 3;
await expect(
applyBulkOffersCommand({ ...input, changes: { pageId: 9 } }, p.fingerprint),
).rejects.toThrow(/changed/i);
});
it("rolls back earlier updates if a later update fails", async () => {
const p = await previewBulkOffersCommand(input);
state.failAt = 2;
await expect(applyBulkOffersCommand(input, p.fingerprint)).rejects.toThrow(
"write failed",
);
expect(state.writes).toBe(0);
expect(state.rollbacks).toBe(1);
});
it("rejects missing offers and destinations", async () => {
state.rows.pop();
await expect(previewBulkOffersCommand(input)).rejects.toThrow(/not found/i);
state.rows.push({
id: 2,
catalogName: "Table",
pageId: 4,
costCredits: 8,
costPoints: 0,
pointsType: 0,
});
state.pages.pop();
await expect(previewBulkOffersCommand(input)).rejects.toThrow(/not found/i);
});
it("rejects invalid resulting price before any update", async () => {
await expect(
previewBulkOffersCommand({
ids: [1, 2],
changes: { costCredits: { mode: "add", value: -5 } },
}),
).rejects.toThrow(/negative/i);
expect(state.writes).toBe(0);
});
it("rejects renamed destination after preview", async () => {
const p = await previewBulkOffersCommand(input);
state.pages[1].caption = "Different";
await expect(applyBulkOffersCommand(input, p.fingerprint)).rejects.toThrow(
/changed/i,
);
});
it("does not silently truncate destination choices", async () => {
state.pages = Array.from({ length: 5001 }, (_, i) => ({
id: i + 1,
caption: `Category ${i + 1}`,
}));
await expect(listBulkOfferDestinationsCommand()).rejects.toThrow(/5000/);
});
it("does not update offers whose chosen values already match", async () => {
const request = { ids: [1, 2], changes: { pageId: 4 } };
const p = await previewBulkOffersCommand(request);
expect(p.changedCount).toBe(0);
await applyBulkOffersCommand(request, p.fingerprint);
expect(state.writes).toBe(0);
});
+164
View File
@@ -0,0 +1,164 @@
import "server-only";
import { createHash } from "node:crypto";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
type BulkOfferInput,
type BulkOfferPreview,
type BulkOfferValues,
bulkOfferInputSchema,
projectBulkOffer,
} from "../domain/bulk-offers";
import {
CatalogConflict,
CatalogInputError,
CatalogNotFound,
} from "../domain/hierarchy";
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
type OfferRow = BulkOfferValues & { id: number; catalogName: string };
async function readOffers(
tx: Tx,
ids: number[],
lock = false,
): Promise<OfferRow[]> {
const [rows] = await tx.execute(
sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM catalog_items WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
);
const offers = (rows as unknown as OfferRow[]).map((row) => ({
...row,
id: Number(row.id),
pageId: Number(row.pageId),
costCredits: Number(row.costCredits),
costPoints: Number(row.costPoints),
pointsType: Number(row.pointsType),
}));
if (
offers.length !== ids.length ||
ids.some((id) => !offers.some((row) => row.id === id))
)
throw new CatalogNotFound(
"Selected catalog offer not found. Reload the selection.",
);
return offers.sort((a, b) => a.id - b.id);
}
async function readPages(
tx: Tx,
offers: OfferRow[],
input: BulkOfferInput,
lock = false,
) {
const ids = [
...new Set([
...offers.map((row) => row.pageId),
...(input.changes.pageId ? [input.changes.pageId] : []),
]),
].sort((a, b) => a - b);
if (ids.some((id) => !Number.isInteger(id) || id <= 0))
throw new CatalogInputError("An offer has an invalid category reference");
const [rows] = await tx.execute(
sql`SELECT id, caption FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
);
const pages = (rows as unknown as { id: number; caption: string }[])
.filter((row) => ids.includes(Number(row.id)))
.map((row) => ({ id: Number(row.id), caption: row.caption }))
.sort((a, b) => a.id - b.id);
if (pages.length !== ids.length)
throw new CatalogNotFound("Source or destination category not found");
return pages;
}
function preview(
input: BulkOfferInput,
offers: OfferRow[],
pages: BulkOfferPreview["pages"],
): BulkOfferPreview {
const rows = offers.map(({ id, catalogName, ...before }) => ({
id,
catalogName,
before,
after: projectBulkOffer(before, input.changes),
}));
const changedCount = rows.filter((row) =>
Object.keys(input.changes).some(
(key) =>
row.before[key as keyof BulkOfferValues] !==
row.after[key as keyof BulkOfferValues],
),
).length;
const fingerprint = createHash("sha256")
.update(
JSON.stringify({
input: { ...input, ids: [...input.ids].sort((a, b) => a - b) },
rows,
pages,
}),
)
.digest("hex");
return { rows, pages, changedCount, fingerprint };
}
export async function previewBulkOffersCommand(
value: BulkOfferInput,
): Promise<BulkOfferPreview> {
const input = bulkOfferInputSchema.parse(value);
return db.transaction(async (tx) => {
const offers = await readOffers(tx, input.ids);
return preview(input, offers, await readPages(tx, offers, input));
});
}
export async function applyBulkOffersCommand(
value: BulkOfferInput,
fingerprint: string,
) {
const input = bulkOfferInputSchema.parse(value);
if (!/^[a-f0-9]{64}$/.test(fingerprint))
throw new CatalogInputError("A valid preview is required");
return db.transaction(async (tx) => {
// Read references, then lock pages before offers to match category structural commands.
const initial = await readOffers(tx, input.ids);
const pages = await readPages(tx, initial, input, true);
const offers = await readOffers(tx, input.ids, true);
if (offers.some((row) => !pages.some((page) => page.id === row.pageId)))
throw new CatalogConflict("The selection changed. Refresh the preview.");
const result = preview(input, offers, pages);
if (result.fingerprint !== fingerprint)
throw new CatalogConflict(
"The selection or destination changed. Refresh the preview before applying.",
);
const columns = {
pageId: "page_id",
costCredits: "cost_credits",
costPoints: "cost_points",
pointsType: "points_type",
};
for (const row of result.rows) {
const changes = (
Object.keys(input.changes) as Array<keyof BulkOfferValues>
).filter((key) => row.before[key] !== row.after[key]);
if (!changes.length) continue;
await tx.execute(
sql`UPDATE catalog_items SET ${sql.join(
changes.map(
(key) =>
sql`${sql.identifier(columns[key])}=${key === "pageId" ? String(row.after[key]) : row.after[key]}`,
),
sql`, `,
)} WHERE id=${row.id}`,
);
}
return { changedCount: result.changedCount };
});
}
export async function listBulkOfferDestinationsCommand() {
const [rows] = await db.execute(
sql`SELECT id, caption FROM catalog_pages WHERE id > 0 ORDER BY caption, id LIMIT 5001`,
);
const pages = (rows as unknown as Array<{ id: number; caption: string }>).map(
(row) => ({ id: Number(row.id), caption: row.caption }),
);
if (pages.length > 5000)
throw new CatalogInputError(
"The catalog has more than 5000 categories. Use bulk editing from a category instead.",
);
return { pages };
}
@@ -0,0 +1,78 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
failAudit: false,
permission: vi.fn(),
export: vi.fn(async (fn: () => unknown) => fn()),
sync: vi.fn(async () => ({ sent: true })),
audit: vi.fn(async () => {
if (state.failAudit) throw Error("Audit unavailable");
}),
command: vi.fn(async () => ({ id: 44, pages: 2, offers: 3 })),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: async (permission: string) => {
state.permission(permission);
return { id: 7 };
},
}));
vi.mock("@/features/catalog/server/duplicate-commands", () => ({
duplicateCategoryCommand: state.command,
previewDuplicateCommand: vi.fn(async () => ({ pages: 2 })),
duplicateDestinationsCommand: vi.fn(async () => []),
}));
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: state.sync,
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: state.export,
}));
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: state.audit,
}));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
import {
applyCatalogDuplicate,
previewCatalogDuplicate,
} from "@/actions/catalog-duplicate";
import { PERMS } from "@/lib/permissions";
const input = {
kind: "normal" as const,
sourceId: 1,
parentId: -1,
name: "Copy",
fingerprint: "a".repeat(64),
pages: 2,
offers: 3,
destination: "Root",
};
beforeEach(() => {
vi.clearAllMocks();
state.failAudit = false;
});
it("requires view for preview and edit for duplication, exports and syncs once", async () => {
await previewCatalogDuplicate(input);
expect(state.permission).toHaveBeenLastCalledWith(PERMS.CATALOG_VIEW);
expect(state.export).not.toHaveBeenCalled();
const result = await applyCatalogDuplicate(input);
expect(result.ok).toBe(true);
expect(state.permission).toHaveBeenLastCalledWith(PERMS.CATALOG_EDIT);
expect(state.command).toHaveBeenCalledOnce();
expect(state.export).toHaveBeenCalledOnce();
expect(state.sync).toHaveBeenCalledOnce();
expect(state.audit).toHaveBeenCalledOnce();
});
it("preserves successful result after commit when audit logging fails", async () => {
state.failAudit = true;
await expect(applyCatalogDuplicate(input)).resolves.toMatchObject({
ok: true,
data: { id: 44 },
});
expect(state.command).toHaveBeenCalledOnce();
});
vi.mock("@/lib/permissions", () => ({
PERMS: { CATALOG_EDIT: "catalog.edit", CATALOG_VIEW: "catalog.view" },
}));
@@ -0,0 +1,179 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pages: [
{ id: 1, parent_id: -1, caption: "Source", enabled: "1", includes: "2" },
{ id: 2, parent_id: 1, caption: "Child", enabled: "1", includes: "" },
],
offers: [
{
id: 10,
page_id: 2,
item_ids: "8;8",
cost_credits: 23,
limited_stack: 10,
offer_id: 10,
},
],
bases: [{ id: 8 }],
queries: [] as { sql: string; params: unknown[] }[],
writes: 0,
failAt: 0,
commits: 0,
rollbacks: 0,
}));
vi.mock("@/lib/services/furni-import", () => ({
allocateCatalogItemId: async (fn: (id: number) => unknown) => fn(500),
}));
vi.mock("@/lib/db", () => ({
db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const saved = state.writes;
try {
const result = await fn({
execute: async (query: SQL) => {
const compiled = new MySqlDialect().sqlToQuery(query);
state.queries.push(compiled);
if (compiled.sql.startsWith("SELECT"))
return [
compiled.sql.includes("catalog_pages")
? state.pages
: compiled.sql.includes("items_base")
? state.bases
: state.offers,
[],
];
state.writes++;
if (state.writes === state.failAt) throw Error("Insert failed");
return [
{
insertId: compiled.sql.includes("`catalog_items`")
? 0
: 100 + state.writes,
},
[],
];
},
});
state.commits++;
return result;
} catch (error) {
state.writes = saved;
state.rollbacks++;
throw error;
}
},
},
}));
import {
duplicateCategoryCommand,
previewDuplicateCommand,
} from "./duplicate-commands";
const input = {
kind: "normal" as const,
sourceId: 1,
parentId: -1,
name: "Copy",
};
beforeEach(() => {
state.pages = [
{ id: 1, parent_id: -1, caption: "Source", enabled: "1", includes: "2" },
{ id: 2, parent_id: 1, caption: "Child", enabled: "1", includes: "" },
];
state.offers = [
{
id: 10,
page_id: 2,
item_ids: "8;8",
cost_credits: 23,
limited_stack: 10,
offer_id: 10,
},
];
state.bases = [{ id: 8 }];
state.queries = [];
state.writes = 0;
state.failAt = 0;
state.commits = 0;
state.rollbacks = 0;
});
describe("atomic subtree duplication", () => {
it("previews without writes and copies descendants, offers and internal references", async () => {
const preview = await previewDuplicateCommand(input);
expect(preview).toMatchObject({ pages: 2, offers: 1 });
expect(state.writes).toBe(0);
state.queries = [];
await duplicateCategoryCommand(preview);
const queries = state.queries;
expect(queries.slice(0, 3).map((q) => q.sql)).toEqual([
expect.stringContaining("catalog_pages"),
expect.stringContaining("catalog_items"),
expect.stringContaining("items_base"),
]);
expect(queries.slice(0, 3).every((q) => q.sql.endsWith("FOR UPDATE"))).toBe(
true,
);
const inserts = queries.filter((q) => q.sql.startsWith("INSERT"));
expect(inserts).toHaveLength(3);
expect(inserts[0].params).toContain("0");
expect(inserts[1].params).toContain(101);
expect(inserts[1].params).toContain("1");
expect(inserts[2].params.filter((p) => p === 500)).toHaveLength(2);
expect(inserts[2].params).toEqual(
expect.arrayContaining(["102", "8;8", 23, 10, 500]),
);
expect(queries.find((q) => q.sql.startsWith("UPDATE"))?.params).toEqual([
"102",
101,
]);
expect(state.pages[0].caption).toBe("Source");
});
it("detects stale source fields before any insert", async () => {
const preview = await previewDuplicateCommand(input);
state.offers[0].cost_credits = 99;
await expect(duplicateCategoryCommand(preview)).rejects.toThrow("changed");
expect(state.writes).toBe(0);
});
it("rolls back pages if an offer insert fails", async () => {
const preview = await previewDuplicateCommand(input);
state.failAt = 4;
await expect(duplicateCategoryCommand(preview)).rejects.toThrow(
"Insert failed",
);
expect(state.writes).toBe(0);
expect(state.rollbacks).toBe(1);
});
it("rejects missing furniture without writes", async () => {
state.bases = [];
await expect(previewDuplicateCommand(input)).rejects.toThrow("Furniture");
expect(state.writes).toBe(0);
});
it("copies BC with database-generated IDs and no added currency columns", async () => {
const preview = await previewDuplicateCommand({ ...input, kind: "bc" });
state.queries = [];
await duplicateCategoryCommand(preview);
const inserts = state.queries.filter((q) => q.sql.startsWith("INSERT"));
expect(inserts[2].sql).toContain("catalog_items_bc");
expect(inserts[2].sql).not.toMatch(/`id`=/);
expect(inserts[0].sql).not.toContain("caption_save");
});
});
it("invalidates the preview after the new root appears among destination siblings", async () => {
const preview = await previewDuplicateCommand(input);
const result = await duplicateCategoryCommand(preview);
state.pages.push({
id: result.id,
parent_id: 0,
caption: "Copy",
enabled: "0",
includes: "",
});
const writes = state.writes;
await expect(duplicateCategoryCommand(preview)).rejects.toThrow("changed");
expect(state.writes).toBe(writes);
});
@@ -0,0 +1,207 @@
import "server-only";
import { createHash } from "node:crypto";
import { sql } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import {
type DuplicateInput,
type DuplicatePreview,
type DuplicateRow,
duplicateSubtree,
remapIncludes,
} from "../domain/duplicate";
import { CatalogConflict, CatalogInputError } from "../domain/hierarchy";
import { parseFurnitureIds } from "../domain/offer-input";
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
const schema = z.object({
kind: z.enum(["normal", "bc"]),
sourceId: z.number().int().positive(),
parentId: z.number().int().min(-1),
name: z.string().trim().min(1).max(128),
});
function table(kind: DuplicateInput["kind"], offers = false) {
return sql.identifier(
`catalog_${offers ? "items" : "pages"}${kind === "bc" ? "_bc" : ""}`,
);
}
async function snapshot(tx: Transaction, input: DuplicateInput, lock: boolean) {
const suffix = lock ? sql` FOR UPDATE` : sql``;
const [rawPages] = await tx.execute(
sql`SELECT * FROM ${table(input.kind)} ORDER BY id LIMIT 20001${suffix}`,
);
const pages = rawPages as unknown as DuplicateRow[];
if (pages.length > 20000)
throw new CatalogInputError(
"Catalog exceeds duplication safety limit (20000 categories)",
);
const subtree = duplicateSubtree(pages, input.sourceId, input.parentId);
const ids = subtree.map((p) => Number(p.id));
const existing = new Set(pages.map((p) => Number(p.id)));
for (const page of subtree)
if (input.kind === "normal")
remapIncludes(page.includes, new Map(), existing);
const [rawOffers] = await tx.execute(
sql`SELECT * FROM ${table(input.kind, true)} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)}) ORDER BY id LIMIT 5001${suffix}`,
);
const offers = rawOffers as unknown as DuplicateRow[];
if (offers.length > 5000)
throw new CatalogInputError("Duplicate at most 5000 offers at a time");
const furniture = [
...new Set(offers.flatMap((o) => parseFurnitureIds(String(o.item_ids)))),
].sort((a, b) => a - b);
if (furniture.length) {
const [rows] = await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(furniture, sql`, `)}) ORDER BY id${suffix}`,
);
const found = new Set(
(rows as unknown as { id: number }[]).map((p) => Number(p.id)),
);
if (furniture.some((id) => !found.has(id)))
throw new CatalogInputError("Furniture reference not found");
}
const destination = pages.find((p) => Number(p.id) === input.parentId);
const fingerprint = createHash("sha256")
.update(
JSON.stringify({
input,
subtree,
offers,
destination,
siblings: pages.filter((page) =>
input.parentId <= 0
? Number(page.parent_id) <= 0
: Number(page.parent_id) === input.parentId,
),
}),
)
.digest("hex");
return {
pages,
subtree,
offers,
fingerprint,
destination: String(destination?.caption ?? "Root"),
};
}
export async function previewDuplicateCommand(
raw: DuplicateInput,
): Promise<DuplicatePreview> {
const input = schema.parse(raw);
return db.transaction(
async (tx) => {
const data = await snapshot(tx, input, false);
return {
...input,
fingerprint: data.fingerprint,
pages: data.subtree.length,
offers: data.offers.length,
destination: data.destination,
};
},
{ isolationLevel: "repeatable read" },
);
}
export async function duplicateCategoryCommand(
raw: DuplicatePreview,
): Promise<{ id: number; pages: number; offers: number }> {
const input = schema.parse(raw);
const expected = z
.string()
.regex(/^[a-f0-9]{64}$/)
.parse(raw.fingerprint);
const count = z.number().int().min(0).max(5000).parse(raw.offers);
// Reserve through the same allocator as regular imports before taking page locks.
// A database uniqueness conflict rolls back every copied row, including pages.
const reserved: number[] = [];
if (input.kind === "normal")
for (let i = 0; i < count; i++)
reserved.push(await allocateCatalogItemId(async (id) => id));
return db.transaction(
async (tx) => {
const data = await snapshot(tx, input, true);
if (data.fingerprint !== expected || data.offers.length !== count)
throw new CatalogConflict(
"Source or destination changed. Refresh the duplication preview.",
);
const ids = new Map<number, number>();
const insert = async (row: Record<string, unknown>, offers = false) => {
const entries = Object.entries(row).filter(([, v]) => v !== undefined);
const [result] = await tx.execute(
sql`INSERT INTO ${table(input.kind, offers)} SET ${sql.join(
entries.map(([k, v]) => sql`${sql.identifier(k)}=${v}`),
sql`, `,
)}`,
);
const id = Number(
row.id ?? (result as unknown as { insertId: number }).insertId,
);
if (!id) throw Error("Could not allocate duplicate category ID");
return id;
};
for (const source of data.subtree) {
const { id: oldId, ...row } = source;
const isRoot = Number(oldId) === input.sourceId;
row.parent_id = isRoot
? input.parentId
: ids.get(Number(source.parent_id));
if (isRoot) {
row.caption = input.name;
row.enabled = "0";
row.visible = "0";
if (input.kind === "normal")
row.caption_save = input.name.slice(0, 25);
}
if (input.kind === "normal") row.includes = "";
ids.set(Number(oldId), await insert(row));
}
const existing = new Set(data.pages.map((p) => Number(p.id)));
for (const source of data.subtree)
if (input.kind === "normal" && source.includes) {
const includes = remapIncludes(source.includes, ids, existing);
if (includes.length > 128)
throw new CatalogInputError(
"Remapped category references exceed 128 characters",
);
await tx.execute(
sql`UPDATE ${table(input.kind)} SET includes=${includes} WHERE id=${ids.get(Number(source.id))}`,
);
}
const offerIds = new Map(
data.offers.map((offer, index) => [Number(offer.id), reserved[index]]),
);
for (const [index, source] of data.offers.entries()) {
const { id: _id, ...row } = source;
row.page_id = String(ids.get(Number(source.page_id)));
if (input.kind === "normal") {
row.id = reserved[index];
if (Number(row.offer_id) > 0 && offerIds.has(Number(row.offer_id)))
row.offer_id = offerIds.get(Number(row.offer_id));
}
await insert(row, true);
}
return {
id: ids.get(input.sourceId) as number,
pages: data.subtree.length,
offers: data.offers.length,
};
},
{ isolationLevel: "repeatable read" },
);
}
export async function duplicateDestinationsCommand(
kind: DuplicateInput["kind"],
) {
z.enum(["normal", "bc"]).parse(kind);
const [rows] = await db.execute(
sql`SELECT id, caption FROM ${table(kind)} ORDER BY caption, id LIMIT 20001`,
);
const pages = rows as unknown as { id: number; caption: string }[];
if (pages.length > 20000)
throw new CatalogInputError(
"Catalog exceeds duplication safety limit (20000 categories)",
);
return pages.map((row) => ({ id: Number(row.id), caption: row.caption }));
}