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

@@ -288,104 +288,120 @@ export function BulkOfferEditor({
{!preview ? (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("fieldHint")}</p>
{(["costCredits", "costPoints"] as const).map((field) => (
<div key={field} className="space-y-3 rounded-md border p-3">
<Label className="flex items-center gap-2">
<Checkbox
checked={prices[field].enabled}
onCheckedChange={(value) =>
updatePrice(field, { enabled: value === true })
}
disabled={busy}
/>
{t(field)}
</Label>
{prices[field].enabled && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<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 })
{/* 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"
>
<Label className="flex items-center gap-2">
<Checkbox
checked={prices[field].enabled}
onCheckedChange={(value) =>
updatePrice(field, { enabled: value === true })
}
disabled={busy}
/>
</div>
</div>
)}
</div>
))}
<div className="space-y-3 rounded-md border p-3">
<Label className="flex items-center gap-2">
<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=""
{t(field)}
</Label>
{prices[field].enabled && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<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}
/>
{currencyName(Number(type))}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
</div>
)}
</div>
))}
<div className="space-y-3 rounded-md border p-3">
<Label className="flex items-center gap-2">
<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">
<Label className="flex items-center gap-2">
<Checkbox
@@ -434,13 +450,16 @@ export function BulkOfferEditor({
<Table>
<TableHeader>
<TableRow>
{[
"offer",
"costCredits",
"costPoints",
"currency",
"destination",
].map((key) => (
{(catalog === "bc"
? ["offer", "destination"]
: [
"offer",
"costCredits",
"costPoints",
"currency",
"destination",
]
).map((key) => (
<TableHead key={key}>{t(key)}</TableHead>
))}
</TableRow>
@@ -456,24 +475,28 @@ export function BulkOfferEditor({
#{row.id}
</span>
</TableCell>
<TableCell>
{renderChange(
row.before.costCredits,
row.after.costCredits,
)}
</TableCell>
<TableCell>
{renderChange(
row.before.costPoints,
row.after.costPoints,
)}
</TableCell>
<TableCell>
{renderChange(
currencyName(row.before.pointsType),
currencyName(row.after.pointsType),
)}
</TableCell>
{catalog !== "bc" && (
<>
<TableCell>
{renderChange(
row.before.costCredits,
row.after.costCredits,
)}
</TableCell>
<TableCell>
{renderChange(
row.before.costPoints,
row.after.costPoints,
)}
</TableCell>
<TableCell>
{renderChange(
currencyName(row.before.pointsType),
currencyName(row.after.pointsType),
)}
</TableCell>
</>
)}
<TableCell>
{renderChange(
`${pageNames.get(row.before.pageId) ?? ""} #${row.before.pageId}`,