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
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:
1 parent
cebcf440c5
commit
e0efbef30d
12 files changed
+548
-218
No files matched your search
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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`, `,
|
||||||
)})`,
|
)})`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user