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

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

No files matched your search

@@ -0,0 +1,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, X,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -80,6 +81,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 { useServerAction } from "@/hooks/use-server-action";
import { getSuggestions, recordMove } from "@/lib/move-suggestions"; import { getSuggestions, recordMove } from "@/lib/move-suggestions";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -167,6 +169,10 @@ export function CatalogItemsTable({
allPages, allPages,
}: CatalogItemsTableProps) { }: CatalogItemsTableProps) {
const router = useRouter(); const router = useRouter();
const bulk = useTranslations("pages.admin.catalog.bulk");
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
CatalogItemData[] | null
>(null);
const { run, isPending } = useServerAction(); const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog(); const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
@@ -294,7 +300,26 @@ export function CatalogItemsTable({
return itemOrder.some((id, idx) => id !== originalOrder[idx]); return itemOrder.some((id, idx) => id !== originalOrder[idx]);
}, [items, itemOrder]); }, [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(() => { useEffect(() => {
onDirtyChange?.(hasUnsavedChanges); onDirtyChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onDirtyChange]); }, [hasUnsavedChanges, onDirtyChange]);
@@ -904,7 +929,19 @@ export function CatalogItemsTable({
return ( return (
<> <>
{confirmDialog} {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"> <CardHeader className="space-y-3">
<div className="flex flex-row items-center justify-between gap-4"> <div className="flex flex-row items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
@@ -958,6 +995,21 @@ export function CatalogItemsTable({
<> <>
{selected.size > 0 && ( {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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -151,6 +151,8 @@ export function MovePageCombobox({
pages, pages,
currentPageId, currentPageId,
suggestedPageIds, suggestedPageIds,
disabled = false,
labels,
}: { }: {
value: number | null; value: number | null;
onChange: (id: number | null) => void; onChange: (id: number | null) => void;
@@ -158,6 +160,14 @@ export function MovePageCombobox({
currentPageId: number; currentPageId: number;
/** Page IDs to show first as suggestions (from move-suggestions.ts) */ /** Page IDs to show first as suggestions (from move-suggestions.ts) */
suggestedPageIds?: number[]; suggestedPageIds?: number[];
disabled?: boolean;
labels?: {
placeholder: string;
search: string;
empty: string;
suggested: string;
all: string;
};
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -194,6 +204,7 @@ export function MovePageCombobox({
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
disabled={disabled}
aria-expanded={open} aria-expanded={open}
className="w-full justify-between font-normal" className="w-full justify-between font-normal"
> >
@@ -205,7 +216,9 @@ export function MovePageCombobox({
{selectedPage.caption} {selectedPage.caption}
</span> </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" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
@@ -215,13 +228,13 @@ export function MovePageCombobox({
align="start" align="start"
> >
<Command> <Command>
<CommandInput placeholder="Search pages..." /> <CommandInput placeholder={labels?.search ?? "Search pages..."} />
<CommandList> <CommandList>
<CommandEmpty>No page found.</CommandEmpty> <CommandEmpty>{labels?.empty ?? "No page found."}</CommandEmpty>
{/* Suggested pages */} {/* Suggested pages */}
{suggested.length > 0 && ( {suggested.length > 0 && (
<CommandGroup heading="Suggested"> <CommandGroup heading={labels?.suggested ?? "Suggested"}>
{suggested.map((page) => ( {suggested.map((page) => (
<CommandItem <CommandItem
key={`s-${page.id}`} key={`s-${page.id}`}
@@ -249,7 +262,9 @@ export function MovePageCombobox({
{/* All pages */} {/* All pages */}
<CommandGroup <CommandGroup
heading={suggested.length > 0 ? "All pages" : undefined} heading={
suggested.length > 0 ? (labels?.all ?? "All pages") : undefined
}
> >
{remaining.map((page) => ( {remaining.map((page) => (
<CommandItem <CommandItem
+2 -2
View File
@@ -263,7 +263,7 @@ export default async function CatalogPagesPage({
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}> <AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
<div className="mb-4 space-y-3"> <div className="mb-4 space-y-3">
<CatalogSyncStatus canEdit={canEdit} /> <CatalogSyncStatus canEdit={canEdit} />
<CatalogSearch catalogType={catalogType} /> <CatalogSearch catalogType={catalogType} canEdit={canEdit} />
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <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")}> <AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
<div className="mb-4 space-y-3"> <div className="mb-4 space-y-3">
<CatalogSyncStatus canEdit={canEdit} /> <CatalogSyncStatus canEdit={canEdit} />
<CatalogSearch catalogType={catalogType} /> <CatalogSearch catalogType={catalogType} canEdit={canEdit} />
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -116,13 +116,13 @@ export function CatalogManagerDialog({
</Button> </Button>
{mounted && open {mounted && open
? createPortal( ? createPortal(
<div className="fixed inset-0 z-[200]"> <div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-foreground/70" /> <div className="absolute inset-0 bg-foreground/70" />
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="visual-catalog-manager-title" 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)" }} style={{ backgroundColor: "var(--admin-canvas)" }}
> >
<CatalogManagerProvider <CatalogManagerProvider
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -25,7 +25,6 @@ import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu"; } from "@/components/ui/context-menu";
@@ -251,13 +250,13 @@ export const TreeItemContent = React.memo(
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger> <ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent className="w-52"> <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} /> <CatalogIcon iconImage={node.iconImage} size={14} />
{node.caption} {node.caption}
<span className="text-muted-foreground font-normal ml-auto"> <span className="text-muted-foreground font-normal ml-auto">
#{node.id} #{node.id}
</span> </span>
</ContextMenuLabel> </div>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem onClick={() => onSelect(node.id)}> <ContextMenuItem onClick={() => onSelect(node.id)}>
<Pencil className="mr-2 h-3.5 w-3.5" /> <Pencil className="mr-2 h-3.5 w-3.5" />
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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();
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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" },
}));
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+80 -1
View File
@@ -1944,7 +1944,86 @@
"searchFailed": "Search failed. Try again.", "searchFailed": "Search failed. Try again.",
"searching": "Searching...", "searching": "Searching...",
"category": "Category", "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": { "shop": {
+80 -1
View File
@@ -1863,7 +1863,86 @@
"searchFailed": "Ricerca non riuscita. Riprova.", "searchFailed": "Ricerca non riuscita. Riprova.",
"searching": "Ricerca in corso...", "searching": "Ricerca in corso...",
"category": "Categoria", "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": { "shop": {
+80 -1
View File
@@ -1693,7 +1693,86 @@
"searchFailed": "Zoeken mislukt. Probeer opnieuw.", "searchFailed": "Zoeken mislukt. Probeer opnieuw.",
"searching": "Zoeken...", "searching": "Zoeken...",
"category": "Categorie", "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": { "shop": {