diff --git a/docs/CATALOG_REFACTOR_PLAN.md b/docs/CATALOG_REFACTOR_PLAN.md new file mode 100644 index 0000000000..5559044b95 --- /dev/null +++ b/docs/CATALOG_REFACTOR_PLAN.md @@ -0,0 +1,118 @@ +# Refactor del catalogo HK — analisi e programma + +Data: 6 settembre 2026. Base verificata: main, commit 9b0ea2fb. Documento di proposta, non implementazione approvata. Il sito /admin/catalog reindirizza al login senza sessione staff: nessuna prova delle mutazioni sul database di produzione. Le criticità indicate sono percorsi verificati nel codice; gli effetti concorrenti richiedono riproduzione controllata. + +## Obiettivo e perimetro + +Un catalogo HK con un solo ambiente di lavoro, regole coerenti e operazioni recuperabili. Conservare stile HK, icone reali, salvataggio diretto, catalogo normale e Builder Club. Nessun ritorno dei Preferiti. + +Inclusi: albero, categorie, offerte, prezzi, bundle, disponibilità, proprietà condivise dei furni, traduzioni, ricerca, anteprima, operazioni massive, manutenzione, collegamenti a Catalog Studio e sincronizzazione Git/hotel. + +Catalog Studio conserva la responsabilità di importare, convertire e riparare .nitro, icone e furnidata. Il refactor collega questi strumenti alla selezione del catalogo; non comporta riscrivere il convertitore, cambiare protocollo dell'emulatore o sostituire il sistema Git/Gitea già esistente. + +## Inventario verificato + +Sono già presenti virtualizzazione dell'albero, trascinamento, multiselezione, griglia/tabella, editor dei prezzi, anteprima negozio, import massivo, traduzioni, manutenzione e coda di export Git. Vanno riutilizzati. + +| File | Righe attuali | Responsabilità da separare | +|---|---:|---| +| src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx | 2342 | Rendering, selezione, editor offerta/furno, prezzi massivi, drag and drop, dialoghi | +| src/components/admin/catalog-manager/sortable-tree.tsx | 1135 | Lettura albero, mutazioni, ricerca, trascinamento, comandi | +| src/components/admin/catalog-manager/inline-editor.tsx | 775 | Fetch, stato modifiche, schede, salvataggio, anteprima | +| src/app/admin/catalog/[id]/catalog-page-form.tsx | 738 | Campi, layout, media, salvataggio | +| src/app/admin/catalog/[id]/catalog-translate-tab.tsx | 617 | Selezione, proposta traduzioni, applicazione | +| src/app/admin/catalog/builder-club/bc-manager.tsx | 608 | Gestione parallela BC | +| src/app/admin/catalog/page.tsx | 526 | Query, conteggi, varianti normale/BC, composizione UI | + +Le dimensioni aiutano a trovare i punti di intervento: l'obiettivo non è un limite arbitrario di righe, ma responsabilità verificabili e riutilizzabili. + +## Problemi e interventi + +| Priorità | Evidenza | Intervento | +|---|---|---| +| P0 | sortable-tree.tsx invia il riordino dei fratelli con Promise.allSettled, una action per riga; catalog.ts aggiorna RCON per ciascuna | Un comando batch con lista completa, validazione, transazione e un solo evento di aggiornamento | +| P0 | catalog-items.ts riordina le offerte con update sequenziali fuori transazione | Stesso contratto atomico per l'ordine delle offerte | +| P0 | updateCatalogPage accetta parentId direttamente; il controllo cicli è separato in movePage | Validazione comune per creazione, form, spostamento e API; controllare destinazioni inesistenti e concorrenza | +| P0 | deletePage normale sposta figli, elimina offerte e pagina separatamente | Transazione, analisi dell'impatto e snapshot ripristinabile | +| P0 | updateCatalogItem modifica pagina, offerta e items_base con scritture separate | Transazione e verifica che il furno appartenga all'offerta; scope distinto per proprietà condivise | +| P1 | cascadeDelete non mantiene un insieme di nodi visitati; il calcolo profondità BC è ricorsivo senza guardia ai cicli | Lettura tollerante di dati incoerenti, diagnostica e arresto sicuro delle traversate | +| P1 | handleEditTab modifica lo stato prima della conferma; chiusura X bypassa la protezione | Un unico controllo delle modifiche per cambio pagina, offerta, scheda, uscita e navigazione | +| P1 | loadPage/loadItemsData non annullano o identificano la richiesta precedente | AbortController e identità della selezione; solo la risposta corrente può aggiornare l'editor | +| P1 | Il salvataggio ignora il booleano restituito da RCON; Git opera in coda | Distinguere DB salvato, invio hotel riuscito/fallito e stato Git; retry senza risalvare i dati | +| P1 | loadCatalogItemsData usa Number(value) || fallback per order_number, offer_id e amount | Definire semantica di zero/null per campo e testare il round trip prima di cambiare i fallback | +| P2 | Tutte le offerte e metadati sono caricati insieme; filtro con CAST(page_id AS CHAR) | Misurare query/payload, separare elenco e dettagli, paginazione e adapter compatibile INT/VARCHAR | +| P2 | Editor normale/BC e form condividono solo parte delle regole; testi anche letterali | Contratti comuni, differenze BC esplicite, traduzioni e permessi coerenti | + +Riferimenti principali: src/actions/catalog.ts, src/actions/catalog-items.ts, src/actions/catalog-bc.ts, src/lib/services/catalog-tree.ts, src/lib/services/catalog-items-loader.ts, src/app/api/admin/catalog/tree/route.ts, src/components/admin/catalog-manager/catalog-manager-dialog.tsx, src/components/admin/catalog-manager/inline-editor.tsx. + +## Alternative + +1. **Pulizia dei file mantenendo tutti gli editor:** rischio iniziale basso, ma conserva duplicazioni e differenze operative. Utile solo come passaggio iniziale. +2. **Refactor progressivo con un editor principale — consigliato:** servizi comuni prima, poi promozione del Visual Manager a pagina. Permette piccoli rilasci e confronti tra vecchio e nuovo percorso. +3. **Riscrittura completa:** libertà maggiore, ma più rischio di perdere casi speciali, compatibilità DB e funzioni già presenti. Non giustificata dall'inventario attuale. + +## Architettura proposta + +Modulo src/features/catalog con confini chiari: + +- domain/: tipi Page, Offer, FurnitureReference, CatalogKind; validazione gerarchie, prezzi, bundle e disponibilità; nessuna dipendenza React/DB. +- server/queries/: letture albero, elenco offerte, dettaglio e ricerca; output serializzabile esplicito. +- server/commands/: create/update/move/reorder/delete; autorizzazione, validazione, transazioni, controllo revisione e audit. +- server/repositories/: accesso Drizzle e compatibilità delle colonne; adapter normale/BC senza fingere che tutti i campi coincidano. +- client/: stato selezione, modifiche locali, operazioni in corso, caricamento e gestione conflitti. +- components/: albero, elenco offerte, editor categoria, editor offerta, dettagli furno, diagnostica, stato sincronizzazione. + +Le route e le action attuali rimangono inizialmente adapter sottili. Un unico risultato di operazione include ID operazione, revisione, elementi modificati, eventuali errori di campo e stato sincronizzazione. Non introdurre nuove librerie prima di verificare i limiti degli strumenti già installati. + +Flusso di scrittura: permesso → validazione → verifica revisione → transazione DB con audit → risposta di salvataggio → aggiornamento hotel/export Git. La durabilità del passaggio DB→coda va garantita con un evento persistito nella transazione o meccanismo equivalente verificato. Un fallimento Git/RCON non deve far ripetere una creazione già committata. Riutilizzare il worker e la coda esistenti, aggiungendo idempotenza dove manca. + +## UX proposta + +Pagina /admin/catalog con barra: Normale/BC, ricerca, nuova categoria, aggiungi furni, stato operazioni. Sotto: categorie a sinistra, offerte al centro, dettagli a destra. Il pannello dettagli si richiude; su schermi piccoli diventa una vista dedicata. Un solo scorrimento per ciascuna area, azioni di salvataggio sempre raggiungibili. + +La URL conserva catalogo, categoria, offerta, vista e ricerca; i campi non salvati restano nello stato locale. Indietro/avanti e ricaricamento devono riaprire il contesto corretto. I vecchi URL dei dettagli continuano a funzionare. + +Tre oggetti riconoscibili: + +- Categoria: percorso, titolo, icona, layout, visibilità e requisiti. +- Offerta: prezzo, valuta, quantità, componenti bundle, disponibilità e ordine. +- Furno condiviso: classname, sprite, dimensioni e interazioni; mostrare quante offerte lo referenziano prima di una modifica globale. + +Idee operative: + +- Ricerca trasversale per nome, classname, ID pagina/offerta/furno e sprite ID, con percorso nei risultati. +- Selettore visuale di categoria e layout; proprietà tecniche nelle Avanzate. +- Prezzi con icone reali delle valute; mostrare il prima/dopo delle operazioni massive, arrotondamenti ed elementi esclusi. +- Multiselezione con riepilogo di spostamento/eliminazione; dopo un errore mantenere selezionati i falliti. +- Anteprima del negozio già esistente integrata nel contesto; non presentarla come prova completa del comportamento del client hotel. +- Diagnostica su richiesta: offerta, SQL, furnidata, Nitro e icona separati. Collegamento a Catalog Studio sul furno esatto; nessuna scansione pesante a ogni apertura. +- Storico di chi/cosa/quando con differenze e ripristino. Il ripristino controlla revisioni successive: non sovrascrive in silenzio modifiche di altri operatori e non annulla acquisti già avvenuti. +- Riepilogo visibile: salvato, invio hotel, Git. Gli errori hanno riferimento al monitor CMS. +- Stati vuoti, errori, caricamento e sola lettura distinti; traduzioni complete e uso da tastiera. + +## Programma di lavoro e criteri di uscita + +| Lotto | Consegna | Criterio per proseguire | +|---|---|---| +| 1. Baseline | Matrice funzioni/route/permessi normale e BC; fixture con bundle, LTD, offerte speciali, zeri/null, alberi incoerenti; misure query e rete | Tutti i flussi esistenti hanno una destinazione nel piano, senza omissioni | +| 2. Integrità | Validatori, transazioni di riordino/spostamento/eliminazione, gerarchie sicure, revisioni | Un fallimento intermedio non lascia dati parziali; due operatori non si sovrascrivono | +| 3. Servizi condivisi | Query/command/repository e risultato comune; vecchie route come adapter | Vecchie UI superano le stesse prove con il nuovo backend | +| 4. Stato editor | Unica gestione delle modifiche, richieste annullabili, risposta coerente con selezione | Annullare l'uscita conserva tutto; cambi rapidi mostrano sempre l'ultima selezione | +| 5. Pagina unificata | Visual Manager nella pagina, griglia/tabella condivise, URL, layout adattivo | Parità normale/BC e vecchi link conservati; niente perdita di scroll o azioni nascoste | +| 6. Operazioni avanzate | Ricerca, editor bundle, prezzi massivi con differenze, storico e diagnosi contestuale | Gli effetti sono spiegati prima dell'applicazione; retry applica solo ciò che manca | +| 7. Sincronizzazione | Stato DB/hotel/Git, operazioni persistenti, retry/idempotenza | Guasto dopo commit e riavvio worker non duplicano né perdono l'operazione | +| 8. Prestazioni e rimozione duplicati | Paginazione, caricamento progressivo, accessibilità, eliminazione vecchi componenti | Confronto misurato e prove finali; nessuna route o funzione rimasta senza equivalente | + +I lotti 2 e 7 condividono il contratto delle operazioni: progettare subito evento persistente e idempotenza, anche se la UI di stato arriva dopo. Nessuna stima in giorni finché non sono note dimensioni reali del catalogo, varianti DB e casi speciali attivi. Ogni lotto può richiedere più PR piccole; niente sostituzione monolitica. + +## Verifica e rilascio + +Test unitari delle regole; integrazione su MariaDB per rollback, concorrenza e varianti INT/VARCHAR; browser con permessi lettura/modifica, desktop e schermo ridotto. Simulare doppio submit, timeout, risposta fuori ordine, fallimento RCON, Git non raggiungibile, riavvio dopo commit. Conservare test e componenti esistenti finché la parità non è dimostrata. + +Registrare baseline e risultati per categorie grandi/piccole: richieste per riordino, tempo DB, payload, tempo fino a editor utilizzabile, risposte fallite. Non promettere percentuali senza dati. + +Rilascio progressivo con selezione reversibile del nuovo editor. Il ritorno alla UI precedente deve usare gli stessi servizi corretti. Migrazioni additive e compatibili; il rollback dell'app non deve richiedere la cancellazione di dati. Eliminare le vecchie UI solo dopo parità verificata. Confermare CI, deploy, health e prove staff prima di dichiarare risolto il flusso live. + +## Primo passo consigliato + +Lotti 1 e 2: inventario di compatibilità e correzione delle operazioni a rischio, mantenendo inizialmente l'aspetto corrente. Poi estrarre i servizi e unificare l'editor. È la sequenza che permette di migliorare UX senza portare avanti gli stessi difetti dentro una nuova schermata. diff --git a/docs/superpowers/plans/2026-09-06-catalog-refactor.md b/docs/superpowers/plans/2026-09-06-catalog-refactor.md new file mode 100644 index 0000000000..691038f944 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-catalog-refactor.md @@ -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. diff --git a/src/actions/catalog-bc.ts b/src/actions/catalog-bc.ts index c6a862c52b..690ad0bc42 100644 --- a/src/actions/catalog-bc.ts +++ b/src/actions/catalog-bc.ts @@ -1,12 +1,24 @@ "use server"; -import { eq, inArray } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { catalogFailure } from "@/features/catalog/server/errors"; +import { + createBcOfferCommand, + updateBcOfferCommand, +} from "@/features/catalog/server/offer-commands"; +import { + createPageCommand, + deletePageCommand, + reorderPagesCommand, + togglePageCommand, + updatePageCommand, +} from "@/features/catalog/server/page-commands"; +import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; import { requirePermission } from "@/lib/admin/guard"; -import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db"; +import { CatalogItemsBc, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { withCatalogExport } from "@/lib/services/catalog-git-queue"; -import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; const BC_PAGE_FIELDS = [ @@ -50,19 +62,24 @@ function pickAllowed( export async function updateBcPage({ id, + expected, ...fields -}: { id: number } & Record) { +}: { id: number; expected?: Record } & Record< + string, + unknown +>) { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { const data = pickAllowed(fields, BC_PAGE_FIELDS); if (Object.keys(data).length === 0) { return { ok: false as const, error: "No valid fields to update" }; } - await db - .update(CatalogPagesBc) - .set(data as Partial) - .where(eq(CatalogPagesBc.id, id)); - await rcon.updateCatalog(); + try { + await updatePageCommand("bc", id, data, expected); + } catch (error) { + return { ok: false as const, error: catalogFailure(error).message }; + } + await sendCatalogUpdate(); await logStaffActivity({ staffId: staff.id, action: "bc_page_update", @@ -79,7 +96,7 @@ export async function deleteBcItem({ id }: { id: number }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id)); - await rcon.updateCatalog(); + await sendCatalogUpdate(); await logStaffActivity({ staffId: staff.id, action: "bc_item_delete", @@ -108,11 +125,12 @@ export async function updateBcItem({ if (Object.keys(safe).length === 0) { return { ok: false as const, error: "No valid fields to update" }; } - await db - .update(CatalogItemsBc) - .set(safe as Partial) - .where(eq(CatalogItemsBc.id, id)); - await rcon.updateCatalog(); + try { + await updateBcOfferCommand(id, safe); + } catch (error) { + return { ok: false as const, error: catalogFailure(error).message }; + } + await sendCatalogUpdate(); await logStaffActivity({ staffId: staff.id, action: "bc_item_update", @@ -137,11 +155,8 @@ export async function createBcItem({ }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - const [result] = await db - .insert(CatalogItemsBc) - .values({ pageId, ...data }); - const createdId = Number(result.insertId); - await rcon.updateCatalog(); + const createdId = await createBcOfferCommand({ pageId, ...data }); + await sendCatalogUpdate(); await logStaffActivity({ staffId: staff.id, action: "bc_item_create", @@ -163,20 +178,8 @@ export async function toggleBcPage({ }) { await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - const [page] = await db - .select({ - enabled: CatalogPagesBc.enabled, - visible: CatalogPagesBc.visible, - }) - .from(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, id)) - .limit(1); - if (!page) return { ok: false as const, error: "Page not found" }; - await db - .update(CatalogPagesBc) - .set({ [field]: page[field] === "1" ? "0" : "1" }) - .where(eq(CatalogPagesBc.id, id)); - await rcon.updateCatalog(); + await togglePageCommand("bc", id, field); + await sendCatalogUpdate(); revalidatePath("/admin/catalog/builder-club"); return { ok: true as const }; }); @@ -194,7 +197,7 @@ export async function createBcPage(input: { }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - const [result] = await db.insert(CatalogPagesBc).values({ + const createdId = await createPageCommand("bc", { caption: input.caption, parentId: input.parentId, pageLayout: input.pageLayout ?? "default_3x3", @@ -206,8 +209,7 @@ export async function createBcPage(input: { pageHeadline: "", pageTeaser: "", }); - const createdId = Number(result.insertId); - await rcon.updateCatalog(); + await sendCatalogUpdate(); await logStaffActivity({ staffId: staff.id, action: "bc_page_create", @@ -221,28 +223,6 @@ export async function createBcPage(input: { }); } -async function moveBcPage(pageId: number, newParentId: number): Promise { - if (newParentId > 0) { - let currentId = newParentId; - for (let i = 0; i < 50; i++) { - if (currentId === pageId) { - throw new Error("Cannot move page: would create a circular hierarchy"); - } - const [parent] = await db - .select({ parentId: CatalogPagesBc.parentId }) - .from(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, currentId)) - .limit(1); - if (!parent || parent.parentId <= 0) break; - currentId = parent.parentId; - } - } - await db - .update(CatalogPagesBc) - .set({ parentId: newParentId }) - .where(eq(CatalogPagesBc.id, pageId)); -} - export async function reorderBcTreePage(input: { pageId: number; newParentId?: number; @@ -250,21 +230,11 @@ export async function reorderBcTreePage(input: { }) { await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - if (input.newParentId !== undefined) { - try { - await moveBcPage(input.pageId, input.newParentId); - } catch (err) { - return { - ok: false as const, - error: err instanceof Error ? err.message : "Invalid move", - }; - } - } - await db - .update(CatalogPagesBc) - .set({ orderNum: input.newOrderNum }) - .where(eq(CatalogPagesBc.id, input.pageId)); - await rcon.updateCatalog(); + await updatePageCommand("bc", input.pageId, { + parentId: input.newParentId, + orderNum: input.newOrderNum, + }); + await sendCatalogUpdate(); revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog/builder-club"); return { ok: true as const, data: {} }; @@ -277,51 +247,23 @@ export async function deleteBcTreePage(input: { }) { await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - const [page] = await db - .select({ parentId: CatalogPagesBc.parentId }) - .from(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, input.pageId)) - .limit(1); - if (!page) return { ok: false as const, error: "Page not found" }; + await deletePageCommand("bc", input.pageId, input.mode); + await sendCatalogUpdate(); + revalidatePath("/admin/catalog"); + revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const, data: {} }; + }); +} - if (input.mode === "reparent") { - await db.transaction(async (tx) => { - await tx - .update(CatalogPagesBc) - .set({ parentId: page.parentId }) - .where(eq(CatalogPagesBc.parentId, input.pageId)); - await tx - .delete(CatalogItemsBc) - .where(eq(CatalogItemsBc.pageId, input.pageId)); - await tx - .delete(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, input.pageId)); - }); - } else { - const toDelete: number[] = [input.pageId]; - const queue: number[] = [input.pageId]; - while (queue.length > 0) { - const children = await db - .select({ id: CatalogPagesBc.id }) - .from(CatalogPagesBc) - .where(inArray(CatalogPagesBc.parentId, queue)); - queue.length = 0; - for (const child of children) { - toDelete.push(child.id); - queue.push(child.id); - } - } - await db.transaction(async (tx) => { - await tx - .delete(CatalogItemsBc) - .where(inArray(CatalogItemsBc.pageId, toDelete)); - await tx - .delete(CatalogPagesBc) - .where(inArray(CatalogPagesBc.id, toDelete)); - }); - } - - await rcon.updateCatalog(); +export async function reorderBcCatalogPages(input: { + parentId: number; + ids: number[]; + expectedIds: number[]; +}) { + await requirePermission(PERMS.CATALOG_EDIT); + return withCatalogExport(async () => { + await reorderPagesCommand("bc", input); + await sendCatalogUpdate(); revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog/builder-club"); return { ok: true as const, data: {} }; diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 1340de3c5f..f4a5ee4104 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -2,72 +2,23 @@ import { eq, inArray, like, or, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { offerPatchSchema } from "@/features/catalog/domain/offer-input"; +import { + createOfferCommand, + moveOffersCommand, + reorderOffersCommand, + updateOfferCommand, +} from "@/features/catalog/server/offer-commands"; +import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; import { requirePermission } from "@/lib/admin/guard"; import { CatalogItems, db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; import { withCatalogExport } from "@/lib/services/catalog-git-queue"; import { allocateCatalogItemId } from "@/lib/services/furni-import"; -import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; import { translateItemsSchema } from "@/lib/validators/catalog"; -const CATALOG_ITEM_FIELDS = [ - "pageId", - "itemIds", - "catalogName", - "costCredits", - "costPoints", - "pointsType", - "amount", - "orderNumber", - "offerId", - "songId", - "limitedSells", - "limitedStack", - "extradata", - "haveOffer", - "clubOnly", -] as const; - -const ITEMS_BASE_FIELDS = [ - "publicName", - "itemName", - "type", - "width", - "length", - "stackHeight", - "allowStack", - "allowSit", - "allowLay", - "allowWalk", - "allowGift", - "allowTrade", - "allowRecycle", - "allowMarketplaceSell", - "allowInventoryStack", - "interactionType", - "interactionModesCount", - "vendingIds", - "customparams", - "effectIdMale", - "effectIdFemale", - "clothingOnWalk", -] as const; - -function pickAllowed( - fields: Record, - allowed: readonly string[], -): Record { - const out: Record = {}; - for (const key of allowed) { - if (Object.hasOwn(fields, key) && fields[key] !== undefined) { - out[key] = fields[key]; - } - } - return out; -} - /** Raw INSERT — catalog_items.id has no AUTO_INCREMENT on real Habbo DBs; page_id is often VARCHAR. */ async function insertCatalogItemRow(data: { pageId: number; @@ -87,8 +38,10 @@ async function insertCatalogItemRow(data: { clubOnly: string; }): Promise { const pageIdStr = String(data.pageId); - return allocateCatalogItemId(async (nextId) => { - await db.execute(sql` + offerPatchSchema.required().parse(data); + return allocateCatalogItemId((nextId) => + createOfferCommand("normal", data, async (tx) => { + await tx.execute(sql` INSERT INTO catalog_items ( id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, @@ -102,8 +55,9 @@ async function insertCatalogItemRow(data: { ${data.haveOffer}, ${data.clubOnly} ) `); - return nextId; - }); + return nextId; + }), + ); } export async function createCatalogItem(data: { @@ -141,7 +95,7 @@ export async function createCatalogItem(data: { } } const id = await insertCatalogItemRow({ ...data, catalogName }); - await rcon.updateCatalog(); + await sendCatalogUpdate(); await logStaffActivity({ staffId: staff.id, action: "catalog_item_create", @@ -221,7 +175,7 @@ export async function bulkCreateCatalogItems({ } if (created > 0) { - await rcon.updateCatalog(); + await sendCatalogUpdate(); await logStaffActivity({ staffId: staff.id, action: "catalog_items_bulk_create", @@ -240,7 +194,7 @@ export async function deleteCatalogItems({ ids }: { ids: number[] }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids)); - await rcon.updateCatalog(); + await sendCatalogUpdate(); await logStaffActivity({ staffId: staff.id, action: "catalog_items_delete", @@ -264,16 +218,8 @@ export async function moveCatalogItems({ if (ids.length === 0) { return { ok: true as const, data: {} }; } - const pageIdStr = String(targetPageId); - await db.execute(sql` - UPDATE catalog_items - SET page_id = ${pageIdStr} - WHERE id IN (${sql.join( - ids.map((id) => sql`${id}`), - sql`, `, - )}) - `); - await rcon.updateCatalog(); + await moveOffersCommand(ids, targetPageId); + await sendCatalogUpdate(); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; }); @@ -286,13 +232,8 @@ export async function reorderCatalogItems({ }) { await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - for (const { id, orderNumber } of orders) { - await db - .update(CatalogItems) - .set({ orderNumber }) - .where(eq(CatalogItems.id, id)); - } - await rcon.updateCatalog(); + await reorderOffersCommand(orders); + await sendCatalogUpdate(); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; }); @@ -309,37 +250,18 @@ export async function updateCatalogItem({ }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS); - if (Object.keys(safeCatalog).length === 0 && !baseItem) { - return { ok: false as const, error: "No valid fields to update" }; + try { + await updateOfferCommand({ id, catalogFields, baseItem }); + } catch (error) { + return { + ok: false as const, + error: + error instanceof Error + ? error.message + : "Unable to update catalog item", + }; } - - // page_id is often VARCHAR — update it via raw SQL when present. - const pageIdRaw = safeCatalog.pageId; - if (pageIdRaw !== undefined) { - const pageIdStr = String(pageIdRaw); - await db.execute(sql` - UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id} - `); - delete safeCatalog.pageId; - } - - if (Object.keys(safeCatalog).length > 0) { - await db - .update(CatalogItems) - .set(safeCatalog as Partial) - .where(eq(CatalogItems.id, id)); - } - if (baseItem) { - const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS); - if (Object.keys(safeBase).length > 0) { - await db - .update(ItemsBase) - .set(safeBase as Partial) - .where(eq(ItemsBase.id, baseItem.id)); - } - } - await rcon.updateCatalog(); + await sendCatalogUpdate(); await logStaffActivity({ staffId: staff.id, action: "catalog_item_update", @@ -453,7 +375,7 @@ export async function translateCatalogItems(input: { invalidateFurniDataCache(); } - await rcon.updateCatalog(); + await sendCatalogUpdate(); await logAudit({ userId: staff.id, action: "items_base_translate", diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index c4511877f5..d4eb697aaf 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -1,14 +1,19 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { catalogFailure } from "@/features/catalog/server/errors"; +import { + createPageCommand, + deletePageCommand, + reorderPagesCommand, + togglePageCommand, + updatePageCommand, +} from "@/features/catalog/server/page-commands"; +import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; import { requirePermission } from "@/lib/admin/guard"; -import { CatalogPages, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import type { ActionResult } from "@/lib/safe-action-shared"; import { withCatalogExport } from "@/lib/services/catalog-git-queue"; -import { deletePage, movePage } from "@/lib/services/catalog-tree"; -import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; const CATALOG_PAGE_FIELDS = [ @@ -46,8 +51,12 @@ function pickPageFields(fields: Record) { export async function updateCatalogPage({ id, + expected, ...fields -}: { id: number } & Record): Promise { +}: { id: number; expected?: Record } & Record< + string, + unknown +>): Promise { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { const data = pickPageFields(fields); @@ -57,11 +66,12 @@ export async function updateCatalogPage({ if (typeof data.caption === "string" && !data.captionSave) { data.captionSave = data.caption.slice(0, 25); } - await db - .update(CatalogPages) - .set(data as Partial) - .where(eq(CatalogPages.id, id)); - await rcon.updateCatalog(); + try { + await updatePageCommand("normal", id, data, expected); + } catch (error) { + return { ok: false as const, error: catalogFailure(error).message }; + } + await sendCatalogUpdate(); await logStaffActivity({ staffId: staff.id, action: "catalog_page_update", @@ -77,8 +87,8 @@ export async function updateCatalogPage({ export async function deleteCatalogPage({ id }: { id: number }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - await deletePage(id, "reparent"); - await rcon.updateCatalog(); + await deletePageCommand("normal", id, "reparent"); + await sendCatalogUpdate(); await logStaffActivity({ staffId: staff.id, action: "catalog_page_delete", @@ -100,22 +110,12 @@ export async function toggleCatalogPage({ }) { await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - const [page] = await db - .select({ - enabled: CatalogPages.enabled, - visible: CatalogPages.visible, - }) - .from(CatalogPages) - .where(eq(CatalogPages.id, id)) - .limit(1); - if (!page) return { ok: false as const, error: "Catalog page not found" }; - const field = action === "toggleEnabled" ? "enabled" : "visible"; - const current = action === "toggleEnabled" ? page.enabled : page.visible; - await db - .update(CatalogPages) - .set({ [field]: current === "1" ? "0" : "1" }) - .where(eq(CatalogPages.id, id)); - await rcon.updateCatalog(); + await togglePageCommand( + "normal", + id, + action === "toggleEnabled" ? "enabled" : "visible", + ); + await sendCatalogUpdate(); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; }); @@ -134,7 +134,7 @@ export async function createCatalogPage(input: { }): Promise> { const staff = await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - const [result] = await db.insert(CatalogPages).values({ + const createdId = await createPageCommand("normal", { caption: input.caption, parentId: input.parentId, pageLayout: input.pageLayout ?? "default_3x3", @@ -151,8 +151,7 @@ export async function createCatalogPage(input: { pageTeaser: "", includes: "", }); - const createdId = Number(result.insertId); - await rcon.updateCatalog(); + await sendCatalogUpdate(); await logStaffActivity({ staffId: staff.id, action: "catalog_page_create", @@ -172,21 +171,11 @@ export async function reorderTreePage(input: { }) { await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - if (input.newParentId !== undefined) { - try { - await movePage(input.pageId, input.newParentId); - } catch (err) { - return { - ok: false as const, - error: err instanceof Error ? err.message : "Invalid move", - }; - } - } - await db - .update(CatalogPages) - .set({ orderNum: input.newOrderNum }) - .where(eq(CatalogPages.id, input.pageId)); - await rcon.updateCatalog(); + await updatePageCommand("normal", input.pageId, { + parentId: input.newParentId, + orderNum: input.newOrderNum, + }); + await sendCatalogUpdate(); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; }); @@ -198,8 +187,22 @@ export async function deleteTreePage(input: { }) { await requirePermission(PERMS.CATALOG_EDIT); return await withCatalogExport(async () => { - await deletePage(input.pageId, input.mode); - await rcon.updateCatalog(); + await deletePageCommand("normal", input.pageId, input.mode); + await sendCatalogUpdate(); + revalidatePath("/admin/catalog"); + return { ok: true as const, data: {} }; + }); +} + +export async function reorderCatalogPages(input: { + parentId: number; + ids: number[]; + expectedIds: number[]; +}) { + await requirePermission(PERMS.CATALOG_EDIT); + return withCatalogExport(async () => { + await reorderPagesCommand("normal", input); + await sendCatalogUpdate(); revalidatePath("/admin/catalog"); return { ok: true as const, data: {} }; }); diff --git a/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx b/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx index 5cbe8dd045..23d8e60ca1 100644 --- a/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx +++ b/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx @@ -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(); }, }, ); diff --git a/src/app/admin/catalog/[id]/catalog-page-form.tsx b/src/app/admin/catalog/[id]/catalog-page-form.tsx index 928679491b..cfaddb9df2 100644 --- a/src/app/admin/catalog/[id]/catalog-page-form.tsx +++ b/src/app/admin/catalog/[id]/catalog-page-form.tsx @@ -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, + }), + { + successMessage: "Page saved.", + errorMessage: "Failed to update.", + }, + ); } async function handleDelete() { diff --git a/src/app/admin/catalog/catalog-manager-dialog-wrapper.tsx b/src/app/admin/catalog/catalog-manager-dialog-wrapper.tsx index c9b0f5c381..53489b1917 100644 --- a/src/app/admin/catalog/catalog-manager-dialog-wrapper.tsx +++ b/src/app/admin/catalog/catalog-manager-dialog-wrapper.tsx @@ -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 }, +); diff --git a/src/app/admin/catalog/catalog-view-toggle.tsx b/src/app/admin/catalog/catalog-view-toggle.tsx index b3233c24a6..ddbe8af02e 100644 --- a/src/app/admin/catalog/catalog-view-toggle.tsx +++ b/src/app/admin/catalog/catalog-view-toggle.tsx @@ -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 ( -
+
{/* Catalog type selector */}
diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx index 4bded1a98e..37d2608392 100644 --- a/src/app/admin/catalog/page.tsx +++ b/src/app/admin/catalog/page.tsx @@ -15,8 +15,11 @@ import { Store } from "lucide-react"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { AdminPageShell } from "@/components/admin/admin-page-shell"; -import { CatalogFixAllButton } from "@/components/admin/catalog/catalog-fix-all-button"; import { CatalogTree } from "@/components/admin/catalog-tree"; +import Link from "@/components/link"; +import { CatalogSearch } from "@/features/catalog/components/catalog-search"; +import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status"; +import { loadCatalogOverview } from "@/features/catalog/server/overview"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { CatalogItems, @@ -28,7 +31,10 @@ import { import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { getCatalogItemCounts } from "@/lib/services/catalog-tree"; import { CatalogActions } from "./catalog-actions"; -import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper"; +import { + CatalogManagerDialog, + CatalogManagerWorkspace, +} from "./catalog-manager-dialog-wrapper"; import { CatalogTable } from "./catalog-table"; import { CatalogViewToggle } from "./catalog-view-toggle"; @@ -56,8 +62,44 @@ export default async function CatalogPagesPage({ const params = await searchParams; const sp = new URLSearchParams(params); const { search, perPage, page, sort, order } = parseListParams(sp); - const viewMode = sp.get("view") || "tree"; - const catalogType = sp.get("catalog") || "normal"; + const requestedView = sp.get("view"); + const viewMode = + requestedView === "tree" || requestedView === "table" + ? requestedView + : "manager"; + const catalogType = sp.get("catalog") === "bc" ? "bc" : "normal"; + if (viewMode === "manager") { + const overview = await loadCatalogOverview(catalogType); + return ( + +
+ +
+ + {t("workspace.diagnostics")} + + {canEdit && ( + + )} +
+
+
+ + +
+ +
+ ); + } type TreeNodeShape = { id: number; @@ -94,7 +136,7 @@ export default async function CatalogPagesPage({ .where( or(eq(CatalogPagesBc.parentId, -1), eq(CatalogPagesBc.parentId, 0)), ) - .orderBy(asc(CatalogPagesBc.orderNum)); + .orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id)); const bcRootIds = bcRootRaw.map((p) => p.id); const [bcChildCounts, bcItemCounts] = await Promise.all([ bcRootIds.length > 0 @@ -290,7 +332,12 @@ export default async function CatalogPagesPage({ initialParentId={sp.get("newPageParent")} /> )} - + + {t("workspace.diagnostics")} +
{viewMode === "tree" ? ( @@ -323,7 +370,7 @@ export default async function CatalogPagesPage({ .select() .from(CatalogPages) .where(or(eq(CatalogPages.parentId, -1), eq(CatalogPages.parentId, 0))) - .orderBy(asc(CatalogPages.orderNum)); + .orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id)); const rootIds = rootPagesRaw.map((p) => p.id); const [rootChildCounts, rootItemCountMap] = await Promise.all([ rootIds.length > 0 @@ -511,7 +558,9 @@ export default async function CatalogPagesPage({ {canEdit && ( )} - + + {t("workspace.diagnostics")} + diff --git a/src/app/api/admin/catalog/search/route.test.ts b/src/app/api/admin/catalog/search/route.test.ts new file mode 100644 index 0000000000..1472c82af1 --- /dev/null +++ b/src/app/api/admin/catalog/search/route.test.ts @@ -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, + ) => { + 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" }); + }); +}); diff --git a/src/app/api/admin/catalog/search/route.ts b/src/app/api/admin/catalog/search/route.ts new file mode 100644 index 0000000000..f9030f1b75 --- /dev/null +++ b/src/app/api/admin/catalog/search/route.ts @@ -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); + } + }, +); diff --git a/src/app/api/admin/catalog/status/route.ts b/src/app/api/admin/catalog/status/route.ts new file mode 100644 index 0000000000..9c91c7444a --- /dev/null +++ b/src/app/api/admin/catalog/status/route.ts @@ -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() }), +); diff --git a/src/app/api/admin/catalog/tree/route.ts b/src/app/api/admin/catalog/tree/route.ts index 7bc61c827d..10e54c1b88 100644 --- a/src/app/api/admin/catalog/tree/route.ts +++ b/src/app/api/admin/catalog/tree/route.ts @@ -1,16 +1,20 @@ import { asc, count, eq, inArray, like, or } from "drizzle-orm"; +import { catalogFailure } from "@/features/catalog/server/errors"; +import { + deletePageCommand, + togglePageCommand, +} from "@/features/catalog/server/page-commands"; +import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; import { apiError, apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { - deletePage, getAncestors, getCatalogItemCounts, getTreeFlat, toInt, } from "@/lib/services/catalog-tree"; -import { rcon } from "@/lib/services/rcon"; import type { TreeNode } from "@/types/catalog"; type PageRow = { @@ -40,7 +44,7 @@ async function getBcTreeFlat(): Promise { enabled: CatalogPagesBc.enabled, }) .from(CatalogPagesBc) - .orderBy(asc(CatalogPagesBc.orderNum)), + .orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id)), db .select({ pageId: CatalogItemsBc.pageId, @@ -56,14 +60,16 @@ async function getBcTreeFlat(): Promise { } const pageMap = new Map(allPages.map((p) => [p.id, p])); const depthCache = new Map(); - function computeDepth(pageId: number): number { + function computeDepth(pageId: number, visiting = new Set()): number { + if (visiting.has(pageId)) return 0; + visiting.add(pageId); if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0; const page = pageMap.get(pageId); if (!page || page.parentId <= 0) { depthCache.set(pageId, 0); return 0; } - const d = computeDepth(page.parentId) + 1; + const d = computeDepth(page.parentId, visiting) + 1; depthCache.set(pageId, d); return d; } @@ -134,7 +140,7 @@ async function getChildren( }) .from(CatalogPagesBc) .where(parentFilterBc) - .orderBy(asc(CatalogPagesBc.orderNum)); + .orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id)); const ids = pages.map((p) => toInt(p.id)); const [childCounts, itemCounts] = await Promise.all([ ids.length @@ -199,7 +205,7 @@ async function getChildren( }) .from(CatalogPages) .where(parentFilter) - .orderBy(asc(CatalogPages.orderNum)); + .orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id)); const ids = pages.map((p) => toInt(p.id)); const [childCounts, itemMap] = await Promise.all([ ids.length @@ -259,7 +265,7 @@ async function searchPages(q: string, isBc: boolean): Promise { }) .from(CatalogPagesBc) .where(or(...conditions)) - .orderBy(asc(CatalogPagesBc.orderNum)) + .orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id)) .limit(50); return pages.map((p) => toTreeNode( @@ -296,7 +302,7 @@ async function searchPages(q: string, isBc: boolean): Promise { }) .from(CatalogPages) .where(or(...conditions)) - .orderBy(asc(CatalogPages.orderNum)) + .orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id)) .limit(50); const ids = pages.map((p) => toInt(p.id)); const [childCounts, itemMap] = await Promise.all([ @@ -430,39 +436,13 @@ export const PATCH = withAdmin( return apiError("Invalid pageId"); } const isBc = body.catalog === "bc"; - if (isBc) { - const [page] = await db - .select({ - enabled: CatalogPagesBc.enabled, - visible: CatalogPagesBc.visible, - }) - .from(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, pageId)) - .limit(1); - if (!page) return apiError("Page not found", 404); - const current = field === "enabled" ? page.enabled : page.visible; - await db - .update(CatalogPagesBc) - .set({ [field]: current === "1" ? "0" : "1" }) - .where(eq(CatalogPagesBc.id, pageId)); - await rcon.updateCatalog(); - return apiOk({}); + try { + await togglePageCommand(isBc ? "bc" : "normal", pageId, field); + } catch (error) { + const failure = catalogFailure(error); + return apiError(failure.message, failure.status); } - const [page] = await db - .select({ - enabled: CatalogPages.enabled, - visible: CatalogPages.visible, - }) - .from(CatalogPages) - .where(eq(CatalogPages.id, pageId)) - .limit(1); - if (!page) return apiError("Page not found", 404); - const current = field === "enabled" ? page.enabled : page.visible; - await db - .update(CatalogPages) - .set({ [field]: current === "1" ? "0" : "1" }) - .where(eq(CatalogPages.id, pageId)); - await rcon.updateCatalog(); + await sendCatalogUpdate(); return apiOk({}); }, ); @@ -477,53 +457,13 @@ export const DELETE = withAdmin( if (!Number.isFinite(pageId) || pageId <= 0) { return apiError("Invalid pageId"); } - if (isBc) { - const [page] = await db - .select({ parentId: CatalogPagesBc.parentId }) - .from(CatalogPagesBc) - .where(eq(CatalogPagesBc.id, pageId)) - .limit(1); - if (!page) return apiError("Page not found", 404); - if (mode === "reparent") { - await db.transaction(async (tx) => { - await tx - .update(CatalogPagesBc) - .set({ parentId: page.parentId }) - .where(eq(CatalogPagesBc.parentId, pageId)); - await tx - .delete(CatalogItemsBc) - .where(eq(CatalogItemsBc.pageId, pageId)); - await tx.delete(CatalogPagesBc).where(eq(CatalogPagesBc.id, pageId)); - }); - } else { - // Deep cascade: collect all descendants - const toDelete: number[] = [pageId]; - const queue: number[] = [pageId]; - while (queue.length > 0) { - const children = await db - .select({ id: CatalogPagesBc.id }) - .from(CatalogPagesBc) - .where(inArray(CatalogPagesBc.parentId, queue)); - queue.length = 0; - for (const child of children) { - toDelete.push(child.id); - queue.push(child.id); - } - } - await db.transaction(async (tx) => { - await tx - .delete(CatalogItemsBc) - .where(inArray(CatalogItemsBc.pageId, toDelete)); - await tx - .delete(CatalogPagesBc) - .where(inArray(CatalogPagesBc.id, toDelete)); - }); - } - await rcon.updateCatalog(); - return apiOk({}); + try { + await deletePageCommand(isBc ? "bc" : "normal", pageId, mode); + } catch (error) { + const failure = catalogFailure(error); + return apiError(failure.message, failure.status); } - await deletePage(pageId, mode); - await rcon.updateCatalog(); + await sendCatalogUpdate(); return apiOk({}); }, ); diff --git a/src/components/admin/catalog-manager/breadcrumb-bar.tsx b/src/components/admin/catalog-manager/breadcrumb-bar.tsx index cbdc8bb0e0..159d7952be 100644 --- a/src/components/admin/catalog-manager/breadcrumb-bar.tsx +++ b/src/components/admin/catalog-manager/breadcrumb-bar.tsx @@ -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() { + {!embedded && ( + + )} - + @@ -281,17 +364,28 @@ function CatalogManagerInner({ className="flex min-h-0 flex-1 overflow-hidden" style={{ backgroundColor: "var(--admin-canvas)" }} > -
div]:w-full md:[&>div]:w-80`} + > + +
+
+ ; -export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { +export function InlineEditor(props: InlineEditorProps) { + const { catalogType } = useCatalogManager(); + return ( + + ); +} + +function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) { + const pageRequests = useLatestRequest(); + const itemRequests = useLatestRequest(); + const saveRequests = useLatestRequest(); + const tActions = useTranslations("pages.admin.actions"); + const tNavigation = useTranslations("pages.admin.navigation"); + const t = useTranslations("pages.admin.catalog.workspace"); + const [offersDirty, setOffersDirty] = useState(false); const { dispatch, catalogType, canEdit } = useCatalogManager(); const catQs = catalogTreeQs(catalogType); const pageHref = @@ -126,6 +142,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { const [itemsData, setItemsData] = useState(null); const [itemsLoading, setItemsLoading] = useState(false); const [itemsLoaded, setItemsLoaded] = useState(false); + const [itemsError, setItemsError] = useState(false); // ── Dirty tracking ────────────────────────────────────────── const isDirty = useMemo(() => { @@ -133,8 +150,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { }, [form, original]); useEffect(() => { - dispatch({ type: "SET_DIRTY", dirty: isDirty }); - }, [isDirty, dispatch]); + dispatch({ type: "SET_DIRTY", dirty: isDirty || offersDirty }); + }, [isDirty, offersDirty, dispatch]); // ── Ctrl+S handler ────────────────────────────────────────── useEffect(() => { @@ -151,11 +168,16 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { const loadPage = useCallback( async (id: number) => { + const request = pageRequests.start(); setLoading(true); try { - const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`); + const res = await fetch( + `/api/admin/catalog/tree?pageId=${id}${catQs}`, + { signal: request.signal }, + ); if (!res.ok) throw new Error(); const data = await res.json(); + if (!request.isCurrent()) return; const p = data.page as PageDetail; setPage(p); const formData: FormState = { @@ -163,8 +185,9 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { pageLayout: p.pageLayout, enabled: String(p.enabled), visible: String(p.visible), - minRank: p.minRank ?? 0, - clubOnly: String(p.clubOnly ?? "0"), + ...(catalogType === "normal" + ? { minRank: p.minRank ?? 0, clubOnly: String(p.clubOnly ?? "0") } + : {}), orderNum: p.orderNum, iconImage: p.iconImage, iconColor: p.iconColor, @@ -179,29 +202,41 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { setForm(formData); setOriginal(formData); } catch { + if (!request.isCurrent()) return; toast.error("Failed to load page"); setPage(null); } finally { - setLoading(false); + if (request.isCurrent()) setLoading(false); } }, - [catQs], + [catQs, catalogType, pageRequests], ); - const loadItemsData = useCallback(async (id: number) => { - setItemsLoading(true); - try { - const res = await fetch(`/api/admin/catalog/items?pageId=${id}`); - if (!res.ok) throw new Error(); - const data = await res.json(); - setItemsData(data as ItemsData); - setItemsLoaded(true); - } catch { - toast.error("Failed to load items"); - } finally { - setItemsLoading(false); - } - }, []); + const loadItemsData = useCallback( + async (id: number) => { + const request = itemRequests.start(); + setItemsError(false); + setItemsLoading(true); + try { + const res = await fetch(`/api/admin/catalog/items?pageId=${id}`, { + signal: request.signal, + }); + if (!res.ok) throw new Error(); + const data = await res.json(); + if (!request.isCurrent()) return; + setItemsData(data as ItemsData); + setItemsLoaded(true); + } catch { + if (!request.isCurrent()) return; + setItemsLoaded(true); + setItemsError(true); + toast.error("Failed to load items"); + } finally { + if (request.isCurrent()) setItemsLoading(false); + } + }, + [itemRequests], + ); const reloadItems = useCallback(() => { if (pageId && pageId > 0) loadItemsData(pageId); @@ -250,15 +285,21 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { } async function handleSave() { - if (!page || !canEdit) return; + if (!page || !canEdit || saving) return; + const request = saveRequests.start(); setSaving(true); try { const result = catalogType === "bc" - ? await updateBcPage({ id: page.id, ...form }) - : await updateCatalogPage({ id: page.id, ...form }); + ? await updateBcPage({ id: page.id, ...form, expected: original }) + : await updateCatalogPage({ + id: page.id, + ...form, + expected: original, + }); + if (!request.isCurrent()) return; if (!result.ok) throw new Error(result.error || "Failed to save"); - toast.success("Page updated & catalog synced"); + toast.success(tActions("saved")); setOriginal({ ...form }); setPage((prev) => prev @@ -281,9 +322,10 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { iconImage: (form.iconImage as number) ?? page.iconImage, }); } catch (e) { - toast.error(e instanceof Error ? e.message : "Failed to save"); + if (request.isCurrent()) + toast.error(e instanceof Error ? e.message : "Failed to save"); } finally { - setSaving(false); + if (request.isCurrent()) setSaving(false); } } @@ -303,7 +345,14 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { ); } - if (!page) return null; + if (!page) + return ( +
+ +
+ ); const layoutColor = LAYOUT_COLORS[String(form.pageLayout)] || "bg-muted text-muted-foreground"; @@ -386,7 +435,12 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { {/* Tabs */} { + if (tab === activeTab) return; + if (offersDirty && !window.confirm(tActions("leaveUnsaved"))) return; + setOffersDirty(false); + setActiveTab(tab); + }} className="flex-1 flex flex-col min-h-0" > @@ -490,17 +544,19 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { className="h-8 text-sm" />
-
- - - update("minRank", parseInt(e.target.value, 10) || 1) - } - className="h-8 text-sm" - /> -
+ {catalogType === "normal" && ( +
+ + + update("minRank", parseInt(e.target.value, 10) || 1) + } + className="h-8 text-sm" + /> +
+ )}
update("visible", v ? "1" : "0")} /> - update("clubOnly", v ? "1" : "0")} - /> + {catalogType === "normal" && ( + update("clubOnly", v ? "1" : "0")} + /> + )}
{/* Headline image picker */} @@ -641,7 +699,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { ) : ( )} - Save & Sync + {tNavigation("save")} @@ -662,7 +720,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { - ) : itemsLoading ? ( + ) : itemsError && !itemsData ? ( +
+ +
+ ) : itemsLoading && !itemsData ? (
@@ -704,6 +768,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { {/* Items table */} Translate tools are available for the shop catalog only. - ) : itemsLoading ? ( + ) : itemsError && !itemsData ? ( +
+ +
+ ) : itemsLoading && !itemsData ? (
diff --git a/src/components/admin/catalog-manager/root-tabs.tsx b/src/components/admin/catalog-manager/root-tabs.tsx index 58e824dc87..d9e1aa9c31 100644 --- a/src/components/admin/catalog-manager/root-tabs.tsx +++ b/src/components/admin/catalog-manager/root-tabs.tsx @@ -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; 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"); } diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index b128366612..bb4d50ac42 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -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 | null>(null); const [activeId, setActiveId] = useState(null); const [treeLoading, setTreeLoading] = useState(false); const [filter, setFilter] = useState("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"); diff --git a/src/components/admin/catalog-manager/tree-item.tsx b/src/components/admin/catalog-manager/tree-item.tsx index ed6ede19d8..141faa237a 100644 --- a/src/components/admin/catalog-manager/tree-item.tsx +++ b/src/components/admin/catalog-manager/tree-item.tsx @@ -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 && diff --git a/src/components/admin/catalog-manager/use-catalog-store.tsx b/src/components/admin/catalog-manager/use-catalog-store.tsx index 9b0b69e7d5..9d293d36df 100644 --- a/src/components/admin/catalog-manager/use-catalog-store.tsx +++ b/src/components/admin/catalog-manager/use-catalog-store.tsx @@ -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, }; diff --git a/src/components/admin/catalog/catalog-fix-all-button.tsx b/src/components/admin/catalog/catalog-fix-all-button.tsx deleted file mode 100644 index 7783b2b588..0000000000 --- a/src/components/admin/catalog/catalog-fix-all-button.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/src/features/catalog/client/catalog-selection-url.test.ts b/src/features/catalog/client/catalog-selection-url.test.ts new file mode 100644 index 0000000000..33892d6826 --- /dev/null +++ b/src/features/catalog/client/catalog-selection-url.test.ts @@ -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 }); + }, + ); +}); diff --git a/src/features/catalog/client/catalog-selection-url.ts b/src/features/catalog/client/catalog-selection-url.ts new file mode 100644 index 0000000000..04407d2a6c --- /dev/null +++ b/src/features/catalog/client/catalog-selection-url.ts @@ -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; +} diff --git a/src/features/catalog/client/latest-request.test.ts b/src/features/catalog/client/latest-request.test.ts new file mode 100644 index 0000000000..1634325a44 --- /dev/null +++ b/src/features/catalog/client/latest-request.test.ts @@ -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((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); + }); +}); diff --git a/src/features/catalog/client/latest-request.ts b/src/features/catalog/client/latest-request.ts new file mode 100644 index 0000000000..cdccdffca1 --- /dev/null +++ b/src/features/catalog/client/latest-request.ts @@ -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; + }, + }; +} diff --git a/src/features/catalog/client/use-catalog-navigation.ts b/src/features/catalog/client/use-catalog-navigation.ts new file mode 100644 index 0000000000..dcb6c6c67d --- /dev/null +++ b/src/features/catalog/client/use-catalog-navigation.ts @@ -0,0 +1,131 @@ +"use client"; +import { useCallback, useEffect, useRef } from "react"; +import { + type CatalogSelection, + readCatalogSelection, + writeCatalogSelection, +} from "./catalog-selection-url"; + +export function requestCatalogNavigation() { + return window.dispatchEvent( + new Event("catalog:before-navigation", { cancelable: true }), + ); +} + +/** Selection history stays local: changing category does not reload the server page. */ +export function useCatalogNavigation({ + enabled, + restore, + mayLeave, +}: { + enabled: boolean; + restore: (selection: CatalogSelection) => void; + mayLeave: () => boolean; +}) { + const current = useRef({ restore, mayLeave }); + useEffect(() => { + current.current = { restore, mayLeave }; + }, [restore, mayLeave]); + const previousUrl = useRef(""); + const position = useRef(0); + const restoringHistory = useRef(false); + useEffect(() => { + if (!enabled) return; + previousUrl.current = window.location.href; + position.current = window.history.state?.catalogSelectionPosition ?? 0; + window.history.replaceState( + { ...window.history.state, catalogSelectionPosition: position.current }, + "", + ); + current.current.restore( + readCatalogSelection(new URL(window.location.href)), + ); + const onPopState = () => { + if (restoringHistory.current) { + restoringHistory.current = false; + return; + } + const targetPosition = window.history.state?.catalogSelectionPosition; + if (!current.current.mayLeave()) { + if ( + typeof targetPosition === "number" && + targetPosition !== position.current + ) { + restoringHistory.current = true; + window.history.go(position.current - targetPosition); + return; + } + window.history.replaceState( + window.history.state, + "", + previousUrl.current, + ); + return; + } + position.current = + typeof targetPosition === "number" ? targetPosition : position.current; + previousUrl.current = window.location.href; + current.current.restore( + readCatalogSelection(new URL(window.location.href)), + ); + }; + const onLink = (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) + return; + const link = (event.target as Element).closest?.( + "a[href]", + ) as HTMLAnchorElement | null; + if ( + !link || + link.target === "_blank" || + link.hasAttribute("download") || + link.href === window.location.href + ) + return; + if (!current.current.mayLeave()) { + event.preventDefault(); + event.stopPropagation(); + } + }; + const onNavigation = (event: Event) => { + if (!current.current.mayLeave()) event.preventDefault(); + }; + window.addEventListener("catalog:before-navigation", onNavigation); + window.addEventListener("popstate", onPopState); + document.addEventListener("click", onLink, true); + return () => { + window.removeEventListener("catalog:before-navigation", onNavigation); + window.removeEventListener("popstate", onPopState); + document.removeEventListener("click", onLink, true); + }; + }, [enabled]); + return useCallback( + (selection: CatalogSelection) => { + if (!enabled) return; + const url = writeCatalogSelection( + new URL(window.location.href), + selection, + ); + if (url.href !== window.location.href) { + position.current += 1; + window.history.pushState( + { + ...window.history.state, + catalogSelectionPosition: position.current, + }, + "", + url, + ); + } + previousUrl.current = url.href; + }, + [enabled], + ); +} diff --git a/src/features/catalog/client/use-latest-request.ts b/src/features/catalog/client/use-latest-request.ts new file mode 100644 index 0000000000..7ff90617f2 --- /dev/null +++ b/src/features/catalog/client/use-latest-request.ts @@ -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; +} diff --git a/src/features/catalog/components/catalog-search.tsx b/src/features/catalog/components/catalog-search.tsx new file mode 100644 index 0000000000..96d38b969c --- /dev/null +++ b/src/features/catalog/components/catalog-search.tsx @@ -0,0 +1,128 @@ +"use client"; +import { Loader2, Search } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import Link from "@/components/link"; +import { Input } from "@/components/ui/input"; +import { useLatestRequest } from "../client/use-latest-request"; +import { + type CatalogSearchResult, + type SearchCatalogKind, + validSearchQuery, +} from "../domain/search"; +export function CatalogSearch({ + catalogType = "normal", +}: { + catalogType?: SearchCatalogKind; +}) { + const t = useTranslations("pages.admin.catalog.workspace"); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">( + "idle", + ); + const requests = useLatestRequest(); + useEffect(() => { + const request = requests.start(); + setResults([]); + if (!validSearchQuery(query)) { + setStatus("idle"); + return () => requests.cancel(); + } + setStatus("loading"); + const timer = setTimeout(async () => { + try { + const params = new URLSearchParams({ + q: query.trim(), + catalog: catalogType, + }); + const response = await fetch(`/api/admin/catalog/search?${params}`, { + signal: request.signal, + }); + if (!response.ok) throw Error("Search failed"); + const data = await response.json(); + if (!request.isCurrent()) return; + setResults(data.results); + setStatus("ready"); + } catch { + if (request.isCurrent()) setStatus("error"); + } + }, 250); + return () => { + clearTimeout(timer); + requests.cancel(); + }; + }, [query, catalogType, requests]); + return ( +
+
+ + setQuery(event.target.value)} + maxLength={100} + className="min-w-0" + /> +
+
+ {status === "loading" && ( +

+ + {t("searching")} +

+ )} + {status === "error" && ( +

+ {t("searchFailed")} +

+ )} + {status === "ready" && results.length === 0 && ( +

+ {t("searchNoResults")} +

+ )} +
+ {results.length > 0 && ( +
    + {results.map((result) => ( +
  • + +
    + + {t(result.kind)} + + + {result.caption} + + + #{result.id} + +
    +

    + {result.path.join(" / ")} +

    + {result.furniture && ( +

    + {result.furniture.classname} · #{result.furniture.id} · + sprite {result.furniture.spriteId} +

    + )} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/features/catalog/components/catalog-sync-status.tsx b/src/features/catalog/components/catalog-sync-status.tsx new file mode 100644 index 0000000000..fb95541ae4 --- /dev/null +++ b/src/features/catalog/components/catalog-sync-status.tsx @@ -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(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 ( +
+ + {t("hotel")}:{" "} + {status?.hotel + ? t(status.hotel.sent ? "sent" : "sendFailed") + : t("unknown")} + + + 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")} + + {status?.hotel?.reference && {status.hotel.reference}} + {canEdit && status?.hotel?.sent === false && ( + + )} + {error && ( + + )} +
+ ); +} diff --git a/src/features/catalog/domain/hierarchy.test.ts b/src/features/catalog/domain/hierarchy.test.ts new file mode 100644 index 0000000000..c7452ee92f --- /dev/null +++ b/src/features/catalog/domain/hierarchy.test.ts @@ -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(); +}); diff --git a/src/features/catalog/domain/hierarchy.ts b/src/features/catalog/domain/hierarchy.ts new file mode 100644 index 0000000000..1e064bf53b --- /dev/null +++ b/src/features/catalog/domain/hierarchy.ts @@ -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(); + 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(); + for (const page of pages) { + const list = children.get(page.parentId) ?? []; + list.push(page.id); + children.set(page.parentId, list); + } + const seen = new Set(); + 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", + ); +} diff --git a/src/features/catalog/domain/offer-input.test.ts b/src/features/catalog/domain/offer-input.test.ts new file mode 100644 index 0000000000..7eec215a98 --- /dev/null +++ b/src/features/catalog/domain/offer-input.test.ts @@ -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, + }); + }); +}); diff --git a/src/features/catalog/domain/offer-input.ts b/src/features/catalog/domain/offer-input.ts new file mode 100644 index 0000000000..b0c97c9c81 --- /dev/null +++ b/src/features/catalog/domain/offer-input.ts @@ -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; +} diff --git a/src/features/catalog/domain/page-input.test.ts b/src/features/catalog/domain/page-input.test.ts new file mode 100644 index 0000000000..ce40e73f6c --- /dev/null +++ b/src/features/catalog/domain/page-input.test.ts @@ -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(); +}); diff --git a/src/features/catalog/domain/page-input.ts b/src/features/catalog/domain/page-input.ts new file mode 100644 index 0000000000..997e6b9e4b --- /dev/null +++ b/src/features/catalog/domain/page-input.ts @@ -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(), +}); diff --git a/src/features/catalog/domain/search.test.ts b/src/features/catalog/domain/search.test.ts new file mode 100644 index 0000000000..141109bd98 --- /dev/null +++ b/src/features/catalog/domain/search.test.ts @@ -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=_=%==%"); + }); +}); diff --git a/src/features/catalog/domain/search.ts b/src/features/catalog/domain/search.ts new file mode 100644 index 0000000000..594abd12af --- /dev/null +++ b/src/features/catalog/domain/search.ts @@ -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}`)}%`, + }; +} diff --git a/src/features/catalog/server/errors.ts b/src/features/catalog/server/errors.ts new file mode 100644 index 0000000000..a1951f0891 --- /dev/null +++ b/src/features/catalog/server/errors.ts @@ -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 }; +} diff --git a/src/features/catalog/server/offer-commands.test.ts b/src/features/catalog/server/offer-commands.test.ts new file mode 100644 index 0000000000..72c0f20e7d --- /dev/null +++ b/src/features/catalog/server/offer-commands.test.ts @@ -0,0 +1,319 @@ +import { type SQL, sql } from "drizzle-orm"; +import { MySqlDialect } from "drizzle-orm/mysql-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + offers: [ + { id: 1, pageId: 10, itemIds: "12;23;12" }, + { id: 2, pageId: 10, itemIds: "23" }, + ], + pages: [{ id: 10 }, { id: 11 }], + bases: [{ id: 12 }, { id: 23 }], + queries: [] as { sql: string; params: unknown[] }[], + writes: 0, + failAt: 0, + commits: 0, + rollbacks: 0, +})); +vi.mock("@/lib/db", async () => ({ + ...(await import("@/db/schema")), + db: { + transaction: async (fn: (tx: unknown) => Promise) => { + const saved = state.writes; + try { + const result = await fn({ + execute: async (query: SQL) => { + const compiled = new MySqlDialect().sqlToQuery(query); + state.queries.push(compiled); + if (compiled.sql.startsWith("SELECT")) { + if (compiled.sql.includes("catalog_pages")) + return [state.pages, []]; + if (compiled.sql.includes("items_base")) + return [ + state.bases.filter((row) => compiled.params.includes(row.id)), + [], + ]; + return [ + state.offers.filter((row) => compiled.params.includes(row.id)), + [], + ]; + } + state.writes++; + if (state.failAt === state.writes) + throw Error("database unavailable"); + return [{ affectedRows: 1, insertId: 40 }, []]; + }, + }); + state.commits++; + return result; + } catch (error) { + state.writes = saved; + state.rollbacks++; + throw error; + } + }, + }, +})); + +import { + createBcOfferCommand, + createOfferCommand, + moveOffersCommand, + reorderOffersCommand, + updateBcOfferCommand, + updateOfferCommand, +} from "./offer-commands"; + +beforeEach(() => { + state.pages = [{ id: 10 }, { id: 11 }]; + state.queries = []; + state.writes = 0; + state.failAt = 0; + state.commits = 0; + state.rollbacks = 0; +}); +describe("transactional offer commands", () => { + it("rolls back offer and shared furniture writes together", async () => { + state.failAt = 2; + await expect( + updateOfferCommand({ + id: 1, + catalogFields: { pageId: 11, costCredits: 0 }, + baseItem: { id: 23, fields: { publicName: "New" } }, + }), + ).rejects.toThrow("database unavailable"); + expect(state.writes).toBe(0); + expect(state.rollbacks).toBe(1); + }); + it("prevents writing an unrelated shared furniture even when submitted as a new bundle member", async () => { + await expect( + updateOfferCommand({ + id: 1, + catalogFields: { itemIds: "12;99" }, + baseItem: { id: 99, fields: { publicName: "Wrong" } }, + }), + ).rejects.toThrow("belong"); + expect(state.writes).toBe(0); + }); + it("updates a non-first bundle component and preserves zero values", async () => { + await updateOfferCommand({ + id: 1, + catalogFields: { pageId: 11, amount: 0, offerId: 0, orderNumber: 0 }, + baseItem: { id: 23, fields: { publicName: "New" } }, + }); + expect(state.commits).toBe(1); + expect(state.writes).toBe(2); + expect( + state.queries.find((q) => q.sql.startsWith("UPDATE `catalog_items`")) + ?.params, + ).toContain("11"); + }); + it("rejects missing destination pages before writes", async () => { + state.pages = []; + await expect( + updateOfferCommand({ id: 1, catalogFields: { pageId: 11 } }), + ).rejects.toThrow("page"); + expect(state.writes).toBe(0); + }); + it("rejects missing bundle references before writes", async () => { + await expect( + updateOfferCommand({ id: 1, catalogFields: { itemIds: "12;99" } }), + ).rejects.toThrow("Furniture"); + expect(state.writes).toBe(0); + }); + it("rolls back the entire reorder after an intermediate failure", async () => { + state.failAt = 2; + await expect( + reorderOffersCommand([ + { id: 1, orderNumber: 0 }, + { id: 2, orderNumber: 1 }, + ]), + ).rejects.toThrow("database unavailable"); + expect(state.writes).toBe(0); + expect(state.rollbacks).toBe(1); + }); + it("rejects duplicate reorder ids", async () => { + await expect( + reorderOffersCommand([ + { id: 1, orderNumber: 0 }, + { id: 1, orderNumber: 1 }, + ]), + ).rejects.toThrow(); + expect(state.writes).toBe(0); + }); + it("rejects missing offers in a batch", async () => { + await expect( + reorderOffersCommand([ + { id: 1, orderNumber: 0 }, + { id: 99, orderNumber: 1 }, + ]), + ).rejects.toThrow("not found"); + expect(state.writes).toBe(0); + }); + it("validates all source offers before moving a batch", async () => { + await expect(moveOffersCommand([1, 99], 11)).rejects.toThrow("not found"); + expect(state.writes).toBe(0); + }); +}); + +it("does not update furniture removed from the submitted bundle", async () => { + await expect( + updateOfferCommand({ + id: 1, + catalogFields: { itemIds: "12" }, + baseItem: { id: 23, fields: { publicName: "Wrong" } }, + }), + ).rejects.toThrow("belong"); + expect(state.writes).toBe(0); +}); +it("keeps explicit zeros in the actual offer SQL parameters", async () => { + await updateOfferCommand({ + id: 1, + catalogFields: { + amount: 0, + orderNumber: 0, + offerId: 0, + limitedSells: 0, + limitedStack: 0, + }, + }); + const update = state.queries.find((query) => query.sql.startsWith("UPDATE")); + expect(update?.params).toEqual([0, 0, 0, 0, 0, 1]); +}); +it("rejects unsafe ids before opening a transaction", async () => { + await expect( + updateOfferCommand({ id: 0, catalogFields: { amount: 1 } }), + ).rejects.toThrow(); + await expect( + reorderOffersCommand([{ id: 1.5, orderNumber: 1 }]), + ).rejects.toThrow(); + expect(state.queries).toHaveLength(0); +}); +it("uses one atomic update and a string page parameter for a move", async () => { + await moveOffersCommand([1, 2], 11); + expect(state.commits).toBe(1); + expect(state.writes).toBe(1); + expect( + state.queries.find((query) => query.sql.startsWith("UPDATE"))?.params, + ).toEqual(["11", 1, 2]); +}); +it("ignores noneditable furniture and offer fields", async () => { + await expect( + updateOfferCommand({ + id: 1, + catalogFields: { id: 2 }, + baseItem: { id: 12, fields: { spriteId: 99 } }, + }), + ).rejects.toThrow("No valid fields"); + expect(state.queries).toHaveLength(0); +}); + +// Creation must retain the parent lock until its insert commits. +it.each(["normal", "bc"] as const)( + "rejects a deleted parent before a %s creation callback runs", + async (kind) => { + state.pages = []; + const insert = vi.fn(); + await expect( + createOfferCommand(kind, { pageId: 11, itemIds: "12;23" }, insert), + ).rejects.toThrow("page"); + expect(insert).not.toHaveBeenCalled(); + expect(state.writes).toBe(0); + }, +); +it("rejects missing furniture before creating an offer", async () => { + const insert = vi.fn(); + await expect( + createOfferCommand("normal", { pageId: 11, itemIds: "12;99" }, insert), + ).rejects.toThrow("Furniture"); + expect(insert).not.toHaveBeenCalled(); +}); +it("runs creation insert under the same transaction after reference locks", async () => { + await createOfferCommand( + "normal", + { pageId: 11, itemIds: "12;23;12" }, + async (tx) => { + await tx.execute(sql`INSERT INTO catalog_items (id) VALUES (40)`); + return 40; + }, + ); + expect(state.queries.map((query) => query.sql.split(" ")[0])).toEqual([ + "SELECT", + "SELECT", + "INSERT", + ]); + expect(state.queries[0].sql).toContain("FOR UPDATE"); + expect(state.queries[1].sql).toContain("FOR UPDATE"); + expect(state.commits).toBe(1); +}); +it("rolls back a failed creation insert", async () => { + state.failAt = 1; + await expect( + createOfferCommand("normal", { pageId: 11, itemIds: "12" }, (tx) => + tx.execute(sql`INSERT INTO catalog_items (id) VALUES (40)`), + ), + ).rejects.toThrow("database unavailable"); + expect(state.rollbacks).toBe(1); + expect(state.writes).toBe(0); +}); +it("rejects missing BC offers instead of reporting a zero-row update as saved", async () => { + await expect( + updateBcOfferCommand(99, { catalogName: "Missing" }), + ).rejects.toThrow("not found"); + expect(state.writes).toBe(0); +}); +it("keeps BC fields explicit and uses BC destination locks", async () => { + await updateBcOfferCommand(1, { + pageId: 11, + itemIds: "12;23", + orderNumber: 0, + costCredits: 99, + }); + expect(state.queries[0].sql).toContain("catalog_pages_bc"); + const update = state.queries.find((query) => query.sql.startsWith("UPDATE")); + expect(update?.sql).toContain("catalog_items_bc"); + expect(update?.sql).not.toContain("cost_credits"); + expect(update?.params).toEqual(["11", "12;23", 0, 1]); +}); + +it("creates a BC offer with only its supported fields and returns the committed id", async () => { + const id = await createBcOfferCommand({ + pageId: 11, + itemIds: "12;23;12", + catalogName: "Bundle", + orderNumber: 0, + extradata: "x;y", + costCredits: 99, + }); + expect(id).toBe(40); + expect(state.commits).toBe(1); + const insert = state.queries.find((query) => query.sql.startsWith("INSERT")); + expect(insert?.sql).toContain("catalog_items_bc"); + expect(insert?.sql).not.toContain("cost_credits"); + expect(insert?.params).toEqual(["11", "12;23;12", "Bundle", 0, "x;y"]); +}); +it("rejects incomplete BC creation before beginning a transaction", async () => { + await expect( + createBcOfferCommand({ pageId: 11, itemIds: "12" }), + ).rejects.toThrow(); + expect(state.queries).toHaveLength(0); +}); +it("rolls back failed BC creation and does not return an id", async () => { + state.failAt = 1; + await expect( + createBcOfferCommand({ + pageId: 11, + itemIds: "12", + catalogName: "One", + orderNumber: 0, + extradata: "", + }), + ).rejects.toThrow("database unavailable"); + expect(state.commits).toBe(0); + expect(state.rollbacks).toBe(1); +}); +it("rejects a missing BC destination before touching the offer", async () => { + state.pages = []; + await expect(updateBcOfferCommand(1, { pageId: 11 })).rejects.toThrow("page"); + expect(state.writes).toBe(0); +}); diff --git a/src/features/catalog/server/offer-commands.ts b/src/features/catalog/server/offer-commands.ts new file mode 100644 index 0000000000..57ea3c32c4 --- /dev/null +++ b/src/features/catalog/server/offer-commands.ts @@ -0,0 +1,206 @@ +import "server-only"; +import { getTableColumns, type SQL, sql } from "drizzle-orm"; +import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db"; +import { + distinctOfferIds, + furniturePatchSchema, + offerIdSchema, + offerInteger, + offerPatchSchema, + parseFurnitureIds, +} from "../domain/offer-input"; + +type OfferKind = "normal" | "bc"; +function offerTable(kind: OfferKind) { + if (kind !== "normal" && kind !== "bc") throw Error("Invalid catalog type"); + return kind === "bc" ? CatalogItemsBc : CatalogItems; +} +type Transaction = Parameters[0]>[0]; +export type UpdateOfferInput = { + id: number; + catalogFields: Record; + baseItem?: { id: number; fields: Record }; +}; +type OfferRow = { id: number; pageId: number | string; itemIds: string }; +async function lockedOffers( + tx: Transaction, + ids: number[], + kind: OfferKind = "normal", +): Promise { + const [rows] = await tx.execute( + sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join( + [...ids].sort((a, b) => a - b), + sql`, `, + )}) ORDER BY id FOR UPDATE`, + ); + const offers = rows as unknown as OfferRow[]; + if ( + offers.length !== ids.length || + ids.some((id) => !offers.some((offer) => Number(offer.id) === id)) + ) + throw Error("Catalog item not found"); + return offers; +} +async function lockPage( + tx: Transaction, + id: number, + kind: OfferKind = "normal", +) { + offerTable(kind); + const [rows] = await tx.execute( + sql`SELECT id FROM ${sql.identifier(kind === "bc" ? "catalog_pages_bc" : "catalog_pages")} WHERE id=${id} FOR UPDATE`, + ); + if ( + !(rows as unknown as { id: number }[]).some((row) => Number(row.id) === id) + ) + throw Error("Catalog page not found"); +} +async function lockFurniture(tx: Transaction, ids: number[]) { + const unique = [...new Set(ids)].sort((a, b) => a - b); + const [rows] = await tx.execute( + sql`SELECT id FROM items_base WHERE id IN (${sql.join(unique, sql`, `)}) ORDER BY id FOR UPDATE`, + ); + const found = new Set( + (rows as unknown as { id: number }[]).map((row) => Number(row.id)), + ); + if (unique.some((id) => !found.has(id))) + throw Error("Furniture reference not found"); +} +function assignments( + table: typeof CatalogItems | typeof CatalogItemsBc | typeof ItemsBase, + fields: Record, +): SQL[] { + const columns = getTableColumns(table); + return Object.entries(fields) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => { + const column = columns[key as keyof typeof columns]; + // SQL string parameters work with both deployed page_id column types. + return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`; + }); +} +export async function updateOfferCommand(input: UpdateOfferInput) { + offerIdSchema.parse(input.id); + const fields = offerPatchSchema.parse(input.catalogFields); + const baseFields = input.baseItem + ? furniturePatchSchema.parse(input.baseItem.fields) + : {}; + if (input.baseItem) offerIdSchema.parse(input.baseItem.id); + if ( + !Object.values(fields).some((value) => value !== undefined) && + !Object.values(baseFields).some((value) => value !== undefined) + ) + throw Error("No valid fields to update"); + return db.transaction(async (tx) => { + // Page locks precede offer locks, matching category deletion's lock order. + if (fields.pageId !== undefined) await lockPage(tx, fields.pageId); + const [offer] = await lockedOffers(tx, [input.id]); + if (input.baseItem) { + const currentIds = String(offer.itemIds).split(";").map(Number); + const nextIds = + fields.itemIds === undefined + ? currentIds + : parseFurnitureIds(fields.itemIds); + if ( + !currentIds.includes(input.baseItem.id) || + !nextIds.includes(input.baseItem.id) + ) + throw Error("Shared furniture must belong to the catalog offer"); + } + const references = + fields.itemIds === undefined ? [] : parseFurnitureIds(fields.itemIds); + if (input.baseItem) references.push(input.baseItem.id); + if (references.length) await lockFurniture(tx, references); + const offerAssignments = assignments(CatalogItems, fields); + if (offerAssignments.length) + await tx.execute( + sql`UPDATE ${CatalogItems} SET ${sql.join(offerAssignments, sql`, `)} WHERE id=${input.id}`, + ); + const baseAssignments = assignments(ItemsBase, baseFields); + if (input.baseItem && baseAssignments.length) + await tx.execute( + sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${input.baseItem.id}`, + ); + }); +} +export async function reorderOffersCommand( + orders: Array<{ id: number; orderNumber: number }>, +) { + const ids = distinctOfferIds(orders.map((row) => row.id)); + for (const row of orders) offerInteger.parse(row.orderNumber); + if (!ids.length) return; + return db.transaction(async (tx) => { + await lockedOffers(tx, ids); + for (const row of orders) + await tx.execute( + sql`UPDATE ${CatalogItems} SET order_number=${row.orderNumber} WHERE id=${row.id}`, + ); + }); +} +export async function moveOffersCommand(ids: number[], targetPageId: number) { + distinctOfferIds(ids); + offerIdSchema.parse(targetPageId); + if (!ids.length) return; + return db.transaction(async (tx) => { + await lockPage(tx, targetPageId); + await lockedOffers(tx, ids); + await tx.execute( + sql`UPDATE ${CatalogItems} SET page_id=${String(targetPageId)} WHERE id IN (${sql.join(ids, sql`, `)})`, + ); + }); +} + +/** Call the allocator before entering this command; only the insert uses its transaction. */ +export async function createOfferCommand( + kind: OfferKind, + references: { pageId: number; itemIds: string }, + insert: (tx: Transaction) => Promise, +): Promise { + offerTable(kind); + offerIdSchema.parse(references.pageId); + const ids = parseFurnitureIds(references.itemIds); + return db.transaction(async (tx) => { + await lockPage(tx, references.pageId, kind); + await lockFurniture(tx, ids); + return insert(tx); + }); +} +// BC deliberately has no currencies, amount, LTD values or shared-furniture mutation. +const bcOfferPatchSchema = offerPatchSchema.pick({ + pageId: true, + itemIds: true, + catalogName: true, + orderNumber: true, + extradata: true, +}); +export async function updateBcOfferCommand( + id: number, + input: Record, +) { + offerIdSchema.parse(id); + const fields = bcOfferPatchSchema.parse(input); + const updates = assignments(CatalogItemsBc, fields); + if (!updates.length) throw Error("No valid fields to update"); + return db.transaction(async (tx) => { + if (fields.pageId !== undefined) await lockPage(tx, fields.pageId, "bc"); + await lockedOffers(tx, [id], "bc"); + if (fields.itemIds !== undefined) + await lockFurniture(tx, parseFurnitureIds(fields.itemIds)); + await tx.execute( + sql`UPDATE ${CatalogItemsBc} SET ${sql.join(updates, sql`, `)} WHERE id=${id}`, + ); + }); +} +export async function createBcOfferCommand( + input: Record, +): Promise { + const fields = bcOfferPatchSchema.required().parse(input); + return createOfferCommand("bc", fields, async (tx) => { + const [result] = await tx.execute( + sql`INSERT INTO ${CatalogItemsBc} SET ${sql.join(assignments(CatalogItemsBc, fields), sql`, `)}`, + ); + const id = Number((result as unknown as { insertId: number }).insertId); + offerIdSchema.parse(id); + return id; + }); +} diff --git a/src/features/catalog/server/overview.ts b/src/features/catalog/server/overview.ts new file mode 100644 index 0000000000..e21b9ff999 --- /dev/null +++ b/src/features/catalog/server/overview.ts @@ -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), + }, + }; +} diff --git a/src/features/catalog/server/page-commands.test.ts b/src/features/catalog/server/page-commands.test.ts new file mode 100644 index 0000000000..a179c7e186 --- /dev/null +++ b/src/features/catalog/server/page-commands.test.ts @@ -0,0 +1,120 @@ +import type { SQL } from "drizzle-orm"; +import { MySqlDialect } from "drizzle-orm/mysql-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + rows: [ + { id: 1, parentId: -1, orderNum: 1, caption: "Current" }, + { id: 2, parentId: 1, orderNum: 1 }, + { id: 3, parentId: 1, orderNum: 2 }, + ], + queries: [] as string[], + writes: 0, + failAt: 0, + commits: 0, + rollbacks: 0, +})); +vi.mock("@/lib/db", async () => ({ + ...(await import("@/db/schema")), + db: { + transaction: async (fn: (tx: unknown) => Promise) => { + const saved = state.writes; + try { + const result = await fn({ + execute: async (query: SQL) => { + const text = new MySqlDialect().sqlToQuery(query).sql; + state.queries.push(text); + if (text.startsWith("SELECT")) return [state.rows, []]; + state.writes++; + if (state.failAt === state.writes) + throw Error("database unavailable"); + return [{ insertId: 4, affectedRows: 1 }, []]; + }, + }); + state.commits++; + return result; + } catch (error) { + state.writes = saved; + state.rollbacks++; + throw error; + } + }, + }, +})); + +import { + deletePageCommand, + reorderPagesCommand, + updatePageCommand, +} from "./page-commands"; + +beforeEach(() => { + state.queries = []; + state.writes = 0; + state.failAt = 0; + state.commits = 0; + state.rollbacks = 0; +}); +describe.each(["normal", "bc"] as const)( + "%s transactional commands", + (kind) => { + it("rejects stale category fields without overwriting another operator", async () => { + await expect( + updatePageCommand(kind, 1, { caption: "Mine" }, { caption: "Old" }), + ).rejects.toThrow("another operator"); + expect(state.writes).toBe(0); + }); + it("accepts matching category fields", async () => { + await updatePageCommand( + kind, + 1, + { caption: "Mine" }, + { caption: "Current" }, + ); + expect(state.commits).toBe(1); + }); + it("rolls back an interrupted reorder", async () => { + state.failAt = 2; + await expect( + reorderPagesCommand(kind, { + parentId: 1, + ids: [3, 2], + expectedIds: [2, 3], + }), + ).rejects.toThrow("database unavailable"); + expect(state.writes).toBe(0); + expect(state.rollbacks).toBe(1); + expect(state.queries[0]).toContain("ORDER BY id FOR UPDATE"); + }); + it("rejects stale reorder before any writes", async () => { + await expect( + reorderPagesCommand(kind, { + parentId: 1, + ids: [3, 2], + expectedIds: [3, 2], + }), + ).rejects.toThrow("changed"); + expect(state.writes).toBe(0); + }); + it("rejects a cyclic parent through ordinary page editing", async () => { + await expect(updatePageCommand(kind, 1, { parentId: 2 })).rejects.toThrow( + "circular", + ); + expect(state.writes).toBe(0); + }); + it("rolls back reparent plus delete if the offer delete fails", async () => { + state.failAt = 2; + await expect(deletePageCommand(kind, 2, "reparent")).rejects.toThrow(); + expect(state.writes).toBe(0); + expect(state.commits).toBe(0); + }); + it("commits the entire subtree delete once", async () => { + expect(await deletePageCommand(kind, 1, "cascade")).toEqual({ + deletedPages: 3, + movedChildren: 0, + }); + expect(state.commits).toBe(1); + expect(state.writes).toBe(2); + }); + }, +); diff --git a/src/features/catalog/server/page-commands.ts b/src/features/catalog/server/page-commands.ts new file mode 100644 index 0000000000..18ddd05fb3 --- /dev/null +++ b/src/features/catalog/server/page-commands.ts @@ -0,0 +1,169 @@ +import "server-only"; +import { getTableColumns, type SQL, sql } from "drizzle-orm"; +import { CatalogPages, CatalogPagesBc, db } from "@/lib/db"; +import { + assertParent, + CatalogConflict, + CatalogInputError, + CatalogNotFound, + collectSubtree, + type HierarchyPage, + positiveId, + validateSiblingOrder, +} from "../domain/hierarchy"; +import { pagePatchSchema } from "../domain/page-input"; +export type CatalogKind = "normal" | "bc"; +type Transaction = Parameters[0]>[0]; +function tables(kind: CatalogKind) { + if (kind !== "normal" && kind !== "bc") + throw new CatalogInputError("Invalid catalog type"); + return { + pages: kind === "bc" ? CatalogPagesBc : CatalogPages, + items: sql.identifier(kind === "bc" ? "catalog_items_bc" : "catalog_items"), + }; +} +async function lockedPages( + tx: Transaction, + kind: CatalogKind, +): Promise { + const { pages } = tables(kind); + // Structural commands serialize against a stable lock order, including parent validation. + const [rows] = await tx.execute( + sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM ${pages} ORDER BY id FOR UPDATE`, + ); + return (rows as unknown as HierarchyPage[]).map((row) => ({ + id: Number(row.id), + parentId: Number(row.parentId), + orderNum: Number(row.orderNum), + })); +} +function fieldsSql(kind: CatalogKind, fields: Record): SQL[] { + const columns = getTableColumns(tables(kind).pages); + return Object.entries(fields) + .filter( + ([key, value]) => value !== undefined && Object.hasOwn(columns, key), + ) + .map( + ([key, value]) => + sql`${sql.identifier(columns[key as keyof typeof columns].name)} = ${value}`, + ); +} +export async function updatePageCommand( + kind: CatalogKind, + id: number, + fields: Record, + expected?: Record, +) { + positiveId(id); + const data = pagePatchSchema.parse(fields); + return db.transaction(async (tx) => { + const rows = await lockedPages(tx, kind); + if (!rows.some((row) => row.id === id)) + throw new CatalogNotFound("Catalog page not found"); + if (expected) { + const columns = getTableColumns(tables(kind).pages); + const [existingRows] = await tx.execute( + sql`SELECT * FROM ${tables(kind).pages} WHERE id=${id} FOR UPDATE`, + ); + const current = (existingRows as unknown as Record[])[0]; + if (!current) throw new CatalogNotFound("Catalog page not found"); + for (const [key, value] of Object.entries(expected)) { + if (!Object.hasOwn(data, key) || !Object.hasOwn(columns, key)) continue; + const column = columns[key as keyof typeof columns]; + if (String(current[column.name] ?? "") !== String(value ?? "")) + throw new CatalogConflict( + "This category was modified by another operator. Reload before saving.", + ); + } + } + if (data.parentId !== undefined) assertParent(rows, id, data.parentId); + const assignments = fieldsSql(kind, data); + if (!assignments.length) + throw new CatalogInputError("No valid fields to update"); + await tx.execute( + sql`UPDATE ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)} WHERE id=${id}`, + ); + }); +} +export async function createPageCommand( + kind: CatalogKind, + fields: Record, +): Promise { + const data = pagePatchSchema.parse(fields); + if (!data.caption) throw new CatalogInputError("Category name is required"); + return db.transaction(async (tx) => { + const rows = await lockedPages(tx, kind); + assertParent(rows, 0, data.parentId ?? -1); + const assignments = fieldsSql(kind, { + ...data, + captionSave: data.captionSave ?? data.caption?.slice(0, 25), + }); + const [result] = await tx.execute( + sql`INSERT INTO ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)}`, + ); + return Number((result as unknown as { insertId: number }).insertId); + }); +} +export async function reorderPagesCommand( + kind: CatalogKind, + input: { parentId: number; ids: number[]; expectedIds: number[] }, +) { + return db.transaction(async (tx) => { + const rows = await lockedPages(tx, kind); + validateSiblingOrder(rows, input.parentId, input.ids, input.expectedIds); + for (const [index, id] of input.ids.entries()) + await tx.execute( + sql`UPDATE ${tables(kind).pages} SET order_num=${index + 1} WHERE id=${id}`, + ); + }); +} +export async function deletePageCommand( + kind: CatalogKind, + id: number, + mode: "reparent" | "cascade", +) { + positiveId(id); + if (mode !== "reparent" && mode !== "cascade") + throw new CatalogInputError("Invalid delete mode"); + return db.transaction(async (tx) => { + const rows = await lockedPages(tx, kind); + const page = rows.find((row) => row.id === id); + if (!page) return { deletedPages: 0, movedChildren: 0 }; + const { pages, items } = tables(kind); + let ids = [id]; + let movedChildren = 0; + if (mode === "reparent") { + assertParent(rows, id, page.parentId); + movedChildren = rows.filter((row) => row.parentId === id).length; + await tx.execute( + sql`UPDATE ${pages} SET parent_id=${page.parentId} WHERE parent_id=${id}`, + ); + } else ids = collectSubtree(rows, id); + // String literals match INT and VARCHAR page_id installations without casting the indexed column. + await tx.execute( + sql`DELETE FROM ${items} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)})`, + ); + await tx.execute( + sql`DELETE FROM ${pages} WHERE id IN (${sql.join(ids, sql`, `)})`, + ); + return { deletedPages: ids.length, movedChildren }; + }); +} +export async function togglePageCommand( + kind: CatalogKind, + id: number, + field: "enabled" | "visible", +) { + positiveId(id); + if (field !== "enabled" && field !== "visible") + throw new CatalogInputError("Invalid toggle field"); + return db.transaction(async (tx) => { + const rows = await lockedPages(tx, kind); + if (!rows.some((row) => row.id === id)) + throw new CatalogNotFound("Catalog page not found"); + const column = sql.identifier(field); + await tx.execute( + sql`UPDATE ${tables(kind).pages} SET ${column}=CASE WHEN ${column}='1' THEN '0' ELSE '1' END WHERE id=${id}`, + ); + }); +} diff --git a/src/features/catalog/server/reorder-action.test.ts b/src/features/catalog/server/reorder-action.test.ts new file mode 100644 index 0000000000..1b8593e017 --- /dev/null +++ b/src/features/catalog/server/reorder-action.test.ts @@ -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(); +}); diff --git a/src/features/catalog/server/search.test.ts b/src/features/catalog/server/search.test.ts new file mode 100644 index 0000000000..c10438e6e8 --- /dev/null +++ b/src/features/catalog/server/search.test.ts @@ -0,0 +1,136 @@ +import type { SQL } from "drizzle-orm"; +import { MySqlDialect } from "drizzle-orm/mysql-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + queries: [] as { sql: string; params: unknown[] }[], + categories: [] as unknown[], + furniture: [] as unknown[], + offers: [] as unknown[], + parents: [] as unknown[], +})); +vi.mock("@/lib/db", () => ({ + db: { + execute: vi.fn(async (query: SQL) => { + const result = new MySqlDialect().sqlToQuery(query); + state.queries.push(result); + if (result.sql.includes("FROM items_base")) return [state.furniture]; + if (result.sql.includes("SELECT i.id")) return [state.offers]; + if (result.sql.includes("WHERE id IN")) return [state.parents]; + return [state.categories]; + }), + }, +})); + +import { searchCatalog } from "./search"; + +beforeEach(() => { + state.queries = []; + state.categories = []; + state.furniture = []; + state.offers = []; + state.parents = []; +}); +describe("bounded catalog search", () => { + it.each(["", "a", " ", "x".repeat(101)])( + "rejects invalid query before SQL: %s", + async (query) => { + await expect(searchCatalog("normal", query)).rejects.toThrow( + "Search requires", + ); + expect(state.queries).toHaveLength(0); + }, + ); + it("rejects an unknown catalog instead of interpolating a table name", async () => { + await expect(searchCatalog("other", "chair")).rejects.toThrow( + "Invalid catalog type", + ); + expect(state.queries).toHaveLength(0); + }); + it("binds text and treats user wildcard characters literally", async () => { + await searchCatalog("normal", "chair_%' OR 1=1"); + expect(state.queries.every((query) => !query.sql.includes("chair"))).toBe( + true, + ); + expect(state.queries[0].params).toContain("chair=_=%' OR 1==1%"); + expect(state.queries.every((query) => query.sql.includes("LIMIT"))).toBe( + true, + ); + }); + it.each(["normal", "bc"])( + "finds numeric IDs and resolves bounded category paths for %s", + async (kind) => { + state.furniture = [{ id: 7, classname: "chair", spriteId: 9 }]; + state.offers = [ + { + id: 50, + caption: "Offer", + pageId: "11", + pageCaption: "Chairs", + parentId: 1, + itemIds: "3;7", + }, + ]; + state.parents = [{ id: 1, caption: "Shop", parentId: -1 }]; + const results = await searchCatalog(kind, "9"); + expect(results).toEqual([ + { + kind: "offer", + id: 50, + pageId: 11, + caption: "Offer", + path: ["Shop", "Chairs"], + href: + kind === "bc" + ? "/admin/catalog/builder-club/11" + : "/admin/catalog/11", + furniture: { id: 7, classname: "chair", spriteId: 9 }, + }, + ]); + const offers = state.queries.find((query) => + query.sql.includes("SELECT i.id"), + ); + expect(offers?.sql.includes("i.offer_id")).toBe(kind === "normal"); + expect(offers?.sql).toContain( + kind === "bc" ? "catalog_items_bc" : "catalog_items", + ); + expect(offers?.params).toContain("7"); + expect(offers?.params).toContain("9"); + expect(state.queries).toHaveLength(4); + }, + ); + it("caps final results and furniture bundle predicates even if a repository overreturns", async () => { + state.categories = Array.from({ length: 40 }, (_, index) => ({ + id: index + 1, + caption: "Category", + parentId: -1, + })); + state.furniture = Array.from({ length: 80 }, (_, index) => ({ + id: index + 1, + classname: "chair", + spriteId: index, + })); + state.offers = Array.from({ length: 80 }, (_, index) => ({ + id: index + 1, + caption: "Offer", + pageId: 1, + pageCaption: "Category", + parentId: -1, + itemIds: "1", + })); + const results = await searchCatalog("normal", "chair"); + expect(results).toHaveLength(25); + const query = state.queries.find((query) => + query.sql.includes("SELECT i.id"), + ); + expect(query?.sql.match(/FIND_IN_SET/g)).toHaveLength(25); + expect(query?.params.at(-1)).toBe(13); + }); + it("terminates corrupt cyclic category paths without repeated parent queries", async () => { + state.categories = [{ id: 1, caption: "One", parentId: 2 }]; + state.parents = [{ id: 2, caption: "Two", parentId: 1 }]; + const results = await searchCatalog("normal", "One"); + expect(results[0].path).toEqual(["Two", "One"]); + expect(state.queries).toHaveLength(4); + }); +}); diff --git a/src/features/catalog/server/search.ts b/src/features/catalog/server/search.ts new file mode 100644 index 0000000000..f3febe117d --- /dev/null +++ b/src/features/catalog/server/search.ts @@ -0,0 +1,149 @@ +import "server-only"; +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { type CatalogSearchResult, parseCatalogSearch } from "../domain/search"; + +const LIMIT = 25; +const MAX_PATH_DEPTH = 16; +type PageRow = { id: number; caption: string; parentId: number }; +type FurnitureRow = { id: number; classname: string; spriteId: number }; +type OfferRow = { + id: number; + caption: string; + pageId: number; + pageCaption: string; + parentId: number; + itemIds: string; +}; + +export async function searchCatalog( + kind: string, + query: string, +): Promise { + const input = parseCatalogSearch(kind, query); + const pages = sql.identifier( + input.kind === "bc" ? "catalog_pages_bc" : "catalog_pages", + ); + const offers = sql.identifier( + input.kind === "bc" ? "catalog_items_bc" : "catalog_items", + ); + const number = input.numericId; + const [categoryData, furnitureData] = await Promise.all([ + db.execute( + sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE caption LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 12`, + ), + db.execute( + sql`SELECT id, item_name AS classname, sprite_id AS spriteId FROM items_base WHERE item_name LIKE ${input.pattern} ESCAPE '=' OR public_name LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number} OR sprite_id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 25`, + ), + ]); + const categories = (categoryData[0] as unknown as PageRow[]).slice(0, 12); + const furniture = (furnitureData[0] as unknown as FurnitureRow[]).slice( + 0, + LIMIT, + ); + const predicates = [sql`i.catalog_name LIKE ${input.pattern} ESCAPE '='`]; + if (number !== null) { + predicates.push(sql`i.id=${number}`, sql`i.page_id=${String(number)}`); + if (input.kind === "normal") predicates.push(sql`i.offer_id=${number}`); + } + // The legacy semicolon relation has no join index. Cap candidate furniture IDs before testing bundle membership. + for (const base of furniture) + predicates.push( + sql`FIND_IN_SET(${String(base.id)}, REPLACE(i.item_ids, ';', ',')) > 0`, + ); + const [offerData] = await db.execute( + sql`SELECT i.id, i.catalog_name AS caption, i.page_id AS pageId, i.item_ids AS itemIds, p.caption AS pageCaption, p.parent_id AS parentId FROM ${offers} i INNER JOIN ${pages} p ON p.id=i.page_id WHERE ${sql.join(predicates, sql` OR `)} ORDER BY ${number === null ? sql`i.id` : sql`CASE WHEN i.id=${number} THEN 0 ELSE 1 END, i.id`} LIMIT ${LIMIT - categories.length}`, + ); + const matches = (offerData as unknown as OfferRow[]).slice( + 0, + LIMIT - categories.length, + ); + const pageMap = new Map(); + for (const page of categories) + pageMap.set(Number(page.id), { + ...page, + id: Number(page.id), + parentId: Number(page.parentId), + }); + for (const offer of matches) + pageMap.set(Number(offer.pageId), { + id: Number(offer.pageId), + caption: offer.pageCaption, + parentId: Number(offer.parentId), + }); + let frontier = [...pageMap.values()].map((page) => page.parentId); + const attempted = new Set(pageMap.keys()); + for (let depth = 0; depth < MAX_PATH_DEPTH; depth++) { + const ids = [...new Set(frontier)] + .filter((id) => id > 0 && !attempted.has(id)) + .slice(0, LIMIT); + if (!ids.length) break; + for (const id of ids) attempted.add(id); + const [parents] = await db.execute( + sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE id IN (${sql.join(ids, sql`, `)}) LIMIT 25`, + ); + frontier = []; + for (const raw of parents as unknown as PageRow[]) { + const page = { + ...raw, + id: Number(raw.id), + parentId: Number(raw.parentId), + }; + pageMap.set(page.id, page); + frontier.push(page.parentId); + } + } + function pagePath(id: number) { + const path: string[] = []; + const seen = new Set(); + let current = id; + while (current > 0 && !seen.has(current) && seen.size <= MAX_PATH_DEPTH) { + seen.add(current); + const page = pageMap.get(current); + if (!page) { + path.unshift(`#${current}`); + break; + } + path.unshift(page.caption); + current = page.parentId; + } + return path; + } + const href = (id: number) => + input.kind === "bc" + ? `/admin/catalog/builder-club/${id}` + : `/admin/catalog/${id}`; + return [ + ...categories.map( + (page): CatalogSearchResult => ({ + kind: "category", + id: Number(page.id), + pageId: Number(page.id), + caption: page.caption, + path: pagePath(Number(page.id)), + href: href(Number(page.id)), + }), + ), + ...matches.map((offer): CatalogSearchResult => { + const ids = new Set(String(offer.itemIds).split(";").map(Number)); + const base = furniture.find((item) => ids.has(Number(item.id))); + return { + kind: "offer", + id: Number(offer.id), + pageId: Number(offer.pageId), + caption: offer.caption, + path: pagePath(Number(offer.pageId)), + href: href(Number(offer.pageId)), + ...(base + ? { + furniture: { + id: Number(base.id), + classname: base.classname, + spriteId: Number(base.spriteId), + }, + } + : {}), + }; + }), + ]; +} diff --git a/src/features/catalog/server/sync-status.test.ts b/src/features/catalog/server/sync-status.test.ts new file mode 100644 index 0000000000..bd811c1f2a --- /dev/null +++ b/src/features/catalog/server/sync-status.test.ts @@ -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()); diff --git a/src/features/catalog/server/sync-status.ts b/src/features/catalog/server/sync-status.ts new file mode 100644 index 0000000000..de5d45b8c1 --- /dev/null +++ b/src/features/catalog/server/sync-status.ts @@ -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 { + 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 { + 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; +} diff --git a/src/lib/services/catalog-git-queue.test.ts b/src/lib/services/catalog-git-queue.test.ts new file mode 100644 index 0000000000..6644f99496 --- /dev/null +++ b/src/lib/services/catalog-git-queue.test.ts @@ -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); +}); diff --git a/src/lib/services/catalog-git-queue.ts b/src/lib/services/catalog-git-queue.ts index 401e4f961b..af7f113dd2 100644 --- a/src/lib/services/catalog-git-queue.ts +++ b/src/lib/services/catalog-git-queue.ts @@ -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( try { return await operation(); } finally { - await finish(); + try { + await finish(); + } catch (error) { + logger.error("Catalog operation finished but export scheduling failed", { + module: "catalog", + error, + }); + } } } diff --git a/src/lib/services/catalog-items-loader.ts b/src/lib/services/catalog-items-loader.ts index d93185b368..065e7ceb39 100644 --- a/src/lib/services/catalog-items-loader.ts +++ b/src/lib/services/catalog-items-loader.ts @@ -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) => ({ 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 ?? ""), })); diff --git a/src/lib/services/catalog-tree.test.ts b/src/lib/services/catalog-tree.test.ts index ab80d5e306..80db07935d 100644 --- a/src/lib/services/catalog-tree.test.ts +++ b/src/lib/services/catalog-tree.test.ts @@ -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", + ); }); }); diff --git a/src/lib/services/catalog-tree.ts b/src/lib/services/catalog-tree.ts index c92175a280..d5b8f7ab4a 100644 --- a/src/lib/services/catalog-tree.ts +++ b/src/lib/services/catalog-tree.ts @@ -1,4 +1,8 @@ -import { asc, eq, inArray, sql } from "drizzle-orm"; +import { asc, eq, sql } from "drizzle-orm"; +import { + deletePageCommand, + updatePageCommand, +} from "@/features/catalog/server/page-commands"; import { CatalogPages, db } from "@/lib/db"; export type { NestedTreeNode, TreeNode } from "@/types/catalog"; @@ -72,7 +76,7 @@ export async function getTreeFlat(): Promise { enabled: CatalogPages.enabled, }) .from(CatalogPages) - .orderBy(asc(CatalogPages.orderNum)); + .orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id)); const itemCountMap = await getCatalogItemCounts(); const childCountMap = new Map(); @@ -204,94 +208,13 @@ export async function movePage( pageId: number, newParentId: number, ): Promise { - if (newParentId > 0) { - let currentId = newParentId; - for (let i = 0; i < 50; i++) { - if (currentId === pageId) { - throw new Error("Cannot move page: would create a circular hierarchy"); - } - const [parent] = await db - .select({ parentId: CatalogPages.parentId }) - .from(CatalogPages) - .where(eq(CatalogPages.id, currentId)) - .limit(1); - if (!parent || parent.parentId <= 0) break; - currentId = parent.parentId; - } - } - - await db - .update(CatalogPages) - .set({ parentId: newParentId }) - .where(eq(CatalogPages.id, pageId)); + await updatePageCommand("normal", pageId, { parentId: newParentId }); } - -/** - * Delete catalog_items for the given page ids. - * Habbo DBs often store page_id as VARCHAR; text delete on INT or VARCHAR - * columns keeps the page_id index usable (CAST would force a full scan). - */ -async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise { - if (pageIds.length === 0) return; - const idStrs = pageIds.map(String); - await db.execute(sql` - DELETE FROM catalog_items - WHERE page_id IN (${sql.join(idStrs, sql`, `)}) - `); -} - -/** - * Delete a page with cascade or reparent mode. - */ export async function deletePage( pageId: number, mode: "cascade" | "reparent" = "reparent", -): Promise<{ deletedPages: number; movedChildren: number }> { - const [page] = await db - .select({ parentId: CatalogPages.parentId }) - .from(CatalogPages) - .where(eq(CatalogPages.id, pageId)) - .limit(1); - if (!page) return { deletedPages: 0, movedChildren: 0 }; - - if (mode === "reparent") { - const [result] = await db - .update(CatalogPages) - .set({ parentId: page.parentId }) - .where(eq(CatalogPages.parentId, pageId)); - - await deleteCatalogItemsByPageIds([pageId]); - await db.delete(CatalogPages).where(eq(CatalogPages.id, pageId)); - - return { deletedPages: 1, movedChildren: result.affectedRows }; - } - - const deleted = await cascadeDelete(pageId); - return { deletedPages: deleted, movedChildren: 0 }; -} - -async function cascadeDelete(pageId: number): Promise { - const toDelete: number[] = [pageId]; - const queue: number[] = [pageId]; - - while (queue.length > 0) { - const children = await db - .select({ id: CatalogPages.id }) - .from(CatalogPages) - .where(inArray(CatalogPages.parentId, queue)); - queue.length = 0; - for (const child of children) { - toDelete.push(child.id); - queue.push(child.id); - } - } - - await deleteCatalogItemsByPageIds(toDelete); - for (let i = toDelete.length - 1; i >= 0; i--) { - await db.delete(CatalogPages).where(eq(CatalogPages.id, toDelete[i])); - } - - return toDelete.length; +) { + return deletePageCommand("normal", pageId, mode); } /** diff --git a/src/messages/en.json b/src/messages/en.json index 69902132a8..6b6fcad64f 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -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": { diff --git a/src/messages/it.json b/src/messages/it.json index 6bcf51752b..9247a5f292 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -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": { diff --git a/src/messages/nl.json b/src/messages/nl.json index 8c068b78b0..24c390d16c 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -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": {