feat(catalog): add reviewed bulk edits and complete category duplication

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,21 @@
# Catalog operations upgrade plan
User approved bulk editing and complete category duplication, keeping Visual Manager in its button-opened modal.
Use subagent-driven-development for the independent duplication task; root owns bulk operations and final review.
- [x] Duplicate category subtree and offers atomically for normal/BC; preview counts, destination/name, fresh IDs, preserve furniture references; fail on stale source, invalid destination, cycles and insert failure. Shared permissions, single export/RCON outcome. UI in Visual Manager.
- [x] Extend selected-offer operations to preview and atomically apply prices, currency, and destination. Visibility belongs to categories, so expose it separately with exact affected categories; no fictitious per-offer flag. Preserve unselected and unrelated fields; conflicts prevent overwriting concurrent changes.
- [x] Meaningful domain/transaction/action tests, browser fixtures for modal preview/confirm, typecheck/Biome/i18n/Knip; review limits and commit exact scope.
No production data mutations, added dependencies or schema migrations. Full category duplication shares existing items_base definitions and asset files. Repeated confirmations must be disabled while saving. Preview should be revalidated under locks before write. Existing direct save and permissions remain.
## Implementation notes
Bulk editing now works from selected normal-catalog offers and from selection across global searches. Only explicitly chosen prices/currency/destination fields change. Prices support set/add/percentage with integer rounding and range validation; 500 selected offers per transaction. Preview binds rows, changes and category names; concurrent changes reject confirmation. Category visibility remains on the existing category controls, not a fabricated offer property. BC has no offer pricing and does not expose that editor.
Duplication copies up to 500 categories and 5000 offers, preserving bundle and asset references. New root starts disabled and hidden. Includes and internal offer IDs are remapped. Explicit normal offer IDs use the existing allocator and do not require AUTO_INCREMENT; database collisions roll back the whole copy. Preview binds destination siblings as well as source data so confirmed preview replay is rejected. Dirty editor state blocks copying saved data until drafts are saved/reset.
No database migration or dependency added. Database transaction tests use mocks and do not prove live MariaDB locking behavior. Browser fixtures use real components with mocked actions, not production writes. Existing allocator is process-local; competing external imports may cause a safe rollback requiring a fresh preview. No automatic retry of uncertain copy commits.
Verification complete: 1339 unit tests passed, 5 skipped. Typecheck/Knip/i18n and changed-file Biome checked. Real-component browser fixtures passed bulk selection, preview, conflicts, pending guards, mobile bounds and table refresh without phantom drafts; duplication nested inside actual manager verified menus, picker, preview and dirty-state guards. Fixed manager portal layering and invalid menu-label nesting uncovered by those tests. Browser actions are mocked; no live data written or deployment claimed.
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import type { BulkOfferInput } from "@/features/catalog/domain/bulk-offers";
import {
applyBulkOffersCommand,
listBulkOfferDestinationsCommand,
previewBulkOffersCommand,
} from "@/features/catalog/server/bulk-offers";
import { catalogFailure } from "@/features/catalog/server/errors";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
export async function previewBulkOffers(input: BulkOfferInput) {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return { ok: true as const, data: await previewBulkOffersCommand(input) };
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
export async function applyBulkOffers(
input: BulkOfferInput,
fingerprint: string,
) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
try {
return await withCatalogExport(async () => {
const data = await applyBulkOffersCommand(input, fingerprint);
if (data.changedCount > 0) {
await logAudit({
userId: staff.id,
action: "catalog_offers_bulk_update",
target: "catalog",
after: {
ids: input.ids,
changes: input.changes,
changedCount: data.changedCount,
},
}).catch((error) =>
logger.error("Catalog bulk update committed; audit write failed", {
module: "catalog",
error,
}),
);
await sendCatalogUpdate();
revalidatePath("/admin/catalog", "layout");
}
return { ok: true as const, data };
});
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
export async function getBulkOfferDestinations() {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return {
ok: true as const,
data: await listBulkOfferDestinationsCommand(),
};
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
+66
View File
@@ -0,0 +1,66 @@
"use server";
import { revalidatePath } from "next/cache";
import type {
DuplicateInput,
DuplicatePreview,
} from "@/features/catalog/domain/duplicate";
import {
duplicateCategoryCommand,
duplicateDestinationsCommand,
previewDuplicateCommand,
} from "@/features/catalog/server/duplicate-commands";
import { catalogFailure } from "@/features/catalog/server/errors";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function previewCatalogDuplicate(input: DuplicateInput) {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return { ok: true as const, data: await previewDuplicateCommand(input) };
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
export async function applyCatalogDuplicate(input: DuplicatePreview) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return withCatalogExport(async () => {
let data: Awaited<ReturnType<typeof duplicateCategoryCommand>>;
try {
data = await duplicateCategoryCommand(input);
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
const hotel = await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_create",
description: `Duplicated ${input.kind} category #${input.sourceId} to #${data.id}: ${data.pages} categories, ${data.offers} offers`,
targetType: "catalog_page",
targetId: data.id,
}).catch((error) => {
logger.error("Category duplicated but audit logging failed", {
module: "catalog",
error,
});
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { ...data, hotel } };
});
}
export async function catalogDuplicateDestinations(
kind: DuplicateInput["kind"],
) {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return {
ok: true as const,
data: await duplicateDestinationsCommand(kind),
};
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
@@ -34,6 +34,7 @@ import {
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
@@ -80,6 +81,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
import { useServerAction } from "@/hooks/use-server-action";
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
import { cn } from "@/lib/utils";
@@ -167,6 +169,10 @@ export function CatalogItemsTable({
allPages,
}: CatalogItemsTableProps) {
const router = useRouter();
const bulk = useTranslations("pages.admin.catalog.bulk");
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
CatalogItemData[] | null
>(null);
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [addOpen, setAddOpen] = useState(false);
@@ -294,7 +300,26 @@ export function CatalogItemsTable({
return itemOrder.some((id, idx) => id !== originalOrder[idx]);
}, [items, itemOrder]);
const hasUnsavedChanges = modifiedItems.length > 0 || hasOrderChanges;
const hasUnsavedChanges =
bulkRefreshFrom === null && (modifiedItems.length > 0 || hasOrderChanges);
useEffect(() => {
if (bulkRefreshFrom === null || items === bulkRefreshFrom) return;
setInlineEdits(
Object.fromEntries(
items.map((item) => [
item.id,
{
costCredits: item.costCredits,
costPoints: item.costPoints,
pointsType: item.pointsType,
amount: item.amount,
},
]),
),
);
setItemOrder(items.map((item) => item.id));
setBulkRefreshFrom(null);
}, [items, bulkRefreshFrom]);
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onDirtyChange]);
@@ -904,7 +929,19 @@ export function CatalogItemsTable({
return (
<>
{confirmDialog}
<Card>
{bulkRefreshFrom !== null && (
<p
role="status"
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<Loader2 className="size-4 animate-spin" />
{bulk("refreshing")}
</p>
)}
<Card
inert={bulkRefreshFrom !== null}
aria-busy={bulkRefreshFrom !== null}
>
<CardHeader className="space-y-3">
<div className="flex flex-row items-center justify-between gap-4">
<div className="min-w-0">
@@ -958,6 +995,21 @@ export function CatalogItemsTable({
<>
{selected.size > 0 && (
<>
<BulkOfferEditor
ids={[...selected]}
allPages={allPages}
disabled={
hasUnsavedChanges ||
isPending ||
savingItemId !== null
}
onApplied={() => {
setBulkRefreshFrom(items);
setSelected(new Set());
if (onRefresh) onRefresh();
else router.refresh();
}}
/>
<Button
variant="outline"
size="sm"
@@ -151,6 +151,8 @@ export function MovePageCombobox({
pages,
currentPageId,
suggestedPageIds,
disabled = false,
labels,
}: {
value: number | null;
onChange: (id: number | null) => void;
@@ -158,6 +160,14 @@ export function MovePageCombobox({
currentPageId: number;
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
suggestedPageIds?: number[];
disabled?: boolean;
labels?: {
placeholder: string;
search: string;
empty: string;
suggested: string;
all: string;
};
}) {
const [open, setOpen] = useState(false);
@@ -194,6 +204,7 @@ export function MovePageCombobox({
<Button
variant="outline"
role="combobox"
disabled={disabled}
aria-expanded={open}
className="w-full justify-between font-normal"
>
@@ -205,7 +216,9 @@ export function MovePageCombobox({
{selectedPage.caption}
</span>
) : (
<span className="text-muted-foreground">Select target page...</span>
<span className="text-muted-foreground">
{labels?.placeholder ?? "Select target page..."}
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -215,13 +228,13 @@ export function MovePageCombobox({
align="start"
>
<Command>
<CommandInput placeholder="Search pages..." />
<CommandInput placeholder={labels?.search ?? "Search pages..."} />
<CommandList>
<CommandEmpty>No page found.</CommandEmpty>
<CommandEmpty>{labels?.empty ?? "No page found."}</CommandEmpty>
{/* Suggested pages */}
{suggested.length > 0 && (
<CommandGroup heading="Suggested">
<CommandGroup heading={labels?.suggested ?? "Suggested"}>
{suggested.map((page) => (
<CommandItem
key={`s-${page.id}`}
@@ -249,7 +262,9 @@ export function MovePageCombobox({
{/* All pages */}
<CommandGroup
heading={suggested.length > 0 ? "All pages" : undefined}
heading={
suggested.length > 0 ? (labels?.all ?? "All pages") : undefined
}
>
{remaining.map((page) => (
<CommandItem
+2 -2
View File
@@ -263,7 +263,7 @@ export default async function CatalogPagesPage({
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
<div className="mb-4 space-y-3">
<CatalogSyncStatus canEdit={canEdit} />
<CatalogSearch catalogType={catalogType} />
<CatalogSearch catalogType={catalogType} canEdit={canEdit} />
</div>
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -496,7 +496,7 @@ export default async function CatalogPagesPage({
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
<div className="mb-4 space-y-3">
<CatalogSyncStatus canEdit={canEdit} />
<CatalogSearch catalogType={catalogType} />
<CatalogSearch catalogType={catalogType} canEdit={canEdit} />
</div>
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -116,13 +116,13 @@ export function CatalogManagerDialog({
</Button>
{mounted && open
? createPortal(
<div className="fixed inset-0 z-[200]">
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-foreground/70" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="visual-catalog-manager-title"
className="fixed top-[2.5vh] left-[1vw] z-[201] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
className="fixed top-[2.5vh] left-[1vw] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<CatalogManagerProvider
@@ -17,7 +17,16 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Loader2, Pencil, Plus, Power, PowerOff, Trash2 } from "lucide-react";
import {
Copy,
Loader2,
Pencil,
Plus,
Power,
PowerOff,
Trash2,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
@@ -41,6 +50,8 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
import { cn } from "@/lib/utils";
import { useCatalogManager } from "./use-catalog-store";
@@ -67,8 +78,12 @@ export function RootTabs({
loading,
onMutation,
}: RootTabsProps) {
const { catalogType, canEdit } = useCatalogManager();
const { catalogType, canEdit, state } = useCatalogManager();
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [duplicateSource, setDuplicateSource] = useState<DuplicateInput | null>(
null,
);
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -158,6 +173,13 @@ export function RootTabs({
style={{ backgroundColor: "var(--admin-surface)" }}
>
{confirmDialog}
{duplicateSource && (
<DuplicateCategoryDialog
source={duplicateSource}
onClose={() => setDuplicateSource(null)}
onComplete={() => onMutation?.()}
/>
)}
{loading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground mx-2" />
)}
@@ -180,6 +202,19 @@ export function RootTabs({
onEdit={() => onEditTab(tab.id)}
onToggle={() => handleToggle(tab)}
onDelete={() => handleDelete(tab)}
onDuplicate={() => {
if (state.editorDirty) {
toast.error(t("draftBlocked"));
return;
}
if (canEdit && requestCatalogNavigation())
setDuplicateSource({
kind: catalogType,
sourceId: tab.id,
parentId: -1,
name: `${tab.caption.slice(0, 121)} (copy)`,
});
}}
/>
))}
</SortableContext>
@@ -218,6 +253,7 @@ function SortableRootTab({
onEdit,
onToggle,
onDelete,
onDuplicate,
}: {
tab: RootTab;
isActive: boolean;
@@ -225,6 +261,7 @@ function SortableRootTab({
onEdit: () => void;
onToggle: () => void;
onDelete: () => void;
onDuplicate: () => void;
}) {
const {
attributes,
@@ -237,6 +274,8 @@ function SortableRootTab({
id: tab.id,
});
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
const { canEdit } = useCatalogManager();
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
@@ -256,6 +295,12 @@ function SortableRootTab({
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
{canEdit && (
<ContextMenuItem onClick={onDuplicate}>
<Copy className="mr-2 h-3.5 w-3.5" />
{t("title")}
</ContextMenuItem>
)}
<ContextMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit page
@@ -31,6 +31,7 @@ import {
Trash2,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
@@ -39,7 +40,6 @@ import {
reorderCatalogPages,
reorderTreePage,
toggleCatalogPage,
updateCatalogPage,
} from "@/actions/catalog";
import {
createBcPage,
@@ -47,7 +47,6 @@ import {
reorderBcCatalogPages,
reorderBcTreePage,
toggleBcPage,
updateBcPage,
} from "@/actions/catalog-bc";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Badge } from "@/components/ui/badge";
@@ -58,6 +57,9 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
import { cn } from "@/lib/utils";
import {
type FlatTreeNode,
@@ -93,6 +95,7 @@ export function SortableTree({
nodeUpdate,
}: SortableTreeProps) {
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
const catQs = catalogTreeQs(catalogType);
const pageBase =
catalogType === "bc" ? "/admin/catalog/builder-club" : "/admin/catalog";
@@ -107,6 +110,9 @@ export function SortableTree({
sortMode,
} = state;
const [duplicateSource, setDuplicateSource] = useState<DuplicateInput | null>(
null,
);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<TreeNodeData[] | null>(
null,
@@ -331,6 +337,7 @@ export function SortableTree({
const handleDeleteRef = useRef<(id: number) => Promise<void>>(async () => {});
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.target as HTMLElement)?.closest('[role="dialog"]')) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
if (e.key === "/") {
@@ -590,67 +597,24 @@ export function SortableTree({
};
handleDeleteRef.current = handleDelete;
const handleDuplicate = async (id: number) => {
if (!canEdit) return;
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`);
if (!res.ok) throw new Error();
const { page } = await res.json();
const result =
catalogType === "bc"
? await createBcPage({
parentId: page.parentId ?? -1,
caption: `${page.caption} (copy)`,
pageLayout: page.pageLayout ?? "default_3x3",
iconImage: page.iconImage ?? 0,
iconColor: page.iconColor ?? 0,
enabled: "0",
visible: page.visible ?? "1",
orderNum: (page.orderNum ?? 0) + 1,
})
: await createCatalogPage({
parentId: page.parentId ?? -1,
caption: `${page.caption} (copy)`,
pageLayout: page.pageLayout ?? "default_3x3",
iconImage: page.iconImage ?? 0,
iconColor: page.iconColor ?? 0,
enabled: "0",
visible: page.visible ?? "1",
minRank: page.minRank ?? 1,
orderNum: (page.orderNum ?? 0) + 1,
});
if (!result.ok) throw new Error(result.error);
const newId = (result.data as Record<string, unknown>).id as number;
if (catalogType === "bc") {
await updateBcPage({
id: newId,
pageHeadline: page.pageHeadline ?? "",
pageTeaser: page.pageTeaser ?? "",
pageText1: page.pageText1,
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
});
} else {
await updateCatalogPage({
id: newId,
pageHeadline: page.pageHeadline ?? "",
pageTeaser: page.pageTeaser ?? "",
pageText1: page.pageText1,
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
});
const handleDuplicate = useCallback(
(id: number) => {
if (state.editorDirty) {
toast.error(t("draftBlocked"));
return;
}
await loadFullTree();
toast.success(`Duplicated "${page.caption}" (items not copied)`);
onSelectPage(newId);
onTreeMutation();
} catch {
toast.error("Failed to duplicate page");
}
};
if (!canEdit || !requestCatalogNavigation()) return;
const node = nodes.get(id);
if (!node) return;
setDuplicateSource({
kind: catalogType,
sourceId: id,
parentId: node.parentId,
name: `${node.caption.slice(0, 121)} (copy)`,
});
},
[state.editorDirty, t, canEdit, nodes, catalogType],
);
const handleOpenFull = (id: number) =>
window.open(`${pageBase}/${id}`, "_blank");
@@ -780,6 +744,7 @@ export function SortableTree({
loadingIds,
handleToggleExpand,
handleSelect,
handleDuplicate,
],
); // eslint-disable-line react-hooks/exhaustive-deps
@@ -792,6 +757,16 @@ export function SortableTree({
style={{ backgroundColor: "var(--admin-surface)" }}
>
{confirmDialog}
{duplicateSource && (
<DuplicateCategoryDialog
source={duplicateSource}
onClose={() => setDuplicateSource(null)}
onComplete={() => {
void loadFullTree();
onTreeMutation();
}}
/>
)}
{/* Header */}
<div className="border-b px-3 py-2 space-y-2 shrink-0">
{subpageParentId !== null ? (
@@ -25,7 +25,6 @@ import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
@@ -251,13 +250,13 @@ export const TreeItemContent = React.memo(
<ContextMenu>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuLabel className="flex items-center gap-2">
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-muted-foreground">
<CatalogIcon iconImage={node.iconImage} size={14} />
{node.caption}
<span className="text-muted-foreground font-normal ml-auto">
#{node.id}
</span>
</ContextMenuLabel>
</div>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onSelect(node.id)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
@@ -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 }));
}
+80 -1
View File
@@ -1944,7 +1944,86 @@
"searchFailed": "Search failed. Try again.",
"searching": "Searching...",
"category": "Category",
"offer": "Offer"
"offer": "Offer",
"duplicate": {
"title": "Duplicate category",
"description": "Copy this category, every descendant and all their offers.",
"name": "New category name",
"destination": "Destination category",
"disabled": "The copied root starts disabled and hidden. Descendants keep their settings; furniture definitions and assets are shared.",
"counts": "{pages} categories and {offers} offers",
"summary": "“{name}” under {destination}",
"cancel": "Cancel",
"working": "Working…",
"confirm": "Confirm duplication",
"preview": "Preview copy",
"saved": "Category and offers duplicated",
"savedPending": "Copy saved; hotel update pending",
"failed": "Duplication failed",
"root": "Catalog root",
"search": "Search categories…",
"empty": "No category found",
"suggested": "Suggested",
"all": "All categories",
"draftBlocked": "Save or discard category edits before duplicating."
}
},
"bulk": {
"previewCount": "{count} of {total} offers will change.",
"success": "Updated {count} offers.",
"selectOffer": "Select offer {name} (#{id})",
"pageEmpty": "No category found.",
"clearSelection": "Clear selection",
"disabledReason": "Save or reset pending edits and wait for current operations before bulk editing.",
"operation": "Operation",
"loadingDestinations": "Loading categories…",
"cancel": "Cancel",
"pagePlaceholder": "Select destination category…",
"noChanges": "The selected values already match. No changes to save.",
"costPoints": "Points price",
"chooseFields": "Check at least one field to change.",
"pageAll": "All categories",
"offer": "Offer",
"costCredits": "Credits",
"invalidPrice": "Enter a valid number. Set and add operations require whole numbers.",
"modes": {
"set": "Set to value",
"add": "Add or subtract",
"percent": "Change by percentage"
},
"fieldHint": "Only checked fields will change.",
"currencies": {
"0": "Duckets",
"5": "Diamonds",
"101": "Snowflakes",
"103": "Hearts",
"104": "Stars",
"105": "Shells"
},
"currency": "Points currency",
"button": "Bulk edit ({count})",
"description": "{count} selected offers. Preview changes before saving.",
"confirm": "Confirm and save {count} offers",
"beforeAfter": "Each cell shows the current value → the new value.",
"requestFailed": "The request failed. Refresh the preview before trying again.",
"priceHint": "Use negative additions or percentages to reduce prices. Percentage results round to the nearest integer; final prices cannot be negative.",
"selectedCount": "{count} offers selected across categories",
"value": "Value",
"unknownCurrency": "Type {type}",
"becomes": "becomes",
"title": "Edit selected offers",
"preview": "Preview changes",
"retryDestinations": "Retry loading categories",
"chooseDestination": "Select a destination category.",
"pageSuggested": "Suggested",
"previewTitle": "Review bulk changes",
"visibilityHint": "Visibility is controlled by the category. Moving offers makes them follow the destination category's visibility.",
"percentage": "Percentage (%)",
"destination": "Destination category",
"pageSearch": "Search categories…",
"back": "Edit changes",
"selectionLimit": "Select at most 500 offers for one operation.",
"refreshing": "Refreshing updated offers…"
}
},
"shop": {
+80 -1
View File
@@ -1863,7 +1863,86 @@
"searchFailed": "Ricerca non riuscita. Riprova.",
"searching": "Ricerca in corso...",
"category": "Categoria",
"offer": "Offerta"
"offer": "Offerta",
"duplicate": {
"title": "Duplica categoria",
"description": "Copia questa categoria, tutte le sottocategorie e le relative offerte.",
"name": "Nome della nuova categoria",
"destination": "Categoria di destinazione",
"disabled": "La radice copiata parte disabilitata e nascosta. Le sottocategorie mantengono le impostazioni; definizioni degli arredi e asset sono condivisi.",
"counts": "{pages} categorie e {offers} offerte",
"summary": "“{name}” sotto {destination}",
"cancel": "Annulla",
"working": "Operazione in corso…",
"confirm": "Conferma duplicazione",
"preview": "Anteprima copia",
"saved": "Categoria e offerte duplicate",
"savedPending": "Copia salvata; aggiornamento hotel in attesa",
"failed": "Duplicazione non riuscita",
"root": "Radice del catalogo",
"search": "Cerca categorie…",
"empty": "Nessuna categoria trovata",
"suggested": "Suggerite",
"all": "Tutte le categorie",
"draftBlocked": "Salva o annulla le modifiche alla categoria prima di duplicarla."
}
},
"bulk": {
"previewCount": "Verranno modificate {count} offerte su {total}.",
"success": "{count} offerte aggiornate.",
"selectOffer": "Seleziona offerta {name} (#{id})",
"pageEmpty": "Nessuna categoria trovata.",
"clearSelection": "Svuota selezione",
"disabledReason": "Salva o annulla le modifiche in sospeso e attendi la fine delle operazioni prima di modificare in blocco.",
"operation": "Operazione",
"loadingDestinations": "Caricamento categorie…",
"cancel": "Annulla",
"pagePlaceholder": "Seleziona la categoria di destinazione…",
"noChanges": "I valori selezionati corrispondono già. Nessuna modifica da salvare.",
"costPoints": "Prezzo in punti",
"chooseFields": "Seleziona almeno un campo da modificare.",
"pageAll": "Tutte le categorie",
"offer": "Offerta",
"costCredits": "Crediti",
"invalidPrice": "Inserisci un numero valido. Le operazioni di impostazione e aggiunta richiedono numeri interi.",
"modes": {
"set": "Imposta valore",
"add": "Aggiungi o sottrai",
"percent": "Varia in percentuale"
},
"fieldHint": "Verranno modificati solo i campi selezionati.",
"currencies": {
"0": "Duckets",
"5": "Diamanti",
"101": "Fiocchi di neve",
"103": "Cuori",
"104": "Stelle",
"105": "Conchiglie"
},
"currency": "Valuta dei punti",
"button": "Modifica in blocco ({count})",
"description": "{count} offerte selezionate. Controlla l'anteprima prima di salvare.",
"confirm": "Conferma e salva {count} offerte",
"beforeAfter": "Ogni cella mostra il valore attuale → il nuovo valore.",
"requestFailed": "Richiesta non riuscita. Aggiorna l'anteprima prima di riprovare.",
"priceHint": "Usa valori o percentuali negativi per ridurre i prezzi. Le percentuali vengono arrotondate all'intero più vicino; i prezzi finali non possono essere negativi.",
"selectedCount": "{count} offerte selezionate tra le categorie",
"value": "Valore",
"unknownCurrency": "Tipo {type}",
"becomes": "diventa",
"title": "Modifica le offerte selezionate",
"preview": "Anteprima modifiche",
"retryDestinations": "Riprova a caricare le categorie",
"chooseDestination": "Seleziona una categoria di destinazione.",
"pageSuggested": "Suggerite",
"previewTitle": "Controlla le modifiche in blocco",
"visibilityHint": "La visibilità dipende dalla categoria. Le offerte spostate seguono la visibilità della categoria di destinazione.",
"percentage": "Percentuale (%)",
"destination": "Categoria di destinazione",
"pageSearch": "Cerca categorie…",
"back": "Rivedi le modifiche",
"selectionLimit": "Seleziona al massimo 500 offerte per operazione.",
"refreshing": "Aggiornamento delle offerte modificate…"
}
},
"shop": {
+80 -1
View File
@@ -1693,7 +1693,86 @@
"searchFailed": "Zoeken mislukt. Probeer opnieuw.",
"searching": "Zoeken...",
"category": "Categorie",
"offer": "Aanbieding"
"offer": "Aanbieding",
"duplicate": {
"title": "Categorie dupliceren",
"description": "Kopieer deze categorie, alle subcategorieën en alle bijbehorende aanbiedingen.",
"name": "Naam van de nieuwe categorie",
"destination": "Doelcategorie",
"disabled": "De gekopieerde hoofdcategorie begint uitgeschakeld en verborgen. Subcategorieën behouden hun instellingen; meubeldefinities en bestanden worden gedeeld.",
"counts": "{pages} categorieën en {offers} aanbiedingen",
"summary": "‘{name}’ onder {destination}",
"cancel": "Annuleren",
"working": "Bezig…",
"confirm": "Dupliceren bevestigen",
"preview": "Kopie bekijken",
"saved": "Categorie en aanbiedingen gedupliceerd",
"savedPending": "Kopie opgeslagen; hotelupdate in afwachting",
"failed": "Dupliceren mislukt",
"root": "Catalogushoofdmap",
"search": "Categorieën zoeken…",
"empty": "Geen categorie gevonden",
"suggested": "Voorgesteld",
"all": "Alle categorieën",
"draftBlocked": "Sla categoriewijzigingen op of annuleer ze voordat je dupliceert."
}
},
"bulk": {
"previewCount": "{count} van {total} aanbiedingen worden gewijzigd.",
"success": "{count} aanbiedingen bijgewerkt.",
"selectOffer": "Selecteer aanbieding {name} (#{id})",
"pageEmpty": "Geen categorie gevonden.",
"clearSelection": "Selectie wissen",
"disabledReason": "Sla openstaande wijzigingen op of herstel ze en wacht tot lopende bewerkingen klaar zijn voordat je in bulk bewerkt.",
"operation": "Bewerking",
"loadingDestinations": "Categorieën laden…",
"cancel": "Annuleren",
"pagePlaceholder": "Selecteer de doelcategorie…",
"noChanges": "De geselecteerde waarden komen al overeen. Geen wijzigingen om op te slaan.",
"costPoints": "Prijs in punten",
"chooseFields": "Vink minstens één veld aan om te wijzigen.",
"pageAll": "Alle categorieën",
"offer": "Aanbieding",
"costCredits": "Credits",
"invalidPrice": "Voer een geldig getal in. Instellen en optellen vereisen gehele getallen.",
"modes": {
"set": "Waarde instellen",
"add": "Optellen of aftrekken",
"percent": "Procentueel wijzigen"
},
"fieldHint": "Alleen aangevinkte velden worden gewijzigd.",
"currencies": {
"0": "Duckets",
"5": "Diamanten",
"101": "Sneeuwvlokken",
"103": "Harten",
"104": "Sterren",
"105": "Schelpen"
},
"currency": "Puntenvaluta",
"button": "Bulk bewerken ({count})",
"description": "{count} aanbiedingen geselecteerd. Bekijk de wijzigingen voordat je opslaat.",
"confirm": "Bevestig en sla {count} aanbiedingen op",
"beforeAfter": "Elke cel toont de huidige waarde → de nieuwe waarde.",
"requestFailed": "Het verzoek is mislukt. Vernieuw het voorbeeld voordat je het opnieuw probeert.",
"priceHint": "Gebruik negatieve waarden of percentages om prijzen te verlagen. Procentuele resultaten worden afgerond op het dichtstbijzijnde gehele getal; eindprijzen mogen niet negatief zijn.",
"selectedCount": "{count} aanbiedingen geselecteerd in categorieën",
"value": "Waarde",
"unknownCurrency": "Type {type}",
"becomes": "wordt",
"title": "Geselecteerde aanbiedingen bewerken",
"preview": "Wijzigingen bekijken",
"retryDestinations": "Categorieën opnieuw laden",
"chooseDestination": "Selecteer een doelcategorie.",
"pageSuggested": "Voorgesteld",
"previewTitle": "Controleer de wijzigingen",
"visibilityHint": "De categorie bepaalt de zichtbaarheid. Verplaatste aanbiedingen volgen de zichtbaarheid van de doelcategorie.",
"percentage": "Percentage (%)",
"destination": "Doelcategorie",
"pageSearch": "Categorieën zoeken…",
"back": "Wijzigingen aanpassen",
"selectionLimit": "Selecteer maximaal 500 aanbiedingen per bewerking.",
"refreshing": "Bijgewerkte aanbiedingen vernieuwen…"
}
},
"shop": {