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,51 @@
import { expect, it } from "vitest";
import { bulkOfferInputSchema, projectBulkOffer } from "./bulk-offers";
const row = { pageId: 4, costCredits: 3, costPoints: 10, pointsType: 5 };
it("combines selected changes, rounds percentages, preserves explicit zero", () => {
expect(
projectBulkOffer(row, {
costCredits: { mode: "set", value: 0 },
costPoints: { mode: "percent", value: 25 },
pageId: 9,
}),
).toEqual({ ...row, costCredits: 0, costPoints: 13, pageId: 9 });
});
it("rejects negative results and integer overflow instead of silently clamping", () => {
expect(() =>
projectBulkOffer(row, { costCredits: { mode: "add", value: -4 } }),
).toThrow();
expect(() =>
projectBulkOffer(row, { costCredits: { mode: "set", value: 2147483648 } }),
).toThrow();
});
it("leaves legacy signed fields untouched when changing a different field", () => {
expect(
projectBulkOffer({ ...row, costCredits: -1 }, { pageId: 5 }).costCredits,
).toBe(-1);
});
it("rejects empty, duplicate, oversized selections and unknown mutations", () => {
for (const input of [
{ ids: [], changes: { pageId: 1 } },
{ ids: [1, 1], changes: { pageId: 1 } },
{
ids: Array.from({ length: 501 }, (_, i) => i + 1),
changes: { pageId: 1 },
},
{ ids: [1], changes: {} },
{ ids: [1], changes: { itemIds: "2" } },
])
expect(() => bulkOfferInputSchema.parse(input)).toThrow();
});
it("rejects fractional set/add prices, accepts percentage discounts", () => {
expect(() =>
bulkOfferInputSchema.parse({
ids: [1],
changes: { costCredits: { mode: "set", value: 1.5 } },
}),
).toThrow();
expect(
projectBulkOffer(row, { costPoints: { mode: "percent", value: -50 } })
.costPoints,
).toBe(5);
});
@@ -0,0 +1,81 @@
import { z } from "zod";
import { CatalogInputError } from "./hierarchy";
import { offerIdSchema, offerInteger } from "./offer-input";
const priceChange = z
.object({
mode: z.enum(["set", "add", "percent"]),
value: z.number().finite().min(-2147483648).max(2147483647),
})
.strict()
.refine(
(v) => v.mode === "percent" || Number.isInteger(v.value),
"Set and add values must be whole numbers",
);
export const bulkOfferChangesSchema = z
.object({
costCredits: priceChange.optional(),
costPoints: priceChange.optional(),
pointsType: offerInteger.nonnegative().optional(),
pageId: offerIdSchema.optional(),
})
.strict()
.refine(
(v) => Object.values(v).some((x) => x !== undefined),
"Choose at least one field to change",
);
export const bulkOfferInputSchema = z
.object({
ids: z
.array(offerIdSchema)
.min(1)
.max(500)
.refine(
(v) => new Set(v).size === v.length,
"Selection contains duplicate offers",
),
changes: bulkOfferChangesSchema,
})
.strict();
export type BulkOfferInput = z.infer<typeof bulkOfferInputSchema>;
export type BulkOfferValues = {
pageId: number;
costCredits: number;
costPoints: number;
pointsType: number;
};
export type BulkOfferPreview = {
fingerprint: string;
changedCount: number;
pages: Array<{ id: number; caption: string }>;
rows: Array<{
id: number;
catalogName: string;
before: BulkOfferValues;
after: BulkOfferValues;
}>;
};
export function projectBulkOffer(
row: BulkOfferValues,
changes: BulkOfferInput["changes"],
): BulkOfferValues {
const after = { ...row };
for (const key of ["costCredits", "costPoints"] as const) {
const change = changes[key];
if (!change) continue;
const value =
change.mode === "set"
? change.value
: change.mode === "add"
? row[key] + change.value
: Math.round(row[key] * (1 + change.value / 100));
if (!Number.isSafeInteger(value) || value < 0 || value > 2147483647)
throw new CatalogInputError(
"A resulting price is negative or exceeds the supported range",
);
after[key] = value;
}
if (changes.pointsType !== undefined) after.pointsType = changes.pointsType;
if (changes.pageId !== undefined) after.pageId = changes.pageId;
return after;
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { duplicateSubtree, remapIncludes } from "./duplicate";
describe("category duplication", () => {
const pages = [
{ id: 1, parent_id: -1 },
{ id: 2, parent_id: 1 },
{ id: 3, parent_id: 2 },
{ id: 4, parent_id: -1 },
];
it("collects all descendants in parent-first order", () =>
expect(duplicateSubtree(pages, 1, 4).map((p) => p.id)).toEqual([1, 2, 3]));
it("rejects corrupt source cycles and missing destination", () => {
expect(() =>
duplicateSubtree(
[
{ id: 1, parent_id: 2 },
{ id: 2, parent_id: 1 },
],
1,
-1,
),
).toThrow();
expect(() => duplicateSubtree(pages, 1, 99)).toThrow();
});
it("rejects missing source ancestry", () =>
expect(() =>
duplicateSubtree([{ id: 1, parent_id: 99 }], 1, -1),
).toThrow());
it("remaps internal includes and preserves external references", () =>
expect(remapIncludes("2;4", new Map([[2, 20]]), new Set([2, 4]))).toBe(
"20;4",
));
it("rejects broken included references", () =>
expect(() => remapIncludes("99", new Map(), new Set([2]))).toThrow());
});
+59
View File
@@ -0,0 +1,59 @@
import {
assertParent,
CatalogInputError,
CatalogNotFound,
collectSubtree,
positiveId,
} from "./hierarchy";
export type DuplicateRow = Record<string, unknown> & {
id: number;
parent_id?: number;
};
export type DuplicateInput = {
kind: "normal" | "bc";
sourceId: number;
parentId: number;
name: string;
};
export type DuplicatePreview = DuplicateInput & {
fingerprint: string;
pages: number;
offers: number;
destination: string;
};
export function duplicateSubtree<T extends DuplicateRow>(
pages: T[],
sourceId: number,
parentId: number,
): T[] {
positiveId(sourceId);
const source = pages.find((p) => Number(p.id) === sourceId);
if (!source) throw new CatalogNotFound("Catalog category not found");
const hierarchy = pages.map((p) => ({
id: Number(p.id),
parentId: Number(p.parent_id),
}));
assertParent(hierarchy, sourceId, Number(source.parent_id));
assertParent(hierarchy, 0, parentId);
const ids = collectSubtree(hierarchy, sourceId);
if (ids.length > 500)
throw new CatalogInputError("Duplicate at most 500 categories at a time");
return ids.map((id) => pages.find((p) => Number(p.id) === id) as T);
}
export function remapIncludes(
value: unknown,
ids: Map<number, number>,
existing: Set<number>,
): string {
const text = String(value ?? "");
if (!text.trim()) return text;
return text
.split(";")
.map((part) => {
const id = Number(part.trim());
if (!/^\d+$/.test(part.trim()) || !existing.has(id))
throw new CatalogInputError("Included category reference not found");
return String(ids.get(id) ?? id);
})
.join(";");
}