refactor(catalog): unify commands and embed guarded catalog workspace
This commit is contained in:
1 parent
16f35568f5
commit
193b6686a9
57 files changed
+3268
-728
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user