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
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user