import "server-only"; import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; import { db, WebsiteSetting } from "@/lib/db"; import { logger } from "@/lib/logger"; import { redis } from "@/lib/redis"; const DEFAULTS: Record = { hotel_name: FALLBACK_HOTEL_NAME, habbo_imaging_url: "/imaging", logo_url: "", nitro_client_url: "", habbo_gamedata_hotel: "it", }; const CACHE_TTL_MS = 300_000; const REDIS_CACHE_KEY = "site_settings"; // Short in-process window so repeated getters in one request (header, nav, // footer all read hotel_name / logo) don't each pay a Redis round-trip. // Redis stays the source of truth across instances. const MEMORY_TTL_MS = 60_000; // During `next build`, pages are prerendered and `Date.now()` is treated as an // unstable prerender value — always serve the in-process cache then (settings // cannot change mid-build). Runtime keeps the normal TTL check. const IS_PRERENDER = process.env.NEXT_PHASE === "phase-production-build" || process.env.NEXT_PHASE === "phase-production-compile"; class SiteSettings { private cache: { map: Map; expiresAt: number } | null = null; private async loadFromDb(): Promise> { try { const rows = await db .select({ key: WebsiteSetting.key, value: WebsiteSetting.value }) .from(WebsiteSetting); return new Map(rows.map((r) => [r.key, r.value])); } catch { logger.warn("Failed to load site settings from database, using defaults"); return new Map(Object.entries(DEFAULTS)); } } private async load(): Promise> { if (this.cache !== null) { if (IS_PRERENDER || this.cache.expiresAt > Date.now()) { return this.cache.map; } } if (redis) { try { const cached = await redis.get(REDIS_CACHE_KEY); if (cached) { const parsed = JSON.parse(cached) as Record; const map = new Map(Object.entries(parsed)); this.cache = { map, expiresAt: Date.now() + MEMORY_TTL_MS }; return map; } } catch { logger.warn("Redis cache read failed for site settings"); } } // Expired but usable fallback — keeps the site up if both Redis and DB fail. if (this.cache !== null) return this.cache.map; const map = await this.loadFromDb(); this.cache = { map, expiresAt: Date.now() + MEMORY_TTL_MS }; if (redis) { try { const obj = Object.fromEntries(map.entries()); await redis.setex( REDIS_CACHE_KEY, Math.ceil(CACHE_TTL_MS / 1000), JSON.stringify(obj), ); } catch { logger.warn("Failed to write site settings to Redis cache"); } } return map; } async getAll(): Promise> { return await this.load(); } async get( key: string, fallback: string | null = null, ): Promise { const map = await this.load(); if (map.has(key)) return map.get(key) as string; // eslint-disable-next-line security/detect-object-injection -- guarded by `key in DEFAULTS` if (key in DEFAULTS) return DEFAULTS[key] as string; return fallback; } async getMany(keys: string[]): Promise> { const map = await this.load(); const result: Record = {}; for (const key of keys) { result[key] = map.get(key) ?? (key in DEFAULTS ? (DEFAULTS[key] as string) : null); } return result; } async getBool(key: string, fallback = false): Promise { const v = await this.get(key, null); if (v === null) return fallback; const s = v.toLowerCase(); return s === "1" || s === "true"; } async update(key: string, value: string): Promise { await db .insert(WebsiteSetting) .values({ key, value }) .onDuplicateKeyUpdate({ set: { value } }); await this.reload(); } async reload(): Promise { this.cache = null; if (redis) { try { await redis.del(REDIS_CACHE_KEY); } catch { logger.warn("Failed to invalidate Redis cache for site settings"); } } } } export const siteSettings = new SiteSettings();