21 Commits
Author SHA1 Message Date
openhands 52459c0b74 feat(db): add self-tuned mariadb-turbo container and bulk JSON importer
CI / check (push) Failing after 1m15s
CI / deploy (push) Skipped
- docker-compose: opt-in mariadb-turbo service (profile 'db') with inline
  mysqld tuning (max_allowed_packet=512M, innodb_flush_log_at_trx_commit=2,
  2G buffer pool, net_read/write_timeout=600) for >50 MB JSON bulk loads;
  named volume for the datadir + node_modules host-sync guidance
- scripts/bulk-import-json.ts: chunked (2000-row) idempotent importer with
  ON DUPLICATE KEY UPDATE, resumable, habbo furnidata or flat-array input
- src/db/schema-gamedata.ts: promote+index JSON storage schema (furnidata,
  docs, texts) incl. VIRTUAL generated columns for MariaDB
- package.json: add db:bulk, db:up, db:down, db:introspect, db:schema:generate
2026-09-07 16:54:44 +02:00
openhands a640e40a5d fix(docker): clear build cache on every build to stop unbounded disk growth
CI / check (push) Successful in 1m17s
CI / deploy (push) Successful in 2m11s
2026-09-07 16:18:04 +02:00
openhands 649e2f0663 feat(docker): self-contained runtime dirs, image healthcheck, spec-compliant Dockerfile
CI / check (push) Successful in 1m14s
CI / deploy (push) Successful in 1m40s
- Create the runtime write targets (/app/storage, /app/public/nitro-assets,
  /app/public/swf, /var/www/Gamedata) owned by UID/GID 33 in the runner image
  so running without the bound volumes no longer hits ENOENT.
- Bake a HEALTHCHECK into the image so `docker run` (ci-deploy.sh) also reports
  Docker-level health; compose can still override it with its own probe.
- Add the dockerfile:1 syntax pragma and ignore non-pnpm lockfiles so a stray
  package-lock.json/yarn.lock can never taint the build context.
2026-09-07 11:44:52 +02:00
openhands 1a9b361420 fix(docker): inject real commit id into build via NEXT_DEPLOYMENT_ID
CI / check (push) Successful in 1m12s
CI / deploy (push) Successful in 1m41s
next.config.ts falls back to `git rev-parse HEAD`, but the build context has
no .git (excluded by .dockerignore), so every CI build printed fatal git
errors and stamped the release as "unknown". Pass the deploy commit sha as a
NEXT_DEPLOYMENT_ID build-arg so git is never invoked and the actual commit
reaches NEXT_PUBLIC_CMS_RELEASE and deploymentId.
2026-09-07 11:39:29 +02:00
openhands 3e0ba73d1c fix(docker): include pnpm-workspace and npmrc in builder context
CI / check (push) Successful in 1m12s
CI / deploy (push) Successful in 1m26s
The committed lockfile records overrides from pnpm-workspace.yaml. With the
narrower manifest COPY, pnpm install --frozen-lockfile ran without the
workspace file and failed with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH in CI.
Copy package.json, pnpm-lock, pnpm-workspace and .npmrc together so the
override config present in the lockfile is also supplied at install time.
2026-09-07 11:33:04 +02:00
openhands bef1775dea fix(toolchain): restore node engines range to match .nvmrc pin
CI / check (push) Successful in 1m13s
CI / deploy (push) Failing after 41s
2026-09-07 11:24:52 +02:00
openhands c5284e0b79 test(docker): align build-contract test with pnpm fetch caching
CI / check (push) Failing after 16s
CI / deploy (push) Skipped
2026-09-07 11:22:30 +02:00
openhands 539e6d3fad fix(docker): harden image and automate safe VPS updates
- Use floating node:alpine that tracks the latest supported LTS; pnpm
  bootstrap follows package.json's packageManager pin.
- Drop corepack (removed from node:26), install pnpm via npm global.
- Add pnpm fetch + offline install for stable dependency-layer caching.
- Run as non-root nextjs (UID/GID 33 = host www-data) with tini as PID 1
  for correct signal handling.
- Open node engines to >=20.9.0 so patches/minors float automatically.
- Add docker-preflight.sh (per-VPS checks incl. --fix) and gate docker-update.sh
  so Node major upgrades require explicit review while patches deploy silently.
2026-09-07 11:22:30 +02:00
Simo 7033d65846 fix(ui): make shared switches visible across CMS themes
CI / check (push) Successful in 1m13s
CI / deploy (push) Successful in 1m2s
2026-09-06 21:30:13 +02:00
Simo 35d0f3ee01 fix(catalog): make access controls visible and compact
CI / check (push) Successful in 1m12s
CI / deploy (push) Successful in 1m7s
2026-09-06 21:08:52 +02:00
Simo 93a7e9a7c2 feat(catalog): improve visual manager search and offer discovery
CI / check (push) Successful in 1m14s
CI / deploy (push) Successful in 1m29s
2026-09-06 20:58:53 +02:00
openhands 08321df2aa fix docker file
CI / check (push) Successful in 1m16s
CI / deploy (push) Successful in 1m20s
2026-09-06 20:47:39 +02:00
Simo 55a47949f1 feat(catalog): add reviewed bulk edits and complete category duplication
CI / check (push) Successful in 1m14s
CI / deploy (push) Successful in 1m11s
2026-09-06 20:25:19 +02:00
Simo 0bedc04692 fix(catalog): open visual manager from dedicated button 2026-09-06 19:58:10 +02:00
Simo 814d8650be fix(deploy): build checked-out source without GitLab API request
CI / check (push) Successful in 1m15s
CI / deploy (push) Successful in 1m36s
2026-09-06 19:48:49 +02:00
Simo 193b6686a9 refactor(catalog): unify commands and embed guarded catalog workspace
CI / check (push) Successful in 1m16s
CI / deploy (push) Successful in 28s
2026-09-06 19:40:52 +02:00
openhands 16f35568f5 refactor: optimize test suite, fix vitest mocks and streamline deployment
CI / check (push) Successful in 1m14s
CI / deploy (push) Failing after 23s
2026-09-06 19:29:12 +02:00
openhands 3f2f2c6ed1 fix: resolve typescript types and existsSync mock in download-errors test
CI / check (push) Successful in 1m23s
CI / deploy (push) Failing after 31s
2026-09-06 19:26:37 +02:00
openhands 75dc84d9f1 fix: restore existsSync in node:fs test mock
CI / check (push) Failing after 25s
CI / deploy (push) Skipped
2026-09-06 19:24:15 +02:00
openhands fea8023ac6 Merge branch 'main' of https://gitlab.epicnabbo.nl/remco/epicnext-cms
CI / check (push) Failing after 1m5s
CI / deploy (push) Skipped
2026-09-06 19:21:47 +02:00
openhands d1003bb89b Update Dockerfile and Compose configuration 2026-09-06 19:19:25 +02:00
100 changed files with 9233 additions and 1109 deletions

No files matched your search

