feat(catalog): add reviewed bulk edits and complete category duplication
This commit is contained in:
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());
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
Reference in new issue
Block a user