diff --git a/e2e/ui/catalog-live-update.spec.ts b/e2e/ui/catalog-live-update.spec.ts new file mode 100644 index 00000000..5b88841a --- /dev/null +++ b/e2e/ui/catalog-live-update.spec.ts @@ -0,0 +1,143 @@ +import { expect, type Page, test } from "@playwright/test"; + +/** + * The guarantee this file exists to guard: an import that finishes outside this + * page — the job worker, another tab, another browser — puts the new categories + * on screen without a reload. Every earlier test in this suite asserted only + * that no page errors were thrown, so a regression back to "refresh the page to + * see it" would have gone unnoticed. + */ + +function node(id: number, caption: string, itemCount = 0) { + return { + id, + caption, + parentId: -1, + depth: 0, + orderNum: id, + enabled: "1", + visible: "1", + iconImage: 0, + iconColor: 0, + pageLayout: "default_3x3", + childCount: 0, + itemCount, + }; +} + +/** The catalog as the server would report it; specs move it forward mid-test. */ +const catalog = { + revision: "rev-1", + pages: [node(1, "Root")], + treeStatus: 200, +}; + +async function mockStudio(page: Page) { + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/api/admin/catalog/revision") + return route.fulfill({ json: { ok: true, revision: catalog.revision } }); + if ( + url.pathname === "/api/admin/catalog/tree" && + url.searchParams.get("mode") === "full" + ) + return route.fulfill({ + status: catalog.treeStatus, + json: { + ok: catalog.treeStatus === 200, + pages: catalog.pages, + totals: null, + revision: catalog.revision, + }, + }); + if (url.pathname.endsWith("/inspect")) + return route.fulfill({ json: { items: [] } }); + if (url.pathname.endsWith("/source-assets")) + return route.fulfill({ json: { items: [] } }); + if (url.pathname.endsWith("/furni")) + return route.fulfill({ + json: url.searchParams.has("action") + ? { totalInDb: 0, inCatalog: 0, notInCatalog: 0, missingNitro: 0 } + : { + items: [], + meta: { + currentPage: 1, + lastPage: 1, + total: 0, + perPage: 100, + }, + }, + }); + if (url.pathname.endsWith("/clone")) + return route.fulfill({ json: { sources: [] } }); + if (url.pathname.endsWith("/import-jobs")) + return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } }); + return route.fulfill({ + status: 404, + json: { error: "Unknown fixture endpoint" }, + }); + }); +} + +test.beforeEach(() => { + catalog.revision = "rev-1"; + catalog.pages = [node(1, "Root")]; + catalog.treeStatus = 200; +}); + +test("a write announced by another tab lands in the mounted categories", async ({ + page, +}, testInfo) => { + test.skip( + (testInfo.project.use.viewport?.width ?? 0) < 1024, + "the category rail is collapsed on narrow viewports", + ); + await mockStudio(page); + const navigations: string[] = []; + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) navigations.push(frame.url()); + }); + + await page.goto("/admin/studio-harness"); + await expect(page.getByRole("button", { name: /^Root/ })).toBeVisible(); + const navigationsAfterLoad = navigations.length; + + // An import finished elsewhere: the catalog moved on, so the tree route now + // answers with the imported category. + catalog.revision = "rev-2"; + catalog.pages = [...catalog.pages, node(2, "Imported Furniture", 3)]; + await page.evaluate((revision) => { + const channel = new BroadcastChannel("atom-cms-catalog"); + channel.postMessage({ revision }); + channel.close(); + }, catalog.revision); + + await expect( + page.getByRole("button", { name: /Imported Furniture/ }), + ).toBeVisible(); + // The page was never navigated or reloaded: the tree updated in place. + expect(navigations).toHaveLength(navigationsAfterLoad); +}); + +test("a failed refresh keeps the categories and says it is stale", async ({ + page, +}, testInfo) => { + test.skip( + (testInfo.project.use.viewport?.width ?? 0) < 1024, + "the category rail is collapsed on narrow viewports", + ); + await mockStudio(page); + await page.goto("/admin/studio-harness"); + await expect(page.getByRole("button", { name: /^Root/ })).toBeVisible(); + + catalog.treeStatus = 500; + catalog.revision = "rev-2"; + await page.evaluate((revision) => { + const channel = new BroadcastChannel("atom-cms-catalog"); + channel.postMessage({ revision }); + channel.close(); + }, catalog.revision); + + await expect(page.getByRole("button", { name: "Retry" })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Root/ })).toBeVisible(); +}); diff --git a/e2e/ui/organize-imports.spec.ts b/e2e/ui/organize-imports.spec.ts deleted file mode 100644 index 4af3a1f6..00000000 --- a/e2e/ui/organize-imports.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test("Organize Imports Dialog validation limits and modes workflow", async ({ - page, -}) => { - const errors: string[] = []; - page.on("pageerror", (error) => errors.push(error.message)); - - await page.route("**/api/**", async (route) => { - const url = new URL(route.request().url()); - if (url.pathname === "/api/admin/catalog/import-groups") { - return route.fulfill({ - json: { - groups: [ - { - name: "Test Group", - caption: "Test Group", - icon: 1, - layout: "default_3x3", - total: 5, - items: [ - { - itemId: 1, - itemName: "chair", - catalogItemId: null, - alreadyPlaced: false, - }, - ], - }, - ], - }, - }); - } - if (url.pathname === "/api/admin/catalog/pages") { - return route.fulfill({ - json: { - pages: [{ id: 101, caption: "Existing Category", parentId: 0 }], - }, - }); - } - if ( - route.request().method() === "POST" && - url.pathname.includes("organize") - ) { - return route.fulfill({ - json: { - ok: true, - data: { - created: [ - { pageId: 102, caption: "Test Group", moved: 0, added: 1 }, - ], - }, - }, - }); - } - return route.fulfill({ - status: 404, - json: { error: "Not found" }, - }); - }); - - // If there's no direct harness route, we can test component behavior or test via catalog admin route if available. - // For now, let's verify error handling and limits in the mock test or navigate to catalog. - await page.goto("/admin/catalog"); - expect(errors).toEqual([]); -}); diff --git a/src/app/api/admin/catalog/revision/route.ts b/src/app/api/admin/catalog/revision/route.ts new file mode 100644 index 00000000..d05f4388 --- /dev/null +++ b/src/app/api/admin/catalog/revision/route.ts @@ -0,0 +1,15 @@ +import { readCatalogRevision } from "@/features/catalog/server/catalog-revision"; +import { apiJson, apiOk } from "@/lib/api"; +import { withAdmin } from "@/lib/api-handler"; +import { PERMS } from "@/lib/permissions"; + +/** + * The staleness probe for every open admin catalog session. Returns the + * revision `sendCatalogUpdate()` last wrote, so a client can tell whether its + * tree is current with one tiny read instead of pulling the whole tree. + */ +export const GET = withAdmin({ permission: PERMS.CATALOG_VIEW }, async () => { + const revision = await readCatalogRevision(); + if (!revision) return apiJson({ ok: true, revision: null }); + return apiOk({ revision }, { headers: { etag: `"${revision}"` } }); +}); diff --git a/src/app/api/admin/catalog/tree/route.ts b/src/app/api/admin/catalog/tree/route.ts index 45f32bd2..5f69cc6e 100644 --- a/src/app/api/admin/catalog/tree/route.ts +++ b/src/app/api/admin/catalog/tree/route.ts @@ -1,4 +1,6 @@ import { asc, count, eq, inArray, like, or } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { readCatalogRevision } from "@/features/catalog/server/catalog-revision"; import { loadCatalogTotals } from "@/features/catalog/server/catalog-totals"; import { catalogFailure } from "@/features/catalog/server/errors"; import { @@ -351,11 +353,23 @@ export const GET = withAdmin( if (sp.get("mode") === "full") { // Totals travel with the tree so one refresh updates both the rail and // the stats bar. Read uncached — this response is already the fresh read. - const [pages, totals] = await Promise.all([ + const [pages, totals, revision] = await Promise.all([ isBc ? getBcTreeFlat() : getTreeFlat(), loadCatalogTotals(isBc ? "bc" : "normal"), + readCatalogRevision(), ]); - return apiOk({ pages, totals }); + // `revision` doubles as the ETag: a client that saw this exact catalog + // state re-asks with `If-None-Match` and gets a 304 instead of the + // whole flat tree, which is the only part of this payload that grows + // with the size of the catalog. + const etag = revision ? `"${revision}"` : null; + if (etag && request.headers.get("if-none-match") === etag) { + return new NextResponse(null, { status: 304, headers: { etag } }); + } + return apiOk( + { pages, totals, revision }, + etag ? { headers: { etag } } : undefined, + ); } const pageIdRaw = sp.get("pageId"); diff --git a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx index b536d01b..e1a48af8 100644 --- a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx +++ b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx @@ -15,6 +15,7 @@ import { useLatestRequest } from "@/features/catalog/client/use-latest-request"; import { refreshCatalogTree, seedCatalogTotals, + useCatalogSync, useLiveCatalog, } from "@/features/catalog/client/use-live-catalog"; import { CatalogSearch } from "@/features/catalog/components/catalog-search"; @@ -172,6 +173,9 @@ function CatalogManagerInner({ // Server totals are the baseline; after an import the live store holds // counts that are newer than both the prop and its 30s server cache. const liveTotals = useLiveCatalog().totals[catalogType]; + // The dialog runs on its own page too (Studio embed aside), so it cannot rely + // on the Studio to keep the shared tree current. + useCatalogSync(); useEffect(() => { seedCatalogTotals(catalogType, totals); }, [catalogType, totals]); diff --git a/src/components/admin/catalog-tree.tsx b/src/components/admin/catalog-tree.tsx index a09db55e..7975e21e 100644 --- a/src/components/admin/catalog-tree.tsx +++ b/src/components/admin/catalog-tree.tsx @@ -1,103 +1,8 @@ "use client"; -import { - ChevronRight, - ChevronsDownUp, - ChevronsUpDown, - Copy, - Eye, - EyeOff, - EyeOff as EyeOffIcon, - FolderTree, - Loader2, - Package, - Pencil, - Plus, - Power, - PowerOff, - Search, - ToggleLeft, - ToggleRight, - Trash2, - X, -} from "lucide-react"; -import { useRouter } from "next/navigation"; -import { - createContext, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from "react"; -import { toast } from "sonner"; -import { useConfirmDialog } from "@/components/admin/confirm-dialog"; -import Link from "@/components/link"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuLabel, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import { Input } from "@/components/ui/input"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { adminFetch } from "@/lib/admin-fetch"; +import { Package } from "lucide-react"; +import { useState } from "react"; import { catalogueIconUrl } from "@/lib/catalog-assets"; -import { translateCaption } from "@/lib/catalog-translations"; -import { cn } from "@/lib/utils"; - -/* ─── Types ──────────────────────────────────────────────── */ - -interface TreeNode { - id: number; - caption: string; - parentId: number; - enabled: string; - visible: string; - iconImage: number; - iconColor: number; - pageLayout: string; - childCount: number; - itemCount: number; -} - -interface CatalogTreeProps { - rootPages: TreeNode[]; - catalogType?: "normal" | "bc"; -} - -/* ─── Context for expand/collapse all ────────────────────── */ - -interface TreeContext { - expandSignal: number; - collapseSignal: number; - filterMode: FilterMode; - onToggleEnabled: (node: TreeNode) => void; - onToggleVisible: (node: TreeNode) => void; - onDeletePage: (node: TreeNode) => void; - searchQuery: string; - catalogType: "normal" | "bc"; -} - -const TreeCtx = createContext({ - expandSignal: 0, - collapseSignal: 0, - filterMode: "all", - onToggleEnabled: () => {}, - onToggleVisible: () => {}, - onDeletePage: () => {}, - searchQuery: "", - catalogType: "normal", -}); /* ─── Catalog Icon ───────────────────────────────────────── */ @@ -147,773 +52,5 @@ export const LAYOUT_COLORS: Record = { single_bundle: "bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]", room_bundle: - "bg-[var(--admin-info-subtle)] text-[var(--admin-info)] dark:text-[var(--admin-info)]", + "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] dark:text-[var(--admin-warning)]", }; - -/* ─── Highlight matched text ─────────────────────────────── */ - -function HighlightText({ text, query }: { text: string; query: string }) { - if (!query.trim()) return <>{text}; - const idx = text.toLowerCase().indexOf(query.toLowerCase()); - if (idx === -1) return <>{text}; - return ( - <> - {text.slice(0, idx)} - - {text.slice(idx, idx + query.length)} - - {text.slice(idx + query.length)} - - ); -} - -/* ─── Main Tree Component ────────────────────────────────── */ - -type FilterMode = "all" | "enabled" | "disabled"; - -export function CatalogTree({ - rootPages, - catalogType = "normal", -}: CatalogTreeProps) { - const router = useRouter(); - const { confirm, dialog: confirmDialog } = useConfirmDialog(); - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState(null); - const [searching, setSearching] = useState(false); - const searchTimeout = useRef | null>(null); - const searchInputRef = useRef(null); - const [expandSignal, setExpandSignal] = useState(0); - const [collapseSignal, setCollapseSignal] = useState(0); - const [filterMode, setFilterMode] = useState("all"); - - // "/" keyboard shortcut to focus search - useEffect(() => { - function onKeyDown(e: KeyboardEvent) { - if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) { - const tag = (e.target as HTMLElement)?.tagName; - if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; - e.preventDefault(); - searchInputRef.current?.focus(); - } - } - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, []); - - // Debounced search - useEffect(() => { - if (!searchQuery.trim()) { - setSearchResults(null); - return; - } - - if (searchTimeout.current) clearTimeout(searchTimeout.current); - searchTimeout.current = setTimeout(async () => { - setSearching(true); - try { - const catParam = catalogType === "bc" ? "&catalog=bc" : ""; - const res = await adminFetch( - `/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catParam}`, - ); - if (res.ok) { - const data = await res.json(); - setSearchResults(data.pages || []); - } - } catch (error) { - console.error("[CatalogTree] Search failed:", error); - } finally { - setSearching(false); - } - }, 300); - - return () => { - if (searchTimeout.current) clearTimeout(searchTimeout.current); - }; - }, [searchQuery, catalogType]); - - // Quick actions from context menu - const catParam = catalogType === "bc" ? "&catalog=bc" : ""; - const catBody = catalogType === "bc" ? ', "catalog": "bc"' : ""; - - const handleToggleEnabled = useCallback( - async (node: TreeNode) => { - try { - const res = await adminFetch("/api/admin/catalog/tree", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: `{"pageId":${node.id},"toggleField":"toggleEnabled"${catBody}}`, - }); - if (!res.ok) throw new Error(); - toast.success( - `${node.caption} ${node.enabled === "1" ? "disabled" : "enabled"}`, - ); - router.refresh(); - } catch { - toast.error("Failed to toggle. Open the page to edit."); - } - }, - [router, catBody], - ); - - const handleToggleVisible = useCallback( - async (node: TreeNode) => { - try { - const res = await adminFetch("/api/admin/catalog/tree", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: `{"pageId":${node.id},"toggleField":"toggleVisible"${catBody}}`, - }); - if (!res.ok) throw new Error(); - toast.success( - `${node.caption} ${node.visible === "1" ? "hidden" : "visible"}`, - ); - router.refresh(); - } catch { - toast.error("Failed to toggle. Open the page to edit."); - } - }, - [router, catBody], - ); - - const handleDeletePage = useCallback( - async (node: TreeNode) => { - const childInfo = - node.childCount > 0 - ? ` Its ${node.childCount} child page(s) will be moved to parent.` - : ""; - const itemInfo = - node.itemCount > 0 ? ` ${node.itemCount} item(s) will be deleted.` : ""; - const ok = await confirm({ - title: "Delete page", - description: `Delete "${node.caption}"?${childInfo}${itemInfo}`, - confirmLabel: "Delete", - }); - if (!ok) return; - - try { - const res = await adminFetch( - `/api/admin/catalog/tree?pageId=${node.id}&mode=reparent${catParam}`, - { - method: "DELETE", - }, - ); - if (!res.ok) throw new Error(); - toast.success(`"${node.caption}" deleted`); - router.refresh(); - } catch { - toast.error("Failed to delete page."); - } - }, - [router, catParam, confirm], - ); - - const unfilteredPages = searchResults ?? rootPages; - const displayPages = - filterMode === "all" - ? unfilteredPages - : unfilteredPages.filter((p) => - filterMode === "enabled" ? p.enabled === "1" : p.enabled !== "1", - ); - - return ( - - {confirmDialog} - -
- {/* Header */} -
-
- -

Catalog Structure

- - {displayPages.length} - {filterMode !== "all" ? ` / ${unfilteredPages.length}` : ""}{" "} - pages - -
-
- {/* Status filter */} -
- - - - - -

Show all pages

-
-
- - - - - -

Show enabled only

-
-
- - - - - -

Show disabled only

-
-
-
- - {/* Expand / Collapse all */} - {searchResults === null && ( - <> - - - - - -

Expand all

-
-
- - - - - -

Collapse all

-
-
- - )} - - {/* Search */} -
- - setSearchQuery(e.target.value)} - className="h-8 pl-8 pr-8 text-sm" - onKeyDown={(e) => { - if (e.key === "Escape") { - setSearchQuery(""); - searchInputRef.current?.blur(); - } - }} - /> - {searching ? ( - - ) : ( - searchQuery && ( - - ) - )} -
-
-
- - {/* Tree body */} -
- {searchResults !== null && ( -
-

- {searchResults.length} result - {searchResults.length !== 1 ? "s" : ""} for " - {searchQuery}" -

-
- )} - {displayPages.map((page) => ( - - ))} - {displayPages.length === 0 && ( -

- {searchResults !== null - ? "No pages match your search" - : "No catalog pages found"} -

- )} -
-
-
-
- ); -} - -/* ─── Tree Item ──────────────────────────────────────────── */ - -function TreeItem({ - node, - level, - isSearchResult, - defaultExpanded, -}: { - node: TreeNode; - level: number; - isSearchResult?: boolean; - defaultExpanded?: boolean; -}) { - void isSearchResult; - const { - expandSignal, - collapseSignal, - filterMode, - searchQuery, - onToggleEnabled, - onToggleVisible, - onDeletePage, - catalogType, - } = useContext(TreeCtx); - const router = useRouter(); - const [expanded, setExpanded] = useState(false); - const [children, setChildren] = useState([]); - const [loading, setLoading] = useState(false); - const [loaded, setLoaded] = useState(false); - const hasChildren = node.childCount > 0; - - const loadChildren = useCallback(async () => { - if (loaded || !hasChildren) return; - setLoading(true); - try { - const catParam = catalogType === "bc" ? "&catalog=bc" : ""; - const res = await adminFetch( - `/api/admin/catalog/tree?parentId=${node.id}${catParam}`, - ); - if (res.ok) { - const data = await res.json(); - setChildren(data.pages || []); - setLoaded(true); - } - } catch (error) { - console.error("[CatalogTree] Failed to load children:", error); - } finally { - setLoading(false); - } - }, [node.id, loaded, hasChildren, catalogType]); - - // Auto-expand root level on mount - useEffect(() => { - if (defaultExpanded && level === 0 && hasChildren && !loaded) { - loadChildren().then(() => setExpanded(true)); - } - }, [defaultExpanded, level, hasChildren, loaded, loadChildren]); - - // Expand all signal - useEffect(() => { - if (expandSignal > 0 && hasChildren) { - if (!loaded) { - loadChildren().then(() => setExpanded(true)); - } else { - setExpanded(true); - } - } - }, [expandSignal, hasChildren, loaded, loadChildren]); - - // Collapse all signal - useEffect(() => { - if (collapseSignal > 0) { - setExpanded(false); - } - }, [collapseSignal]); - - const handleToggle = () => { - if (!expanded && !loaded) { - loadChildren().then(() => setExpanded(true)); - return; - } - setExpanded(!expanded); - }; - - const isDisabled = node.enabled !== "1"; - const isHidden = node.visible !== "1"; - const isEmpty = node.itemCount === 0 && node.childCount === 0; - const layoutColor = - LAYOUT_COLORS[node.pageLayout] || "bg-muted text-muted-foreground"; - const italianName = translateCaption(node.caption); - const displayName = italianName ?? node.caption; - - return ( -
- - - - - {/* biome-ignore lint/a11y/useSemanticElements: wrapped by Tooltip+ContextMenuTrigger asChild (Radix); a real button nests button-in-button when expanded */} -
- router.push( - catalogType === "bc" - ? `/admin/catalog/builder-club/${node.id}` - : `/admin/catalog/${node.id}`, - ) - } - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - router.push( - catalogType === "bc" - ? `/admin/catalog/builder-club/${node.id}` - : `/admin/catalog/${node.id}`, - ); - } - }} - > - {/* Expand/collapse toggle */} - {hasChildren ? ( - - ) : ( - - )} - - {/* Catalog icon */} - - - {/* Caption link */} - - - - - {italianName && ( - - - - )} - - - {/* Quick toggle enabled on hover */} - - - - - -

