fix(catalog): make the Builder Club catalog read and write its own offers
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 28s
CI / tests-unit (push) Successful in 1m39s
CI / tests-integration (push) Successful in 1m42s
CI / tests-ui (push) Successful in 2m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m6s

The previous commit taught bulk editing and delete-with-restore about the BC
catalog. Neither actually worked, and one of them was destructive.

`catalog_items_bc` has six columns: id, item_ids, page_id, catalog_name,
order_number, extradata. There is no price, points, currency, offer_id, limit or
membership column on it. The bulk path read and wrote columns that do not
exist, and the UPDATE was aimed at catalog_items while the SELECT came from
catalog_items_bc — so a BC category move wrote into the normal catalog. Two
tests now pin that pairing: reads and writes have to stay in the same table.

Underneath it the BC table was never being read at all. The inline editor
fetched `/api/admin/catalog/items?pageId=N` without the catalog, so opening a BC
category showed the normal catalog's offers, and the route selected BC rows
directly instead of going through the loader, skipping the furni enrichment the
table needs to render anything but a bare caption. Both catalogs now take the
same path, and the catalog is in the fetch callback's dependencies — without
that, a switch keeps reading the previous catalog's rows through a stale
closure.

Because a BC offer has no price, the editor no longer offers one. The server
refuses price, points and currency changes with a readable message instead of
letting them reach the database as an unknown-column error, and a BC bulk edit
is what it can actually be: a category move.

BC deletions also went through a bare DELETE, which made them the one catalog
mutation with no way back. They now keep their rows and hand back a restoreId
like the normal ones. The catalog is recorded in the audit target rather than
in the payload, so a restore can never put a BC row into the normal offers
table.
This commit is contained in:
openhands committed 2026-09-30 19:17:20 +02:00
1 parent cebcf440c5
commit e0efbef30d
12 files changed
+548 -218

No files matched your search

