feat(catalog): add saved packages, preview and bulk price editing
This commit is contained in:
1 parent
867113d5d4
commit
047cd9f3dd
48 files changed
+7712
No files matched your search
@@ -46,3 +46,38 @@ Local verification on 2026-09-09:
|
|||||||
- Deployment rollback, verified-digest publication, ownership/cancellation and concurrent news edit behavior have focused regression tests.
|
- Deployment rollback, verified-digest publication, ownership/cancellation and concurrent news edit behavior have focused regression tests.
|
||||||
|
|
||||||
Docker runtime, database migration execution and authenticated browser flows still require an integration environment. Check the Gitea pipeline and live release identifier after publication. A successful local build is not production verification.
|
Docker runtime, database migration execution and authenticated browser flows still require an integration environment. Check the Gitea pipeline and live release identifier after publication. A successful local build is not production verification.
|
||||||
|
|
||||||
|
|
||||||
|
## Catalog packages
|
||||||
|
|
||||||
|
The normal catalog toolbar now opens a dedicated Catalog packages dialog.
|
||||||
|
Create a named draft from selected categories and their descendants, either to
|
||||||
|
update those categories or copy them under a chosen parent. Drafts are shared
|
||||||
|
with authorized staff; saving a draft does not modify the live catalog.
|
||||||
|
|
||||||
|
Edit category metadata and offer prices, or review bulk price changes before
|
||||||
|
applying them to the draft. The catalog preview supports category navigation,
|
||||||
|
search, real local furniture icons and rank/Club/VIP access simulation. It does
|
||||||
|
not render the game client or evaluate ancestors outside the selected package;
|
||||||
|
special layouts and that access limitation are disclosed in the preview.
|
||||||
|
|
||||||
|
Publication requires a saved draft and a fresh review. Concurrent source changes
|
||||||
|
block publication; version checks prevent one editor overwriting another.
|
||||||
|
Copying preserves the underlying category and offer fields and remaps internal
|
||||||
|
references while retaining furniture IDs and assets. The catalog writes and
|
||||||
|
published result are committed together; retrying the same published package
|
||||||
|
does not copy it again. Failures in hotel notifications, audit or Git export
|
||||||
|
scheduling after commit are reported as warnings rather than failed publication.
|
||||||
|
|
||||||
|
Apply additive migration `0027_catalog_packages.sql` before opening this tool.
|
||||||
|
Existing migration automation discovers the file. Limits are 200 categories,
|
||||||
|
500 offers and 8 MB of package data. Package source checks inspect at most 20,000
|
||||||
|
catalog categories. Publication briefly locks category rows while validating and
|
||||||
|
writing changes, so large live catalogs should be checked under realistic load.
|
||||||
|
English, Italian and Dutch copy is provided; other locales use the new English
|
||||||
|
strings pending translation. No new dependency is required.
|
||||||
|
|
||||||
|
Validation: 1,506 tests passed (six skipped), type checking, lint, translation
|
||||||
|
contracts and a production build with fixture configuration. A browser fixture
|
||||||
|
verified the real dialog at 1280 and 390 pixels; server actions were simulated.
|
||||||
|
The new database migration and package publication have not run in production.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS website_catalog_packages (
|
||||||
|
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
version INT UNSIGNED NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL,
|
||||||
|
mode VARCHAR(16) NOT NULL,
|
||||||
|
payload LONGTEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX package_recent (updated_at, id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,46 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
allowed: true,
|
||||||
|
query: vi.fn(async () => [{ id: 2, classname: "sofa", name: "Sofa" }]),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: vi.fn(async () => {
|
||||||
|
if (!state.allowed) throw new Error("forbidden");
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { CATALOG_VIEW: "catalog.view" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
ItemsBase: { id: "id", itemName: "itemName", publicName: "publicName" },
|
||||||
|
db: { select: () => ({ from: () => ({ where: state.query }) }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { getCatalogPreviewAssets } from "./catalog-preview-assets";
|
||||||
|
|
||||||
|
describe("preview asset authorization and bounds", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
state.allowed = true;
|
||||||
|
state.query.mockClear();
|
||||||
|
});
|
||||||
|
it("checks catalog viewing permission before querying", async () => {
|
||||||
|
state.allowed = false;
|
||||||
|
await expect(getCatalogPreviewAssets([2])).rejects.toThrow("forbidden");
|
||||||
|
expect(requirePermission).toHaveBeenCalledWith("catalog.view");
|
||||||
|
expect(state.query).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("rejects oversized and invalid requests without querying", async () => {
|
||||||
|
expect((await getCatalogPreviewAssets(Array(201).fill(2))).ok).toBe(false);
|
||||||
|
expect((await getCatalogPreviewAssets([NaN, -2])).ok).toBe(false);
|
||||||
|
expect(state.query).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("returns only database-backed classname and display name", async () => {
|
||||||
|
expect(await getCatalogPreviewAssets([2, 2])).toEqual({
|
||||||
|
ok: true,
|
||||||
|
assets: [{ id: 2, classname: "sofa", name: "Sofa" }],
|
||||||
|
});
|
||||||
|
expect(state.query).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use server";
|
||||||
|
import { inArray } from "drizzle-orm";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { db, ItemsBase } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
export async function getCatalogPreviewAssets(itemIds: number[]) {
|
||||||
|
await requirePermission(PERMS.CATALOG_VIEW);
|
||||||
|
if (
|
||||||
|
!Array.isArray(itemIds) ||
|
||||||
|
itemIds.length > 200 ||
|
||||||
|
itemIds.some(
|
||||||
|
(id) => !Number.isSafeInteger(id) || id <= 0 || id > 2147483647,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return { ok: false as const, assets: [] };
|
||||||
|
const ids = [...new Set(itemIds)];
|
||||||
|
if (!ids.length) return { ok: true as const, assets: [] };
|
||||||
|
try {
|
||||||
|
const assets = await db
|
||||||
|
.select({
|
||||||
|
id: ItemsBase.id,
|
||||||
|
classname: ItemsBase.itemName,
|
||||||
|
name: ItemsBase.publicName,
|
||||||
|
})
|
||||||
|
.from(ItemsBase)
|
||||||
|
.where(inArray(ItemsBase.id, ids));
|
||||||
|
return { ok: true as const, assets };
|
||||||
|
} catch {
|
||||||
|
return { ok: false as const, assets: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { CatalogTree } from "@/components/admin/catalog-tree";
|
|||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
||||||
import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status";
|
import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status";
|
||||||
|
import { CatalogPackagesDialog } from "@/features/catalog/components/packages/catalog-packages-dialog";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import {
|
import {
|
||||||
CatalogItems,
|
CatalogItems,
|
||||||
@@ -506,6 +507,7 @@ export default async function CatalogPagesPage({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
||||||
|
<CatalogPackagesDialog canEdit={canEdit} />
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<CatalogManagerDialog
|
<CatalogManagerDialog
|
||||||
catalogType="normal"
|
catalogType="normal"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
int,
|
||||||
|
longtext,
|
||||||
|
mysqlTable,
|
||||||
|
timestamp,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/mysql-core";
|
||||||
|
export const CatalogPackages = mysqlTable("website_catalog_packages", {
|
||||||
|
id: varchar("id", { length: 36 }).primaryKey(),
|
||||||
|
name: varchar("name", { length: 128 }).notNull(),
|
||||||
|
version: int("version", { unsigned: true }).notNull(),
|
||||||
|
status: varchar("status", { length: 16 }).notNull(),
|
||||||
|
mode: varchar("mode", { length: 16 }).notNull(),
|
||||||
|
payload: longtext("payload").notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,70 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { expect, it, vi } from "vitest";
|
||||||
|
import type { PackageSnapshot } from "@/features/catalog/domain/packages";
|
||||||
|
|
||||||
|
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
||||||
|
|
||||||
|
import { PackageCategoryPicker } from "./package-category-picker";
|
||||||
|
import { PackageSnapshotEditor } from "./package-snapshot-editor";
|
||||||
|
|
||||||
|
it("renders a bounded category page from a large destination list", () => {
|
||||||
|
const pages = Array.from({ length: 5000 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
caption: `Category ${index + 1}`,
|
||||||
|
}));
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<PackageCategoryPicker
|
||||||
|
pages={pages}
|
||||||
|
selected={[]}
|
||||||
|
onChange={() => {}}
|
||||||
|
multiple
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect((markup.match(/type="checkbox"/g) ?? []).length).toBe(25);
|
||||||
|
expect(markup).toContain("Category 25");
|
||||||
|
expect(markup).not.toContain("Category 26");
|
||||||
|
});
|
||||||
|
it("bounds offer controls and disables editing in a published or read-only snapshot", () => {
|
||||||
|
const snapshot: PackageSnapshot = {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
parentId: -1,
|
||||||
|
caption: "Rooms",
|
||||||
|
enabled: "1",
|
||||||
|
visible: "1",
|
||||||
|
iconImage: 1,
|
||||||
|
minRank: 1,
|
||||||
|
clubOnly: "0",
|
||||||
|
vipOnly: "0",
|
||||||
|
orderNum: 1,
|
||||||
|
pageLayout: "default_3x3",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
offers: Array.from({ length: 500 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
pageId: 10,
|
||||||
|
catalogName: `Offer ${index + 1}`,
|
||||||
|
itemIds: "42",
|
||||||
|
costCredits: 100,
|
||||||
|
costPoints: 0,
|
||||||
|
pointsType: 0,
|
||||||
|
amount: 1,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<PackageSnapshotEditor
|
||||||
|
snapshot={snapshot}
|
||||||
|
disabled
|
||||||
|
revision={1}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(markup).toContain("Offer 25");
|
||||||
|
expect(markup).not.toContain("Offer 26");
|
||||||
|
expect(markup).toContain('<fieldset disabled=""');
|
||||||
|
expect(markup).not.toContain("selectMatching");
|
||||||
|
expect((markup.match(/aria-label="costCredits #[0-9]+"/g) ?? []).length).toBe(
|
||||||
|
25,
|
||||||
|
);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,30 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
"Source categories or offers changed since this package was created. Create a fresh package to include the current data.":
|
||||||
|
"sourceChanged",
|
||||||
|
"Package changed. Reload it before saving": "saveConflict",
|
||||||
|
"Package changed. Refresh the publication preview": "publishConflict",
|
||||||
|
"Catalog changed. Refresh the publication preview": "catalogChanged",
|
||||||
|
"Catalog package not found": "missing",
|
||||||
|
"A package supports at most 200 categories including descendants":
|
||||||
|
"pageLimit",
|
||||||
|
"A package supports at most 500 offers": "offerLimit",
|
||||||
|
"Package data exceeds 8 MB safety limit": "payloadLimit",
|
||||||
|
"Package membership cannot change": "membership",
|
||||||
|
"Package hierarchy cannot change": "hierarchy",
|
||||||
|
"Package furniture references cannot change": "references",
|
||||||
|
"Package furniture reference no longer exists": "furnitureMissing",
|
||||||
|
"Package published, but audit logging failed.": "auditWarning",
|
||||||
|
"Package published, but the hotel update was not delivered.": "hotelWarning",
|
||||||
|
"Package published, but export scheduling failed.": "exportWarning",
|
||||||
|
"Package published. Refresh the page to see the latest catalog.":
|
||||||
|
"refreshWarning",
|
||||||
|
};
|
||||||
|
export async function packageMessage(message: string): Promise<string> {
|
||||||
|
const key = messages[message];
|
||||||
|
if (!key) return message;
|
||||||
|
const t = await getTranslations("pages.admin.catalogPackages.backend");
|
||||||
|
return t(key);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user