{isDisabled ? "Enable" : "Disable"}

-
-
- - {/* Quick add subpage */} - - - - - -

Create subpage

-
-
- - {/* Status indicators */} -
- {isHidden && ( - - )} - {isDisabled && ( - - OFF - - )} - {node.itemCount > 0 && ( - - {node.itemCount} - - )} - {/* Layout badge on hover */} - - {node.pageLayout.replace("default_3x3", "3x3")} - -
-
-
- -

- {node.caption}{" "} - - #{node.id} - -

- - - - - - - - - - - - - - - - - - - - - - - - - - - -
Layout{node.pageLayout}
Items{node.itemCount}
- Subpages - {node.childCount}
Icon{node.iconImage}
- Visible - - {node.visible === "1" ? "Yes" : "No"} -
- Enabled - - {node.enabled === "1" ? "Yes" : "No"} -
-

- Right-click for actions · Double-click to open -

-
-
-
- - {/* Right-click context menu */} - - - {node.caption} #{node.id} - - - router.push( - catalogType === "bc" - ? `/admin/catalog/builder-club/${node.id}` - : `/admin/catalog/${node.id}`, - ) - } - > - - Edit Page - - navigator.clipboard.writeText(String(node.id))} - > - - Copy ID - - - onToggleEnabled(node)}> - {node.enabled === "1" ? ( - - ) : ( - - )} - {node.enabled === "1" ? "Disable" : "Enable"} - - onToggleVisible(node)}> - {node.visible === "1" ? ( - - ) : ( - - )} - {node.visible === "1" ? "Hide" : "Show"} - - - - router.push( - catalogType === "bc" - ? `/admin/catalog?catalog=bc&newPageParent=${node.id}` - : `/admin/catalog?newPageParent=${node.id}`, - ) - } - > - - Create Subpage - - - onDeletePage(node)} - > - - Delete - - -
- - {/* Children */} - {expanded && - children.length > 0 && - (() => { - const filteredChildren = - filterMode === "all" - ? children - : children.filter((c) => - filterMode === "enabled" - ? c.enabled === "1" - : c.enabled !== "1", - ); - return filteredChildren.length > 0 ? ( -
- {/* Tree guide line */} -
- {filteredChildren.map((child) => ( - - ))} -
- ) : null; - })()} -
- ); -} diff --git a/src/components/admin/catalog/catalog-items-table/catalog-items-table.tsx b/src/components/admin/catalog/catalog-items-table/catalog-items-table.tsx index a0780065..3d847b60 100644 --- a/src/components/admin/catalog/catalog-items-table/catalog-items-table.tsx +++ b/src/components/admin/catalog/catalog-items-table/catalog-items-table.tsx @@ -33,7 +33,6 @@ import { Trash2, X, } from "lucide-react"; -import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -81,6 +80,7 @@ import { TableRow, } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { refreshCatalogTree } from "@/features/catalog/client/use-live-catalog"; import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor"; import { discoverOffers, @@ -154,7 +154,13 @@ function SortableRow({ interface CatalogItemsTableProps { initialSearch?: string; onDirtyChange?: (dirty: boolean) => void; - onRefresh?: () => void; + /** + * Required, not optional: every mutation here has to end in a refresh of the + * caller's own data. The table used to fall back to `router.refresh()` when + * this was omitted, which re-rendered the whole route to redraw one table — + * and lost the editor state and scroll position that go with it. + */ + onRefresh: () => void; items: CatalogItemData[]; pageId: number; pageLayout: string; @@ -176,7 +182,6 @@ export function CatalogItemsTable({ furniRevisionMap, allPages, }: CatalogItemsTableProps) { - const router = useRouter(); const bulk = useTranslations("pages.admin.catalog.bulk"); const discovery = useTranslations("pages.admin.catalog.discovery"); const t = useTranslations( @@ -455,8 +460,7 @@ export function CatalogItemsTable({ errorMessage: `Failed to save item #${item.id}.`, onSuccess: () => { setSavingItemId(null); - if (onRefresh) onRefresh(); - else router.refresh(); + onRefresh(); }, onError: () => setSavingItemId(null), }, @@ -500,8 +504,7 @@ export function CatalogItemsTable({ } if (savedCount > 0) { - if (onRefresh) onRefresh(); - else router.refresh(); + onRefresh(); } if (failedCount > 0) { return { @@ -533,8 +536,7 @@ export function CatalogItemsTable({ errorMessage: "Failed to delete items.", onSuccess: () => { setSelected(new Set()); - if (onRefresh) onRefresh(); - else router.refresh(); + onRefresh(); }, }); } @@ -568,8 +570,7 @@ export function CatalogItemsTable({ setSelected(new Set()); setMoveOpen(false); setMoveTargetPageId(null); - if (onRefresh) onRefresh(); - else router.refresh(); + onRefresh(); }, }, ); @@ -630,6 +631,7 @@ export function CatalogItemsTable({ run(() => reorderCatalogItems({ orders }), { successMessage: "Item order saved.", errorMessage: "Failed to save order.", + onSuccess: () => onRefresh(), }); } @@ -669,8 +671,7 @@ export function CatalogItemsTable({ onSuccess: () => { setAddOpen(false); setNewItem({ ...DEFAULT_NEW_ITEM }); - if (onRefresh) onRefresh(); - else router.refresh(); + onRefresh(); }, }, ); @@ -686,6 +687,7 @@ export function CatalogItemsTable({ run(() => deleteCatalogItems({ ids: [id] }), { successMessage: "Item deleted.", errorMessage: "Failed to delete item.", + onSuccess: () => onRefresh(), }); } @@ -721,8 +723,7 @@ export function CatalogItemsTable({ onSuccess: () => { setMoveOneId(null); setMoveOneTarget(null); - if (onRefresh) onRefresh(); - else router.refresh(); + onRefresh(); }, }, ); @@ -751,6 +752,7 @@ export function CatalogItemsTable({ { successMessage: `Item #${item.id} duplicated.`, errorMessage: "Failed to duplicate item.", + onSuccess: () => onRefresh(), }, ); } @@ -792,8 +794,7 @@ export function CatalogItemsTable({ onSuccess: () => { setEditingItem(null); setEditingBase(null); - if (onRefresh) onRefresh(); - else router.refresh(); + onRefresh(); }, }, ); @@ -882,8 +883,7 @@ export function CatalogItemsTable({ onApplied={() => { setBulkRefreshFrom(items); setSelected(new Set()); - if (onRefresh) onRefresh(); - else router.refresh(); + onRefresh(); }} /> + + ) : ( + <> + + Updating catalog… + + )} +

