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:
1 parent
05c2706eaf
commit
9f024dc6ac
15 files changed
+510
-64
No files matched your search
@@ -18,6 +18,21 @@ CACHE_MEMORY_MAX_ENTRIES=2000
|
||||
# every 5 minutes brings an over-budget directory back to 90% of this (default 20000).
|
||||
IMAGING_CACHE_MAX_ENTRIES=20000
|
||||
|
||||
# --- CACHE TTL TUNING (seconds; defaults apply when unset) ---
|
||||
# Higher = less DB load but staler data. Publishing news rotates its cache
|
||||
# revision immediately, so news TTLs only delay content that bypasses that path.
|
||||
CMS_HOME_PAYLOAD_TTL_SEC=15
|
||||
CMS_NEWS_LIST_TTL_SEC=300
|
||||
CMS_NEWS_ARCHIVE_TTL_SEC=60
|
||||
CMS_SEARCH_TTL_SEC=30
|
||||
CMS_ONLINE_COUNT_TTL_SEC=10
|
||||
CMS_COUNTERS_TTL_SEC=300
|
||||
CMS_SITE_SETTINGS_TTL_SEC=300
|
||||
CMS_SITE_SETTINGS_MEMORY_TTL_SEC=60
|
||||
# Boot-time cache warm-up (primes hot keys after each deploy). Set false on
|
||||
# tiny hosts where the boot COUNT(*) burst is undesirable.
|
||||
CMS_WARMUP_ENABLED=true
|
||||
|
||||
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||
NODE_ENV=production
|
||||
PORT=3002
|
||||
@@ -78,6 +93,14 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true
|
||||
# Override for tests/staging (production uses the public endpoint by default).
|
||||
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
|
||||
|
||||
# --- CAPTCHA (env fallback; admin settings win when set) ---
|
||||
# Enable Turnstile on register/login/forgot/resend without touching the DB.
|
||||
# "none" (default), "turnstile", "recaptcha" or "hcaptcha".
|
||||
CAPTCHA_PROVIDER=
|
||||
# Cloudflare Turnstile keys from https://dash.cloudflare.com/turnstile
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
|
||||
# --- PATHS ---
|
||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Copy,
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createCatalogItem,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
reorderCatalogItems,
|
||||
restoreDeletedCatalogItems,
|
||||
updateCatalogItem,
|
||||
updateCatalogItemsBulk,
|
||||
} from "@/actions/catalog-items";
|
||||
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
||||
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
|
||||
@@ -115,10 +117,15 @@ export type { BaseItemData, CatalogItemData };
|
||||
function SortableRow({
|
||||
id,
|
||||
className,
|
||||
dataIndex,
|
||||
measureRef,
|
||||
children,
|
||||
}: {
|
||||
id: number;
|
||||
className?: string;
|
||||
/** Virtualizer index; forwarded as data-index so measureElement can map it. */
|
||||
dataIndex?: number;
|
||||
measureRef?: (el: HTMLTableRowElement | null) => void;
|
||||
children: (props: {
|
||||
dragHandleProps: React.HTMLAttributes<HTMLDivElement>;
|
||||
}) => React.ReactNode;
|
||||
@@ -133,6 +140,13 @@ function SortableRow({
|
||||
} = useSortable({
|
||||
id,
|
||||
});
|
||||
const setRefs = useCallback(
|
||||
(node: HTMLTableRowElement | null) => {
|
||||
setNodeRef(node);
|
||||
measureRef?.(node);
|
||||
},
|
||||
[setNodeRef, measureRef],
|
||||
);
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
@@ -144,7 +158,8 @@ function SortableRow({
|
||||
} as unknown as React.HTMLAttributes<HTMLDivElement>;
|
||||
return (
|
||||
<TableRow
|
||||
ref={setNodeRef as unknown as React.Ref<HTMLTableRowElement>}
|
||||
ref={setRefs}
|
||||
data-index={dataIndex}
|
||||
style={style}
|
||||
className={className}
|
||||
>
|
||||
@@ -280,6 +295,23 @@ export function CatalogItemsTable({
|
||||
.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
||||
}, [filteredItems, itemOrder, searchQuery, filters]);
|
||||
|
||||
// ── Virtualization (table view) ─────────────────────────────────
|
||||
// A page with hundreds of offers used to mount a full <tr> per row, each
|
||||
// with several inputs and icons — the single biggest source of lag in the
|
||||
// catalog editor. Above the threshold only the visible window (plus a small
|
||||
// overscan) is mounted; spacer rows keep the scrollbar honest.
|
||||
const VIRTUALIZE_THRESHOLD = 150;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isVirtualized = orderedFilteredItems.length > VIRTUALIZE_THRESHOLD;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: orderedFilteredItems.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 52,
|
||||
overscan: 15,
|
||||
enabled: isVirtualized,
|
||||
});
|
||||
const virtualRows = isVirtualized ? virtualizer.getVirtualItems() : [];
|
||||
|
||||
// ── Selection ────────────────────────────────────────────────────
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
|
||||
@@ -504,18 +536,12 @@ export function CatalogItemsTable({
|
||||
async function handleSaveAllModified() {
|
||||
if (modifiedItems.length === 0) return;
|
||||
|
||||
run(
|
||||
async () => {
|
||||
let savedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const item of modifiedItems) {
|
||||
const updates = modifiedItems.flatMap((item) => {
|
||||
const edit = inlineEdits[item.id];
|
||||
if (!edit) continue;
|
||||
|
||||
const result = await updateCatalogItem({
|
||||
if (!edit) return [];
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
catalog,
|
||||
catalogFields: {
|
||||
catalogName: item.catalogName,
|
||||
itemIds: item.itemIds,
|
||||
@@ -531,28 +557,15 @@ export function CatalogItemsTable({
|
||||
clubOnly: item.clubOnly,
|
||||
extradata: item.extradata,
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (result.ok) savedCount++;
|
||||
else failedCount++;
|
||||
}
|
||||
|
||||
if (savedCount > 0) {
|
||||
onRefresh();
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Saved ${savedCount}, failed ${failedCount}.`,
|
||||
};
|
||||
}
|
||||
return { ok: true as const, data: { savedCount } };
|
||||
},
|
||||
{
|
||||
successMessage: `Saved ${modifiedItems.length} item(s).`,
|
||||
run(() => updateCatalogItemsBulk({ updates, catalog }), {
|
||||
successMessage: `Saved ${updates.length} item(s).`,
|
||||
errorMessage: "Failed to save items.",
|
||||
},
|
||||
);
|
||||
onSuccess: () => onRefresh(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Bulk delete selected ─────────────────────────────────────────
|
||||
@@ -1243,7 +1256,10 @@ export function CatalogItemsTable({
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<div className="rounded-md border overflow-auto max-h-[600px]">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="rounded-md border overflow-auto max-h-[600px]"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableRow>
|
||||
@@ -1278,7 +1294,29 @@ export function CatalogItemsTable({
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<TableBody>
|
||||
{orderedFilteredItems.map((item) => {
|
||||
{isVirtualized && virtualRows.length > 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={10}
|
||||
style={{
|
||||
height: virtualRows[0]?.start ?? 0,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
}}
|
||||
/>
|
||||
</tr>
|
||||
)}
|
||||
{(isVirtualized
|
||||
? virtualRows.map((vr) => ({
|
||||
item: orderedFilteredItems[vr.index],
|
||||
index: vr.index,
|
||||
}))
|
||||
: orderedFilteredItems.map((item, index) => ({
|
||||
item,
|
||||
index,
|
||||
}))
|
||||
).map(({ item, index }) => {
|
||||
if (!item) return null;
|
||||
const pts = getPointsLabel(item.pointsType);
|
||||
const edit = inlineEdits[item.id];
|
||||
const modified = isItemModified(item);
|
||||
@@ -1289,6 +1327,14 @@ export function CatalogItemsTable({
|
||||
<SortableRow
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
dataIndex={index}
|
||||
measureRef={
|
||||
isVirtualized
|
||||
? (el) => {
|
||||
if (el) virtualizer.measureElement(el);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
modified &&
|
||||
"bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]",
|
||||
@@ -1561,6 +1607,25 @@ export function CatalogItemsTable({
|
||||
</SortableRow>
|
||||
);
|
||||
})}
|
||||
{isVirtualized &&
|
||||
virtualRows.length > 0 &&
|
||||
(() => {
|
||||
const last = virtualRows[virtualRows.length - 1];
|
||||
const total = virtualizer.getTotalSize();
|
||||
if (!last || last.end >= total) return null;
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={10}
|
||||
style={{
|
||||
height: total - last.end,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
}}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
})()}
|
||||
</TableBody>
|
||||
</SortableContext>
|
||||
</Table>
|
||||
|
||||
@@ -18,6 +18,14 @@ interface ItemsShopPreviewProps {
|
||||
onItemClick?: (item: CatalogItemData) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview is a visual check, not an editor. Mounting a tile (image, badges,
|
||||
* click handler) per offer made pages with hundreds of items crawl, so the grid
|
||||
* shows a window and states how many are hidden. The table view is the place to
|
||||
* work through the full list.
|
||||
*/
|
||||
const PREVIEW_TILE_LIMIT = 200;
|
||||
|
||||
export function ItemsShopPreview({
|
||||
items,
|
||||
furniRevisionMap,
|
||||
@@ -38,15 +46,23 @@ export function ItemsShopPreview({
|
||||
|
||||
const gridClass = getShopGridClass(layout);
|
||||
const tileSize = getShopTileSize(layout);
|
||||
const visibleItems =
|
||||
items.length > PREVIEW_TILE_LIMIT
|
||||
? items.slice(0, PREVIEW_TILE_LIMIT)
|
||||
: items;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-gradient-to-b from-[var(--admin-surface)] to-[var(--admin-canvas)] p-4">
|
||||
<div className="mb-3 flex items-center justify-between text-xs text-[var(--admin-text-muted)]">
|
||||
<span className="font-medium">Shop preview · {layout}</span>
|
||||
<span>{items.length} items</span>
|
||||
<span>
|
||||
{items.length} items
|
||||
{visibleItems.length !== items.length &&
|
||||
` · showing first ${visibleItems.length}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn("grid gap-2", gridClass)}>
|
||||
{items.map((item) => {
|
||||
{visibleItems.map((item) => {
|
||||
const firstId = parseFirstItemId(item.itemIds);
|
||||
const furni = firstId != null ? furniRevisionMap[firstId] : undefined;
|
||||
|
||||
@@ -62,6 +78,12 @@ export function ItemsShopPreview({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{visibleItems.length !== items.length && (
|
||||
<p className="mt-3 text-center text-xs text-[var(--admin-text-muted)]">
|
||||
Preview capped at {PREVIEW_TILE_LIMIT} items — use the table view for
|
||||
the rest.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+34
@@ -103,6 +103,27 @@ const schema = z
|
||||
// Redis — strongly recommended in production (required for multi-instance).
|
||||
// Without it, rate limits / shared caches are in-process only.
|
||||
REDIS_URL: z.string().optional(),
|
||||
// ── Cache TTL tuning (seconds; see src/lib/perf-ttls.ts) ──────────────
|
||||
// Defaults match the previous hard-coded values; set only to tune.
|
||||
CMS_HOME_PAYLOAD_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||
CMS_NEWS_LIST_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||
CMS_NEWS_ARCHIVE_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||
CMS_SEARCH_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||
CMS_ONLINE_COUNT_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||
CMS_COUNTERS_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||
CMS_SITE_SETTINGS_TTL_SEC: z.coerce.number().int().positive().optional(),
|
||||
CMS_SITE_SETTINGS_MEMORY_TTL_SEC: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional(),
|
||||
// Boot-time cache warm-up (priming hot keys after each deploy). Set to
|
||||
// "false"/"0" to skip — useful on hosts where the boot DB burst is
|
||||
// undesirable (e.g. tiny containers right after a restore).
|
||||
CMS_WARMUP_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value !== "false" && value !== "0"),
|
||||
// App-layer anti-DDoS gate (proxy rate limiter). On by default in
|
||||
// production; set to "false" or "0" to disable without removing it.
|
||||
ANTI_DDOS_ENABLED: z
|
||||
@@ -151,6 +172,19 @@ const schema = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value !== "false" && value !== "0"),
|
||||
// ── CAPTCHA (Turnstile) — env fallback for the DB-backed settings ─────
|
||||
// The admin panel (housekeeping → settings) wins when it has values; these
|
||||
// let a deploy enable Turnstile without touching the DB. An explicit DB
|
||||
// value — including "none" — always overrides the env fallback.
|
||||
CAPTCHA_PROVIDER: z.preprocess(
|
||||
(value) => (value === "" ? undefined : value),
|
||||
z.enum(["none", "turnstile", "recaptcha", "hcaptcha"]).optional(),
|
||||
),
|
||||
// Public site key (rendered in the widget via server → client props).
|
||||
TURNSTILE_SITE_KEY: z.string().optional(),
|
||||
// Server-only secret used against Cloudflare's siteverify endpoint.
|
||||
// Never NEXT_PUBLIC_: verification would become client-trusting.
|
||||
TURNSTILE_SECRET_KEY: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.NODE_ENV !== "production") return;
|
||||
|
||||
@@ -31,7 +31,13 @@ export type UpdateOfferInput = {
|
||||
catalogFields: Record<string, unknown>;
|
||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||
};
|
||||
type OfferRow = { id: number; pageId: number | string; itemIds: string };
|
||||
type OfferRow = {
|
||||
id: number;
|
||||
pageId: number | string;
|
||||
itemIds: string;
|
||||
limitedStack?: number | string;
|
||||
limitedSells?: number | string;
|
||||
};
|
||||
async function lockedOffers(
|
||||
tx: Transaction,
|
||||
ids: number[],
|
||||
@@ -39,7 +45,7 @@ async function lockedOffers(
|
||||
): Promise<OfferRow[]> {
|
||||
const offers = rowsFrom<OfferRow>(
|
||||
await tx.execute(
|
||||
sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join(
|
||||
sql`SELECT id, page_id AS pageId, item_ids AS itemIds, limited_stack AS limitedStack, limited_sells AS limitedSells FROM ${offerTable(kind)} WHERE id IN (${sql.join(
|
||||
[...ids].sort((a, b) => a - b),
|
||||
sql`, `,
|
||||
)}) ORDER BY id FOR UPDATE`,
|
||||
@@ -193,6 +199,118 @@ export async function updateOfferCommand(
|
||||
await recordHistory(tx, "prices", input.id, userId, before);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Many offer patches in ONE transaction: the inline table editor's "Save All"
|
||||
* used to issue a transaction + lock + RCON refresh + audit row per item, which
|
||||
* took tens of seconds on a page with hundreds of edited rows. Lock ordering
|
||||
* matches {@link updateOfferCommand}: pages → offers → furniture. A failing row
|
||||
* aborts the whole batch (full rollback) rather than leaving a half-applied save.
|
||||
*/
|
||||
export async function bulkUpdateOffersCommand(
|
||||
updates: UpdateOfferInput[],
|
||||
userId?: number,
|
||||
) {
|
||||
if (!updates.length) throw Error("No valid fields to update");
|
||||
if (updates.length > 500) throw Error("Max 500 items per bulk update");
|
||||
const parsed = updates.map((input) => {
|
||||
offerIdSchema.parse(input.id);
|
||||
const fields = offerPatchSchema.parse(input.catalogFields);
|
||||
const baseFields = input.baseItem
|
||||
? furniturePatchSchema.parse(input.baseItem.fields)
|
||||
: {};
|
||||
if (!Object.values(fields).some((value) => value !== undefined))
|
||||
throw Error("No valid fields to update");
|
||||
return { input, fields, baseFields };
|
||||
});
|
||||
|
||||
const offerIds = distinctOfferIds(parsed.map((row) => row.input.id));
|
||||
const furnitureIds: number[] = [];
|
||||
for (const row of parsed) {
|
||||
if (row.fields.itemIds !== undefined)
|
||||
furnitureIds.push(...parseFurnitureIds(row.fields.itemIds));
|
||||
if (row.input.baseItem) furnitureIds.push(row.input.baseItem.id);
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
for (const pageId of new Set(
|
||||
parsed.map((row) => row.fields.pageId).filter((id) => id !== undefined),
|
||||
))
|
||||
await lockPage(tx, pageId as number);
|
||||
const offers = await lockedOffers(tx, offerIds);
|
||||
const offerById = new Map(offers.map((offer) => [Number(offer.id), offer]));
|
||||
if (furnitureIds.length) await lockFurniture(tx, furnitureIds);
|
||||
|
||||
const beforeById = new Map<
|
||||
number,
|
||||
Awaited<ReturnType<typeof historySnapshot>>
|
||||
>();
|
||||
if (userId)
|
||||
for (const id of offerIds)
|
||||
beforeById.set(id, await historySnapshot(tx, "prices", id));
|
||||
|
||||
for (const row of parsed) {
|
||||
const offer = offerById.get(row.input.id);
|
||||
if (!offer) throw Error("Catalog item not found");
|
||||
if (row.input.baseItem) {
|
||||
const currentIds = String(offer.itemIds).split(";").map(Number);
|
||||
const nextIds =
|
||||
row.fields.itemIds === undefined
|
||||
? currentIds
|
||||
: parseFurnitureIds(row.fields.itemIds);
|
||||
if (
|
||||
!currentIds.includes(row.input.baseItem.id) ||
|
||||
!nextIds.includes(row.input.baseItem.id)
|
||||
)
|
||||
throw Error("Shared furniture must belong to the catalog offer");
|
||||
}
|
||||
if (
|
||||
row.fields.orderNumber !== undefined ||
|
||||
row.fields.pageId !== undefined
|
||||
) {
|
||||
const targetPageId = row.fields.pageId ?? offer.pageId;
|
||||
if (row.fields.orderNumber !== undefined)
|
||||
await assertOrderUnique(
|
||||
tx,
|
||||
targetPageId,
|
||||
row.fields.orderNumber,
|
||||
row.input.id,
|
||||
);
|
||||
}
|
||||
if (
|
||||
row.fields.limitedStack !== undefined ||
|
||||
row.fields.limitedSells !== undefined
|
||||
) {
|
||||
const stack = row.fields.limitedStack ?? 0;
|
||||
const sells = row.fields.limitedSells ?? 0;
|
||||
const resolvedStack =
|
||||
row.fields.limitedStack !== undefined
|
||||
? stack
|
||||
: Number(offer.limitedStack ?? 0);
|
||||
const resolvedSells =
|
||||
row.fields.limitedSells !== undefined
|
||||
? sells
|
||||
: Number(offer.limitedSells ?? 0);
|
||||
assertLimitedStackOk(resolvedStack, resolvedSells);
|
||||
}
|
||||
const offerAssignments = assignments(CatalogItems, row.fields);
|
||||
if (offerAssignments.length)
|
||||
await tx.execute(
|
||||
sql`UPDATE ${CatalogItems} SET ${sql.join(offerAssignments, sql`, `)} WHERE id=${row.input.id}`,
|
||||
);
|
||||
const baseAssignments = assignments(ItemsBase, row.baseFields);
|
||||
if (row.input.baseItem && baseAssignments.length)
|
||||
await tx.execute(
|
||||
sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${row.input.baseItem.id}`,
|
||||
);
|
||||
}
|
||||
if (userId)
|
||||
for (const id of offerIds) {
|
||||
const before = beforeById.get(id);
|
||||
if (before) await recordHistory(tx, "prices", id, userId, before);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderOffersCommand(
|
||||
orders: Array<{ id: number; orderNumber: number }>,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import "server-only";
|
||||
|
||||
import { env } from "@/env";
|
||||
|
||||
/**
|
||||
* Env-tuned cache TTLs for the hot public/admin read paths. Every value falls
|
||||
* back to the previous hard-coded default when the variable is unset (and under
|
||||
* SKIP_ENV_VALIDATION, where env is the raw process.env), so existing installs
|
||||
* behave identically until they opt into tuning.
|
||||
*
|
||||
* Keep these in seconds on the wire (env) and milliseconds in code, matching
|
||||
* the REDIS_CACHE_TTL_DEFAULT convention.
|
||||
*/
|
||||
function seconds(
|
||||
value: string | number | undefined,
|
||||
fallbackSec: number,
|
||||
): number {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return (Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackSec) * 1000;
|
||||
}
|
||||
|
||||
/** `/api/home` landing payload (articles + online count + hotel name). */
|
||||
export const HOME_PAYLOAD_TTL_MS = seconds(env.CMS_HOME_PAYLOAD_TTL_SEC, 15);
|
||||
|
||||
/** Shared news preview list used by the homepage and widgets. */
|
||||
export const NEWS_LIST_TTL_MS = seconds(env.CMS_NEWS_LIST_TTL_SEC, 300);
|
||||
|
||||
/** News archive / search landing — includes a COUNT(*) over a LIKE scan. */
|
||||
export const NEWS_ARCHIVE_TTL_MS = seconds(env.CMS_NEWS_ARCHIVE_TTL_SEC, 60);
|
||||
|
||||
/** Public directory search sections (users/rooms/news/events). */
|
||||
export const PUBLIC_SEARCH_TTL_MS = seconds(env.CMS_SEARCH_TTL_SEC, 30);
|
||||
|
||||
/** Online player counter — the hottest COUNT(*) on the site. */
|
||||
export const ONLINE_COUNT_TTL_MS = seconds(env.CMS_ONLINE_COUNT_TTL_SEC, 10);
|
||||
|
||||
/** Public counter keys (totals) and the warm-up's stale window for them. */
|
||||
export const COUNTERS_TTL_MS = seconds(env.CMS_COUNTERS_TTL_SEC, 300);
|
||||
|
||||
/** Site settings: Redis/shared TTL and the per-process memory mirror. */
|
||||
export const SITE_SETTINGS_TTL_MS = seconds(env.CMS_SITE_SETTINGS_TTL_SEC, 300);
|
||||
export const SITE_SETTINGS_MEMORY_TTL_MS = seconds(
|
||||
env.CMS_SITE_SETTINGS_MEMORY_TTL_SEC,
|
||||
60,
|
||||
);
|
||||
|
||||
/** Boot-time cache warm-up toggle (instrumentation.ts). */
|
||||
export const CACHE_WARMUP_ENABLED = (() => {
|
||||
// Validated mode exposes the zod-transformed boolean; SKIP_ENV_VALIDATION
|
||||
// (tooling/tests) hands the raw string through, so accept both spellings.
|
||||
const raw = env.CMS_WARMUP_ENABLED as unknown;
|
||||
if (typeof raw === "boolean") return raw;
|
||||
if (typeof raw === "string") return raw !== "false" && raw !== "0";
|
||||
return true;
|
||||
})();
|
||||
@@ -4,6 +4,11 @@ import { asc, desc, eq, gte } from "drizzle-orm";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { CameraWeb, db, User, WebsiteTeams } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
CACHE_WARMUP_ENABLED,
|
||||
COUNTERS_TTL_MS,
|
||||
ONLINE_COUNT_TTL_MS,
|
||||
} from "@/lib/perf-ttls";
|
||||
import { apiCacheKey, redisCache } from "@/lib/redis-cache";
|
||||
import { cachedHomePayload } from "@/lib/services/home-payload";
|
||||
import { getNewsList } from "@/lib/services/news-list";
|
||||
@@ -30,8 +35,8 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
* stop the server from serving.
|
||||
*/
|
||||
|
||||
const COUNTER_TTL_MS = 300_000;
|
||||
const ONLINE_TTL_MS = 10_000;
|
||||
const COUNTER_TTL_MS = COUNTERS_TTL_MS;
|
||||
const ONLINE_TTL_MS = ONLINE_COUNT_TTL_MS;
|
||||
|
||||
async function warm<T>(name: string, load: () => Promise<T>): Promise<void> {
|
||||
try {
|
||||
@@ -45,6 +50,10 @@ async function warm<T>(name: string, load: () => Promise<T>): Promise<void> {
|
||||
}
|
||||
|
||||
export async function warmPublicCaches(): Promise<void> {
|
||||
// Boot warm-up can be turned off per deploy (CMS_WARMUP_ENABLED=false): on
|
||||
// small hosts a burst of COUNT(*) queries right after boot competes with the
|
||||
// emulator for the same database.
|
||||
if (!CACHE_WARMUP_ENABLED) return;
|
||||
// Elke `warm` wordt afgewacht voordat de volgende begint, dus de COUNT(*)
|
||||
// queries gaan één voor één in plaats van als een burst op een database die
|
||||
// nog aan het opstarten is. Dat is goed genoeg; kunstmatige sleeps zouden
|
||||
|
||||
+40
-16
@@ -1,20 +1,28 @@
|
||||
import { env } from "@/env";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
/**
|
||||
* Server-side CAPTCHA verification, driven by website_settings so staff pick the
|
||||
* provider in housekeeping. Supports Cloudflare Turnstile, Google reCAPTCHA,
|
||||
* and hCaptcha (three AtomCMS offers, mutually exclusive).
|
||||
* Server-side CAPTCHA verification. The provider is driven by website_settings
|
||||
* (staff pick it in housekeeping) with an ENV FALLBACK so a deploy can enable
|
||||
* Turnstile without touching the DB: unset DB keys fall back to
|
||||
* CAPTCHA_PROVIDER / TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY. An explicit DB
|
||||
* value — including "none" — always wins over env. Supports Cloudflare
|
||||
* Turnstile, Google reCAPTCHA, and hCaptcha (mutually exclusive).
|
||||
*
|
||||
* Behaviour:
|
||||
* - provider "none" (or unset) → fail-open (allow)
|
||||
* - provider "none" (or unset everywhere) → fail-open (allow)
|
||||
* - provider configured but site key / secret missing, token absent, provider
|
||||
* API error, or network failure → fail-closed (deny)
|
||||
*
|
||||
* Settings keys:
|
||||
* captcha_provider = "turnstile" | "recaptcha" | "hcaptcha" | "none" (default none)
|
||||
* turnstile_secret / turnstile_site_key
|
||||
* recaptcha_secret / recaptcha_site_key
|
||||
* hcaptcha_secret / hcaptcha_site_key
|
||||
* Settings keys (DB) / env fallbacks:
|
||||
* captcha_provider = "turnstile" | "recaptcha" | "hcaptcha" | "none"
|
||||
* CAPTCHA_PROVIDER (env, used when the DB key is unset)
|
||||
* turnstile_site_key / TURNSTILE_SITE_KEY
|
||||
* turnstile_secret / TURNSTILE_SECRET_KEY
|
||||
* recaptcha_site_key (DB only)
|
||||
* recaptcha_secret (DB only)
|
||||
* hcaptcha_site_key (DB only)
|
||||
* hcaptcha_secret (DB only)
|
||||
*/
|
||||
export interface CaptchaConfig {
|
||||
provider: "turnstile" | "recaptcha" | "hcaptcha" | "none";
|
||||
@@ -28,15 +36,32 @@ const TURNSTILE_URL =
|
||||
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
|
||||
const HCAPTCHA_URL = "https://hcaptcha.com/siteverify";
|
||||
|
||||
/** DB first (staff can override live), env fallback for unconfigured deploys. */
|
||||
async function settingOrEnv(
|
||||
settingKey: string,
|
||||
settingDefault: string,
|
||||
envValue: string | undefined,
|
||||
): Promise<string> {
|
||||
const dbValue = (await siteSettings.get(settingKey, settingDefault)) ?? "";
|
||||
if (dbValue.trim() !== "") return dbValue.trim();
|
||||
return envValue?.trim() || "";
|
||||
}
|
||||
|
||||
/** Public config the register/login pages need to render the widget (no secrets). */
|
||||
export async function captchaConfig(): Promise<CaptchaConfig> {
|
||||
// Empty DB fallback distinguishes "unset" from an explicit "none": only an
|
||||
// explicit DB value short-circuits the env fallback.
|
||||
const provider = (
|
||||
(await siteSettings.get("captcha_provider", "none")) ?? "none"
|
||||
await settingOrEnv("captcha_provider", "", env.CAPTCHA_PROVIDER)
|
||||
).toLowerCase();
|
||||
if (provider === "turnstile") {
|
||||
return {
|
||||
provider: "turnstile",
|
||||
siteKey: (await siteSettings.get("turnstile_site_key", "")) ?? "",
|
||||
siteKey: await settingOrEnv(
|
||||
"turnstile_site_key",
|
||||
"",
|
||||
env.TURNSTILE_SITE_KEY,
|
||||
),
|
||||
field: "cf-turnstile-response",
|
||||
};
|
||||
}
|
||||
@@ -67,13 +92,12 @@ export async function verifyCaptcha(
|
||||
|
||||
if (!cfg.siteKey) return false;
|
||||
|
||||
const secretKey: string =
|
||||
const secret =
|
||||
cfg.provider === "turnstile"
|
||||
? "turnstile_secret"
|
||||
? await settingOrEnv("turnstile_secret", "", env.TURNSTILE_SECRET_KEY)
|
||||
: cfg.provider === "recaptcha"
|
||||
? "recaptcha_secret"
|
||||
: "hcaptcha_secret";
|
||||
const secret = (await siteSettings.get(secretKey, "")) ?? "";
|
||||
? ((await siteSettings.get("recaptcha_secret", "")) ?? "")
|
||||
: ((await siteSettings.get("hcaptcha_secret", "")) ?? "");
|
||||
if (!secret) return false;
|
||||
if (!token) return false;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import "server-only";
|
||||
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { HOME_PAYLOAD_TTL_MS } from "@/lib/perf-ttls";
|
||||
import { apiCacheKey, cacheSafe } from "@/lib/redis-cache";
|
||||
import { cacheNews } from "@/lib/services/news-cache";
|
||||
|
||||
@@ -47,5 +48,5 @@ export async function loadHomePayload() {
|
||||
|
||||
/** Cached read of {@link loadHomePayload} under the route's own key. */
|
||||
export async function cachedHomePayload() {
|
||||
return cacheNews(apiCacheKey("home"), 15_000, loadHomePayload);
|
||||
return cacheNews(apiCacheKey("home"), HOME_PAYLOAD_TTL_MS, loadHomePayload);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
import { and, asc, count, desc, eq, like, or, sql } from "drizzle-orm";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { NEWS_ARCHIVE_TTL_MS } from "@/lib/perf-ttls";
|
||||
import { cacheSafe } from "@/lib/redis-cache";
|
||||
import { cacheNews } from "@/lib/services/news-cache";
|
||||
|
||||
@@ -12,7 +13,7 @@ import { cacheNews } from "@/lib/services/news-cache";
|
||||
* entries immediately, so the window only applies when pub/sub cannot be
|
||||
* delivered.
|
||||
*/
|
||||
const ARCHIVE_TTL_MS = 60_000;
|
||||
const ARCHIVE_TTL_MS = NEWS_ARCHIVE_TTL_MS;
|
||||
|
||||
async function queryNewsArchive(
|
||||
options: { search?: string; page?: number; order?: string } = {},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
import { NEWS_LIST_TTL_MS } from "@/lib/perf-ttls";
|
||||
import { cacheNews } from "@/lib/services/news-cache";
|
||||
import "server-only";
|
||||
|
||||
@@ -21,7 +22,7 @@ interface NewsListRow {
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
const TTL_MS = 300_000;
|
||||
const TTL_MS = NEWS_LIST_TTL_MS;
|
||||
const CACHE_KEY = "news_list";
|
||||
const FETCH_LIMIT = 30;
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import "server-only";
|
||||
import { and, count, eq, or, sql } from "drizzle-orm";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db";
|
||||
import { ONLINE_COUNT_TTL_MS } from "@/lib/perf-ttls";
|
||||
|
||||
export { ONLINE_COUNT_TTL_MS };
|
||||
|
||||
/**
|
||||
* Row counters for the public homepage.
|
||||
@@ -80,9 +83,8 @@ export async function countOnline(): Promise<number> {
|
||||
// One function for every caller (homepage, login, register, headers, the client
|
||||
// page and the warm-up). Each call site used to inline this, and the ones that
|
||||
// omitted `staleMs` wrote an entry without a grace window, which made the key
|
||||
// block on COUNT(*) at every TTL boundary.
|
||||
export const ONLINE_COUNT_TTL_MS = 10_000;
|
||||
|
||||
// block on COUNT(*) at every TTL boundary. The TTL itself is env-tuned via
|
||||
// perf-ttls (CMS_ONLINE_COUNT_TTL_SEC).
|
||||
export async function cachedOnlineCount(): Promise<number> {
|
||||
return cached("online_count", ONLINE_COUNT_TTL_MS, countOnline, {
|
||||
staleMs: 15_000,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cached } from "@/lib/cache";
|
||||
import { PUBLIC_SEARCH_TTL_MS } from "@/lib/perf-ttls";
|
||||
import { publicReadFailure } from "@/lib/services/public-read";
|
||||
import "server-only";
|
||||
import { and, asc, count, eq, inArray, like, or } from "drizzle-orm";
|
||||
@@ -14,7 +15,7 @@ import { loadNewsArchive } from "@/lib/services/news-archive";
|
||||
* directory, and a user who registers or a room that opens should show up
|
||||
* without waiting.
|
||||
*/
|
||||
const SEARCH_TTL_MS = 30_000;
|
||||
const SEARCH_TTL_MS = PUBLIC_SEARCH_TTL_MS;
|
||||
|
||||
function pageNumber(value: number | undefined, total: number) {
|
||||
return Math.min(
|
||||
|
||||
@@ -2,6 +2,10 @@ import "server-only";
|
||||
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
SITE_SETTINGS_MEMORY_TTL_MS,
|
||||
SITE_SETTINGS_TTL_MS,
|
||||
} from "@/lib/perf-ttls";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
@@ -11,12 +15,12 @@ const DEFAULTS: Record<string, string> = {
|
||||
habbo_gamedata_hotel: "it",
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 300_000;
|
||||
const CACHE_TTL_MS = SITE_SETTINGS_TTL_MS;
|
||||
const REDIS_CACHE_KEY = "site_settings";
|
||||
// Short in-process window so repeated getters in one request (header, nav,
|
||||
// footer all read logo and other settings) don't each pay a Redis round-trip.
|
||||
// Redis stays the source of truth across instances.
|
||||
const MEMORY_TTL_MS = 60_000;
|
||||
const MEMORY_TTL_MS = SITE_SETTINGS_MEMORY_TTL_MS;
|
||||
|
||||
// During `next build`, pages are prerendered and `Date.now()` is treated as an
|
||||
// unstable prerender value — always serve the in-process cache then (settings
|
||||
|
||||
Reference in new issue
Block a user