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
+420
-90
No files matched your search
@@ -1,5 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
@@ -50,6 +49,13 @@ vi.mock("@/features/catalog/server/page-commands", () => ({
|
||||
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());
|
||||
vi.mock("@/features/catalog/server/sync-status", () => ({
|
||||
sendCatalogUpdate: mockSendCatalogUpdate,
|
||||
@@ -86,7 +92,6 @@ vi.mock("@/lib/db", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { CatalogItemsBc } from "@/lib/db";
|
||||
import {
|
||||
createBcItem,
|
||||
createBcPage,
|
||||
@@ -179,16 +184,25 @@ describe("updateBcPage", () => {
|
||||
});
|
||||
|
||||
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 });
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(state.deletes).toHaveLength(1);
|
||||
expect(state.deletes[0].table).toBe(CatalogItemsBc);
|
||||
expect(state.deletes[0].where).toEqual(eq(CatalogItemsBc.id, 7));
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: { deleted: 1, restoreId: 55 },
|
||||
});
|
||||
// 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.objectContaining({
|
||||
action: "bc_item_delete",
|
||||
description: "Deleted BC catalog item #7",
|
||||
description: "Deleted BC catalog offer #7",
|
||||
}),
|
||||
);
|
||||
expect(mockSendCatalogUpdate).toHaveBeenCalled();
|
||||
@@ -197,9 +211,14 @@ describe("deleteBcItem", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates delete failures", async () => {
|
||||
state.deleteError = new Error("db down");
|
||||
await expect(deleteBcItem({ id: 7 })).rejects.toThrow("db down");
|
||||
it("reports a failed delete instead of pretending it worked", async () => {
|
||||
mockDeleteCatalogItemsCommand.mockRejectedValueOnce(Error("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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import { deleteCatalogItemsCommand } from "@/features/catalog/server/item-deletes";
|
||||
import {
|
||||
createBcOfferCommand,
|
||||
updateBcOfferCommand,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "@/features/catalog/server/page-commands";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { CatalogItemsBc, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
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);
|
||||
try {
|
||||
return await withCatalogExport(async () => {
|
||||
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
|
||||
const data: { deleted: number; restoreId: number } =
|
||||
await deleteCatalogItemsCommand([id], staff.id, requestKey, "bc");
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_delete",
|
||||
description: `Deleted BC catalog item #${id}`,
|
||||
description: `Deleted BC catalog offer #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
return { ok: true as const, data };
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBcItem({
|
||||
|
||||
@@ -210,9 +210,11 @@ export async function bulkCreateCatalogItems({
|
||||
export async function deleteCatalogItems({
|
||||
ids,
|
||||
requestKey,
|
||||
catalog = "normal",
|
||||
}: {
|
||||
ids: number[];
|
||||
requestKey?: string;
|
||||
catalog?: "normal" | "bc";
|
||||
}) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
try {
|
||||
@@ -220,15 +222,21 @@ export async function deleteCatalogItems({
|
||||
const data: {
|
||||
deleted: number;
|
||||
restoreId: number;
|
||||
} = await deleteCatalogItemsCommand(ids, staff.id, requestKey);
|
||||
} = await deleteCatalogItemsCommand(
|
||||
ids,
|
||||
staff.id,
|
||||
requestKey,
|
||||
catalog === "bc" ? "bc" : "normal",
|
||||
);
|
||||
await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_items_delete",
|
||||
description: `Deleted ${data.deleted} catalog offer(s): ${ids.join(", ")}`,
|
||||
targetType: "catalog_item",
|
||||
description: `Deleted ${data.deleted} ${catalog === "bc" ? "BC " : ""}catalog offer(s): ${ids.join(", ")}`,
|
||||
targetType: catalog === "bc" ? "catalog_item_bc" : "catalog_item",
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
if (catalog === "bc") revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data };
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { apiError, apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { CatalogItemsBc, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
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(
|
||||
{ permission: PERMS.CATALOG_VIEW },
|
||||
async (request) => {
|
||||
@@ -18,16 +10,16 @@ export const GET = withAdmin(
|
||||
if (!Number.isFinite(pageId) || pageId <= 0) {
|
||||
return apiError("Invalid pageId");
|
||||
}
|
||||
const isBc = request.nextUrl.searchParams.get("catalog") === "bc";
|
||||
if (isBc) {
|
||||
const items = await db
|
||||
.select()
|
||||
.from(CatalogItemsBc)
|
||||
.where(eq(CatalogItemsBc.pageId, pageId))
|
||||
.orderBy(asc(CatalogItemsBc.orderNumber));
|
||||
return apiOk({ items: jsonSafe(items) });
|
||||
}
|
||||
const data = await loadCatalogItemsData(pageId);
|
||||
return apiOk(jsonSafe(data) as unknown as Record<string, unknown>);
|
||||
const catalog =
|
||||
request.nextUrl.searchParams.get("catalog") === "bc" ? "bc" : "normal";
|
||||
// Both catalogs go through the loader. The BC branch used to select the
|
||||
// rows directly, which returned them without the furni enrichment the
|
||||
// table needs to render anything but a bare caption.
|
||||
return apiOk(
|
||||
(await loadCatalogItemsData(pageId, catalog)) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -233,9 +233,10 @@ function InlineEditorSession({
|
||||
setItemsError(false);
|
||||
setItemsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`, {
|
||||
signal: request.signal,
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/admin/catalog/items?pageId=${id}${catQs}`,
|
||||
{ signal: request.signal },
|
||||
);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
if (!request.isCurrent()) return;
|
||||
@@ -250,7 +251,10 @@ function InlineEditorSession({
|
||||
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(() => {
|
||||
|
||||
@@ -564,7 +564,10 @@ export function CatalogItemsTable({
|
||||
if (!ok) return;
|
||||
|
||||
const ids = [...selected];
|
||||
run(() => deleteCatalogItems({ ids, requestKey: crypto.randomUUID() }), {
|
||||
run(
|
||||
() =>
|
||||
deleteCatalogItems({ ids, requestKey: crypto.randomUUID(), catalog }),
|
||||
{
|
||||
successMessage: `Deleted ${ids.length} item(s).`,
|
||||
errorMessage: "Failed to delete items.",
|
||||
onSuccess: (data) => {
|
||||
@@ -572,7 +575,8 @@ export function CatalogItemsTable({
|
||||
onRefresh();
|
||||
offerUndoDelete(Number(data.restoreId), ids.length);
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Move selected items to another page ─────────────────────────
|
||||
|
||||
@@ -288,8 +288,16 @@ export function BulkOfferEditor({
|
||||
{!preview ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("fieldHint")}</p>
|
||||
{/* A BC offer has no price, points or currency column to
|
||||
write. The server refuses those changes too; hiding the
|
||||
fields keeps the dialog from offering what cannot be done. */}
|
||||
{catalog !== "bc" && (
|
||||
<>
|
||||
{(["costCredits", "costPoints"] as const).map((field) => (
|
||||
<div key={field} className="space-y-3 rounded-md border p-3">
|
||||
<div
|
||||
key={field}
|
||||
className="space-y-3 rounded-md border p-3"
|
||||
>
|
||||
<Label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={prices[field].enabled}
|
||||
@@ -338,10 +346,14 @@ export function BulkOfferEditor({
|
||||
<Input
|
||||
id={`bulk-${field}-value`}
|
||||
type="number"
|
||||
step={prices[field].mode === "percent" ? "any" : 1}
|
||||
step={
|
||||
prices[field].mode === "percent" ? "any" : 1
|
||||
}
|
||||
value={prices[field].value}
|
||||
onChange={(event) =>
|
||||
updatePrice(field, { value: event.target.value })
|
||||
updatePrice(field, {
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
@@ -375,7 +387,9 @@ export function BulkOfferEditor({
|
||||
<SelectItem key={type} value={type}>
|
||||
<span className="flex items-center gap-2">
|
||||
<CurrencyIcon
|
||||
kind={currencyKindFromPointsType(Number(type))}
|
||||
kind={currencyKindFromPointsType(
|
||||
Number(type),
|
||||
)}
|
||||
alt=""
|
||||
/>
|
||||
{currencyName(Number(type))}
|
||||
@@ -386,6 +400,8 @@ export function BulkOfferEditor({
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
@@ -434,13 +450,16 @@ export function BulkOfferEditor({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{[
|
||||
{(catalog === "bc"
|
||||
? ["offer", "destination"]
|
||||
: [
|
||||
"offer",
|
||||
"costCredits",
|
||||
"costPoints",
|
||||
"currency",
|
||||
"destination",
|
||||
].map((key) => (
|
||||
]
|
||||
).map((key) => (
|
||||
<TableHead key={key}>{t(key)}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -456,6 +475,8 @@ export function BulkOfferEditor({
|
||||
#{row.id}
|
||||
</span>
|
||||
</TableCell>
|
||||
{catalog !== "bc" && (
|
||||
<>
|
||||
<TableCell>
|
||||
{renderChange(
|
||||
row.before.costCredits,
|
||||
@@ -474,6 +495,8 @@ export function BulkOfferEditor({
|
||||
currencyName(row.after.pointsType),
|
||||
)}
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell>
|
||||
{renderChange(
|
||||
`${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")),
|
||||
).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 () => {
|
||||
const p = await previewBulkOffersCommand(input);
|
||||
state.rows[0].costCredits = 100;
|
||||
|
||||
@@ -25,6 +25,29 @@ import {
|
||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
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
|
||||
* editing is a table choice rather than a second code path. Only normal offers
|
||||
@@ -46,7 +69,11 @@ async function readOffers(
|
||||
): Promise<OfferRow[]> {
|
||||
const offers = rowsFrom<OfferRow>(
|
||||
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) => ({
|
||||
...row,
|
||||
@@ -126,6 +153,7 @@ export async function previewBulkOffersCommand(
|
||||
kind: BulkCatalogKind = "normal",
|
||||
): Promise<BulkOfferPreview> {
|
||||
const input = bulkOfferInputSchema.parse(value);
|
||||
assertEditableFields(input, kind);
|
||||
return db.transaction(async (tx) => {
|
||||
const offers = await readOffers(tx, input.ids, kind);
|
||||
return preview(input, offers, await readPages(tx, offers, input, kind));
|
||||
@@ -139,6 +167,7 @@ export async function applyBulkOffersCommand(
|
||||
kind: BulkCatalogKind = "normal",
|
||||
) {
|
||||
const input = bulkOfferInputSchema.parse(value);
|
||||
assertEditableFields(input, kind);
|
||||
if (!/^[a-f0-9]{64}$/.test(fingerprint))
|
||||
throw new CatalogInputError("A valid preview is required");
|
||||
const work = async (tx: Tx, operationId?: string) => {
|
||||
@@ -163,10 +192,16 @@ export async function applyBulkOffersCommand(
|
||||
for (const row of result.rows) {
|
||||
const changes = (
|
||||
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;
|
||||
await tx.execute(
|
||||
sql`UPDATE catalog_items SET ${sql.join(
|
||||
sql`UPDATE ${offerTable(kind)} SET ${sql.join(
|
||||
changes.map(
|
||||
(key) =>
|
||||
sql`${sql.identifier(columns[key])}=${key === "pageId" ? String(row.after[key]) : row.after[key]}`,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { asc, sql } from "drizzle-orm";
|
||||
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 { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||
|
||||
@@ -132,19 +132,27 @@ export interface CatalogItemsData {
|
||||
allPages: { id: number; caption: string }[];
|
||||
/** CMS `habbo_gamedata_hotel` — locale used for Suggest names */
|
||||
gamedataHotel: string;
|
||||
/** Which catalog these offers came from; BC rows carry no prices. */
|
||||
catalog: "normal" | "bc";
|
||||
}
|
||||
|
||||
export async function loadCatalogItemsData(
|
||||
pageId: number,
|
||||
catalog: "normal" | "bc" = "normal",
|
||||
): Promise<CatalogItemsData> {
|
||||
// Load items via raw query to work around pageId Int vs VARCHAR mismatch
|
||||
const pageIdStr = String(pageId);
|
||||
// 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`
|
||||
SELECT * FROM catalog_items
|
||||
SELECT * FROM ${sql.raw(offers)}
|
||||
WHERE CAST(page_id AS CHAR) = ${pageIdStr}
|
||||
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) => ({
|
||||
id: Number(r.id),
|
||||
itemIds: String(r.item_ids ?? ""),
|
||||
@@ -164,11 +172,12 @@ export async function loadCatalogItemsData(
|
||||
clubOnly: String(r.club_only ?? "0"),
|
||||
}));
|
||||
|
||||
const pagesTable = catalog === "bc" ? CatalogPagesBc : CatalogPages;
|
||||
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
|
||||
db
|
||||
.select({ id: CatalogPages.id, caption: CatalogPages.caption })
|
||||
.from(CatalogPages)
|
||||
.orderBy(asc(CatalogPages.caption)),
|
||||
.select({ id: pagesTable.id, caption: pagesTable.caption })
|
||||
.from(pagesTable)
|
||||
.orderBy(asc(pagesTable.caption)),
|
||||
queryRows<{ interaction_type: string }>(sql`
|
||||
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
|
||||
`),
|
||||
@@ -325,5 +334,6 @@ export async function loadCatalogItemsData(
|
||||
interactionTypes,
|
||||
allPages,
|
||||
gamedataHotel,
|
||||
catalog,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user