+ ); +} + export const CatalogRail = memo(function CatalogRail({ tree, selectedId, @@ -77,6 +138,7 @@ export const CatalogRail = memo(function CatalogRail({ > All categories +
{visible.length === 0 && (

diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index bbc274f3..164b78be 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -65,8 +65,8 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { - ensureCatalogTreeLoaded, refreshCatalogTree, + useCatalogSync, useLiveCatalog, } from "@/features/catalog/client/use-live-catalog"; import { adminFetch } from "@/lib/admin-fetch"; @@ -202,9 +202,9 @@ export function StudioClient({ const tree = useLiveCatalog().tree; const [selectedPage, setSelectedPage] = useState(null); const [railOpen, setRailOpen] = useState(false); + useCatalogSync(); useEffect(() => { setRailOpen(window.matchMedia("(min-width: 1024px)").matches); - void ensureCatalogTreeLoaded(); }, []); // Search & list diff --git a/src/features/catalog/client/use-catalog-sync.test.tsx b/src/features/catalog/client/use-catalog-sync.test.tsx new file mode 100644 index 00000000..88f7d6e8 --- /dev/null +++ b/src/features/catalog/client/use-catalog-sync.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const adminFetch = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/admin-fetch", () => ({ adminFetch })); + +import { + checkCatalogRevision, + resetLiveCatalogForTests, + useCatalogSync, + useLiveCatalog, +} from "./use-live-catalog"; + +function page(id: number, caption = `page ${id}`) { + return { + id, + caption, + parentId: -1, + depth: 0, + orderNum: 0, + enabled: "1", + visible: "1", + iconImage: 0, + iconColor: 0, + pageLayout: "default_3x3", + childCount: 0, + itemCount: 0, + }; +} + +/** What the server currently looks like, as the route would report it. */ +const server = { + revision: "rev-1", + pages: [page(1)], + treeFails: false, +}; + +function respond(body: unknown) { + return { + ok: true, + status: 200, + json: async () => body, + headers: new Headers(), + } as unknown as Response; +} + +function serve(): Response { + const url = String(adminFetch.mock.calls.at(-1)?.[0] ?? ""); + if (url.includes("/catalog/revision")) + return respond({ revision: server.revision }); + if (server.treeFails) { + return { + ok: false, + status: 500, + json: async () => ({}), + headers: new Headers(), + } as unknown as Response; + } + return respond({ + pages: server.pages, + totals: null, + revision: server.revision, + }); +} + +function Probe() { + // The real interval is long; each test drives the revision check itself. + useCatalogSync(600_000); + const { tree, refreshError } = useLiveCatalog(); + return ( +

+ + {tree.map((node) => node.caption).join(",")} + + {refreshError ? stale : null} +
+ ); +} + +let container: HTMLDivElement; +let root: Root; +const navigations: string[] = []; + +async function render() { + await act(async () => { + root.render(); + await Promise.resolve(); + }); +} + +function captions() { + return container.querySelector('[data-testid="captions"]')?.textContent ?? ""; +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe("useCatalogSync", () => { + beforeEach(async () => { + resetLiveCatalogForTests(); + adminFetch.mockReset(); + server.revision = "rev-1"; + server.pages = [page(1)]; + server.treeFails = false; + adminFetch.mockImplementation(serve); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + navigations.length = 0; + window.addEventListener("beforeunload", () => navigations.push("unload")); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it("updates the mounted tree when an import finishes in another session", async () => { + await render(); + expect(captions()).toBe("page 1"); + + // Another tab, browser or the job worker wrote the catalog. + server.revision = "rev-2"; + server.pages = [page(1), page(2, "Imported Furniture")]; + await act(async () => { + await checkCatalogRevision(); + }); + + expect(captions()).toBe("page 1,Imported Furniture"); + // Nothing about this update went through the router: the document was + // never torn down and no navigation was triggered. + expect(navigations).toEqual([]); + expect(document.body.contains(container)).toBe(true); + }); + + it("follows a catalog write announced by another tab", async () => { + await render(); + const otherTab = new BroadcastChannel("atom-cms-catalog"); + + server.revision = "rev-3"; + server.pages = [page(1), page(7, "Chairs")]; + otherTab.postMessage({ revision: "rev-3" }); + await flush(); + + expect(captions()).toBe("page 1,Chairs"); + otherTab.close(); + }); + + it("keeps the previous tree and marks it stale when the read fails", async () => { + await render(); + server.treeFails = true; + server.revision = "rev-4"; + + await act(async () => { + await checkCatalogRevision(); + }); + + expect(captions()).toBe("page 1"); + expect(container.querySelector('[data-testid="stale"]')).not.toBeNull(); + }); +}); diff --git a/src/features/catalog/client/use-live-catalog.test.ts b/src/features/catalog/client/use-live-catalog.test.ts index 02b8c331..31478453 100644 --- a/src/features/catalog/client/use-live-catalog.test.ts +++ b/src/features/catalog/client/use-live-catalog.test.ts @@ -5,14 +5,21 @@ vi.mock("@/lib/admin-fetch", () => ({ adminFetch })); import { applyCatalogDelta, + checkCatalogRevision, + ensureCatalogTreeLoaded, getLiveCatalogSnapshot, refreshCatalogTree, resetLiveCatalogForTests, seedCatalogTotals, } from "./use-live-catalog"; -function ok(body: unknown) { - return { ok: true, json: async () => body } as unknown as Response; +function ok(body: unknown, init: { etag?: string } = {}) { + return { + ok: true, + status: 200, + json: async () => body, + headers: new Headers(init.etag ? { etag: init.etag } : {}), + } as unknown as Response; } function page(id: number, over: Record = {}) { @@ -153,7 +160,7 @@ describe("refreshCatalogTree", () => { expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1, 2]); }); - it("leaves the previous snapshot alone when the read fails", async () => { + it("keeps the tree it holds when the read fails, and says so", async () => { adminFetch.mockResolvedValueOnce(ok({ pages: [page(1)] })); await refreshCatalogTree(); adminFetch.mockResolvedValueOnce({ @@ -164,6 +171,159 @@ describe("refreshCatalogTree", () => { await refreshCatalogTree(); + const snapshot = getLiveCatalogSnapshot(); + expect(snapshot.tree.map((n) => n.id)).toEqual([1]); + // A rail that cannot refresh must say so: showing the previous tree as if + // it were current is the silent-staleness bug this error exists for. + expect(snapshot.refreshError).toContain("500"); + expect(snapshot.refreshing).toBe(false); + }); + + it("clears the error once a later read succeeds", async () => { + adminFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({}), + } as unknown as Response); + await refreshCatalogTree(); + expect(getLiveCatalogSnapshot().refreshError).toContain("503"); + + adminFetch.mockResolvedValueOnce(ok({ pages: [page(1)] })); + await refreshCatalogTree(); + + expect(getLiveCatalogSnapshot().refreshError).toBeNull(); + }); + + it("reports a read in flight while it runs", async () => { + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + adminFetch.mockImplementationOnce(async () => { + expect(getLiveCatalogSnapshot().refreshing).toBe(true); + await gate; + return ok({ pages: [] }); + }); + + const read = refreshCatalogTree(); + release?.(); + await read; + + expect(getLiveCatalogSnapshot().refreshing).toBe(false); + }); +}); + +describe("catalog revision", () => { + beforeEach(() => { + resetLiveCatalogForTests(); + adminFetch.mockReset(); + }); + + it("adopts the revision the tree route reports", async () => { + adminFetch.mockResolvedValueOnce( + ok({ pages: [page(1)], revision: "rev-1" }, { etag: '"rev-1"' }), + ); + + await refreshCatalogTree(); + + expect(getLiveCatalogSnapshot().revision).toBe("rev-1"); + }); + + it("does not re-read the tree when the revision is unchanged", async () => { + adminFetch.mockResolvedValueOnce( + ok({ pages: [page(1)], revision: "rev-1" }, { etag: '"rev-1"' }), + ); + await refreshCatalogTree(); + adminFetch.mockResolvedValueOnce(ok({ revision: "rev-1" })); + + await checkCatalogRevision(); + + expect(adminFetch).toHaveBeenCalledTimes(2); + expect(adminFetch.mock.calls[1][0]).toContain("/catalog/revision"); + }); + + it("re-reads the tree when another session moved the revision on", async () => { + adminFetch.mockResolvedValueOnce( + ok({ pages: [page(1)], revision: "rev-1" }, { etag: '"rev-1"' }), + ); + await refreshCatalogTree(); + + adminFetch.mockResolvedValueOnce(ok({ revision: "rev-2" })); + adminFetch.mockResolvedValueOnce( + ok({ pages: [page(1), page(2)], revision: "rev-2" }, { etag: '"rev-2"' }), + ); + + await checkCatalogRevision(); + + expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1, 2]); + expect(getLiveCatalogSnapshot().revision).toBe("rev-2"); + }); + + it("probes the revision on every mount instead of trusting the session", async () => { + adminFetch.mockResolvedValueOnce( + ok({ pages: [page(1)], revision: "rev-1" }, { etag: '"rev-1"' }), + ); + await refreshCatalogTree(); + + // A second mount of the tree in the same session used to be a no-op, which + // is why a session that started before an import kept showing the old + // catalog. + adminFetch.mockResolvedValueOnce(ok({ revision: "rev-1" })); + adminFetch.mockResolvedValueOnce(ok({ revision: "rev-2" })); + adminFetch.mockResolvedValueOnce( + ok({ pages: [page(1), page(9)], revision: "rev-2" }, { etag: '"rev-2"' }), + ); + await ensureCatalogTreeLoaded(); + await ensureCatalogTreeLoaded(); + + expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1, 9]); + }); + + it("replays the ETag so an unchanged catalog answers 304", async () => { + adminFetch.mockResolvedValueOnce( + ok({ pages: [page(1)], revision: "rev-1" }, { etag: '"rev-1"' }), + ); + await refreshCatalogTree(); + + adminFetch.mockResolvedValueOnce({ + ok: false, + status: 304, + json: async () => ({}), + headers: new Headers({ etag: '"rev-1"' }), + } as unknown as Response); + adminFetch.mockResolvedValueOnce(ok({ revision: "rev-2" })); + adminFetch.mockResolvedValueOnce( + ok({ pages: [page(1), page(2)], revision: "rev-2" }, { etag: '"rev-2"' }), + ); + + await refreshCatalogTree(); + // 304 means nothing changed: the tree stays, and no error is invented. expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1]); + expect(getLiveCatalogSnapshot().refreshError).toBeNull(); + + await checkCatalogRevision(); + + expect(getLiveCatalogSnapshot().tree.map((n) => n.id)).toEqual([1, 2]); + expect(adminFetch.mock.calls[1][1]).toMatchObject({ + headers: { "if-none-match": '"rev-1"' }, + }); + }); + + it("stays quiet when the probe itself fails", async () => { + adminFetch.mockResolvedValueOnce( + ok({ pages: [page(1)], revision: "rev-1" }, { etag: '"rev-1"' }), + ); + await refreshCatalogTree(); + adminFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({}), + } as unknown as Response); + + await checkCatalogRevision(); + + // The tree may be perfectly current; a failed probe is not a failed read. + expect(getLiveCatalogSnapshot().refreshError).toBeNull(); + expect(getLiveCatalogSnapshot().revision).toBe("rev-1"); }); }); diff --git a/src/features/catalog/client/use-live-catalog.ts b/src/features/catalog/client/use-live-catalog.ts index 6a36675a..e0d6a26d 100644 --- a/src/features/catalog/client/use-live-catalog.ts +++ b/src/features/catalog/client/use-live-catalog.ts @@ -1,6 +1,6 @@ "use client"; -import { useSyncExternalStore } from "react"; +import { useEffect, useSyncExternalStore } from "react"; import { adminFetch } from "@/lib/admin-fetch"; import type { TreeNode } from "@/types/catalog"; import { @@ -19,12 +19,21 @@ interface LiveCatalogSnapshot { tree: TreeNode[]; totals: Record; treeLoaded: boolean; + /** Catalog state the tree was read at, as bumped by `sendCatalogUpdate()`. */ + revision: string | null; + /** A read is in flight; the rail shows this instead of pretending to be live. */ + refreshing: boolean; + /** Last read failure. Never swallowed: a rail that cannot refresh must say so. */ + refreshError: string | null; } const EMPTY_SNAPSHOT: LiveCatalogSnapshot = { tree: [], totals: { normal: null, bc: null }, treeLoaded: false, + revision: null, + refreshing: false, + refreshError: null, }; // A module-level external store keeps the catalog tree alive across client @@ -93,14 +102,38 @@ export function applyCatalogDelta(delta: CatalogTreeDelta): void { }); } +const TREE_URL = "/api/admin/catalog/tree?mode=full"; +const REVISION_URL = "/api/admin/catalog/revision"; +const CHANNEL = "atom-cms-catalog"; + +/** Last ETag the server handed out, replayed as `If-None-Match` on the next read. */ +let treeEtag: string | null = null; +let treeRequest: Promise | null = null; +let treeRequestQueued = false; + async function loadTree(): Promise { - const res = await adminFetch("/api/admin/catalog/tree?mode=full"); + const res = await adminFetch( + TREE_URL, + treeEtag + ? { cache: "no-store", headers: { "if-none-match": treeEtag } } + : { cache: "no-store" }, + ); + // 304 means the catalog state this tree was read at is still current: nothing + // changed, so nothing to re-render. This is what keeps the poll cheap on a + // large catalog, where the flat tree is by far the biggest payload here. + if (res.status === 304) { + emit({ ...snapshot, refreshing: false, refreshError: null }); + return; + } if (!res.ok) throw new Error(`Catalog tree refresh failed (${res.status})`); + treeEtag = res.headers.get("etag") ?? treeEtag; const data = await res.json(); // The route answers with live totals alongside the tree: importing writes // catalog rows server-side, so the numbers the store holds would otherwise // stay pre-import for the rest of the session. const totals = normalizeCatalogTotals(data?.totals); + const revision = + typeof data?.revision === "string" ? data.revision : snapshot.revision; emit({ ...snapshot, tree: normalizeTreePages(data?.pages), @@ -109,12 +142,13 @@ async function loadTree(): Promise { normal: totals ?? snapshot.totals.normal, }, treeLoaded: true, + revision, + refreshing: false, + refreshError: null, }); + if (revision) broadcastRevision(revision); } -let treeRequest: Promise | null = null; -let treeRequestQueued = false; - /** * Reload the whole tree and totals over the API. Used where the server decides * the shape of the result — the furniture importers derive their category pages @@ -131,15 +165,24 @@ export function refreshCatalogTree(): Promise { treeRequestQueued = true; return treeRequest; } + if (!snapshot.refreshing) emit({ ...snapshot, refreshing: true }); treeRequest = (async () => { try { do { treeRequestQueued = false; await loadTree(); } while (treeRequestQueued); - } catch { - // A failed read keeps the previous snapshot; the next mount or - // mutation tries again. + } catch (error) { + // Keep the previous tree — stale beats empty — but never silently: + // without an error in the snapshot the rail would show pre-import + // counts as if they were current. + treeEtag = null; + emit({ + ...snapshot, + refreshing: false, + refreshError: + error instanceof Error ? error.message : "Catalog refresh failed", + }); } finally { treeRequest = null; } @@ -147,14 +190,89 @@ export function refreshCatalogTree(): Promise { return treeRequest; } -/** Load the tree once per session; later calls are no-ops. */ +/** + * Compare the server's revision against the one this tree was read at and + * refresh only when they differ. Cheap enough to run on every mount, on every + * focus and on a slow poll, which is what closes the gaps a single-tab store + * leaves: an import finished in another tab, another browser or the job worker + * has to show up here too. + */ +export async function checkCatalogRevision(): Promise { + const res = await adminFetch(REVISION_URL, { cache: "no-store" }); + // A failed probe is not worth reporting: the tree itself may be perfectly + // current, and the next poll tries again. + if (!res.ok) return; + let revision: unknown; + try { + revision = (await res.json())?.revision; + } catch { + return; + } + if (typeof revision !== "string" || !revision) return; + if (revision === snapshot.revision) return; + await refreshCatalogTree(); +} + +/** + * Load the tree when this session has none, otherwise ask the server whether + * what we hold is still current. The tree used to be read once per session, + * which is exactly why a session that started before an import could keep + * showing the pre-import catalog. + */ export function ensureCatalogTreeLoaded(): Promise { - return snapshot.treeLoaded ? Promise.resolve() : refreshCatalogTree(); + return snapshot.treeLoaded ? checkCatalogRevision() : refreshCatalogTree(); +} + +// ── Cross-tab ──────────────────────────────────────────────────────────── + +let channel: BroadcastChannel | null = null; + +function broadcastRevision(revision: string): void { + // One tab's refresh tells the others; their own refresh then finds the + // revision it already has and stops, so this cannot loop. + channel?.postMessage({ revision }); +} + +function getChannel(): BroadcastChannel | null { + if (channel || typeof BroadcastChannel === "undefined") return channel; + channel = new BroadcastChannel(CHANNEL); + channel.addEventListener("message", (event) => { + const revision = (event.data as { revision?: unknown } | null)?.revision; + if (typeof revision !== "string" || revision === snapshot.revision) return; + void refreshCatalogTree(); + }); + return channel; +} + +/** + * Keep a mounted admin view honest about catalog writes it did not make itself: + * load once, then poll the server revision, re-check on focus, and follow + * writes from other tabs. Mount it once per view that renders the tree. + */ +export function useCatalogSync(intervalMs = 20_000): void { + useEffect(() => { + void ensureCatalogTreeLoaded(); + getChannel(); + const recheck = () => { + if (document.visibilityState === "visible") void checkCatalogRevision(); + }; + const interval = setInterval(recheck, intervalMs); + document.addEventListener("visibilitychange", recheck); + window.addEventListener("focus", recheck); + return () => { + clearInterval(interval); + document.removeEventListener("visibilitychange", recheck); + window.removeEventListener("focus", recheck); + }; + }, [intervalMs]); } /** Test seam: drop all live catalog state between cases. */ export function resetLiveCatalogForTests(): void { treeRequest = null; treeRequestQueued = false; + treeEtag = null; + channel?.close(); + channel = null; emit(EMPTY_SNAPSHOT); } diff --git a/src/features/catalog/server/catalog-revision.ts b/src/features/catalog/server/catalog-revision.ts new file mode 100644 index 00000000..43cb4a7e --- /dev/null +++ b/src/features/catalog/server/catalog-revision.ts @@ -0,0 +1,62 @@ +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"; + +const FILE = "catalog-revision.json"; + +/** + * A cheap "did the catalog change?" token for every open admin session. + * + * `sendCatalogUpdate()` runs after each catalog write, so bumping this there + * covers every import, page edit, item edit and organize run without touching + * each call site. Clients compare the revision they loaded against the current + * one to decide whether their tree is stale: one tiny file read instead of + * re-reading the whole tree every few seconds. + * + * The value is random rather than a counter. Two CMS processes writing at the + * same moment must never hand out the same revision, and consumers only ever + * compare for inequality, so a monotonic number would buy nothing. + * + * Like the totals cache this only tracks writes made through the CMS. A direct + * database edit leaves the revision untouched, which is exactly when a reload + * is warranted anyway. + */ +export async function bumpCatalogRevision(): Promise { + const revision = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`; + const root = catalogStateRoot(); + const temp = path.join(root, `revision-${randomUUID()}.tmp`); + try { + await fs.mkdir(root, { recursive: true }); + await fs.writeFile(temp, JSON.stringify({ revision }), { flag: "wx" }); + await fs.rename(temp, path.join(root, FILE)); + } catch (error) { + logger.error("Cannot persist catalog revision", { + module: "catalog", + error, + }); + } finally { + await fs.rm(temp, { force: true }).catch(() => {}); + } + return revision; +} + +export async function readCatalogRevision(): Promise { + try { + const parsed = JSON.parse( + await fs.readFile(path.join(catalogStateRoot(), FILE), "utf8"), + ) as { revision?: unknown }; + return typeof parsed.revision === "string" ? parsed.revision : null; + } catch (error) { + // A missing file means "no revision yet", which every client reads as + // stale — the correct answer on a fresh install. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + logger.error("Cannot read catalog revision", { + module: "catalog", + error, + }); + return null; + } +} diff --git a/src/features/catalog/server/sync-status.ts b/src/features/catalog/server/sync-status.ts index f3efe6a7..22edf148 100644 --- a/src/features/catalog/server/sync-status.ts +++ b/src/features/catalog/server/sync-status.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { logger } from "@/lib/logger"; import { catalogStateRoot } from "@/lib/services/catalog-git-config"; import { rcon } from "@/lib/services/rcon"; +import { bumpCatalogRevision } from "./catalog-revision"; import { invalidateCatalogTotals } from "./catalog-totals"; export interface CatalogHotelStatus { sent: boolean; @@ -44,6 +45,10 @@ export async function sendCatalogUpdate(): Promise { // Callers reach here only after writing catalog rows, so the admin stats // totals must not keep serving the pre-write numbers from their 30s cache. invalidateCatalogTotals(); + // Every catalog write lands here, which makes this the one place that can + // tell open admin sessions their tree is stale — including writes made by a + // different tab, browser or process than the one watching. + await bumpCatalogRevision(); const status = { sent, checkedAt: new Date().toISOString(), reference }; const root = catalogStateRoot(); const temp = path.join(root, `hotel-${randomUUID()}.tmp`); diff --git a/src/lib/api.ts b/src/lib/api.ts index 89fd1f14..31cdc4dd 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -40,8 +40,11 @@ export function apiJson(data: unknown, init?: ResponseInit): NextResponse { } /** Standard success envelope: { ok: true, ...data }. */ -export function apiOk(data?: Record): NextResponse { - return apiJson({ ok: true, ...data }); +export function apiOk( + data?: Record, + init?: ResponseInit, +): NextResponse { + return apiJson({ ok: true, ...data }, init); } /** Standard error envelope. */ diff --git a/src/lib/furni/offer-purchasability.test.ts b/src/lib/furni/offer-purchasability.test.ts new file mode 100644 index 00000000..b2695371 --- /dev/null +++ b/src/lib/furni/offer-purchasability.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + type OfferPurchasabilityInput, + offerPurchasabilityProblems, +} from "./offer-purchasability"; + +function offer(over: Partial = {}) { + return { + pageEnabled: "1", + pageVisible: "1", + itemIds: "1234", + amount: 1, + costCredits: 10, + costPoints: 0, + offerId: 500, + haveOffer: "1", + furniId: 1234, + ...over, + }; +} + +describe("offerPurchasabilityProblems", () => { + it("accepts an offer the hotel can sell", () => { + expect(offerPurchasabilityProblems(offer())).toEqual([]); + }); + + it("accepts a free offer and one without an offer id", () => { + expect(offerPurchasabilityProblems(offer({ costCredits: 0 }))).toEqual([]); + expect(offerPurchasabilityProblems(offer({ offerId: -1 }))).toEqual([]); + }); + + it("reports a page the client will not show", () => { + expect(offerPurchasabilityProblems(offer({ pageVisible: "0" }))).toEqual([ + "catalog page is hidden", + ]); + expect(offerPurchasabilityProblems(offer({ pageEnabled: "0" }))).toEqual([ + "catalog page is disabled", + ]); + }); + + it("reports item_ids that do not name the imported furni", () => { + expect(offerPurchasabilityProblems(offer({ furniId: 9999 }))).toEqual([ + "item_ids does not contain furni id 9999 (it holds 1234)", + ]); + expect( + offerPurchasabilityProblems(offer({ itemIds: " 1234 ; 1235 " })), + ).toEqual([]); + expect(offerPurchasabilityProblems(offer({ itemIds: "" }))).toEqual([ + "catalog item has no furni id in item_ids", + ]); + }); + + it("reports an offer that would deliver nothing", () => { + expect(offerPurchasabilityProblems(offer({ amount: 0 }))).toEqual([ + "amount is 0, so the offer delivers nothing", + ]); + }); + + it("reports a negative price and a broken offer id", () => { + expect(offerPurchasabilityProblems(offer({ costCredits: -1 }))).toEqual([ + "offer has a negative price", + ]); + expect(offerPurchasabilityProblems(offer({ haveOffer: "0" }))).toEqual([ + "offer_id is set but haveOffer is not 1", + ]); + }); + + it("lists every problem at once", () => { + expect( + offerPurchasabilityProblems( + offer({ pageVisible: "0", pageEnabled: "0", amount: 0 }), + ), + ).toEqual([ + "catalog page is disabled", + "catalog page is hidden", + "amount is 0, so the offer delivers nothing", + ]); + }); +}); diff --git a/src/lib/furni/offer-purchasability.ts b/src/lib/furni/offer-purchasability.ts new file mode 100644 index 00000000..15881946 --- /dev/null +++ b/src/lib/furni/offer-purchasability.ts @@ -0,0 +1,69 @@ +/** + * Why a catalog offer can be "imported successfully" and still be invisible in + * the hotel. + * + * The import writes `catalog_items` plus a `furnidata` entry and reports `ok`. + * Whether the emulator will actually sell the offer depends on a handful of + * conditions the import never asserts: the page has to be enabled and visible, + * `item_ids` has to name the furni id, `amount` has to be positive. Every one of + * them looks fine in the CMS and produces a client that shows nothing, which is + * reported as "the import did not work". + * + * This module is the pure part of that check so the rules can be tested without + * a database; `importSingleFurni` supplies the row it read back. + */ + +export interface OfferPurchasabilityInput { + /** Catalog page the offer sits on. */ + pageEnabled: string; + pageVisible: string; + /** Raw `catalog_items.item_ids`, `;`-separated. */ + itemIds: string; + amount: number; + costCredits: number; + costPoints: number; + /** `-1` means the offer is not wired up. */ + offerId: number; + haveOffer: string; + /** The `catalog_item_base.id` the import just wrote. */ + furniId: number; +} + +/** Offer id used by Habbo DBs for "this offer has no offer id". */ +const NO_OFFER_ID = -1; + +function parsesItemIds(itemIds: string): number[] { + return itemIds + .split(/[;,]/) + .map((part) => Number(part.trim())) + .filter((value) => Number.isFinite(value) && value > 0); +} + +/** Problems that stop the hotel from selling the offer, in reporting order. */ +export function offerPurchasabilityProblems( + offer: OfferPurchasabilityInput, +): string[] { + const problems: string[] = []; + const ids = parsesItemIds(offer.itemIds); + if (ids.length === 0) { + problems.push("catalog item has no furni id in item_ids"); + } else if (!ids.includes(offer.furniId)) { + problems.push( + `item_ids does not contain furni id ${offer.furniId} (it holds ${ids.join(", ")})`, + ); + } + if (offer.pageEnabled !== "1") problems.push("catalog page is disabled"); + if (offer.pageVisible !== "1") problems.push("catalog page is hidden"); + if (!Number.isFinite(offer.amount) || offer.amount < 1) + problems.push(`amount is ${offer.amount}, so the offer delivers nothing`); + if (offer.costCredits < 0 || offer.costPoints < 0) + problems.push("offer has a negative price"); + if ( + offer.offerId !== NO_OFFER_ID && + offer.haveOffer !== "1" && + ids.length > 0 + ) { + problems.push("offer_id is set but haveOffer is not 1"); + } + return problems; +} diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index c5441139..768c0fbe 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { and, eq, type SQL, sql } from "drizzle-orm"; import { CatalogPages, db, execResult, ItemsBase, queryRows } from "@/lib/db"; +import { offerPurchasabilityProblems } from "@/lib/furni/offer-purchasability"; import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel"; import { logger } from "@/lib/logger"; import { logServerError } from "@/lib/server-log"; @@ -525,6 +526,55 @@ export async function allocateCatalogItemId( /** Test seam: drop the cached id counter between tests. */ +/** + * Read a written offer back and report everything that would keep the hotel from + * selling it. Best-effort by design: a failed probe must never fail an import + * that already wrote its files, so this returns no problems on error. + */ +async function catalogOfferProblems( + catalogItemId: number, + furniId: number, +): Promise { + try { + const rows = await queryRows<{ + item_ids: string; + amount: number; + cost_credits: number; + cost_points: number; + offer_id: number; + haveOffer: string; + page_enabled: string; + page_visible: string; + }>(sql` + SELECT ci.item_ids, ci.amount, ci.cost_credits, ci.cost_points, ci.offer_id, ci.haveOffer, + cp.enabled AS page_enabled, cp.visible AS page_visible + FROM catalog_items ci + LEFT JOIN catalog_pages cp ON cp.id = ci.page_id + WHERE ci.id = ${catalogItemId} + LIMIT 1 + `); + const row = rows[0]; + if (!row) return ["catalog entry disappeared right after writing it"]; + return offerPurchasabilityProblems({ + pageEnabled: String(row.page_enabled ?? "0"), + pageVisible: String(row.page_visible ?? "0"), + itemIds: String(row.item_ids ?? ""), + amount: Number(row.amount), + costCredits: Number(row.cost_credits), + costPoints: Number(row.cost_points), + offerId: Number(row.offer_id), + haveOffer: String(row.haveOffer ?? "0"), + furniId, + }); + } catch (error) { + logger.warn("[import-furni] Cannot verify the imported offer", { + error: (error as Error).message, + catalogItemId, + }); + return []; + } +} + export async function importSingleFurni(params: { id: number; classname: string; @@ -1149,6 +1199,25 @@ export async function importSingleFurni(params: { warnings.push("Catalog entry creation failed"); } + if (catalogItemId !== null) { + // The write above is not proof the hotel will sell anything: a hidden or + // disabled page, an `item_ids` that misses the furni id or a zero amount + // all import "successfully" and show the player nothing. Read the row back + // and say so here, where the import report already shows up, instead of + // letting it surface as "the import did not work" in the client. + const problems = await catalogOfferProblems(catalogItemId, itemId); + for (const problem of problems) { + warnings.push(`Offer not purchasable: ${problem}`); + } + if (problems.length > 0) { + logger.warn("[import-furni] Imported offer is not purchasable", { + classname, + catalogItemId, + problems, + }); + } + } + return { ok: true, itemId,