feat(catalog): add saved packages, preview and bulk price editing
CI / check (push) Successful in 52s
CI / deploy (push) Successful in 1m23s
CI / publish-container (push) Failing after 27s

This commit is contained in:
Simo committed 2026-09-09 20:04:10 +02:00
1 parent 867113d5d4
commit 047cd9f3dd
48 files changed
+7712

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
import { beforeEach, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
permission: vi.fn(),
create: vi.fn(),
save: vi.fn(),
get: vi.fn(),
list: vi.fn(),
preview: vi.fn(),
publish: vi.fn(),
export: vi.fn(),
audit: vi.fn(),
hotel: vi.fn(),
refresh: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: mocks.permission }));
vi.mock("@/lib/permissions", () => ({
PERMS: { CATALOG_VIEW: "view", CATALOG_EDIT: "edit" },
}));
vi.mock("@/features/catalog/server/packages", () => ({
createCatalogPackageCommand: mocks.create,
saveCatalogPackageCommand: mocks.save,
getCatalogPackageCommand: mocks.get,
listCatalogPackagesCommand: mocks.list,
previewCatalogPackageCommand: mocks.preview,
publishCatalogPackageCommand: mocks.publish,
}));
vi.mock("@/features/catalog/server/package-messages", () => ({
packageMessage: async (m: string) => m,
}));
vi.mock("@/features/catalog/server/errors", () => ({
catalogFailure: (e: Error) => ({ message: e.message }),
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: mocks.export,
}));
vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.audit }));
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: mocks.hotel,
}));
vi.mock("next/cache", () => ({ revalidatePath: mocks.refresh }));
import {
createCatalogPackage,
getCatalogPackage,
listCatalogPackages,
previewCatalogPackage,
publishCatalogPackage,
saveCatalogPackage,
} from "./catalog-packages";
beforeEach(() => {
vi.resetAllMocks();
mocks.permission.mockResolvedValue({ id: 7 });
mocks.export.mockImplementation((fn) => fn());
mocks.publish.mockResolvedValue({
pageIds: [4],
offerCount: 1,
alreadyPublished: false,
});
mocks.hotel.mockResolvedValue({ sent: true });
mocks.preview.mockResolvedValue({
version: 1,
fingerprint: "a".repeat(64),
changedPages: 1,
changedOffers: 1,
conflicts: [],
});
});
it("keeps draft creation, saving and preview out of live export, audit and hotel updates", async () => {
await createCatalogPackage({
name: "Pack",
pageIds: [1],
mode: "copy",
targetParentId: -1,
});
await saveCatalogPackage({
id: "id",
version: 1,
name: "Pack",
draft: { pages: [], offers: [] },
targetParentId: -1,
});
await getCatalogPackage("id");
await listCatalogPackages();
await previewCatalogPackage("id");
expect(mocks.permission.mock.calls.map((c) => c[0])).toEqual([
"edit",
"edit",
"view",
"view",
"view",
]);
expect(mocks.export).not.toHaveBeenCalled();
expect(mocks.audit).not.toHaveBeenCalled();
expect(mocks.hotel).not.toHaveBeenCalled();
});
it("reports committed publication with warnings if audit and hotel updates fail", async () => {
mocks.audit.mockRejectedValue(Error("audit down"));
mocks.hotel.mockRejectedValue(Error("hotel down"));
const result = await publishCatalogPackage({
id: "id",
version: 1,
fingerprint: "a".repeat(64),
});
expect(result).toMatchObject({
ok: true,
data: { pageIds: [4], warnings: expect.any(Array) },
});
if (result.ok) expect(result.data.warnings).toHaveLength(2);
expect(mocks.permission).toHaveBeenCalledWith("edit");
});
it("does not report a rollback if an export wrapper fails after commit", async () => {
mocks.export.mockImplementation(async (fn) => {
await fn();
throw Error("export failed");
});
expect(
await publishCatalogPackage({
id: "id",
version: 1,
fingerprint: "a".repeat(64),
}),
).toMatchObject({
ok: true,
data: { warnings: ["Package published, but export scheduling failed."] },
});
});
it("reports a transaction failure without sending hotel updates or audit", async () => {
mocks.publish.mockRejectedValue(Error("live conflict"));
expect(
await publishCatalogPackage({
id: "id",
version: 1,
fingerprint: "a".repeat(64),
}),
).toEqual({ ok: false, error: "live conflict" });
expect(mocks.audit).not.toHaveBeenCalled();
expect(mocks.hotel).not.toHaveBeenCalled();
});
it("does not repeat external side effects for an already published package", async () => {
mocks.publish.mockResolvedValue({
pageIds: [4],
offerCount: 1,
alreadyPublished: true,
});
expect(
await publishCatalogPackage({
id: "id",
version: 1,
fingerprint: "a".repeat(64),
}),
).toMatchObject({ ok: true, data: { alreadyPublished: true } });
expect(mocks.audit).not.toHaveBeenCalled();
expect(mocks.hotel).not.toHaveBeenCalled();
});
+123
View File
@@ -0,0 +1,123 @@
"use server";
import { revalidatePath } from "next/cache";
import type {
CreateCatalogPackageInput,
PackagePublication,
PublishCatalogPackageInput,
SaveCatalogPackageInput,
} from "@/features/catalog/domain/packages";
import { catalogFailure } from "@/features/catalog/server/errors";
import { packageMessage } from "@/features/catalog/server/package-messages";
import {
createCatalogPackageCommand,
getCatalogPackageCommand,
listCatalogPackagesCommand,
previewCatalogPackageCommand,
publishCatalogPackageCommand,
saveCatalogPackageCommand,
} from "@/features/catalog/server/packages";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
async function attempt<T>(operation: () => Promise<T>) {
try {
return { ok: true as const, data: await operation() };
} catch (error) {
return {
ok: false as const,
error: await packageMessage(catalogFailure(error).message),
};
}
}
export async function listCatalogPackages() {
await requirePermission(PERMS.CATALOG_VIEW);
return attempt(listCatalogPackagesCommand);
}
export async function getCatalogPackage(id: string) {
await requirePermission(PERMS.CATALOG_VIEW);
return attempt(() => getCatalogPackageCommand(id));
}
export async function createCatalogPackage(input: CreateCatalogPackageInput) {
await requirePermission(PERMS.CATALOG_EDIT);
return attempt(() => createCatalogPackageCommand(input));
}
export async function saveCatalogPackage(input: SaveCatalogPackageInput) {
await requirePermission(PERMS.CATALOG_EDIT);
return attempt(() => saveCatalogPackageCommand(input));
}
export async function previewCatalogPackage(id: string) {
await requirePermission(PERMS.CATALOG_VIEW);
return attempt(async () => {
const data = await previewCatalogPackageCommand(id);
return {
...data,
conflicts: await Promise.all(data.conflicts.map(packageMessage)),
};
});
}
export async function publishCatalogPackage(input: PublishCatalogPackageInput) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return attempt(async () => {
let committed: PackagePublication | undefined;
const warnings: string[] = [];
try {
await withCatalogExport(async () => {
committed = await publishCatalogPackageCommand(input);
return committed;
});
} catch (error) {
if (!committed) throw error;
warnings.push("Package published, but export scheduling failed.");
}
if (!committed) throw Error("Package publication did not return a result");
const result = committed;
if (result.alreadyPublished)
return {
...result,
...(warnings.length
? { warnings: await Promise.all(warnings.map(packageMessage)) }
: {}),
};
try {
await logAudit({
userId: staff.id,
action: "catalog_package_publish",
target: "catalog_package",
after: {
packageId: input.id,
pageIds: result.pageIds,
offerCount: result.offerCount,
},
});
} catch {
warnings.push("Package published, but audit logging failed.");
}
try {
const hotel = await sendCatalogUpdate();
if (!hotel.sent)
warnings.push(
"Package published, but the hotel update was not delivered.",
);
} catch {
warnings.push(
"Package published, but the hotel update was not delivered.",
);
}
try {
revalidatePath("/admin/catalog");
} catch {
warnings.push(
"Package published. Refresh the page to see the latest catalog.",
);
}
return {
...result,
...(warnings.length
? { warnings: await Promise.all(warnings.map(packageMessage)) }
: {}),
} satisfies PackagePublication;
});
}
@@ -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();
});
});
+31
View File
@@ -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: [] };
}
}