perf(app): batch catalog updates, tune caches, add captcha env fallback

- catalog: apply bulk offer edits in a single transaction with one RCON
  refresh instead of N round-trips, virtualize the items table past 150
  rows and cap the shop preview at 200 tiles
- cache: lift the public cache TTLs and the boot warm-up into env-tunable
  constants (src/lib/perf-ttls.ts; CMS_*_TTL_SEC, CMS_WARMUP_ENABLED)
- auth: resolve the captcha provider, site key and secret from
  CAPTCHA_PROVIDER, TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY when the
  admin settings are unset; an explicit DB value (including "none") still
  wins, so Turnstile can be enabled per-deploy without touching the DB.
This commit is contained in:
openhands committed 2026-10-15 16:00:00 +02:00
1 parent 05c2706eaf
commit 9f024dc6ac
15 files changed
+529 -83

No files matched your search

+86
View File
@@ -10,6 +10,7 @@ import {
restoreDeletedCatalogItemsCommand,
} from "@/features/catalog/server/item-deletes";
import {
bulkUpdateOffersCommand,
createOfferCommand,
moveBcOffersCommand,
moveOffersCommand,
@@ -399,6 +400,91 @@ export async function updateCatalogItem({
});
}
/**
* "Save All" for the inline table editor. One transaction, one RCON refresh,
* one audit row — instead of a full per-item round-trip that took tens of
* seconds on pages with hundreds of edited rows. A failing row rolls back the
* whole batch, so the editor never ends up half-saved.
*/
export async function updateCatalogItemsBulk({
updates,
catalog = "normal",
}: {
updates: Array<{ id: number; catalogFields: Record<string, unknown> }>;
catalog?: "normal" | "bc";
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
if (!Array.isArray(updates) || updates.length === 0) {
return { ok: false as const, error: "No items to update" };
}
if (updates.length > 500) {
return { ok: false as const, error: "Max 500 items per bulk update" };
}
// BC rows go through their own command per item (no price columns, and the
// BC command keeps its narrower field set); the loop still saves the
// per-item RCON refreshes inside one export lock below.
if (catalog === "bc") {
const { updateBcItem } = await import("@/actions/catalog-bc");
return await withCatalogExport(async () => {
let savedCount = 0;
let lastError = "";
for (const update of updates) {
const result = await updateBcItem({
id: update.id,
...(update.catalogFields as {
itemIds?: string;
catalogName?: string;
orderNumber?: number;
extradata?: string;
}),
});
if (result.ok) savedCount++;
else lastError = result.error ?? "Unable to update item";
}
if (savedCount > 0) {
await logStaffActivity({
staffId: staff.id,
action: "bc_items_bulk_update",
description: `Updated ${savedCount} BC catalog item(s)`,
targetType: "catalog_item_bc",
});
}
if (savedCount === 0 && lastError) {
return { ok: false as const, error: lastError };
}
return {
ok: true as const,
data: { savedCount, failedCount: updates.length - savedCount },
};
});
}
return await withCatalogExport(async () => {
try {
await bulkUpdateOffersCommand(updates, staff.id);
} catch (error) {
return {
ok: false as const,
error:
error instanceof Error
? error.message
: "Unable to update catalog items",
};
}
await sendCatalogUpdate();
await logStaffActivity({
staffId: staff.id,
action: "catalog_items_bulk_update",
description: `Updated ${updates.length} catalog item(s)`,
targetType: "catalog_item",
});
revalidatePath("/admin/catalog");
return {
ok: true as const,
data: { savedCount: updates.length, failedCount: 0 },
};
});
}
export async function translateCatalogItems(input: {
/** `id` is items_base.id (not catalog_items.id) */
items: Array<{ id: number; publicName: string; description?: string }>;