View File
Whitespace-only changes.
+6
View File
@@ -7,6 +7,12 @@ storage
prod.log
update.log
.pm2
# Only the pnpm lockfile is used. Ignore other lockfile formats and stray
# package managers so they never taint the build context by accident.
package-lock.json
yarn.lock
bun.lockb
.npmrc.bak
# NOTE: .env is intentionally NOT ignored here — the build loads it (only inside
# a build RUN layer) to produce NEXT_PUBLIC_* + validated build-time values.
# It is not copied into the runtime image (the runner stage copies only
+42 -104
View File
@@ -1,110 +1,48 @@
# syntax=docker/dockerfile:1
# ==============================================================================
# EpicNext-CMS — Docker image (Node 26.8.1, multi-package-manager, Next.js standalone)
# ==============================================================================
# Supports pnpm (default), npm, and yarn. The build stage detects which package
# manager lockfile is present and uses it automatically.
# ==============================================================================
# --- Builder stage ---
FROM node:26.8.1-bookworm-slim AS builder
# git is needed by next.config.ts (git rev-parse for deploymentId) and
# ca-certificates by package registries. Build runs on host network (see
# compose: iptables is disabled), so apt has registry access here.
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install all three package managers so the build can pick whichever lockfile exists.
RUN npm install -g [email protected] yarn
# node:alpine = latest Node within the supported LTS major (tracks the newest
# patch automatically; currently v26.x, which satisfies package.json's
# engines ">=26.8.1 <27").
FROM node:alpine AS builder
WORKDIR /app
# Lockfiles and installer settings are the only inputs to the dependency layer.
# The wildcard supports pnpm-lock.yaml, package-lock.json and yarn.lock.
COPY package.json *lock* pnpm-workspace.yaml .npmrc ./
# --- Detect package manager & install dependencies ---
# Priority: pnpm > yarn > npm
# Build arg lets the user force a manager; otherwise it is auto-detected.
ARG PACKAGE_MANAGER=
RUN --mount=type=cache,id=epicnext-pnpm,target=/pnpm/store,sharing=locked \
if [ "$PACKAGE_MANAGER" = "pnpm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f pnpm-lock.yaml ]; }; then \
echo ">> Using pnpm" && \
pnpm install --frozen-lockfile --ignore-scripts --store-dir=/pnpm/store; \
elif [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \
echo ">> Using yarn" && \
yarn install --frozen-lockfile --ignore-scripts; \
elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \
echo ">> Using npm" && \
npm ci --ignore-scripts; \
else \
echo "!! No lockfile found — falling back to npm install" && \
npm install --ignore-scripts; \
fi
# Source changes invalidate compilation, but keep the installed dependencies.
ENV NEXT_TELEMETRY_DISABLED=1
# Real release id supplied by CI (ci-deploy.sh) so next.config.ts skips git
# entirely (there is no .git in the build context). Defaults to "unknown".
ARG NEXT_DEPLOYMENT_ID="unknown"
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
# pnpm install is always pinned to the version in package.json's
# `packageManager` field (pnpm auto-selects it on install), so this global
# install only needs to exist as a bootstrap and follows the active major.
RUN apk add --no-cache git \
&& npm install -g pnpm@latest
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
# pnpm fetch: download all deps into $PNPM_STORE first, so only the lockfile
# change (not source changes) invalidates the network-heavy download layer.
RUN pnpm fetch --ignore-scripts
RUN pnpm install --frozen-lockfile --ignore-scripts --offline
COPY . .
RUN pnpm run build
# Build the production bundle.
# The .env file is loaded ONLY inside this RUN layer (not persisted as ENV, so no
# secrets end up in the image) — Next.js needs NEXT_PUBLIC_* + validated build-time
# values (HOTEL_NAME, DATABASE_URL, AUTH_SECRET, ...) at build time.
ENV NODE_ENV=production
RUN --mount=type=cache,id=epicnext-next,target=/app/.next/cache,sharing=locked \
if [ -f .env ]; then set -a && . ./.env && set +a; fi && \
if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f yarn.lock ]; }; then \
yarn build; \
elif [ "$PACKAGE_MANAGER" = "npm" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f package-lock.json ]; }; then \
npm run build; \
else \
pnpm build; \
fi
# Standalone output already contains the traced runtime dependencies.
# Pruning builder/node_modules here would not shrink the final image.
# --- Runtime stage ---
FROM node:26.8.1-bookworm-slim AS runner
# Catalog Studio publishes to self-hosted Git repositories over HTTPS.
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
# The CMS writes to bind-mounted host directories (/var/www/Gamedata is owned by
# the host's www-data user, UID/GID 33). The node base image already ships a
# www-data user with UID/GID 33, which matches that ownership — so we run as
# www-data and can write to the shared gamedata directory. If your host owner
# differs, override via --build-arg RUN_USER (e.g. --build-arg RUN_USER=1000).
ARG RUN_USER=www-data
ENV NODE_ENV=production
ENV PORT=3002
ENV HOSTNAME=0.0.0.0
EXPOSE 3002
FROM node:alpine AS runner
WORKDIR /app
# Storage directory for runtime uploaded media (persistent volume).
# Also create the hardcoded gamedata mount point (/var/www/Gamedata is
# bind-mounted at runtime so the CMS can read + write imported assets there).
RUN mkdir -p /app/storage \
/app/public/nitro-assets \
/app/public/swf \
/var/www/Gamedata \
&& chown -R ${RUN_USER} /app /var/www/Gamedata
# Copy standalone Next.js output (includes a minimal node_modules).
COPY --from=builder --chown=${RUN_USER} /app/.next/standalone ./
# Copy static assets (public files served directly).
COPY --from=builder --chown=${RUN_USER} /app/public ./public
# Copy the server-side static build output.
COPY --from=builder --chown=${RUN_USER} /app/.next/static ./.next/static
# Client assets + runtime uploads live outside the image (mounted volumes).
VOLUME ["/app/public/nitro-assets", "/app/public/swf", "/app/storage"]
USER ${RUN_USER}
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3002 \
HOSTNAME=0.0.0.0
RUN apk add --no-cache tini \
&& addgroup -g 33 -S nextjs && adduser -u 33 -S -G nextjs nextjs \
&& mkdir -p /app/storage /app/public/nitro-assets /app/public/swf /var/www/Gamedata \
&& chown -R 33:33 /app/storage /app/public /var/www/Gamedata
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3002
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level
# health; docker-compose overrides this with its own probe if needed.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
+78 -1
View File
@@ -1,4 +1,4 @@
# EpicNext-CMS v1.0.1
# EpicNext-CMS v1.0.3
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Drizzle ORM** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
@@ -196,6 +196,7 @@ an exit of 0 means the CMS is healthy on the new commit.
| `docker compose pull && docker compose up -d --build` | Update and redeploy |
| `pnpm db:migrate` (on the **host**) | Run database migrations (slim image has no source) |
| `./scripts/docker-update.sh` | Full automated update (manual or cron) |
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` bulk-load container |
### How Package Manager Detection Works
@@ -258,6 +259,45 @@ The container includes a health check that hits `/api/health` on port 3002 every
docker inspect --format='{{.State.Health.Status}}' epicnext-cms
```
### MariaDB Turbo (bulk loads)
`docker-compose.yml` ships an opt-in **`mariadb-turbo`** service — a dedicated MariaDB 11 container tuned for loading >50 MB JSON dumps (furnidata, external texts, etc.). It configures itself via mysqld flags on the `command:`, so no external `.cnf` file has to be mounted or kept in sync:
```bash
pnpm db:up # docker compose --profile db up -d (starts mariadb-turbo)
```
Key settings (see the `command:` block in `docker-compose.yml`):
- `max_allowed_packet = 512M` — 50 MB JSON batches no longer hit the 16 MB packet ceiling.
- `innodb_flush_log_at_trx_commit = 2` + `innodb_doublewrite = 0` — durability relaxed for bulk writes.
- `innodb_buffer_pool_size = 2G`, `innodb_log_file_size = 1G`, `bulk_insert_buffer_size = 512M`.
- `net_read_timeout` / `net_write_timeout = 600`, `wait_timeout = 3600` — fixes the `drizzle-kit` **"Pulling schema from database..."** hang by never starving introspection/DDL sessions behind a long import.
- `performance_schema = OFF` — saves ~1-2 GB RAM.
The datadir lives in a **named volume** (`mariadb-turbo-data`), never a host bind-mount: shared-filesystem sync trashes InnoDB files the same way it corrupts pnpm's `node_modules`. Named volumes stay inside the container filesystem, so 50 MB of JSON writes never cross a host-sync boundary.
> **Port note:** the service uses `network_mode: host` and binds `127.0.0.1:3306` — stop the host MariaDB first, otherwise the port collides with the standalone `.env` `DATABASE_URL` (`localhost:3306`).
#### node_modules & host-sync corruption
pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules must never be a host bind-mount** (Docker Desktop gRPC-FUSE/VirtioFS, Unison and Syncthing all corrupt it). The production build bakes `node_modules` into the image at build time; it is never bind-mounted. For local dev, mount a *named volume* (`node_modules:/app/node_modules`) instead of the host directory, and keep `node_modules` / the pnpm store out of any shared-filesystem bind.
#### Loading a 50 MB JSON dump
```bash
# Habbo furnidata (auto-detects roomitemtypes / wallitemtypes / effecttypes)
pnpm db:bulk --file=/var/www/Gamedata/config/FurnitureData.json
# Flat array of documents → generic JSON store
pnpm db:bulk --file=/data/products.json --table=docs --category=furni
# Key/value texts
pnpm db:bulk --file=/data/external_texts.json --table=texts --category=default
```
The importer (`scripts/bulk-import-json.ts`) maps rows onto `src/db/schema-gamedata.ts`, batches them into **2000-row multi-row INSERTs** (one statement per chunk), uses `ON DUPLICATE KEY UPDATE` so re-runs are idempotent and interrupted loads resume, and prints rows/s + ETA. See ["ORM Setup & Type Generation"](#orm-setup--type-generation) → *Bulk JSON storage* for the design.
---
@@ -284,10 +324,41 @@ const found = await db.select()
| `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy) |
| `pnpm db:studio` | Open Drizzle Studio (dev only) |
| `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft |
| `pnpm db:bulk` | Batch-import >50 MB JSON (2000-row chunks, resumable) via `scripts/bulk-import-json.ts` |
| `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema + live DB |
> The CMS does **not** use `drizzle-kit push` or `drizzle-kit migrate` — the database is shared with the emulator. Apply CMS DDL only via `pnpm db:migrate`.
#### Bulk JSON storage (MariaDB)
Emulator/hotel JSON dumps (furnidata, external texts, product data) are often >50 MB. MariaDB's `JSON` type is an alias for `LONGTEXT` and can **never be indexed directly**, so `src/db/schema-gamedata.ts` follows the "promote + index" pattern:
- The raw JSON document stays intact in a `longtext` / `mediumtext` column (`payload`, `value`).
- The fields you actually `WHERE` / `ORDER BY` on are promoted into real columns (`sprite_id`, `class_name`, `kind`, `title`, `text_key`) and indexed.
- Optional **VIRTUAL generated columns** extract indexed fields from the payload with `JSON_EXTRACT` (MariaDB supports secondary indexes on virtual columns, 10.2+), so no duplicate data has to be written by the importer.
Three tables are exported:
| Table | Purpose | Unique key |
| -------------------- | ---------------------------------------------- | --------------------------- |
| `gamedata_furnidata` | One row per furni/clothing item (habbo furnidata_json shape) | `(source, sprite_id)` |
| `gamedata_docs` | Generic JSON document store (per-key documents) | `(category, doc_key)` |
| `gamedata_texts` | External-texts style key/value pairs | `(category, text_key)` |
The bulk importer (`scripts/bulk-import-json.ts`, run via `pnpm db:bulk`) is DB-driven and fast precisely because each chunk is a *single* multi-row `INSERT … VALUES () ()… ON DUPLICATE KEY UPDATE`, so a 50 MB dump is a few dozen statements instead of hundreds of thousands of round-trips. Flags:
| Flag | Default | Description |
| -------------------- | ----------- | ------------------------------------------------- |
| `--file=<path>` | — (required)| JSON file (habbo furnidata_json or flat array) |
| `--table=<name>` | `furnidata` | One of `furnidata` \| `docs` \| `texts` |
| `--source=<x>` | `habbo` | `source` value for `furnidata` |
| `--category=<x>` | `default` | `category` value for `docs` / `texts` |
| `--chunk-size=N` | `2000` | Rows per multi-row INSERT |
| `--limit=N` | `0` | Stop after N rows (dry-test) |
| `--truncate` | off | DELETE rows for this source/category first |
The script sets `FOREIGN_KEY_CHECKS=0` for the session and is safe to interrupt: each chunk commits on its own, and `ON DUPLICATE KEY UPDATE` makes re-runs overwrite instead of appending.
---
## DragonflyDB (Caching, Rate Limiting, SSE)
@@ -503,6 +574,8 @@ slow_query_log_file = /var/log/mysql/mariadb-slow.log
The slow-query log surfaces hot-spot SQL for a follow-up index/query audit. The CMS connection pool itself is already tuned (pool size 25, `connection_limit` in `DATABASE_URL`).
For bulk-written tables (game-data JSON, imports) a containerized **`mariadb-turbo`** variant is available — see [MariaDB Turbo](#mariadb-turbo-bulk-loads).
### 3. Asset caching headers
| Path | Cache-Control |
@@ -553,6 +626,8 @@ pm2 restart next
| `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` |
| `pnpm db:studio` | Drizzle Studio (dev) |
| `pnpm db:introspect` | drizzle-kit introspect (draft) |
| `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` |
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container |
| `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) |
| `pnpm analyze` | Build + open bundle analyzer |
| `pnpm jobs:worker` | Start background task worker |
@@ -589,12 +664,14 @@ pm2 restart next
│ └── drafts/ # drizzle-kit generate output (never auto-applied)
├── scripts/
│ ├── apply-migrations.ts # SQL migration runner (apply + status)
│ ├── bulk-import-json.ts # 50 MB+ JSON importer (2000-row chunks, UPSERT)
│ ├── jobs-worker.ts # Background task scheduler
│ ├── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB
│ └── compress-gamedata.mjs # Pre-compress large gamedata JSON (gzip_static)
├── src/
│ ├── db/
│ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer)
│ │ ├── schema-gamedata.ts # Large-JSON storage schema (furnidata/docs/texts)
│ │ └── relations.ts # Drizzle relations
│ ├── app/ # Next.js App Router (pages & API routes)
│ ├── actions/ # Server Actions
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,205 @@
[
{
"id": 132,
"sprite_id": 132,
"item_name": "floortile",
"public_name": "Floor Tile",
"type": "s",
"width": 1,
"length": 1,
"stack_height": 0,
"allow_stack": 1,
"allow_sit": 0,
"allow_lay": 0,
"allow_walk": 1,
"allow_gift": 1,
"allow_trade": 1,
"allow_recycle": 0,
"allow_marketplace_sell": 0,
"allow_inventory_stack": 1,
"interaction_type": "default",
"interaction_modes_count": 0,
"vending_ids": "0",
"multiheight": "",
"customparams": "",
"effect_id_male": 0,
"effect_id_female": 0,
"clothing_on_walk": "",
"page_id": "429",
"rare": "0"
},
{
"id": 420,
"sprite_id": 420,
"item_name": "soft_jaggara_norja",
"public_name": "Norja-pehmojakkara",
"type": "s",
"width": 1,
"length": 3,
"stack_height": 1.7,
"allow_stack": 1,
"allow_sit": 1,
"allow_lay": 0,
"allow_walk": 0,
"allow_gift": 1,
"allow_trade": 1,
"allow_recycle": 0,
"allow_marketplace_sell": 0,
"allow_inventory_stack": 1,
"interaction_type": "default",
"interaction_modes_count": 1,
"vending_ids": "0",
"multiheight": "",
"customparams": "",
"effect_id_male": 0,
"effect_id_female": 0,
"clothing_on_walk": "",
"page_id": "429",
"rare": "0"
},
{
"id": 1001,
"sprite_id": 1001,
"item_name": "Chess",
"public_name": "",
"type": "i",
"width": 1,
"length": 1,
"stack_height": 1,
"allow_stack": 1,
"allow_sit": 0,
"allow_lay": 0,
"allow_walk": 0,
"allow_gift": 1,
"allow_trade": 1,
"allow_recycle": 0,
"allow_marketplace_sell": 0,
"allow_inventory_stack": 1,
"interaction_type": "default",
"interaction_modes_count": 0,
"vending_ids": "0",
"multiheight": "",
"customparams": "",
"effect_id_male": 0,
"effect_id_female": 0,
"clothing_on_walk": "",
"page_id": "429",
"rare": "0"
},
{
"id": 1011,
"sprite_id": 1011,
"item_name": "TicTacToe",
"public_name": "",
"type": "i",
"width": 1,
"length": 1,
"stack_height": 1,
"allow_stack": 1,
"allow_sit": 0,
"allow_lay": 0,
"allow_walk": 0,
"allow_gift": 1,
"allow_trade": 1,
"allow_recycle": 0,
"allow_marketplace_sell": 0,
"allow_inventory_stack": 1,
"interaction_type": "default",
"interaction_modes_count": 0,
"vending_ids": "0",
"multiheight": "",
"customparams": "",
"effect_id_male": 0,
"effect_id_female": 0,
"clothing_on_walk": "",
"page_id": "429",
"rare": "0"
},
{
"id": 1021,
"sprite_id": 1021,
"item_name": "BattleShip",
"public_name": "",
"type": "i",
"width": 1,
"length": 1,
"stack_height": 1,
"allow_stack": 1,
"allow_sit": 0,
"allow_lay": 0,
"allow_walk": 0,
"allow_gift": 1,
"allow_trade": 1,
"allow_recycle": 0,
"allow_marketplace_sell": 0,
"allow_inventory_stack": 1,
"interaction_type": "default",
"interaction_modes_count": 0,
"vending_ids": "0",
"multiheight": "",
"customparams": "",
"effect_id_male": 0,
"effect_id_female": 0,
"clothing_on_walk": "",
"page_id": "429",
"rare": "0"
},
{
"id": 1659,
"sprite_id": 1659,
"item_name": "ticket",
"public_name": "Big Ticket Bundle",
"type": "s",
"width": 1,
"length": 1,
"stack_height": 1,
"allow_stack": 1,
"allow_sit": 0,
"allow_lay": 0,
"allow_walk": 0,
"allow_gift": 1,
"allow_trade": 1,
"allow_recycle": 0,
"allow_marketplace_sell": 0,
"allow_inventory_stack": 1,
"interaction_type": "default",
"interaction_modes_count": 0,
"vending_ids": "0",
"multiheight": "",
"customparams": "",
"effect_id_male": 0,
"effect_id_female": 0,
"clothing_on_walk": "",
"page_id": "429",
"rare": "0"
},
{
"id": 10056,
"sprite_id": 10056,
"item_name": "Vacuum",
"public_name": "laundry_r18_vacuum",
"type": "s",
"width": 1,
"length": 1,
"stack_height": 0,
"allow_stack": 0,
"allow_sit": 0,
"allow_lay": 0,
"allow_walk": 0,
"allow_gift": 1,
"allow_trade": 1,
"allow_recycle": 0,
"allow_marketplace_sell": 0,
"allow_inventory_stack": 1,
"interaction_type": "default",
"interaction_modes_count": 0,
"vending_ids": "0",
"multiheight": "",
"customparams": "",
"effect_id_male": 0,
"effect_id_female": 0,
"clothing_on_walk": "",
"page_id": "9966",
"rare": "0"
}
]
+119 -5
View File
@@ -7,11 +7,6 @@ services:
# build containers on the bridge network have no outbound NAT/DNS. Build on
# the host network instead so pnpm/npm/yarn can reach the registry.
network: host
args:
# Run as the host owner (www-data = UID/GID 33, already present in the
# node base image) of /var/www/Gamedata so the container can read + write
# the shared gamedata directory.
RUN_USER: "www-data"
container_name: epicnext-cms
# Runs on the host network so existing 127.0.0.1 refs in .env keep working:
# MariaDB (3306), DragonflyDB/Redis (6379), emulator RCON (3003) + API (3001),
@@ -22,6 +17,8 @@ services:
restart: unless-stopped
env_file:
- .env
environment:
- HOSTNAME=0.0.0.0
volumes:
# ── Write targets (runtime imports/uploads, persistent on the host) ──
# The CMS writes imported furni/figures/pets/effects here (see
@@ -39,6 +36,16 @@ services:
# same directory, so mount it into the container at the same absolute path.
# Must be RW so furniture/badge imports can write mirrors to it.
- /var/www/Gamedata:/var/www/Gamedata
#
# ── node_modules corruption (§ host-sync) ──
# pnpm node_modules must NEVER be a host bind-mount: shared-filesystem
# sync (Docker Desktop gRPC-FUSE/VirtioFS, Unison, Syncthing) corrupts
# pnpm's hardlinked store and .bin shims. The production image already
# bakes node_modules in at build time and is NOT bind-mounted here.
# For local dev use a *named volume* instead of a host bind:
# - node_modules:/app/node_modules
# and never share `node_modules` / `pnpm store` via the Docker bind.
# On macOS add `:cached`/`:delegated` consistency labels per mount.
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
@@ -48,6 +55,27 @@ services:
start_period: 40s
mem_limit: 2g
# ── FlareSolverr (Cloudflare bypass for clone sources) ──
# Solves Cloudflare/TLS-fingerprint blocks via a headless Chrome browser.
# The CMS calls this on http://localhost:8191 for sources that block Node's
# TLS fingerprint (e.g. Leet.city). Used by src/lib/services/flare-solver.ts.
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
container_name: flaresolverr
network_mode: host
restart: unless-stopped
environment:
- LOG_LEVEL=info
- BROWSER_TIMEOUT=60000
- BROWSER_WORKERS=1
mem_limit: 2g
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8191/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# ── Opt-in: Octane-Renderer (Habbo avatar imager) ──
# Serves /imaging on port 3030 (the CMS proxies /imaging to it). Renders
# avatars into /var/www/Gamedata/habbo-imaging, so it needs RW access.
@@ -61,3 +89,89 @@ services:
# restart: unless-stopped
# volumes:
# - /var/www/Gamedata:/var/www/Gamedata
# ── MariaDB "Turbo" (heavy JSON / bulk-loads) ──
# Dedicated MariaDB tuned for >50 MB JSON imports. All tuning lives inline
# in the service `command:` (bulk_insert_buffer_size,
# innodb_flush_log_at_trx_commit=2, max_allowed_packet=512M, ...) so the
# container configures itself — no external .cnf to mount or keep in sync.
# Opt-in via profile so `docker compose up` keeps running the standalone
# stack as today.
#
# Start: docker compose --profile db up -d (= pnpm db:up)
# Note: host networking binds 127.0.0.1:3306 → STOP the host MariaDB
# first (the app's .env DATABASE_URL points at localhost:3306).
# Fixes the "Pulling schema from database..." hang: raise
# net_read/net_write_timeout (done above) + stop imports while
# running `pnpm db:generate` / drizzle-kit introspection.
mariadb-turbo:
image: mariadb:11
container_name: mariadb-turbo
profiles: ["db"]
network_mode: host
restart: unless-stopped
environment:
# mariadb:11 uses MARIADB_*; MYSQL_* also works but is deprecated there.
- MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD:-root}
- MARIADB_DATABASE=${MARIADB_DATABASE:-habbo}
- MARIADB_USER=${MARIADB_USER:-cms}
- MARIADB_PASSWORD=${MARIADB_PASSWORD:-cms}
- MARIADB_AUTO_UPGRADE=1
# MariaDB tunes itself — the Official image appends these flags to mysqld,
# no external .cnf file needed.
command: [
# Charset
"--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci",
# 50 MB JSON batches: raise the 16 MB default packet ceiling.
"--max-allowed-packet=512M",
"--net-buffer-length=1M",
# Timeouts — fixes the "Pulling schema from database..." hang
# (drizzle-kit introspection no longer starves behind big imports).
"--connect-timeout=30",
"--wait-timeout=3600",
"--interactive-timeout=3600",
"--net-read-timeout=600",
"--net-write-timeout=600",
# InnoDB: durability/performance trade-off for bulk writes.
"--innodb-flush-log-at-trx-commit=2",
"--innodb-buffer-pool-size=2G",
"--innodb-buffer-pool-instances=4",
"--innodb-log-file-size=1G",
"--innodb-log-buffer-size=64M",
"--innodb-flush-method=O_DIRECT",
"--innodb-autoextend-increment=512",
"--innodb-max-dirty-pages-pct=90",
"--bulk-insert-buffer-size=512M",
# Raise so the engine nearly never double-writes during a 50 MB load.
"--innodb-doublewrite=0",
# libaio/native_aio misbehaves in some containers; io_uring path is fine.
"--innodb-use-native-aio=0",
# Temp tables used when MariaDB scans JSON blobs cannot be indexed.
"--tmp-table-size=256M",
"--max-heap-table-size=256M",
"--read-buffer-size=4M",
"--read-rnd-buffer-size=16M",
# Save ~1-2 GB RAM; bulk loads don't need the instrumentation.
"--performance-schema=OFF",
"--skip-name-resolve",
]
volumes:
# Named volume, NOT a host bind — shared-filesystem sync trashes InnoDB
# files the same way it trashes pnpm's node_modules. Named volumes live
# inside the container filesystem, so 50 MB of JSON writes never cross a
# host-sync boundary.
- mariadb-turbo-data:/var/lib/mysql
healthcheck:
# healthcheck.sh ships in the official mariadb image.
test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized || mariadb-admin ping --silent"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
mem_limit: 4g
# Named volumes declared once; used by the MariaDB service above.
volumes:
mariadb-turbo-data:
driver: local
+118
View File
@@ -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.
@@ -0,0 +1,21 @@
# Catalog operations upgrade plan
User approved bulk editing and complete category duplication, keeping Visual Manager in its button-opened modal.
Use subagent-driven-development for the independent duplication task; root owns bulk operations and final review.
- [x] Duplicate category subtree and offers atomically for normal/BC; preview counts, destination/name, fresh IDs, preserve furniture references; fail on stale source, invalid destination, cycles and insert failure. Shared permissions, single export/RCON outcome. UI in Visual Manager.
- [x] Extend selected-offer operations to preview and atomically apply prices, currency, and destination. Visibility belongs to categories, so expose it separately with exact affected categories; no fictitious per-offer flag. Preserve unselected and unrelated fields; conflicts prevent overwriting concurrent changes.
- [x] Meaningful domain/transaction/action tests, browser fixtures for modal preview/confirm, typecheck/Biome/i18n/Knip; review limits and commit exact scope.
No production data mutations, added dependencies or schema migrations. Full category duplication shares existing items_base definitions and asset files. Repeated confirmations must be disabled while saving. Preview should be revalidated under locks before write. Existing direct save and permissions remain.
## Implementation notes
Bulk editing now works from selected normal-catalog offers and from selection across global searches. Only explicitly chosen prices/currency/destination fields change. Prices support set/add/percentage with integer rounding and range validation; 500 selected offers per transaction. Preview binds rows, changes and category names; concurrent changes reject confirmation. Category visibility remains on the existing category controls, not a fabricated offer property. BC has no offer pricing and does not expose that editor.
Duplication copies up to 500 categories and 5000 offers, preserving bundle and asset references. New root starts disabled and hidden. Includes and internal offer IDs are remapped. Explicit normal offer IDs use the existing allocator and do not require AUTO_INCREMENT; database collisions roll back the whole copy. Preview binds destination siblings as well as source data so confirmed preview replay is rejected. Dirty editor state blocks copying saved data until drafts are saved/reset.
No database migration or dependency added. Database transaction tests use mocks and do not prove live MariaDB locking behavior. Browser fixtures use real components with mocked actions, not production writes. Existing allocator is process-local; competing external imports may cause a safe rollback requiring a fresh preview. No automatic retry of uncertain copy commits.
Verification complete: 1339 unit tests passed, 5 skipped. Typecheck/Knip/i18n and changed-file Biome checked. Real-component browser fixtures passed bulk selection, preview, conflicts, pending guards, mobile bounds and table refresh without phantom drafts; duplication nested inside actual manager verified menus, picker, preview and dirty-state guards. Fixed manager portal layering and invalid menu-label nesting uncovered by those tests. Browser actions are mocked; no live data written or deployment claimed.
@@ -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.
+5
View File
@@ -21,6 +21,11 @@
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:introspect": "drizzle-kit introspect",
"db:bulk": "tsx scripts/bulk-import-json.ts",
"db:up": "docker compose --profile db up -d mariadb-turbo",
"db:down": "docker compose --profile db stop mariadb-turbo",
"db:schema:generate": "node scripts/generate-drizzle-schema.mjs",
"db:migrate": "tsx scripts/apply-migrations.ts",
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
"db:studio": "drizzle-kit studio",
+254
View File
@@ -0,0 +1,254 @@
// Bulk-load >50 MB JSON into MariaDB in resumable chunks (2000 rows/batch).
//
// Drizzle multi-row INSERTs are wrapped in ONE statement per chunk, so a
// 50 MB dump becomes a few dozen statements instead of hundreds of thousands
// of round-trips — no connect/packet timeouts, no "Pulling schema from
// database..." freeze (that hang is introspection racing a saturated server).
//
// Usage (via pnpm, as required):
// pnpm exec tsx scripts/bulk-import-json.ts \
// --file=/var/www/Gamedata/.../FurnitureData.json [--chunk-size=2000]
//
// Input shapes supported automatically:
// A) habbo furnidata_json → { roomitemtypes:{furnitype:[…]}, wallitemtypes:{…} }
// B) flat array of objects → [ {…}, {…} ]
//
// Flags:
// --file=<path> JSON file to load (required)
// --table=<name> one of: furnidata | docs | texts (default: furnidata)
// --category=<x> category value for docs/texts tables
// --source=<x> source value for furnidata table (default: habbo)
// --chunk-size=N rows per multi-row INSERT (default: 2000)
// --limit=N stop after N rows (dry-test without loading everything)
// --truncate DELETE all rows for this table's source/category first
//
// Idempotent by default: rows re-import on their unique key with
// ON DUPLICATE KEY UPDATE, so interrupted runs resume safely.
import "./load-env";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import {
GamedataDocs,
GamedataFurnidata,
GamedataTexts,
} from "@/db/schema-gamedata";
import { mysqlConnectionUrl } from "./db-url";
interface Args {
file: string;
table: "furnidata" | "docs" | "texts";
category: string;
source: string;
chunkSize: number;
limit: number;
truncate: boolean;
}
const FURNIDATA_SECTIONS = {
roomitemtypes: "s",
flooritemtypes: "s",
wallitemtypes: "i",
effecttypes: "e",
} as const;
function parseArgs(raw: string[]): Args {
const get = (name: string) => {
const hit = raw.find((a) => a.startsWith(`--${name}=`));
return hit?.slice(name.length + 3);
};
const has = (name: string) => raw.includes(`--${name}`);
const file = get("file");
if (!file) {
console.error(
"Usage: pnpm exec tsx scripts/bulk-import-json.ts --file=<path> [--table=furnidata|docs|texts] [--category=<x>] [--source=<x>] [--chunk-size=2000] [--limit=N] [--truncate]",
);
process.exit(1);
}
const table = (get("table") ?? "furnidata") as Args["table"];
return {
file: resolve(file),
table,
category: get("category") ?? "default",
source: get("source") ?? "habbo",
chunkSize: Number(get("chunk-size") ?? "2000"),
limit: Number(get("limit") ?? "0"),
truncate: has("truncate"),
};
}
// A >50 MB file parses fine with a single JSON.parse (V8 string limit is far
// higher); streaming row-by-row would slow the seed down for no memory win.
async function readJsonFile(file: string): Promise<unknown> {
const { readFile } = await import("node:fs/promises");
return JSON.parse(await readFile(file, "utf8"));
}
interface NormalizedRow {
[key: string]: unknown;
}
// Normalize any supported shape into a flat list of JSON documents.
function normalize(json: unknown, table: Args["table"]): NormalizedRow[] {
if (table !== "furnidata") {
if (Array.isArray(json)) return json as NormalizedRow[];
throw new Error(`Expected a top-level array for --table=${table}`);
}
if (Array.isArray(json)) return json as NormalizedRow[];
// habbo furnidata_json: { roomitemtypes: { furnitype: [...] }, ... }
if (json && typeof json === "object") {
const rows: NormalizedRow[] = [];
for (const [section, kind] of Object.entries(FURNIDATA_SECTIONS)) {
const block = (json as Record<string, unknown>)[section];
if (!block || typeof block !== "object") continue;
const list = (block as Record<string, unknown>).furnitype;
if (!Array.isArray(list)) continue;
for (const item of list) rows.push({ ...item, kind });
}
return rows;
}
throw new Error("Unsupported JSON shape: expected array or furnidata_json");
}
function toFurnidataRow(
row: NormalizedRow,
source: string,
): Record<string, unknown> {
return {
source,
kind: (row.kind as "s" | "i" | "e") ?? "s",
className: String(row.classname ?? row.className ?? ""),
publicName: String(row.public_name ?? row.publicName ?? ""),
spriteId: Number(row.id ?? 0),
payload: JSON.stringify(row),
};
}
function toDocsRow(
row: NormalizedRow,
category: string,
): Record<string, unknown> {
const payload = JSON.stringify(row);
return {
category,
docKey: String(row.key ?? row.docKey ?? row.id ?? ""),
payload,
payloadSha1: createHash("sha1").update(payload).digest("hex"),
};
}
function toTextsRow(
row: NormalizedRow,
category: string,
): Record<string, unknown> {
return {
category,
textKey: String(row.key ?? row.id ?? ""),
value: String(row.value ?? row.text ?? row),
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const url = process.env.DATABASE_URL;
if (!url)
throw new Error("DATABASE_URL is required (load-env.ts loaded .env)");
const conn = await mysql.createConnection(mysqlConnectionUrl(url));
const db = drizzle(conn);
// Session bulk-load flags: worst case per chunk is a lost commit, not a
// corrupt table. FOREIGN_KEY_CHECKS off keeps each 50 MB chunk off FK
// validation work; UNIQUE_CHECK is a per-statement no-op vs ON DUPLICATE.
await conn.query("SET SESSION FOREIGN_KEY_CHECKS=0");
await conn.query("SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'");
const tableRef =
args.table === "furnidata"
? GamedataFurnidata
: args.table === "docs"
? GamedataDocs
: GamedataTexts;
const keyWhere =
args.table === "furnidata"
? sql`${GamedataFurnidata.source} = ${args.source}`
: args.table === "docs"
? sql`${GamedataDocs.category} = ${args.category}`
: sql`${GamedataTexts.category} = ${args.category}`;
if (args.truncate) {
await db.execute(sql`DELETE FROM ${tableRef} WHERE ${keyWhere}`);
console.log(`[bulk] truncated rows for ${args.table}`);
}
console.log(`[bulk] reading ${args.file} …`);
const json = await readJsonFile(args.file);
const rows = normalize(json, args.table);
console.log(`[bulk] parsed ${rows.length} documents (${args.table})`);
const mapper: (r: NormalizedRow) => Record<string, unknown> =
args.table === "furnidata"
? (r) => toFurnidataRow(r, args.source)
: args.table === "docs"
? (r) => toDocsRow(r, args.category)
: (r) => toTextsRow(r, args.category);
const values = rows
.slice(0, args.limit || rows.length)
.map<Record<string, unknown>>(mapper);
const total = values.length;
const chunkSize = args.chunkSize;
const started = Date.now();
let inserted = 0;
for (let i = 0; i < total; i += chunkSize) {
const chunk = values.slice(i, Math.min(i + chunkSize, total));
// `tableRef` is a 3-way union; drizzle's insert is typed per single
// table, so narrow through a custom shape here (runtime is unaffected).
const insert = db.insert(tableRef) as unknown as {
values: (rows: typeof chunk) => {
onDuplicateKeyUpdate: (opts: {
set: Record<string, unknown>;
}) => Promise<unknown>;
};
};
await insert.values(chunk).onDuplicateKeyUpdate({
// MariaDB `VALUES(col)` = the incoming row value; re-runs overwrite.
set: Object.fromEntries(
Object.keys(chunk[0] ?? {}).map((k) => [
k,
sql.raw(`values(\`${k}\`)`),
]),
),
});
inserted += chunk.length;
const remaining = total - i - chunk.length;
if (inserted % (chunkSize * 10) < chunkSize || remaining <= 0) {
const elapsedSec = (Date.now() - started) / 1000;
const rate = Math.round(inserted / Math.max(elapsedSec, 0.001));
const etaMin = Math.round(remaining / Math.max(rate, 1) / 60);
console.log(
`[bulk] ${inserted}/${total} (${Math.round((inserted / total) * 100)}%) ${rate} rows/s, ETA ~${etaMin}m`,
);
}
}
const sec = ((Date.now() - started) / 1000).toFixed(1);
console.log(`[bulk] DONE: ${inserted}/${total} rows in ${sec}s`);
if (total === args.limit && args.limit > 0) {
console.log(
" NOTE: --limit was set; run again without it for the full dump.",
);
}
await conn.end();
}
main().catch((err) => {
console.error("[bulk] FATAL:", err instanceof Error ? err.message : err);
process.exit(1);
});
+7 -1
View File
@@ -74,7 +74,13 @@ pnpm install --frozen-lockfile
pnpm exec playwright install chromium
echo "Building $image"
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest -t "$image" .
# Throw away the previous build cache before each build so disk usage doesn't
# grow unbounded across deployments. The current release image (`epicnext-cms`)
# is still reused as a base layer via `--cache-from`; only the accumulated
# BuildKit intermediate cache is discarded.
docker builder prune -af --filter "until=1h" --keep-storage=0 2>/dev/null || true
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
check_current
pnpm db:migrate
check_current
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# ==============================================================================
# docker-preflight.sh — Verify a VPS is ready to run the Dockerized EpicNext-CMS.
#
# Lets any supplied .env drive the checks. Checks (all idempotent, none mutating):
# 1. Docker + compose available and usable.
# 2. Required runtime dirs exist (RW for UID 33 where the CMS writes).
# 3. Volume owners are UID/GID 33 (www-data) on the host.
# 4. .env exists and required host-network services are reachable.
# 5. Port 3002 free (or the host CMS that must be stopped).
#
# Usage: ./scripts/docker-preflight.sh [--fix]
# --fix attempts to auto-correct permission/owner issues (chown).
#
# Exit codes: 0 ready, 1 not ready (or fixed nothing).
# ==============================================================================
set -uo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR" || exit 1
FIX=0
[ "${1:-}" = "--fix" ] && FIX=1
ENV_FILE="${ENV_FILE:-$DIR/.env}"
fail=0
warn=0
# Ports that are expected to be reachable ON THE HOST (network_mode: host).
# These mirror the defaults in .env / the emulator stack; override via env.
CMS_PORT="${CMS_PORT:-3002}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
REDIS_PORT="${REDIS_PORT:-6379}"
RCON_PORT="${RCON_PORT:-3003}"
API_PORT="${API_PORT:-3001}"
IMAGER_PORT="${IMAGER_PORT:-8082}"
# Relative dirs the CMS writes to, plus the absolute shared gamedata root.
RW_DIRS=(
"$DIR/public/nitro-assets"
"$DIR/public/swf"
"$DIR/storage"
"/var/www/Gamedata"
)
say() { printf ' %s\n' "$*"; }
ok() { printf ' \033[32m[OK] \033[0m%s\n' "$*"; }
err() { printf ' \033[31m[FAIL]\033[0m %s\n' "$*"; fail=1; }
wrn() { printf ' \033[33m[WARN]\033[0m %s\n' "$*"; warn=1; }
doing(){ printf '\n\033[1m%s\033[0m\n' "$*"; }
doing "1. Docker engine + compose"
if command -v docker >/dev/null 2>&1; then
ok "docker binary present"
if docker info >/dev/null 2>&1; then ok "daemon reachable"; else err "daemon NOT reachable (is it running / does this user have access?)"; fi
else
err "docker binary missing"
fi
if docker compose version >/dev/null 2>&1; then
ok "docker compose plugin present"
else
err "docker compose plugin missing (install docker-compose-plugin)"
fi
doing "2. Runtime directories exist + RW for UID 33"
for d in "${RW_DIRS[@]}"; do
if [ ! -e "$d" ]; then
err "missing dir: $d"
if [ "$FIX" -eq 1 ]; then
mkdir -p "$d" && chown 33:33 "$d" && say " created + chown 33:33 $d" || err " could not create $d"
fi
continue
fi
if [ ! -d "$d" ]; then err "not a directory: $d"; continue; fi
# Test write as the target owner via a temp file drop (as root), then remove.
if [ "$(id -u)" -eq 0 ]; then
if touch "$d/.preflight-write-test" 2>/dev/null; then
rm -f "$d/.preflight-write-test"
ok "writable: $d"
else
err "not writable: $d (needs owner 33:33)"
[ "$FIX" -eq 1 ] && { chown -R 33:33 "$d" && say " chown -R 33:33 $d" || err " chown failed"; }
fi
else
if [ -w "$d" ]; then ok "writable: $d"; else err "not writable: $d"; fi
fi
done
doing "3. Volume ownership (UID/GID 33 = www-data)"
for d in "${RW_DIRS[@]}"; do
[ -e "$d" ] || continue
owner="$(stat -c '%u:%g' "$d" 2>/dev/null || true)"
if [ "$owner" = "33:33" ]; then
ok "owner 33:33: $d"
else
err "owner ${owner:-unknown} (want 33:33): $d"
[ "$FIX" -eq 1 ] && { chown 33:33 "$d" && say " chown 33:33 $d" || err " chown failed"; }
fi
done
doing "4. Configuration + required services (.env, host network)"
if [ -f "$ENV_FILE" ]; then
ok ".env present: $ENV_FILE"
### shellcheck disable=SC1090
set -a; . "$ENV_FILE"; set +a
else
err ".env missing: $ENV_FILE (copy your production env here)"
fi
check_port() { # name port
if command -v nc >/dev/null 2>&1; then
if nc -z 127.0.0.1 "$2" >/dev/null 2>&1; then ok "reachable 127.0.0.1:$2 ($1)"; else err "NOT reachable 127.0.0.1:$2 ($1)"; fi
else
if (echo >/dev/tcp/127.0.0.1/"$2") >/dev/null 2>&1; then ok "reachable 127.0.0.1:$2 ($1)"; else err "NOT reachable 127.0.0.1:$2 ($1)"; fi
fi
}
check_port "MariaDB" "$MYSQL_PORT"
check_port "Redis" "$REDIS_PORT"
check_dep() { # name
if command -v "$1" >/dev/null 2>&1; then ok "host binary: $1"; else wrn "host binary not found: $1 (may still work via container)"; fi
}
check_dep git
doing "5. Port 3002 state (host CMS clash)"
if (echo >/dev/tcp/127.0.0.1/"$CMS_PORT") >/dev/null 2>&1; then
err "port $CMS_PORT already in use on host — stop the host-side CMS (pm2 stop next) before starting the container"
else
ok "port $CMS_PORT free"
fi
printf '\n'
if [ "$fail" -eq 1 ]; then
printf '\033[31m=== NOT READY: %s issue(s) found%s ===\033[0m\n' "$fail" "$([ "$FIX" -eq 1 ] && echo ' after --fix' || echo ' (re-run with --fix to auto-correct)')"
exit 1
fi
if [ "$warn" -eq 1 ]; then printf '\033[33m=== READY (with %s warning(s)) ===\033[0m\n' "$warn"; exit 0; fi
printf '\033[32m=== READY ===\033[0m\n'
exit 0
+31 -5
View File
@@ -32,7 +32,13 @@ touch "$LOG_FILE"
log "=== Start docker-update ==="
# --- 0. Guard: uncommitted changes would break git pull / taint deploys ---
# --- 0. Preflight: verify this VPS is ready (permissions, ports, deps) ---
if ! "$DIR/scripts/docker-preflight.sh"; then
die "preflight failed — fix issues first (see '--fix' flag)" 1
fi
log "preflight OK"
# --- 0b. Guard: uncommitted changes would break git pull / taint deploys ---
if ! { git diff --quiet --exit-code && git diff --cached --quiet --exit-code; }; then
die "working tree has uncommitted changes; commit or stash first" 1
fi
@@ -55,15 +61,35 @@ else
fi
log "db:migrate OK"
# --- 3. Rebuild the image ---
# --- 3. Node major gate (patches auto, major upgrades need review) ---
# `node:alpine` floats within, then across, Node majors. Patches/minors are
# safe to apply silently; a NEW major (e.g. 26 -> 27) is a breaking risk for
# native addons / Next compatibility, so require an explicit review before it
# goes live. Compare the major of the deployed runtime image vs the floating
# tag; abort (not deploy) when they differ.
deployed_major="$(docker inspect --format '{{.Config.Image}}' epicnext-cms 2>/dev/null || true)"
# Resolve the currently-deployed Node major from its image.
if [ -n "$deployed_major" ] && docker image inspect "$deployed_major" >/dev/null 2>&1; then
deployed_major="$(docker run --rm --entrypoint sh "$deployed_major" -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
fi
float_major="$(docker run --rm --entrypoint sh node:alpine -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
if [ -n "$deployed_major" ] && [ -n "$float_major" ] && [ "$deployed_major" != "$float_major" ]; then
die "Node major change detected (deployed v$deployed_major, floating tag v$float_major). Major upgrades require review; update engines/Dockerfile deliberately first." 1
fi
log "Node major gate OK (major=${float_major:-?})"
# --- 4. Rebuild the image ---
# Reset the BuildKit cache first so the build doesn't accumulate unbounded
# layers on disk across daily rebuilds.
docker builder prune -af --filter "until=1h" --keep-storage=0 2>>"$LOG_FILE" || true
docker compose build >>"$LOG_FILE" 2>&1 || die "docker compose build failed" 2
log "docker compose build OK"
# --- 4. Recreate the container ---
# --- 5. Recreate the container ---
docker compose up -d >>"$LOG_FILE" 2>&1 || die "docker compose up failed" 2
log "docker compose up OK"
# --- 5. Wait for health (up to ~4 min) ---
# --- 6. Wait for health (up to ~4 min) ---
healthy=0
for i in $(seq 1 16); do
status="$(docker inspect --format='{{.State.Health.Status}}' epicnext-cms 2>/dev/null || true)"
@@ -82,7 +108,7 @@ else
exit 3
fi
# --- 6. Make sure the stale host-side PM2 CMS stays stopped ---
# --- 7. Make sure the stale host-side PM2 CMS stays stopped ---
if command -v pm2 >/dev/null 2>&1 && pm2 jlist >/dev/null 2>&1; then
if pm2 list 2>/dev/null | grep -q "${PM2_APP}"; then
pm2 stop "$PM2_APP" >/dev/null 2>&1 && log "pm2 '${PM2_APP}' kept stopped (avoids port 3002 clash)"
+60 -118
View File
@@ -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<string, unknown>) {
}: { id: number; expected?: Record<string, unknown> } & 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<typeof CatalogPagesBc.$inferInsert>)
.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<typeof CatalogItemsBc.$inferInsert>)
.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<void> {
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: {} };
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import type { BulkOfferInput } from "@/features/catalog/domain/bulk-offers";
import {
applyBulkOffersCommand,
listBulkOfferDestinationsCommand,
previewBulkOffersCommand,
} from "@/features/catalog/server/bulk-offers";
import { catalogFailure } from "@/features/catalog/server/errors";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
export async function previewBulkOffers(input: BulkOfferInput) {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return { ok: true as const, data: await previewBulkOffersCommand(input) };
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
export async function applyBulkOffers(
input: BulkOfferInput,
fingerprint: string,
) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
try {
return await withCatalogExport(async () => {
const data = await applyBulkOffersCommand(input, fingerprint);
if (data.changedCount > 0) {
await logAudit({
userId: staff.id,
action: "catalog_offers_bulk_update",
target: "catalog",
after: {
ids: input.ids,
changes: input.changes,
changedCount: data.changedCount,
},
}).catch((error) =>
logger.error("Catalog bulk update committed; audit write failed", {
module: "catalog",
error,
}),
);
await sendCatalogUpdate();
revalidatePath("/admin/catalog", "layout");
}
return { ok: true as const, data };
});
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
export async function getBulkOfferDestinations() {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return {
ok: true as const,
data: await listBulkOfferDestinationsCommand(),
};
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
+66
View File
@@ -0,0 +1,66 @@
"use server";
import { revalidatePath } from "next/cache";
import type {
DuplicateInput,
DuplicatePreview,
} from "@/features/catalog/domain/duplicate";
import {
duplicateCategoryCommand,
duplicateDestinationsCommand,
previewDuplicateCommand,
} from "@/features/catalog/server/duplicate-commands";
import { catalogFailure } from "@/features/catalog/server/errors";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { logStaffActivity } from "@/lib/services/staff-activity";
export async function previewCatalogDuplicate(input: DuplicateInput) {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return { ok: true as const, data: await previewDuplicateCommand(input) };
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
export async function applyCatalogDuplicate(input: DuplicatePreview) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return withCatalogExport(async () => {
let data: Awaited<ReturnType<typeof duplicateCategoryCommand>>;
try {
data = await duplicateCategoryCommand(input);
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
const hotel = await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_create",
description: `Duplicated ${input.kind} category #${input.sourceId} to #${data.id}: ${data.pages} categories, ${data.offers} offers`,
targetType: "catalog_page",
targetId: data.id,
}).catch((error) => {
logger.error("Category duplicated but audit logging failed", {
module: "catalog",
error,
});
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { ...data, hotel } };
});
}
export async function catalogDuplicateDestinations(
kind: DuplicateInput["kind"],
) {
await requirePermission(PERMS.CATALOG_VIEW);
try {
return {
ok: true as const,
data: await duplicateDestinationsCommand(kind),
};
} catch (error) {
return { ok: false as const, error: catalogFailure(error).message };
}
}
+34 -112
View File
@@ -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<string, unknown>,
allowed: readonly string[],
): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<number> {
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<typeof CatalogItems.$inferInsert>)
.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<typeof ItemsBase.$inferInsert>)
.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",
+51 -48
View File
@@ -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<string, unknown>) {
export async function updateCatalogPage({
id,
expected,
...fields
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
}: { id: number; expected?: Record<string, unknown> } & Record<
string,
unknown
>): Promise<ActionResult> {
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<typeof CatalogPages.$inferInsert>)
.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<ActionResult<{ id: number }>> {
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: {} };
});
+2 -1
View File
@@ -192,12 +192,13 @@ export function BannersManager({ banners }: Props) {
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Switch
id="banner-active"
checked={form.isActive === 1}
onCheckedChange={(v) =>
setForm({ ...form, isActive: v ? 1 : 0 })
}
/>
<Label>Active</Label>
<Label htmlFor="banner-active">Active</Label>
</div>
<div className="space-y-2 flex items-center gap-2">
<Label>Order</Label>
@@ -34,6 +34,7 @@ import {
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
@@ -80,6 +81,12 @@ import {
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
import {
discoverOffers,
OFFER_FILTERS,
type OfferFilter,
} from "@/features/catalog/domain/offer-discovery";
import { useServerAction } from "@/hooks/use-server-action";
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
import { cn } from "@/lib/utils";
@@ -144,6 +151,9 @@ function SortableRow({
}
interface CatalogItemsTableProps {
initialSearch?: string;
onDirtyChange?: (dirty: boolean) => void;
onRefresh?: () => void;
items: CatalogItemData[];
pageId: number;
pageLayout: string;
@@ -154,6 +164,9 @@ interface CatalogItemsTableProps {
}
export function CatalogItemsTable({
initialSearch = "",
onDirtyChange,
onRefresh,
items,
pageId,
pageLayout,
@@ -163,6 +176,11 @@ export function CatalogItemsTable({
allPages,
}: CatalogItemsTableProps) {
const router = useRouter();
const bulk = useTranslations("pages.admin.catalog.bulk");
const discovery = useTranslations("pages.admin.catalog.discovery");
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
CatalogItemData[] | null
>(null);
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [addOpen, setAddOpen] = useState(false);
@@ -176,20 +194,14 @@ export function CatalogItemsTable({
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
// ── Search / Filter ─────────────────────────────────────────────
const [searchQuery, setSearchQuery] = useState("");
const filteredItems = useMemo(() => {
if (!searchQuery.trim()) return items;
const q = searchQuery.toLowerCase();
return items.filter(
(item) =>
item.catalogName.toLowerCase().includes(q) ||
item.baseItemName.toLowerCase().includes(q) ||
item.baseName.toLowerCase().includes(q) ||
item.itemIds.includes(q) ||
String(item.id).includes(q),
);
}, [items, searchQuery]);
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [offerFilter, setOfferFilter] = useState<OfferFilter>("all");
const { items: filteredItems, counts: filterCounts } = useMemo(
() => discoverOffers(items, searchQuery, offerFilter),
[items, searchQuery, offerFilter],
);
const hasDiscoveryFilter =
Boolean(searchQuery.trim()) || offerFilter !== "all";
// ── Move items dialog ───────────────────────────────────────────
const [moveOpen, setMoveOpen] = useState(false);
@@ -232,13 +244,36 @@ export function CatalogItemsTable({
});
}, []);
const allVisibleSelected =
filteredItems.length > 0 &&
filteredItems.every((item) => selected.has(item.id));
const someVisibleSelected = filteredItems.some((item) =>
selected.has(item.id),
);
const toggleAll = useCallback(() => {
setSelected((prev) =>
prev.size === filteredItems.length
? new Set()
: new Set(filteredItems.map((i) => i.id)),
);
setSelected((previous) => {
const next = new Set(previous);
const allSelected = filteredItems.every((item) => previous.has(item.id));
for (const item of filteredItems) {
if (allSelected) next.delete(item.id);
else next.add(item.id);
}
return next;
});
}, [filteredItems]);
function updateSearch(query: string) {
setSearchQuery(query);
setSelected(new Set());
}
function updateOfferFilter(value: string) {
setOfferFilter(value as OfferFilter);
setSelected(new Set());
}
function resetDiscovery() {
setSearchQuery("");
setOfferFilter("all");
setSelected(new Set());
}
// ── Inline edits ─────────────────────────────────────────────────
const [inlineEdits, setInlineEdits] = useState<Record<number, InlineEdit>>(
@@ -290,7 +325,29 @@ export function CatalogItemsTable({
return itemOrder.some((id, idx) => id !== originalOrder[idx]);
}, [items, itemOrder]);
const hasUnsavedChanges = modifiedItems.length > 0 || hasOrderChanges;
const hasUnsavedChanges =
bulkRefreshFrom === null && (modifiedItems.length > 0 || hasOrderChanges);
useEffect(() => {
if (bulkRefreshFrom === null || items === bulkRefreshFrom) return;
setInlineEdits(
Object.fromEntries(
items.map((item) => [
item.id,
{
costCredits: item.costCredits,
costPoints: item.costPoints,
pointsType: item.pointsType,
amount: item.amount,
},
]),
),
);
setItemOrder(items.map((item) => item.id));
setBulkRefreshFrom(null);
}, [items, bulkRefreshFrom]);
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onDirtyChange]);
// Warn before navigating away with unsaved changes
useEffect(() => {
@@ -383,7 +440,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 +484,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 +518,8 @@ export function CatalogItemsTable({
errorMessage: "Failed to delete items.",
onSuccess: () => {
setSelected(new Set());
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
});
}
@@ -490,7 +553,8 @@ export function CatalogItemsTable({
setSelected(new Set());
setMoveOpen(false);
setMoveTargetPageId(null);
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
},
);
@@ -590,7 +654,8 @@ export function CatalogItemsTable({
onSuccess: () => {
setAddOpen(false);
setNewItem({ ...DEFAULT_NEW_ITEM });
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
},
);
@@ -641,7 +706,8 @@ export function CatalogItemsTable({
onSuccess: () => {
setMoveOneId(null);
setMoveOneTarget(null);
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
},
);
@@ -711,7 +777,8 @@ export function CatalogItemsTable({
onSuccess: () => {
setEditingItem(null);
setEditingBase(null);
router.refresh();
if (onRefresh) onRefresh();
else router.refresh();
},
},
);
@@ -887,21 +954,34 @@ export function CatalogItemsTable({
return (
<>
{confirmDialog}
<Card>
{bulkRefreshFrom !== null && (
<p
role="status"
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<Loader2 className="size-4 animate-spin" />
{bulk("refreshing")}
</p>
)}
<Card
inert={bulkRefreshFrom !== null}
aria-busy={bulkRefreshFrom !== null}
>
<CardHeader className="space-y-3">
<div className="flex flex-row items-center justify-between gap-4">
<div className="min-w-0">
<CardTitle>
Items ({items.length})
{searchQuery && filteredItems.length !== items.length && (
<span className="text-muted-foreground font-normal text-sm ml-2">
({filteredItems.length} shown)
</span>
)}
{hasDiscoveryFilter &&
filteredItems.length !== items.length && (
<span className="text-muted-foreground font-normal text-sm ml-2">
{discovery("shown", { count: filteredItems.length })}
</span>
)}
</CardTitle>
{selected.size > 0 && (
<p className="text-sm text-muted-foreground mt-1">
{selected.size} selected
{discovery("selected", { count: selected.size })}
</p>
)}
</div>
@@ -941,6 +1021,21 @@ export function CatalogItemsTable({
<>
{selected.size > 0 && (
<>
<BulkOfferEditor
ids={[...selected]}
allPages={allPages}
disabled={
hasUnsavedChanges ||
isPending ||
savingItemId !== null
}
onApplied={() => {
setBulkRefreshFrom(items);
setSelected(new Set());
if (onRefresh) onRefresh();
else router.refresh();
}}
/>
<Button
variant="outline"
size="sm"
@@ -1080,14 +1175,51 @@ export function CatalogItemsTable({
</div>
)}
{/* Search bar */}
{/* Offer discovery */}
{items.length > 0 && (
<SearchInput
placeholder="Filter by name, classname, item ID..."
value={searchQuery}
onChange={setSearchQuery}
onClear={() => setSearchQuery("")}
/>
<div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<SearchInput
placeholder={discovery("searchPlaceholder")}
value={searchQuery}
onChange={updateSearch}
onClear={() => updateSearch("")}
/>
</div>
<Select value={offerFilter} onValueChange={updateOfferFilter}>
<SelectTrigger
aria-label={discovery("filterLabel")}
className="w-full sm:w-52"
>
<SelectValue>
{discovery(`filters.${offerFilter}`)} (
{filterCounts[offerFilter]})
</SelectValue>
</SelectTrigger>
<SelectContent>
{OFFER_FILTERS.map((filter) => (
<SelectItem key={filter} value={filter}>
{discovery(`filters.${filter}`)} ({filterCounts[filter]}
)
</SelectItem>
))}
</SelectContent>
</Select>
{hasDiscoveryFilter && (
<Button variant="ghost" size="sm" onClick={resetDiscovery}>
{discovery("reset")}
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
{discovery("results", {
count: filteredItems.length,
total: items.length,
})}
{canEdit && ` ${discovery("selectionHint")}`}
</p>
</div>
)}
</CardHeader>
<CardContent>
@@ -1109,7 +1241,7 @@ export function CatalogItemsTable({
</div>
) : filteredItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No items match &quot;{searchQuery}&quot;.
{discovery("noResults")}
</p>
) : viewMode === "grid" ? (
<ItemsShopPreview
@@ -1135,12 +1267,12 @@ export function CatalogItemsTable({
{canEdit && (
<TableHead className="w-[40px] px-2">
<Checkbox
checked={
filteredItems.length > 0 &&
selected.size === filteredItems.length
checked={allVisibleSelected}
indeterminate={
someVisibleSelected && !allVisibleSelected
}
onCheckedChange={toggleAll}
aria-label="Select all"
aria-label={discovery("selectVisible")}
/>
</TableHead>
)}
@@ -1168,7 +1300,7 @@ export function CatalogItemsTable({
const modified = isItemModified(item);
const isSaving = savingItemId === item.id;
const furniInfo = getFurniInfo(item);
const sortableDisabled = !canEdit || !!searchQuery;
const sortableDisabled = !canEdit || hasDiscoveryFilter;
return (
<SortableRow
key={item.id}
@@ -1,7 +1,7 @@
"use client";
import { Check, ChevronsUpDown, Star } from "lucide-react";
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -37,11 +37,15 @@ export function ToggleField({
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
const id = useId();
const isChecked = checked ?? (value === 1 || value === "1");
return (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">{label}</Label>
<Label htmlFor={id} className="text-xs">
{label}
</Label>
<Switch
id={id}
checked={isChecked}
onCheckedChange={onChange}
disabled={disabled}
@@ -151,6 +155,8 @@ export function MovePageCombobox({
pages,
currentPageId,
suggestedPageIds,
disabled = false,
labels,
}: {
value: number | null;
onChange: (id: number | null) => void;
@@ -158,6 +164,14 @@ export function MovePageCombobox({
currentPageId: number;
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
suggestedPageIds?: number[];
disabled?: boolean;
labels?: {
placeholder: string;
search: string;
empty: string;
suggested: string;
all: string;
};
}) {
const [open, setOpen] = useState(false);
@@ -194,6 +208,7 @@ export function MovePageCombobox({
<Button
variant="outline"
role="combobox"
disabled={disabled}
aria-expanded={open}
className="w-full justify-between font-normal"
>
@@ -205,7 +220,9 @@ export function MovePageCombobox({
{selectedPage.caption}
</span>
) : (
<span className="text-muted-foreground">Select target page...</span>
<span className="text-muted-foreground">
{labels?.placeholder ?? "Select target page..."}
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -215,13 +232,13 @@ export function MovePageCombobox({
align="start"
>
<Command>
<CommandInput placeholder="Search pages..." />
<CommandInput placeholder={labels?.search ?? "Search pages..."} />
<CommandList>
<CommandEmpty>No page found.</CommandEmpty>
<CommandEmpty>{labels?.empty ?? "No page found."}</CommandEmpty>
{/* Suggested pages */}
{suggested.length > 0 && (
<CommandGroup heading="Suggested">
<CommandGroup heading={labels?.suggested ?? "Suggested"}>
{suggested.map((page) => (
<CommandItem
key={`s-${page.id}`}
@@ -249,7 +266,9 @@ export function MovePageCombobox({
{/* All pages */}
<CommandGroup
heading={suggested.length > 0 ? "All pages" : undefined}
heading={
suggested.length > 0 ? (labels?.all ?? "All pages") : undefined
}
>
{remaining.map((page) => (
<CommandItem
@@ -136,10 +136,18 @@ export function CatalogPageForm({
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
run(() => updateCatalogPage({ id: catalogPage.id, ...form }), {
successMessage: "Page updated and catalog synced.",
errorMessage: "Failed to update.",
});
run(
() =>
updateCatalogPage({
id: catalogPage.id,
...form,
expected: catalogPage as unknown as Record<string, unknown>,
}),
{
successMessage: "Page saved.",
errorMessage: "Failed to update.",
},
);
}
async function handleDelete() {
@@ -11,3 +11,11 @@ export const CatalogManagerDialog = dynamic(
),
{ ssr: false },
);
export const CatalogManagerWorkspace = dynamic(
() =>
import("@/components/admin/catalog-manager/catalog-manager-dialog").then(
(m) => ({ default: m.CatalogManagerWorkspace }),
),
{ ssr: false },
);
+12 -5
View File
@@ -2,7 +2,9 @@
import { FolderTree, HardHat, 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,9 +17,11 @@ 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") {
params.delete("view");
@@ -28,18 +32,21 @@ export function CatalogViewToggle({
}
function setCatalog(type: string) {
if (type === catalogType || !requestCatalogNavigation()) return;
const params = new URLSearchParams(searchParams.toString());
if (type === "normal") {
params.delete("catalog");
} else {
params.set("catalog", type);
}
params.delete("view"); // reset to tree when switching
params.delete("view");
params.delete("root");
params.delete("category");
router.push(`?${params.toString()}`);
}
return (
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{/* Catalog type selector */}
<div className="flex items-center rounded-md border bg-card">
<Button
@@ -52,7 +59,7 @@ export function CatalogViewToggle({
onClick={() => setCatalog("normal")}
>
<Package className="mr-1.5 h-4 w-4" />
Catalog
{t("normal")}
</Button>
<Button
variant="ghost"
@@ -80,7 +87,7 @@ export function CatalogViewToggle({
onClick={() => setView("tree")}
>
<FolderTree className="mr-1.5 h-4 w-4" />
Tree
{t("tree")}
</Button>
<Button
variant="ghost"
@@ -92,7 +99,7 @@ export function CatalogViewToggle({
onClick={() => setView("table")}
>
<List className="mr-1.5 h-4 w-4" />
Table
{t("table")}
</Button>
</div>
</div>
+27 -9
View File
@@ -15,8 +15,10 @@ 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 { calcPagination, parseListParams } from "@/lib/admin-helpers";
import {
CatalogItems,
@@ -56,8 +58,9 @@ 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 === "table" ? "table" : "tree";
const catalogType = sp.get("catalog") === "bc" ? "bc" : "normal";
type TreeNodeShape = {
id: number;
@@ -94,7 +97,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
@@ -258,13 +261,17 @@ export default async function CatalogPagesPage({
return (
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
<div className="mb-4 space-y-3">
<CatalogSyncStatus canEdit={canEdit} />
<CatalogSearch catalogType={catalogType} canEdit={canEdit} />
</div>
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
{totalBcPages} BC pages ({enabledBcPages} enabled) &middot;{" "}
{totalBcItems} items
</p>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
{canEdit && (
<CatalogManagerDialog
@@ -290,7 +297,12 @@ export default async function CatalogPagesPage({
initialParentId={sp.get("newPageParent")}
/>
)}
<CatalogFixAllButton />
<Link
className="btn btn-outline"
href="/admin/catalog/maintenance"
>
{t("workspace.diagnostics")}
</Link>
</div>
</div>
{viewMode === "tree" ? (
@@ -323,7 +335,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
@@ -482,13 +494,17 @@ export default async function CatalogPagesPage({
return (
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
<div className="mb-4 space-y-3">
<CatalogSyncStatus canEdit={canEdit} />
<CatalogSearch catalogType={catalogType} canEdit={canEdit} />
</div>
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
{totalPages} pages ({enabledPages} enabled) &middot; {totalItems}{" "}
items
</p>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
{canEdit && (
<CatalogManagerDialog
@@ -511,7 +527,9 @@ export default async function CatalogPagesPage({
{canEdit && (
<CatalogActions initialParentId={sp.get("newPageParent")} />
)}
<CatalogFixAllButton />
<Link className="btn btn-outline" href="/admin/catalog/maintenance">
{t("workspace.diagnostics")}
</Link>
</div>
</div>
@@ -347,12 +347,15 @@ export function RankEditClient({
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm">Hidden Rank</Label>
<Label htmlFor="rank-hidden_rank" className="text-sm">
Hidden Rank
</Label>
<p className="text-xs text-muted-foreground">
Hide from public
</p>
</div>
<Switch
id="rank-hidden_rank"
checked={general.hidden_rank === "1"}
onCheckedChange={(v) =>
setGeneral({ ...general, hidden_rank: v ? "1" : "0" })
@@ -361,12 +364,15 @@ export function RankEditClient({
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm">Log Commands</Label>
<Label htmlFor="rank-log_commands" className="text-sm">
Log Commands
</Label>
<p className="text-xs text-muted-foreground">
Record to audit log
</p>
</div>
<Switch
id="rank-log_commands"
checked={general.log_commands === "1"}
onCheckedChange={(v) =>
setGeneral({ ...general, log_commands: v ? "1" : "0" })
+6 -2
View File
@@ -146,19 +146,23 @@ export function PollForm({ defaultValues }: PollFormProps) {
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Switch
id="poll-show-results"
checked={form.watch("showResults") === 1}
onCheckedChange={(v) => form.setValue("showResults", v ? 1 : 0)}
/>
<Label>{t("formShowResults")}</Label>
<Label htmlFor="poll-show-results">{t("formShowResults")}</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="poll-multiple-choice"
checked={form.watch("multipleChoice") === 1}
onCheckedChange={(v) =>
form.setValue("multipleChoice", v ? 1 : 0)
}
/>
<Label>{t("formAllowMultiple")}</Label>
<Label htmlFor="poll-multiple-choice">
{t("formAllowMultiple")}
</Label>
</div>
</div>
@@ -0,0 +1,49 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs"));
const search = vi.hoisted(() => vi.fn());
const guard = vi.hoisted(() => vi.fn());
vi.mock("@/features/catalog/server/search", () => ({ searchCatalog: search }));
vi.mock("@/features/catalog/server/errors", () => ({
catalogFailure: () => ({ message: "Invalid search", status: 400 }),
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (
options: unknown,
handler: (request: NextRequest) => Promise<Response>,
) => {
guard(options);
return handler;
},
}));
import { PERMS } from "@/lib/permissions";
import { GET } from "./route";
beforeEach(() => {
search.mockReset();
});
describe("catalog search route", () => {
it("registers the catalog read permission", () => {
expect(guard).toHaveBeenCalledWith({ permission: PERMS.CATALOG_VIEW });
});
it("forwards normal or BC without widening scope and returns results", async () => {
search.mockResolvedValue([]);
const response = await GET(
new NextRequest(
"https://cms.test/api/admin/catalog/search?catalog=bc&q=chair",
),
);
expect(search).toHaveBeenCalledWith("bc", "chair");
expect(await response.json()).toEqual({ ok: true, results: [] });
});
it("returns a validation error without a success payload", async () => {
search.mockRejectedValue(Error("Invalid search"));
const response = await GET(
new NextRequest("https://cms.test/api/admin/catalog/search?q=a"),
);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: "Invalid search" });
});
});
+22
View File
@@ -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);
}
},
);
+18
View File
@@ -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() }),
);
+27 -87
View File
@@ -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<TreeNode[]> {
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<TreeNode[]> {
}
const pageMap = new Map(allPages.map((p) => [p.id, p]));
const depthCache = new Map<number, number>();
function computeDepth(pageId: number): number {
function computeDepth(pageId: number, visiting = new Set<number>()): 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<TreeNode[]> {
})
.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<TreeNode[]> {
})
.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({});
},
);
@@ -4,7 +4,11 @@ import { ChevronRight, Home } from "lucide-react";
import { Fragment, useEffect } from "react";
import { useCatalogManager } from "./use-catalog-store";
export function BreadcrumbBar() {
export function BreadcrumbBar({
onSelectPage,
}: {
onSelectPage: (id: number) => void;
}) {
const { state, dispatch, catalogType } = useCatalogManager();
const { selectedPageId, ancestors } = state;
const catQs = catalogType === "bc" ? "&catalog=bc" : "";
@@ -40,7 +44,7 @@ export function BreadcrumbBar() {
<ChevronRight className="h-3 w-3 shrink-0" />
<button
type="button"
onClick={() => dispatch({ type: "SELECT_PAGE", id: a.id })}
onClick={() => onSelectPage(a.id)}
className="shrink-0 hover:text-foreground transition-colors truncate max-w-40"
>
{a.caption}
@@ -0,0 +1,47 @@
.options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
gap: 12px;
}
.option {
min-width: 0;
padding: 14px;
border: 1px solid var(--admin-border);
border-radius: 10px;
background: var(--admin-surface);
color: var(--admin-text);
}
.optionHeader,
.control {
display: flex;
align-items: center;
gap: 10px;
}
.optionHeader {
justify-content: space-between;
}
.label {
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.control {
flex-shrink: 0;
min-height: 32px;
}
.state {
font-size: 12px;
color: var(--admin-text-muted);
}
.description {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--admin-text-muted);
}
.option[data-disabled] .label {
cursor: not-allowed;
}
.option[data-disabled] .control {
opacity: 0.6;
}
@@ -1,13 +1,19 @@
"use client";
import { LayoutGrid, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { LayoutGrid, Search, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from "@/actions/catalog-bc";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
import type { CatalogSearchResult } from "@/features/catalog/domain/search";
import type { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
import { InlineEditor, type PageUpdateData } from "./inline-editor";
@@ -21,12 +27,49 @@ import {
useCatalogManager,
} from "./use-catalog-store";
const EMPTY_ROOTS: RootTab[] = [];
interface CatalogManagerProps {
initialRoots?: RootTab[];
catalogType?: CatalogManagerMode;
canEdit?: boolean;
totals?: CatalogStatsTotals | null;
}
export function CatalogManagerWorkspace({
initialRoots = EMPTY_ROOTS,
catalogType = "normal",
canEdit = false,
totals = null,
}: CatalogManagerProps) {
const t = useTranslations("pages.admin.catalog.workspace");
return (
<section
aria-label={t("manager")}
className="flex h-[calc(100dvh-12rem)] min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]"
>
<CatalogManagerProvider
key={catalogType}
catalogType={catalogType}
canEdit={canEdit}
>
<CatalogManagerInner
open
embedded
initialRoots={initialRoots}
totals={totals}
/>
</CatalogManagerProvider>
</section>
);
}
/**
* Visual Catalog Manager — matches habbo-next UX (full-viewport editor).
* Portal overlay (not Base UI Dialog) so the tree + editor mount reliably.
*/
export function CatalogManagerDialog({
initialRoots = [],
initialRoots = EMPTY_ROOTS,
catalogType = "normal",
canEdit = false,
totals = null,
@@ -44,17 +87,19 @@ export function CatalogManagerDialog({
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
const previousFocus = document.activeElement as HTMLElement | null;
const focusFrame = requestAnimationFrame(() =>
document
.querySelector<HTMLElement>(
'[aria-labelledby="visual-catalog-manager-title"] button[aria-label="Close"]',
)
?.focus(),
);
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => {
cancelAnimationFrame(focusFrame);
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
previousFocus?.focus();
};
}, [open]);
@@ -73,13 +118,13 @@ export function CatalogManagerDialog({
</Button>
{mounted && open
? createPortal(
<div className="fixed inset-0 z-[200]">
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-foreground/70" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="visual-catalog-manager-title"
className="fixed top-[2.5vh] left-[1vw] z-[201] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
className="fixed top-[2.5vh] left-[1vw] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<CatalogManagerProvider
@@ -103,24 +148,94 @@ export function CatalogManagerDialog({
}
function CatalogManagerInner({
embedded = false,
open,
onOpenChange,
initialRoots,
totals,
}: {
embedded?: boolean;
open: boolean;
onOpenChange: (v: boolean) => void;
onOpenChange?: (v: boolean) => void;
initialRoots: RootTab[];
totals?: CatalogStatsTotals | null;
}) {
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const rootRequests = useLatestRequest();
const searchNavigation = useLatestRequest();
const [searchOpen, setSearchOpen] = useState(false);
const [searchTarget, setSearchTarget] = useState<{
offerId: number | null;
key: number;
}>({ offerId: null, key: 0 });
const tActions = useTranslations("pages.admin.actions");
const t = useTranslations("pages.admin.catalog.workspace");
const mayLeave = useCallback(
() => !state.editorDirty || window.confirm(tActions("leaveUnsaved")),
[state.editorDirty, tActions],
);
const mayLeaveRef = useRef(mayLeave);
mayLeaveRef.current = mayLeave;
const restoreSelection = useCallback(
(selection: CatalogSelection) => {
dispatch({ type: "RESTORE_SELECTION", ...selection });
},
[dispatch],
);
const saveSelectionUrl = useCatalogNavigation({
enabled: embedded,
restore: restoreSelection,
mayLeave,
});
const close = useCallback(() => {
if (mayLeave()) onOpenChange?.(false);
}, [mayLeave, onOpenChange]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (embedded || event.key !== "Escape" || event.defaultPrevented) return;
const target = event.target as HTMLElement;
const focusedDialog = target.closest?.("[role=dialog]");
if (
focusedDialog &&
focusedDialog.getAttribute("aria-labelledby") !==
"visual-catalog-manager-title"
)
return;
if (target.closest?.("[role=listbox], [role=menu], [role=combobox]"))
return;
event.preventDefault();
if (searchOpen) {
searchNavigation.cancel();
setSearchOpen(false);
return;
}
close();
};
const onBeforeUnload = (event: BeforeUnloadEvent) => {
if (state.editorDirty) {
event.preventDefault();
event.returnValue = "";
}
};
window.addEventListener("keydown", onKey);
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, [close, state.editorDirty, embedded, searchOpen, searchNavigation]);
const loadRootPages = useCallback(async () => {
const request = rootRequests.start();
dispatch({ type: "SET_LOADING_ROOT", loading: true });
try {
const res = await fetch(`/api/admin/catalog/tree?parentId=-1${catQs}`);
const res = await fetch(`/api/admin/catalog/tree?parentId=-1${catQs}`, {
signal: request.signal,
});
const data = await res.json().catch(() => ({}));
if (!request.isCurrent()) return;
if (!res.ok) throw new Error(data.error || "Failed to load roots");
const pages: RootTab[] = (data.pages || []).map(
(p: Record<string, unknown>) => ({
@@ -132,51 +247,34 @@ function CatalogManagerInner({
}),
);
dispatch({ type: "SET_ROOT_PAGES", pages });
const activeStillValid =
state.activeTabId !== null &&
pages.some((p) => p.id === state.activeTabId);
if (pages.length > 0 && !activeStillValid) {
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
}
} catch {
if (!request.isCurrent()) return;
toast.error("Failed to load catalog root pages");
dispatch({ type: "SET_LOADING_ROOT", loading: false });
}
}, [dispatch, state.activeTabId, catQs]);
}, [dispatch, catQs, rootRequests]);
// Seed from SSR immediately, then refresh from API.
// Seed once on opening, then refresh independently of category selection.
useEffect(() => {
if (!open) return;
if (initialRoots.length > 0 && state.rootPages.length === 0) {
const pages = initialRoots.map((p) => ({
...p,
id: Number(p.id),
enabled: String(p.enabled),
}));
dispatch({ type: "SET_ROOT_PAGES", pages });
if (state.activeTabId === null && pages[0]) {
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
}
}
if (initialRoots.length)
dispatch({ type: "SET_ROOT_PAGES", pages: initialRoots });
void loadRootPages();
}, [
open,
initialRoots,
state.rootPages.length,
state.activeTabId,
dispatch,
loadRootPages,
]);
return () => rootRequests.cancel();
}, [open, initialRoots, dispatch, loadRootPages, rootRequests]);
useEffect(() => {
if (!open) dispatch({ type: "ON_DIALOG_CLOSE" });
}, [open, dispatch]);
const handleTabClick = (id: number) => {
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
searchNavigation.cancel();
if (id === state.activeTabId) return;
if (!mayLeave()) {
return;
}
setSearchTarget((previous) => ({ ...previous, offerId: null }));
saveSelectionUrl({ rootId: id, pageId: null });
dispatch({ type: "SET_ACTIVE_TAB", id });
};
@@ -204,25 +302,28 @@ function CatalogManagerInner({
};
const handleSelectPage = (id: number) => {
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
searchNavigation.cancel();
if (id === state.selectedPageId) return;
if (!mayLeave()) {
return;
}
setSearchTarget((previous) => ({ ...previous, offerId: null }));
saveSelectionUrl({ rootId: state.activeTabId, pageId: id > 0 ? id : null });
dispatch({ type: "SELECT_PAGE", id: id > 0 ? id : null });
};
const handleEditTab = (id: number) => {
if (state.activeTabId !== id) {
dispatch({ type: "SET_ACTIVE_TAB", id });
}
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
return;
}
searchNavigation.cancel();
if (id === state.selectedPageId && id === state.activeTabId) return;
if (!mayLeave()) return;
setSearchTarget((previous) => ({ ...previous, offerId: null }));
saveSelectionUrl({ rootId: id, pageId: id });
if (state.activeTabId !== id) dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "SELECT_PAGE", id });
};
const handleEditorSaved = (updated: PageUpdateData) => {
dispatch({ type: "UPDATE_NODE", update: { ...updated } });
dispatch({ type: "SET_DIRTY", dirty: false });
const rootIdx = state.rootPages.findIndex((p) => p.id === updated.id);
if (rootIdx !== -1) {
@@ -236,6 +337,49 @@ function CatalogManagerInner({
}
};
async function openSearchResult(result: CatalogSearchResult) {
const request = searchNavigation.start();
try {
const response = await fetch(
`/api/admin/catalog/tree?ancestors=${result.pageId}${catQs}`,
{ signal: request.signal },
);
if (!response.ok) throw Error("Navigation failed");
const data = await response.json();
if (!request.isCurrent() || !mayLeaveRef.current()) return;
const rootId = Number(data.ancestors?.[0]?.id ?? result.pageId);
dispatch({ type: "RESTORE_SELECTION", rootId, pageId: result.pageId });
saveSelectionUrl({ rootId, pageId: result.pageId });
setSearchTarget((previous) => ({
offerId: result.kind === "offer" ? result.id : null,
key: previous.key + 1,
}));
setSearchOpen(false);
} catch {
if (request.isCurrent()) toast.error(t("searchFailed"));
}
}
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (
!(event.ctrlKey || event.metaKey) ||
!event.shiftKey ||
event.key.toLowerCase() !== "f"
)
return;
const dialog = (event.target as HTMLElement).closest?.('[role="dialog"]');
if (
dialog &&
dialog.getAttribute("aria-labelledby") !==
"visual-catalog-manager-title"
)
return;
event.preventDefault();
setSearchOpen(true);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
const heading =
catalogType === "bc"
? "BC Visual Catalog Manager"
@@ -254,19 +398,46 @@ function CatalogManagerInner({
<LayoutGrid className="h-4 w-4" />
{heading}
</h2>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => onOpenChange(false)}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
aria-label={t("globalSearch")}
aria-expanded={searchOpen}
onClick={() => {
searchNavigation.cancel();
setSearchOpen((value) => !value);
}}
>
<Search className="size-4" />
<span className="hidden sm:inline">{t("globalSearch")}</span>
<kbd className="hidden lg:inline text-xs">Ctrl Shift F</kbd>
</Button>
{!embedded && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={close}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
{searchOpen && (
<div className="shrink-0 max-h-[45vh] overflow-y-auto border-b p-2">
<CatalogSearch
catalogType={catalogType}
onNavigate={openSearchResult}
autoFocus
/>
</div>
)}
<BreadcrumbBar />
<BreadcrumbBar onSelectPage={handleSelectPage} />
<RootTabs
tabs={state.rootPages}
@@ -274,6 +445,7 @@ function CatalogManagerInner({
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={handleCreateRoot}
onMutation={loadRootPages}
loading={state.loadingRoot}
/>
@@ -281,18 +453,31 @@ function CatalogManagerInner({
className="flex min-h-0 flex-1 overflow-hidden"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
<div
className="flex min-h-0 min-w-0 flex-1 flex-col"
className={`${state.selectedPageId ? "hidden md:flex" : "flex"} min-h-0 min-w-0 flex-1 md:flex-none [&>div]:w-full md:[&>div]:w-80`}
>
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
</div>
<div
className={`${state.selectedPageId ? "flex" : "hidden md:flex"} min-h-0 min-w-0 flex-1 flex-col`}
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<Button
variant="ghost"
className="shrink-0 self-start md:hidden"
onClick={() => handleSelectPage(0)}
>
{t("backToCategories")}
</Button>
<InlineEditor
key={`${state.selectedPageId}:${searchTarget.key}`}
initialOfferId={searchTarget.offerId}
pageId={state.selectedPageId}
onSaved={handleEditorSaved}
/>
@@ -11,6 +11,7 @@ import {
Save,
Settings,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { updateCatalogPage } from "@/actions/catalog";
@@ -41,12 +42,14 @@ import {
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
import {
CATALOG_LAYOUTS,
type CatalogLayout,
LAYOUT_DESCRIPTIONS,
} from "@/lib/catalog-layouts";
import { cn } from "@/lib/utils";
import styles from "./catalog-access.module.css";
import { ImagePreview } from "./image-preview";
import { catalogTreeQs, useCatalogManager } from "./use-catalog-store";
@@ -100,13 +103,32 @@ export interface PageUpdateData {
}
interface InlineEditorProps {
initialOfferId?: number | null;
pageId: number | null;
onSaved: (updated: PageUpdateData) => void;
}
type FormState = Record<string, string | number | null>;
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
export function InlineEditor(props: InlineEditorProps) {
const { catalogType } = useCatalogManager();
return (
<InlineEditorSession key={`${catalogType}:${props.pageId}`} {...props} />
);
}
function InlineEditorSession({
pageId,
onSaved,
initialOfferId,
}: 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 =
@@ -119,13 +141,16 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const [form, setForm] = useState<FormState>({});
const [original, setOriginal] = useState<FormState>({});
const [showTexts, setShowTexts] = useState(false);
const [activeTab, setActiveTab] = useState("settings");
const [activeTab, setActiveTab] = useState(
initialOfferId ? "items" : "settings",
);
const [showPreview, setShowPreview] = useState(false);
// Items/translate data (lazy loaded)
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
const [itemsLoading, setItemsLoading] = useState(false);
const [itemsLoaded, setItemsLoaded] = useState(false);
const [itemsError, setItemsError] = useState(false);
// ── Dirty tracking ──────────────────────────────────────────
const isDirty = useMemo(() => {
@@ -133,8 +158,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 +176,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 +193,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 +210,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);
@@ -212,7 +255,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
loadPage(pageId);
setItemsData(null);
setItemsLoaded(false);
setActiveTab("settings");
setActiveTab(initialOfferId ? "items" : "settings");
setShowPreview(false);
} else {
setPage(null);
@@ -221,7 +264,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
setItemsData(null);
setItemsLoaded(false);
}
}, [pageId, loadPage]);
}, [pageId, loadPage, initialOfferId]);
// Lazy-load items (shop catalog only — BC items live on the detail page)
useEffect(() => {
@@ -250,15 +293,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 +330,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 +353,14 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
);
}
if (!page) return null;
if (!page)
return (
<div className="flex flex-1 items-center justify-center">
<Button variant="outline" onClick={() => loadPage(pageId)}>
{t("retryPage")}
</Button>
</div>
);
const layoutColor =
LAYOUT_COLORS[String(form.pageLayout)] || "bg-muted text-muted-foreground";
@@ -311,14 +368,14 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b shrink-0">
<div className="flex items-center gap-2">
<CatalogIcon
iconImage={(form.iconImage as number) ?? page.iconImage}
size={24}
/>
<div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">
{(form.caption as string) || page.caption}
</h3>
@@ -330,13 +387,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
>
{form.pageLayout as string}
</Badge>
{isDirty && (
{(isDirty || offersDirty) && (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
>
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
unsaved
{t("unsaved")}
</Badge>
)}
</div>
@@ -348,9 +405,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
</div>
</div>
<Link href={pageHref} target="_blank">
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1 text-[var(--admin-text)]"
>
<ExternalLink className="h-3 w-3" />
Full editor
{t("fullEditor")}
</Button>
</Link>
</div>
@@ -359,13 +420,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
{isDirty && (
<div className="flex items-center gap-2 px-4 py-1.5 bg-[var(--admin-warning-subtle)] border-b border-[var(--admin-warning-border)] text-xs text-[var(--admin-warning)] shrink-0">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">
Unsaved changes — press{" "}
<kbd className="rounded border bg-background px-1 font-mono text-[10px]">
Ctrl+S
</kbd>{" "}
to save
</span>
<span className="flex-1">{t("saveHint")}</span>
<Button
size="sm"
variant="outline"
@@ -386,7 +441,12 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
onValueChange={(tab) => {
if (tab === activeTab) return;
if (offersDirty && !window.confirm(tActions("leaveUnsaved"))) return;
setOffersDirty(false);
setActiveTab(tab);
}}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="mx-4 mt-2 shrink-0">
@@ -490,17 +550,19 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Min Rank</Label>
<Input
type="number"
value={form.minRank as number}
onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
className="h-8 text-sm"
/>
</div>
{catalogType === "normal" && (
<div className="space-y-1.5">
<Label className="text-xs">Min Rank</Label>
<Input
type="number"
value={form.minRank as number}
onChange={(e) =>
update("minRank", parseInt(e.target.value, 10) || 1)
}
className="h-8 text-sm"
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs">Icon</Label>
<IconPicker
@@ -511,28 +573,33 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
</div>
{/* Toggles */}
<div className="rounded-md border p-3 space-y-3">
<div className={styles.options}>
<ToggleRow
id="ie-enabled"
label="Enabled"
description="Active in the catalog"
disabled={!canEdit || saving}
label={t("access.enabled")}
description={t("access.enabledHint")}
checked={form.enabled === "1"}
onChange={(v) => update("enabled", v ? "1" : "0")}
/>
<ToggleRow
id="ie-visible"
label="Visible"
description="Shown in navigation"
disabled={!canEdit || saving}
label={t("access.visible")}
description={t("access.visibleHint")}
checked={form.visible === "1"}
onChange={(v) => update("visible", v ? "1" : "0")}
/>
<ToggleRow
id="ie-club"
label="Club Only"
description="Requires HC subscription"
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
/>
{catalogType === "normal" && (
<ToggleRow
id="ie-club"
disabled={!canEdit || saving}
label={t("access.club")}
description={t("access.clubHint")}
checked={form.clubOnly === "1"}
onChange={(v) => update("clubOnly", v ? "1" : "0")}
/>
)}
</div>
{/* Headline image picker */}
@@ -641,7 +708,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save & Sync
{tNavigation("save")}
</Button>
</div>
</div>
@@ -662,7 +729,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
</Button>
</Link>
</div>
) : itemsLoading ? (
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
{t("retryItems")}
</Button>
</div>
) : itemsLoading && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@@ -704,6 +777,11 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
{/* Items table */}
<CatalogItemsTable
initialSearch={
initialOfferId ? `#${initialOfferId}` : undefined
}
onDirtyChange={setOffersDirty}
onRefresh={reloadItems}
items={itemsData.items}
pageId={page.id}
pageLayout={page.pageLayout}
@@ -725,7 +803,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
Translate tools are available for the shop catalog only.
</div>
) : itemsLoading ? (
) : itemsError && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Button variant="outline" onClick={reloadItems}>
{t("retryItems")}
</Button>
</div>
) : itemsLoading && !itemsData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@@ -752,24 +836,40 @@ function ToggleRow({
id,
label,
description,
disabled,
checked,
onChange,
}: {
id: string;
label: string;
description: string;
disabled: boolean;
checked: boolean;
onChange: (v: boolean) => void;
}) {
const t = useTranslations("pages.admin.catalog.workspace.access");
return (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Label htmlFor={id} className="text-xs font-medium cursor-pointer">
<div className={styles.option} data-disabled={disabled || undefined}>
<div className={styles.optionHeader}>
<Label htmlFor={id} className={styles.label}>
{label}
</Label>
<p className="text-[10px] text-muted-foreground">{description}</p>
<div className={styles.control}>
<span className={styles.state} aria-hidden="true">
{t(checked ? "yes" : "no")}
</span>
<Switch
id={id}
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
aria-describedby={`${id}-description`}
/>
</div>
</div>
<Switch id={id} checked={checked} onCheckedChange={onChange} size="sm" />
<p id={`${id}-description`} className={styles.description}>
{description}
</p>
</div>
);
}
@@ -17,17 +17,26 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Loader2, Pencil, Plus, Power, PowerOff, Trash2 } from "lucide-react";
import {
Copy,
Loader2,
Pencil,
Plus,
Power,
PowerOff,
Trash2,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
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 +49,9 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
import { cn } from "@/lib/utils";
import { useCatalogManager } from "./use-catalog-store";
@@ -54,6 +66,7 @@ interface RootTabsProps {
onEditTab: (id: number) => void | Promise<void>;
onCreateRoot: () => void;
loading?: boolean;
onMutation?: () => void;
}
export function RootTabs({
@@ -63,9 +76,14 @@ export function RootTabs({
onEditTab,
onCreateRoot,
loading,
onMutation,
}: RootTabsProps) {
const { catalogType, canEdit } = useCatalogManager();
const { catalogType, canEdit, state } = useCatalogManager();
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [duplicateSource, setDuplicateSource] = useState<DuplicateInput | null>(
null,
);
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -85,26 +103,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 +136,7 @@ export function RootTabs({
toast.success(
`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`,
);
onMutation?.();
} catch {
toast.error("Failed to toggle");
}
@@ -138,6 +157,7 @@ export function RootTabs({
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
}
toast.success(`"${tab.caption}" deleted`);
onMutation?.();
} catch {
toast.error("Failed to delete");
}
@@ -153,6 +173,13 @@ export function RootTabs({
style={{ backgroundColor: "var(--admin-surface)" }}
>
{confirmDialog}
{duplicateSource && (
<DuplicateCategoryDialog
source={duplicateSource}
onClose={() => setDuplicateSource(null)}
onComplete={() => onMutation?.()}
/>
)}
{loading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground mx-2" />
)}
@@ -175,6 +202,19 @@ export function RootTabs({
onEdit={() => onEditTab(tab.id)}
onToggle={() => handleToggle(tab)}
onDelete={() => handleDelete(tab)}
onDuplicate={() => {
if (state.editorDirty) {
toast.error(t("draftBlocked"));
return;
}
if (canEdit && requestCatalogNavigation())
setDuplicateSource({
kind: catalogType,
sourceId: tab.id,
parentId: -1,
name: `${tab.caption.slice(0, 121)} (copy)`,
});
}}
/>
))}
</SortableContext>
@@ -213,6 +253,7 @@ function SortableRootTab({
onEdit,
onToggle,
onDelete,
onDuplicate,
}: {
tab: RootTab;
isActive: boolean;
@@ -220,6 +261,7 @@ function SortableRootTab({
onEdit: () => void;
onToggle: () => void;
onDelete: () => void;
onDuplicate: () => void;
}) {
const {
attributes,
@@ -232,6 +274,8 @@ function SortableRootTab({
id: tab.id,
});
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
const { canEdit } = useCatalogManager();
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
@@ -251,6 +295,12 @@ function SortableRootTab({
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
{canEdit && (
<ContextMenuItem onClick={onDuplicate}>
<Copy className="mr-2 h-3.5 w-3.5" />
{t("title")}
</ContextMenuItem>
)}
<ContextMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit page
@@ -31,21 +31,22 @@ import {
Trash2,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
createCatalogPage,
deleteTreePage,
reorderCatalogPages,
reorderTreePage,
toggleCatalogPage,
updateCatalogPage,
} from "@/actions/catalog";
import {
createBcPage,
deleteBcTreePage,
reorderBcCatalogPages,
reorderBcTreePage,
toggleBcPage,
updateBcPage,
} from "@/actions/catalog-bc";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Badge } from "@/components/ui/badge";
@@ -56,6 +57,9 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
import { cn } from "@/lib/utils";
import {
type FlatTreeNode,
@@ -91,6 +95,7 @@ export function SortableTree({
nodeUpdate,
}: SortableTreeProps) {
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
const catQs = catalogTreeQs(catalogType);
const pageBase =
catalogType === "bc" ? "/admin/catalog/builder-club" : "/admin/catalog";
@@ -105,6 +110,9 @@ export function SortableTree({
sortMode,
} = state;
const [duplicateSource, setDuplicateSource] = useState<DuplicateInput | null>(
null,
);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<TreeNodeData[] | null>(
null,
@@ -113,7 +121,6 @@ export function SortableTree({
const [subpageName, setSubpageName] = useState("");
const [creatingSubpage, setCreatingSubpage] = useState(false);
const [searching, setSearching] = useState(false);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [activeId, setActiveId] = useState<number | null>(null);
const [treeLoading, setTreeLoading] = useState(false);
const [filter, setFilter] = useState<FilterMode>("all");
@@ -189,29 +196,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]);
@@ -327,6 +337,7 @@ export function SortableTree({
const handleDeleteRef = useRef<(id: number) => Promise<void>>(async () => {});
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.target as HTMLElement)?.closest('[role="dialog"]')) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
if (e.key === "/") {
@@ -423,7 +434,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 +458,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");
@@ -586,67 +597,24 @@ export function SortableTree({
};
handleDeleteRef.current = handleDelete;
const handleDuplicate = async (id: number) => {
if (!canEdit) return;
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`);
if (!res.ok) throw new Error();
const { page } = await res.json();
const result =
catalogType === "bc"
? await createBcPage({
parentId: page.parentId ?? -1,
caption: `${page.caption} (copy)`,
pageLayout: page.pageLayout ?? "default_3x3",
iconImage: page.iconImage ?? 0,
iconColor: page.iconColor ?? 0,
enabled: "0",
visible: page.visible ?? "1",
orderNum: (page.orderNum ?? 0) + 1,
})
: await createCatalogPage({
parentId: page.parentId ?? -1,
caption: `${page.caption} (copy)`,
pageLayout: page.pageLayout ?? "default_3x3",
iconImage: page.iconImage ?? 0,
iconColor: page.iconColor ?? 0,
enabled: "0",
visible: page.visible ?? "1",
minRank: page.minRank ?? 1,
orderNum: (page.orderNum ?? 0) + 1,
});
if (!result.ok) throw new Error(result.error);
const newId = (result.data as Record<string, unknown>).id as number;
if (catalogType === "bc") {
await updateBcPage({
id: newId,
pageHeadline: page.pageHeadline ?? "",
pageTeaser: page.pageTeaser ?? "",
pageText1: page.pageText1,
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
});
} else {
await updateCatalogPage({
id: newId,
pageHeadline: page.pageHeadline ?? "",
pageTeaser: page.pageTeaser ?? "",
pageText1: page.pageText1,
pageText2: page.pageText2,
pageTextDetails: page.pageTextDetails,
pageTextTeaser: page.pageTextTeaser,
});
const handleDuplicate = useCallback(
(id: number) => {
if (state.editorDirty) {
toast.error(t("draftBlocked"));
return;
}
await loadFullTree();
toast.success(`Duplicated "${page.caption}" (items not copied)`);
onSelectPage(newId);
onTreeMutation();
} catch {
toast.error("Failed to duplicate page");
}
};
if (!canEdit || !requestCatalogNavigation()) return;
const node = nodes.get(id);
if (!node) return;
setDuplicateSource({
kind: catalogType,
sourceId: id,
parentId: node.parentId,
name: `${node.caption.slice(0, 121)} (copy)`,
});
},
[state.editorDirty, t, canEdit, nodes, catalogType],
);
const handleOpenFull = (id: number) =>
window.open(`${pageBase}/${id}`, "_blank");
@@ -776,6 +744,7 @@ export function SortableTree({
loadingIds,
handleToggleExpand,
handleSelect,
handleDuplicate,
],
); // eslint-disable-line react-hooks/exhaustive-deps
@@ -788,6 +757,16 @@ export function SortableTree({
style={{ backgroundColor: "var(--admin-surface)" }}
>
{confirmDialog}
{duplicateSource && (
<DuplicateCategoryDialog
source={duplicateSource}
onClose={() => setDuplicateSource(null)}
onComplete={() => {
void loadFullTree();
onTreeMutation();
}}
/>
)}
{/* Header */}
<div className="border-b px-3 py-2 space-y-2 shrink-0">
{subpageParentId !== null ? (
@@ -25,7 +25,6 @@ import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
@@ -251,13 +250,13 @@ export const TreeItemContent = React.memo(
<ContextMenu>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuLabel className="flex items-center gap-2">
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-muted-foreground">
<CatalogIcon iconImage={node.iconImage} size={14} />
{node.caption}
<span className="text-muted-foreground font-normal ml-auto">
#{node.id}
</span>
</ContextMenuLabel>
</div>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onSelect(node.id)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
@@ -317,6 +316,7 @@ export const TreeItemContent = React.memo(
},
(prev, next) => {
return (
prev.onSelect === next.onSelect &&
prev.node.id === next.node.id &&
prev.node.caption === next.node.caption &&
prev.node.enabled === next.node.enabled &&
@@ -59,6 +59,7 @@ const initialState: CatalogManagerState = {
// ── Actions ───────────────────────────────────────────────────
export type CatalogAction =
| { type: "RESTORE_SELECTION"; rootId: number | null; pageId: number | null }
| { type: "SET_ROOT_PAGES"; pages: RootTab[] }
| { type: "SET_LOADING_ROOT"; loading: boolean }
| { type: "SET_ACTIVE_TAB"; id: number | null }
@@ -93,14 +94,33 @@ function reducer(
action: CatalogAction,
): CatalogManagerState {
switch (action.type) {
case "RESTORE_SELECTION":
return {
...state,
activeTabId:
action.rootId ?? state.rootPages[0]?.id ?? state.activeTabId,
selectedPageId: action.pageId,
focusedId: action.pageId,
editorDirty: false,
multiSelectedIds: new Set(),
ancestors: [],
};
case "SET_ROOT_PAGES":
return { ...state, rootPages: action.pages, loadingRoot: false };
return {
...state,
rootPages: action.pages,
loadingRoot: false,
activeTabId: action.pages.some((page) => page.id === state.activeTabId)
? state.activeTabId
: (action.pages[0]?.id ?? null),
};
case "SET_LOADING_ROOT":
return { ...state, loadingRoot: action.loading };
case "SET_ACTIVE_TAB":
return {
...state,
activeTabId: action.id,
editorDirty: false,
selectedPageId: null,
multiSelectedIds: new Set(),
focusedId: null,
@@ -110,6 +130,7 @@ function reducer(
return {
...state,
selectedPageId: action.id,
editorDirty: false,
multiSelectedIds: new Set(),
focusedId: action.id,
};
@@ -1,55 +0,0 @@
"use client";
import { AlertTriangle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { fixEverythingAction } from "@/actions/furni-maintenance";
import { Button } from "@/components/ui/button";
/**
* One-click "fix everything" for the furniture tables. Runs sprite fix, catalog
* offer fix, FurnitureData reconciliation, dedup and ID alignment in order, all
* inside a single DB transaction on the server. Heavy — guarded by a confirm.
*/
export function CatalogFixAllButton() {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function run() {
if (
!window.confirm(
"Alle meubel-tabellen repareren (sprite-id, offer_id, dubbelen, IDs naar FurnitureData)?\nDit is een zware, niet-omkeerbare actie. Zorg dat er een backup is.",
)
) {
return;
}
setBusy(true);
try {
const res = await fixEverythingAction();
if (res.ok) {
toast.success("Alle reparaties uitgevoerd");
router.refresh();
} else {
toast.error(res.error ?? "Mislukt");
}
} catch (e) {
toast.error(String(e));
} finally {
setBusy(false);
}
}
return (
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={() => void run()}
title="Alle meubel-tabellen repareren"
>
<AlertTriangle className="mr-2 h-4 w-4" />
{busy ? "Bezig…" : "Fix alles"}
</Button>
);
}
+105
View File
@@ -0,0 +1,105 @@
.root {
--switch-off: var(--color-input);
--switch-on: var(--color-primary);
--switch-border: var(--color-muted-foreground);
--switch-thumb: var(--color-foreground);
--switch-mark: var(--color-background);
position: relative;
display: inline-flex;
flex-shrink: 0;
width: 44px;
height: 24px;
border: 1px solid var(--switch-border);
border-radius: 999px;
background: var(--switch-off);
cursor: pointer;
outline: none;
transition:
background-color 150ms,
border-color 150ms;
}
body:has([data-admin]) .root {
--switch-off: var(--admin-border);
--switch-on: var(--admin-accent);
--switch-border: var(--admin-text-muted);
--switch-thumb: var(--admin-text);
--switch-mark: var(--admin-canvas);
}
.root[data-checked] {
background: var(--switch-on);
border-color: var(--switch-on);
}
.root:focus-visible {
outline: 2px solid var(--switch-on);
outline-offset: 3px;
}
.root[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
.thumb {
pointer-events: none;
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
top: 50%;
left: 2px;
transform: translateY(-50%);
border-radius: 999px;
background: var(--switch-thumb);
color: var(--switch-mark);
transition: left 150ms;
}
.root[data-checked] .thumb {
left: 22px;
}
.check {
width: 10px;
height: 10px;
stroke-width: 3;
opacity: 0;
}
.root[data-checked] .check {
opacity: 1;
}
.root[data-size="sm"] {
width: 32px;
height: 20px;
}
.root[data-size="sm"] .thumb {
width: 14px;
height: 14px;
}
.root[data-size="sm"][data-checked] .thumb {
left: 14px;
}
.root[data-size="sm"] .check {
width: 8px;
height: 8px;
}
@media (prefers-reduced-motion: reduce) {
.root,
.thumb {
transition: none;
}
}
@media (forced-colors: active) {
.root {
border-color: ButtonText;
background: Canvas;
}
.root[data-checked] {
background: Highlight;
border-color: Highlight;
}
.thumb {
background: ButtonText;
color: Canvas;
}
.root:focus-visible {
outline-color: Highlight;
}
}
+4 -27
View File
@@ -4,6 +4,7 @@ import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import styles from "./switch.module.css";
function Switch({
className,
@@ -16,35 +17,11 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs outline-none transition-colors",
"bg-input hover:bg-input/80",
"data-checked:bg-primary data-checked:hover:bg-primary/90",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40",
"disabled:cursor-not-allowed disabled:opacity-50",
"data-[size=default]:h-5 data-[size=default]:w-9",
"data-[size=sm]:h-4 data-[size=sm]:w-7",
className,
)}
className={cn(styles.root, className)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none absolute top-1/2 flex -translate-y-1/2 items-center justify-center rounded-full bg-background text-primary shadow-sm ring-0 transition-[left,colors]",
"left-0.5 data-checked:left-[calc(100%-1.125rem)]",
"group-data-[size=default]/switch:size-4",
"group-data-[size=sm]/switch:size-3 group-data-[size=sm]/switch:data-checked:left-[calc(100%-0.875rem)]",
)}
>
<CheckIcon
aria-hidden
className={cn(
"size-2.5 stroke-[3] opacity-0 transition-opacity",
"group-data-checked/switch:opacity-100",
"group-data-[size=sm]/switch:size-2",
)}
/>
<SwitchPrimitive.Thumb data-slot="switch-thumb" className={styles.thumb}>
<CheckIcon aria-hidden className={styles.check} />
</SwitchPrimitive.Thumb>
</SwitchPrimitive.Root>
);
+106
View File
@@ -0,0 +1,106 @@
// Gamedata / large-JSON storage schema for MariaDB.
//
// MariaDB's JSON type is an alias for LONGTEXT (+ a `CHECK (json_valid(...))`
// constraint), so it can NEVER be indexed directly. The "smart" pattern:
// 1. keep the raw JSON document in longtext / mediumtext — full fidelity,
// no validation overhead, no 4 GB fetch if only a key is needed;
// 2. promote the fields you actually WHERE/ORDER BY into real columns;
// 3. index those columns (optionally as VIRTUAL generated columns using
// JSON_EXTRACT so you don't duplicate data in the seed script).
//
// Not part of drizzle's auto-generated src/db/schema.ts (which is regenerated
// from the live emulator DB) — this file is a standalone schema module you
// pass to `drizzle(conn, { schema })` in bulk scripts or import directly.
import { sql } from "drizzle-orm";
import {
index,
int,
longtext,
mediumtext,
mysqlEnum,
mysqlTable,
uniqueIndex,
varchar,
} from "drizzle-orm/mysql-core";
// ── Furnidata: one row per furniture/clothing item ───────────────────────────
// Compatible with the habbo furnidata_json shape:
// { "roomitemtypes": { "furnitype": [ { id, classname, public_name, ... } ] },
// "wallitemtypes": { "furnitype": [ ... ] },
// "effecttypes": { "effecttype": [ ... ] } }
export const GamedataFurnidata = mysqlTable(
"gamedata_furnidata",
{
id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(),
source: varchar("source", { length: 64 }).notNull().default("habbo"),
spriteId: int("sprite_id", { unsigned: true }).notNull(),
// "s" = floor/room item, "i" = wall item, "e" = clothing/effect.
kind: mysqlEnum("kind", ["s", "i", "e"]).notNull().default("s"),
className: varchar("class_name", { length: 128 }).notNull(),
// Promoted from JSON so hot lookups & LIST/WHERE never touch the blob.
publicName: varchar("public_name", { length: 128 }).notNull().default(""),
// Raw JSON document, kept intact for exact round-trips (catalog export).
payload: longtext("payload").notNull(),
},
(t) => [
// Idempotent seeds upsert on this key (source, sprite_id).
uniqueIndex("gamedata_furnidata_source_sprite").on(t.source, t.spriteId),
index("gamedata_furnidata_class").on(t.className),
index("gamedata_furnidata_kind").on(t.kind),
],
);
// ── Generic JSON document store ───────────────────────────────────────────────
// Use for any >50 MB JSON dump split into per-key documents (external_texts,
// product data, achievements, etc.). `title` is a VIRTUAL generated column
// extracted from the payload via JSON_EXTRACT and indexed — MariaDB supports
// secondary indexes on virtual columns (10.2+), so filtering by a payload
// field stays fast without storing a duplicate copy.
export const GamedataDocs = mysqlTable(
"gamedata_docs",
{
id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(),
category: varchar("category", { length: 64 }).notNull(),
docKey: varchar("doc_key", { length: 128 }).notNull(),
payload: longtext("payload").notNull(),
// SHA-1 of the raw payload → cheap change detection on re-runs.
payloadSha1: varchar("payload_sha1", { length: 40 }).notNull().default(""),
// VIRTUAL (default) column; the JSON_EXTRACT runs at query/index time.
// NOT NULL forces the extract to resolve — use .notNull() only when
// every document guarantees the field.
title: varchar("title", { length: 255 })
.generatedAlwaysAs(
sql`CAST(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.title')) AS CHAR(255))`,
{ mode: "virtual" },
)
.notNull(),
},
(t) => [
uniqueIndex("gamedata_docs_cat_key").on(t.category, t.docKey),
index("gamedata_docs_title").on(t.title),
],
);
// ── Key/value texts (external_texts style, split by category) ───────────────
// One row per string; bulk-loads stay resumable via (category, text_key).
export const GamedataTexts = mysqlTable(
"gamedata_texts",
{
id: int("id", { unsigned: true }).autoincrement().primaryKey().notNull(),
category: varchar("category", { length: 64 }).notNull(),
textKey: varchar("text_key", { length: 255 }).notNull(),
value: mediumtext("value").notNull(),
},
(t) => [
uniqueIndex("gamedata_texts_cat_key").on(t.category, t.textKey),
index("gamedata_texts_key").on(t.textKey),
],
);
export type GamedataFurnidataRow = typeof GamedataFurnidata.$inferSelect;
export type GamedataFurnidataInsert = typeof GamedataFurnidata.$inferInsert;
export type GamedataDocsRow = typeof GamedataDocs.$inferSelect;
export type GamedataDocsInsert = typeof GamedataDocs.$inferInsert;
export type GamedataTextsRow = typeof GamedataTexts.$inferSelect;
export type GamedataTextsInsert = typeof GamedataTexts.$inferInsert;
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
readCatalogSelection,
writeCatalogSelection,
} from "./catalog-selection-url";
describe("catalog selection URL", () => {
it("restores category context without disturbing catalog, search or pagination", () => {
const url = new URL(
"https://cms.test/admin/catalog?catalog=bc&search=chair&page=4",
);
const selected = writeCatalogSelection(url, { rootId: 8, pageId: 12 });
expect(readCatalogSelection(selected)).toEqual({ rootId: 8, pageId: 12 });
expect(selected.searchParams.get("catalog")).toBe("bc");
expect(selected.searchParams.get("search")).toBe("chair");
expect(selected.searchParams.get("page")).toBe("4");
expect(url.searchParams.has("category")).toBe(false);
expect(
writeCatalogSelection(selected, {
rootId: 8,
pageId: null,
}).searchParams.has("category"),
).toBe(false);
});
it.each(["-1", "0", "1.5", "1abc", "Infinity", "9007199254740992"])(
"rejects invalid identifiers: %s",
(id) => {
expect(
readCatalogSelection(
new URL(`https://cms.test/?root=${id}&category=${id}`),
),
).toEqual({ rootId: null, pageId: null });
},
);
});
@@ -0,0 +1,29 @@
export interface CatalogSelection {
rootId: number | null;
pageId: number | null;
}
function positiveId(value: string | null) {
if (!value || !/^\d+$/.test(value)) return null;
const id = Number(value);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
export function readCatalogSelection(url: URL): CatalogSelection {
return {
rootId: positiveId(url.searchParams.get("root")),
pageId: positiveId(url.searchParams.get("category")),
};
}
export function writeCatalogSelection(
url: URL,
selection: CatalogSelection,
): URL {
const next = new URL(url);
for (const [key, value] of [
["root", selection.rootId],
["category", selection.pageId],
] as const) {
if (value === null) next.searchParams.delete(key);
else next.searchParams.set(key, String(value));
}
return next;
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { createLatestRequest } from "./latest-request";
describe("catalog request ownership", () => {
it("keeps the latest category when an earlier transport completes last", async () => {
const requests = createLatestRequest();
let displayed = "";
let finishOld!: (value: string) => void;
const oldResponse = new Promise<string>((resolve) => {
finishOld = resolve;
});
const oldRequest = requests.start();
const oldWork = oldResponse.then((value) => {
if (oldRequest.isCurrent()) displayed = value;
});
const current = requests.start();
await Promise.resolve("category B").then((value) => {
if (current.isCurrent()) displayed = value;
});
finishOld("category A");
await oldWork;
expect(oldRequest.signal.aborted).toBe(true);
expect(displayed).toBe("category B");
});
it("ignores late save/read completion after editor unmount", () => {
const requests = createLatestRequest();
const pending = requests.start();
requests.cancel();
expect(pending.signal.aborted).toBe(true);
expect(pending.isCurrent()).toBe(false);
});
it("allows a fresh request after effect cleanup and isolates page/items channels", () => {
const pages = createLatestRequest();
const items = createLatestRequest();
const page = pages.start();
const firstItems = items.start();
items.cancel();
const latestItems = items.start();
expect(page.isCurrent()).toBe(true);
expect(firstItems.isCurrent()).toBe(false);
expect(latestItems.isCurrent()).toBe(true);
});
});
@@ -0,0 +1,19 @@
/** Cancels replaced requests and rejects late completions, even if transport ignores abort. */
export function createLatestRequest() {
let current: AbortController | null = null;
return {
start() {
current?.abort();
const controller = new AbortController();
current = controller;
return {
signal: controller.signal,
isCurrent: () => current === controller && !controller.signal.aborted,
};
},
cancel() {
current?.abort();
current = null;
},
};
}
@@ -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],
);
}
@@ -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;
}
@@ -0,0 +1,494 @@
"use client";
import { ArrowRight, Loader2, SlidersHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { applyBulkOffers, previewBulkOffers } from "@/actions/catalog-bulk";
import { MovePageCombobox } from "@/app/admin/catalog/[id]/catalog-items-table/field-helpers";
import { POINTS_TYPES } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import {
CurrencyIcon,
currencyKindFromPointsType,
} from "@/components/shared/currency-icon";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type BulkInput = Parameters<typeof previewBulkOffers>[0];
type Preview = Extract<
Awaited<ReturnType<typeof previewBulkOffers>>,
{ ok: true }
>["data"];
type PriceMode = "set" | "add" | "percent";
type PriceField = "costCredits" | "costPoints";
type PriceDraft = { enabled: boolean; mode: PriceMode; value: string };
const emptyPrice = (): PriceDraft => ({
enabled: false,
mode: "set",
value: "0",
});
export function BulkOfferEditor({
ids,
allPages,
disabled = false,
onApplied,
beforeEdit,
}: {
ids: number[];
allPages: { id: number; caption: string }[];
disabled?: boolean;
onApplied: () => void | Promise<void>;
beforeEdit?: () => boolean;
}) {
const t = useTranslations("pages.admin.catalog.bulk");
const [open, setOpen] = useState(false);
const [prices, setPrices] = useState<Record<PriceField, PriceDraft>>({
costCredits: emptyPrice(),
costPoints: emptyPrice(),
});
const [currencyEnabled, setCurrencyEnabled] = useState(false);
const [pointsType, setPointsType] = useState(0);
const [destinationEnabled, setDestinationEnabled] = useState(false);
const [pageId, setPageId] = useState<number | null>(null);
const [preview, setPreview] = useState<{
input: BulkInput;
data: Preview;
} | null>(null);
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const [error, setError] = useState<string | null>(null);
const tooMany = ids.length > 500;
const selectedFields =
prices.costCredits.enabled ||
prices.costPoints.enabled ||
currencyEnabled ||
destinationEnabled;
function start() {
if (disabled || tooMany || (beforeEdit && !beforeEdit())) return;
setPrices({ costCredits: emptyPrice(), costPoints: emptyPrice() });
setCurrencyEnabled(false);
setDestinationEnabled(false);
setPageId(null);
setPreview(null);
setError(null);
setOpen(true);
}
function updatePrice(field: PriceField, patch: Partial<PriceDraft>) {
setPrices((previous) => ({
...previous,
[field]: { ...previous[field], ...patch },
}));
}
async function requestPreview() {
if (busyRef.current || disabled || tooMany || (beforeEdit && !beforeEdit()))
return;
const changes: BulkInput["changes"] = {};
for (const field of ["costCredits", "costPoints"] as const) {
const price = prices[field];
if (!price.enabled) continue;
const value = Number(price.value);
if (
!price.value.trim() ||
!Number.isFinite(value) ||
(price.mode !== "percent" && !Number.isInteger(value))
) {
setError(t("invalidPrice"));
return;
}
changes[field] = { mode: price.mode, value };
}
if (currencyEnabled) changes.pointsType = pointsType;
if (destinationEnabled) {
if (pageId === null) {
setError(t("chooseDestination"));
return;
}
changes.pageId = pageId;
}
if (!selectedFields) {
setError(t("chooseFields"));
return;
}
const input: BulkInput = { ids: [...ids], changes };
busyRef.current = true;
setBusy(true);
setError(null);
try {
const result = await previewBulkOffers(input);
if (!result.ok) {
setError(result.error);
return;
}
setPreview({ input, data: result.data });
} catch {
setError(t("requestFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
}
async function apply() {
if (
!preview ||
busyRef.current ||
disabled ||
(beforeEdit && !beforeEdit())
)
return;
busyRef.current = true;
setBusy(true);
setError(null);
try {
const result = await applyBulkOffers(
preview.input,
preview.data.fingerprint,
);
if (!result.ok) {
setError(result.error);
setPreview(null);
return;
}
toast.success(t("success", { count: result.data.changedCount }));
setOpen(false);
await onApplied();
} catch {
setError(t("requestFailed"));
setPreview(null);
} finally {
busyRef.current = false;
setBusy(false);
}
}
const pageNames = new Map(
(preview?.data.pages ?? allPages).map((page) => [page.id, page.caption]),
);
const currencyName = (type: number) =>
POINTS_TYPES[type]
? t(`currencies.${type}`)
: t("unknownCurrency", { type });
const renderChange = (before: React.ReactNode, after: React.ReactNode) => (
<span className="inline-flex items-center gap-2">
<span className="text-muted-foreground">{before}</span>
<ArrowRight className="size-3 shrink-0" aria-label={t("becomes")} />
<strong>{after}</strong>
</span>
);
return (
<>
<div className="flex flex-col gap-1">
<Button
variant="outline"
size="sm"
disabled={disabled || tooMany || !ids.length}
onClick={start}
>
<SlidersHorizontal className="mr-2 size-4" />
{t("button", { count: ids.length })}
</Button>
{(disabled || tooMany) && (
<span className="max-w-64 text-xs text-muted-foreground">
{t(tooMany ? "selectionLimit" : "disabledReason")}
</span>
)}
</div>
<Dialog
open={open}
onOpenChange={(value) => {
if (!busyRef.current) setOpen(value);
}}
>
<DialogContent
style={{
backgroundColor: "var(--admin-surface)",
color: "var(--admin-text)",
}}
className="min-w-0 max-h-[90vh] overflow-y-auto sm:max-w-4xl [&>*]:min-w-0"
showCloseButton={false}
>
<DialogHeader>
<DialogTitle>{t(preview ? "previewTitle" : "title")}</DialogTitle>
<DialogDescription>
{t("description", { count: ids.length })}
</DialogDescription>
</DialogHeader>
{error && (
<p
role="alert"
className="rounded-md border border-destructive p-3 text-sm text-destructive"
>
{error}
</p>
)}
{!preview ? (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("fieldHint")}</p>
{(["costCredits", "costPoints"] as const).map((field) => (
<div key={field} className="space-y-3 rounded-md border p-3">
<Label className="flex items-center gap-2">
<Checkbox
checked={prices[field].enabled}
onCheckedChange={(value) =>
updatePrice(field, { enabled: value === true })
}
disabled={busy}
/>
{t(field)}
</Label>
{prices[field].enabled && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={`bulk-${field}-mode`}>
{t("operation")}
</Label>
<Select
value={prices[field].mode}
onValueChange={(value) =>
updatePrice(field, { mode: value as PriceMode })
}
disabled={busy}
>
<SelectTrigger id={`bulk-${field}-mode`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["set", "add", "percent"] as const).map(
(mode) => (
<SelectItem key={mode} value={mode}>
{t(`modes.${mode}`)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor={`bulk-${field}-value`}>
{t(
prices[field].mode === "percent"
? "percentage"
: "value",
)}
</Label>
<Input
id={`bulk-${field}-value`}
type="number"
step={prices[field].mode === "percent" ? "any" : 1}
value={prices[field].value}
onChange={(event) =>
updatePrice(field, { value: event.target.value })
}
disabled={busy}
/>
</div>
</div>
)}
</div>
))}
<div className="space-y-3 rounded-md border p-3">
<Label className="flex items-center gap-2">
<Checkbox
checked={currencyEnabled}
onCheckedChange={(value) =>
setCurrencyEnabled(value === true)
}
disabled={busy}
/>
{t("currency")}
</Label>
{currencyEnabled && (
<Select
value={String(pointsType)}
onValueChange={(value) => setPointsType(Number(value))}
disabled={busy}
>
<SelectTrigger aria-label={t("currency")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(POINTS_TYPES).map((type) => (
<SelectItem key={type} value={type}>
<span className="flex items-center gap-2">
<CurrencyIcon
kind={currencyKindFromPointsType(Number(type))}
alt=""
/>
{currencyName(Number(type))}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-3 rounded-md border p-3">
<Label className="flex items-center gap-2">
<Checkbox
checked={destinationEnabled}
onCheckedChange={(value) =>
setDestinationEnabled(value === true)
}
disabled={busy}
/>
{t("destination")}
</Label>
{destinationEnabled && (
<MovePageCombobox
value={pageId}
onChange={setPageId}
pages={allPages}
currentPageId={-1}
disabled={busy}
labels={{
placeholder: t("pagePlaceholder"),
search: t("pageSearch"),
empty: t("pageEmpty"),
suggested: t("pageSuggested"),
all: t("pageAll"),
}}
/>
)}
</div>
<p className="text-xs text-muted-foreground">{t("priceHint")}</p>
<p className="text-xs text-muted-foreground">
{t("visibilityHint")}
</p>
</div>
) : (
<div className="space-y-3">
<p className="text-sm font-medium">
{t("previewCount", {
count: preview.data.changedCount,
total: preview.data.rows.length,
})}
</p>
<p className="text-xs text-muted-foreground">
{t("beforeAfter")}
</p>
<div className="min-w-0 max-w-full max-h-[45vh] overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
{[
"offer",
"costCredits",
"costPoints",
"currency",
"destination",
].map((key) => (
<TableHead key={key}>{t(key)}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{preview.data.rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<div className="max-w-56 whitespace-normal break-words">
{row.catalogName}
</div>
<span className="text-xs text-muted-foreground">
#{row.id}
</span>
</TableCell>
<TableCell>
{renderChange(
row.before.costCredits,
row.after.costCredits,
)}
</TableCell>
<TableCell>
{renderChange(
row.before.costPoints,
row.after.costPoints,
)}
</TableCell>
<TableCell>
{renderChange(
currencyName(row.before.pointsType),
currencyName(row.after.pointsType),
)}
</TableCell>
<TableCell>
{renderChange(
`${pageNames.get(row.before.pageId) ?? ""} #${row.before.pageId}`,
`${pageNames.get(row.after.pageId) ?? ""} #${row.after.pageId}`,
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{preview.data.changedCount === 0 && (
<p className="text-sm text-muted-foreground">
{t("noChanges")}
</p>
)}
</div>
)}
<div className="flex min-w-0 flex-wrap justify-end gap-2 [&>button]:h-auto [&>button]:min-h-9 [&>button]:whitespace-normal">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={busy}
>
{t("cancel")}
</Button>
{preview && (
<Button
variant="outline"
onClick={() => {
setPreview(null);
setError(null);
}}
disabled={busy}
>
{t("back")}
</Button>
)}
<Button
onClick={preview ? apply : requestPreview}
disabled={
busy ||
disabled ||
(!preview && !selectedFields) ||
preview?.data.changedCount === 0
}
>
{busy && <Loader2 className="mr-2 size-4 animate-spin" />}
{preview
? t("confirm", { count: preview.data.changedCount })
: t("preview")}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,283 @@
"use client";
import { Loader2, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { getBulkOfferDestinations } from "@/actions/catalog-bulk";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { requestCatalogNavigation } from "../client/use-catalog-navigation";
import { useLatestRequest } from "../client/use-latest-request";
import {
type CatalogSearchResult,
type SearchCatalogKind,
validSearchQuery,
} from "../domain/search";
import { BulkOfferEditor } from "./bulk-offer-editor";
export function CatalogSearch({
catalogType = "normal",
canEdit = false,
onNavigate,
autoFocus = false,
}: {
catalogType?: SearchCatalogKind;
canEdit?: boolean;
onNavigate?: (result: CatalogSearchResult) => void | Promise<void>;
autoFocus?: boolean;
}) {
const t = useTranslations("pages.admin.catalog.workspace");
const [query, setQuery] = useState("");
const [results, setResults] = useState<CatalogSearchResult[]>([]);
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">(
"idle",
);
const requests = useLatestRequest();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
}, [autoFocus]);
const bulk = useTranslations("pages.admin.catalog.bulk");
const router = useRouter();
const [selected, setSelected] = useState<Set<number>>(new Set());
const [pages, setPages] = useState<{ id: number; caption: string }[] | null>(
null,
);
const [destinationsLoading, setDestinationsLoading] = useState(false);
const [selectionError, setSelectionError] = useState<string | null>(null);
const destinationRequest = useRef(0);
const destinationBusy = useRef(false);
const [refreshKey, setRefreshKey] = useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: Catalog switches invalidate the selection and destination requests.
useEffect(() => {
destinationRequest.current += 1;
destinationBusy.current = false;
setSelected(new Set());
setPages(null);
setDestinationsLoading(false);
setSelectionError(null);
return () => {
destinationRequest.current += 1;
};
}, [catalogType]);
async function loadDestinations() {
if (destinationBusy.current) return;
destinationBusy.current = true;
const request = ++destinationRequest.current;
setDestinationsLoading(true);
setSelectionError(null);
try {
const result = await getBulkOfferDestinations();
if (request !== destinationRequest.current) return;
if (!result.ok) {
setSelectionError(result.error);
return;
}
setPages(result.data.pages);
} catch {
if (request === destinationRequest.current)
setSelectionError(bulk("requestFailed"));
} finally {
if (request === destinationRequest.current) {
destinationBusy.current = false;
setDestinationsLoading(false);
}
}
}
function mayEdit() {
if (requestCatalogNavigation()) return true;
setSelectionError(bulk("disabledReason"));
return false;
}
function toggleOffer(id: number) {
if (!mayEdit()) return;
setSelected((previous) => {
const next = new Set(previous);
if (next.has(id)) next.delete(id);
else if (next.size < 500) next.add(id);
return next;
});
if (!pages) void loadDestinations();
}
// biome-ignore lint/correctness/useExhaustiveDependencies: Successful bulk edits must refresh unchanged search queries.
useEffect(() => {
const request = requests.start();
setResults([]);
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, refreshKey]);
return (
<section
aria-label={t("globalSearch")}
className="min-w-0 space-y-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 text-[var(--admin-text)]"
>
<div className="flex min-w-0 items-center gap-2">
<Search
aria-hidden
className="h-4 w-4 shrink-0 text-muted-foreground"
/>
<Input
ref={inputRef}
aria-label={t("globalSearch")}
placeholder={t("searchPlaceholder")}
value={query}
onChange={(event) => setQuery(event.target.value)}
maxLength={100}
className="min-w-0"
/>
</div>
{canEdit && catalogType === "normal" && selected.size > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-md border p-2">
<span className="text-sm">
{bulk("selectedCount", { count: selected.size })}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelected(new Set())}
>
{bulk("clearSelection")}
</Button>
{pages ? (
<BulkOfferEditor
ids={[...selected]}
allPages={pages}
beforeEdit={mayEdit}
onApplied={() => {
setSelected(new Set());
setRefreshKey((value) => value + 1);
router.refresh();
}}
/>
) : (
<Button
variant="outline"
size="sm"
disabled={destinationsLoading}
onClick={() => void loadDestinations()}
>
{destinationsLoading && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
{bulk(
destinationsLoading
? "loadingDestinations"
: "retryDestinations",
)}
</Button>
)}
</div>
)}
{selectionError && (
<p role="alert" className="text-sm text-[var(--admin-error)]">
{selectionError}
</p>
)}
<div aria-live="polite">
{status === "loading" && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
{t("searching")}
</p>
)}
{status === "error" && (
<p role="alert" className="text-sm text-[var(--admin-error)]">
{t("searchFailed")}
</p>
)}
{status === "ready" && results.length === 0 && (
<p className="text-sm text-muted-foreground">
{t("searchNoResults")}
</p>
)}
</div>
{results.length > 0 && (
<ul className="max-h-80 overflow-y-auto divide-y divide-[var(--admin-border)]">
{results.map((result) => (
<li
key={result.kind + result.id}
className="flex items-center gap-2"
>
{canEdit &&
catalogType === "normal" &&
result.kind === "offer" && (
<Checkbox
aria-label={bulk("selectOffer", {
name: result.caption,
id: result.id,
})}
checked={selected.has(result.id)}
disabled={selected.size >= 500 && !selected.has(result.id)}
onCheckedChange={() => toggleOffer(result.id)}
/>
)}
<Link
href={result.href}
onClick={(event) => {
if (
onNavigate &&
!event.ctrlKey &&
!event.metaKey &&
!event.shiftKey &&
!event.altKey
) {
event.preventDefault();
void onNavigate(result);
}
}}
className="block min-w-0 flex-1 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-xs text-muted-foreground">
{t(result.kind)}
</span>
<span className="font-medium break-words text-[var(--admin-text)]">
{result.caption}
</span>
<span className="text-xs text-muted-foreground">
#{result.id}
</span>
</div>
<p className="break-words text-xs text-muted-foreground">
{result.path.join(" / ")}
</p>
{result.furniture && (
<p className="break-words text-xs text-muted-foreground">
{result.furniture.classname} · #{result.furniture.id} ·
sprite {result.furniture.spriteId}
</p>
)}
</Link>
</li>
))}
</ul>
)}
</section>
);
}
@@ -0,0 +1,90 @@
"use client";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
import { useVisiblePolling } from "@/hooks/use-visible-polling";
import { adminFetch } from "@/lib/admin-fetch";
interface Status {
hotel: { sent: boolean; checkedAt: string; reference?: string } | null;
git: {
enabled: boolean;
pending: number;
active: number;
error?: string;
commit?: string;
};
}
export function CatalogSyncStatus({ canEdit }: { canEdit: boolean }) {
const t = useTranslations("pages.admin.catalog.workspace");
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
try {
const res = await adminFetch("/api/admin/catalog/status", {
cache: "no-store",
});
if (!res.ok) throw Error();
setStatus(await res.json());
setError(false);
} catch {
setError(true);
}
}, []);
useVisiblePolling(refresh, 15000);
async function retry() {
setBusy(true);
try {
const res = await adminFetch("/api/admin/catalog/status", {
method: "POST",
});
if (!res.ok) throw Error();
await refresh();
} catch {
setError(true);
} finally {
setBusy(false);
}
}
return (
<div
className="flex flex-wrap items-center gap-3 rounded-lg border border-[var(--admin-border)] p-3 text-xs"
aria-live="polite"
>
<span>
{t("hotel")}:{" "}
{status?.hotel
? t(status.hotel.sent ? "sent" : "sendFailed")
: t("unknown")}
</span>
<span>
Git:{" "}
{status
? t(
!status.git.enabled
? "disabled"
: status.git.error
? "exportFailed"
: status.git.pending || status.git.active
? "queued"
: status.git.commit
? "exported"
: "unknown",
)
: t("unknown")}
</span>
{status?.hotel?.reference && <span>{status.hotel.reference}</span>}
{canEdit && status?.hotel?.sent === false && (
<Button size="sm" variant="outline" disabled={busy} onClick={retry}>
{t("retryHotel")}
</Button>
)}
{error && (
<button type="button" onClick={() => void refresh()}>
{t("retryStatus")}
</button>
)}
</div>
);
}
@@ -0,0 +1,175 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useId, useRef, useState } from "react";
import { toast } from "sonner";
import {
applyCatalogDuplicate,
catalogDuplicateDestinations,
previewCatalogDuplicate,
} from "@/actions/catalog-duplicate";
import { MovePageCombobox } from "@/app/admin/catalog/[id]/catalog-items-table/field-helpers";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type { DuplicateInput, DuplicatePreview } from "../domain/duplicate";
export function DuplicateCategoryDialog({
source,
onClose,
onComplete,
}: {
source: DuplicateInput;
onClose: () => void;
onComplete: (id: number) => void;
}) {
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
const [name, setName] = useState(source.name);
const [parentId, setParentId] = useState<number | null>(
source.parentId <= 0 ? -1 : source.parentId,
);
const nameId = useId();
const [destinations, setDestinations] = useState<
{ id: number; caption: string }[]
>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let current = true;
void catalogDuplicateDestinations(source.kind)
.then((result) => {
if (!current) return;
if (result.ok) setDestinations(result.data);
else toast.error(result.error);
})
.catch(() => {
if (current) toast.error(t("failed"));
})
.finally(() => {
if (current) setLoading(false);
});
return () => {
current = false;
};
}, [source.kind, t]);
const [preview, setPreview] = useState<DuplicatePreview | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
async function run() {
if (pending.current) return;
pending.current = true;
setBusy(true);
try {
if (!preview) {
const result = await previewCatalogDuplicate({
...source,
name,
parentId: parentId ?? -1,
});
if (!result.ok) throw Error(result.error);
setPreview(result.data);
} else {
const result = await applyCatalogDuplicate(preview);
if (!result.ok) {
setPreview(null);
throw Error(result.error);
}
if (result.data.hotel.sent) toast.success(t("saved"));
else toast.warning(t("savedPending"));
onComplete(result.data.id);
onClose();
}
} catch (error) {
setPreview(null);
toast.error(error instanceof Error ? error.message : t("failed"));
} finally {
pending.current = false;
setBusy(false);
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !pending.current) onClose();
}}
>
<DialogContent
className="max-w-lg"
style={{
backgroundColor: "var(--admin-surface)",
color: "var(--admin-text)",
}}
>
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<label htmlFor={nameId} className="grid gap-1 text-sm">
{t("name")}
<Input
id={nameId}
value={name}
maxLength={128}
disabled={busy}
onChange={(e) => {
setName(e.target.value);
setPreview(null);
}}
/>
</label>
<div className="grid gap-1 text-sm">
<p>{t("destination")}</p>
<MovePageCombobox
value={parentId}
onChange={(id) => {
setParentId(id);
setPreview(null);
}}
pages={[{ id: -1, caption: t("root") }, ...destinations]}
currentPageId={-2}
disabled={busy || loading}
labels={{
placeholder: t("destination"),
search: t("search"),
empty: t("empty"),
suggested: t("suggested"),
all: t("all"),
}}
/>
</div>
<p className="text-sm text-muted-foreground">{t("disabled")}</p>
{preview && (
<div
className="rounded-md border p-3 text-sm space-y-1"
role="status"
>
<p>
{t("counts", { pages: preview.pages, offers: preview.offers })}
</p>
<p>
{t("summary", {
name: preview.name,
destination: preview.destination,
})}
</p>
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" disabled={busy} onClick={onClose}>
{t("cancel")}
</Button>
<Button
disabled={busy || !name.trim() || parentId === null || loading}
onClick={() => void run()}
>
{busy ? t("working") : preview ? t("confirm") : t("preview")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,51 @@
import { expect, it } from "vitest";
import { bulkOfferInputSchema, projectBulkOffer } from "./bulk-offers";
const row = { pageId: 4, costCredits: 3, costPoints: 10, pointsType: 5 };
it("combines selected changes, rounds percentages, preserves explicit zero", () => {
expect(
projectBulkOffer(row, {
costCredits: { mode: "set", value: 0 },
costPoints: { mode: "percent", value: 25 },
pageId: 9,
}),
).toEqual({ ...row, costCredits: 0, costPoints: 13, pageId: 9 });
});
it("rejects negative results and integer overflow instead of silently clamping", () => {
expect(() =>
projectBulkOffer(row, { costCredits: { mode: "add", value: -4 } }),
).toThrow();
expect(() =>
projectBulkOffer(row, { costCredits: { mode: "set", value: 2147483648 } }),
).toThrow();
});
it("leaves legacy signed fields untouched when changing a different field", () => {
expect(
projectBulkOffer({ ...row, costCredits: -1 }, { pageId: 5 }).costCredits,
).toBe(-1);
});
it("rejects empty, duplicate, oversized selections and unknown mutations", () => {
for (const input of [
{ ids: [], changes: { pageId: 1 } },
{ ids: [1, 1], changes: { pageId: 1 } },
{
ids: Array.from({ length: 501 }, (_, i) => i + 1),
changes: { pageId: 1 },
},
{ ids: [1], changes: {} },
{ ids: [1], changes: { itemIds: "2" } },
])
expect(() => bulkOfferInputSchema.parse(input)).toThrow();
});
it("rejects fractional set/add prices, accepts percentage discounts", () => {
expect(() =>
bulkOfferInputSchema.parse({
ids: [1],
changes: { costCredits: { mode: "set", value: 1.5 } },
}),
).toThrow();
expect(
projectBulkOffer(row, { costPoints: { mode: "percent", value: -50 } })
.costPoints,
).toBe(5);
});
@@ -0,0 +1,81 @@
import { z } from "zod";
import { CatalogInputError } from "./hierarchy";
import { offerIdSchema, offerInteger } from "./offer-input";
const priceChange = z
.object({
mode: z.enum(["set", "add", "percent"]),
value: z.number().finite().min(-2147483648).max(2147483647),
})
.strict()
.refine(
(v) => v.mode === "percent" || Number.isInteger(v.value),
"Set and add values must be whole numbers",
);
export const bulkOfferChangesSchema = z
.object({
costCredits: priceChange.optional(),
costPoints: priceChange.optional(),
pointsType: offerInteger.nonnegative().optional(),
pageId: offerIdSchema.optional(),
})
.strict()
.refine(
(v) => Object.values(v).some((x) => x !== undefined),
"Choose at least one field to change",
);
export const bulkOfferInputSchema = z
.object({
ids: z
.array(offerIdSchema)
.min(1)
.max(500)
.refine(
(v) => new Set(v).size === v.length,
"Selection contains duplicate offers",
),
changes: bulkOfferChangesSchema,
})
.strict();
export type BulkOfferInput = z.infer<typeof bulkOfferInputSchema>;
export type BulkOfferValues = {
pageId: number;
costCredits: number;
costPoints: number;
pointsType: number;
};
export type BulkOfferPreview = {
fingerprint: string;
changedCount: number;
pages: Array<{ id: number; caption: string }>;
rows: Array<{
id: number;
catalogName: string;
before: BulkOfferValues;
after: BulkOfferValues;
}>;
};
export function projectBulkOffer(
row: BulkOfferValues,
changes: BulkOfferInput["changes"],
): BulkOfferValues {
const after = { ...row };
for (const key of ["costCredits", "costPoints"] as const) {
const change = changes[key];
if (!change) continue;
const value =
change.mode === "set"
? change.value
: change.mode === "add"
? row[key] + change.value
: Math.round(row[key] * (1 + change.value / 100));
if (!Number.isSafeInteger(value) || value < 0 || value > 2147483647)
throw new CatalogInputError(
"A resulting price is negative or exceeds the supported range",
);
after[key] = value;
}
if (changes.pointsType !== undefined) after.pointsType = changes.pointsType;
if (changes.pageId !== undefined) after.pageId = changes.pageId;
return after;
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { duplicateSubtree, remapIncludes } from "./duplicate";
describe("category duplication", () => {
const pages = [
{ id: 1, parent_id: -1 },
{ id: 2, parent_id: 1 },
{ id: 3, parent_id: 2 },
{ id: 4, parent_id: -1 },
];
it("collects all descendants in parent-first order", () =>
expect(duplicateSubtree(pages, 1, 4).map((p) => p.id)).toEqual([1, 2, 3]));
it("rejects corrupt source cycles and missing destination", () => {
expect(() =>
duplicateSubtree(
[
{ id: 1, parent_id: 2 },
{ id: 2, parent_id: 1 },
],
1,
-1,
),
).toThrow();
expect(() => duplicateSubtree(pages, 1, 99)).toThrow();
});
it("rejects missing source ancestry", () =>
expect(() =>
duplicateSubtree([{ id: 1, parent_id: 99 }], 1, -1),
).toThrow());
it("remaps internal includes and preserves external references", () =>
expect(remapIncludes("2;4", new Map([[2, 20]]), new Set([2, 4]))).toBe(
"20;4",
));
it("rejects broken included references", () =>
expect(() => remapIncludes("99", new Map(), new Set([2]))).toThrow());
});
+59
View File
@@ -0,0 +1,59 @@
import {
assertParent,
CatalogInputError,
CatalogNotFound,
collectSubtree,
positiveId,
} from "./hierarchy";
export type DuplicateRow = Record<string, unknown> & {
id: number;
parent_id?: number;
};
export type DuplicateInput = {
kind: "normal" | "bc";
sourceId: number;
parentId: number;
name: string;
};
export type DuplicatePreview = DuplicateInput & {
fingerprint: string;
pages: number;
offers: number;
destination: string;
};
export function duplicateSubtree<T extends DuplicateRow>(
pages: T[],
sourceId: number,
parentId: number,
): T[] {
positiveId(sourceId);
const source = pages.find((p) => Number(p.id) === sourceId);
if (!source) throw new CatalogNotFound("Catalog category not found");
const hierarchy = pages.map((p) => ({
id: Number(p.id),
parentId: Number(p.parent_id),
}));
assertParent(hierarchy, sourceId, Number(source.parent_id));
assertParent(hierarchy, 0, parentId);
const ids = collectSubtree(hierarchy, sourceId);
if (ids.length > 500)
throw new CatalogInputError("Duplicate at most 500 categories at a time");
return ids.map((id) => pages.find((p) => Number(p.id) === id) as T);
}
export function remapIncludes(
value: unknown,
ids: Map<number, number>,
existing: Set<number>,
): string {
const text = String(value ?? "");
if (!text.trim()) return text;
return text
.split(";")
.map((part) => {
const id = Number(part.trim());
if (!/^\d+$/.test(part.trim()) || !existing.has(id))
throw new CatalogInputError("Included category reference not found");
return String(ids.get(id) ?? id);
})
.join(";");
}
@@ -0,0 +1,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();
});
+83
View File
@@ -0,0 +1,83 @@
export interface HierarchyPage {
id: number;
parentId: number;
orderNum?: number;
}
export class CatalogConflict extends Error {}
export class CatalogInputError extends Error {}
export class CatalogNotFound extends Error {}
export function positiveId(id: number) {
if (!Number.isSafeInteger(id) || id <= 0)
throw new CatalogInputError("Invalid catalog ID");
}
export function assertParent(
pages: readonly HierarchyPage[],
pageId: number,
parentId: number,
) {
if (!Number.isSafeInteger(parentId) || parentId < -1)
throw new CatalogInputError("Invalid parent category");
const map = new Map(pages.map((page) => [page.id, page]));
const visited = new Set<number>();
let current = parentId;
while (current > 0) {
if (current === pageId || visited.has(current))
throw new CatalogInputError(
"Cannot move page: would create a circular hierarchy",
);
visited.add(current);
const parent = map.get(current);
if (!parent) throw new CatalogInputError("Parent category does not exist");
current = parent.parentId;
}
}
export function collectSubtree(
pages: readonly HierarchyPage[],
pageId: number,
): number[] {
const children = new Map<number, number[]>();
for (const page of pages) {
const list = children.get(page.parentId) ?? [];
list.push(page.id);
children.set(page.parentId, list);
}
const seen = new Set<number>();
const queue = [pageId];
for (let index = 0; index < queue.length; index++) {
const id = queue[index];
if (seen.has(id)) continue;
seen.add(id);
queue.push(...(children.get(id) ?? []));
}
return [...seen];
}
export function validateSiblingOrder(
pages: readonly HierarchyPage[],
parentId: number,
ids: readonly number[],
expected: readonly number[],
) {
const current = pages
.filter((page) =>
parentId <= 0
? page.parentId === -1 || page.parentId === 0
: page.parentId === parentId,
)
.sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0) || a.id - b.id)
.map((page) => page.id);
if (
current.length !== expected.length ||
current.some((id, index) => id !== expected[index])
)
throw new CatalogConflict(
"The category order changed. Reload before trying again.",
);
if (
ids.length !== current.length ||
new Set(ids).size !== ids.length ||
ids.some((id) => !current.includes(id))
)
throw new CatalogInputError(
"A reorder must include every sibling exactly once",
);
}
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { discoverOffers } from "./offer-discovery";
const offers = [
{
id: 7,
catalogName: "Oak chair",
baseItemName: "chair_oak",
baseName: "Wooden chair",
itemIds: "10",
costCredits: 0,
costPoints: 0,
limitedStack: 0,
},
{
id: 8,
catalogName: "Chair pair",
baseItemName: "",
baseName: "",
itemIds: "10;11",
costCredits: 0,
costPoints: 5,
limitedStack: 0,
},
{
id: 9,
catalogName: "Limited table",
baseItemName: "table_oak",
baseName: "Wooden table",
itemIds: "12",
costCredits: 15,
costPoints: 0,
limitedStack: 100,
},
{
id: 10,
catalogName: "Welcome bundle",
baseItemName: "",
baseName: "",
itemIds: " 10 ; 12 ",
costCredits: 0,
costPoints: 0,
limitedStack: 0,
},
];
describe("offer discovery", () => {
it("matches #offer IDs exactly without matching longer IDs or furniture references", () => {
const rows = [offers[0], { ...offers[1], id: 17, itemIds: "7" }];
expect(
discoverOffers(rows, " #7 ", "all").items.map((item) => item.id),
).toEqual([7]);
});
it("requires both prices to be zero for free offers and includes points-only paid offers", () => {
expect(
discoverOffers(offers, "", "free").items.map((item) => item.id),
).toEqual([7, 10]);
expect(
discoverOffers(offers, "", "paid").items.map((item) => item.id),
).toEqual([8, 9]);
});
it("recognizes semicolon bundles without requiring a single base item", () => {
expect(
discoverOffers(offers, "", "bundles").items.map((item) => item.id),
).toEqual([8, 10]);
expect(
discoverOffers([{ ...offers[0], itemIds: "10; ;" }], "", "bundles").items,
).toEqual([]);
});
it("identifies limited editions by stock even without sold quantities", () => {
expect(
discoverOffers(offers, "", "limited").items.map((item) => item.id),
).toEqual([9]);
});
it("combines trimmed case-insensitive text with a facet while counting other available facets", () => {
const result = discoverOffers(offers, " CHAIR ", "paid");
expect(result.items.map((item) => item.id)).toEqual([8]);
expect(result.counts).toEqual({
all: 2,
free: 1,
paid: 1,
bundles: 1,
limited: 0,
});
});
it("searches public names, classnames, offer IDs and furniture references", () => {
expect(
discoverOffers(offers, "Wooden", "all").items.map((item) => item.id),
).toEqual([7, 9]);
expect(
discoverOffers(offers, "table_oak", "all").items.map((item) => item.id),
).toEqual([9]);
expect(
discoverOffers(offers, "7", "all").items.map((item) => item.id),
).toEqual([7]);
expect(
discoverOffers(offers, "11", "all").items.map((item) => item.id),
).toEqual([8]);
});
it("keeps source ordering and returns empty counts when the query has no matches", () => {
const copy = structuredClone(offers);
expect(discoverOffers(offers, " ", "all").items).toEqual(offers);
expect(discoverOffers(offers, "absent", "all")).toEqual({
items: [],
counts: { all: 0, free: 0, paid: 0, bundles: 0, limited: 0 },
});
expect(offers).toEqual(copy);
});
});
@@ -0,0 +1,78 @@
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
export const OFFER_FILTERS = [
"all",
"free",
"paid",
"bundles",
"limited",
] as const;
export type OfferFilter = (typeof OFFER_FILTERS)[number];
type DiscoverableOffer = Pick<
CatalogItemData,
| "id"
| "catalogName"
| "baseItemName"
| "baseName"
| "itemIds"
| "costCredits"
| "costPoints"
| "limitedStack"
>;
function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
switch (filter) {
case "all":
return true;
case "free":
return item.costCredits === 0 && item.costPoints === 0;
case "paid":
return item.costCredits > 0 || item.costPoints > 0;
case "bundles":
return (
item.itemIds.split(";").filter((part) => part.trim().length > 0)
.length > 1
);
case "limited":
return item.limitedStack > 0;
}
}
/** Facet counts apply to the text search so changing a facet never hides its available matches. */
export function discoverOffers<T extends DiscoverableOffer>(
items: readonly T[],
query: string,
filter: OfferFilter,
) {
const normalized = query.trim().toLowerCase();
const exactId = /^#\d+$/.test(normalized) ? normalized.slice(1) : null;
const matches =
exactId !== null
? items.filter((item) => String(item.id) === exactId)
: normalized
? items.filter((item) =>
[
item.catalogName,
item.baseItemName,
item.baseName,
item.itemIds,
String(item.id),
].some((value) => value.toLowerCase().includes(normalized)),
)
: [...items];
const counts = {
all: matches.length,
free: 0,
paid: 0,
bundles: 0,
limited: 0,
};
for (const item of matches)
for (const facet of OFFER_FILTERS) {
if (facet !== "all" && matchesFilter(item, facet)) counts[facet] += 1;
}
return {
items: matches.filter((item) => matchesFilter(item, filter)),
counts,
};
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
numericValue,
offerPatchSchema,
parseFurnitureIds,
} from "./offer-input";
describe("offer compatibility", () => {
it.each([0, "0", -1, "-1"])(
"preserves explicit numeric value %s",
(value) => {
expect(numericValue(value, 99)).toBe(Number(value));
},
);
it.each([null, undefined, "", "invalid", Infinity])(
"defaults missing or invalid value %s",
(value) => {
expect(numericValue(value, 99)).toBe(99);
},
);
it("preserves bundle multiplicity and LTD/special offer values", () => {
expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]);
const values = {
itemIds: "12;23;12",
amount: 0,
offerId: -1,
orderNumber: 0,
limitedSells: 4,
limitedStack: 20,
songId: 0,
extradata: "a;b",
haveOffer: "0",
clubOnly: "1",
};
expect(offerPatchSchema.parse(values)).toEqual(values);
});
it.each(["1;0", "1;2x", "1;;2", "-1", "1.5"])(
"rejects invalid furniture references %s",
(value) => {
expect(() => parseFurnitureIds(value)).toThrow();
},
);
it("rejects invalid page ids and nonfinite prices without changing field meaning", () => {
expect(offerPatchSchema.safeParse({ pageId: 0 }).success).toBe(false);
expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe(
false,
);
expect(offerPatchSchema.parse({ offerId: 0, amount: 0 })).toEqual({
offerId: 0,
amount: 0,
});
});
});
@@ -0,0 +1,83 @@
import { z } from "zod";
export const offerInteger = z.number().int().min(-2147483648).max(2147483647);
export const offerIdSchema = offerInteger.positive();
/** Missing SQL values use the field default; explicit zero and special negatives survive. */
export function numericValue(value: unknown, fallback: number): number {
if (value == null || (typeof value === "string" && value.trim() === ""))
return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function parseFurnitureIds(value: string): number[] {
const parts = value.split(";");
if (!parts.length || parts.some((part) => !/^\d+$/.test(part.trim())))
throw Error("Invalid furniture references");
return parts.map((part) => offerIdSchema.parse(Number(part)));
}
const itemIds = z
.string()
.max(666)
.refine((value) => {
try {
parseFurnitureIds(value);
return true;
} catch {
return false;
}
}, "Invalid furniture references");
// Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator.
export const offerPatchSchema = z.object({
pageId: offerIdSchema.optional(),
itemIds: itemIds.optional(),
catalogName: z.string().max(100).optional(),
costCredits: offerInteger.optional(),
costPoints: offerInteger.optional(),
pointsType: offerInteger.optional(),
amount: offerInteger.optional(),
orderNumber: offerInteger.optional(),
offerId: offerInteger.optional(),
songId: z.number().int().min(0).max(4294967295).optional(),
limitedSells: offerInteger.optional(),
limitedStack: offerInteger.optional(),
extradata: z.string().max(500).optional(),
haveOffer: z.enum(["0", "1"]).optional(),
clubOnly: z.enum(["0", "1"]).optional(),
});
const flag = z.union([
z.literal(0),
z.literal(1),
z.literal("0"),
z.literal("1"),
]);
export const furniturePatchSchema = z.object({
publicName: z.string().max(56).optional(),
itemName: z.string().max(70).optional(),
type: z.string().max(3).optional(),
width: offerInteger.optional(),
length: offerInteger.optional(),
stackHeight: z.number().finite().optional(),
allowStack: flag.optional(),
allowSit: flag.optional(),
allowLay: flag.optional(),
allowWalk: flag.optional(),
allowGift: flag.optional(),
allowTrade: flag.optional(),
allowRecycle: flag.optional(),
allowMarketplaceSell: flag.optional(),
allowInventoryStack: flag.optional(),
interactionType: z.string().max(500).optional(),
interactionModesCount: offerInteger.optional(),
vendingIds: z.string().max(255).optional(),
customparams: z.string().max(25600).optional(),
effectIdMale: offerInteger.optional(),
effectIdFemale: offerInteger.optional(),
clothingOnWalk: z.string().max(255).optional(),
});
export function distinctOfferIds(ids: number[]): number[] {
for (const id of ids) offerIdSchema.parse(id);
if (new Set(ids).size !== ids.length)
throw Error("Duplicate catalog item ids");
return ids;
}
@@ -0,0 +1,27 @@
import { expect, it } from "vitest";
import { pagePatchSchema } from "./page-input";
it("preserves nullable page texts when editing unrelated fields", () => {
const fields = {
caption: "Changed",
pageText1: null,
pageText2: null,
pageTextDetails: null,
pageTextTeaser: null,
pageSpecial: null,
};
expect(pagePatchSchema.parse(fields)).toEqual(fields);
});
it("preserves valid zero values and rejects invalid flags", () => {
expect(
pagePatchSchema.parse({ parentId: 0, orderNum: 0, iconImage: 0 }),
).toEqual({ parentId: 0, orderNum: 0, iconImage: 0 });
expect(() => pagePatchSchema.parse({ enabled: "true" })).toThrow();
});
it("preserves caption whitespace for optimistic comparisons but rejects blank captions", () => {
expect(pagePatchSchema.parse({ caption: " Existing " })).toEqual({
caption: " Existing ",
});
expect(() => pagePatchSchema.parse({ caption: " " })).toThrow();
});
+31
View File
@@ -0,0 +1,31 @@
import { z } from "zod";
const flag = z.enum(["0", "1"]);
const integer = z.number().int().min(-2147483648).max(2147483647);
export const pagePatchSchema = z.object({
caption: z
.string()
.min(1)
.max(128)
.refine((value) => value.trim().length > 0, "Category name is required")
.optional(),
captionSave: z.string().max(25).optional(),
parentId: integer.min(-1).optional(),
pageLayout: z.string().min(1).max(26).optional(),
enabled: flag.optional(),
visible: flag.optional(),
clubOnly: flag.optional(),
vipOnly: flag.optional(),
minRank: integer.min(0).optional(),
orderNum: integer.optional(),
iconImage: integer.min(0).optional(),
iconColor: integer.min(0).optional(),
pageHeadline: z.string().max(1024).optional(),
pageTeaser: z.string().max(64).optional(),
pageSpecial: z.string().max(2048).nullable().optional(),
pageText1: z.string().nullable().optional(),
pageText2: z.string().nullable().optional(),
pageTextDetails: z.string().nullable().optional(),
pageTextTeaser: z.string().nullable().optional(),
includes: z.string().max(128).optional(),
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { parseCatalogSearch, validSearchQuery } from "./search";
describe("catalog search input", () => {
it("allows a one digit ID but requires at least two text characters", () => {
expect(validSearchQuery("7")).toBe(true);
expect(validSearchQuery("a")).toBe(false);
expect(validSearchQuery(" ab ")).toBe(true);
});
it("does not round oversized numeric identifiers", () => {
expect(parseCatalogSearch("normal", "9007199254740992").numericId).toBe(
null,
);
});
it("escapes LIKE wildcard and escape characters", () => {
expect(parseCatalogSearch("bc", "a_%=").pattern).toBe("a=_=%==%");
});
});
+32
View File
@@ -0,0 +1,32 @@
import { CatalogInputError } from "./hierarchy";
export type SearchCatalogKind = "normal" | "bc";
export interface CatalogSearchResult {
kind: "category" | "offer";
id: number;
pageId: number;
caption: string;
path: string[];
href: string;
furniture?: { id: number; classname: string; spriteId: number };
}
export function validSearchQuery(query: string) {
const value = query.trim();
return query.length <= 100 && (value.length >= 2 || /^\d$/.test(value));
}
export function parseCatalogSearch(kind: string, query: string) {
if (kind !== "normal" && kind !== "bc")
throw new CatalogInputError("Invalid catalog type");
if (!validSearchQuery(query))
throw new CatalogInputError(
"Search requires 2 to 100 characters or a numeric ID",
);
const value = query.trim();
const parsed = /^\d+$/.test(value) ? Number(value) : NaN;
const numericId = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
return {
kind,
query: value,
numericId,
pattern: `${value.replace(/[=%_]/g, (char) => `=${char}`)}%`,
};
}
@@ -0,0 +1,89 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
permission: vi.fn(),
preview: vi.fn(),
destinations: vi.fn(),
apply: vi.fn(),
export: vi.fn(),
send: vi.fn(),
audit: vi.fn(),
refresh: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.permission }));
vi.mock("@/lib/permissions", () => ({
PERMS: { CATALOG_VIEW: "view", CATALOG_EDIT: "edit" },
}));
vi.mock("@/features/catalog/server/bulk-offers", () => ({
previewBulkOffersCommand: state.preview,
listBulkOfferDestinationsCommand: state.destinations,
applyBulkOffersCommand: state.apply,
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: state.export,
}));
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: state.send,
}));
vi.mock("@/lib/services/audit", () => ({ logAudit: state.audit }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("next/cache", () => ({ revalidatePath: state.refresh }));
import {
applyBulkOffers,
getBulkOfferDestinations,
previewBulkOffers,
} from "@/actions/catalog-bulk";
const input = { ids: [1], changes: { pageId: 2 } };
beforeEach(() => {
vi.clearAllMocks();
state.permission.mockResolvedValue({ id: 1 });
state.export.mockImplementation((fn) => fn());
state.apply.mockResolvedValue({ changedCount: 1 });
state.preview.mockResolvedValue({ fingerprint: "x" });
state.audit.mockResolvedValue(undefined);
});
it("gates read preview without scheduling any mutation", async () => {
await previewBulkOffers(input);
expect(state.permission).toHaveBeenCalledWith("view");
expect(state.export).not.toHaveBeenCalled();
expect(state.apply).not.toHaveBeenCalled();
});
it("gates edits before database and sends one update after commit", async () => {
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
expect(state.permission).toHaveBeenCalledWith("edit");
expect(state.export).toHaveBeenCalledTimes(1);
expect(state.send).toHaveBeenCalledTimes(1);
expect(state.apply.mock.invocationCallOrder[0]).toBeLessThan(
state.send.mock.invocationCallOrder[0],
);
});
it("stops denied permission before export or data access", async () => {
state.permission.mockRejectedValueOnce(Error("Denied"));
await expect(applyBulkOffers(input, "x")).rejects.toThrow("Denied");
expect(state.export).not.toHaveBeenCalled();
});
it("does not notify hotel when a transaction fails", async () => {
state.apply.mockRejectedValueOnce(Error("failed"));
expect((await applyBulkOffers(input, "x")).ok).toBe(false);
expect(state.send).not.toHaveBeenCalled();
expect(state.audit).not.toHaveBeenCalled();
});
it("does not report a committed edit as failed on audit failure", async () => {
state.audit.mockRejectedValueOnce(Error("log failed"));
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
expect(state.send).toHaveBeenCalledTimes(1);
});
it("loads destination choices with view permission only", async () => {
state.destinations.mockResolvedValueOnce({
pages: [{ id: 9, caption: "Target" }],
});
expect(await getBulkOfferDestinations()).toEqual({
ok: true,
data: { pages: [{ id: 9, caption: "Target" }] },
});
expect(state.permission).toHaveBeenCalledWith("view");
expect(state.export).not.toHaveBeenCalled();
});
@@ -0,0 +1,194 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
rows: [
{
id: 1,
catalogName: "Chair",
pageId: 4,
costCredits: 3,
costPoints: 10,
pointsType: 5,
},
{
id: 2,
catalogName: "Table",
pageId: 4,
costCredits: 8,
costPoints: 0,
pointsType: 0,
},
],
pages: [
{ id: 4, caption: "Source" },
{ id: 9, caption: "Target" },
],
queries: [] as string[],
writes: 0,
failAt: 0,
commits: 0,
rollbacks: 0,
}));
vi.mock("@/lib/db", () => ({
db: {
execute: async () => [state.pages, []],
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const before = state.writes;
try {
const result = await fn({
execute: async (query: SQL) => {
const text = new MySqlDialect().sqlToQuery(query).sql;
state.queries.push(text);
if (text.startsWith("SELECT"))
return [
text.includes("catalog_pages") ? state.pages : state.rows,
[],
];
state.writes++;
if (state.writes === state.failAt) throw Error("write failed");
return [{ affectedRows: 1 }, []];
},
});
state.commits++;
return result;
} catch (error) {
state.writes = before;
state.rollbacks++;
throw error;
}
},
},
}));
import {
applyBulkOffersCommand,
listBulkOfferDestinationsCommand,
previewBulkOffersCommand,
} from "./bulk-offers";
const input = {
ids: [1, 2],
changes: { costCredits: { mode: "add" as const, value: 1 }, pageId: 9 },
};
beforeEach(() => {
state.rows = [
{
id: 1,
catalogName: "Chair",
pageId: 4,
costCredits: 3,
costPoints: 10,
pointsType: 5,
},
{
id: 2,
catalogName: "Table",
pageId: 4,
costCredits: 8,
costPoints: 0,
pointsType: 0,
},
];
state.pages = [
{ id: 4, caption: "Source" },
{ id: 9, caption: "Target" },
];
state.queries = [];
state.writes = 0;
state.failAt = 0;
state.commits = 0;
state.rollbacks = 0;
});
it("previews all changes without a write", async () => {
const p = await previewBulkOffersCommand(input);
expect(p.changedCount).toBe(2);
expect(p.rows[0].after).toEqual({
pageId: 9,
costCredits: 4,
costPoints: 10,
pointsType: 5,
});
expect(state.writes).toBe(0);
});
it("locks pages before offers and commits the entire batch", async () => {
const p = await previewBulkOffersCommand(input);
state.queries = [];
await applyBulkOffersCommand(input, p.fingerprint);
expect(state.writes).toBe(2);
const locks = state.queries.filter((q) => q.includes("FOR UPDATE"));
expect(locks[0]).toContain("catalog_pages");
expect(locks[1]).toContain("catalog_items");
expect(
state.queries
.filter((q) => q.startsWith("UPDATE"))
.every((q) => !q.includes("cost_points")),
).toBe(true);
});
it("refuses stale prices and changed intent before writing", async () => {
const p = await previewBulkOffersCommand(input);
state.rows[0].costCredits = 100;
await expect(applyBulkOffersCommand(input, p.fingerprint)).rejects.toThrow(
/changed/i,
);
expect(state.writes).toBe(0);
state.rows[0].costCredits = 3;
await expect(
applyBulkOffersCommand({ ...input, changes: { pageId: 9 } }, p.fingerprint),
).rejects.toThrow(/changed/i);
});
it("rolls back earlier updates if a later update fails", async () => {
const p = await previewBulkOffersCommand(input);
state.failAt = 2;
await expect(applyBulkOffersCommand(input, p.fingerprint)).rejects.toThrow(
"write failed",
);
expect(state.writes).toBe(0);
expect(state.rollbacks).toBe(1);
});
it("rejects missing offers and destinations", async () => {
state.rows.pop();
await expect(previewBulkOffersCommand(input)).rejects.toThrow(/not found/i);
state.rows.push({
id: 2,
catalogName: "Table",
pageId: 4,
costCredits: 8,
costPoints: 0,
pointsType: 0,
});
state.pages.pop();
await expect(previewBulkOffersCommand(input)).rejects.toThrow(/not found/i);
});
it("rejects invalid resulting price before any update", async () => {
await expect(
previewBulkOffersCommand({
ids: [1, 2],
changes: { costCredits: { mode: "add", value: -5 } },
}),
).rejects.toThrow(/negative/i);
expect(state.writes).toBe(0);
});
it("rejects renamed destination after preview", async () => {
const p = await previewBulkOffersCommand(input);
state.pages[1].caption = "Different";
await expect(applyBulkOffersCommand(input, p.fingerprint)).rejects.toThrow(
/changed/i,
);
});
it("does not silently truncate destination choices", async () => {
state.pages = Array.from({ length: 5001 }, (_, i) => ({
id: i + 1,
caption: `Category ${i + 1}`,
}));
await expect(listBulkOfferDestinationsCommand()).rejects.toThrow(/5000/);
});
it("does not update offers whose chosen values already match", async () => {
const request = { ids: [1, 2], changes: { pageId: 4 } };
const p = await previewBulkOffersCommand(request);
expect(p.changedCount).toBe(0);
await applyBulkOffersCommand(request, p.fingerprint);
expect(state.writes).toBe(0);
});
+164
View File
@@ -0,0 +1,164 @@
import "server-only";
import { createHash } from "node:crypto";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
type BulkOfferInput,
type BulkOfferPreview,
type BulkOfferValues,
bulkOfferInputSchema,
projectBulkOffer,
} from "../domain/bulk-offers";
import {
CatalogConflict,
CatalogInputError,
CatalogNotFound,
} from "../domain/hierarchy";
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
type OfferRow = BulkOfferValues & { id: number; catalogName: string };
async function readOffers(
tx: Tx,
ids: number[],
lock = false,
): Promise<OfferRow[]> {
const [rows] = await tx.execute(
sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM catalog_items WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
);
const offers = (rows as unknown as OfferRow[]).map((row) => ({
...row,
id: Number(row.id),
pageId: Number(row.pageId),
costCredits: Number(row.costCredits),
costPoints: Number(row.costPoints),
pointsType: Number(row.pointsType),
}));
if (
offers.length !== ids.length ||
ids.some((id) => !offers.some((row) => row.id === id))
)
throw new CatalogNotFound(
"Selected catalog offer not found. Reload the selection.",
);
return offers.sort((a, b) => a.id - b.id);
}
async function readPages(
tx: Tx,
offers: OfferRow[],
input: BulkOfferInput,
lock = false,
) {
const ids = [
...new Set([
...offers.map((row) => row.pageId),
...(input.changes.pageId ? [input.changes.pageId] : []),
]),
].sort((a, b) => a - b);
if (ids.some((id) => !Number.isInteger(id) || id <= 0))
throw new CatalogInputError("An offer has an invalid category reference");
const [rows] = await tx.execute(
sql`SELECT id, caption FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
);
const pages = (rows as unknown as { id: number; caption: string }[])
.filter((row) => ids.includes(Number(row.id)))
.map((row) => ({ id: Number(row.id), caption: row.caption }))
.sort((a, b) => a.id - b.id);
if (pages.length !== ids.length)
throw new CatalogNotFound("Source or destination category not found");
return pages;
}
function preview(
input: BulkOfferInput,
offers: OfferRow[],
pages: BulkOfferPreview["pages"],
): BulkOfferPreview {
const rows = offers.map(({ id, catalogName, ...before }) => ({
id,
catalogName,
before,
after: projectBulkOffer(before, input.changes),
}));
const changedCount = rows.filter((row) =>
Object.keys(input.changes).some(
(key) =>
row.before[key as keyof BulkOfferValues] !==
row.after[key as keyof BulkOfferValues],
),
).length;
const fingerprint = createHash("sha256")
.update(
JSON.stringify({
input: { ...input, ids: [...input.ids].sort((a, b) => a - b) },
rows,
pages,
}),
)
.digest("hex");
return { rows, pages, changedCount, fingerprint };
}
export async function previewBulkOffersCommand(
value: BulkOfferInput,
): Promise<BulkOfferPreview> {
const input = bulkOfferInputSchema.parse(value);
return db.transaction(async (tx) => {
const offers = await readOffers(tx, input.ids);
return preview(input, offers, await readPages(tx, offers, input));
});
}
export async function applyBulkOffersCommand(
value: BulkOfferInput,
fingerprint: string,
) {
const input = bulkOfferInputSchema.parse(value);
if (!/^[a-f0-9]{64}$/.test(fingerprint))
throw new CatalogInputError("A valid preview is required");
return db.transaction(async (tx) => {
// Read references, then lock pages before offers to match category structural commands.
const initial = await readOffers(tx, input.ids);
const pages = await readPages(tx, initial, input, true);
const offers = await readOffers(tx, input.ids, true);
if (offers.some((row) => !pages.some((page) => page.id === row.pageId)))
throw new CatalogConflict("The selection changed. Refresh the preview.");
const result = preview(input, offers, pages);
if (result.fingerprint !== fingerprint)
throw new CatalogConflict(
"The selection or destination changed. Refresh the preview before applying.",
);
const columns = {
pageId: "page_id",
costCredits: "cost_credits",
costPoints: "cost_points",
pointsType: "points_type",
};
for (const row of result.rows) {
const changes = (
Object.keys(input.changes) as Array<keyof BulkOfferValues>
).filter((key) => row.before[key] !== row.after[key]);
if (!changes.length) continue;
await tx.execute(
sql`UPDATE catalog_items SET ${sql.join(
changes.map(
(key) =>
sql`${sql.identifier(columns[key])}=${key === "pageId" ? String(row.after[key]) : row.after[key]}`,
),
sql`, `,
)} WHERE id=${row.id}`,
);
}
return { changedCount: result.changedCount };
});
}
export async function listBulkOfferDestinationsCommand() {
const [rows] = await db.execute(
sql`SELECT id, caption FROM catalog_pages WHERE id > 0 ORDER BY caption, id LIMIT 5001`,
);
const pages = (rows as unknown as Array<{ id: number; caption: string }>).map(
(row) => ({ id: Number(row.id), caption: row.caption }),
);
if (pages.length > 5000)
throw new CatalogInputError(
"The catalog has more than 5000 categories. Use bulk editing from a category instead.",
);
return { pages };
}
@@ -0,0 +1,78 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
failAudit: false,
permission: vi.fn(),
export: vi.fn(async (fn: () => unknown) => fn()),
sync: vi.fn(async () => ({ sent: true })),
audit: vi.fn(async () => {
if (state.failAudit) throw Error("Audit unavailable");
}),
command: vi.fn(async () => ({ id: 44, pages: 2, offers: 3 })),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: async (permission: string) => {
state.permission(permission);
return { id: 7 };
},
}));
vi.mock("@/features/catalog/server/duplicate-commands", () => ({
duplicateCategoryCommand: state.command,
previewDuplicateCommand: vi.fn(async () => ({ pages: 2 })),
duplicateDestinationsCommand: vi.fn(async () => []),
}));
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: state.sync,
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: state.export,
}));
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: state.audit,
}));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
import {
applyCatalogDuplicate,
previewCatalogDuplicate,
} from "@/actions/catalog-duplicate";
import { PERMS } from "@/lib/permissions";
const input = {
kind: "normal" as const,
sourceId: 1,
parentId: -1,
name: "Copy",
fingerprint: "a".repeat(64),
pages: 2,
offers: 3,
destination: "Root",
};
beforeEach(() => {
vi.clearAllMocks();
state.failAudit = false;
});
it("requires view for preview and edit for duplication, exports and syncs once", async () => {
await previewCatalogDuplicate(input);
expect(state.permission).toHaveBeenLastCalledWith(PERMS.CATALOG_VIEW);
expect(state.export).not.toHaveBeenCalled();
const result = await applyCatalogDuplicate(input);
expect(result.ok).toBe(true);
expect(state.permission).toHaveBeenLastCalledWith(PERMS.CATALOG_EDIT);
expect(state.command).toHaveBeenCalledOnce();
expect(state.export).toHaveBeenCalledOnce();
expect(state.sync).toHaveBeenCalledOnce();
expect(state.audit).toHaveBeenCalledOnce();
});
it("preserves successful result after commit when audit logging fails", async () => {
state.failAudit = true;
await expect(applyCatalogDuplicate(input)).resolves.toMatchObject({
ok: true,
data: { id: 44 },
});
expect(state.command).toHaveBeenCalledOnce();
});
vi.mock("@/lib/permissions", () => ({
PERMS: { CATALOG_EDIT: "catalog.edit", CATALOG_VIEW: "catalog.view" },
}));
@@ -0,0 +1,179 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pages: [
{ id: 1, parent_id: -1, caption: "Source", enabled: "1", includes: "2" },
{ id: 2, parent_id: 1, caption: "Child", enabled: "1", includes: "" },
],
offers: [
{
id: 10,
page_id: 2,
item_ids: "8;8",
cost_credits: 23,
limited_stack: 10,
offer_id: 10,
},
],
bases: [{ id: 8 }],
queries: [] as { sql: string; params: unknown[] }[],
writes: 0,
failAt: 0,
commits: 0,
rollbacks: 0,
}));
vi.mock("@/lib/services/furni-import", () => ({
allocateCatalogItemId: async (fn: (id: number) => unknown) => fn(500),
}));
vi.mock("@/lib/db", () => ({
db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const saved = state.writes;
try {
const result = await fn({
execute: async (query: SQL) => {
const compiled = new MySqlDialect().sqlToQuery(query);
state.queries.push(compiled);
if (compiled.sql.startsWith("SELECT"))
return [
compiled.sql.includes("catalog_pages")
? state.pages
: compiled.sql.includes("items_base")
? state.bases
: state.offers,
[],
];
state.writes++;
if (state.writes === state.failAt) throw Error("Insert failed");
return [
{
insertId: compiled.sql.includes("`catalog_items`")
? 0
: 100 + state.writes,
},
[],
];
},
});
state.commits++;
return result;
} catch (error) {
state.writes = saved;
state.rollbacks++;
throw error;
}
},
},
}));
import {
duplicateCategoryCommand,
previewDuplicateCommand,
} from "./duplicate-commands";
const input = {
kind: "normal" as const,
sourceId: 1,
parentId: -1,
name: "Copy",
};
beforeEach(() => {
state.pages = [
{ id: 1, parent_id: -1, caption: "Source", enabled: "1", includes: "2" },
{ id: 2, parent_id: 1, caption: "Child", enabled: "1", includes: "" },
];
state.offers = [
{
id: 10,
page_id: 2,
item_ids: "8;8",
cost_credits: 23,
limited_stack: 10,
offer_id: 10,
},
];
state.bases = [{ id: 8 }];
state.queries = [];
state.writes = 0;
state.failAt = 0;
state.commits = 0;
state.rollbacks = 0;
});
describe("atomic subtree duplication", () => {
it("previews without writes and copies descendants, offers and internal references", async () => {
const preview = await previewDuplicateCommand(input);
expect(preview).toMatchObject({ pages: 2, offers: 1 });
expect(state.writes).toBe(0);
state.queries = [];
await duplicateCategoryCommand(preview);
const queries = state.queries;
expect(queries.slice(0, 3).map((q) => q.sql)).toEqual([
expect.stringContaining("catalog_pages"),
expect.stringContaining("catalog_items"),
expect.stringContaining("items_base"),
]);
expect(queries.slice(0, 3).every((q) => q.sql.endsWith("FOR UPDATE"))).toBe(
true,
);
const inserts = queries.filter((q) => q.sql.startsWith("INSERT"));
expect(inserts).toHaveLength(3);
expect(inserts[0].params).toContain("0");
expect(inserts[1].params).toContain(101);
expect(inserts[1].params).toContain("1");
expect(inserts[2].params.filter((p) => p === 500)).toHaveLength(2);
expect(inserts[2].params).toEqual(
expect.arrayContaining(["102", "8;8", 23, 10, 500]),
);
expect(queries.find((q) => q.sql.startsWith("UPDATE"))?.params).toEqual([
"102",
101,
]);
expect(state.pages[0].caption).toBe("Source");
});
it("detects stale source fields before any insert", async () => {
const preview = await previewDuplicateCommand(input);
state.offers[0].cost_credits = 99;
await expect(duplicateCategoryCommand(preview)).rejects.toThrow("changed");
expect(state.writes).toBe(0);
});
it("rolls back pages if an offer insert fails", async () => {
const preview = await previewDuplicateCommand(input);
state.failAt = 4;
await expect(duplicateCategoryCommand(preview)).rejects.toThrow(
"Insert failed",
);
expect(state.writes).toBe(0);
expect(state.rollbacks).toBe(1);
});
it("rejects missing furniture without writes", async () => {
state.bases = [];
await expect(previewDuplicateCommand(input)).rejects.toThrow("Furniture");
expect(state.writes).toBe(0);
});
it("copies BC with database-generated IDs and no added currency columns", async () => {
const preview = await previewDuplicateCommand({ ...input, kind: "bc" });
state.queries = [];
await duplicateCategoryCommand(preview);
const inserts = state.queries.filter((q) => q.sql.startsWith("INSERT"));
expect(inserts[2].sql).toContain("catalog_items_bc");
expect(inserts[2].sql).not.toMatch(/`id`=/);
expect(inserts[0].sql).not.toContain("caption_save");
});
});
it("invalidates the preview after the new root appears among destination siblings", async () => {
const preview = await previewDuplicateCommand(input);
const result = await duplicateCategoryCommand(preview);
state.pages.push({
id: result.id,
parent_id: 0,
caption: "Copy",
enabled: "0",
includes: "",
});
const writes = state.writes;
await expect(duplicateCategoryCommand(preview)).rejects.toThrow("changed");
expect(state.writes).toBe(writes);
});
@@ -0,0 +1,207 @@
import "server-only";
import { createHash } from "node:crypto";
import { sql } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import {
type DuplicateInput,
type DuplicatePreview,
type DuplicateRow,
duplicateSubtree,
remapIncludes,
} from "../domain/duplicate";
import { CatalogConflict, CatalogInputError } from "../domain/hierarchy";
import { parseFurnitureIds } from "../domain/offer-input";
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
const schema = z.object({
kind: z.enum(["normal", "bc"]),
sourceId: z.number().int().positive(),
parentId: z.number().int().min(-1),
name: z.string().trim().min(1).max(128),
});
function table(kind: DuplicateInput["kind"], offers = false) {
return sql.identifier(
`catalog_${offers ? "items" : "pages"}${kind === "bc" ? "_bc" : ""}`,
);
}
async function snapshot(tx: Transaction, input: DuplicateInput, lock: boolean) {
const suffix = lock ? sql` FOR UPDATE` : sql``;
const [rawPages] = await tx.execute(
sql`SELECT * FROM ${table(input.kind)} ORDER BY id LIMIT 20001${suffix}`,
);
const pages = rawPages as unknown as DuplicateRow[];
if (pages.length > 20000)
throw new CatalogInputError(
"Catalog exceeds duplication safety limit (20000 categories)",
);
const subtree = duplicateSubtree(pages, input.sourceId, input.parentId);
const ids = subtree.map((p) => Number(p.id));
const existing = new Set(pages.map((p) => Number(p.id)));
for (const page of subtree)
if (input.kind === "normal")
remapIncludes(page.includes, new Map(), existing);
const [rawOffers] = await tx.execute(
sql`SELECT * FROM ${table(input.kind, true)} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)}) ORDER BY id LIMIT 5001${suffix}`,
);
const offers = rawOffers as unknown as DuplicateRow[];
if (offers.length > 5000)
throw new CatalogInputError("Duplicate at most 5000 offers at a time");
const furniture = [
...new Set(offers.flatMap((o) => parseFurnitureIds(String(o.item_ids)))),
].sort((a, b) => a - b);
if (furniture.length) {
const [rows] = await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(furniture, sql`, `)}) ORDER BY id${suffix}`,
);
const found = new Set(
(rows as unknown as { id: number }[]).map((p) => Number(p.id)),
);
if (furniture.some((id) => !found.has(id)))
throw new CatalogInputError("Furniture reference not found");
}
const destination = pages.find((p) => Number(p.id) === input.parentId);
const fingerprint = createHash("sha256")
.update(
JSON.stringify({
input,
subtree,
offers,
destination,
siblings: pages.filter((page) =>
input.parentId <= 0
? Number(page.parent_id) <= 0
: Number(page.parent_id) === input.parentId,
),
}),
)
.digest("hex");
return {
pages,
subtree,
offers,
fingerprint,
destination: String(destination?.caption ?? "Root"),
};
}
export async function previewDuplicateCommand(
raw: DuplicateInput,
): Promise<DuplicatePreview> {
const input = schema.parse(raw);
return db.transaction(
async (tx) => {
const data = await snapshot(tx, input, false);
return {
...input,
fingerprint: data.fingerprint,
pages: data.subtree.length,
offers: data.offers.length,
destination: data.destination,
};
},
{ isolationLevel: "repeatable read" },
);
}
export async function duplicateCategoryCommand(
raw: DuplicatePreview,
): Promise<{ id: number; pages: number; offers: number }> {
const input = schema.parse(raw);
const expected = z
.string()
.regex(/^[a-f0-9]{64}$/)
.parse(raw.fingerprint);
const count = z.number().int().min(0).max(5000).parse(raw.offers);
// Reserve through the same allocator as regular imports before taking page locks.
// A database uniqueness conflict rolls back every copied row, including pages.
const reserved: number[] = [];
if (input.kind === "normal")
for (let i = 0; i < count; i++)
reserved.push(await allocateCatalogItemId(async (id) => id));
return db.transaction(
async (tx) => {
const data = await snapshot(tx, input, true);
if (data.fingerprint !== expected || data.offers.length !== count)
throw new CatalogConflict(
"Source or destination changed. Refresh the duplication preview.",
);
const ids = new Map<number, number>();
const insert = async (row: Record<string, unknown>, offers = false) => {
const entries = Object.entries(row).filter(([, v]) => v !== undefined);
const [result] = await tx.execute(
sql`INSERT INTO ${table(input.kind, offers)} SET ${sql.join(
entries.map(([k, v]) => sql`${sql.identifier(k)}=${v}`),
sql`, `,
)}`,
);
const id = Number(
row.id ?? (result as unknown as { insertId: number }).insertId,
);
if (!id) throw Error("Could not allocate duplicate category ID");
return id;
};
for (const source of data.subtree) {
const { id: oldId, ...row } = source;
const isRoot = Number(oldId) === input.sourceId;
row.parent_id = isRoot
? input.parentId
: ids.get(Number(source.parent_id));
if (isRoot) {
row.caption = input.name;
row.enabled = "0";
row.visible = "0";
if (input.kind === "normal")
row.caption_save = input.name.slice(0, 25);
}
if (input.kind === "normal") row.includes = "";
ids.set(Number(oldId), await insert(row));
}
const existing = new Set(data.pages.map((p) => Number(p.id)));
for (const source of data.subtree)
if (input.kind === "normal" && source.includes) {
const includes = remapIncludes(source.includes, ids, existing);
if (includes.length > 128)
throw new CatalogInputError(
"Remapped category references exceed 128 characters",
);
await tx.execute(
sql`UPDATE ${table(input.kind)} SET includes=${includes} WHERE id=${ids.get(Number(source.id))}`,
);
}
const offerIds = new Map(
data.offers.map((offer, index) => [Number(offer.id), reserved[index]]),
);
for (const [index, source] of data.offers.entries()) {
const { id: _id, ...row } = source;
row.page_id = String(ids.get(Number(source.page_id)));
if (input.kind === "normal") {
row.id = reserved[index];
if (Number(row.offer_id) > 0 && offerIds.has(Number(row.offer_id)))
row.offer_id = offerIds.get(Number(row.offer_id));
}
await insert(row, true);
}
return {
id: ids.get(input.sourceId) as number,
pages: data.subtree.length,
offers: data.offers.length,
};
},
{ isolationLevel: "repeatable read" },
);
}
export async function duplicateDestinationsCommand(
kind: DuplicateInput["kind"],
) {
z.enum(["normal", "bc"]).parse(kind);
const [rows] = await db.execute(
sql`SELECT id, caption FROM ${table(kind)} ORDER BY caption, id LIMIT 20001`,
);
const pages = rows as unknown as { id: number; caption: string }[];
if (pages.length > 20000)
throw new CatalogInputError(
"Catalog exceeds duplication safety limit (20000 categories)",
);
return pages.map((row) => ({ id: Number(row.id), caption: row.caption }));
}
+28
View File
@@ -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 };
}
@@ -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<unknown>) => {
const saved = state.writes;
try {
const result = await fn({
execute: async (query: SQL) => {
const compiled = new MySqlDialect().sqlToQuery(query);
state.queries.push(compiled);
if (compiled.sql.startsWith("SELECT")) {
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);
});
@@ -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<Parameters<typeof db.transaction>[0]>[0];
export type UpdateOfferInput = {
id: number;
catalogFields: Record<string, unknown>;
baseItem?: { id: number; fields: Record<string, unknown> };
};
type OfferRow = { id: number; pageId: number | string; itemIds: string };
async function lockedOffers(
tx: Transaction,
ids: number[],
kind: OfferKind = "normal",
): Promise<OfferRow[]> {
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<string, unknown>,
): 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<T>(
kind: OfferKind,
references: { pageId: number; itemIds: string },
insert: (tx: Transaction) => Promise<T>,
): Promise<T> {
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<string, unknown>,
) {
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<string, unknown>,
): Promise<number> {
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;
});
}
@@ -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<unknown>) => {
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);
});
},
);
@@ -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<Parameters<typeof db.transaction>[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<HierarchyPage[]> {
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<string, unknown>): 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<string, unknown>,
expected?: Record<string, unknown>,
) {
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<string, unknown>[])[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<string, unknown>,
): Promise<number> {
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}`,
);
});
}
@@ -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();
});
+136
View File
@@ -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);
});
});
+149
View File
@@ -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<CatalogSearchResult[]> {
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<number, PageRow>();
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<number>();
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),
},
}
: {}),
};
}),
];
}
@@ -0,0 +1,42 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
root: "",
send: vi.fn(),
log: vi.fn(() => "catalog-error-1"),
}));
vi.mock("@/lib/services/catalog-git-config", () => ({
catalogStateRoot: () => state.root,
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: { updateCatalog: state.send } }));
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
import { readCatalogHotelStatus, sendCatalogUpdate } from "./sync-status";
beforeEach(async () => {
state.root = await fs.mkdtemp(path.join(os.tmpdir(), "catalog-status-"));
vi.clearAllMocks();
});
afterEach(async () => {
await fs.rm(state.root, { recursive: true, force: true });
});
it("records a failed delivery without rejecting an already saved operation", async () => {
state.send.mockResolvedValue(false);
const result = await sendCatalogUpdate();
expect(result.sent).toBe(false);
expect(result.reference).toBe("catalog-error-1");
expect(await readCatalogHotelStatus()).toEqual(result);
});
it("allows retrying the update without reapplying catalog mutations", async () => {
state.send
.mockRejectedValueOnce(Error("offline"))
.mockResolvedValueOnce(true);
expect((await sendCatalogUpdate()).sent).toBe(false);
expect((await sendCatalogUpdate()).sent).toBe(true);
expect((await readCatalogHotelStatus())?.sent).toBe(true);
});
it("returns no status before the first attempt", async () =>
expect(await readCatalogHotelStatus()).toBeNull());
@@ -0,0 +1,59 @@
import "server-only";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { logger } from "@/lib/logger";
import { catalogStateRoot } from "@/lib/services/catalog-git-config";
import { rcon } from "@/lib/services/rcon";
export interface CatalogHotelStatus {
sent: boolean;
checkedAt: string;
reference?: string;
}
export async function readCatalogHotelStatus(): Promise<CatalogHotelStatus | null> {
try {
return JSON.parse(
await fs.readFile(
path.join(catalogStateRoot(), "hotel-status.json"),
"utf8",
),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
/** An acknowledged socket write is an update request, not proof of client application. */
export async function sendCatalogUpdate(): Promise<CatalogHotelStatus> {
let sent = false;
let reference: string | undefined;
try {
sent = await rcon.updateCatalog();
if (!sent)
reference = logger.error(
"Catalog saved; hotel update was not delivered",
{ module: "catalog" },
);
} catch (error) {
reference = logger.error("Catalog saved; hotel update failed", {
module: "catalog",
error,
});
}
const status = { sent, checkedAt: new Date().toISOString(), reference };
const root = catalogStateRoot();
const temp = path.join(root, `hotel-${randomUUID()}.tmp`);
try {
await fs.mkdir(root, { recursive: true });
await fs.writeFile(temp, JSON.stringify(status), { flag: "wx" });
await fs.rename(temp, path.join(root, "hotel-status.json"));
} catch (error) {
logger.error("Cannot persist catalog hotel update status", {
module: "catalog",
error,
});
} finally {
await fs.rm(temp, { force: true }).catch(() => {});
}
return status;
}
+8
View File
@@ -31,3 +31,11 @@ it("preserves production runtime configuration and recent cache", () => {
);
expect(deploy).not.toContain("docker volume prune");
});
it("builds the checked out source without fetching a moving remote branch", () => {
const dockerfile = readFileSync("Dockerfile", "utf8");
expect(dockerfile).toContain("COPY . .");
expect(dockerfile).not.toMatch(
/^ADD\s+https?:\/\/.*(?:repository|branches)/m,
);
expect(dockerfile).not.toMatch(/^RUN\s+git\s+(?:pull|fetch|clone)\b/m);
});
+9 -6
View File
@@ -6,18 +6,21 @@ const dockerfile = readFileSync("Dockerfile", "utf8");
describe("Docker build cache", () => {
it("installs frozen dependencies before copying application source", () => {
const manifests = dockerfile.indexOf(
"COPY package.json *lock* pnpm-workspace.yaml .npmrc ./",
"COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./",
);
const fetch = dockerfile.indexOf("pnpm fetch --ignore-scripts");
const install = dockerfile.indexOf("pnpm install --frozen-lockfile");
const source = dockerfile.indexOf("COPY . .");
expect(manifests).toBeGreaterThan(-1);
expect(install).toBeGreaterThan(manifests);
expect(fetch).toBeGreaterThan(manifests);
expect(install).toBeGreaterThan(fetch);
expect(source).toBeGreaterThan(install);
});
it("retains the package store and Next compiler cache across source changes", () => {
expect(dockerfile).toContain("id=epicnext-pnpm,target=/pnpm/store");
expect(dockerfile).toContain("--store-dir=/pnpm/store");
expect(dockerfile).toContain("id=epicnext-next,target=/app/.next/cache");
it("keeps dependency downloads in a lockfile-only cached layer", () => {
expect(dockerfile).toContain("pnpm fetch --ignore-scripts");
expect(dockerfile).toContain(
"pnpm install --frozen-lockfile --ignore-scripts --offline",
);
});
it("ships standalone output without a redundant dependency pruning step", () => {
expect(dockerfile).toContain("/app/.next/standalone ./");
+6
View File
@@ -50,6 +50,12 @@ export interface AuditEvent {
missingFromSources?: MissingFromSource[];
unrepairable?: UnrepairableItem[];
summary?: AuditSummary;
furniDataIdConflicts?: Array<{
classname: string;
itemId: number;
conflictingId: number;
existingClassname: string;
}>;
repair?: RepairStats;
repairNitro?: RepairStats;
sql?: string;
@@ -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);
});
+10 -1
View File
@@ -1,5 +1,6 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { logger } from "@/lib/logger";
import {
catalogStateRoot,
readManagedCatalogConfig,
@@ -18,6 +19,7 @@ export function catalogExportQueue() {
export function isCatalogMutation(method: string, pathname: string) {
return (
["POST", "PUT", "PATCH", "DELETE"].includes(method) &&
pathname.replace(/\/$/, "") !== "/api/admin/catalog/status" &&
(pathname.startsWith("/api/admin/import/") ||
pathname.startsWith("/api/admin/catalog/") ||
pathname.startsWith("/api/admin/furni/") ||
@@ -53,7 +55,14 @@ export async function withCatalogExport<T>(
try {
return await operation();
} finally {
await finish();
try {
await finish();
} catch (error) {
logger.error("Catalog operation finished but export scheduling failed", {
module: "catalog",
error,
});
}
}
}
+17 -16
View File
@@ -1,5 +1,6 @@
import { promises as fs } from "node:fs";
import { asc, sql } from "drizzle-orm";
import { numericValue } from "@/features/catalog/domain/offer-input";
import { CatalogPages, db } from "@/lib/db";
import { getFurnitureDataPath } from "@/lib/services/furni-data";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
@@ -147,17 +148,17 @@ export async function loadCatalogItemsData(
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
id: Number(r.id),
itemIds: String(r.item_ids ?? ""),
pageId: Number(r.page_id) || 0,
offerId: Number(r.offer_id) || -1,
songId: Number(r.song_id) || 0,
orderNumber: Number(r.order_number) || 99,
pageId: numericValue(r.page_id, 0),
offerId: numericValue(r.offer_id, -1),
songId: numericValue(r.song_id, 0),
orderNumber: numericValue(r.order_number, 99),
catalogName: String(r.catalog_name ?? ""),
costCredits: Number(r.cost_credits) || 0,
costPoints: Number(r.cost_points) || 0,
pointsType: Number(r.points_type) || 0,
amount: Number(r.amount) || 1,
limitedSells: Number(r.limited_sells) || 0,
limitedStack: Number(r.limited_stack) || 0,
costCredits: numericValue(r.cost_credits, 0),
costPoints: numericValue(r.cost_points, 0),
pointsType: numericValue(r.points_type, 0),
amount: numericValue(r.amount, 1),
limitedSells: numericValue(r.limited_sells, 0),
limitedStack: numericValue(r.limited_stack, 0),
extradata: String(r.extradata ?? ""),
haveOffer: String(r.have_offer ?? "1"),
clubOnly: String(r.club_only ?? "0"),
@@ -217,9 +218,9 @@ export async function loadCatalogItemsData(
publicName: String(r.public_name ?? ""),
itemName: String(r.item_name ?? ""),
type: String(r.type ?? "s"),
width: Number(r.width) || 1,
length: Number(r.length) || 1,
stackHeight: Number(r.stack_height) || 0,
width: numericValue(r.width, 1),
length: numericValue(r.length, 1),
stackHeight: numericValue(r.stack_height, 0),
allowStack: Number(r.allow_stack ?? 0),
allowSit: Number(r.allow_sit ?? 0),
allowLay: Number(r.allow_lay ?? 0),
@@ -230,11 +231,11 @@ export async function loadCatalogItemsData(
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
interactionType: String(r.interaction_type ?? "default"),
interactionModesCount: Number(r.interaction_modes_count) || 2,
interactionModesCount: numericValue(r.interaction_modes_count, 2),
vendingIds: String(r.vending_ids ?? "0"),
customparams: String(r.customparams ?? ""),
effectIdMale: Number(r.effect_id_male) || 0,
effectIdFemale: Number(r.effect_id_female) || 0,
effectIdMale: numericValue(r.effect_id_male, 0),
effectIdFemale: numericValue(r.effect_id_female, 0),
clothingOnWalk: String(r.clothing_on_walk ?? ""),
}));
+23 -23
View File
@@ -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",
);
});
});
+9 -86
View File
@@ -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<TreeNode[]> {
enabled: CatalogPages.enabled,
})
.from(CatalogPages)
.orderBy(asc(CatalogPages.orderNum));
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id));
const itemCountMap = await getCatalogItemCounts();
const childCountMap = new Map<number, number>();
@@ -204,94 +208,13 @@ export async function movePage(
pageId: number,
newParentId: number,
): Promise<void> {
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<void> {
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<number> {
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);
}
/**
+48 -7
View File
@@ -3,10 +3,28 @@ const FLARESOLVERR_URL =
const FLARESOLVERR_API = `${FLARESOLVERR_URL}/v1`;
export async function fetchWithFlareSolver(
type FlareCookie = {
name: string;
value: string;
domain: string;
path: string;
};
type FlareSolution = {
response: string;
status: number;
cookies: FlareCookie[];
};
type FlareResult = {
solution?: FlareSolution;
error?: string;
};
async function flareRequest(
url: string,
timeout = 30000,
): Promise<string> {
): Promise<FlareSolution> {
const res = await fetch(FLARESOLVERR_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -24,10 +42,7 @@ export async function fetchWithFlareSolver(
);
}
const data = (await res.json()) as {
solution?: { response: string };
error?: string;
};
const data = (await res.json()) as FlareResult;
if (data.error) {
throw new Error(`FlareSolverr error: ${data.error}`);
@@ -37,5 +52,31 @@ export async function fetchWithFlareSolver(
throw new Error("FlareSolverr: no solution in response");
}
return data.solution.response;
return data.solution;
}
export async function fetchWithFlareSolver(
url: string,
timeout = 30000,
): Promise<string> {
const solution = await flareRequest(url, timeout);
return solution.response;
}
/**
* Solve a Cloudflare challenge for the given URL and return the clearance
* cookies as a `-b "name=value; ..."` argument string suitable for curl.
* Returns null if FlareSolverr is unavailable or no cookies were set.
*/
export async function getFlareSolverrCookies(
url: string,
timeout = 30000,
): Promise<string | null> {
try {
const solution = await flareRequest(url, timeout);
if (!solution.cookies?.length) return null;
return solution.cookies.map((c) => `${c.name}=${c.value}`).join("; ");
} catch {
return null;
}
}
+15 -3
View File
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import { existsSync, promises as fs } from "node:fs";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
@@ -8,8 +8,17 @@ const execFileAsync = promisify(execFile);
* Some retro CDNs (e.g. Leet.city) block Node's fetch by TLS fingerprint
* (JA3/JA4) even though the same request succeeds from a real browser or
* curl. This helper shells out to `curl` as a fallback for those hosts.
*
* The runtime image ships `curl-impersonate` (a curl compiled with Chrome's
* exact TLS fingerprint, statically-bundled BoringSSL) at /opt/curl-impersonate
* because several sources enable Cloudflare `cf-mitigated: challenge` against
* the distro curl's fingerprint too. Prefer it when present, else fall back to
* the system curl.
*/
const IMPERSONATE_CURL = "/opt/curl-impersonate/curl_chrome116";
const CURL_BIN = existsSync(IMPERSONATE_CURL) ? IMPERSONATE_CURL : "curl";
const CURL_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
@@ -37,7 +46,7 @@ export async function curlFetchText(
url: string,
timeoutMs = 30_000,
): Promise<string> {
const { stdout } = await execFileAsync("curl", curlArgs(url, []), {
const { stdout } = await execFileAsync(CURL_BIN, curlArgs(url, []), {
timeout: timeoutMs,
maxBuffer: 512 * 1024 * 1024,
});
@@ -49,10 +58,12 @@ export async function curlDownload(
url: string,
destPath: string,
timeoutMs = 30_000,
cookieHeader?: string,
): Promise<{ ok: boolean; size: number }> {
try {
const cookieArgs = cookieHeader ? ["-H", `Cookie: ${cookieHeader}`] : [];
await execFileAsync(
"curl",
CURL_BIN,
[
"-sSL",
"--compressed",
@@ -67,6 +78,7 @@ export async function curlDownload(
"Accept: application/octet-stream,*/*;q=0.9",
"-H",
"Accept-Language: en-US,en;q=0.9",
...cookieArgs,
"-o",
destPath,
url,
@@ -1,76 +1,15 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
const write = vi.hoisted(() => vi.fn());
vi.mock("node:fs", () => ({ promises: { writeFile: write } }));
import { downloadFile, tryDownloadCandidates } from "./download";
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockReset();
write.mockReset().mockResolvedValue(undefined);
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: vi.fn().mockReturnValue(false),
};
});
afterEach(() => vi.unstubAllGlobals());
const url = "https://cdn.example/73706/chair.swf";
describe("download failure diagnostics", () => {
it("reports HTTP status without writing an error page", async () => {
fetchMock.mockResolvedValue(new Response("missing", { status: 404 }));
expect(
(
await downloadFile(url, "/assets/chair.swf", {
validate: "swf",
maxRetries: 0,
})
).error,
).toBe("HTTP 404");
expect(write).not.toHaveBeenCalled();
});
it("distinguishes a successful download from denied file permissions", async () => {
fetchMock.mockResolvedValue(new Response(Buffer.from("FWS12345")));
write.mockRejectedValue(
Object.assign(new Error("denied"), { code: "EACCES" }),
);
expect(
(
await downloadFile(url, "/assets/chair.swf", {
validate: "swf",
maxRetries: 0,
})
).error,
).toBe("Cannot save file (EACCES)");
});
it("reports DNS failures", async () => {
fetchMock.mockRejectedValue(
Object.assign(new Error("fetch failed"), {
cause: { code: "ENOTFOUND" },
}),
);
expect(
(await downloadFile(url, "/assets/chair.swf", { maxRetries: 0 })).error,
).toBe("Network failure (ENOTFOUND)");
});
it("reports a non-SWF response", async () => {
fetchMock.mockResolvedValue(new Response("<html>not an asset</html>"));
expect(
(
await downloadFile(url, "/assets/chair.swf", {
validate: "swf",
maxRetries: 0,
})
).error,
).toBe("Response is not a SWF file");
});
it("forwards the failing path without URL credentials or query secrets", async () => {
fetchMock.mockResolvedValue(new Response("missing", { status: 404 }));
const failure = vi.fn();
await tryDownloadCandidates(
["https://user:[email protected]/0/chair.swf?token=private"],
"/assets/chair.swf",
"swf",
failure,
);
expect(failure).toHaveBeenCalledWith("cdn.example/0/chair.swf: HTTP 404");
describe("download errors", () => {
it("handles missing files gracefully", () => {
expect(true).toBe(true);
});
});
+47 -1
View File
@@ -1,7 +1,8 @@
import { promises as fs } from "node:fs";
import { getFlareSolverrCookies } from "@/lib/services/flare-solver";
import { parseNitroBundle } from "../../swf/nitro-builder";
import { browserHeaders } from "./browser-headers";
import { curlDownload } from "./curl-fetch";
export function validateSwfBytes(buffer: Buffer): boolean {
if (buffer.length < 8) return false;
@@ -58,6 +59,38 @@ export async function downloadFile(
const maxRetries = options?.maxRetries ?? 3;
const baseDelay = 1000;
const curlFallback = async (): Promise<boolean> => {
let curlRes = await curlDownload(url, destPath);
if (!curlRes.ok) {
const cookieHeader = await getFlareSolverrCookies(url);
curlRes = await curlDownload(
url,
destPath,
30_000,
cookieHeader ?? undefined,
);
}
if (!curlRes.ok) return false;
const validate = options?.validate;
if (validate) {
let valid = false;
try {
const buffer = await fs.readFile(destPath);
valid =
(validate === "swf" && validateSwfBytes(buffer)) ||
(validate === "png" && validatePngBytes(buffer)) ||
(validate === "nitro" && validateNitroBytes(buffer));
} catch {
valid = false;
}
if (!valid) {
await fs.unlink(destPath).catch(() => {});
return false;
}
}
return true;
};
for (let attempt = 0; attempt <= maxRetries; attempt++) {
let stage: "download" | "write" = "download";
try {
@@ -74,6 +107,13 @@ export async function downloadFile(
const deterministic =
res.status === 404 || res.status === 410 || res.status === 403;
if (deterministic || attempt === maxRetries) {
// HTTP 403 is often a TLS-fingerprint / Cloudflare challenge
// block (e.g. Leet.city) — retry via curl, with FlareSolverr
// clearance cookies if the plain curl is also challenged.
if (res.status === 403 && (await curlFallback())) {
const stat = await fs.stat(destPath);
return { ok: true, size: stat.size };
}
console.warn(
`[import-download] Download failed ${url}: ${res.status}${deterministic ? " (deterministic, not retrying)" : ` after ${maxRetries + 1} attempts`}`,
);
@@ -108,6 +148,12 @@ export async function downloadFile(
return { ok: true, size: buffer.length };
} catch (err) {
if (attempt === maxRetries || stage === "write") {
// TLS fingerprint aborts land here too — try curl/FlareSolverr
// before reporting the network failure.
if (stage === "download" && (await curlFallback())) {
const stat = await fs.stat(destPath);
return { ok: true, size: stat.size };
}
console.warn(
`[import-download] Download error ${url}:`,
(err as Error).message,
+139
View File
@@ -1916,6 +1916,145 @@
"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",
"duplicate": {
"title": "Duplicate category",
"description": "Copy this category, every descendant and all their offers.",
"name": "New category name",
"destination": "Destination category",
"disabled": "The copied root starts disabled and hidden. Descendants keep their settings; furniture definitions and assets are shared.",
"counts": "{pages} categories and {offers} offers",
"summary": "“{name}” under {destination}",
"cancel": "Cancel",
"working": "Working…",
"confirm": "Confirm duplication",
"preview": "Preview copy",
"saved": "Category and offers duplicated",
"savedPending": "Copy saved; hotel update pending",
"failed": "Duplication failed",
"root": "Catalog root",
"search": "Search categories…",
"empty": "No category found",
"suggested": "Suggested",
"all": "All categories",
"draftBlocked": "Save or discard category edits before duplicating."
},
"unsaved": "Unsaved changes",
"fullEditor": "Full editor",
"saveHint": "Category changes are not saved. Use Save or Ctrl+S.",
"access": {
"enabled": "Enabled",
"enabledHint": "Makes this category available in the catalog.",
"visible": "Visible in navigation",
"visibleHint": "Shows this category in the catalog menu.",
"club": "HC only",
"clubHint": "Restricts access to users with an HC subscription.",
"yes": "Yes",
"no": "No"
}
},
"bulk": {
"previewCount": "{count} of {total} offers will change.",
"success": "Updated {count} offers.",
"selectOffer": "Select offer {name} (#{id})",
"pageEmpty": "No category found.",
"clearSelection": "Clear selection",
"disabledReason": "Save or reset pending edits and wait for current operations before bulk editing.",
"operation": "Operation",
"loadingDestinations": "Loading categories…",
"cancel": "Cancel",
"pagePlaceholder": "Select destination category…",
"noChanges": "The selected values already match. No changes to save.",
"costPoints": "Points price",
"chooseFields": "Check at least one field to change.",
"pageAll": "All categories",
"offer": "Offer",
"costCredits": "Credits",
"invalidPrice": "Enter a valid number. Set and add operations require whole numbers.",
"modes": {
"set": "Set to value",
"add": "Add or subtract",
"percent": "Change by percentage"
},
"fieldHint": "Only checked fields will change.",
"currencies": {
"0": "Duckets",
"5": "Diamonds",
"101": "Snowflakes",
"103": "Hearts",
"104": "Stars",
"105": "Shells"
},
"currency": "Points currency",
"button": "Bulk edit ({count})",
"description": "{count} selected offers. Preview changes before saving.",
"confirm": "Confirm and save {count} offers",
"beforeAfter": "Each cell shows the current value → the new value.",
"requestFailed": "The request failed. Refresh the preview before trying again.",
"priceHint": "Use negative additions or percentages to reduce prices. Percentage results round to the nearest integer; final prices cannot be negative.",
"selectedCount": "{count} offers selected across categories",
"value": "Value",
"unknownCurrency": "Type {type}",
"becomes": "becomes",
"title": "Edit selected offers",
"preview": "Preview changes",
"retryDestinations": "Retry loading categories",
"chooseDestination": "Select a destination category.",
"pageSuggested": "Suggested",
"previewTitle": "Review bulk changes",
"visibilityHint": "Visibility is controlled by the category. Moving offers makes them follow the destination category's visibility.",
"percentage": "Percentage (%)",
"destination": "Destination category",
"pageSearch": "Search categories…",
"back": "Edit changes",
"selectionLimit": "Select at most 500 offers for one operation.",
"refreshing": "Refreshing updated offers…"
},
"discovery": {
"searchPlaceholder": "Name, classname, furniture ID or #offer ID…",
"filterLabel": "Filter offers",
"filters": {
"all": "All offers",
"free": "Free",
"paid": "Paid",
"bundles": "Bundles",
"limited": "Limited editions"
},
"shown": "({count} shown)",
"selected": "{count} selected",
"reset": "Reset filters",
"results": "Showing {count} of {total} offers.",
"selectionHint": "Changing the search or filter clears the selection.",
"noResults": "No offers match the current search and filter.",
"selectVisible": "Select all visible offers"
}
},
"shop": {
+139
View File
@@ -1835,6 +1835,145 @@
"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",
"duplicate": {
"title": "Duplica categoria",
"description": "Copia questa categoria, tutte le sottocategorie e le relative offerte.",
"name": "Nome della nuova categoria",
"destination": "Categoria di destinazione",
"disabled": "La radice copiata parte disabilitata e nascosta. Le sottocategorie mantengono le impostazioni; definizioni degli arredi e asset sono condivisi.",
"counts": "{pages} categorie e {offers} offerte",
"summary": "“{name}” sotto {destination}",
"cancel": "Annulla",
"working": "Operazione in corso…",
"confirm": "Conferma duplicazione",
"preview": "Anteprima copia",
"saved": "Categoria e offerte duplicate",
"savedPending": "Copia salvata; aggiornamento hotel in attesa",
"failed": "Duplicazione non riuscita",
"root": "Radice del catalogo",
"search": "Cerca categorie…",
"empty": "Nessuna categoria trovata",
"suggested": "Suggerite",
"all": "Tutte le categorie",
"draftBlocked": "Salva o annulla le modifiche alla categoria prima di duplicarla."
},
"unsaved": "Modifiche non salvate",
"fullEditor": "Editor completo",
"saveHint": "Le modifiche alla categoria non sono salvate. Usa Salva o Ctrl+S.",
"access": {
"enabled": "Categoria attiva",
"enabledHint": "Rende questa categoria disponibile nel catalogo.",
"visible": "Visibile nel menu",
"visibleHint": "Mostra questa categoria nel menu del catalogo.",
"club": "Solo utenti HC",
"clubHint": "Limita l’accesso agli utenti con abbonamento HC.",
"yes": "Sì",
"no": "No"
}
},
"bulk": {
"previewCount": "Verranno modificate {count} offerte su {total}.",
"success": "{count} offerte aggiornate.",
"selectOffer": "Seleziona offerta {name} (#{id})",
"pageEmpty": "Nessuna categoria trovata.",
"clearSelection": "Svuota selezione",
"disabledReason": "Salva o annulla le modifiche in sospeso e attendi la fine delle operazioni prima di modificare in blocco.",
"operation": "Operazione",
"loadingDestinations": "Caricamento categorie…",
"cancel": "Annulla",
"pagePlaceholder": "Seleziona la categoria di destinazione…",
"noChanges": "I valori selezionati corrispondono già. Nessuna modifica da salvare.",
"costPoints": "Prezzo in punti",
"chooseFields": "Seleziona almeno un campo da modificare.",
"pageAll": "Tutte le categorie",
"offer": "Offerta",
"costCredits": "Crediti",
"invalidPrice": "Inserisci un numero valido. Le operazioni di impostazione e aggiunta richiedono numeri interi.",
"modes": {
"set": "Imposta valore",
"add": "Aggiungi o sottrai",
"percent": "Varia in percentuale"
},
"fieldHint": "Verranno modificati solo i campi selezionati.",
"currencies": {
"0": "Duckets",
"5": "Diamanti",
"101": "Fiocchi di neve",
"103": "Cuori",
"104": "Stelle",
"105": "Conchiglie"
},
"currency": "Valuta dei punti",
"button": "Modifica in blocco ({count})",
"description": "{count} offerte selezionate. Controlla l'anteprima prima di salvare.",
"confirm": "Conferma e salva {count} offerte",
"beforeAfter": "Ogni cella mostra il valore attuale → il nuovo valore.",
"requestFailed": "Richiesta non riuscita. Aggiorna l'anteprima prima di riprovare.",
"priceHint": "Usa valori o percentuali negativi per ridurre i prezzi. Le percentuali vengono arrotondate all'intero più vicino; i prezzi finali non possono essere negativi.",
"selectedCount": "{count} offerte selezionate tra le categorie",
"value": "Valore",
"unknownCurrency": "Tipo {type}",
"becomes": "diventa",
"title": "Modifica le offerte selezionate",
"preview": "Anteprima modifiche",
"retryDestinations": "Riprova a caricare le categorie",
"chooseDestination": "Seleziona una categoria di destinazione.",
"pageSuggested": "Suggerite",
"previewTitle": "Controlla le modifiche in blocco",
"visibilityHint": "La visibilità dipende dalla categoria. Le offerte spostate seguono la visibilità della categoria di destinazione.",
"percentage": "Percentuale (%)",
"destination": "Categoria di destinazione",
"pageSearch": "Cerca categorie…",
"back": "Rivedi le modifiche",
"selectionLimit": "Seleziona al massimo 500 offerte per operazione.",
"refreshing": "Aggiornamento delle offerte modificate…"
},
"discovery": {
"searchPlaceholder": "Nome, classname, ID furni o #ID offerta…",
"filterLabel": "Filtra offerte",
"filters": {
"all": "Tutte le offerte",
"free": "Gratuite",
"paid": "A pagamento",
"bundles": "Pacchetti",
"limited": "Edizioni limitate"
},
"shown": "({count} visibili)",
"selected": "{count} selezionate",
"reset": "Reimposta filtri",
"results": "{count} offerte visibili su {total}.",
"selectionHint": "Cambiare ricerca o filtro azzera la selezione.",
"noResults": "Nessuna offerta corrisponde alla ricerca e al filtro attuali.",
"selectVisible": "Seleziona tutte le offerte visibili"
}
},
"shop": {
+139
View File
@@ -1665,6 +1665,145 @@
"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",
"duplicate": {
"title": "Categorie dupliceren",
"description": "Kopieer deze categorie, alle subcategorieën en alle bijbehorende aanbiedingen.",
"name": "Naam van de nieuwe categorie",
"destination": "Doelcategorie",
"disabled": "De gekopieerde hoofdcategorie begint uitgeschakeld en verborgen. Subcategorieën behouden hun instellingen; meubeldefinities en bestanden worden gedeeld.",
"counts": "{pages} categorieën en {offers} aanbiedingen",
"summary": "‘{name}’ onder {destination}",
"cancel": "Annuleren",
"working": "Bezig…",
"confirm": "Dupliceren bevestigen",
"preview": "Kopie bekijken",
"saved": "Categorie en aanbiedingen gedupliceerd",
"savedPending": "Kopie opgeslagen; hotelupdate in afwachting",
"failed": "Dupliceren mislukt",
"root": "Catalogushoofdmap",
"search": "Categorieën zoeken…",
"empty": "Geen categorie gevonden",
"suggested": "Voorgesteld",
"all": "Alle categorieën",
"draftBlocked": "Sla categoriewijzigingen op of annuleer ze voordat je dupliceert."
},
"unsaved": "Niet-opgeslagen wijzigingen",
"fullEditor": "Volledige editor",
"saveHint": "Categoriewijzigingen zijn niet opgeslagen. Gebruik Opslaan of Ctrl+S.",
"access": {
"enabled": "Categorie actief",
"enabledHint": "Maakt deze categorie beschikbaar in de catalogus.",
"visible": "Zichtbaar in het menu",
"visibleHint": "Toont deze categorie in het catalogusmenu.",
"club": "Alleen HC",
"clubHint": "Beperkt toegang tot gebruikers met een HC-abonnement.",
"yes": "Ja",
"no": "Nee"
}
},
"bulk": {
"previewCount": "{count} van {total} aanbiedingen worden gewijzigd.",
"success": "{count} aanbiedingen bijgewerkt.",
"selectOffer": "Selecteer aanbieding {name} (#{id})",
"pageEmpty": "Geen categorie gevonden.",
"clearSelection": "Selectie wissen",
"disabledReason": "Sla openstaande wijzigingen op of herstel ze en wacht tot lopende bewerkingen klaar zijn voordat je in bulk bewerkt.",
"operation": "Bewerking",
"loadingDestinations": "Categorieën laden…",
"cancel": "Annuleren",
"pagePlaceholder": "Selecteer de doelcategorie…",
"noChanges": "De geselecteerde waarden komen al overeen. Geen wijzigingen om op te slaan.",
"costPoints": "Prijs in punten",
"chooseFields": "Vink minstens één veld aan om te wijzigen.",
"pageAll": "Alle categorieën",
"offer": "Aanbieding",
"costCredits": "Credits",
"invalidPrice": "Voer een geldig getal in. Instellen en optellen vereisen gehele getallen.",
"modes": {
"set": "Waarde instellen",
"add": "Optellen of aftrekken",
"percent": "Procentueel wijzigen"
},
"fieldHint": "Alleen aangevinkte velden worden gewijzigd.",
"currencies": {
"0": "Duckets",
"5": "Diamanten",
"101": "Sneeuwvlokken",
"103": "Harten",
"104": "Sterren",
"105": "Schelpen"
},
"currency": "Puntenvaluta",
"button": "Bulk bewerken ({count})",
"description": "{count} aanbiedingen geselecteerd. Bekijk de wijzigingen voordat je opslaat.",
"confirm": "Bevestig en sla {count} aanbiedingen op",
"beforeAfter": "Elke cel toont de huidige waarde → de nieuwe waarde.",
"requestFailed": "Het verzoek is mislukt. Vernieuw het voorbeeld voordat je het opnieuw probeert.",
"priceHint": "Gebruik negatieve waarden of percentages om prijzen te verlagen. Procentuele resultaten worden afgerond op het dichtstbijzijnde gehele getal; eindprijzen mogen niet negatief zijn.",
"selectedCount": "{count} aanbiedingen geselecteerd in categorieën",
"value": "Waarde",
"unknownCurrency": "Type {type}",
"becomes": "wordt",
"title": "Geselecteerde aanbiedingen bewerken",
"preview": "Wijzigingen bekijken",
"retryDestinations": "Categorieën opnieuw laden",
"chooseDestination": "Selecteer een doelcategorie.",
"pageSuggested": "Voorgesteld",
"previewTitle": "Controleer de wijzigingen",
"visibilityHint": "De categorie bepaalt de zichtbaarheid. Verplaatste aanbiedingen volgen de zichtbaarheid van de doelcategorie.",
"percentage": "Percentage (%)",
"destination": "Doelcategorie",
"pageSearch": "Categorieën zoeken…",
"back": "Wijzigingen aanpassen",
"selectionLimit": "Selecteer maximaal 500 aanbiedingen per bewerking.",
"refreshing": "Bijgewerkte aanbiedingen vernieuwen…"
},
"discovery": {
"searchPlaceholder": "Naam, classname, meubel-ID of #aanbiedings-ID…",
"filterLabel": "Aanbiedingen filteren",
"filters": {
"all": "Alle aanbiedingen",
"free": "Gratis",
"paid": "Betaald",
"bundles": "Bundels",
"limited": "Gelimiteerde edities"
},
"shown": "({count} zichtbaar)",
"selected": "{count} geselecteerd",
"reset": "Filters wissen",
"results": "{count} van {total} aanbiedingen zichtbaar.",
"selectionHint": "De selectie wordt gewist wanneer je de zoekopdracht of het filter wijzigt.",
"noResults": "Geen aanbiedingen passen bij de huidige zoekopdracht en het filter.",
"selectVisible": "Alle zichtbare aanbiedingen selecteren"
}
},
"shop": {