refactor(catalog): unify commands and embed guarded catalog workspace
CI / check (push) Successful in 1m16s
CI / deploy (push) Successful in 28s

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

@@ -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();
});
+83
View File
@@ -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();
});
+31
View File
@@ -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=_=%==%");
});
});
+32
View File
@@ -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}`)}%`,
};
}
+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 };
}
@@ -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;
});
}
+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),
},
};
}
@@ -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();
});
+136
View File
@@ -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);
});
});
+149
View File
@@ -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;
}