refactor(catalog): unify commands and embed guarded catalog workspace
This commit is contained in:
1 parent
16f35568f5
commit
193b6686a9
57 files changed
+3268
-728
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
||||
# Catalog refactor implementation plan
|
||||
|
||||
> For agentic workers: use superpowers:subagent-driven-development for independent changes and review each deliverable.
|
||||
|
||||
Goal: deliver the approved progressive catalog refactor while preserving current features.
|
||||
Architecture: shared domain validation and transactional commands behind existing action contracts; unified editor reuses current views and tools.
|
||||
Stack: Next, React, Drizzle/MariaDB, Zod, Vitest, Playwright; no added dependencies.
|
||||
Spec: docs/CATALOG_REFACTOR_PLAN.md
|
||||
Constraints: preserve normal/BC differences, direct save, real icons, permissions, existing routes, no favorites. No production data mutations for testing.
|
||||
|
||||
- [x] Characterize compatibility and command rules with tests; capture baseline source map.
|
||||
- [x] Domain hierarchy/reorder validation and transactional page commands (normal/BC), deterministic locking, common updates/deletion.
|
||||
- [x] Offer update/reorder atomicity and safe numeric normalization, shared mutation contracts.
|
||||
- [x] Editor cancellation/dirty state protection (agent catalog_editor_state), review and browser verification.
|
||||
- [x] Promote editor as an in-page view with reversible classic view, URL context, responsive panels and coherent tool access.
|
||||
- [ ] Sync outcomes/diagnostics/history integration; safe retry and explicit limits.
|
||||
- [ ] Verify unit tests, database integration if runtime available, browser, typecheck, lint/i18n, full suite; review final scope and remaining environment-only checks.
|
||||
|
||||
Execution notes: changes are progressive, no destructive migration. Existing UI adapters stay until functional parity is tested. Whole-program completion must not be claimed from the first deliverable.
|
||||
|
||||
## First implementation delivery (2026-09-06)
|
||||
|
||||
Completed: shared normal/BC page and offer commands; transactional page reorder/delete/move and offer create/update/reorder; hierarchy validation and optimistic page-save checks; embedded default manager with classic views retained; request cancellation, editor unsaved-change guards and URL selection; bounded global category/offer/furniture search; separate hotel/Git status and hotel retry; export-finalization failures no longer mask committed server actions. Existing permissions and direct-save behavior retained. No added dependency or DB migration.
|
||||
|
||||
Verification: full unit suite 1308 passed / 5 skipped before final retry-classification regression; final focused catalog suite 96 passed. TypeScript, i18n static validation, Knip and Biome on all 54 changed source files pass. Browser fixtures cover editor loading races, retry failures, unsaved changes, URL history, read-only, normal/BC, search and 375px layout. Database calls and mutations are mocked in those fixtures. Full-repository formatter check reports pre-existing Windows CRLF formatting differences; unrelated files were not reformatted.
|
||||
|
||||
Remaining roadmap: per-operation durable dispatch/outbox, category/offer snapshot history and conflict-aware undo, contextual maintenance diagnosis, pagination/performance measurement against representative real data, further decomposition of retained legacy views. Existing coarse audit/export infrastructure remains; the new status file is not a durable outbox and RCON socket success does not prove client application. External furni importer mutation internals remain outside shared editor commands.
|
||||
|
||||
Environment checks still required: real MariaDB locking/rollback integration, staff-authenticated end-to-end smoke test and pipeline/deployment health. No production mutations performed; no production deployment claimed.
|
||||
+60
-118
File diff suppressed because it is too large.
Load diff
+34
-112
File diff suppressed because it is too large.
Load diff
+51
-48
File diff suppressed because it is too large.
Load diff
@@ -144,6 +144,8 @@ function SortableRow({
|
||||
}
|
||||
|
||||
interface CatalogItemsTableProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onRefresh?: () => void;
|
||||
items: CatalogItemData[];
|
||||
pageId: number;
|
||||
pageLayout: string;
|
||||
@@ -154,6 +156,8 @@ interface CatalogItemsTableProps {
|
||||
}
|
||||
|
||||
export function CatalogItemsTable({
|
||||
onDirtyChange,
|
||||
onRefresh,
|
||||
items,
|
||||
pageId,
|
||||
pageLayout,
|
||||
@@ -291,6 +295,9 @@ export function CatalogItemsTable({
|
||||
}, [items, itemOrder]);
|
||||
|
||||
const hasUnsavedChanges = modifiedItems.length > 0 || hasOrderChanges;
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasUnsavedChanges);
|
||||
}, [hasUnsavedChanges, onDirtyChange]);
|
||||
|
||||
// Warn before navigating away with unsaved changes
|
||||
useEffect(() => {
|
||||
@@ -383,7 +390,8 @@ export function CatalogItemsTable({
|
||||
errorMessage: `Failed to save item #${item.id}.`,
|
||||
onSuccess: () => {
|
||||
setSavingItemId(null);
|
||||
router.refresh();
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
},
|
||||
onError: () => setSavingItemId(null),
|
||||
},
|
||||
@@ -426,6 +434,10 @@ export function CatalogItemsTable({
|
||||
else failedCount++;
|
||||
}
|
||||
|
||||
if (savedCount > 0) {
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
return {
|
||||
ok: false as const,
|
||||
@@ -456,7 +468,8 @@ export function CatalogItemsTable({
|
||||
errorMessage: "Failed to delete items.",
|
||||
onSuccess: () => {
|
||||
setSelected(new Set());
|
||||
router.refresh();
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -490,7 +503,8 @@ export function CatalogItemsTable({
|
||||
setSelected(new Set());
|
||||
setMoveOpen(false);
|
||||
setMoveTargetPageId(null);
|
||||
router.refresh();
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -590,7 +604,8 @@ export function CatalogItemsTable({
|
||||
onSuccess: () => {
|
||||
setAddOpen(false);
|
||||
setNewItem({ ...DEFAULT_NEW_ITEM });
|
||||
router.refresh();
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -641,7 +656,8 @@ export function CatalogItemsTable({
|
||||
onSuccess: () => {
|
||||
setMoveOneId(null);
|
||||
setMoveOneTarget(null);
|
||||
router.refresh();
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -711,7 +727,8 @@ export function CatalogItemsTable({
|
||||
onSuccess: () => {
|
||||
setEditingItem(null);
|
||||
setEditingBase(null);
|
||||
router.refresh();
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -136,10 +136,18 @@ export function CatalogPageForm({
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
|
||||
successMessage: "Page updated and catalog synced.",
|
||||
errorMessage: "Failed to update.",
|
||||
});
|
||||
run(
|
||||
() =>
|
||||
updateCatalogPage({
|
||||
id: catalogPage.id,
|
||||
...form,
|
||||
expected: catalogPage as unknown as Record<string, unknown>,
|
||||
}),
|
||||
{
|
||||
successMessage: "Page saved.",
|
||||
errorMessage: "Failed to update.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
|
||||
@@ -11,3 +11,11 @@ export const CatalogManagerDialog = dynamic(
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export const CatalogManagerWorkspace = dynamic(
|
||||
() =>
|
||||
import("@/components/admin/catalog-manager/catalog-manager-dialog").then(
|
||||
(m) => ({ default: m.CatalogManagerWorkspace }),
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { FolderTree, HardHat, List, Package } from "lucide-react";
|
||||
import { FolderTree, HardHat, LayoutGrid, List, Package } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CatalogViewToggleProps {
|
||||
@@ -15,11 +17,13 @@ export function CatalogViewToggle({
|
||||
catalogType = "normal",
|
||||
}: CatalogViewToggleProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("pages.admin.catalog.workspace");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
function setView(view: string) {
|
||||
if (view === currentView || !requestCatalogNavigation()) return;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (view === "tree") {
|
||||
if (view === "manager") {
|
||||
params.delete("view");
|
||||
} else {
|
||||
params.set("view", view);
|
||||
@@ -28,18 +32,21 @@ export function CatalogViewToggle({
|
||||
}
|
||||
|
||||
function setCatalog(type: string) {
|
||||
if (type === catalogType || !requestCatalogNavigation()) return;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (type === "normal") {
|
||||
params.delete("catalog");
|
||||
} else {
|
||||
params.set("catalog", type);
|
||||
}
|
||||
params.delete("view"); // reset to tree when switching
|
||||
params.delete("view");
|
||||
params.delete("root");
|
||||
params.delete("category");
|
||||
router.push(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Catalog type selector */}
|
||||
<div className="flex items-center rounded-md border bg-card">
|
||||
<Button
|
||||
@@ -52,7 +59,7 @@ export function CatalogViewToggle({
|
||||
onClick={() => setCatalog("normal")}
|
||||
>
|
||||
<Package className="mr-1.5 h-4 w-4" />
|
||||
Catalog
|
||||
{t("normal")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -69,6 +76,14 @@ export function CatalogViewToggle({
|
||||
</div>
|
||||
|
||||
{/* View mode selector */}
|
||||
<Button
|
||||
variant={currentView === "manager" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setView("manager")}
|
||||
>
|
||||
<LayoutGrid className="mr-1.5 h-4 w-4" />
|
||||
{t("editor")}
|
||||
</Button>
|
||||
<div className="flex items-center rounded-md border bg-card">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -80,7 +95,7 @@ export function CatalogViewToggle({
|
||||
onClick={() => setView("tree")}
|
||||
>
|
||||
<FolderTree className="mr-1.5 h-4 w-4" />
|
||||
Tree
|
||||
{t("tree")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -92,7 +107,7 @@ export function CatalogViewToggle({
|
||||
onClick={() => setView("table")}
|
||||
>
|
||||
<List className="mr-1.5 h-4 w-4" />
|
||||
Table
|
||||
{t("table")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,49 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs"));
|
||||
const search = vi.hoisted(() => vi.fn());
|
||||
const guard = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/features/catalog/server/search", () => ({ searchCatalog: search }));
|
||||
vi.mock("@/features/catalog/server/errors", () => ({
|
||||
catalogFailure: () => ({ message: "Invalid search", status: 400 }),
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (
|
||||
options: unknown,
|
||||
handler: (request: NextRequest) => Promise<Response>,
|
||||
) => {
|
||||
guard(options);
|
||||
return handler;
|
||||
},
|
||||
}));
|
||||
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { GET } from "./route";
|
||||
|
||||
beforeEach(() => {
|
||||
search.mockReset();
|
||||
});
|
||||
describe("catalog search route", () => {
|
||||
it("registers the catalog read permission", () => {
|
||||
expect(guard).toHaveBeenCalledWith({ permission: PERMS.CATALOG_VIEW });
|
||||
});
|
||||
it("forwards normal or BC without widening scope and returns results", async () => {
|
||||
search.mockResolvedValue([]);
|
||||
const response = await GET(
|
||||
new NextRequest(
|
||||
"https://cms.test/api/admin/catalog/search?catalog=bc&q=chair",
|
||||
),
|
||||
);
|
||||
expect(search).toHaveBeenCalledWith("bc", "chair");
|
||||
expect(await response.json()).toEqual({ ok: true, results: [] });
|
||||
});
|
||||
it("returns a validation error without a success payload", async () => {
|
||||
search.mockRejectedValue(Error("Invalid search"));
|
||||
const response = await GET(
|
||||
new NextRequest("https://cms.test/api/admin/catalog/search?q=a"),
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ error: "Invalid search" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import { searchCatalog } from "@/features/catalog/server/search";
|
||||
import { apiError, apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.CATALOG_VIEW },
|
||||
async (request) => {
|
||||
const params = request.nextUrl.searchParams;
|
||||
try {
|
||||
return apiOk({
|
||||
results: await searchCatalog(
|
||||
params.get("catalog") ?? "normal",
|
||||
params.get("q") ?? "",
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
const failure = catalogFailure(error);
|
||||
return apiError(failure.message, failure.status);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
readCatalogHotelStatus,
|
||||
sendCatalogUpdate,
|
||||
} from "@/features/catalog/server/sync-status";
|
||||
import { apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { catalogExportStatus } from "@/lib/services/catalog-git-queue";
|
||||
export const GET = withAdmin({ permission: PERMS.CATALOG_VIEW }, async () => {
|
||||
const [hotel, git] = await Promise.all([
|
||||
readCatalogHotelStatus(),
|
||||
catalogExportStatus(),
|
||||
]);
|
||||
return apiOk({ hotel, git });
|
||||
});
|
||||
export const POST = withAdmin({ permission: PERMS.CATALOG_EDIT }, async () =>
|
||||
apiOk({ hotel: await sendCatalogUpdate() }),
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,7 +4,11 @@ import { ChevronRight, Home } from "lucide-react";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { useCatalogManager } from "./use-catalog-store";
|
||||
|
||||
export function BreadcrumbBar() {
|
||||
export function BreadcrumbBar({
|
||||
onSelectPage,
|
||||
}: {
|
||||
onSelectPage: (id: number) => void;
|
||||
}) {
|
||||
const { state, dispatch, catalogType } = useCatalogManager();
|
||||
const { selectedPageId, ancestors } = state;
|
||||
const catQs = catalogType === "bc" ? "&catalog=bc" : "";
|
||||
@@ -40,7 +44,7 @@ export function BreadcrumbBar() {
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "SELECT_PAGE", id: a.id })}
|
||||
onClick={() => onSelectPage(a.id)}
|
||||
className="shrink-0 hover:text-foreground transition-colors truncate max-w-40"
|
||||
>
|
||||
{a.caption}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -22,12 +22,12 @@ import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteTreePage,
|
||||
reorderTreePage,
|
||||
reorderCatalogPages,
|
||||
toggleCatalogPage,
|
||||
} from "@/actions/catalog";
|
||||
import {
|
||||
deleteBcTreePage,
|
||||
reorderBcTreePage,
|
||||
reorderBcCatalogPages,
|
||||
toggleBcPage,
|
||||
} from "@/actions/catalog-bc";
|
||||
import { CatalogIcon } from "@/components/admin/catalog-tree";
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCatalogManager } from "./use-catalog-store";
|
||||
|
||||
@@ -54,6 +55,7 @@ interface RootTabsProps {
|
||||
onEditTab: (id: number) => void | Promise<void>;
|
||||
onCreateRoot: () => void;
|
||||
loading?: boolean;
|
||||
onMutation?: () => void;
|
||||
}
|
||||
|
||||
export function RootTabs({
|
||||
@@ -63,6 +65,7 @@ export function RootTabs({
|
||||
onEditTab,
|
||||
onCreateRoot,
|
||||
loading,
|
||||
onMutation,
|
||||
}: RootTabsProps) {
|
||||
const { catalogType, canEdit } = useCatalogManager();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
@@ -85,26 +88,26 @@ export function RootTabs({
|
||||
const newIndex = tabs.findIndex((t) => t.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
if (!canEdit) return;
|
||||
if (!canEdit || !requestCatalogNavigation()) return;
|
||||
const newOrder = arrayMove(tabs, oldIndex, newIndex);
|
||||
|
||||
// Batch reorder
|
||||
try {
|
||||
const reorder =
|
||||
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
|
||||
const results = await Promise.allSettled(
|
||||
newOrder.map((tab, idx) =>
|
||||
reorder({ pageId: tab.id, newOrderNum: idx + 1 }),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
if (failed) toast.error(`${failed} reorder(s) failed`);
|
||||
else toast.success("Tabs reordered");
|
||||
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
|
||||
const result = await reorder({
|
||||
parentId: -1,
|
||||
ids: newOrder.map((tab) => tab.id),
|
||||
expectedIds: tabs.map((tab) => tab.id),
|
||||
});
|
||||
if (!result.ok) throw Error("Failed to reorder tabs");
|
||||
toast.success("Tabs reordered");
|
||||
onMutation?.();
|
||||
} catch {
|
||||
toast.error("Failed to reorder tabs");
|
||||
}
|
||||
},
|
||||
[tabs, canEdit, catalogType],
|
||||
[tabs, canEdit, catalogType, onMutation],
|
||||
);
|
||||
|
||||
const handleToggle = async (tab: RootTab) => {
|
||||
@@ -118,6 +121,7 @@ export function RootTabs({
|
||||
toast.success(
|
||||
`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`,
|
||||
);
|
||||
onMutation?.();
|
||||
} catch {
|
||||
toast.error("Failed to toggle");
|
||||
}
|
||||
@@ -138,6 +142,7 @@ export function RootTabs({
|
||||
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
|
||||
}
|
||||
toast.success(`"${tab.caption}" deleted`);
|
||||
onMutation?.();
|
||||
} catch {
|
||||
toast.error("Failed to delete");
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { toast } from "sonner";
|
||||
import {
|
||||
createCatalogPage,
|
||||
deleteTreePage,
|
||||
reorderCatalogPages,
|
||||
reorderTreePage,
|
||||
toggleCatalogPage,
|
||||
updateCatalogPage,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
import {
|
||||
createBcPage,
|
||||
deleteBcTreePage,
|
||||
reorderBcCatalogPages,
|
||||
reorderBcTreePage,
|
||||
toggleBcPage,
|
||||
updateBcPage,
|
||||
@@ -113,7 +115,6 @@ export function SortableTree({
|
||||
const [subpageName, setSubpageName] = useState("");
|
||||
const [creatingSubpage, setCreatingSubpage] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
const [treeLoading, setTreeLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<FilterMode>("all");
|
||||
@@ -189,29 +190,32 @@ export function SortableTree({
|
||||
void loadTabTree();
|
||||
}, [activeTabId, loadTabTree, dispatch]);
|
||||
|
||||
// Debounced search
|
||||
// Ignore stale search completions when the query or catalog changes.
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||
searchTimeout.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
setSearchResults(null);
|
||||
setSearching(false);
|
||||
if (!searchQuery.trim()) return;
|
||||
const timeout = setTimeout(async () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catQs}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSearchResults(data.pages || []);
|
||||
}
|
||||
if (!res.ok) throw new Error("Search failed");
|
||||
const data = await res.json();
|
||||
if (!controller.signal.aborted) setSearchResults(data.pages || []);
|
||||
} catch {
|
||||
if (!controller.signal.aborted)
|
||||
toast.error("Failed to search catalog pages");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
if (!controller.signal.aborted) setSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => {
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||
controller.abort();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [searchQuery, catQs]);
|
||||
|
||||
@@ -423,7 +427,8 @@ export function SortableTree({
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
if (!over || active.id === over.id || activeTabId === null) return;
|
||||
if (!canEdit || !over || active.id === over.id || activeTabId === null)
|
||||
return;
|
||||
|
||||
const activeNode = nodes.get(active.id as number);
|
||||
const overNode = nodes.get(over.id as number);
|
||||
@@ -446,16 +451,15 @@ export function SortableTree({
|
||||
|
||||
if (!canEdit) return;
|
||||
const reorder =
|
||||
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
|
||||
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
newOrder.map((id, idx) =>
|
||||
reorder({ pageId: id, newOrderNum: idx + 1 }),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
if (failed > 0) toast.error(`${failed} reorder(s) failed`);
|
||||
else toast.success("Reordered");
|
||||
const result = await reorder({
|
||||
parentId,
|
||||
ids: newOrder,
|
||||
expectedIds: siblings,
|
||||
});
|
||||
if (!result.ok) throw Error("Failed to reorder");
|
||||
toast.success("Reordered");
|
||||
onTreeMutation();
|
||||
} catch {
|
||||
toast.error("Failed to reorder");
|
||||
|
||||
@@ -317,6 +317,7 @@ export const TreeItemContent = React.memo(
|
||||
},
|
||||
(prev, next) => {
|
||||
return (
|
||||
prev.onSelect === next.onSelect &&
|
||||
prev.node.id === next.node.id &&
|
||||
prev.node.caption === next.node.caption &&
|
||||
prev.node.enabled === next.node.enabled &&
|
||||
|
||||
@@ -59,6 +59,7 @@ const initialState: CatalogManagerState = {
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
|
||||
export type CatalogAction =
|
||||
| { type: "RESTORE_SELECTION"; rootId: number | null; pageId: number | null }
|
||||
| { type: "SET_ROOT_PAGES"; pages: RootTab[] }
|
||||
| { type: "SET_LOADING_ROOT"; loading: boolean }
|
||||
| { type: "SET_ACTIVE_TAB"; id: number | null }
|
||||
@@ -93,14 +94,33 @@ function reducer(
|
||||
action: CatalogAction,
|
||||
): CatalogManagerState {
|
||||
switch (action.type) {
|
||||
case "RESTORE_SELECTION":
|
||||
return {
|
||||
...state,
|
||||
activeTabId:
|
||||
action.rootId ?? state.rootPages[0]?.id ?? state.activeTabId,
|
||||
selectedPageId: action.pageId,
|
||||
focusedId: action.pageId,
|
||||
editorDirty: false,
|
||||
multiSelectedIds: new Set(),
|
||||
ancestors: [],
|
||||
};
|
||||
case "SET_ROOT_PAGES":
|
||||
return { ...state, rootPages: action.pages, loadingRoot: false };
|
||||
return {
|
||||
...state,
|
||||
rootPages: action.pages,
|
||||
loadingRoot: false,
|
||||
activeTabId: action.pages.some((page) => page.id === state.activeTabId)
|
||||
? state.activeTabId
|
||||
: (action.pages[0]?.id ?? null),
|
||||
};
|
||||
case "SET_LOADING_ROOT":
|
||||
return { ...state, loadingRoot: action.loading };
|
||||
case "SET_ACTIVE_TAB":
|
||||
return {
|
||||
...state,
|
||||
activeTabId: action.id,
|
||||
editorDirty: false,
|
||||
selectedPageId: null,
|
||||
multiSelectedIds: new Set(),
|
||||
focusedId: null,
|
||||
@@ -110,6 +130,7 @@ function reducer(
|
||||
return {
|
||||
...state,
|
||||
selectedPageId: action.id,
|
||||
editorDirty: false,
|
||||
multiSelectedIds: new Set(),
|
||||
focusedId: action.id,
|
||||
};
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fixEverythingAction } from "@/actions/furni-maintenance";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* One-click "fix everything" for the furniture tables. Runs sprite fix, catalog
|
||||
* offer fix, FurnitureData reconciliation, dedup and ID alignment in order, all
|
||||
* inside a single DB transaction on the server. Heavy — guarded by a confirm.
|
||||
*/
|
||||
export function CatalogFixAllButton() {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function run() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Alle meubel-tabellen repareren (sprite-id, offer_id, dubbelen, IDs naar FurnitureData)?\nDit is een zware, niet-omkeerbare actie. Zorg dat er een backup is.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fixEverythingAction();
|
||||
if (res.ok) {
|
||||
toast.success("Alle reparaties uitgevoerd");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.error ?? "Mislukt");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void run()}
|
||||
title="Alle meubel-tabellen repareren"
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-4 w-4" />
|
||||
{busy ? "Bezig…" : "Fix alles"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
readCatalogSelection,
|
||||
writeCatalogSelection,
|
||||
} from "./catalog-selection-url";
|
||||
|
||||
describe("catalog selection URL", () => {
|
||||
it("restores category context without disturbing catalog, search or pagination", () => {
|
||||
const url = new URL(
|
||||
"https://cms.test/admin/catalog?catalog=bc&search=chair&page=4",
|
||||
);
|
||||
const selected = writeCatalogSelection(url, { rootId: 8, pageId: 12 });
|
||||
expect(readCatalogSelection(selected)).toEqual({ rootId: 8, pageId: 12 });
|
||||
expect(selected.searchParams.get("catalog")).toBe("bc");
|
||||
expect(selected.searchParams.get("search")).toBe("chair");
|
||||
expect(selected.searchParams.get("page")).toBe("4");
|
||||
expect(url.searchParams.has("category")).toBe(false);
|
||||
expect(
|
||||
writeCatalogSelection(selected, {
|
||||
rootId: 8,
|
||||
pageId: null,
|
||||
}).searchParams.has("category"),
|
||||
).toBe(false);
|
||||
});
|
||||
it.each(["-1", "0", "1.5", "1abc", "Infinity", "9007199254740992"])(
|
||||
"rejects invalid identifiers: %s",
|
||||
(id) => {
|
||||
expect(
|
||||
readCatalogSelection(
|
||||
new URL(`https://cms.test/?root=${id}&category=${id}`),
|
||||
),
|
||||
).toEqual({ rootId: null, pageId: null });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface CatalogSelection {
|
||||
rootId: number | null;
|
||||
pageId: number | null;
|
||||
}
|
||||
function positiveId(value: string | null) {
|
||||
if (!value || !/^\d+$/.test(value)) return null;
|
||||
const id = Number(value);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
export function readCatalogSelection(url: URL): CatalogSelection {
|
||||
return {
|
||||
rootId: positiveId(url.searchParams.get("root")),
|
||||
pageId: positiveId(url.searchParams.get("category")),
|
||||
};
|
||||
}
|
||||
export function writeCatalogSelection(
|
||||
url: URL,
|
||||
selection: CatalogSelection,
|
||||
): URL {
|
||||
const next = new URL(url);
|
||||
for (const [key, value] of [
|
||||
["root", selection.rootId],
|
||||
["category", selection.pageId],
|
||||
] as const) {
|
||||
if (value === null) next.searchParams.delete(key);
|
||||
else next.searchParams.set(key, String(value));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLatestRequest } from "./latest-request";
|
||||
|
||||
describe("catalog request ownership", () => {
|
||||
it("keeps the latest category when an earlier transport completes last", async () => {
|
||||
const requests = createLatestRequest();
|
||||
let displayed = "";
|
||||
let finishOld!: (value: string) => void;
|
||||
const oldResponse = new Promise<string>((resolve) => {
|
||||
finishOld = resolve;
|
||||
});
|
||||
const oldRequest = requests.start();
|
||||
const oldWork = oldResponse.then((value) => {
|
||||
if (oldRequest.isCurrent()) displayed = value;
|
||||
});
|
||||
const current = requests.start();
|
||||
await Promise.resolve("category B").then((value) => {
|
||||
if (current.isCurrent()) displayed = value;
|
||||
});
|
||||
finishOld("category A");
|
||||
await oldWork;
|
||||
expect(oldRequest.signal.aborted).toBe(true);
|
||||
expect(displayed).toBe("category B");
|
||||
});
|
||||
it("ignores late save/read completion after editor unmount", () => {
|
||||
const requests = createLatestRequest();
|
||||
const pending = requests.start();
|
||||
requests.cancel();
|
||||
expect(pending.signal.aborted).toBe(true);
|
||||
expect(pending.isCurrent()).toBe(false);
|
||||
});
|
||||
it("allows a fresh request after effect cleanup and isolates page/items channels", () => {
|
||||
const pages = createLatestRequest();
|
||||
const items = createLatestRequest();
|
||||
const page = pages.start();
|
||||
const firstItems = items.start();
|
||||
items.cancel();
|
||||
const latestItems = items.start();
|
||||
expect(page.isCurrent()).toBe(true);
|
||||
expect(firstItems.isCurrent()).toBe(false);
|
||||
expect(latestItems.isCurrent()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Cancels replaced requests and rejects late completions, even if transport ignores abort. */
|
||||
export function createLatestRequest() {
|
||||
let current: AbortController | null = null;
|
||||
return {
|
||||
start() {
|
||||
current?.abort();
|
||||
const controller = new AbortController();
|
||||
current = controller;
|
||||
return {
|
||||
signal: controller.signal,
|
||||
isCurrent: () => current === controller && !controller.signal.aborted,
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
current?.abort();
|
||||
current = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createLatestRequest } from "./latest-request";
|
||||
export function useLatestRequest() {
|
||||
const [requests] = useState(createLatestRequest);
|
||||
useEffect(() => () => requests.cancel(), [requests]);
|
||||
return requests;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
|
||||
interface Status {
|
||||
hotel: { sent: boolean; checkedAt: string; reference?: string } | null;
|
||||
git: {
|
||||
enabled: boolean;
|
||||
pending: number;
|
||||
active: number;
|
||||
error?: string;
|
||||
commit?: string;
|
||||
};
|
||||
}
|
||||
export function CatalogSyncStatus({ canEdit }: { canEdit: boolean }) {
|
||||
const t = useTranslations("pages.admin.catalog.workspace");
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/catalog/status", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw Error();
|
||||
setStatus(await res.json());
|
||||
setError(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
}, []);
|
||||
useVisiblePolling(refresh, 15000);
|
||||
async function retry() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/catalog/status", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw Error();
|
||||
await refresh();
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border border-[var(--admin-border)] p-3 text-xs"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span>
|
||||
{t("hotel")}:{" "}
|
||||
{status?.hotel
|
||||
? t(status.hotel.sent ? "sent" : "sendFailed")
|
||||
: t("unknown")}
|
||||
</span>
|
||||
<span>
|
||||
Git:{" "}
|
||||
{status
|
||||
? t(
|
||||
!status.git.enabled
|
||||
? "disabled"
|
||||
: status.git.error
|
||||
? "exportFailed"
|
||||
: status.git.pending || status.git.active
|
||||
? "queued"
|
||||
: status.git.commit
|
||||
? "exported"
|
||||
: "unknown",
|
||||
)
|
||||
: t("unknown")}
|
||||
</span>
|
||||
{status?.hotel?.reference && <span>{status.hotel.reference}</span>}
|
||||
{canEdit && status?.hotel?.sent === false && (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={retry}>
|
||||
{t("retryHotel")}
|
||||
</Button>
|
||||
)}
|
||||
{error && (
|
||||
<button type="button" onClick={() => void refresh()}>
|
||||
{t("retryStatus")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertParent,
|
||||
collectSubtree,
|
||||
validateSiblingOrder,
|
||||
} from "./hierarchy";
|
||||
|
||||
const pages = [
|
||||
{ id: 1, parentId: -1, orderNum: 0 },
|
||||
{ id: 2, parentId: 1, orderNum: 1 },
|
||||
{ id: 3, parentId: 1, orderNum: 2 },
|
||||
{ id: 4, parentId: 2, orderNum: 1 },
|
||||
];
|
||||
describe("catalog hierarchy commands", () => {
|
||||
it("rejects self, descendant, missing and malformed destinations", () => {
|
||||
for (const parent of [1, 2, 4, 999, 1.5, -2])
|
||||
expect(() => assertParent(pages, 1, parent)).toThrow();
|
||||
});
|
||||
it("allows detaching a page from an inherited cycle", () =>
|
||||
expect(() =>
|
||||
assertParent(
|
||||
[
|
||||
{ id: 1, parentId: 2 },
|
||||
{ id: 2, parentId: 1 },
|
||||
],
|
||||
1,
|
||||
-1,
|
||||
),
|
||||
).not.toThrow());
|
||||
it("rejects a destination with an inherited parent cycle", () =>
|
||||
expect(() =>
|
||||
assertParent(
|
||||
[
|
||||
{ id: 1, parentId: -1 },
|
||||
{ id: 2, parentId: 3 },
|
||||
{ id: 3, parentId: 2 },
|
||||
],
|
||||
1,
|
||||
2,
|
||||
),
|
||||
).toThrow());
|
||||
it("collects cyclic subtrees once without recursion", () =>
|
||||
expect(
|
||||
collectSubtree(
|
||||
[
|
||||
{ id: 1, parentId: 2 },
|
||||
{ id: 2, parentId: 1 },
|
||||
],
|
||||
1,
|
||||
),
|
||||
).toEqual([1, 2]));
|
||||
it("rejects stale or partial sibling lists before reordering", () => {
|
||||
expect(() => validateSiblingOrder(pages, 1, [2], [2, 3])).toThrow();
|
||||
expect(() => validateSiblingOrder(pages, 1, [3, 2], [3, 2])).toThrow();
|
||||
expect(() => validateSiblingOrder(pages, 1, [2, 2], [2, 3])).toThrow();
|
||||
});
|
||||
it("accepts a complete reorder matching the previous sibling order", () =>
|
||||
expect(() => validateSiblingOrder(pages, 1, [3, 2], [2, 3])).not.toThrow());
|
||||
});
|
||||
|
||||
it("orders mixed root conventions with a stable ID tie breaker", () => {
|
||||
const roots = [
|
||||
{ id: 3, parentId: 0, orderNum: 1 },
|
||||
{ id: 2, parentId: -1, orderNum: 1 },
|
||||
];
|
||||
expect(() => validateSiblingOrder(roots, -1, [3, 2], [2, 3])).not.toThrow();
|
||||
expect(() => validateSiblingOrder(roots, -1, [2, 3], [3, 2])).toThrow();
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
export interface HierarchyPage {
|
||||
id: number;
|
||||
parentId: number;
|
||||
orderNum?: number;
|
||||
}
|
||||
export class CatalogConflict extends Error {}
|
||||
export class CatalogInputError extends Error {}
|
||||
export class CatalogNotFound extends Error {}
|
||||
export function positiveId(id: number) {
|
||||
if (!Number.isSafeInteger(id) || id <= 0)
|
||||
throw new CatalogInputError("Invalid catalog ID");
|
||||
}
|
||||
export function assertParent(
|
||||
pages: readonly HierarchyPage[],
|
||||
pageId: number,
|
||||
parentId: number,
|
||||
) {
|
||||
if (!Number.isSafeInteger(parentId) || parentId < -1)
|
||||
throw new CatalogInputError("Invalid parent category");
|
||||
const map = new Map(pages.map((page) => [page.id, page]));
|
||||
const visited = new Set<number>();
|
||||
let current = parentId;
|
||||
while (current > 0) {
|
||||
if (current === pageId || visited.has(current))
|
||||
throw new CatalogInputError(
|
||||
"Cannot move page: would create a circular hierarchy",
|
||||
);
|
||||
visited.add(current);
|
||||
const parent = map.get(current);
|
||||
if (!parent) throw new CatalogInputError("Parent category does not exist");
|
||||
current = parent.parentId;
|
||||
}
|
||||
}
|
||||
export function collectSubtree(
|
||||
pages: readonly HierarchyPage[],
|
||||
pageId: number,
|
||||
): number[] {
|
||||
const children = new Map<number, number[]>();
|
||||
for (const page of pages) {
|
||||
const list = children.get(page.parentId) ?? [];
|
||||
list.push(page.id);
|
||||
children.set(page.parentId, list);
|
||||
}
|
||||
const seen = new Set<number>();
|
||||
const queue = [pageId];
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
const id = queue[index];
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
queue.push(...(children.get(id) ?? []));
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
export function validateSiblingOrder(
|
||||
pages: readonly HierarchyPage[],
|
||||
parentId: number,
|
||||
ids: readonly number[],
|
||||
expected: readonly number[],
|
||||
) {
|
||||
const current = pages
|
||||
.filter((page) =>
|
||||
parentId <= 0
|
||||
? page.parentId === -1 || page.parentId === 0
|
||||
: page.parentId === parentId,
|
||||
)
|
||||
.sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0) || a.id - b.id)
|
||||
.map((page) => page.id);
|
||||
if (
|
||||
current.length !== expected.length ||
|
||||
current.some((id, index) => id !== expected[index])
|
||||
)
|
||||
throw new CatalogConflict(
|
||||
"The category order changed. Reload before trying again.",
|
||||
);
|
||||
if (
|
||||
ids.length !== current.length ||
|
||||
new Set(ids).size !== ids.length ||
|
||||
ids.some((id) => !current.includes(id))
|
||||
)
|
||||
throw new CatalogInputError(
|
||||
"A reorder must include every sibling exactly once",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
numericValue,
|
||||
offerPatchSchema,
|
||||
parseFurnitureIds,
|
||||
} from "./offer-input";
|
||||
|
||||
describe("offer compatibility", () => {
|
||||
it.each([0, "0", -1, "-1"])(
|
||||
"preserves explicit numeric value %s",
|
||||
(value) => {
|
||||
expect(numericValue(value, 99)).toBe(Number(value));
|
||||
},
|
||||
);
|
||||
it.each([null, undefined, "", "invalid", Infinity])(
|
||||
"defaults missing or invalid value %s",
|
||||
(value) => {
|
||||
expect(numericValue(value, 99)).toBe(99);
|
||||
},
|
||||
);
|
||||
it("preserves bundle multiplicity and LTD/special offer values", () => {
|
||||
expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]);
|
||||
const values = {
|
||||
itemIds: "12;23;12",
|
||||
amount: 0,
|
||||
offerId: -1,
|
||||
orderNumber: 0,
|
||||
limitedSells: 4,
|
||||
limitedStack: 20,
|
||||
songId: 0,
|
||||
extradata: "a;b",
|
||||
haveOffer: "0",
|
||||
clubOnly: "1",
|
||||
};
|
||||
expect(offerPatchSchema.parse(values)).toEqual(values);
|
||||
});
|
||||
it.each(["1;0", "1;2x", "1;;2", "-1", "1.5"])(
|
||||
"rejects invalid furniture references %s",
|
||||
(value) => {
|
||||
expect(() => parseFurnitureIds(value)).toThrow();
|
||||
},
|
||||
);
|
||||
it("rejects invalid page ids and nonfinite prices without changing field meaning", () => {
|
||||
expect(offerPatchSchema.safeParse({ pageId: 0 }).success).toBe(false);
|
||||
expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(offerPatchSchema.parse({ offerId: 0, amount: 0 })).toEqual({
|
||||
offerId: 0,
|
||||
amount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const offerInteger = z.number().int().min(-2147483648).max(2147483647);
|
||||
export const offerIdSchema = offerInteger.positive();
|
||||
|
||||
/** Missing SQL values use the field default; explicit zero and special negatives survive. */
|
||||
export function numericValue(value: unknown, fallback: number): number {
|
||||
if (value == null || (typeof value === "string" && value.trim() === ""))
|
||||
return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
export function parseFurnitureIds(value: string): number[] {
|
||||
const parts = value.split(";");
|
||||
if (!parts.length || parts.some((part) => !/^\d+$/.test(part.trim())))
|
||||
throw Error("Invalid furniture references");
|
||||
return parts.map((part) => offerIdSchema.parse(Number(part)));
|
||||
}
|
||||
const itemIds = z
|
||||
.string()
|
||||
.max(666)
|
||||
.refine((value) => {
|
||||
try {
|
||||
parseFurnitureIds(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "Invalid furniture references");
|
||||
// Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator.
|
||||
export const offerPatchSchema = z.object({
|
||||
pageId: offerIdSchema.optional(),
|
||||
itemIds: itemIds.optional(),
|
||||
catalogName: z.string().max(100).optional(),
|
||||
costCredits: offerInteger.optional(),
|
||||
costPoints: offerInteger.optional(),
|
||||
pointsType: offerInteger.optional(),
|
||||
amount: offerInteger.optional(),
|
||||
orderNumber: offerInteger.optional(),
|
||||
offerId: offerInteger.optional(),
|
||||
songId: z.number().int().min(0).max(4294967295).optional(),
|
||||
limitedSells: offerInteger.optional(),
|
||||
limitedStack: offerInteger.optional(),
|
||||
extradata: z.string().max(500).optional(),
|
||||
haveOffer: z.enum(["0", "1"]).optional(),
|
||||
clubOnly: z.enum(["0", "1"]).optional(),
|
||||
});
|
||||
const flag = z.union([
|
||||
z.literal(0),
|
||||
z.literal(1),
|
||||
z.literal("0"),
|
||||
z.literal("1"),
|
||||
]);
|
||||
export const furniturePatchSchema = z.object({
|
||||
publicName: z.string().max(56).optional(),
|
||||
itemName: z.string().max(70).optional(),
|
||||
type: z.string().max(3).optional(),
|
||||
width: offerInteger.optional(),
|
||||
length: offerInteger.optional(),
|
||||
stackHeight: z.number().finite().optional(),
|
||||
allowStack: flag.optional(),
|
||||
allowSit: flag.optional(),
|
||||
allowLay: flag.optional(),
|
||||
allowWalk: flag.optional(),
|
||||
allowGift: flag.optional(),
|
||||
allowTrade: flag.optional(),
|
||||
allowRecycle: flag.optional(),
|
||||
allowMarketplaceSell: flag.optional(),
|
||||
allowInventoryStack: flag.optional(),
|
||||
interactionType: z.string().max(500).optional(),
|
||||
interactionModesCount: offerInteger.optional(),
|
||||
vendingIds: z.string().max(255).optional(),
|
||||
customparams: z.string().max(25600).optional(),
|
||||
effectIdMale: offerInteger.optional(),
|
||||
effectIdFemale: offerInteger.optional(),
|
||||
clothingOnWalk: z.string().max(255).optional(),
|
||||
});
|
||||
export function distinctOfferIds(ids: number[]): number[] {
|
||||
for (const id of ids) offerIdSchema.parse(id);
|
||||
if (new Set(ids).size !== ids.length)
|
||||
throw Error("Duplicate catalog item ids");
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { pagePatchSchema } from "./page-input";
|
||||
|
||||
it("preserves nullable page texts when editing unrelated fields", () => {
|
||||
const fields = {
|
||||
caption: "Changed",
|
||||
pageText1: null,
|
||||
pageText2: null,
|
||||
pageTextDetails: null,
|
||||
pageTextTeaser: null,
|
||||
pageSpecial: null,
|
||||
};
|
||||
expect(pagePatchSchema.parse(fields)).toEqual(fields);
|
||||
});
|
||||
it("preserves valid zero values and rejects invalid flags", () => {
|
||||
expect(
|
||||
pagePatchSchema.parse({ parentId: 0, orderNum: 0, iconImage: 0 }),
|
||||
).toEqual({ parentId: 0, orderNum: 0, iconImage: 0 });
|
||||
expect(() => pagePatchSchema.parse({ enabled: "true" })).toThrow();
|
||||
});
|
||||
|
||||
it("preserves caption whitespace for optimistic comparisons but rejects blank captions", () => {
|
||||
expect(pagePatchSchema.parse({ caption: " Existing " })).toEqual({
|
||||
caption: " Existing ",
|
||||
});
|
||||
expect(() => pagePatchSchema.parse({ caption: " " })).toThrow();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const flag = z.enum(["0", "1"]);
|
||||
const integer = z.number().int().min(-2147483648).max(2147483647);
|
||||
export const pagePatchSchema = z.object({
|
||||
caption: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.refine((value) => value.trim().length > 0, "Category name is required")
|
||||
.optional(),
|
||||
captionSave: z.string().max(25).optional(),
|
||||
parentId: integer.min(-1).optional(),
|
||||
pageLayout: z.string().min(1).max(26).optional(),
|
||||
enabled: flag.optional(),
|
||||
visible: flag.optional(),
|
||||
clubOnly: flag.optional(),
|
||||
vipOnly: flag.optional(),
|
||||
minRank: integer.min(0).optional(),
|
||||
orderNum: integer.optional(),
|
||||
iconImage: integer.min(0).optional(),
|
||||
iconColor: integer.min(0).optional(),
|
||||
pageHeadline: z.string().max(1024).optional(),
|
||||
pageTeaser: z.string().max(64).optional(),
|
||||
pageSpecial: z.string().max(2048).nullable().optional(),
|
||||
pageText1: z.string().nullable().optional(),
|
||||
pageText2: z.string().nullable().optional(),
|
||||
pageTextDetails: z.string().nullable().optional(),
|
||||
pageTextTeaser: z.string().nullable().optional(),
|
||||
includes: z.string().max(128).optional(),
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCatalogSearch, validSearchQuery } from "./search";
|
||||
|
||||
describe("catalog search input", () => {
|
||||
it("allows a one digit ID but requires at least two text characters", () => {
|
||||
expect(validSearchQuery("7")).toBe(true);
|
||||
expect(validSearchQuery("a")).toBe(false);
|
||||
expect(validSearchQuery(" ab ")).toBe(true);
|
||||
});
|
||||
it("does not round oversized numeric identifiers", () => {
|
||||
expect(parseCatalogSearch("normal", "9007199254740992").numericId).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
it("escapes LIKE wildcard and escape characters", () => {
|
||||
expect(parseCatalogSearch("bc", "a_%=").pattern).toBe("a=_=%==%");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CatalogInputError } from "./hierarchy";
|
||||
export type SearchCatalogKind = "normal" | "bc";
|
||||
export interface CatalogSearchResult {
|
||||
kind: "category" | "offer";
|
||||
id: number;
|
||||
pageId: number;
|
||||
caption: string;
|
||||
path: string[];
|
||||
href: string;
|
||||
furniture?: { id: number; classname: string; spriteId: number };
|
||||
}
|
||||
export function validSearchQuery(query: string) {
|
||||
const value = query.trim();
|
||||
return query.length <= 100 && (value.length >= 2 || /^\d$/.test(value));
|
||||
}
|
||||
export function parseCatalogSearch(kind: string, query: string) {
|
||||
if (kind !== "normal" && kind !== "bc")
|
||||
throw new CatalogInputError("Invalid catalog type");
|
||||
if (!validSearchQuery(query))
|
||||
throw new CatalogInputError(
|
||||
"Search requires 2 to 100 characters or a numeric ID",
|
||||
);
|
||||
const value = query.trim();
|
||||
const parsed = /^\d+$/.test(value) ? Number(value) : NaN;
|
||||
const numericId = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
return {
|
||||
kind,
|
||||
query: value,
|
||||
numericId,
|
||||
pattern: `${value.replace(/[=%_]/g, (char) => `=${char}`)}%`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import "server-only";
|
||||
import { ZodError } from "zod";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
CatalogConflict,
|
||||
CatalogInputError,
|
||||
CatalogNotFound,
|
||||
} from "../domain/hierarchy";
|
||||
export function catalogFailure(error: unknown) {
|
||||
if (error instanceof CatalogConflict)
|
||||
return { message: error.message, status: 409 };
|
||||
if (error instanceof CatalogNotFound)
|
||||
return { message: error.message, status: 404 };
|
||||
if (error instanceof CatalogInputError)
|
||||
return { message: error.message, status: 400 };
|
||||
if (error instanceof ZodError)
|
||||
return {
|
||||
message: error.issues
|
||||
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
||||
.join("; "),
|
||||
status: 400,
|
||||
};
|
||||
const reference = logger.error("Catalog operation failed", {
|
||||
module: "catalog",
|
||||
error,
|
||||
});
|
||||
return { message: `Catalog operation failed (${reference})`, status: 500 };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
||||
import "server-only";
|
||||
import { asc, count, eq, or } from "drizzle-orm";
|
||||
import {
|
||||
CatalogItems,
|
||||
CatalogItemsBc,
|
||||
CatalogPages,
|
||||
CatalogPagesBc,
|
||||
db,
|
||||
} from "@/lib/db";
|
||||
import type { CatalogKind } from "./page-commands";
|
||||
export async function loadCatalogOverview(kind: CatalogKind) {
|
||||
const pages = kind === "bc" ? CatalogPagesBc : CatalogPages;
|
||||
const items = kind === "bc" ? CatalogItemsBc : CatalogItems;
|
||||
const [roots, pageCount, itemCount, enabledCount] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: pages.id,
|
||||
caption: pages.caption,
|
||||
iconImage: pages.iconImage,
|
||||
enabled: pages.enabled,
|
||||
orderNum: pages.orderNum,
|
||||
})
|
||||
.from(pages)
|
||||
.where(or(eq(pages.parentId, -1), eq(pages.parentId, 0)))
|
||||
.orderBy(asc(pages.orderNum), asc(pages.id)),
|
||||
db.select({ total: count() }).from(pages),
|
||||
db.select({ total: count() }).from(items),
|
||||
db.select({ total: count() }).from(pages).where(eq(pages.enabled, "1")),
|
||||
]);
|
||||
return {
|
||||
roots,
|
||||
totals: {
|
||||
totalPages: Number(pageCount[0]?.total ?? 0),
|
||||
totalItems: Number(itemCount[0]?.total ?? 0),
|
||||
enabledPages: Number(enabledCount[0]?.total ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
reorder: vi.fn(),
|
||||
send: vi.fn(),
|
||||
permission: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/features/catalog/server/page-commands", () => ({
|
||||
reorderPagesCommand: state.reorder,
|
||||
}));
|
||||
vi.mock("@/features/catalog/server/sync-status", () => ({
|
||||
sendCatalogUpdate: state.send,
|
||||
}));
|
||||
vi.mock("@/features/catalog/server/errors", () => ({
|
||||
catalogFailure: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.permission }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { CATALOG_EDIT: "catalog.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||
withCatalogExport: async (fn: () => unknown) => fn(),
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { reorderCatalogPages } from "@/actions/catalog";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.reorder.mockResolvedValue(undefined);
|
||||
state.permission.mockResolvedValue({ id: 1 });
|
||||
});
|
||||
it("requests only one hotel update after a complete sibling reorder", async () => {
|
||||
const input = { parentId: 1, ids: [3, 2], expectedIds: [2, 3] };
|
||||
await reorderCatalogPages(input);
|
||||
expect(state.reorder).toHaveBeenCalledWith("normal", input);
|
||||
expect(state.send).toHaveBeenCalledOnce();
|
||||
expect(state.permission).toHaveBeenCalledWith("catalog.edit");
|
||||
});
|
||||
it("does not request a hotel update for a rejected reorder", async () => {
|
||||
state.reorder.mockRejectedValueOnce(Error("conflict"));
|
||||
await expect(
|
||||
reorderCatalogPages({ parentId: 1, ids: [3, 2], expectedIds: [2, 3] }),
|
||||
).rejects.toThrow();
|
||||
expect(state.send).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,42 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
root: "",
|
||||
send: vi.fn(),
|
||||
log: vi.fn(() => "catalog-error-1"),
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-git-config", () => ({
|
||||
catalogStateRoot: () => state.root,
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { updateCatalog: state.send } }));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
|
||||
|
||||
import { readCatalogHotelStatus, sendCatalogUpdate } from "./sync-status";
|
||||
|
||||
beforeEach(async () => {
|
||||
state.root = await fs.mkdtemp(path.join(os.tmpdir(), "catalog-status-"));
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.rm(state.root, { recursive: true, force: true });
|
||||
});
|
||||
it("records a failed delivery without rejecting an already saved operation", async () => {
|
||||
state.send.mockResolvedValue(false);
|
||||
const result = await sendCatalogUpdate();
|
||||
expect(result.sent).toBe(false);
|
||||
expect(result.reference).toBe("catalog-error-1");
|
||||
expect(await readCatalogHotelStatus()).toEqual(result);
|
||||
});
|
||||
it("allows retrying the update without reapplying catalog mutations", async () => {
|
||||
state.send
|
||||
.mockRejectedValueOnce(Error("offline"))
|
||||
.mockResolvedValueOnce(true);
|
||||
expect((await sendCatalogUpdate()).sent).toBe(false);
|
||||
expect((await sendCatalogUpdate()).sent).toBe(true);
|
||||
expect((await readCatalogHotelStatus())?.sent).toBe(true);
|
||||
});
|
||||
it("returns no status before the first attempt", async () =>
|
||||
expect(await readCatalogHotelStatus()).toBeNull());
|
||||
@@ -0,0 +1,59 @@
|
||||
import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { catalogStateRoot } from "@/lib/services/catalog-git-config";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
export interface CatalogHotelStatus {
|
||||
sent: boolean;
|
||||
checkedAt: string;
|
||||
reference?: string;
|
||||
}
|
||||
export async function readCatalogHotelStatus(): Promise<CatalogHotelStatus | null> {
|
||||
try {
|
||||
return JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(catalogStateRoot(), "hotel-status.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** An acknowledged socket write is an update request, not proof of client application. */
|
||||
export async function sendCatalogUpdate(): Promise<CatalogHotelStatus> {
|
||||
let sent = false;
|
||||
let reference: string | undefined;
|
||||
try {
|
||||
sent = await rcon.updateCatalog();
|
||||
if (!sent)
|
||||
reference = logger.error(
|
||||
"Catalog saved; hotel update was not delivered",
|
||||
{ module: "catalog" },
|
||||
);
|
||||
} catch (error) {
|
||||
reference = logger.error("Catalog saved; hotel update failed", {
|
||||
module: "catalog",
|
||||
error,
|
||||
});
|
||||
}
|
||||
const status = { sent, checkedAt: new Date().toISOString(), reference };
|
||||
const root = catalogStateRoot();
|
||||
const temp = path.join(root, `hotel-${randomUUID()}.tmp`);
|
||||
try {
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await fs.writeFile(temp, JSON.stringify(status), { flag: "wx" });
|
||||
await fs.rename(temp, path.join(root, "hotel-status.json"));
|
||||
} catch (error) {
|
||||
logger.error("Cannot persist catalog hotel update status", {
|
||||
module: "catalog",
|
||||
error,
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(temp, { force: true }).catch(() => {});
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ finish: vi.fn(), log: vi.fn() }));
|
||||
vi.mock("./catalog-git-config", () => ({
|
||||
readManagedCatalogConfig: () => ({ enabled: true }),
|
||||
catalogStateRoot: () => "unused",
|
||||
}));
|
||||
vi.mock("./catalog-git-core", () => ({
|
||||
CatalogExportQueue: class {
|
||||
async begin() {
|
||||
return state.finish;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
|
||||
|
||||
import { isCatalogMutation, withCatalogExport } from "./catalog-git-queue";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.finish.mockRejectedValue(Error("disk full"));
|
||||
});
|
||||
it("does not turn a committed create into a failed action when queue completion fails", async () => {
|
||||
expect(await withCatalogExport(async () => ({ ok: true, id: 42 }))).toEqual({
|
||||
ok: true,
|
||||
id: 42,
|
||||
});
|
||||
expect(state.log).toHaveBeenCalled();
|
||||
});
|
||||
it("preserves the original mutation error if cleanup also fails", async () => {
|
||||
await expect(
|
||||
withCatalogExport(async () => {
|
||||
throw Error("database conflict");
|
||||
}),
|
||||
).rejects.toThrow("database conflict");
|
||||
});
|
||||
|
||||
it("keeps hotel retries independent from Git export scheduling", () => {
|
||||
expect(isCatalogMutation("POST", "/api/admin/catalog/status")).toBe(false);
|
||||
expect(isCatalogMutation("POST", "/api/admin/catalog/status/")).toBe(false);
|
||||
expect(isCatalogMutation("PATCH", "/api/admin/catalog/tree")).toBe(true);
|
||||
expect(isCatalogMutation("POST", "/api/admin/import/furni")).toBe(true);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
catalogStateRoot,
|
||||
readManagedCatalogConfig,
|
||||
@@ -18,6 +19,7 @@ export function catalogExportQueue() {
|
||||
export function isCatalogMutation(method: string, pathname: string) {
|
||||
return (
|
||||
["POST", "PUT", "PATCH", "DELETE"].includes(method) &&
|
||||
pathname.replace(/\/$/, "") !== "/api/admin/catalog/status" &&
|
||||
(pathname.startsWith("/api/admin/import/") ||
|
||||
pathname.startsWith("/api/admin/catalog/") ||
|
||||
pathname.startsWith("/api/admin/furni/") ||
|
||||
@@ -53,7 +55,14 @@ export async function withCatalogExport<T>(
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
await finish();
|
||||
try {
|
||||
await finish();
|
||||
} catch (error) {
|
||||
logger.error("Catalog operation finished but export scheduling failed", {
|
||||
module: "catalog",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { asc, sql } from "drizzle-orm";
|
||||
import { numericValue } from "@/features/catalog/domain/offer-input";
|
||||
import { CatalogPages, db } from "@/lib/db";
|
||||
import { getFurnitureDataPath } from "@/lib/services/furni-data";
|
||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||
@@ -147,17 +148,17 @@ export async function loadCatalogItemsData(
|
||||
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
|
||||
id: Number(r.id),
|
||||
itemIds: String(r.item_ids ?? ""),
|
||||
pageId: Number(r.page_id) || 0,
|
||||
offerId: Number(r.offer_id) || -1,
|
||||
songId: Number(r.song_id) || 0,
|
||||
orderNumber: Number(r.order_number) || 99,
|
||||
pageId: numericValue(r.page_id, 0),
|
||||
offerId: numericValue(r.offer_id, -1),
|
||||
songId: numericValue(r.song_id, 0),
|
||||
orderNumber: numericValue(r.order_number, 99),
|
||||
catalogName: String(r.catalog_name ?? ""),
|
||||
costCredits: Number(r.cost_credits) || 0,
|
||||
costPoints: Number(r.cost_points) || 0,
|
||||
pointsType: Number(r.points_type) || 0,
|
||||
amount: Number(r.amount) || 1,
|
||||
limitedSells: Number(r.limited_sells) || 0,
|
||||
limitedStack: Number(r.limited_stack) || 0,
|
||||
costCredits: numericValue(r.cost_credits, 0),
|
||||
costPoints: numericValue(r.cost_points, 0),
|
||||
pointsType: numericValue(r.points_type, 0),
|
||||
amount: numericValue(r.amount, 1),
|
||||
limitedSells: numericValue(r.limited_sells, 0),
|
||||
limitedStack: numericValue(r.limited_stack, 0),
|
||||
extradata: String(r.extradata ?? ""),
|
||||
haveOffer: String(r.have_offer ?? "1"),
|
||||
clubOnly: String(r.club_only ?? "0"),
|
||||
@@ -217,9 +218,9 @@ export async function loadCatalogItemsData(
|
||||
publicName: String(r.public_name ?? ""),
|
||||
itemName: String(r.item_name ?? ""),
|
||||
type: String(r.type ?? "s"),
|
||||
width: Number(r.width) || 1,
|
||||
length: Number(r.length) || 1,
|
||||
stackHeight: Number(r.stack_height) || 0,
|
||||
width: numericValue(r.width, 1),
|
||||
length: numericValue(r.length, 1),
|
||||
stackHeight: numericValue(r.stack_height, 0),
|
||||
allowStack: Number(r.allow_stack ?? 0),
|
||||
allowSit: Number(r.allow_sit ?? 0),
|
||||
allowLay: Number(r.allow_lay ?? 0),
|
||||
@@ -230,11 +231,11 @@ export async function loadCatalogItemsData(
|
||||
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
||||
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
||||
interactionType: String(r.interaction_type ?? "default"),
|
||||
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
||||
interactionModesCount: numericValue(r.interaction_modes_count, 2),
|
||||
vendingIds: String(r.vending_ids ?? "0"),
|
||||
customparams: String(r.customparams ?? ""),
|
||||
effectIdMale: Number(r.effect_id_male) || 0,
|
||||
effectIdFemale: Number(r.effect_id_female) || 0,
|
||||
effectIdMale: numericValue(r.effect_id_male, 0),
|
||||
effectIdFemale: numericValue(r.effect_id_female, 0),
|
||||
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const commands = vi.hoisted(() => ({
|
||||
updatePageCommand: vi.fn(),
|
||||
deletePageCommand: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/features/catalog/server/page-commands", () => commands);
|
||||
const insertValues = vi.hoisted(() => vi.fn());
|
||||
const selectLimit = vi.hoisted(() => vi.fn());
|
||||
const updateSetData = vi.hoisted(() => vi.fn());
|
||||
@@ -168,22 +173,11 @@ describe("createPage", () => {
|
||||
});
|
||||
|
||||
describe("movePage", () => {
|
||||
it("throws on circular hierarchy", async () => {
|
||||
selectLimit.mockResolvedValue([{ parentId: 1 }]);
|
||||
await expect(movePage(1, 2)).rejects.toThrow("circular hierarchy");
|
||||
});
|
||||
|
||||
it("allows valid move to root", async () => {
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
await movePage(1, 0);
|
||||
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 0 });
|
||||
});
|
||||
|
||||
it("allows valid move to other parent", async () => {
|
||||
selectLimit.mockResolvedValue([{ parentId: 0 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
it("uses the shared structural command", async () => {
|
||||
await movePage(1, 2);
|
||||
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 2 });
|
||||
expect(commands.updatePageCommand).toHaveBeenCalledWith("normal", 1, {
|
||||
parentId: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,13 +202,19 @@ describe("getAncestors", () => {
|
||||
});
|
||||
|
||||
describe("deletePage", () => {
|
||||
it("reparent mode moves children to parent", async () => {
|
||||
selectLimit.mockResolvedValue([{ parentId: 1 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 2 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
|
||||
const result = await deletePage(5, "reparent");
|
||||
expect(result).toEqual({ deletedPages: 1, movedChildren: 2 });
|
||||
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 1 });
|
||||
it("uses the shared transactional delete", async () => {
|
||||
commands.deletePageCommand.mockResolvedValue({
|
||||
deletedPages: 1,
|
||||
movedChildren: 2,
|
||||
});
|
||||
expect(await deletePage(5, "reparent")).toEqual({
|
||||
deletedPages: 1,
|
||||
movedChildren: 2,
|
||||
});
|
||||
expect(commands.deletePageCommand).toHaveBeenCalledWith(
|
||||
"normal",
|
||||
5,
|
||||
"reparent",
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1916,6 +1916,35 @@
|
||||
"save": "Save item",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"workspace": {
|
||||
"diagnostics": "Diagnostics",
|
||||
"editor": "Editor",
|
||||
"normal": "Catalog",
|
||||
"tree": "Tree",
|
||||
"table": "Table",
|
||||
"manager": "Catalog manager",
|
||||
"backToCategories": "Back to categories",
|
||||
"retryPage": "Retry loading page",
|
||||
"retryItems": "Retry loading items",
|
||||
"hotel": "Hotel",
|
||||
"sent": "Update request sent",
|
||||
"sendFailed": "Update request failed",
|
||||
"unknown": "No status yet",
|
||||
"disabled": "Not configured",
|
||||
"exportFailed": "Export failed",
|
||||
"queued": "Export pending",
|
||||
"exported": "Export completed",
|
||||
"retryHotel": "Retry hotel update",
|
||||
"retryStatus": "Status unavailable — retry",
|
||||
"saved": "Saved",
|
||||
"globalSearch": "Search catalog",
|
||||
"searchPlaceholder": "Category, offer, classname or ID",
|
||||
"searchNoResults": "No results found",
|
||||
"searchFailed": "Search failed. Try again.",
|
||||
"searching": "Searching...",
|
||||
"category": "Category",
|
||||
"offer": "Offer"
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
|
||||
@@ -1835,6 +1835,35 @@
|
||||
"save": "Salva articolo",
|
||||
"saving": "Salvataggio…",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"workspace": {
|
||||
"diagnostics": "Diagnostica",
|
||||
"editor": "Editor",
|
||||
"normal": "Catalogo",
|
||||
"tree": "Albero",
|
||||
"table": "Tabella",
|
||||
"manager": "Gestione catalogo",
|
||||
"backToCategories": "Torna alle categorie",
|
||||
"retryPage": "Riprova caricamento pagina",
|
||||
"retryItems": "Riprova caricamento offerte",
|
||||
"hotel": "Hotel",
|
||||
"sent": "Richiesta di aggiornamento inviata",
|
||||
"sendFailed": "Invio aggiornamento fallito",
|
||||
"unknown": "Stato non ancora disponibile",
|
||||
"disabled": "Non configurato",
|
||||
"exportFailed": "Esportazione fallita",
|
||||
"queued": "Esportazione in attesa",
|
||||
"exported": "Esportazione completata",
|
||||
"retryHotel": "Riprova aggiornamento hotel",
|
||||
"retryStatus": "Stato non disponibile — riprova",
|
||||
"saved": "Salvato",
|
||||
"globalSearch": "Cerca nel catalogo",
|
||||
"searchPlaceholder": "Categoria, offerta, classname o ID",
|
||||
"searchNoResults": "Nessun risultato",
|
||||
"searchFailed": "Ricerca non riuscita. Riprova.",
|
||||
"searching": "Ricerca in corso...",
|
||||
"category": "Categoria",
|
||||
"offer": "Offerta"
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
|
||||
@@ -1665,6 +1665,35 @@
|
||||
"save": "Item opslaan",
|
||||
"saving": "Opslaan…",
|
||||
"cancel": "Annuleren"
|
||||
},
|
||||
"workspace": {
|
||||
"diagnostics": "Diagnostiek",
|
||||
"editor": "Editor",
|
||||
"normal": "Catalogus",
|
||||
"tree": "Boom",
|
||||
"table": "Tabel",
|
||||
"manager": "Catalogusbeheer",
|
||||
"backToCategories": "Terug naar categorieën",
|
||||
"retryPage": "Pagina opnieuw laden",
|
||||
"retryItems": "Aanbiedingen opnieuw laden",
|
||||
"hotel": "Hotel",
|
||||
"sent": "Updateverzoek verzonden",
|
||||
"sendFailed": "Updateverzoek mislukt",
|
||||
"unknown": "Nog geen status",
|
||||
"disabled": "Niet ingesteld",
|
||||
"exportFailed": "Export mislukt",
|
||||
"queued": "Export in wachtrij",
|
||||
"exported": "Export voltooid",
|
||||
"retryHotel": "Hotelupdate opnieuw proberen",
|
||||
"retryStatus": "Status niet beschikbaar — opnieuw proberen",
|
||||
"saved": "Opgeslagen",
|
||||
"globalSearch": "Catalogus doorzoeken",
|
||||
"searchPlaceholder": "Categorie, aanbieding, classname of ID",
|
||||
"searchNoResults": "Geen resultaten gevonden",
|
||||
"searchFailed": "Zoeken mislukt. Probeer opnieuw.",
|
||||
"searching": "Zoeken...",
|
||||
"category": "Categorie",
|
||||
"offer": "Aanbieding"
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
|
||||
Reference in new issue
Block a user