refactor(catalog): unify commands and embed guarded catalog workspace

This commit is contained in:
Simo committed 2026-09-06 19:40:52 +02:00
1 parent 16f35568f5
commit 193b6686a9
57 files changed
+3268 -728

No files matched your search

+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 };
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
import "server-only";
import { asc, count, eq, or } from "drizzle-orm";
import {
CatalogItems,
CatalogItemsBc,
CatalogPages,
CatalogPagesBc,
db,
} from "@/lib/db";
import type { CatalogKind } from "./page-commands";
export async function loadCatalogOverview(kind: CatalogKind) {
const pages = kind === "bc" ? CatalogPagesBc : CatalogPages;
const items = kind === "bc" ? CatalogItemsBc : CatalogItems;
const [roots, pageCount, itemCount, enabledCount] = await Promise.all([
db
.select({
id: pages.id,
caption: pages.caption,
iconImage: pages.iconImage,
enabled: pages.enabled,
orderNum: pages.orderNum,
})
.from(pages)
.where(or(eq(pages.parentId, -1), eq(pages.parentId, 0)))
.orderBy(asc(pages.orderNum), asc(pages.id)),
db.select({ total: count() }).from(pages),
db.select({ total: count() }).from(items),
db.select({ total: count() }).from(pages).where(eq(pages.enabled, "1")),
]);
return {
roots,
totals: {
totalPages: Number(pageCount[0]?.total ?? 0),
totalItems: Number(itemCount[0]?.total ?? 0),
enabledPages: Number(enabledCount[0]?.total ?? 0),
},
};
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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();
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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;
}