+30 -11
View File
@@ -1,5 +1,4 @@
// @ts-nocheck // @ts-nocheck
import { eq } from "drizzle-orm";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ const state = vi.hoisted(() => ({
@@ -50,6 +49,13 @@ vi.mock("@/features/catalog/server/page-commands", () => ({
togglePageCommand: mockTogglePageCommand, togglePageCommand: mockTogglePageCommand,
})); }));
const mockDeleteCatalogItemsCommand = vi.hoisted(() =>
vi.fn(async () => ({ deleted: 1, restoreId: 55 })),
);
vi.mock("@/features/catalog/server/item-deletes", () => ({
deleteCatalogItemsCommand: mockDeleteCatalogItemsCommand,
}));
const mockSendCatalogUpdate = vi.hoisted(() => vi.fn()); const mockSendCatalogUpdate = vi.hoisted(() => vi.fn());
vi.mock("@/features/catalog/server/sync-status", () => ({ vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: mockSendCatalogUpdate, sendCatalogUpdate: mockSendCatalogUpdate,
@@ -86,7 +92,6 @@ vi.mock("@/lib/db", async () => {
}; };
}); });
import { CatalogItemsBc } from "@/lib/db";
import { import {
createBcItem, createBcItem,
createBcPage, createBcPage,
@@ -179,16 +184,25 @@ describe("updateBcPage", () => {
}); });
describe("deleteBcItem", () => { describe("deleteBcItem", () => {
it("deletes the bc item and logs activity", async () => { it("deletes through the restorable path and logs activity", async () => {
const result = await deleteBcItem({ id: 7 }); const result = await deleteBcItem({ id: 7 });
expect(result).toEqual({ ok: true }); expect(result).toEqual({
expect(state.deletes).toHaveLength(1); ok: true,
expect(state.deletes[0].table).toBe(CatalogItemsBc); data: { deleted: 1, restoreId: 55 },
expect(state.deletes[0].where).toEqual(eq(CatalogItemsBc.id, 7)); });
// Not a raw DELETE: BC rows are kept so the delete can be undone, the same
// as the normal catalog.
expect(mockDeleteCatalogItemsCommand).toHaveBeenCalledWith(
[7],
expect.anything(),
undefined,
"bc",
);
expect(state.deletes).toHaveLength(0);
expect(mockLogStaffActivity).toHaveBeenCalledWith( expect(mockLogStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
action: "bc_item_delete", action: "bc_item_delete",
description: "Deleted BC catalog item #7", description: "Deleted BC catalog offer #7",
}), }),
); );
expect(mockSendCatalogUpdate).toHaveBeenCalled(); expect(mockSendCatalogUpdate).toHaveBeenCalled();
@@ -197,9 +211,14 @@ describe("deleteBcItem", () => {
); );
}); });
it("propagates delete failures", async () => { it("reports a failed delete instead of pretending it worked", async () => {
state.deleteError = new Error("db down"); mockDeleteCatalogItemsCommand.mockRejectedValueOnce(Error("db down"));
await expect(deleteBcItem({ id: 7 })).rejects.toThrow("db down"); mockCatalogFailure.mockReturnValueOnce({ message: "Delete failed" });
const result = await deleteBcItem({ id: 7 });
expect(result).toEqual({ ok: false, error: "Delete failed" });
expect(mockSendCatalogUpdate).not.toHaveBeenCalled();
}); });
}); });
+30 -15
View File
@@ -1,8 +1,8 @@
"use server"; "use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { catalogFailure } from "@/features/catalog/server/errors"; import { catalogFailure } from "@/features/catalog/server/errors";
import { deleteCatalogItemsCommand } from "@/features/catalog/server/item-deletes";
import { import {
createBcOfferCommand, createBcOfferCommand,
updateBcOfferCommand, updateBcOfferCommand,
@@ -16,7 +16,6 @@ import {
} from "@/features/catalog/server/page-commands"; } from "@/features/catalog/server/page-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { CatalogItemsBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { withCatalogExport } from "@/lib/services/catalog-git-queue"; import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -92,21 +91,37 @@ export async function updateBcPage({
}); });
} }
export async function deleteBcItem({ id }: { id: number }) { /**
* BC offers are deleted through the same keep-and-restore path as normal ones.
* It used to be a bare `DELETE` here, so a BC deletion was the one catalog
* mutation with no way back.
*/
export async function deleteBcItem({
id,
requestKey,
}: {
id: number;
requestKey?: string;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT); const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => { try {
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id)); return await withCatalogExport(async () => {
await sendCatalogUpdate(); const data: { deleted: number; restoreId: number } =
await logStaffActivity({ await deleteCatalogItemsCommand([id], staff.id, requestKey, "bc");
staffId: staff.id, await sendCatalogUpdate();
action: "bc_item_delete", await logStaffActivity({
description: `Deleted BC catalog item #${id}`, staffId: staff.id,
targetType: "catalog_item_bc", action: "bc_item_delete",
targetId: id, description: `Deleted BC catalog offer #${id}`,
targetType: "catalog_item_bc",
targetId: id,
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data };
}); });
revalidatePath("/admin/catalog/builder-club"); } catch (error) {
return { ok: true as const }; return { ok: false as const, error: catalogFailure(error).message };
}); }
} }
export async function updateBcItem({ export async function updateBcItem({
+11 -3
View File
@@ -210,9 +210,11 @@ export async function bulkCreateCatalogItems({
export async function deleteCatalogItems({ export async function deleteCatalogItems({
ids, ids,
requestKey, requestKey,
catalog = "normal",
}: { }: {
ids: number[]; ids: number[];
requestKey?: string; requestKey?: string;
catalog?: "normal" | "bc";
}) { }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT); const staff = await requirePermission(PERMS.CATALOG_EDIT);
try { try {
@@ -220,15 +222,21 @@ export async function deleteCatalogItems({
const data: { const data: {
deleted: number; deleted: number;
restoreId: number; restoreId: number;
} = await deleteCatalogItemsCommand(ids, staff.id, requestKey); } = await deleteCatalogItemsCommand(
ids,
staff.id,
requestKey,
catalog === "bc" ? "bc" : "normal",
);
await sendCatalogUpdate(); await sendCatalogUpdate();
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "catalog_items_delete", action: "catalog_items_delete",
description: `Deleted ${data.deleted} catalog offer(s): ${ids.join(", ")}`, description: `Deleted ${data.deleted} ${catalog === "bc" ? "BC " : ""}catalog offer(s): ${ids.join(", ")}`,
targetType: "catalog_item", targetType: catalog === "bc" ? "catalog_item_bc" : "catalog_item",
}); });
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
if (catalog === "bc") revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data }; return { ok: true as const, data };
}); });
} catch (error) { } catch (error) {
+11 -19
View File
@@ -1,16 +1,8 @@
import { asc, eq } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api"; import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { CatalogItemsBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader"; import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
function jsonSafe<T>(data: T): T {
return JSON.parse(
JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? Number(v) : v)),
) as T;
}
export const GET = withAdmin( export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW }, { permission: PERMS.CATALOG_VIEW },
async (request) => { async (request) => {
@@ -18,16 +10,16 @@ export const GET = withAdmin(
if (!Number.isFinite(pageId) || pageId <= 0) { if (!Number.isFinite(pageId) || pageId <= 0) {
return apiError("Invalid pageId"); return apiError("Invalid pageId");
} }
const isBc = request.nextUrl.searchParams.get("catalog") === "bc"; const catalog =
if (isBc) { request.nextUrl.searchParams.get("catalog") === "bc" ? "bc" : "normal";
const items = await db // Both catalogs go through the loader. The BC branch used to select the
.select() // rows directly, which returned them without the furni enrichment the
.from(CatalogItemsBc) // table needs to render anything but a bare caption.
.where(eq(CatalogItemsBc.pageId, pageId)) return apiOk(
.orderBy(asc(CatalogItemsBc.orderNumber)); (await loadCatalogItemsData(pageId, catalog)) as unknown as Record<
return apiOk({ items: jsonSafe(items) }); string,
} unknown
const data = await loadCatalogItemsData(pageId); >,
return apiOk(jsonSafe(data) as unknown as Record<string, unknown>); );
}, },
); );
@@ -233,9 +233,10 @@ function InlineEditorSession({
setItemsError(false); setItemsError(false);
setItemsLoading(true); setItemsLoading(true);
try { try {
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`, { const res = await fetch(
signal: request.signal, `/api/admin/catalog/items?pageId=${id}${catQs}`,
}); { signal: request.signal },
);
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
const data = await res.json(); const data = await res.json();
if (!request.isCurrent()) return; if (!request.isCurrent()) return;
@@ -250,7 +251,10 @@ function InlineEditorSession({
if (request.isCurrent()) setItemsLoading(false); if (request.isCurrent()) setItemsLoading(false);
} }
}, },
[itemRequests], // `catQs` selects which catalog the offers come from, so it has to be part
// of this callback's identity: without it a switch keeps reading the
// previous catalog's rows.
[itemRequests, catQs],
); );
const reloadItems = useCallback(() => { const reloadItems = useCallback(() => {
@@ -564,15 +564,19 @@ export function CatalogItemsTable({
if (!ok) return; if (!ok) return;
const ids = [...selected]; const ids = [...selected];
run(() => deleteCatalogItems({ ids, requestKey: crypto.randomUUID() }), { run(
successMessage: `Deleted ${ids.length} item(s).`, () =>
errorMessage: "Failed to delete items.", deleteCatalogItems({ ids, requestKey: crypto.randomUUID(), catalog }),
onSuccess: (data) => { {
setSelected(new Set()); successMessage: `Deleted ${ids.length} item(s).`,
onRefresh(); errorMessage: "Failed to delete items.",
offerUndoDelete(Number(data.restoreId), ids.length); onSuccess: (data) => {
setSelected(new Set());
onRefresh();
offerUndoDelete(Number(data.restoreId), ids.length);
},
}, },
}); );
} }
// ── Move selected items to another page ───────────────────────── // ── Move selected items to another page ─────────────────────────
@@ -288,104 +288,120 @@ export function BulkOfferEditor({
{!preview ? ( {!preview ? (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("fieldHint")}</p> <p className="text-sm text-muted-foreground">{t("fieldHint")}</p>
{(["costCredits", "costPoints"] as const).map((field) => ( {/* A BC offer has no price, points or currency column to
<div key={field} className="space-y-3 rounded-md border p-3"> write. The server refuses those changes too; hiding the
<Label className="flex items-center gap-2"> fields keeps the dialog from offering what cannot be done. */}
<Checkbox {catalog !== "bc" && (
checked={prices[field].enabled} <>
onCheckedChange={(value) => {(["costCredits", "costPoints"] as const).map((field) => (
updatePrice(field, { enabled: value === true }) <div
} key={field}
disabled={busy} className="space-y-3 rounded-md border p-3"
/> >
{t(field)} <Label className="flex items-center gap-2">
</Label> <Checkbox
{prices[field].enabled && ( checked={prices[field].enabled}
<div className="grid gap-3 sm:grid-cols-2"> onCheckedChange={(value) =>
<div className="space-y-1"> updatePrice(field, { enabled: value === true })
<Label htmlFor={`bulk-${field}-mode`}>
{t("operation")}
</Label>
<Select
value={prices[field].mode}
onValueChange={(value) =>
updatePrice(field, { mode: value as PriceMode })
}
disabled={busy}
>
<SelectTrigger id={`bulk-${field}-mode`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["set", "add", "percent"] as const).map(
(mode) => (
<SelectItem key={mode} value={mode}>
{t(`modes.${mode}`)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor={`bulk-${field}-value`}>
{t(
prices[field].mode === "percent"
? "percentage"
: "value",
)}
</Label>
<Input
id={`bulk-${field}-value`}
type="number"
step={prices[field].mode === "percent" ? "any" : 1}
value={prices[field].value}
onChange={(event) =>
updatePrice(field, { value: event.target.value })
} }
disabled={busy} disabled={busy}
/> />
</div> {t(field)}
</div> </Label>
)} {prices[field].enabled && (
</div> <div className="grid gap-3 sm:grid-cols-2">
))} <div className="space-y-1">
<div className="space-y-3 rounded-md border p-3"> <Label htmlFor={`bulk-${field}-mode`}>
<Label className="flex items-center gap-2"> {t("operation")}
<Checkbox </Label>
checked={currencyEnabled} <Select
onCheckedChange={(value) => value={prices[field].mode}
setCurrencyEnabled(value === true) onValueChange={(value) =>
} updatePrice(field, { mode: value as PriceMode })
disabled={busy} }
/> disabled={busy}
{t("currency")} >
</Label> <SelectTrigger id={`bulk-${field}-mode`}>
{currencyEnabled && ( <SelectValue />
<Select </SelectTrigger>
value={String(pointsType)} <SelectContent>
onValueChange={(value) => setPointsType(Number(value))} {(["set", "add", "percent"] as const).map(
disabled={busy} (mode) => (
> <SelectItem key={mode} value={mode}>
<SelectTrigger aria-label={t("currency")}> {t(`modes.${mode}`)}
<SelectValue /> </SelectItem>
</SelectTrigger> ),
<SelectContent> )}
{Object.keys(POINTS_TYPES).map((type) => ( </SelectContent>
<SelectItem key={type} value={type}> </Select>
<span className="flex items-center gap-2"> </div>
<CurrencyIcon <div className="space-y-1">
kind={currencyKindFromPointsType(Number(type))} <Label htmlFor={`bulk-${field}-value`}>
alt="" {t(
prices[field].mode === "percent"
? "percentage"
: "value",
)}
</Label>
<Input
id={`bulk-${field}-value`}
type="number"
step={
prices[field].mode === "percent" ? "any" : 1
}
value={prices[field].value}
onChange={(event) =>
updatePrice(field, {
value: event.target.value,
})
}
disabled={busy}
/> />
{currencyName(Number(type))} </div>
</span> </div>
</SelectItem> )}
))} </div>
</SelectContent> ))}
</Select> <div className="space-y-3 rounded-md border p-3">
)} <Label className="flex items-center gap-2">
</div> <Checkbox
checked={currencyEnabled}
onCheckedChange={(value) =>
setCurrencyEnabled(value === true)
}
disabled={busy}
/>
{t("currency")}
</Label>
{currencyEnabled && (
<Select
value={String(pointsType)}
onValueChange={(value) => setPointsType(Number(value))}
disabled={busy}
>
<SelectTrigger aria-label={t("currency")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(POINTS_TYPES).map((type) => (
<SelectItem key={type} value={type}>
<span className="flex items-center gap-2">
<CurrencyIcon
kind={currencyKindFromPointsType(
Number(type),
)}
alt=""
/>
{currencyName(Number(type))}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</>
)}
<div className="space-y-3 rounded-md border p-3"> <div className="space-y-3 rounded-md border p-3">
<Label className="flex items-center gap-2"> <Label className="flex items-center gap-2">
<Checkbox <Checkbox
@@ -434,13 +450,16 @@ export function BulkOfferEditor({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
{[ {(catalog === "bc"
"offer", ? ["offer", "destination"]
"costCredits", : [
"costPoints", "offer",
"currency", "costCredits",
"destination", "costPoints",
].map((key) => ( "currency",
"destination",
]
).map((key) => (
<TableHead key={key}>{t(key)}</TableHead> <TableHead key={key}>{t(key)}</TableHead>
))} ))}
</TableRow> </TableRow>
@@ -456,24 +475,28 @@ export function BulkOfferEditor({
#{row.id} #{row.id}
</span> </span>
</TableCell> </TableCell>
<TableCell> {catalog !== "bc" && (
{renderChange( <>
row.before.costCredits, <TableCell>
row.after.costCredits, {renderChange(
)} row.before.costCredits,
</TableCell> row.after.costCredits,
<TableCell> )}
{renderChange( </TableCell>
row.before.costPoints, <TableCell>
row.after.costPoints, {renderChange(
)} row.before.costPoints,
</TableCell> row.after.costPoints,
<TableCell> )}
{renderChange( </TableCell>
currencyName(row.before.pointsType), <TableCell>
currencyName(row.after.pointsType), {renderChange(
)} currencyName(row.before.pointsType),
</TableCell> currencyName(row.after.pointsType),
)}
</TableCell>
</>
)}
<TableCell> <TableCell>
{renderChange( {renderChange(
`${pageNames.get(row.before.pageId) ?? ""} #${row.before.pageId}`, `${pageNames.get(row.before.pageId) ?? ""} #${row.before.pageId}`,
@@ -196,6 +196,51 @@ it("locks pages before offers and commits the entire batch", async () => {
.every((q) => !q.includes("cost_points")), .every((q) => !q.includes("cost_points")),
).toBe(true); ).toBe(true);
}); });
it("refuses a price change on a BC offer, which has no price column", async () => {
await expect(
previewBulkOffersCommand(
{
ids: [1, 2],
changes: { costCredits: { mode: "set", value: 5 } },
},
"bc",
),
).rejects.toThrow(/Builder Club offer has no price/);
expect(state.writes).toBe(0);
});
it("refuses a currency change on a BC offer too", async () => {
await expect(
previewBulkOffersCommand({ ids: [1], changes: { pointsType: 5 } }, "bc"),
).rejects.toThrow(/currency/);
});
it("allows a BC category move and writes no column BC lacks", async () => {
const p = await previewBulkOffersCommand(
{ ids: [1, 2], changes: { pageId: 9 } },
"bc",
);
expect(p.changedCount).toBe(2);
state.queries = [];
await applyBulkOffersCommand(
{ ids: [1, 2], changes: { pageId: 9 } },
p.fingerprint,
undefined,
undefined,
"bc",
);
// The read and the write both have to stay inside the BC table: reading from
// BC and writing to normal is how a category move would silently corrupt the
// wrong catalog.
expect(state.queries.some((q) => q.includes("catalog_items_bc"))).toBe(true);
expect(
state.queries.some(
(q) => q.startsWith("UPDATE") && !q.includes("catalog_items_bc"),
),
).toBe(false);
expect(state.audit).toHaveLength(0);
});
it("refuses stale prices and changed intent before writing", async () => { it("refuses stale prices and changed intent before writing", async () => {
const p = await previewBulkOffersCommand(input); const p = await previewBulkOffersCommand(input);
state.rows[0].costCredits = 100; state.rows[0].costCredits = 100;
+38 -3
View File
@@ -25,6 +25,29 @@ import {
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]; type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
export type BulkCatalogKind = "normal" | "bc"; export type BulkCatalogKind = "normal" | "bc";
/**
* `catalog_items_bc` stores only a reference to the furniture: there are no
* cost, points, currency or offer_id columns on it at all. So BC bulk editing is
* a category move and nothing else — the other fields are refused here rather
* than reaching the database as an unknown-column error.
*/
const BC_FIELD_LABELS: Record<string, string> = {
costCredits: "price",
costPoints: "points",
pointsType: "currency",
};
function assertEditableFields(input: BulkOfferInput, kind: BulkCatalogKind) {
if (kind !== "bc") return;
const refused = Object.keys(input.changes).filter(
(key) => BC_FIELD_LABELS[key] !== undefined,
);
if (refused.length)
throw new CatalogInputError(
`A Builder Club offer has no ${refused.map((key) => BC_FIELD_LABELS[key]).join(" or ")}. It can only be moved to another category.`,
);
}
/** /**
* The BC catalog has the same offer columns under a second table, so bulk * The BC catalog has the same offer columns under a second table, so bulk
* editing is a table choice rather than a second code path. Only normal offers * editing is a table choice rather than a second code path. Only normal offers
@@ -46,7 +69,11 @@ async function readOffers(
): Promise<OfferRow[]> { ): Promise<OfferRow[]> {
const offers = rowsFrom<OfferRow>( const offers = rowsFrom<OfferRow>(
await tx.execute( await tx.execute(
sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM ${offerTable(kind)} WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`, sql`SELECT id, catalog_name AS catalogName, page_id AS pageId ${
kind === "bc"
? sql`, 0 AS costCredits, 0 AS costPoints, 0 AS pointsType`
: sql`, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType`
} FROM ${offerTable(kind)} WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
), ),
).map((row) => ({ ).map((row) => ({
...row, ...row,
@@ -126,6 +153,7 @@ export async function previewBulkOffersCommand(
kind: BulkCatalogKind = "normal", kind: BulkCatalogKind = "normal",
): Promise<BulkOfferPreview> { ): Promise<BulkOfferPreview> {
const input = bulkOfferInputSchema.parse(value); const input = bulkOfferInputSchema.parse(value);
assertEditableFields(input, kind);
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const offers = await readOffers(tx, input.ids, kind); const offers = await readOffers(tx, input.ids, kind);
return preview(input, offers, await readPages(tx, offers, input, kind)); return preview(input, offers, await readPages(tx, offers, input, kind));
@@ -139,6 +167,7 @@ export async function applyBulkOffersCommand(
kind: BulkCatalogKind = "normal", kind: BulkCatalogKind = "normal",
) { ) {
const input = bulkOfferInputSchema.parse(value); const input = bulkOfferInputSchema.parse(value);
assertEditableFields(input, kind);
if (!/^[a-f0-9]{64}$/.test(fingerprint)) if (!/^[a-f0-9]{64}$/.test(fingerprint))
throw new CatalogInputError("A valid preview is required"); throw new CatalogInputError("A valid preview is required");
const work = async (tx: Tx, operationId?: string) => { const work = async (tx: Tx, operationId?: string) => {
@@ -163,10 +192,16 @@ export async function applyBulkOffersCommand(
for (const row of result.rows) { for (const row of result.rows) {
const changes = ( const changes = (
Object.keys(input.changes) as Array<keyof BulkOfferValues> Object.keys(input.changes) as Array<keyof BulkOfferValues>
).filter((key) => row.before[key] !== row.after[key]); ).filter(
(key) =>
// `assertEditableFields` already refused these, so a BC update
// only ever writes page_id.
(kind !== "bc" || key === "pageId") &&
row.before[key] !== row.after[key],
);
if (!changes.length) continue; if (!changes.length) continue;
await tx.execute( await tx.execute(
sql`UPDATE catalog_items SET ${sql.join( sql`UPDATE ${offerTable(kind)} SET ${sql.join(
changes.map( changes.map(
(key) => (key) =>
sql`${sql.identifier(columns[key])}=${key === "pageId" ? String(row.after[key]) : row.after[key]}`, sql`${sql.identifier(columns[key])}=${key === "pageId" ? String(row.after[key]) : row.after[key]}`,
@@ -44,6 +44,19 @@ const state = vi.hoisted(() => ({
// Categories that still exist; a restore must not write offers onto // Categories that still exist; a restore must not write offers onto
// categories that are gone. // categories that are gone.
pages: [4] as number[], pages: [4] as number[],
// The BC table has six columns and no prices at all, which is exactly why it
// needs its own column list rather than a filtered subset of the normal one.
bcRows: [
{
id: 5,
itemIds: "50",
pageId: 7,
catalogName: "BC Chair",
orderNumber: 1,
extradata: "",
},
] as Array<Record<string, unknown>>,
bcPages: [7] as number[],
audit: [] as Array<{ audit: [] as Array<{
id: number; id: number;
action: string; action: string;
@@ -97,15 +110,20 @@ vi.mock("@/lib/db", async () => {
}, },
transaction: async (fn: (tx: unknown) => Promise<unknown>) => { transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const rowsBefore = structuredClone(state.rows); const rowsBefore = structuredClone(state.rows);
const bcRowsBefore = structuredClone(state.bcRows);
const auditBefore = structuredClone(state.audit); const auditBefore = structuredClone(state.audit);
const pagesBefore = [...state.pages]; const pagesBefore = [...state.pages];
const bcPagesBefore = [...state.bcPages];
try { try {
return await fn({ return await fn({
execute: async (query: SQL) => { execute: async (query: SQL) => {
const { text, params } = run(query); const { text, params } = run(query);
const isBc = text.includes("_bc");
const rows = isBc ? state.bcRows : state.rows;
if (text.startsWith("SELECT") && text.includes("catalog_pages")) { if (text.startsWith("SELECT") && text.includes("catalog_pages")) {
const pool = isBc ? state.bcPages : state.pages;
return [ return [
state.pages pool
.filter((id) => .filter((id) =>
params.some((param) => Number(param) === id), params.some((param) => Number(param) === id),
) )
@@ -114,16 +132,25 @@ vi.mock("@/lib/db", async () => {
]; ];
} }
if (text.startsWith("SELECT") && text.includes("catalog_items")) { if (text.startsWith("SELECT") && text.includes("catalog_items")) {
const wanted = text.includes("FROM catalog_items WHERE id IN"); const wanted = /FROM catalog_items(_bc)? WHERE id IN/.test(
text,
);
return [ return [
wanted wanted
? state.rows.filter((row) => ? rows.filter((row) =>
params.some((param) => Number(param) === row.id), params.some((param) => Number(param) === row.id),
) )
: [], : [],
[], [],
]; ];
} }
if (text.startsWith("DELETE FROM catalog_items_bc")) {
for (const param of params)
state.bcRows = state.bcRows.filter(
(row) => row.id !== Number(param),
);
return [{ affectedRows: params.length }, []];
}
if (text.startsWith("DELETE FROM catalog_items")) { if (text.startsWith("DELETE FROM catalog_items")) {
for (const param of params) for (const param of params)
state.rows = state.rows.filter( state.rows = state.rows.filter(
@@ -131,6 +158,23 @@ vi.mock("@/lib/db", async () => {
); );
return [{ affectedRows: params.length }, []]; return [{ affectedRows: params.length }, []];
} }
if (text.startsWith("INSERT INTO catalog_items_bc")) {
// Read the column names back out of the statement, so a
// column the BC table does not have fails the test.
const columns = text.slice(
text.indexOf("(") + 1,
text.indexOf(") VALUES"),
);
const names = columns
.split(",")
.map((name) => name.trim().replace(/`/g, ""));
state.bcRows.push(
Object.fromEntries(
names.map((name, index) => [name, params[index]]),
),
);
return [{ affectedRows: 1 }, []];
}
if (text.startsWith("INSERT INTO catalog_items")) { if (text.startsWith("INSERT INTO catalog_items")) {
state.rows.push({ state.rows.push({
id: Number(params[0]), id: Number(params[0]),
@@ -196,6 +240,8 @@ vi.mock("@/lib/db", async () => {
state.rows = rowsBefore; state.rows = rowsBefore;
state.audit = auditBefore; state.audit = auditBefore;
state.pages = pagesBefore; state.pages = pagesBefore;
state.bcRows = bcRowsBefore;
state.bcPages = bcPagesBefore;
state.rollbacks++; state.rollbacks++;
throw error; throw error;
} }
@@ -268,6 +314,17 @@ beforeEach(() => {
] as typeof state.rows); ] as typeof state.rows);
state.audit = []; state.audit = [];
state.pages = [4]; state.pages = [4];
state.bcRows = [
{
id: 5,
itemIds: "50",
pageId: 7,
catalogName: "BC Chair",
orderNumber: 1,
extradata: "",
},
];
state.bcPages = [7];
state.queries = []; state.queries = [];
state.nextAuditId = 900; state.nextAuditId = 900;
state.failInsert = false; state.failInsert = false;
@@ -396,6 +453,68 @@ it("skips a corrupt record instead of failing the whole list", async () => {
expect(listed.map((row) => row.offers)).toEqual([1]); expect(listed.map((row) => row.offers)).toEqual([1]);
}); });
it("deletes and restores a BC offer without touching the normal table", async () => {
const { deleted, restoreId } = await deleteCatalogItemsCommand(
[5],
7,
undefined,
"bc",
);
expect(deleted).toBe(1);
expect(state.bcRows).toHaveLength(0);
// The normal catalog is a different table and must be untouched.
expect(state.rows.map((row) => row.id)).toEqual([1, 2]);
expect(state.audit.at(-1)?.target).toBe("catalog_items_bc");
const restored = await restoreDeletedCatalogItemsCommand(restoreId, 7);
expect(restored.restored).toBe(1);
// Column names, because that is what the INSERT carries: proving the restore
// writes the real columns is the point of the BC path existing separately.
expect(state.bcRows[0]).toMatchObject({
id: 5,
item_ids: "50",
// Numbers stay numbers through the audit JSON round trip.
page_id: 7,
catalog_name: "BC Chair",
order_number: 1,
});
// Exactly the six columns the BC table has. A price column here would be an
// unknown-column error against a real database.
expect(Object.keys(state.bcRows[0]).sort()).toEqual([
"catalog_name",
"extradata",
"id",
"item_ids",
"order_number",
"page_id",
]);
});
it("refuses a BC restore whose category is gone", async () => {
const { restoreId } = await deleteCatalogItemsCommand(
[5],
7,
undefined,
"bc",
);
state.bcPages = [];
await expect(restoreDeletedCatalogItemsCommand(restoreId, 7)).rejects.toThrow(
/no longer exist/,
);
expect(state.bcRows).toHaveLength(0);
});
it("lists BC and normal deletions side by side", async () => {
await deleteCatalogItemsCommand([1], 7);
await deleteCatalogItemsCommand([5], 7, undefined, "bc");
const listed = await listRestorableDeletionsCommand();
expect(listed.map((row) => row.catalog)).toEqual(["bc", "normal"]);
});
it("deletes nothing when part of the selection is already gone", async () => { it("deletes nothing when part of the selection is already gone", async () => {
await expect(deleteCatalogItemsCommand([1, 99], 7)).rejects.toThrow(); await expect(deleteCatalogItemsCommand([1, 99], 7)).rejects.toThrow();
expect(state.rows.map((row) => row.id)).toEqual([1, 2]); expect(state.rows.map((row) => row.id)).toEqual([1, 2]);
+84 -28
View File
@@ -12,7 +12,15 @@ import { CatalogInputError, CatalogNotFound } from "../domain/hierarchy";
* have reused the id. So the full row is serialised into the audit log at delete * have reused the id. So the full row is serialised into the audit log at delete
* time and the restore is a re-insert of that exact row. * time and the restore is a re-insert of that exact row.
*/ */
const RESTORE_COLUMNS = [ export type ItemCatalog = "normal" | "bc";
/**
* The BC table stores only a reference to the furniture — no price, limit or
* membership columns exist there. Reading or writing them would be an unknown
* column error, so the two catalogs get their own column list rather than one
* that silently drops fields.
*/
const NORMAL_COLUMNS = [
"id", "id",
"itemIds", "itemIds",
"pageId", "pageId",
@@ -31,13 +39,34 @@ const RESTORE_COLUMNS = [
"clubOnly", "clubOnly",
] as const; ] as const;
type DeletedOffer = Record<(typeof RESTORE_COLUMNS)[number], string | number>; const BC_COLUMNS = [
"id",
"itemIds",
"pageId",
"catalogName",
"orderNumber",
"extradata",
] as const;
function columnsFor(catalog: ItemCatalog): readonly string[] {
return catalog === "bc" ? BC_COLUMNS : NORMAL_COLUMNS;
}
function offersTable(catalog: ItemCatalog) {
return catalog === "bc" ? "catalog_items_bc" : "catalog_items";
}
function pagesTable(catalog: ItemCatalog) {
return catalog === "bc" ? "catalog_pages_bc" : "catalog_pages";
}
type DeletedOffer = Record<string, string | number>;
/** /**
* `haveOffer` is camelCase in the database itself; every other column is * `haveOffer` is camelCase in the database itself; every other column is
* snake_case, so the mapping is written out once rather than derived per call. * snake_case, so the mapping is written out once rather than derived per call.
*/ */
const COLUMN_SQL_NAMES: Record<(typeof RESTORE_COLUMNS)[number], string> = { const COLUMN_SQL_NAMES: Record<(typeof NORMAL_COLUMNS)[number], string> = {
id: "id", id: "id",
itemIds: "item_ids", itemIds: "item_ids",
pageId: "page_id", pageId: "page_id",
@@ -56,24 +85,41 @@ const COLUMN_SQL_NAMES: Record<(typeof RESTORE_COLUMNS)[number], string> = {
clubOnly: "club_only", clubOnly: "club_only",
}; };
const RESTORE_IDS = sql.join( function restoreIds(catalog: ItemCatalog) {
RESTORE_COLUMNS.map((key) => sql.identifier(COLUMN_SQL_NAMES[key])),
sql`, `,
);
function deletedOfferSelect() {
return sql.join( return sql.join(
RESTORE_COLUMNS.map( columnsFor(catalog).map((key) =>
(key) => sql.identifier(COLUMN_SQL_NAMES[key as keyof typeof COLUMN_SQL_NAMES]),
sql`${sql.identifier(COLUMN_SQL_NAMES[key])} AS ${sql.identifier(key)}`,
), ),
sql`, `, sql`, `,
); );
} }
const DELETE_ACTION = "catalog_items_delete_restore"; function deletedOfferSelect(catalog: ItemCatalog) {
return sql.join(
columnsFor(catalog).map(
(key) =>
sql`${sql.identifier(COLUMN_SQL_NAMES[key as keyof typeof COLUMN_SQL_NAMES])} AS ${sql.identifier(key)}`,
),
sql`, `,
);
}
function parseOffers(raw: string | null): DeletedOffer[] { /**
* The catalog is encoded in the action and target columns rather than inferred
* from the payload, so a restore can never re-insert a BC row into the normal
* offers table.
*/
function deleteAction(catalog: ItemCatalog) {
return `${offersTable(catalog)}_delete_restore`;
}
function catalogFromTarget(target: string): ItemCatalog | null {
if (target === "catalog_items_bc") return "bc";
if (target === "catalog_items") return "normal";
return null;
}
function parseOffers(raw: string | null, catalog: ItemCatalog): DeletedOffer[] {
let value: unknown; let value: unknown;
try { try {
value = JSON.parse(raw ?? "null"); value = JSON.parse(raw ?? "null");
@@ -86,7 +132,7 @@ function parseOffers(raw: string | null): DeletedOffer[] {
throw Error("unavailable"); throw Error("unavailable");
} }
const record = row as Record<string, unknown>; const record = row as Record<string, unknown>;
if (!RESTORE_COLUMNS.every((key) => key in record)) { if (!columnsFor(catalog).every((key) => key in record)) {
throw Error("unavailable"); throw Error("unavailable");
} }
} }
@@ -101,6 +147,7 @@ export async function deleteCatalogItemsCommand(
ids: number[], ids: number[],
userId: number, userId: number,
requestKey: string = randomUUID(), requestKey: string = randomUUID(),
catalog: ItemCatalog = "normal",
) { ) {
const selection = [...new Set(ids)].sort((a, b) => a - b); const selection = [...new Set(ids)].sort((a, b) => a - b);
if ( if (
@@ -114,12 +161,14 @@ export async function deleteCatalogItemsCommand(
actorId: userId, actorId: userId,
kind: "catalog.items.delete", kind: "catalog.items.delete",
key: requestKey, key: requestKey,
input: { ids: selection }, // The catalog is part of the request identity: one delete key replayed
// against the other catalog is not the same work.
input: { ids: selection, catalog },
}, },
async (tx, operationId) => { async (tx, operationId) => {
const removed = rowsFrom<DeletedOffer>( const removed = rowsFrom<DeletedOffer>(
await tx.execute( await tx.execute(
sql`SELECT ${deletedOfferSelect()} FROM catalog_items WHERE id IN (${sql.join( sql`SELECT ${deletedOfferSelect(catalog)} FROM ${sql.raw(offersTable(catalog))} WHERE id IN (${sql.join(
selection, selection,
sql`, `, sql`, `,
)}) ORDER BY id FOR UPDATE`, )}) ORDER BY id FOR UPDATE`,
@@ -133,12 +182,12 @@ export async function deleteCatalogItemsCommand(
"Some selected offers were already deleted. Reload the selection.", "Some selected offers were already deleted. Reload the selection.",
); );
await tx.execute( await tx.execute(
sql`DELETE FROM catalog_items WHERE id IN (${sql.join(selection, sql`, `)})`, sql`DELETE FROM ${sql.raw(offersTable(catalog))} WHERE id IN (${sql.join(selection, sql`, `)})`,
); );
const inserted = await tx.insert(AdminAuditLog).values({ const inserted = await tx.insert(AdminAuditLog).values({
userId, userId,
action: DELETE_ACTION, action: deleteAction(catalog),
target: "catalog_items", target: offersTable(catalog),
targetId: null, targetId: null,
details: JSON.stringify(getOperationContext()), details: JSON.stringify(getOperationContext()),
before: JSON.stringify(removed), before: JSON.stringify(removed),
@@ -165,6 +214,7 @@ export async function deleteCatalogItemsCommand(
*/ */
export interface RestorableDeletion { export interface RestorableDeletion {
restoreId: number; restoreId: number;
catalog: ItemCatalog;
offers: number; offers: number;
deletedAt: string; deletedAt: string;
staffId: number; staffId: number;
@@ -177,19 +227,23 @@ export async function listRestorableDeletionsCommand(
const rows = rowsFrom<{ const rows = rowsFrom<{
id: number; id: number;
userId: number; userId: number;
target: string;
before: string | null; before: string | null;
createdAt: string; createdAt: string;
}>( }>(
await db.execute( await db.execute(
sql`SELECT id, user_id AS userId, before, created_at AS createdAt FROM ${AdminAuditLog} WHERE action=${DELETE_ACTION} ORDER BY id DESC LIMIT ${capped}`, sql`SELECT id, user_id AS userId, target, before, created_at AS createdAt FROM ${AdminAuditLog} WHERE action IN (${deleteAction("normal")}, ${deleteAction("bc")}) ORDER BY id DESC LIMIT ${capped}`,
), ),
); );
const entries: RestorableDeletion[] = []; const entries: RestorableDeletion[] = [];
for (const row of rows) { for (const row of rows) {
const catalog = catalogFromTarget(String(row.target));
if (catalog === null) continue;
try { try {
entries.push({ entries.push({
restoreId: Number(row.id), restoreId: Number(row.id),
offers: parseOffers(row.before).length, catalog,
offers: parseOffers(row.before, catalog).length,
deletedAt: String(row.createdAt ?? ""), deletedAt: String(row.createdAt ?? ""),
staffId: Number(row.userId), staffId: Number(row.userId),
}); });
@@ -223,13 +277,14 @@ export async function restoreDeletedCatalogItemsCommand(
.from(AdminAuditLog) .from(AdminAuditLog)
.where(sql`${AdminAuditLog.id}=${restoreId} FOR UPDATE`) .where(sql`${AdminAuditLog.id}=${restoreId} FOR UPDATE`)
.limit(1); .limit(1);
if (!entry || entry.action !== DELETE_ACTION) const catalog = entry ? catalogFromTarget(entry.target) : null;
if (!entry || catalog === null || entry.action !== deleteAction(catalog))
throw new CatalogNotFound("This deletion can no longer be restored"); throw new CatalogNotFound("This deletion can no longer be restored");
const offers = parseOffers(entry.before); const offers = parseOffers(entry.before, catalog);
const ids = offers.map((offer) => Number(offer.id)); const ids = offers.map((offer) => Number(offer.id));
const taken = rowsFrom<{ id: number }>( const taken = rowsFrom<{ id: number }>(
await tx.execute( await tx.execute(
sql`SELECT id FROM catalog_items WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id FOR UPDATE`, sql`SELECT id FROM ${sql.raw(offersTable(catalog))} WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id FOR UPDATE`,
), ),
); );
if (taken.length) if (taken.length)
@@ -248,7 +303,7 @@ export async function restoreDeletedCatalogItemsCommand(
const found = new Set( const found = new Set(
rowsFrom<{ id: number }>( rowsFrom<{ id: number }>(
await tx.execute( await tx.execute(
sql`SELECT id FROM catalog_pages WHERE id IN (${sql.join(pageIds, sql`, `)}) ORDER BY id FOR UPDATE`, sql`SELECT id FROM ${sql.raw(pagesTable(catalog))} WHERE id IN (${sql.join(pageIds, sql`, `)}) ORDER BY id FOR UPDATE`,
), ),
).map((row) => Number(row.id)), ).map((row) => Number(row.id)),
); );
@@ -258,10 +313,11 @@ export async function restoreDeletedCatalogItemsCommand(
`These categories no longer exist, so the offers would be unreachable: ${missing.join(", ")}. Restore was not applied.`, `These categories no longer exist, so the offers would be unreachable: ${missing.join(", ")}. Restore was not applied.`,
); );
} }
const keys = columnsFor(catalog);
for (const offer of offers) { for (const offer of offers) {
await tx.execute( await tx.execute(
sql`INSERT INTO catalog_items (${RESTORE_IDS}) VALUES (${sql.join( sql`INSERT INTO ${sql.raw(offersTable(catalog))} (${restoreIds(catalog)}) VALUES (${sql.join(
RESTORE_COLUMNS.map((key) => sql`${offer[key]}`), keys.map((key) => sql`${offer[key]}`),
sql`, `, sql`, `,
)})`, )})`,
); );
+15 -5
View File
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { asc, sql } from "drizzle-orm"; import { asc, sql } from "drizzle-orm";
import { numericValue } from "@/features/catalog/domain/offer-input"; import { numericValue } from "@/features/catalog/domain/offer-input";
import { CatalogPages, db, queryRows } from "@/lib/db"; import { CatalogPages, CatalogPagesBc, db, queryRows } from "@/lib/db";
import { getFurnitureDataPath } from "@/lib/services/furni-data"; import { getFurnitureDataPath } from "@/lib/services/furni-data";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
@@ -132,19 +132,27 @@ export interface CatalogItemsData {
allPages: { id: number; caption: string }[]; allPages: { id: number; caption: string }[];
/** CMS `habbo_gamedata_hotel` — locale used for Suggest names */ /** CMS `habbo_gamedata_hotel` — locale used for Suggest names */
gamedataHotel: string; gamedataHotel: string;
/** Which catalog these offers came from; BC rows carry no prices. */
catalog: "normal" | "bc";
} }
export async function loadCatalogItemsData( export async function loadCatalogItemsData(
pageId: number, pageId: number,
catalog: "normal" | "bc" = "normal",
): Promise<CatalogItemsData> { ): Promise<CatalogItemsData> {
// Load items via raw query to work around pageId Int vs VARCHAR mismatch // Load items via raw query to work around pageId Int vs VARCHAR mismatch
const pageIdStr = String(pageId); const pageIdStr = String(pageId);
// CAST: live Habbo DBs often store page_id as VARCHAR while schema maps Int. // CAST: live Habbo DBs often store page_id as VARCHAR while schema maps Int.
const offers = catalog === "bc" ? "catalog_items_bc" : "catalog_items";
const rawItems = await queryRows<Record<string, unknown>>(sql` const rawItems = await queryRows<Record<string, unknown>>(sql`
SELECT * FROM catalog_items SELECT * FROM ${sql.raw(offers)}
WHERE CAST(page_id AS CHAR) = ${pageIdStr} WHERE CAST(page_id AS CHAR) = ${pageIdStr}
ORDER BY order_number ASC, id ASC ORDER BY order_number ASC, id ASC
`); `);
// The BC table carries only a reference to the furniture: there are no price,
// limit or membership columns to read. Every other field is filled from the
// column default so the table renders, and stays read-only for those fields
// rather than pretending a price exists.
const items: RawItem[] = rawItems.map((r) => ({ const items: RawItem[] = rawItems.map((r) => ({
id: Number(r.id), id: Number(r.id),
itemIds: String(r.item_ids ?? ""), itemIds: String(r.item_ids ?? ""),
@@ -164,11 +172,12 @@ export async function loadCatalogItemsData(
clubOnly: String(r.club_only ?? "0"), clubOnly: String(r.club_only ?? "0"),
})); }));
const pagesTable = catalog === "bc" ? CatalogPagesBc : CatalogPages;
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([ const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
db db
.select({ id: CatalogPages.id, caption: CatalogPages.caption }) .select({ id: pagesTable.id, caption: pagesTable.caption })
.from(CatalogPages) .from(pagesTable)
.orderBy(asc(CatalogPages.caption)), .orderBy(asc(pagesTable.caption)),
queryRows<{ interaction_type: string }>(sql` queryRows<{ interaction_type: string }>(sql`
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
`), `),
@@ -325,5 +334,6 @@ export async function loadCatalogItemsData(
interactionTypes, interactionTypes,
allPages, allPages,
gamedataHotel, gamedataHotel,
catalog,
}; };
} }