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,494 @@
"use client";
import { ArrowRight, Loader2, SlidersHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { applyBulkOffers, previewBulkOffers } from "@/actions/catalog-bulk";
import { MovePageCombobox } from "@/app/admin/catalog/[id]/catalog-items-table/field-helpers";
import { POINTS_TYPES } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import {
CurrencyIcon,
currencyKindFromPointsType,
} from "@/components/shared/currency-icon";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type BulkInput = Parameters<typeof previewBulkOffers>[0];
type Preview = Extract<
Awaited<ReturnType<typeof previewBulkOffers>>,
{ ok: true }
>["data"];
type PriceMode = "set" | "add" | "percent";
type PriceField = "costCredits" | "costPoints";
type PriceDraft = { enabled: boolean; mode: PriceMode; value: string };
const emptyPrice = (): PriceDraft => ({
enabled: false,
mode: "set",
value: "0",
});
export function BulkOfferEditor({
ids,
allPages,
disabled = false,
onApplied,
beforeEdit,
}: {
ids: number[];
allPages: { id: number; caption: string }[];
disabled?: boolean;
onApplied: () => void | Promise<void>;
beforeEdit?: () => boolean;
}) {
const t = useTranslations("pages.admin.catalog.bulk");
const [open, setOpen] = useState(false);
const [prices, setPrices] = useState<Record<PriceField, PriceDraft>>({
costCredits: emptyPrice(),
costPoints: emptyPrice(),
});
const [currencyEnabled, setCurrencyEnabled] = useState(false);
const [pointsType, setPointsType] = useState(0);
const [destinationEnabled, setDestinationEnabled] = useState(false);
const [pageId, setPageId] = useState<number | null>(null);
const [preview, setPreview] = useState<{
input: BulkInput;
data: Preview;
} | null>(null);
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const [error, setError] = useState<string | null>(null);
const tooMany = ids.length > 500;
const selectedFields =
prices.costCredits.enabled ||
prices.costPoints.enabled ||
currencyEnabled ||
destinationEnabled;
function start() {
if (disabled || tooMany || (beforeEdit && !beforeEdit())) return;
setPrices({ costCredits: emptyPrice(), costPoints: emptyPrice() });
setCurrencyEnabled(false);
setDestinationEnabled(false);
setPageId(null);
setPreview(null);
setError(null);
setOpen(true);
}
function updatePrice(field: PriceField, patch: Partial<PriceDraft>) {
setPrices((previous) => ({
...previous,
[field]: { ...previous[field], ...patch },
}));
}
async function requestPreview() {
if (busyRef.current || disabled || tooMany || (beforeEdit && !beforeEdit()))
return;
const changes: BulkInput["changes"] = {};
for (const field of ["costCredits", "costPoints"] as const) {
const price = prices[field];
if (!price.enabled) continue;
const value = Number(price.value);
if (
!price.value.trim() ||
!Number.isFinite(value) ||
(price.mode !== "percent" && !Number.isInteger(value))
) {
setError(t("invalidPrice"));
return;
}
changes[field] = { mode: price.mode, value };
}
if (currencyEnabled) changes.pointsType = pointsType;
if (destinationEnabled) {
if (pageId === null) {
setError(t("chooseDestination"));
return;
}
changes.pageId = pageId;
}
if (!selectedFields) {
setError(t("chooseFields"));
return;
}
const input: BulkInput = { ids: [...ids], changes };
busyRef.current = true;
setBusy(true);
setError(null);
try {
const result = await previewBulkOffers(input);
if (!result.ok) {
setError(result.error);
return;
}
setPreview({ input, data: result.data });
} catch {
setError(t("requestFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
}
async function apply() {
if (
!preview ||
busyRef.current ||
disabled ||
(beforeEdit && !beforeEdit())
)
return;
busyRef.current = true;
setBusy(true);
setError(null);
try {
const result = await applyBulkOffers(
preview.input,
preview.data.fingerprint,
);
if (!result.ok) {
setError(result.error);
setPreview(null);
return;
}
toast.success(t("success", { count: result.data.changedCount }));
setOpen(false);
await onApplied();
} catch {
setError(t("requestFailed"));
setPreview(null);
} finally {
busyRef.current = false;
setBusy(false);
}
}
const pageNames = new Map(
(preview?.data.pages ?? allPages).map((page) => [page.id, page.caption]),
);
const currencyName = (type: number) =>
POINTS_TYPES[type]
? t(`currencies.${type}`)
: t("unknownCurrency", { type });
const renderChange = (before: React.ReactNode, after: React.ReactNode) => (
<span className="inline-flex items-center gap-2">
<span className="text-muted-foreground">{before}</span>
<ArrowRight className="size-3 shrink-0" aria-label={t("becomes")} />
<strong>{after}</strong>
</span>
);
return (
<>
<div className="flex flex-col gap-1">
<Button
variant="outline"
size="sm"
disabled={disabled || tooMany || !ids.length}
onClick={start}
>
<SlidersHorizontal className="mr-2 size-4" />
{t("button", { count: ids.length })}
</Button>
{(disabled || tooMany) && (
<span className="max-w-64 text-xs text-muted-foreground">
{t(tooMany ? "selectionLimit" : "disabledReason")}
</span>
)}
</div>
<Dialog
open={open}
onOpenChange={(value) => {
if (!busyRef.current) setOpen(value);
}}
>
<DialogContent
style={{
backgroundColor: "var(--admin-surface)",
color: "var(--admin-text)",
}}
className="min-w-0 max-h-[90vh] overflow-y-auto sm:max-w-4xl [&>*]:min-w-0"
showCloseButton={false}
>
<DialogHeader>
<DialogTitle>{t(preview ? "previewTitle" : "title")}</DialogTitle>
<DialogDescription>
{t("description", { count: ids.length })}
</DialogDescription>
</DialogHeader>
{error && (
<p
role="alert"
className="rounded-md border border-destructive p-3 text-sm text-destructive"
>
{error}
</p>
)}
{!preview ? (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("fieldHint")}</p>
{(["costCredits", "costPoints"] as const).map((field) => (
<div key={field} className="space-y-3 rounded-md border p-3">
<Label className="flex items-center gap-2">
<Checkbox
checked={prices[field].enabled}
onCheckedChange={(value) =>
updatePrice(field, { enabled: value === true })
}
disabled={busy}
/>
{t(field)}
</Label>
{prices[field].enabled && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={`bulk-${field}-mode`}>
{t("operation")}
</Label>
<Select
value={prices[field].mode}
onValueChange={(value) =>
updatePrice(field, { mode: value as PriceMode })
}
disabled={busy}
>
<SelectTrigger id={`bulk-${field}-mode`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["set", "add", "percent"] as const).map(
(mode) => (
<SelectItem key={mode} value={mode}>
{t(`modes.${mode}`)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor={`bulk-${field}-value`}>
{t(
prices[field].mode === "percent"
? "percentage"
: "value",
)}
</Label>
<Input
id={`bulk-${field}-value`}
type="number"
step={prices[field].mode === "percent" ? "any" : 1}
value={prices[field].value}
onChange={(event) =>
updatePrice(field, { value: event.target.value })
}
disabled={busy}
/>
</div>
</div>
)}
</div>
))}
<div className="space-y-3 rounded-md border p-3">
<Label className="flex items-center gap-2">
<Checkbox
checked={currencyEnabled}
onCheckedChange={(value) =>
setCurrencyEnabled(value === true)
}
disabled={busy}
/>
{t("currency")}
</Label>
{currencyEnabled && (
<Select
value={String(pointsType)}
onValueChange={(value) => setPointsType(Number(value))}
disabled={busy}
>
<SelectTrigger aria-label={t("currency")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(POINTS_TYPES).map((type) => (
<SelectItem key={type} value={type}>
<span className="flex items-center gap-2">
<CurrencyIcon
kind={currencyKindFromPointsType(Number(type))}
alt=""
/>
{currencyName(Number(type))}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-3 rounded-md border p-3">
<Label className="flex items-center gap-2">
<Checkbox
checked={destinationEnabled}
onCheckedChange={(value) =>
setDestinationEnabled(value === true)
}
disabled={busy}
/>
{t("destination")}
</Label>
{destinationEnabled && (
<MovePageCombobox
value={pageId}
onChange={setPageId}
pages={allPages}
currentPageId={-1}
disabled={busy}
labels={{
placeholder: t("pagePlaceholder"),
search: t("pageSearch"),
empty: t("pageEmpty"),
suggested: t("pageSuggested"),
all: t("pageAll"),
}}
/>
)}
</div>
<p className="text-xs text-muted-foreground">{t("priceHint")}</p>
<p className="text-xs text-muted-foreground">
{t("visibilityHint")}
</p>
</div>
) : (
<div className="space-y-3">
<p className="text-sm font-medium">
{t("previewCount", {
count: preview.data.changedCount,
total: preview.data.rows.length,
})}
</p>
<p className="text-xs text-muted-foreground">
{t("beforeAfter")}
</p>
<div className="min-w-0 max-w-full max-h-[45vh] overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
{[
"offer",
"costCredits",
"costPoints",
"currency",
"destination",
].map((key) => (
<TableHead key={key}>{t(key)}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{preview.data.rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<div className="max-w-56 whitespace-normal break-words">
{row.catalogName}
</div>
<span className="text-xs text-muted-foreground">
#{row.id}
</span>
</TableCell>
<TableCell>
{renderChange(
row.before.costCredits,
row.after.costCredits,
)}
</TableCell>
<TableCell>
{renderChange(
row.before.costPoints,
row.after.costPoints,
)}
</TableCell>
<TableCell>
{renderChange(
currencyName(row.before.pointsType),
currencyName(row.after.pointsType),
)}
</TableCell>
<TableCell>
{renderChange(
`${pageNames.get(row.before.pageId) ?? ""} #${row.before.pageId}`,
`${pageNames.get(row.after.pageId) ?? ""} #${row.after.pageId}`,
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{preview.data.changedCount === 0 && (
<p className="text-sm text-muted-foreground">
{t("noChanges")}
</p>
)}
</div>
)}
<div className="flex min-w-0 flex-wrap justify-end gap-2 [&>button]:h-auto [&>button]:min-h-9 [&>button]:whitespace-normal">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={busy}
>
{t("cancel")}
</Button>
{preview && (
<Button
variant="outline"
onClick={() => {
setPreview(null);
setError(null);
}}
disabled={busy}
>
{t("back")}
</Button>
)}
<Button
onClick={preview ? apply : requestPreview}
disabled={
busy ||
disabled ||
(!preview && !selectedFields) ||
preview?.data.changedCount === 0
}
>
{busy && <Loader2 className="mr-2 size-4 animate-spin" />}
{preview
? t("confirm", { count: preview.data.changedCount })
: t("preview")}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,19 +1,27 @@
"use client";
import { Loader2, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { getBulkOfferDestinations } from "@/actions/catalog-bulk";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { requestCatalogNavigation } from "../client/use-catalog-navigation";
import { useLatestRequest } from "../client/use-latest-request";
import {
type CatalogSearchResult,
type SearchCatalogKind,
validSearchQuery,
} from "../domain/search";
import { BulkOfferEditor } from "./bulk-offer-editor";
export function CatalogSearch({
catalogType = "normal",
canEdit = false,
}: {
catalogType?: SearchCatalogKind;
canEdit?: boolean;
}) {
const t = useTranslations("pages.admin.catalog.workspace");
const [query, setQuery] = useState("");
@@ -22,6 +30,69 @@ export function CatalogSearch({
"idle",
);
const requests = useLatestRequest();
const bulk = useTranslations("pages.admin.catalog.bulk");
const router = useRouter();
const [selected, setSelected] = useState<Set<number>>(new Set());
const [pages, setPages] = useState<{ id: number; caption: string }[] | null>(
null,
);
const [destinationsLoading, setDestinationsLoading] = useState(false);
const [selectionError, setSelectionError] = useState<string | null>(null);
const destinationRequest = useRef(0);
const destinationBusy = useRef(false);
const [refreshKey, setRefreshKey] = useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: Catalog switches invalidate the selection and destination requests.
useEffect(() => {
destinationRequest.current += 1;
destinationBusy.current = false;
setSelected(new Set());
setPages(null);
setDestinationsLoading(false);
setSelectionError(null);
return () => {
destinationRequest.current += 1;
};
}, [catalogType]);
async function loadDestinations() {
if (destinationBusy.current) return;
destinationBusy.current = true;
const request = ++destinationRequest.current;
setDestinationsLoading(true);
setSelectionError(null);
try {
const result = await getBulkOfferDestinations();
if (request !== destinationRequest.current) return;
if (!result.ok) {
setSelectionError(result.error);
return;
}
setPages(result.data.pages);
} catch {
if (request === destinationRequest.current)
setSelectionError(bulk("requestFailed"));
} finally {
if (request === destinationRequest.current) {
destinationBusy.current = false;
setDestinationsLoading(false);
}
}
}
function mayEdit() {
if (requestCatalogNavigation()) return true;
setSelectionError(bulk("disabledReason"));
return false;
}
function toggleOffer(id: number) {
if (!mayEdit()) return;
setSelected((previous) => {
const next = new Set(previous);
if (next.has(id)) next.delete(id);
else if (next.size < 500) next.add(id);
return next;
});
if (!pages) void loadDestinations();
}
// biome-ignore lint/correctness/useExhaustiveDependencies: Successful bulk edits must refresh unchanged search queries.
useEffect(() => {
const request = requests.start();
setResults([]);
@@ -52,7 +123,7 @@ export function CatalogSearch({
clearTimeout(timer);
requests.cancel();
};
}, [query, catalogType, requests]);
}, [query, catalogType, requests, refreshKey]);
return (
<section
aria-label={t("globalSearch")}
@@ -72,6 +143,53 @@ export function CatalogSearch({
className="min-w-0"
/>
</div>
{canEdit && catalogType === "normal" && selected.size > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-md border p-2">
<span className="text-sm">
{bulk("selectedCount", { count: selected.size })}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelected(new Set())}
>
{bulk("clearSelection")}
</Button>
{pages ? (
<BulkOfferEditor
ids={[...selected]}
allPages={pages}
beforeEdit={mayEdit}
onApplied={() => {
setSelected(new Set());
setRefreshKey((value) => value + 1);
router.refresh();
}}
/>
) : (
<Button
variant="outline"
size="sm"
disabled={destinationsLoading}
onClick={() => void loadDestinations()}
>
{destinationsLoading && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
{bulk(
destinationsLoading
? "loadingDestinations"
: "retryDestinations",
)}
</Button>
)}
</div>
)}
{selectionError && (
<p role="alert" className="text-sm text-[var(--admin-error)]">
{selectionError}
</p>
)}
<div aria-live="polite">
{status === "loading" && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -93,10 +211,26 @@ export function CatalogSearch({
{results.length > 0 && (
<ul className="max-h-80 overflow-y-auto divide-y divide-[var(--admin-border)]">
{results.map((result) => (
<li key={result.kind + result.id}>
<li
key={result.kind + result.id}
className="flex items-center gap-2"
>
{canEdit &&
catalogType === "normal" &&
result.kind === "offer" && (
<Checkbox
aria-label={bulk("selectOffer", {
name: result.caption,
id: result.id,
})}
checked={selected.has(result.id)}
disabled={selected.size >= 500 && !selected.has(result.id)}
onCheckedChange={() => toggleOffer(result.id)}
/>
)}
<Link
href={result.href}
className="block min-w-0 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
className="block min-w-0 flex-1 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-xs text-muted-foreground">
@@ -0,0 +1,175 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useId, useRef, useState } from "react";
import { toast } from "sonner";
import {
applyCatalogDuplicate,
catalogDuplicateDestinations,
previewCatalogDuplicate,
} from "@/actions/catalog-duplicate";
import { MovePageCombobox } from "@/app/admin/catalog/[id]/catalog-items-table/field-helpers";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type { DuplicateInput, DuplicatePreview } from "../domain/duplicate";
export function DuplicateCategoryDialog({
source,
onClose,
onComplete,
}: {
source: DuplicateInput;
onClose: () => void;
onComplete: (id: number) => void;
}) {
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
const [name, setName] = useState(source.name);
const [parentId, setParentId] = useState<number | null>(
source.parentId <= 0 ? -1 : source.parentId,
);
const nameId = useId();
const [destinations, setDestinations] = useState<
{ id: number; caption: string }[]
>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let current = true;
void catalogDuplicateDestinations(source.kind)
.then((result) => {
if (!current) return;
if (result.ok) setDestinations(result.data);
else toast.error(result.error);
})
.catch(() => {
if (current) toast.error(t("failed"));
})
.finally(() => {
if (current) setLoading(false);
});
return () => {
current = false;
};
}, [source.kind, t]);
const [preview, setPreview] = useState<DuplicatePreview | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
async function run() {
if (pending.current) return;
pending.current = true;
setBusy(true);
try {
if (!preview) {
const result = await previewCatalogDuplicate({
...source,
name,
parentId: parentId ?? -1,
});
if (!result.ok) throw Error(result.error);
setPreview(result.data);
} else {
const result = await applyCatalogDuplicate(preview);
if (!result.ok) {
setPreview(null);
throw Error(result.error);
}
if (result.data.hotel.sent) toast.success(t("saved"));
else toast.warning(t("savedPending"));
onComplete(result.data.id);
onClose();
}
} catch (error) {
setPreview(null);
toast.error(error instanceof Error ? error.message : t("failed"));
} finally {
pending.current = false;
setBusy(false);
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !pending.current) onClose();
}}
>
<DialogContent
className="max-w-lg"
style={{
backgroundColor: "var(--admin-surface)",
color: "var(--admin-text)",
}}
>
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<label htmlFor={nameId} className="grid gap-1 text-sm">
{t("name")}
<Input
id={nameId}
value={name}
maxLength={128}
disabled={busy}
onChange={(e) => {
setName(e.target.value);
setPreview(null);
}}
/>
</label>
<div className="grid gap-1 text-sm">
<p>{t("destination")}</p>
<MovePageCombobox
value={parentId}
onChange={(id) => {
setParentId(id);
setPreview(null);
}}
pages={[{ id: -1, caption: t("root") }, ...destinations]}
currentPageId={-2}
disabled={busy || loading}
labels={{
placeholder: t("destination"),
search: t("search"),
empty: t("empty"),
suggested: t("suggested"),
all: t("all"),
}}
/>
</div>
<p className="text-sm text-muted-foreground">{t("disabled")}</p>
{preview && (
<div
className="rounded-md border p-3 text-sm space-y-1"
role="status"
>
<p>
{t("counts", { pages: preview.pages, offers: preview.offers })}
</p>
<p>
{t("summary", {
name: preview.name,
destination: preview.destination,
})}
</p>
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" disabled={busy} onClick={onClose}>
{t("cancel")}
</Button>
<Button
disabled={busy || !name.trim() || parentId === null || loading}
onClick={() => void run()}
>
{busy ? t("working") : preview ? t("confirm") : t("preview")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -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(";");
}
@@ -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 }));
}