feat(catalog): record bulk edits, make deletions reversible, unify the tree read
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m48s
CI / tests-ui (push) Successful in 2m37s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m23s
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m48s
CI / tests-ui (push) Successful in 2m37s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m23s
A bulk offer edit is the catalog mutation that rewrites hundreds of rows at once, and it was the only one writing nothing to the staff activity log: 22 of the 43 catalog actions logged, this one did not. The entry it now writes says what changed, not just that something did, because the log has no undo of its own and "bulk updated 200 offers" cannot answer the question it exists for. Deleting offers had no inverse at all. Every removed row is now kept at delete time and the caller gets a restoreId back, so an accidental multi-select is a click rather than a hand-edit of the table. The undo toast covers the common case; a RecentDeletionsPanel holds the same records so a delete noticed later is still reachable. Three refusals guard it: an id that another offer has since taken, a category that no longer exists (which would leave an offer that sells nowhere and shows under no page), and a delete whose restore record cannot be written — that one rolls back rather than deleting without a way back. Reading the audit row FOR UPDATE is also what stops two restores of one deletion from both inserting. sendCatalogUpdate() overwrote hotel-status.json on every write, so "which imports reached the hotel" was answerable for the last attempt only, and a failure two imports ago was gone by the time anyone looked. That file is now also appended to as a bounded 50-entry tail. The tree route carried four copies of the same page-select-plus-counts shaping, of which the BC branches had already drifted: one counted offers through the VARCHAR-tolerant helper, the other inline and swallowing errors. All of it is one readPages() now, and readFullTree sends both catalogs through one depth computation instead of delegating normal to getTreeFlat while computing BC here — a split that left two implementations behind one function name. getTreeFlat is gone. The BC ancestor walk also went from 20 levels to 50, matching getAncestors, so a deeply nested catalog no longer loses its breadcrumb. Bulk editing reaches the BC catalog, which previously had no way to edit or duplicate offers in bulk. The catalog is part of the operation identity now, so replaying one request key against the other catalog is not mistaken for the same work. Integration tests failed to import: the next/cache mock supplied only revalidatePath, and catalog-totals calls unstable_cache at module scope.
This commit is contained in:
1 parent
9550b3d66f
commit
cebcf440c5
20 files changed
+1780
-494
No files matched your search
@@ -35,7 +35,13 @@ vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs"));
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
// The data cache only exists inside a Next render, so both entry points degrade
|
||||
// to the real work underneath them instead of being stubbed out.
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
revalidateTag: vi.fn(),
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (url: string) => {
|
||||
throw Error(`Unexpected integration redirect: ${url}`);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
readCatalogHotelHistory,
|
||||
readCatalogHotelStatus,
|
||||
sendCatalogUpdate,
|
||||
} from "@/features/catalog/server/sync-status";
|
||||
@@ -7,11 +8,12 @@ import { withAdmin } from "@/lib/api-handler";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { catalogExportStatus } from "@/lib/services/catalog-git-queue";
|
||||
export const GET = withAdmin({ permission: PERMS.CATALOG_VIEW }, async () => {
|
||||
const [hotel, git] = await Promise.all([
|
||||
const [hotel, history, git] = await Promise.all([
|
||||
readCatalogHotelStatus(),
|
||||
readCatalogHotelHistory(),
|
||||
catalogExportStatus(),
|
||||
]);
|
||||
return apiOk({ hotel, git });
|
||||
return apiOk({ hotel, history, git });
|
||||
});
|
||||
export const POST = withAdmin({ permission: PERMS.CATALOG_EDIT }, async () =>
|
||||
apiOk({ hotel: await sendCatalogUpdate() }),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -830,6 +830,7 @@ function InlineEditorSession({
|
||||
interactionTypes={itemsData.interactionTypes}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
allPages={itemsData.allPages}
|
||||
catalog={catalogType}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -59,12 +59,15 @@ const emptyPrice = (): PriceDraft => ({
|
||||
export function BulkOfferEditor({
|
||||
ids,
|
||||
allPages,
|
||||
catalog = "normal",
|
||||
disabled = false,
|
||||
onApplied,
|
||||
beforeEdit,
|
||||
}: {
|
||||
ids: number[];
|
||||
allPages: { id: number; caption: string }[];
|
||||
/** Which catalog the selection belongs to. BC has no undo history. */
|
||||
catalog?: "normal" | "bc";
|
||||
disabled?: boolean;
|
||||
onApplied: () => void | Promise<void>;
|
||||
beforeEdit?: () => boolean;
|
||||
@@ -145,7 +148,7 @@ export function BulkOfferEditor({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await previewBulkOffers(input);
|
||||
const result = await previewBulkOffers(input, catalog);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
@@ -174,6 +177,7 @@ export function BulkOfferEditor({
|
||||
preview.input,
|
||||
preview.data.fingerprint,
|
||||
preview.requestKey,
|
||||
catalog,
|
||||
);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
|
||||
@@ -68,7 +68,7 @@ export function CatalogSearch({
|
||||
setDestinationsLoading(true);
|
||||
setSelectionError(null);
|
||||
try {
|
||||
const result = await getBulkOfferDestinations();
|
||||
const result = await getBulkOfferDestinations(catalogType);
|
||||
if (request !== destinationRequest.current) return;
|
||||
if (!result.ok) {
|
||||
setSelectionError(result.error);
|
||||
@@ -152,7 +152,7 @@ export function CatalogSearch({
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
{canEdit && catalogType === "normal" && selected.size > 0 && (
|
||||
{canEdit && selected.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border p-2">
|
||||
<span className="text-sm">
|
||||
{bulk("selectedCount", { count: selected.size })}
|
||||
@@ -168,6 +168,7 @@ export function CatalogSearch({
|
||||
<BulkOfferEditor
|
||||
ids={[...selected]}
|
||||
allPages={pages}
|
||||
catalog={catalogType}
|
||||
beforeEdit={mayEdit}
|
||||
onApplied={() => {
|
||||
setSelected(new Set());
|
||||
|
||||
@@ -109,6 +109,6 @@ it("undo denial stops before export and a conflict does not notify", async () =>
|
||||
it("passes a stable operation key and does not synchronously notify after commit", async () => {
|
||||
const key = "123e4567-e89b-42d3-a456-426614174000";
|
||||
expect((await applyBulkOffers(input, "x", key)).ok).toBe(true);
|
||||
expect(state.apply).toHaveBeenCalledWith(input, "x", 1, key);
|
||||
expect(state.apply).toHaveBeenCalledWith(input, "x", 1, key, "normal");
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -14,7 +14,11 @@ vi.mock("@/lib/services/catalog-git-config", () => ({
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { updateCatalog: state.send } }));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
|
||||
|
||||
import { readCatalogHotelStatus, sendCatalogUpdate } from "./sync-status";
|
||||
import {
|
||||
readCatalogHotelHistory,
|
||||
readCatalogHotelStatus,
|
||||
sendCatalogUpdate,
|
||||
} from "./sync-status";
|
||||
|
||||
beforeEach(async () => {
|
||||
state.root = await fs.mkdtemp(path.join(os.tmpdir(), "catalog-status-"));
|
||||
@@ -40,3 +44,38 @@ it("allows retrying the update without reapplying catalog mutations", async () =
|
||||
});
|
||||
it("returns no status before the first attempt", async () =>
|
||||
expect(await readCatalogHotelStatus()).toBeNull());
|
||||
|
||||
it("returns no history before the first attempt", async () =>
|
||||
expect(await readCatalogHotelHistory()).toEqual([]));
|
||||
|
||||
it("keeps earlier deliveries instead of overwriting them", async () => {
|
||||
state.send.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||
await sendCatalogUpdate();
|
||||
state.log.mockReturnValueOnce("catalog-error-2");
|
||||
await sendCatalogUpdate();
|
||||
|
||||
const history = await readCatalogHotelHistory();
|
||||
expect(history.map((entry) => entry.sent)).toEqual([false, true]);
|
||||
expect(history[0].reference).toBe("catalog-error-2");
|
||||
// The snapshot is still the newest write, so the rail keeps showing it.
|
||||
expect((await readCatalogHotelStatus())?.sent).toBe(false);
|
||||
});
|
||||
|
||||
it("survives a corrupt history file without blocking a delivery", async () => {
|
||||
state.send.mockResolvedValue(true);
|
||||
await fs.mkdir(state.root, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(state.root, "hotel-status-history.json"),
|
||||
"{not json",
|
||||
);
|
||||
|
||||
await expect(sendCatalogUpdate()).resolves.toMatchObject({ sent: true });
|
||||
await expect(readCatalogHotelHistory()).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops the oldest entries past the retention limit", async () => {
|
||||
state.send.mockResolvedValue(true);
|
||||
for (let i = 0; i < 55; i++) await sendCatalogUpdate();
|
||||
|
||||
expect(await readCatalogHotelHistory()).toHaveLength(50);
|
||||
});
|
||||
@@ -12,6 +12,11 @@ export interface CatalogHotelStatus {
|
||||
checkedAt: string;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
/** How many deliveries the history file keeps before dropping the oldest. */
|
||||
const HISTORY_LIMIT = 50;
|
||||
const HISTORY_FILE = "hotel-status-history.json";
|
||||
|
||||
export async function readCatalogHotelStatus(): Promise<CatalogHotelStatus | null> {
|
||||
try {
|
||||
return JSON.parse(
|
||||
@@ -25,6 +30,61 @@ export async function readCatalogHotelStatus(): Promise<CatalogHotelStatus | nul
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The last delivery is not the same question as "which imports reached the
|
||||
* hotel". `hotel-status.json` answers the first and overwrites itself on every
|
||||
* write, so a failure two imports ago is gone by the time anyone looks. This
|
||||
* keeps a bounded tail instead, newest first.
|
||||
*/
|
||||
export async function readCatalogHotelHistory(): Promise<CatalogHotelStatus[]> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
await fs.readFile(path.join(catalogStateRoot(), HISTORY_FILE), "utf8"),
|
||||
);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(entry): entry is CatalogHotelStatus =>
|
||||
!!entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof (entry as CatalogHotelStatus).checkedAt === "string" &&
|
||||
typeof (entry as CatalogHotelStatus).sent === "boolean",
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function appendHotelHistory(status: CatalogHotelStatus): Promise<void> {
|
||||
const root = catalogStateRoot();
|
||||
try {
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
const file = path.join(root, HISTORY_FILE);
|
||||
let previous: CatalogHotelStatus[] = [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await fs.readFile(file, "utf8"));
|
||||
if (Array.isArray(parsed)) previous = parsed as CatalogHotelStatus[];
|
||||
} catch (error) {
|
||||
// A corrupt history must not stop the delivery itself; the snapshot
|
||||
// below is still written either way.
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") previous = [];
|
||||
}
|
||||
const next = [status, ...previous].slice(0, HISTORY_LIMIT);
|
||||
const temp = path.join(root, `hotel-history-${randomUUID()}.tmp`);
|
||||
try {
|
||||
await fs.writeFile(temp, JSON.stringify(next), { flag: "wx" });
|
||||
await fs.rename(temp, file);
|
||||
} finally {
|
||||
await fs.rm(temp, { force: true }).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Cannot append catalog hotel update history", {
|
||||
module: "catalog",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
/** An acknowledged socket write is an update request, not proof of client application. */
|
||||
export async function sendCatalogUpdate(): Promise<CatalogHotelStatus> {
|
||||
let sent = false;
|
||||
@@ -50,6 +110,7 @@ export async function sendCatalogUpdate(): Promise<CatalogHotelStatus> {
|
||||
// different tab, browser or process than the one watching.
|
||||
await bumpCatalogRevision();
|
||||
const status = { sent, checkedAt: new Date().toISOString(), reference };
|
||||
await appendHotelHistory(status);
|
||||
const root = catalogStateRoot();
|
||||
const temp = path.join(root, `hotel-${randomUUID()}.tmp`);
|
||||
try {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -524,7 +524,7 @@ export const hotelMigrationEntries: readonly MigrationEntry[] = [
|
||||
"GET /api/admin/import/clone",
|
||||
"GET /api/admin/import/furni",
|
||||
"getHabboGamedataHotel",
|
||||
"getTreeFlat",
|
||||
"readFullTree",
|
||||
"previewAutoCatalog",
|
||||
"siteSettings.getBool",
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { asc, eq, sql } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
deletePageCommand,
|
||||
updatePageCommand,
|
||||
@@ -59,76 +59,6 @@ export async function getCatalogItemCounts(
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full catalog tree as a flat list with depth info.
|
||||
*/
|
||||
export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||
const allPages = await db
|
||||
.select({
|
||||
id: CatalogPages.id,
|
||||
parentId: CatalogPages.parentId,
|
||||
caption: CatalogPages.caption,
|
||||
pageLayout: CatalogPages.pageLayout,
|
||||
iconColor: CatalogPages.iconColor,
|
||||
iconImage: CatalogPages.iconImage,
|
||||
orderNum: CatalogPages.orderNum,
|
||||
visible: CatalogPages.visible,
|
||||
enabled: CatalogPages.enabled,
|
||||
})
|
||||
.from(CatalogPages)
|
||||
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id));
|
||||
const itemCountMap = await getCatalogItemCounts();
|
||||
|
||||
const childCountMap = new Map<number, number>();
|
||||
for (const p of allPages) {
|
||||
const parentId = toInt(p.parentId, -1);
|
||||
childCountMap.set(parentId, (childCountMap.get(parentId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const pageMap = new Map(allPages.map((p) => [p.id, p]));
|
||||
const depthCache = new Map<number, number>();
|
||||
|
||||
function computeDepth(pageId: number, visiting = new Set<number>()): number {
|
||||
if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0;
|
||||
// Cycle guard: parent chains may loop in messy DBs — stop at 0 instead of overflowing.
|
||||
if (visiting.has(pageId)) {
|
||||
depthCache.set(pageId, 0);
|
||||
return 0;
|
||||
}
|
||||
visiting.add(pageId);
|
||||
const page = pageMap.get(pageId);
|
||||
const parentId = page ? toInt(page.parentId, -1) : -1;
|
||||
if (!page || parentId <= 0) {
|
||||
visiting.delete(pageId);
|
||||
depthCache.set(pageId, 0);
|
||||
return 0;
|
||||
}
|
||||
const d = computeDepth(parentId, visiting) + 1;
|
||||
visiting.delete(pageId);
|
||||
depthCache.set(pageId, d);
|
||||
return d;
|
||||
}
|
||||
|
||||
return allPages.map((p) => {
|
||||
const id = toInt(p.id);
|
||||
const parentId = toInt(p.parentId, -1);
|
||||
return {
|
||||
id,
|
||||
caption: p.caption,
|
||||
parentId,
|
||||
depth: computeDepth(id),
|
||||
orderNum: toInt(p.orderNum),
|
||||
enabled: String(p.enabled ?? "0"),
|
||||
visible: String(p.visible ?? "0"),
|
||||
iconImage: toInt(p.iconImage),
|
||||
iconColor: toInt(p.iconColor),
|
||||
pageLayout: p.pageLayout,
|
||||
childCount: childCountMap.get(id) ?? 0,
|
||||
itemCount: itemCountMap.get(id) ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nested tree structure from flat nodes.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user