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,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
readCatalogSelection,
|
||||
writeCatalogSelection,
|
||||
} from "./catalog-selection-url";
|
||||
|
||||
describe("catalog selection URL", () => {
|
||||
it("restores category context without disturbing catalog, search or pagination", () => {
|
||||
const url = new URL(
|
||||
"https://cms.test/admin/catalog?catalog=bc&search=chair&page=4",
|
||||
);
|
||||
const selected = writeCatalogSelection(url, { rootId: 8, pageId: 12 });
|
||||
expect(readCatalogSelection(selected)).toEqual({ rootId: 8, pageId: 12 });
|
||||
expect(selected.searchParams.get("catalog")).toBe("bc");
|
||||
expect(selected.searchParams.get("search")).toBe("chair");
|
||||
expect(selected.searchParams.get("page")).toBe("4");
|
||||
expect(url.searchParams.has("category")).toBe(false);
|
||||
expect(
|
||||
writeCatalogSelection(selected, {
|
||||
rootId: 8,
|
||||
pageId: null,
|
||||
}).searchParams.has("category"),
|
||||
).toBe(false);
|
||||
});
|
||||
it.each(["-1", "0", "1.5", "1abc", "Infinity", "9007199254740992"])(
|
||||
"rejects invalid identifiers: %s",
|
||||
(id) => {
|
||||
expect(
|
||||
readCatalogSelection(
|
||||
new URL(`https://cms.test/?root=${id}&category=${id}`),
|
||||
),
|
||||
).toEqual({ rootId: null, pageId: null });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface CatalogSelection {
|
||||
rootId: number | null;
|
||||
pageId: number | null;
|
||||
}
|
||||
function positiveId(value: string | null) {
|
||||
if (!value || !/^\d+$/.test(value)) return null;
|
||||
const id = Number(value);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
export function readCatalogSelection(url: URL): CatalogSelection {
|
||||
return {
|
||||
rootId: positiveId(url.searchParams.get("root")),
|
||||
pageId: positiveId(url.searchParams.get("category")),
|
||||
};
|
||||
}
|
||||
export function writeCatalogSelection(
|
||||
url: URL,
|
||||
selection: CatalogSelection,
|
||||
): URL {
|
||||
const next = new URL(url);
|
||||
for (const [key, value] of [
|
||||
["root", selection.rootId],
|
||||
["category", selection.pageId],
|
||||
] as const) {
|
||||
if (value === null) next.searchParams.delete(key);
|
||||
else next.searchParams.set(key, String(value));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLatestRequest } from "./latest-request";
|
||||
|
||||
describe("catalog request ownership", () => {
|
||||
it("keeps the latest category when an earlier transport completes last", async () => {
|
||||
const requests = createLatestRequest();
|
||||
let displayed = "";
|
||||
let finishOld!: (value: string) => void;
|
||||
const oldResponse = new Promise<string>((resolve) => {
|
||||
finishOld = resolve;
|
||||
});
|
||||
const oldRequest = requests.start();
|
||||
const oldWork = oldResponse.then((value) => {
|
||||
if (oldRequest.isCurrent()) displayed = value;
|
||||
});
|
||||
const current = requests.start();
|
||||
await Promise.resolve("category B").then((value) => {
|
||||
if (current.isCurrent()) displayed = value;
|
||||
});
|
||||
finishOld("category A");
|
||||
await oldWork;
|
||||
expect(oldRequest.signal.aborted).toBe(true);
|
||||
expect(displayed).toBe("category B");
|
||||
});
|
||||
it("ignores late save/read completion after editor unmount", () => {
|
||||
const requests = createLatestRequest();
|
||||
const pending = requests.start();
|
||||
requests.cancel();
|
||||
expect(pending.signal.aborted).toBe(true);
|
||||
expect(pending.isCurrent()).toBe(false);
|
||||
});
|
||||
it("allows a fresh request after effect cleanup and isolates page/items channels", () => {
|
||||
const pages = createLatestRequest();
|
||||
const items = createLatestRequest();
|
||||
const page = pages.start();
|
||||
const firstItems = items.start();
|
||||
items.cancel();
|
||||
const latestItems = items.start();
|
||||
expect(page.isCurrent()).toBe(true);
|
||||
expect(firstItems.isCurrent()).toBe(false);
|
||||
expect(latestItems.isCurrent()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Cancels replaced requests and rejects late completions, even if transport ignores abort. */
|
||||
export function createLatestRequest() {
|
||||
let current: AbortController | null = null;
|
||||
return {
|
||||
start() {
|
||||
current?.abort();
|
||||
const controller = new AbortController();
|
||||
current = controller;
|
||||
return {
|
||||
signal: controller.signal,
|
||||
isCurrent: () => current === controller && !controller.signal.aborted,
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
current?.abort();
|
||||
current = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
type CatalogSelection,
|
||||
readCatalogSelection,
|
||||
writeCatalogSelection,
|
||||
} from "./catalog-selection-url";
|
||||
|
||||
export function requestCatalogNavigation() {
|
||||
return window.dispatchEvent(
|
||||
new Event("catalog:before-navigation", { cancelable: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Selection history stays local: changing category does not reload the server page. */
|
||||
export function useCatalogNavigation({
|
||||
enabled,
|
||||
restore,
|
||||
mayLeave,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
restore: (selection: CatalogSelection) => void;
|
||||
mayLeave: () => boolean;
|
||||
}) {
|
||||
const current = useRef({ restore, mayLeave });
|
||||
useEffect(() => {
|
||||
current.current = { restore, mayLeave };
|
||||
}, [restore, mayLeave]);
|
||||
const previousUrl = useRef("");
|
||||
const position = useRef(0);
|
||||
const restoringHistory = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
previousUrl.current = window.location.href;
|
||||
position.current = window.history.state?.catalogSelectionPosition ?? 0;
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, catalogSelectionPosition: position.current },
|
||||
"",
|
||||
);
|
||||
current.current.restore(
|
||||
readCatalogSelection(new URL(window.location.href)),
|
||||
);
|
||||
const onPopState = () => {
|
||||
if (restoringHistory.current) {
|
||||
restoringHistory.current = false;
|
||||
return;
|
||||
}
|
||||
const targetPosition = window.history.state?.catalogSelectionPosition;
|
||||
if (!current.current.mayLeave()) {
|
||||
if (
|
||||
typeof targetPosition === "number" &&
|
||||
targetPosition !== position.current
|
||||
) {
|
||||
restoringHistory.current = true;
|
||||
window.history.go(position.current - targetPosition);
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
previousUrl.current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
position.current =
|
||||
typeof targetPosition === "number" ? targetPosition : position.current;
|
||||
previousUrl.current = window.location.href;
|
||||
current.current.restore(
|
||||
readCatalogSelection(new URL(window.location.href)),
|
||||
);
|
||||
};
|
||||
const onLink = (event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const link = (event.target as Element).closest?.(
|
||||
"a[href]",
|
||||
) as HTMLAnchorElement | null;
|
||||
if (
|
||||
!link ||
|
||||
link.target === "_blank" ||
|
||||
link.hasAttribute("download") ||
|
||||
link.href === window.location.href
|
||||
)
|
||||
return;
|
||||
if (!current.current.mayLeave()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
const onNavigation = (event: Event) => {
|
||||
if (!current.current.mayLeave()) event.preventDefault();
|
||||
};
|
||||
window.addEventListener("catalog:before-navigation", onNavigation);
|
||||
window.addEventListener("popstate", onPopState);
|
||||
document.addEventListener("click", onLink, true);
|
||||
return () => {
|
||||
window.removeEventListener("catalog:before-navigation", onNavigation);
|
||||
window.removeEventListener("popstate", onPopState);
|
||||
document.removeEventListener("click", onLink, true);
|
||||
};
|
||||
}, [enabled]);
|
||||
return useCallback(
|
||||
(selection: CatalogSelection) => {
|
||||
if (!enabled) return;
|
||||
const url = writeCatalogSelection(
|
||||
new URL(window.location.href),
|
||||
selection,
|
||||
);
|
||||
if (url.href !== window.location.href) {
|
||||
position.current += 1;
|
||||
window.history.pushState(
|
||||
{
|
||||
...window.history.state,
|
||||
catalogSelectionPosition: position.current,
|
||||
},
|
||||
"",
|
||||
url,
|
||||
);
|
||||
}
|
||||
previousUrl.current = url.href;
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createLatestRequest } from "./latest-request";
|
||||
export function useLatestRequest() {
|
||||
const [requests] = useState(createLatestRequest);
|
||||
useEffect(() => () => requests.cancel(), [requests]);
|
||||
return requests;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "@/components/link";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useLatestRequest } from "../client/use-latest-request";
|
||||
import {
|
||||
type CatalogSearchResult,
|
||||
type SearchCatalogKind,
|
||||
validSearchQuery,
|
||||
} from "../domain/search";
|
||||
export function CatalogSearch({
|
||||
catalogType = "normal",
|
||||
}: {
|
||||
catalogType?: SearchCatalogKind;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.catalog.workspace");
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<CatalogSearchResult[]>([]);
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const requests = useLatestRequest();
|
||||
useEffect(() => {
|
||||
const request = requests.start();
|
||||
setResults([]);
|
||||
if (!validSearchQuery(query)) {
|
||||
setStatus("idle");
|
||||
return () => requests.cancel();
|
||||
}
|
||||
setStatus("loading");
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: query.trim(),
|
||||
catalog: catalogType,
|
||||
});
|
||||
const response = await fetch(`/api/admin/catalog/search?${params}`, {
|
||||
signal: request.signal,
|
||||
});
|
||||
if (!response.ok) throw Error("Search failed");
|
||||
const data = await response.json();
|
||||
if (!request.isCurrent()) return;
|
||||
setResults(data.results);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
if (request.isCurrent()) setStatus("error");
|
||||
}
|
||||
}, 250);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
requests.cancel();
|
||||
};
|
||||
}, [query, catalogType, requests]);
|
||||
return (
|
||||
<section
|
||||
aria-label={t("globalSearch")}
|
||||
className="min-w-0 space-y-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 text-[var(--admin-text)]"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Search
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("globalSearch")}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
maxLength={100}
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
<div aria-live="polite">
|
||||
{status === "loading" && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
||||
{t("searching")}
|
||||
</p>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<p role="alert" className="text-sm text-[var(--admin-error)]">
|
||||
{t("searchFailed")}
|
||||
</p>
|
||||
)}
|
||||
{status === "ready" && results.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("searchNoResults")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<ul className="max-h-80 overflow-y-auto divide-y divide-[var(--admin-border)]">
|
||||
{results.map((result) => (
|
||||
<li key={result.kind + result.id}>
|
||||
<Link
|
||||
href={result.href}
|
||||
className="block min-w-0 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(result.kind)}
|
||||
</span>
|
||||
<span className="font-medium break-words">
|
||||
{result.caption}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
#{result.id}
|
||||
</span>
|
||||
</div>
|
||||
<p className="break-words text-xs text-muted-foreground">
|
||||
{result.path.join(" / ")}
|
||||
</p>
|
||||
{result.furniture && (
|
||||
<p className="break-words text-xs text-muted-foreground">
|
||||
{result.furniture.classname} · #{result.furniture.id} ·
|
||||
sprite {result.furniture.spriteId}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
|
||||
interface Status {
|
||||
hotel: { sent: boolean; checkedAt: string; reference?: string } | null;
|
||||
git: {
|
||||
enabled: boolean;
|
||||
pending: number;
|
||||
active: number;
|
||||
error?: string;
|
||||
commit?: string;
|
||||
};
|
||||
}
|
||||
export function CatalogSyncStatus({ canEdit }: { canEdit: boolean }) {
|
||||
const t = useTranslations("pages.admin.catalog.workspace");
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/catalog/status", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw Error();
|
||||
setStatus(await res.json());
|
||||
setError(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
}, []);
|
||||
useVisiblePolling(refresh, 15000);
|
||||
async function retry() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/catalog/status", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw Error();
|
||||
await refresh();
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-3 rounded-lg border border-[var(--admin-border)] p-3 text-xs"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span>
|
||||
{t("hotel")}:{" "}
|
||||
{status?.hotel
|
||||
? t(status.hotel.sent ? "sent" : "sendFailed")
|
||||
: t("unknown")}
|
||||
</span>
|
||||
<span>
|
||||
Git:{" "}
|
||||
{status
|
||||
? t(
|
||||
!status.git.enabled
|
||||
? "disabled"
|
||||
: status.git.error
|
||||
? "exportFailed"
|
||||
: status.git.pending || status.git.active
|
||||
? "queued"
|
||||
: status.git.commit
|
||||
? "exported"
|
||||
: "unknown",
|
||||
)
|
||||
: t("unknown")}
|
||||
</span>
|
||||
{status?.hotel?.reference && <span>{status.hotel.reference}</span>}
|
||||
{canEdit && status?.hotel?.sent === false && (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={retry}>
|
||||
{t("retryHotel")}
|
||||
</Button>
|
||||
)}
|
||||
{error && (
|
||||
<button type="button" onClick={() => void refresh()}>
|
||||
{t("retryStatus")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertParent,
|
||||
collectSubtree,
|
||||
validateSiblingOrder,
|
||||
} from "./hierarchy";
|
||||
|
||||
const pages = [
|
||||
{ id: 1, parentId: -1, orderNum: 0 },
|
||||
{ id: 2, parentId: 1, orderNum: 1 },
|
||||
{ id: 3, parentId: 1, orderNum: 2 },
|
||||
{ id: 4, parentId: 2, orderNum: 1 },
|
||||
];
|
||||
describe("catalog hierarchy commands", () => {
|
||||
it("rejects self, descendant, missing and malformed destinations", () => {
|
||||
for (const parent of [1, 2, 4, 999, 1.5, -2])
|
||||
expect(() => assertParent(pages, 1, parent)).toThrow();
|
||||
});
|
||||
it("allows detaching a page from an inherited cycle", () =>
|
||||
expect(() =>
|
||||
assertParent(
|
||||
[
|
||||
{ id: 1, parentId: 2 },
|
||||
{ id: 2, parentId: 1 },
|
||||
],
|
||||
1,
|
||||
-1,
|
||||
),
|
||||
).not.toThrow());
|
||||
it("rejects a destination with an inherited parent cycle", () =>
|
||||
expect(() =>
|
||||
assertParent(
|
||||
[
|
||||
{ id: 1, parentId: -1 },
|
||||
{ id: 2, parentId: 3 },
|
||||
{ id: 3, parentId: 2 },
|
||||
],
|
||||
1,
|
||||
2,
|
||||
),
|
||||
).toThrow());
|
||||
it("collects cyclic subtrees once without recursion", () =>
|
||||
expect(
|
||||
collectSubtree(
|
||||
[
|
||||
{ id: 1, parentId: 2 },
|
||||
{ id: 2, parentId: 1 },
|
||||
],
|
||||
1,
|
||||
),
|
||||
).toEqual([1, 2]));
|
||||
it("rejects stale or partial sibling lists before reordering", () => {
|
||||
expect(() => validateSiblingOrder(pages, 1, [2], [2, 3])).toThrow();
|
||||
expect(() => validateSiblingOrder(pages, 1, [3, 2], [3, 2])).toThrow();
|
||||
expect(() => validateSiblingOrder(pages, 1, [2, 2], [2, 3])).toThrow();
|
||||
});
|
||||
it("accepts a complete reorder matching the previous sibling order", () =>
|
||||
expect(() => validateSiblingOrder(pages, 1, [3, 2], [2, 3])).not.toThrow());
|
||||
});
|
||||
|
||||
it("orders mixed root conventions with a stable ID tie breaker", () => {
|
||||
const roots = [
|
||||
{ id: 3, parentId: 0, orderNum: 1 },
|
||||
{ id: 2, parentId: -1, orderNum: 1 },
|
||||
];
|
||||
expect(() => validateSiblingOrder(roots, -1, [3, 2], [2, 3])).not.toThrow();
|
||||
expect(() => validateSiblingOrder(roots, -1, [2, 3], [3, 2])).toThrow();
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
export interface HierarchyPage {
|
||||
id: number;
|
||||
parentId: number;
|
||||
orderNum?: number;
|
||||
}
|
||||
export class CatalogConflict extends Error {}
|
||||
export class CatalogInputError extends Error {}
|
||||
export class CatalogNotFound extends Error {}
|
||||
export function positiveId(id: number) {
|
||||
if (!Number.isSafeInteger(id) || id <= 0)
|
||||
throw new CatalogInputError("Invalid catalog ID");
|
||||
}
|
||||
export function assertParent(
|
||||
pages: readonly HierarchyPage[],
|
||||
pageId: number,
|
||||
parentId: number,
|
||||
) {
|
||||
if (!Number.isSafeInteger(parentId) || parentId < -1)
|
||||
throw new CatalogInputError("Invalid parent category");
|
||||
const map = new Map(pages.map((page) => [page.id, page]));
|
||||
const visited = new Set<number>();
|
||||
let current = parentId;
|
||||
while (current > 0) {
|
||||
if (current === pageId || visited.has(current))
|
||||
throw new CatalogInputError(
|
||||
"Cannot move page: would create a circular hierarchy",
|
||||
);
|
||||
visited.add(current);
|
||||
const parent = map.get(current);
|
||||
if (!parent) throw new CatalogInputError("Parent category does not exist");
|
||||
current = parent.parentId;
|
||||
}
|
||||
}
|
||||
export function collectSubtree(
|
||||
pages: readonly HierarchyPage[],
|
||||
pageId: number,
|
||||
): number[] {
|
||||
const children = new Map<number, number[]>();
|
||||
for (const page of pages) {
|
||||
const list = children.get(page.parentId) ?? [];
|
||||
list.push(page.id);
|
||||
children.set(page.parentId, list);
|
||||
}
|
||||
const seen = new Set<number>();
|
||||
const queue = [pageId];
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
const id = queue[index];
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
queue.push(...(children.get(id) ?? []));
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
export function validateSiblingOrder(
|
||||
pages: readonly HierarchyPage[],
|
||||
parentId: number,
|
||||
ids: readonly number[],
|
||||
expected: readonly number[],
|
||||
) {
|
||||
const current = pages
|
||||
.filter((page) =>
|
||||
parentId <= 0
|
||||
? page.parentId === -1 || page.parentId === 0
|
||||
: page.parentId === parentId,
|
||||
)
|
||||
.sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0) || a.id - b.id)
|
||||
.map((page) => page.id);
|
||||
if (
|
||||
current.length !== expected.length ||
|
||||
current.some((id, index) => id !== expected[index])
|
||||
)
|
||||
throw new CatalogConflict(
|
||||
"The category order changed. Reload before trying again.",
|
||||
);
|
||||
if (
|
||||
ids.length !== current.length ||
|
||||
new Set(ids).size !== ids.length ||
|
||||
ids.some((id) => !current.includes(id))
|
||||
)
|
||||
throw new CatalogInputError(
|
||||
"A reorder must include every sibling exactly once",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
numericValue,
|
||||
offerPatchSchema,
|
||||
parseFurnitureIds,
|
||||
} from "./offer-input";
|
||||
|
||||
describe("offer compatibility", () => {
|
||||
it.each([0, "0", -1, "-1"])(
|
||||
"preserves explicit numeric value %s",
|
||||
(value) => {
|
||||
expect(numericValue(value, 99)).toBe(Number(value));
|
||||
},
|
||||
);
|
||||
it.each([null, undefined, "", "invalid", Infinity])(
|
||||
"defaults missing or invalid value %s",
|
||||
(value) => {
|
||||
expect(numericValue(value, 99)).toBe(99);
|
||||
},
|
||||
);
|
||||
it("preserves bundle multiplicity and LTD/special offer values", () => {
|
||||
expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]);
|
||||
const values = {
|
||||
itemIds: "12;23;12",
|
||||
amount: 0,
|
||||
offerId: -1,
|
||||
orderNumber: 0,
|
||||
limitedSells: 4,
|
||||
limitedStack: 20,
|
||||
songId: 0,
|
||||
extradata: "a;b",
|
||||
haveOffer: "0",
|
||||
clubOnly: "1",
|
||||
};
|
||||
expect(offerPatchSchema.parse(values)).toEqual(values);
|
||||
});
|
||||
it.each(["1;0", "1;2x", "1;;2", "-1", "1.5"])(
|
||||
"rejects invalid furniture references %s",
|
||||
(value) => {
|
||||
expect(() => parseFurnitureIds(value)).toThrow();
|
||||
},
|
||||
);
|
||||
it("rejects invalid page ids and nonfinite prices without changing field meaning", () => {
|
||||
expect(offerPatchSchema.safeParse({ pageId: 0 }).success).toBe(false);
|
||||
expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(offerPatchSchema.parse({ offerId: 0, amount: 0 })).toEqual({
|
||||
offerId: 0,
|
||||
amount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const offerInteger = z.number().int().min(-2147483648).max(2147483647);
|
||||
export const offerIdSchema = offerInteger.positive();
|
||||
|
||||
/** Missing SQL values use the field default; explicit zero and special negatives survive. */
|
||||
export function numericValue(value: unknown, fallback: number): number {
|
||||
if (value == null || (typeof value === "string" && value.trim() === ""))
|
||||
return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
export function parseFurnitureIds(value: string): number[] {
|
||||
const parts = value.split(";");
|
||||
if (!parts.length || parts.some((part) => !/^\d+$/.test(part.trim())))
|
||||
throw Error("Invalid furniture references");
|
||||
return parts.map((part) => offerIdSchema.parse(Number(part)));
|
||||
}
|
||||
const itemIds = z
|
||||
.string()
|
||||
.max(666)
|
||||
.refine((value) => {
|
||||
try {
|
||||
parseFurnitureIds(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "Invalid furniture references");
|
||||
// Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator.
|
||||
export const offerPatchSchema = z.object({
|
||||
pageId: offerIdSchema.optional(),
|
||||
itemIds: itemIds.optional(),
|
||||
catalogName: z.string().max(100).optional(),
|
||||
costCredits: offerInteger.optional(),
|
||||
costPoints: offerInteger.optional(),
|
||||
pointsType: offerInteger.optional(),
|
||||
amount: offerInteger.optional(),
|
||||
orderNumber: offerInteger.optional(),
|
||||
offerId: offerInteger.optional(),
|
||||
songId: z.number().int().min(0).max(4294967295).optional(),
|
||||
limitedSells: offerInteger.optional(),
|
||||
limitedStack: offerInteger.optional(),
|
||||
extradata: z.string().max(500).optional(),
|
||||
haveOffer: z.enum(["0", "1"]).optional(),
|
||||
clubOnly: z.enum(["0", "1"]).optional(),
|
||||
});
|
||||
const flag = z.union([
|
||||
z.literal(0),
|
||||
z.literal(1),
|
||||
z.literal("0"),
|
||||
z.literal("1"),
|
||||
]);
|
||||
export const furniturePatchSchema = z.object({
|
||||
publicName: z.string().max(56).optional(),
|
||||
itemName: z.string().max(70).optional(),
|
||||
type: z.string().max(3).optional(),
|
||||
width: offerInteger.optional(),
|
||||
length: offerInteger.optional(),
|
||||
stackHeight: z.number().finite().optional(),
|
||||
allowStack: flag.optional(),
|
||||
allowSit: flag.optional(),
|
||||
allowLay: flag.optional(),
|
||||
allowWalk: flag.optional(),
|
||||
allowGift: flag.optional(),
|
||||
allowTrade: flag.optional(),
|
||||
allowRecycle: flag.optional(),
|
||||
allowMarketplaceSell: flag.optional(),
|
||||
allowInventoryStack: flag.optional(),
|
||||
interactionType: z.string().max(500).optional(),
|
||||
interactionModesCount: offerInteger.optional(),
|
||||
vendingIds: z.string().max(255).optional(),
|
||||
customparams: z.string().max(25600).optional(),
|
||||
effectIdMale: offerInteger.optional(),
|
||||
effectIdFemale: offerInteger.optional(),
|
||||
clothingOnWalk: z.string().max(255).optional(),
|
||||
});
|
||||
export function distinctOfferIds(ids: number[]): number[] {
|
||||
for (const id of ids) offerIdSchema.parse(id);
|
||||
if (new Set(ids).size !== ids.length)
|
||||
throw Error("Duplicate catalog item ids");
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { pagePatchSchema } from "./page-input";
|
||||
|
||||
it("preserves nullable page texts when editing unrelated fields", () => {
|
||||
const fields = {
|
||||
caption: "Changed",
|
||||
pageText1: null,
|
||||
pageText2: null,
|
||||
pageTextDetails: null,
|
||||
pageTextTeaser: null,
|
||||
pageSpecial: null,
|
||||
};
|
||||
expect(pagePatchSchema.parse(fields)).toEqual(fields);
|
||||
});
|
||||
it("preserves valid zero values and rejects invalid flags", () => {
|
||||
expect(
|
||||
pagePatchSchema.parse({ parentId: 0, orderNum: 0, iconImage: 0 }),
|
||||
).toEqual({ parentId: 0, orderNum: 0, iconImage: 0 });
|
||||
expect(() => pagePatchSchema.parse({ enabled: "true" })).toThrow();
|
||||
});
|
||||
|
||||
it("preserves caption whitespace for optimistic comparisons but rejects blank captions", () => {
|
||||
expect(pagePatchSchema.parse({ caption: " Existing " })).toEqual({
|
||||
caption: " Existing ",
|
||||
});
|
||||
expect(() => pagePatchSchema.parse({ caption: " " })).toThrow();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const flag = z.enum(["0", "1"]);
|
||||
const integer = z.number().int().min(-2147483648).max(2147483647);
|
||||
export const pagePatchSchema = z.object({
|
||||
caption: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.refine((value) => value.trim().length > 0, "Category name is required")
|
||||
.optional(),
|
||||
captionSave: z.string().max(25).optional(),
|
||||
parentId: integer.min(-1).optional(),
|
||||
pageLayout: z.string().min(1).max(26).optional(),
|
||||
enabled: flag.optional(),
|
||||
visible: flag.optional(),
|
||||
clubOnly: flag.optional(),
|
||||
vipOnly: flag.optional(),
|
||||
minRank: integer.min(0).optional(),
|
||||
orderNum: integer.optional(),
|
||||
iconImage: integer.min(0).optional(),
|
||||
iconColor: integer.min(0).optional(),
|
||||
pageHeadline: z.string().max(1024).optional(),
|
||||
pageTeaser: z.string().max(64).optional(),
|
||||
pageSpecial: z.string().max(2048).nullable().optional(),
|
||||
pageText1: z.string().nullable().optional(),
|
||||
pageText2: z.string().nullable().optional(),
|
||||
pageTextDetails: z.string().nullable().optional(),
|
||||
pageTextTeaser: z.string().nullable().optional(),
|
||||
includes: z.string().max(128).optional(),
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCatalogSearch, validSearchQuery } from "./search";
|
||||
|
||||
describe("catalog search input", () => {
|
||||
it("allows a one digit ID but requires at least two text characters", () => {
|
||||
expect(validSearchQuery("7")).toBe(true);
|
||||
expect(validSearchQuery("a")).toBe(false);
|
||||
expect(validSearchQuery(" ab ")).toBe(true);
|
||||
});
|
||||
it("does not round oversized numeric identifiers", () => {
|
||||
expect(parseCatalogSearch("normal", "9007199254740992").numericId).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
it("escapes LIKE wildcard and escape characters", () => {
|
||||
expect(parseCatalogSearch("bc", "a_%=").pattern).toBe("a=_=%==%");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CatalogInputError } from "./hierarchy";
|
||||
export type SearchCatalogKind = "normal" | "bc";
|
||||
export interface CatalogSearchResult {
|
||||
kind: "category" | "offer";
|
||||
id: number;
|
||||
pageId: number;
|
||||
caption: string;
|
||||
path: string[];
|
||||
href: string;
|
||||
furniture?: { id: number; classname: string; spriteId: number };
|
||||
}
|
||||
export function validSearchQuery(query: string) {
|
||||
const value = query.trim();
|
||||
return query.length <= 100 && (value.length >= 2 || /^\d$/.test(value));
|
||||
}
|
||||
export function parseCatalogSearch(kind: string, query: string) {
|
||||
if (kind !== "normal" && kind !== "bc")
|
||||
throw new CatalogInputError("Invalid catalog type");
|
||||
if (!validSearchQuery(query))
|
||||
throw new CatalogInputError(
|
||||
"Search requires 2 to 100 characters or a numeric ID",
|
||||
);
|
||||
const value = query.trim();
|
||||
const parsed = /^\d+$/.test(value) ? Number(value) : NaN;
|
||||
const numericId = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
return {
|
||||
kind,
|
||||
query: value,
|
||||
numericId,
|
||||
pattern: `${value.replace(/[=%_]/g, (char) => `=${char}`)}%`,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
offers: [
|
||||
{ id: 1, pageId: 10, itemIds: "12;23;12" },
|
||||
{ id: 2, pageId: 10, itemIds: "23" },
|
||||
],
|
||||
pages: [{ id: 10 }, { id: 11 }],
|
||||
bases: [{ id: 12 }, { id: 23 }],
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
writes: 0,
|
||||
failAt: 0,
|
||||
commits: 0,
|
||||
rollbacks: 0,
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({
|
||||
...(await import("@/db/schema")),
|
||||
db: {
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const saved = state.writes;
|
||||
try {
|
||||
const result = await fn({
|
||||
execute: async (query: SQL) => {
|
||||
const compiled = new MySqlDialect().sqlToQuery(query);
|
||||
state.queries.push(compiled);
|
||||
if (compiled.sql.startsWith("SELECT")) {
|
||||
if (compiled.sql.includes("catalog_pages"))
|
||||
return [state.pages, []];
|
||||
if (compiled.sql.includes("items_base"))
|
||||
return [
|
||||
state.bases.filter((row) => compiled.params.includes(row.id)),
|
||||
[],
|
||||
];
|
||||
return [
|
||||
state.offers.filter((row) => compiled.params.includes(row.id)),
|
||||
[],
|
||||
];
|
||||
}
|
||||
state.writes++;
|
||||
if (state.failAt === state.writes)
|
||||
throw Error("database unavailable");
|
||||
return [{ affectedRows: 1, insertId: 40 }, []];
|
||||
},
|
||||
});
|
||||
state.commits++;
|
||||
return result;
|
||||
} catch (error) {
|
||||
state.writes = saved;
|
||||
state.rollbacks++;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createBcOfferCommand,
|
||||
createOfferCommand,
|
||||
moveOffersCommand,
|
||||
reorderOffersCommand,
|
||||
updateBcOfferCommand,
|
||||
updateOfferCommand,
|
||||
} from "./offer-commands";
|
||||
|
||||
beforeEach(() => {
|
||||
state.pages = [{ id: 10 }, { id: 11 }];
|
||||
state.queries = [];
|
||||
state.writes = 0;
|
||||
state.failAt = 0;
|
||||
state.commits = 0;
|
||||
state.rollbacks = 0;
|
||||
});
|
||||
describe("transactional offer commands", () => {
|
||||
it("rolls back offer and shared furniture writes together", async () => {
|
||||
state.failAt = 2;
|
||||
await expect(
|
||||
updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { pageId: 11, costCredits: 0 },
|
||||
baseItem: { id: 23, fields: { publicName: "New" } },
|
||||
}),
|
||||
).rejects.toThrow("database unavailable");
|
||||
expect(state.writes).toBe(0);
|
||||
expect(state.rollbacks).toBe(1);
|
||||
});
|
||||
it("prevents writing an unrelated shared furniture even when submitted as a new bundle member", async () => {
|
||||
await expect(
|
||||
updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { itemIds: "12;99" },
|
||||
baseItem: { id: 99, fields: { publicName: "Wrong" } },
|
||||
}),
|
||||
).rejects.toThrow("belong");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("updates a non-first bundle component and preserves zero values", async () => {
|
||||
await updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { pageId: 11, amount: 0, offerId: 0, orderNumber: 0 },
|
||||
baseItem: { id: 23, fields: { publicName: "New" } },
|
||||
});
|
||||
expect(state.commits).toBe(1);
|
||||
expect(state.writes).toBe(2);
|
||||
expect(
|
||||
state.queries.find((q) => q.sql.startsWith("UPDATE `catalog_items`"))
|
||||
?.params,
|
||||
).toContain("11");
|
||||
});
|
||||
it("rejects missing destination pages before writes", async () => {
|
||||
state.pages = [];
|
||||
await expect(
|
||||
updateOfferCommand({ id: 1, catalogFields: { pageId: 11 } }),
|
||||
).rejects.toThrow("page");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rejects missing bundle references before writes", async () => {
|
||||
await expect(
|
||||
updateOfferCommand({ id: 1, catalogFields: { itemIds: "12;99" } }),
|
||||
).rejects.toThrow("Furniture");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rolls back the entire reorder after an intermediate failure", async () => {
|
||||
state.failAt = 2;
|
||||
await expect(
|
||||
reorderOffersCommand([
|
||||
{ id: 1, orderNumber: 0 },
|
||||
{ id: 2, orderNumber: 1 },
|
||||
]),
|
||||
).rejects.toThrow("database unavailable");
|
||||
expect(state.writes).toBe(0);
|
||||
expect(state.rollbacks).toBe(1);
|
||||
});
|
||||
it("rejects duplicate reorder ids", async () => {
|
||||
await expect(
|
||||
reorderOffersCommand([
|
||||
{ id: 1, orderNumber: 0 },
|
||||
{ id: 1, orderNumber: 1 },
|
||||
]),
|
||||
).rejects.toThrow();
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rejects missing offers in a batch", async () => {
|
||||
await expect(
|
||||
reorderOffersCommand([
|
||||
{ id: 1, orderNumber: 0 },
|
||||
{ id: 99, orderNumber: 1 },
|
||||
]),
|
||||
).rejects.toThrow("not found");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("validates all source offers before moving a batch", async () => {
|
||||
await expect(moveOffersCommand([1, 99], 11)).rejects.toThrow("not found");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not update furniture removed from the submitted bundle", async () => {
|
||||
await expect(
|
||||
updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { itemIds: "12" },
|
||||
baseItem: { id: 23, fields: { publicName: "Wrong" } },
|
||||
}),
|
||||
).rejects.toThrow("belong");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("keeps explicit zeros in the actual offer SQL parameters", async () => {
|
||||
await updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: {
|
||||
amount: 0,
|
||||
orderNumber: 0,
|
||||
offerId: 0,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
},
|
||||
});
|
||||
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
|
||||
expect(update?.params).toEqual([0, 0, 0, 0, 0, 1]);
|
||||
});
|
||||
it("rejects unsafe ids before opening a transaction", async () => {
|
||||
await expect(
|
||||
updateOfferCommand({ id: 0, catalogFields: { amount: 1 } }),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
reorderOffersCommand([{ id: 1.5, orderNumber: 1 }]),
|
||||
).rejects.toThrow();
|
||||
expect(state.queries).toHaveLength(0);
|
||||
});
|
||||
it("uses one atomic update and a string page parameter for a move", async () => {
|
||||
await moveOffersCommand([1, 2], 11);
|
||||
expect(state.commits).toBe(1);
|
||||
expect(state.writes).toBe(1);
|
||||
expect(
|
||||
state.queries.find((query) => query.sql.startsWith("UPDATE"))?.params,
|
||||
).toEqual(["11", 1, 2]);
|
||||
});
|
||||
it("ignores noneditable furniture and offer fields", async () => {
|
||||
await expect(
|
||||
updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { id: 2 },
|
||||
baseItem: { id: 12, fields: { spriteId: 99 } },
|
||||
}),
|
||||
).rejects.toThrow("No valid fields");
|
||||
expect(state.queries).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Creation must retain the parent lock until its insert commits.
|
||||
it.each(["normal", "bc"] as const)(
|
||||
"rejects a deleted parent before a %s creation callback runs",
|
||||
async (kind) => {
|
||||
state.pages = [];
|
||||
const insert = vi.fn();
|
||||
await expect(
|
||||
createOfferCommand(kind, { pageId: 11, itemIds: "12;23" }, insert),
|
||||
).rejects.toThrow("page");
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(state.writes).toBe(0);
|
||||
},
|
||||
);
|
||||
it("rejects missing furniture before creating an offer", async () => {
|
||||
const insert = vi.fn();
|
||||
await expect(
|
||||
createOfferCommand("normal", { pageId: 11, itemIds: "12;99" }, insert),
|
||||
).rejects.toThrow("Furniture");
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
});
|
||||
it("runs creation insert under the same transaction after reference locks", async () => {
|
||||
await createOfferCommand(
|
||||
"normal",
|
||||
{ pageId: 11, itemIds: "12;23;12" },
|
||||
async (tx) => {
|
||||
await tx.execute(sql`INSERT INTO catalog_items (id) VALUES (40)`);
|
||||
return 40;
|
||||
},
|
||||
);
|
||||
expect(state.queries.map((query) => query.sql.split(" ")[0])).toEqual([
|
||||
"SELECT",
|
||||
"SELECT",
|
||||
"INSERT",
|
||||
]);
|
||||
expect(state.queries[0].sql).toContain("FOR UPDATE");
|
||||
expect(state.queries[1].sql).toContain("FOR UPDATE");
|
||||
expect(state.commits).toBe(1);
|
||||
});
|
||||
it("rolls back a failed creation insert", async () => {
|
||||
state.failAt = 1;
|
||||
await expect(
|
||||
createOfferCommand("normal", { pageId: 11, itemIds: "12" }, (tx) =>
|
||||
tx.execute(sql`INSERT INTO catalog_items (id) VALUES (40)`),
|
||||
),
|
||||
).rejects.toThrow("database unavailable");
|
||||
expect(state.rollbacks).toBe(1);
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rejects missing BC offers instead of reporting a zero-row update as saved", async () => {
|
||||
await expect(
|
||||
updateBcOfferCommand(99, { catalogName: "Missing" }),
|
||||
).rejects.toThrow("not found");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("keeps BC fields explicit and uses BC destination locks", async () => {
|
||||
await updateBcOfferCommand(1, {
|
||||
pageId: 11,
|
||||
itemIds: "12;23",
|
||||
orderNumber: 0,
|
||||
costCredits: 99,
|
||||
});
|
||||
expect(state.queries[0].sql).toContain("catalog_pages_bc");
|
||||
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
|
||||
expect(update?.sql).toContain("catalog_items_bc");
|
||||
expect(update?.sql).not.toContain("cost_credits");
|
||||
expect(update?.params).toEqual(["11", "12;23", 0, 1]);
|
||||
});
|
||||
|
||||
it("creates a BC offer with only its supported fields and returns the committed id", async () => {
|
||||
const id = await createBcOfferCommand({
|
||||
pageId: 11,
|
||||
itemIds: "12;23;12",
|
||||
catalogName: "Bundle",
|
||||
orderNumber: 0,
|
||||
extradata: "x;y",
|
||||
costCredits: 99,
|
||||
});
|
||||
expect(id).toBe(40);
|
||||
expect(state.commits).toBe(1);
|
||||
const insert = state.queries.find((query) => query.sql.startsWith("INSERT"));
|
||||
expect(insert?.sql).toContain("catalog_items_bc");
|
||||
expect(insert?.sql).not.toContain("cost_credits");
|
||||
expect(insert?.params).toEqual(["11", "12;23;12", "Bundle", 0, "x;y"]);
|
||||
});
|
||||
it("rejects incomplete BC creation before beginning a transaction", async () => {
|
||||
await expect(
|
||||
createBcOfferCommand({ pageId: 11, itemIds: "12" }),
|
||||
).rejects.toThrow();
|
||||
expect(state.queries).toHaveLength(0);
|
||||
});
|
||||
it("rolls back failed BC creation and does not return an id", async () => {
|
||||
state.failAt = 1;
|
||||
await expect(
|
||||
createBcOfferCommand({
|
||||
pageId: 11,
|
||||
itemIds: "12",
|
||||
catalogName: "One",
|
||||
orderNumber: 0,
|
||||
extradata: "",
|
||||
}),
|
||||
).rejects.toThrow("database unavailable");
|
||||
expect(state.commits).toBe(0);
|
||||
expect(state.rollbacks).toBe(1);
|
||||
});
|
||||
it("rejects a missing BC destination before touching the offer", async () => {
|
||||
state.pages = [];
|
||||
await expect(updateBcOfferCommand(1, { pageId: 11 })).rejects.toThrow("page");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import "server-only";
|
||||
import { getTableColumns, type SQL, sql } from "drizzle-orm";
|
||||
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
|
||||
import {
|
||||
distinctOfferIds,
|
||||
furniturePatchSchema,
|
||||
offerIdSchema,
|
||||
offerInteger,
|
||||
offerPatchSchema,
|
||||
parseFurnitureIds,
|
||||
} from "../domain/offer-input";
|
||||
|
||||
type OfferKind = "normal" | "bc";
|
||||
function offerTable(kind: OfferKind) {
|
||||
if (kind !== "normal" && kind !== "bc") throw Error("Invalid catalog type");
|
||||
return kind === "bc" ? CatalogItemsBc : CatalogItems;
|
||||
}
|
||||
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
export type UpdateOfferInput = {
|
||||
id: number;
|
||||
catalogFields: Record<string, unknown>;
|
||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||
};
|
||||
type OfferRow = { id: number; pageId: number | string; itemIds: string };
|
||||
async function lockedOffers(
|
||||
tx: Transaction,
|
||||
ids: number[],
|
||||
kind: OfferKind = "normal",
|
||||
): Promise<OfferRow[]> {
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join(
|
||||
[...ids].sort((a, b) => a - b),
|
||||
sql`, `,
|
||||
)}) ORDER BY id FOR UPDATE`,
|
||||
);
|
||||
const offers = rows as unknown as OfferRow[];
|
||||
if (
|
||||
offers.length !== ids.length ||
|
||||
ids.some((id) => !offers.some((offer) => Number(offer.id) === id))
|
||||
)
|
||||
throw Error("Catalog item not found");
|
||||
return offers;
|
||||
}
|
||||
async function lockPage(
|
||||
tx: Transaction,
|
||||
id: number,
|
||||
kind: OfferKind = "normal",
|
||||
) {
|
||||
offerTable(kind);
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id FROM ${sql.identifier(kind === "bc" ? "catalog_pages_bc" : "catalog_pages")} WHERE id=${id} FOR UPDATE`,
|
||||
);
|
||||
if (
|
||||
!(rows as unknown as { id: number }[]).some((row) => Number(row.id) === id)
|
||||
)
|
||||
throw Error("Catalog page not found");
|
||||
}
|
||||
async function lockFurniture(tx: Transaction, ids: number[]) {
|
||||
const unique = [...new Set(ids)].sort((a, b) => a - b);
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id FROM items_base WHERE id IN (${sql.join(unique, sql`, `)}) ORDER BY id FOR UPDATE`,
|
||||
);
|
||||
const found = new Set(
|
||||
(rows as unknown as { id: number }[]).map((row) => Number(row.id)),
|
||||
);
|
||||
if (unique.some((id) => !found.has(id)))
|
||||
throw Error("Furniture reference not found");
|
||||
}
|
||||
function assignments(
|
||||
table: typeof CatalogItems | typeof CatalogItemsBc | typeof ItemsBase,
|
||||
fields: Record<string, unknown>,
|
||||
): SQL[] {
|
||||
const columns = getTableColumns(table);
|
||||
return Object.entries(fields)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => {
|
||||
const column = columns[key as keyof typeof columns];
|
||||
// SQL string parameters work with both deployed page_id column types.
|
||||
return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`;
|
||||
});
|
||||
}
|
||||
export async function updateOfferCommand(input: UpdateOfferInput) {
|
||||
offerIdSchema.parse(input.id);
|
||||
const fields = offerPatchSchema.parse(input.catalogFields);
|
||||
const baseFields = input.baseItem
|
||||
? furniturePatchSchema.parse(input.baseItem.fields)
|
||||
: {};
|
||||
if (input.baseItem) offerIdSchema.parse(input.baseItem.id);
|
||||
if (
|
||||
!Object.values(fields).some((value) => value !== undefined) &&
|
||||
!Object.values(baseFields).some((value) => value !== undefined)
|
||||
)
|
||||
throw Error("No valid fields to update");
|
||||
return db.transaction(async (tx) => {
|
||||
// Page locks precede offer locks, matching category deletion's lock order.
|
||||
if (fields.pageId !== undefined) await lockPage(tx, fields.pageId);
|
||||
const [offer] = await lockedOffers(tx, [input.id]);
|
||||
if (input.baseItem) {
|
||||
const currentIds = String(offer.itemIds).split(";").map(Number);
|
||||
const nextIds =
|
||||
fields.itemIds === undefined
|
||||
? currentIds
|
||||
: parseFurnitureIds(fields.itemIds);
|
||||
if (
|
||||
!currentIds.includes(input.baseItem.id) ||
|
||||
!nextIds.includes(input.baseItem.id)
|
||||
)
|
||||
throw Error("Shared furniture must belong to the catalog offer");
|
||||
}
|
||||
const references =
|
||||
fields.itemIds === undefined ? [] : parseFurnitureIds(fields.itemIds);
|
||||
if (input.baseItem) references.push(input.baseItem.id);
|
||||
if (references.length) await lockFurniture(tx, references);
|
||||
const offerAssignments = assignments(CatalogItems, fields);
|
||||
if (offerAssignments.length)
|
||||
await tx.execute(
|
||||
sql`UPDATE ${CatalogItems} SET ${sql.join(offerAssignments, sql`, `)} WHERE id=${input.id}`,
|
||||
);
|
||||
const baseAssignments = assignments(ItemsBase, baseFields);
|
||||
if (input.baseItem && baseAssignments.length)
|
||||
await tx.execute(
|
||||
sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${input.baseItem.id}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
export async function reorderOffersCommand(
|
||||
orders: Array<{ id: number; orderNumber: number }>,
|
||||
) {
|
||||
const ids = distinctOfferIds(orders.map((row) => row.id));
|
||||
for (const row of orders) offerInteger.parse(row.orderNumber);
|
||||
if (!ids.length) return;
|
||||
return db.transaction(async (tx) => {
|
||||
await lockedOffers(tx, ids);
|
||||
for (const row of orders)
|
||||
await tx.execute(
|
||||
sql`UPDATE ${CatalogItems} SET order_number=${row.orderNumber} WHERE id=${row.id}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
export async function moveOffersCommand(ids: number[], targetPageId: number) {
|
||||
distinctOfferIds(ids);
|
||||
offerIdSchema.parse(targetPageId);
|
||||
if (!ids.length) return;
|
||||
return db.transaction(async (tx) => {
|
||||
await lockPage(tx, targetPageId);
|
||||
await lockedOffers(tx, ids);
|
||||
await tx.execute(
|
||||
sql`UPDATE ${CatalogItems} SET page_id=${String(targetPageId)} WHERE id IN (${sql.join(ids, sql`, `)})`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Call the allocator before entering this command; only the insert uses its transaction. */
|
||||
export async function createOfferCommand<T>(
|
||||
kind: OfferKind,
|
||||
references: { pageId: number; itemIds: string },
|
||||
insert: (tx: Transaction) => Promise<T>,
|
||||
): Promise<T> {
|
||||
offerTable(kind);
|
||||
offerIdSchema.parse(references.pageId);
|
||||
const ids = parseFurnitureIds(references.itemIds);
|
||||
return db.transaction(async (tx) => {
|
||||
await lockPage(tx, references.pageId, kind);
|
||||
await lockFurniture(tx, ids);
|
||||
return insert(tx);
|
||||
});
|
||||
}
|
||||
// BC deliberately has no currencies, amount, LTD values or shared-furniture mutation.
|
||||
const bcOfferPatchSchema = offerPatchSchema.pick({
|
||||
pageId: true,
|
||||
itemIds: true,
|
||||
catalogName: true,
|
||||
orderNumber: true,
|
||||
extradata: true,
|
||||
});
|
||||
export async function updateBcOfferCommand(
|
||||
id: number,
|
||||
input: Record<string, unknown>,
|
||||
) {
|
||||
offerIdSchema.parse(id);
|
||||
const fields = bcOfferPatchSchema.parse(input);
|
||||
const updates = assignments(CatalogItemsBc, fields);
|
||||
if (!updates.length) throw Error("No valid fields to update");
|
||||
return db.transaction(async (tx) => {
|
||||
if (fields.pageId !== undefined) await lockPage(tx, fields.pageId, "bc");
|
||||
await lockedOffers(tx, [id], "bc");
|
||||
if (fields.itemIds !== undefined)
|
||||
await lockFurniture(tx, parseFurnitureIds(fields.itemIds));
|
||||
await tx.execute(
|
||||
sql`UPDATE ${CatalogItemsBc} SET ${sql.join(updates, sql`, `)} WHERE id=${id}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
export async function createBcOfferCommand(
|
||||
input: Record<string, unknown>,
|
||||
): Promise<number> {
|
||||
const fields = bcOfferPatchSchema.required().parse(input);
|
||||
return createOfferCommand("bc", fields, async (tx) => {
|
||||
const [result] = await tx.execute(
|
||||
sql`INSERT INTO ${CatalogItemsBc} SET ${sql.join(assignments(CatalogItemsBc, fields), sql`, `)}`,
|
||||
);
|
||||
const id = Number((result as unknown as { insertId: number }).insertId);
|
||||
offerIdSchema.parse(id);
|
||||
return id;
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: [
|
||||
{ id: 1, parentId: -1, orderNum: 1, caption: "Current" },
|
||||
{ id: 2, parentId: 1, orderNum: 1 },
|
||||
{ id: 3, parentId: 1, orderNum: 2 },
|
||||
],
|
||||
queries: [] as string[],
|
||||
writes: 0,
|
||||
failAt: 0,
|
||||
commits: 0,
|
||||
rollbacks: 0,
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({
|
||||
...(await import("@/db/schema")),
|
||||
db: {
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const saved = state.writes;
|
||||
try {
|
||||
const result = await fn({
|
||||
execute: async (query: SQL) => {
|
||||
const text = new MySqlDialect().sqlToQuery(query).sql;
|
||||
state.queries.push(text);
|
||||
if (text.startsWith("SELECT")) return [state.rows, []];
|
||||
state.writes++;
|
||||
if (state.failAt === state.writes)
|
||||
throw Error("database unavailable");
|
||||
return [{ insertId: 4, affectedRows: 1 }, []];
|
||||
},
|
||||
});
|
||||
state.commits++;
|
||||
return result;
|
||||
} catch (error) {
|
||||
state.writes = saved;
|
||||
state.rollbacks++;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
deletePageCommand,
|
||||
reorderPagesCommand,
|
||||
updatePageCommand,
|
||||
} from "./page-commands";
|
||||
|
||||
beforeEach(() => {
|
||||
state.queries = [];
|
||||
state.writes = 0;
|
||||
state.failAt = 0;
|
||||
state.commits = 0;
|
||||
state.rollbacks = 0;
|
||||
});
|
||||
describe.each(["normal", "bc"] as const)(
|
||||
"%s transactional commands",
|
||||
(kind) => {
|
||||
it("rejects stale category fields without overwriting another operator", async () => {
|
||||
await expect(
|
||||
updatePageCommand(kind, 1, { caption: "Mine" }, { caption: "Old" }),
|
||||
).rejects.toThrow("another operator");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("accepts matching category fields", async () => {
|
||||
await updatePageCommand(
|
||||
kind,
|
||||
1,
|
||||
{ caption: "Mine" },
|
||||
{ caption: "Current" },
|
||||
);
|
||||
expect(state.commits).toBe(1);
|
||||
});
|
||||
it("rolls back an interrupted reorder", async () => {
|
||||
state.failAt = 2;
|
||||
await expect(
|
||||
reorderPagesCommand(kind, {
|
||||
parentId: 1,
|
||||
ids: [3, 2],
|
||||
expectedIds: [2, 3],
|
||||
}),
|
||||
).rejects.toThrow("database unavailable");
|
||||
expect(state.writes).toBe(0);
|
||||
expect(state.rollbacks).toBe(1);
|
||||
expect(state.queries[0]).toContain("ORDER BY id FOR UPDATE");
|
||||
});
|
||||
it("rejects stale reorder before any writes", async () => {
|
||||
await expect(
|
||||
reorderPagesCommand(kind, {
|
||||
parentId: 1,
|
||||
ids: [3, 2],
|
||||
expectedIds: [3, 2],
|
||||
}),
|
||||
).rejects.toThrow("changed");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rejects a cyclic parent through ordinary page editing", async () => {
|
||||
await expect(updatePageCommand(kind, 1, { parentId: 2 })).rejects.toThrow(
|
||||
"circular",
|
||||
);
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rolls back reparent plus delete if the offer delete fails", async () => {
|
||||
state.failAt = 2;
|
||||
await expect(deletePageCommand(kind, 2, "reparent")).rejects.toThrow();
|
||||
expect(state.writes).toBe(0);
|
||||
expect(state.commits).toBe(0);
|
||||
});
|
||||
it("commits the entire subtree delete once", async () => {
|
||||
expect(await deletePageCommand(kind, 1, "cascade")).toEqual({
|
||||
deletedPages: 3,
|
||||
movedChildren: 0,
|
||||
});
|
||||
expect(state.commits).toBe(1);
|
||||
expect(state.writes).toBe(2);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,169 @@
|
||||
import "server-only";
|
||||
import { getTableColumns, type SQL, sql } from "drizzle-orm";
|
||||
import { CatalogPages, CatalogPagesBc, db } from "@/lib/db";
|
||||
import {
|
||||
assertParent,
|
||||
CatalogConflict,
|
||||
CatalogInputError,
|
||||
CatalogNotFound,
|
||||
collectSubtree,
|
||||
type HierarchyPage,
|
||||
positiveId,
|
||||
validateSiblingOrder,
|
||||
} from "../domain/hierarchy";
|
||||
import { pagePatchSchema } from "../domain/page-input";
|
||||
export type CatalogKind = "normal" | "bc";
|
||||
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
function tables(kind: CatalogKind) {
|
||||
if (kind !== "normal" && kind !== "bc")
|
||||
throw new CatalogInputError("Invalid catalog type");
|
||||
return {
|
||||
pages: kind === "bc" ? CatalogPagesBc : CatalogPages,
|
||||
items: sql.identifier(kind === "bc" ? "catalog_items_bc" : "catalog_items"),
|
||||
};
|
||||
}
|
||||
async function lockedPages(
|
||||
tx: Transaction,
|
||||
kind: CatalogKind,
|
||||
): Promise<HierarchyPage[]> {
|
||||
const { pages } = tables(kind);
|
||||
// Structural commands serialize against a stable lock order, including parent validation.
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM ${pages} ORDER BY id FOR UPDATE`,
|
||||
);
|
||||
return (rows as unknown as HierarchyPage[]).map((row) => ({
|
||||
id: Number(row.id),
|
||||
parentId: Number(row.parentId),
|
||||
orderNum: Number(row.orderNum),
|
||||
}));
|
||||
}
|
||||
function fieldsSql(kind: CatalogKind, fields: Record<string, unknown>): SQL[] {
|
||||
const columns = getTableColumns(tables(kind).pages);
|
||||
return Object.entries(fields)
|
||||
.filter(
|
||||
([key, value]) => value !== undefined && Object.hasOwn(columns, key),
|
||||
)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
sql`${sql.identifier(columns[key as keyof typeof columns].name)} = ${value}`,
|
||||
);
|
||||
}
|
||||
export async function updatePageCommand(
|
||||
kind: CatalogKind,
|
||||
id: number,
|
||||
fields: Record<string, unknown>,
|
||||
expected?: Record<string, unknown>,
|
||||
) {
|
||||
positiveId(id);
|
||||
const data = pagePatchSchema.parse(fields);
|
||||
return db.transaction(async (tx) => {
|
||||
const rows = await lockedPages(tx, kind);
|
||||
if (!rows.some((row) => row.id === id))
|
||||
throw new CatalogNotFound("Catalog page not found");
|
||||
if (expected) {
|
||||
const columns = getTableColumns(tables(kind).pages);
|
||||
const [existingRows] = await tx.execute(
|
||||
sql`SELECT * FROM ${tables(kind).pages} WHERE id=${id} FOR UPDATE`,
|
||||
);
|
||||
const current = (existingRows as unknown as Record<string, unknown>[])[0];
|
||||
if (!current) throw new CatalogNotFound("Catalog page not found");
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
if (!Object.hasOwn(data, key) || !Object.hasOwn(columns, key)) continue;
|
||||
const column = columns[key as keyof typeof columns];
|
||||
if (String(current[column.name] ?? "") !== String(value ?? ""))
|
||||
throw new CatalogConflict(
|
||||
"This category was modified by another operator. Reload before saving.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.parentId !== undefined) assertParent(rows, id, data.parentId);
|
||||
const assignments = fieldsSql(kind, data);
|
||||
if (!assignments.length)
|
||||
throw new CatalogInputError("No valid fields to update");
|
||||
await tx.execute(
|
||||
sql`UPDATE ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)} WHERE id=${id}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
export async function createPageCommand(
|
||||
kind: CatalogKind,
|
||||
fields: Record<string, unknown>,
|
||||
): Promise<number> {
|
||||
const data = pagePatchSchema.parse(fields);
|
||||
if (!data.caption) throw new CatalogInputError("Category name is required");
|
||||
return db.transaction(async (tx) => {
|
||||
const rows = await lockedPages(tx, kind);
|
||||
assertParent(rows, 0, data.parentId ?? -1);
|
||||
const assignments = fieldsSql(kind, {
|
||||
...data,
|
||||
captionSave: data.captionSave ?? data.caption?.slice(0, 25),
|
||||
});
|
||||
const [result] = await tx.execute(
|
||||
sql`INSERT INTO ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)}`,
|
||||
);
|
||||
return Number((result as unknown as { insertId: number }).insertId);
|
||||
});
|
||||
}
|
||||
export async function reorderPagesCommand(
|
||||
kind: CatalogKind,
|
||||
input: { parentId: number; ids: number[]; expectedIds: number[] },
|
||||
) {
|
||||
return db.transaction(async (tx) => {
|
||||
const rows = await lockedPages(tx, kind);
|
||||
validateSiblingOrder(rows, input.parentId, input.ids, input.expectedIds);
|
||||
for (const [index, id] of input.ids.entries())
|
||||
await tx.execute(
|
||||
sql`UPDATE ${tables(kind).pages} SET order_num=${index + 1} WHERE id=${id}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
export async function deletePageCommand(
|
||||
kind: CatalogKind,
|
||||
id: number,
|
||||
mode: "reparent" | "cascade",
|
||||
) {
|
||||
positiveId(id);
|
||||
if (mode !== "reparent" && mode !== "cascade")
|
||||
throw new CatalogInputError("Invalid delete mode");
|
||||
return db.transaction(async (tx) => {
|
||||
const rows = await lockedPages(tx, kind);
|
||||
const page = rows.find((row) => row.id === id);
|
||||
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
||||
const { pages, items } = tables(kind);
|
||||
let ids = [id];
|
||||
let movedChildren = 0;
|
||||
if (mode === "reparent") {
|
||||
assertParent(rows, id, page.parentId);
|
||||
movedChildren = rows.filter((row) => row.parentId === id).length;
|
||||
await tx.execute(
|
||||
sql`UPDATE ${pages} SET parent_id=${page.parentId} WHERE parent_id=${id}`,
|
||||
);
|
||||
} else ids = collectSubtree(rows, id);
|
||||
// String literals match INT and VARCHAR page_id installations without casting the indexed column.
|
||||
await tx.execute(
|
||||
sql`DELETE FROM ${items} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)})`,
|
||||
);
|
||||
await tx.execute(
|
||||
sql`DELETE FROM ${pages} WHERE id IN (${sql.join(ids, sql`, `)})`,
|
||||
);
|
||||
return { deletedPages: ids.length, movedChildren };
|
||||
});
|
||||
}
|
||||
export async function togglePageCommand(
|
||||
kind: CatalogKind,
|
||||
id: number,
|
||||
field: "enabled" | "visible",
|
||||
) {
|
||||
positiveId(id);
|
||||
if (field !== "enabled" && field !== "visible")
|
||||
throw new CatalogInputError("Invalid toggle field");
|
||||
return db.transaction(async (tx) => {
|
||||
const rows = await lockedPages(tx, kind);
|
||||
if (!rows.some((row) => row.id === id))
|
||||
throw new CatalogNotFound("Catalog page not found");
|
||||
const column = sql.identifier(field);
|
||||
await tx.execute(
|
||||
sql`UPDATE ${tables(kind).pages} SET ${column}=CASE WHEN ${column}='1' THEN '0' ELSE '1' END WHERE id=${id}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
categories: [] as unknown[],
|
||||
furniture: [] as unknown[],
|
||||
offers: [] as unknown[],
|
||||
parents: [] as unknown[],
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
execute: vi.fn(async (query: SQL) => {
|
||||
const result = new MySqlDialect().sqlToQuery(query);
|
||||
state.queries.push(result);
|
||||
if (result.sql.includes("FROM items_base")) return [state.furniture];
|
||||
if (result.sql.includes("SELECT i.id")) return [state.offers];
|
||||
if (result.sql.includes("WHERE id IN")) return [state.parents];
|
||||
return [state.categories];
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { searchCatalog } from "./search";
|
||||
|
||||
beforeEach(() => {
|
||||
state.queries = [];
|
||||
state.categories = [];
|
||||
state.furniture = [];
|
||||
state.offers = [];
|
||||
state.parents = [];
|
||||
});
|
||||
describe("bounded catalog search", () => {
|
||||
it.each(["", "a", " ", "x".repeat(101)])(
|
||||
"rejects invalid query before SQL: %s",
|
||||
async (query) => {
|
||||
await expect(searchCatalog("normal", query)).rejects.toThrow(
|
||||
"Search requires",
|
||||
);
|
||||
expect(state.queries).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
it("rejects an unknown catalog instead of interpolating a table name", async () => {
|
||||
await expect(searchCatalog("other", "chair")).rejects.toThrow(
|
||||
"Invalid catalog type",
|
||||
);
|
||||
expect(state.queries).toHaveLength(0);
|
||||
});
|
||||
it("binds text and treats user wildcard characters literally", async () => {
|
||||
await searchCatalog("normal", "chair_%' OR 1=1");
|
||||
expect(state.queries.every((query) => !query.sql.includes("chair"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(state.queries[0].params).toContain("chair=_=%' OR 1==1%");
|
||||
expect(state.queries.every((query) => query.sql.includes("LIMIT"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it.each(["normal", "bc"])(
|
||||
"finds numeric IDs and resolves bounded category paths for %s",
|
||||
async (kind) => {
|
||||
state.furniture = [{ id: 7, classname: "chair", spriteId: 9 }];
|
||||
state.offers = [
|
||||
{
|
||||
id: 50,
|
||||
caption: "Offer",
|
||||
pageId: "11",
|
||||
pageCaption: "Chairs",
|
||||
parentId: 1,
|
||||
itemIds: "3;7",
|
||||
},
|
||||
];
|
||||
state.parents = [{ id: 1, caption: "Shop", parentId: -1 }];
|
||||
const results = await searchCatalog(kind, "9");
|
||||
expect(results).toEqual([
|
||||
{
|
||||
kind: "offer",
|
||||
id: 50,
|
||||
pageId: 11,
|
||||
caption: "Offer",
|
||||
path: ["Shop", "Chairs"],
|
||||
href:
|
||||
kind === "bc"
|
||||
? "/admin/catalog/builder-club/11"
|
||||
: "/admin/catalog/11",
|
||||
furniture: { id: 7, classname: "chair", spriteId: 9 },
|
||||
},
|
||||
]);
|
||||
const offers = state.queries.find((query) =>
|
||||
query.sql.includes("SELECT i.id"),
|
||||
);
|
||||
expect(offers?.sql.includes("i.offer_id")).toBe(kind === "normal");
|
||||
expect(offers?.sql).toContain(
|
||||
kind === "bc" ? "catalog_items_bc" : "catalog_items",
|
||||
);
|
||||
expect(offers?.params).toContain("7");
|
||||
expect(offers?.params).toContain("9");
|
||||
expect(state.queries).toHaveLength(4);
|
||||
},
|
||||
);
|
||||
it("caps final results and furniture bundle predicates even if a repository overreturns", async () => {
|
||||
state.categories = Array.from({ length: 40 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
caption: "Category",
|
||||
parentId: -1,
|
||||
}));
|
||||
state.furniture = Array.from({ length: 80 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
classname: "chair",
|
||||
spriteId: index,
|
||||
}));
|
||||
state.offers = Array.from({ length: 80 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
caption: "Offer",
|
||||
pageId: 1,
|
||||
pageCaption: "Category",
|
||||
parentId: -1,
|
||||
itemIds: "1",
|
||||
}));
|
||||
const results = await searchCatalog("normal", "chair");
|
||||
expect(results).toHaveLength(25);
|
||||
const query = state.queries.find((query) =>
|
||||
query.sql.includes("SELECT i.id"),
|
||||
);
|
||||
expect(query?.sql.match(/FIND_IN_SET/g)).toHaveLength(25);
|
||||
expect(query?.params.at(-1)).toBe(13);
|
||||
});
|
||||
it("terminates corrupt cyclic category paths without repeated parent queries", async () => {
|
||||
state.categories = [{ id: 1, caption: "One", parentId: 2 }];
|
||||
state.parents = [{ id: 2, caption: "Two", parentId: 1 }];
|
||||
const results = await searchCatalog("normal", "One");
|
||||
expect(results[0].path).toEqual(["Two", "One"]);
|
||||
expect(state.queries).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import "server-only";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { type CatalogSearchResult, parseCatalogSearch } from "../domain/search";
|
||||
|
||||
const LIMIT = 25;
|
||||
const MAX_PATH_DEPTH = 16;
|
||||
type PageRow = { id: number; caption: string; parentId: number };
|
||||
type FurnitureRow = { id: number; classname: string; spriteId: number };
|
||||
type OfferRow = {
|
||||
id: number;
|
||||
caption: string;
|
||||
pageId: number;
|
||||
pageCaption: string;
|
||||
parentId: number;
|
||||
itemIds: string;
|
||||
};
|
||||
|
||||
export async function searchCatalog(
|
||||
kind: string,
|
||||
query: string,
|
||||
): Promise<CatalogSearchResult[]> {
|
||||
const input = parseCatalogSearch(kind, query);
|
||||
const pages = sql.identifier(
|
||||
input.kind === "bc" ? "catalog_pages_bc" : "catalog_pages",
|
||||
);
|
||||
const offers = sql.identifier(
|
||||
input.kind === "bc" ? "catalog_items_bc" : "catalog_items",
|
||||
);
|
||||
const number = input.numericId;
|
||||
const [categoryData, furnitureData] = await Promise.all([
|
||||
db.execute(
|
||||
sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE caption LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 12`,
|
||||
),
|
||||
db.execute(
|
||||
sql`SELECT id, item_name AS classname, sprite_id AS spriteId FROM items_base WHERE item_name LIKE ${input.pattern} ESCAPE '=' OR public_name LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number} OR sprite_id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 25`,
|
||||
),
|
||||
]);
|
||||
const categories = (categoryData[0] as unknown as PageRow[]).slice(0, 12);
|
||||
const furniture = (furnitureData[0] as unknown as FurnitureRow[]).slice(
|
||||
0,
|
||||
LIMIT,
|
||||
);
|
||||
const predicates = [sql`i.catalog_name LIKE ${input.pattern} ESCAPE '='`];
|
||||
if (number !== null) {
|
||||
predicates.push(sql`i.id=${number}`, sql`i.page_id=${String(number)}`);
|
||||
if (input.kind === "normal") predicates.push(sql`i.offer_id=${number}`);
|
||||
}
|
||||
// The legacy semicolon relation has no join index. Cap candidate furniture IDs before testing bundle membership.
|
||||
for (const base of furniture)
|
||||
predicates.push(
|
||||
sql`FIND_IN_SET(${String(base.id)}, REPLACE(i.item_ids, ';', ',')) > 0`,
|
||||
);
|
||||
const [offerData] = await db.execute(
|
||||
sql`SELECT i.id, i.catalog_name AS caption, i.page_id AS pageId, i.item_ids AS itemIds, p.caption AS pageCaption, p.parent_id AS parentId FROM ${offers} i INNER JOIN ${pages} p ON p.id=i.page_id WHERE ${sql.join(predicates, sql` OR `)} ORDER BY ${number === null ? sql`i.id` : sql`CASE WHEN i.id=${number} THEN 0 ELSE 1 END, i.id`} LIMIT ${LIMIT - categories.length}`,
|
||||
);
|
||||
const matches = (offerData as unknown as OfferRow[]).slice(
|
||||
0,
|
||||
LIMIT - categories.length,
|
||||
);
|
||||
const pageMap = new Map<number, PageRow>();
|
||||
for (const page of categories)
|
||||
pageMap.set(Number(page.id), {
|
||||
...page,
|
||||
id: Number(page.id),
|
||||
parentId: Number(page.parentId),
|
||||
});
|
||||
for (const offer of matches)
|
||||
pageMap.set(Number(offer.pageId), {
|
||||
id: Number(offer.pageId),
|
||||
caption: offer.pageCaption,
|
||||
parentId: Number(offer.parentId),
|
||||
});
|
||||
let frontier = [...pageMap.values()].map((page) => page.parentId);
|
||||
const attempted = new Set(pageMap.keys());
|
||||
for (let depth = 0; depth < MAX_PATH_DEPTH; depth++) {
|
||||
const ids = [...new Set(frontier)]
|
||||
.filter((id) => id > 0 && !attempted.has(id))
|
||||
.slice(0, LIMIT);
|
||||
if (!ids.length) break;
|
||||
for (const id of ids) attempted.add(id);
|
||||
const [parents] = await db.execute(
|
||||
sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE id IN (${sql.join(ids, sql`, `)}) LIMIT 25`,
|
||||
);
|
||||
frontier = [];
|
||||
for (const raw of parents as unknown as PageRow[]) {
|
||||
const page = {
|
||||
...raw,
|
||||
id: Number(raw.id),
|
||||
parentId: Number(raw.parentId),
|
||||
};
|
||||
pageMap.set(page.id, page);
|
||||
frontier.push(page.parentId);
|
||||
}
|
||||
}
|
||||
function pagePath(id: number) {
|
||||
const path: string[] = [];
|
||||
const seen = new Set<number>();
|
||||
let current = id;
|
||||
while (current > 0 && !seen.has(current) && seen.size <= MAX_PATH_DEPTH) {
|
||||
seen.add(current);
|
||||
const page = pageMap.get(current);
|
||||
if (!page) {
|
||||
path.unshift(`#${current}`);
|
||||
break;
|
||||
}
|
||||
path.unshift(page.caption);
|
||||
current = page.parentId;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
const href = (id: number) =>
|
||||
input.kind === "bc"
|
||||
? `/admin/catalog/builder-club/${id}`
|
||||
: `/admin/catalog/${id}`;
|
||||
return [
|
||||
...categories.map(
|
||||
(page): CatalogSearchResult => ({
|
||||
kind: "category",
|
||||
id: Number(page.id),
|
||||
pageId: Number(page.id),
|
||||
caption: page.caption,
|
||||
path: pagePath(Number(page.id)),
|
||||
href: href(Number(page.id)),
|
||||
}),
|
||||
),
|
||||
...matches.map((offer): CatalogSearchResult => {
|
||||
const ids = new Set(String(offer.itemIds).split(";").map(Number));
|
||||
const base = furniture.find((item) => ids.has(Number(item.id)));
|
||||
return {
|
||||
kind: "offer",
|
||||
id: Number(offer.id),
|
||||
pageId: Number(offer.pageId),
|
||||
caption: offer.caption,
|
||||
path: pagePath(Number(offer.pageId)),
|
||||
href: href(Number(offer.pageId)),
|
||||
...(base
|
||||
? {
|
||||
furniture: {
|
||||
id: Number(base.id),
|
||||
classname: base.classname,
|
||||
spriteId: Number(base.spriteId),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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