From f285a7cd982f61ca350f1b505201df43e2c87d9d Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 17 Aug 2026 22:02:21 +0200 Subject: [PATCH] Add performance optimizations and component refactors - Cache read-heavy public API routes via redisCache (leaderboard, values, shop, articles, photos, guilds, teams, staff, users, home, radio, badges) - Add single-flight and bounded-memory cache layer with unit tests - Parallelize independent DB queries on search, rares, shop, staff, polls and profile pages - Push radio points leaderboard aggregation to SQL with a LIMIT - Split studio-client and import-furni-client into focused modules - Clean up next.config.ts --- next.config.ts | 11 +- src/app/(site)/photos/page.tsx | 9 +- src/app/(site)/polls/[id]/page.tsx | 17 +- src/app/(site)/rankings/page.tsx | 25 +- src/app/(site)/rares/page.tsx | 39 +- src/app/(site)/search/page.tsx | 53 +-- src/app/(site)/shop/page.tsx | 44 +-- src/app/(site)/staff/page.tsx | 26 +- src/app/(site)/u/[username]/page.tsx | 175 +++++---- .../import/furni/import-furni-client.tsx | 186 +-------- .../admin/import/furni/import-furni-icons.ts | 45 +++ .../admin/import/furni/import-furni-types.ts | 47 +++ src/app/admin/import/furni/pagination.tsx | 89 +++++ src/app/api/articles/[slug]/route.ts | 45 ++- src/app/api/articles/route.ts | 62 +-- src/app/api/badges/leaderboard/route.ts | 364 ++++++++++-------- src/app/api/guilds/[id]/route.ts | 69 ++-- src/app/api/guilds/route.ts | 67 ++-- src/app/api/home/route.ts | 8 +- src/app/api/leaderboard/route.ts | 15 +- src/app/api/photos/route.ts | 58 +-- src/app/api/radio/current-dj/route.ts | 43 ++- src/app/api/radio/points/leaderboard/route.ts | 74 ++-- src/app/api/radio/shouts/route.ts | 75 ++-- src/app/api/shop/categories/route.ts | 34 +- src/app/api/shop/route.ts | 88 +++-- src/app/api/staff/route.ts | 25 +- src/app/api/teams/route.ts | 27 +- src/app/api/users/[username]/route.ts | 57 +-- src/app/api/values/[id]/route.ts | 97 +++-- src/app/api/values/categories/route.ts | 32 +- src/app/api/values/route.ts | 68 ++-- .../admin/studio/batch-progress.tsx | 71 ++++ src/components/admin/studio/catalog-rail.tsx | 103 +++++ src/components/admin/studio/checkbox-dot.tsx | 22 ++ src/components/admin/studio/studio-client.tsx | 279 +------------- .../admin/studio/studio-furni-icons.ts | 40 ++ src/components/admin/studio/studio-types.ts | 42 ++ src/lib/cache.test.ts | 65 ++++ src/lib/cache.ts | 82 +++- src/lib/redis-cache.ts | 14 + src/lib/services/site-settings.ts | 29 +- 42 files changed, 1616 insertions(+), 1205 deletions(-) create mode 100644 src/app/admin/import/furni/import-furni-icons.ts create mode 100644 src/app/admin/import/furni/import-furni-types.ts create mode 100644 src/app/admin/import/furni/pagination.tsx create mode 100644 src/components/admin/studio/batch-progress.tsx create mode 100644 src/components/admin/studio/catalog-rail.tsx create mode 100644 src/components/admin/studio/checkbox-dot.tsx create mode 100644 src/components/admin/studio/studio-furni-icons.ts create mode 100644 src/components/admin/studio/studio-types.ts create mode 100644 src/lib/cache.test.ts diff --git a/next.config.ts b/next.config.ts index dd86e53d..b164596b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -34,11 +34,11 @@ const securityHeaders = [ ]; const nextConfig: NextConfig = { - cacheComponents: true, // <-- Dit activeert Cache Components en lost de 'instant' build-fout op! + cacheComponents: true, deploymentId: resolveDeploymentId(), serverExternalPackages: ["lzma", "sharp", "pino", "pino-pretty"], - // Onderdruk de specifieke Turbopack waarschuwingen voor brede bestandspatronen in de lib-map + // Silence Turbopack warnings for the broad file patterns in src/lib. turbopack: { ignoreIssue: [ { @@ -47,20 +47,19 @@ const nextConfig: NextConfig = { ], }, - // Voorkom dat type-warnings (zoals ongebruikte imports) de productiebuild blokkeren typescript: { ignoreBuildErrors: false, }, - // Compress responses with gzip/brotli + // Compress responses with gzip/brotli. compress: true, - // Disable Next.js telemetry and browser sourcemaps in production + // Disable Next.js telemetry and browser sourcemaps in production. productionBrowserSourceMaps: false, experimental: { useTypeScriptCli: true, - hideLogsAfterAbort: true, // <-- Dit verbergt de prerender bail-out logberichten! + hideLogsAfterAbort: true, }, // Optimize images served through next/image with sharp → AVIF/WebP. diff --git a/src/app/(site)/photos/page.tsx b/src/app/(site)/photos/page.tsx index 67ec492d..91c2f04b 100644 --- a/src/app/(site)/photos/page.tsx +++ b/src/app/(site)/photos/page.tsx @@ -5,6 +5,7 @@ import { PhotoLightbox, } from "@/components/public/photo-lightbox"; import { ContentCard, EmptyState } from "@/components/public/ui"; +import { cached } from "@/lib/cache"; import { CameraWeb, db } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; @@ -23,11 +24,9 @@ export default async function PhotosPage() { let photos: Photo[] = []; try { - photos = await db - .select() - .from(CameraWeb) - .orderBy(desc(CameraWeb.timestamp)) - .limit(48); + photos = await cached("photos:grid", 60_000, async () => + db.select().from(CameraWeb).orderBy(desc(CameraWeb.timestamp)).limit(48), + ); } catch { photos = []; } diff --git a/src/app/(site)/polls/[id]/page.tsx b/src/app/(site)/polls/[id]/page.tsx index 30787738..2c2cd1cc 100644 --- a/src/app/(site)/polls/[id]/page.tsx +++ b/src/app/(site)/polls/[id]/page.tsx @@ -60,9 +60,11 @@ export default async function PollDetailPage({ const isOpen = poll.status === "active" && !hasEnded; const questionIds = questions.map((q) => q.id); - const myVotes = + + // My votes and the aggregate results are independent — load them together. + const [myVotes, allVotes] = await Promise.all([ Number.isInteger(userId) && userId > 0 && questionIds.length > 0 - ? await db + ? db .select() .from(WebsitePollVote) .where( @@ -72,12 +74,9 @@ export default async function PollDetailPage({ ), ) .catch(() => []) - : []; - const hasVoted = myVotes.length > 0; - - const allVotes = + : Promise.resolve([]), poll.showResults === 1 && questionIds.length > 0 - ? await db + ? db .select({ questionId: WebsitePollVote.questionId, answer: WebsitePollVote.answer, @@ -85,7 +84,9 @@ export default async function PollDetailPage({ .from(WebsitePollVote) .where(inArray(WebsitePollVote.questionId, questionIds)) .catch(() => []) - : []; + : Promise.resolve([]), + ]); + const hasVoted = myVotes.length > 0; const votesByQuestion = new Map(); for (const vote of allVotes) { diff --git a/src/app/(site)/rankings/page.tsx b/src/app/(site)/rankings/page.tsx index 22b9b26e..606e4b97 100644 --- a/src/app/(site)/rankings/page.tsx +++ b/src/app/(site)/rankings/page.tsx @@ -9,6 +9,7 @@ import { } from "@/components/public/ui"; import { CurrencyIcon } from "@/components/shared/currency-icon"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; +import { cached } from "@/lib/cache"; import { db, User } from "@/lib/db"; export const metadata = { title: "Rankings" }; @@ -25,17 +26,19 @@ export default async function RankingsPage() { const t = await getTranslations("pages.rankings"); let users: TopUser[] = []; try { - users = await db - .select({ - username: User.username, - look: User.look, - credits: User.credits, - rank: User.rank, - online: User.online, - }) - .from(User) - .orderBy(desc(User.credits)) - .limit(12); + users = await cached("rankings:top", 60_000, async () => + db + .select({ + username: User.username, + look: User.look, + credits: User.credits, + rank: User.rank, + online: User.online, + }) + .from(User) + .orderBy(desc(User.credits)) + .limit(12), + ); } catch { users = []; } diff --git a/src/app/(site)/rares/page.tsx b/src/app/(site)/rares/page.tsx index 80237974..db7f9760 100644 --- a/src/app/(site)/rares/page.tsx +++ b/src/app/(site)/rares/page.tsx @@ -16,9 +16,11 @@ type CategoryRow = { export default async function RareValuesPage() { const t = await getTranslations("pages.rares"); - let categories: CategoryRow[] = []; - try { - categories = await db + + // Categories, per-category rare counts and site settings are all + // independent — run them concurrently instead of sequentially. + const [categoryRows, grouped, badgesBase] = await Promise.all([ + db .select({ id: WebsiteRareValueCategories.id, name: WebsiteRareValueCategories.name, @@ -29,29 +31,26 @@ export default async function RareValuesPage() { .orderBy( asc(WebsiteRareValueCategories.priority), asc(WebsiteRareValueCategories.name), - ); - } catch { - categories = []; - } - - // Per-category rare counts (grouped so we avoid N queries). - const counts = new Map(); - try { - const grouped = await db + ) + .catch((): CategoryRow[] => []), + db .select({ categoryId: WebsiteRareValues.categoryId, value: count(), }) .from(WebsiteRareValues) - .groupBy(WebsiteRareValues.categoryId); - for (const g of grouped) { - counts.set(String(g.categoryId), Number(g.value)); - } - } catch { - // leave counts empty -> shown as 0 - } + .groupBy(WebsiteRareValues.categoryId) + .catch(() => []), + siteSettings.get("badges_path", ""), + ]); - const badgesBase = (await siteSettings.get("badges_path", "")) ?? ""; + const categories = categoryRows; + + // Per-category rare counts (grouped so we avoid N queries). + const counts = new Map(); + for (const g of grouped) { + counts.set(String(g.categoryId), Number(g.value)); + } return (
diff --git a/src/app/(site)/search/page.tsx b/src/app/(site)/search/page.tsx index 26414707..6944c491 100644 --- a/src/app/(site)/search/page.tsx +++ b/src/app/(site)/search/page.tsx @@ -36,32 +36,33 @@ export default async function SearchPage({ ); } - const users = await db - .select({ - id: User.id, - username: User.username, - look: User.look, - motto: User.motto, - online: User.online, - rank: User.rank, - }) - .from(User) - .where(like(User.username, `%${query}%`)) - .limit(30) - .catch(() => []); - - const rooms = await db - .select({ - id: Rooms.id, - name: Rooms.name, - ownerName: Rooms.ownerName, - users: Rooms.users, - usersMax: Rooms.usersMax, - }) - .from(Rooms) - .where(and(like(Rooms.name, `%${query}%`), eq(Rooms.state, "open"))) - .limit(12) - .catch(() => []); + const [users, rooms] = await Promise.all([ + db + .select({ + id: User.id, + username: User.username, + look: User.look, + motto: User.motto, + online: User.online, + rank: User.rank, + }) + .from(User) + .where(like(User.username, `%${query}%`)) + .limit(30) + .catch(() => []), + db + .select({ + id: Rooms.id, + name: Rooms.name, + ownerName: Rooms.ownerName, + users: Rooms.users, + usersMax: Rooms.usersMax, + }) + .from(Rooms) + .where(and(like(Rooms.name, `%${query}%`), eq(Rooms.state, "open"))) + .limit(12) + .catch(() => []), + ]); return (
diff --git a/src/app/(site)/shop/page.tsx b/src/app/(site)/shop/page.tsx index fe8514cb..5e2fa630 100644 --- a/src/app/(site)/shop/page.tsx +++ b/src/app/(site)/shop/page.tsx @@ -112,38 +112,30 @@ export default async function ShopPage({ : t("success.bought") : null; - // Categories for the filter bar. - let categories: ShopCategory[] = []; - try { - categories = await db + // Categories for the filter bar and the article grid are independent — + // fetch them concurrently. + const [categories, articles] = await Promise.all([ + db .select({ id: WebsiteShopCategories.id, name: WebsiteShopCategories.name, icon: WebsiteShopCategories.icon, }) .from(WebsiteShopCategories) - .orderBy(asc(WebsiteShopCategories.name)); - } catch { - categories = []; - } - - let articles: ShopArticle[] = []; - try { - if (categoryId) { - articles = await db - .select(articleSelect) - .from(WebsiteShopArticles) - .where(eq(WebsiteShopArticles.categoryId, BigInt(categoryId))) - .orderBy(asc(WebsiteShopArticles.position)); - } else { - articles = await db - .select(articleSelect) - .from(WebsiteShopArticles) - .orderBy(asc(WebsiteShopArticles.position)); - } - } catch { - articles = []; - } + .orderBy(asc(WebsiteShopCategories.name)) + .catch((): ShopCategory[] => []), + (categoryId + ? db + .select(articleSelect) + .from(WebsiteShopArticles) + .where(eq(WebsiteShopArticles.categoryId, BigInt(categoryId))) + .orderBy(asc(WebsiteShopArticles.position)) + : db + .select(articleSelect) + .from(WebsiteShopArticles) + .orderBy(asc(WebsiteShopArticles.position)) + ).catch((): ShopArticle[] => []), + ]); const activeCategory = categoryId ? categories.find((c) => String(c.id) === categoryId) diff --git a/src/app/(site)/staff/page.tsx b/src/app/(site)/staff/page.tsx index 3c78dfef..b5cd57ca 100644 --- a/src/app/(site)/staff/page.tsx +++ b/src/app/(site)/staff/page.tsx @@ -27,9 +27,9 @@ export default async function StaffPage() { const minStaffRank = Number(await siteSettings.get("min_staff_rank", "7")) || 7; - let staff: StaffMember[] = []; - try { - staff = await db + // Independent queries — staff members and teams load concurrently. + const [staff, teams] = await Promise.all([ + db .select({ username: User.username, look: User.look, @@ -39,14 +39,9 @@ export default async function StaffPage() { .from(User) .where(gte(User.rank, minStaffRank)) .orderBy(desc(User.rank), asc(User.username)) - .limit(100); - } catch { - staff = []; - } - - let teams: Team[] = []; - try { - const rows = await db + .limit(100) + .catch((): StaffMember[] => []), + db .select({ id: WebsiteTeams.id, rankName: WebsiteTeams.rankName, @@ -56,11 +51,10 @@ export default async function StaffPage() { }) .from(WebsiteTeams) .where(eq(WebsiteTeams.hiddenRank, false)) - .orderBy(asc(WebsiteTeams.id)); - teams = rows.map((row) => ({ ...row, id: String(row.id) })); - } catch { - teams = []; - } + .orderBy(asc(WebsiteTeams.id)) + .then((rows) => rows.map((row) => ({ ...row, id: String(row.id) }))) + .catch((): Team[] => []), + ]); return (
diff --git a/src/app/(site)/u/[username]/page.tsx b/src/app/(site)/u/[username]/page.tsx index 9aba8573..7784ac76 100644 --- a/src/app/(site)/u/[username]/page.tsx +++ b/src/app/(site)/u/[username]/page.tsx @@ -191,19 +191,69 @@ export default async function ProfilePage({ // Currency ledger rows (duckets/diamonds). Credits live on the users row // itself; the alt-currencies sit in the users_currency ledger keyed by type. - const currencyRows = await db - .select({ - type: UsersCurrency.type, - amount: UsersCurrency.amount, - }) - .from(UsersCurrency) - .where( - and( - eq(UsersCurrency.userId, user.id), - inArray(UsersCurrency.type, [CURRENCY_DUCKETS, CURRENCY_DIAMONDS]), - ), - ) - .catch(() => []); + // Currency ledger rows (duckets/diamonds). Credits live on the users row + // itself; the alt-currencies sit in the users_currency ledger keyed by type. + // All sub-sections run in parallel (each query keeps its own .catch so a DB + // hiccup still degrades only that section instead of 500-ing the profile). + const [currencyRows, friendships, rooms, badges, photos, guestbook] = + await Promise.all([ + db + .select({ + type: UsersCurrency.type, + amount: UsersCurrency.amount, + }) + .from(UsersCurrency) + .where( + and( + eq(UsersCurrency.userId, user.id), + inArray(UsersCurrency.type, [CURRENCY_DUCKETS, CURRENCY_DIAMONDS]), + ), + ) + .catch(() => []), + // Friends. messenger_friendships stores one directed row per pair + // keyed on user_one_id; resolve the user_two_id side below. + db + .select({ userTwoId: MessengerFriendships.userTwoId }) + .from(MessengerFriendships) + .where(eq(MessengerFriendships.userOneId, user.id)) + .orderBy(desc(MessengerFriendships.friendsSince)) + .limit(18) + .catch(() => []), + // Rooms owned by this user. + db + .select({ + id: Rooms.id, + name: Rooms.name, + users: Rooms.users, + usersMax: Rooms.usersMax, + state: Rooms.state, + }) + .from(Rooms) + .where(eq(Rooms.ownerId, user.id)) + .orderBy(desc(Rooms.users)) + .limit(8) + .catch(() => []), + db + .select() + .from(UsersBadges) + .where(eq(UsersBadges.userId, user.id)) + .orderBy(asc(UsersBadges.slotId)) + .catch(() => []), + db + .select() + .from(CameraWeb) + .where(eq(CameraWeb.userId, user.id)) + .orderBy(desc(CameraWeb.timestamp)) + .limit(6) + .catch(() => []), + db + .select() + .from(WebsiteUserGuestbooks) + .where(eq(WebsiteUserGuestbooks.profileId, user.id)) + .orderBy(desc(WebsiteUserGuestbooks.createdAt)) + .limit(30) + .catch(() => []), + ]); const ducketsAmount = currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0; const diamondsAmount = @@ -232,83 +282,38 @@ export default async function ProfilePage({ }, ]; - // Friends. messenger_friendships stores one directed row per pair keyed on - // user_one_id; resolve the user_two_id side to usernames/looks for the grid. - const friendships = await db - .select({ userTwoId: MessengerFriendships.userTwoId }) - .from(MessengerFriendships) - .where(eq(MessengerFriendships.userOneId, user.id)) - .orderBy(desc(MessengerFriendships.friendsSince)) - .limit(18) - .catch(() => []); + // Resolve friend usernames/looks and the guestbook author usernames/looks in + // two parallel queries (the ids come from the queries above). const friendIds = Array.from(new Set(friendships.map((f) => f.userTwoId))); - const friendUsers = friendIds.length - ? await db - .select({ - id: User.id, - username: User.username, - look: User.look, - }) - .from(User) - .where(inArray(User.id, friendIds)) - .catch(() => []) - : []; + const authorIds = Array.from(new Set(guestbook.map((g) => g.userId))); + const [friendUsers, authors] = await Promise.all([ + friendIds.length + ? db + .select({ + id: User.id, + username: User.username, + look: User.look, + }) + .from(User) + .where(inArray(User.id, friendIds)) + .catch(() => []) + : [], + authorIds.length + ? db + .select({ + id: User.id, + username: User.username, + look: User.look, + }) + .from(User) + .where(inArray(User.id, authorIds)) + .catch(() => []) + : [], + ]); const friendById = new Map(friendUsers.map((f) => [f.id, f])); const friends = friendships .map((f) => friendById.get(f.userTwoId)) .filter((f): f is NonNullable => Boolean(f)); - - // Rooms owned by this user. - const rooms = await db - .select({ - id: Rooms.id, - name: Rooms.name, - users: Rooms.users, - usersMax: Rooms.usersMax, - state: Rooms.state, - }) - .from(Rooms) - .where(eq(Rooms.ownerId, user.id)) - .orderBy(desc(Rooms.users)) - .limit(8) - .catch(() => []); - - const badges = await db - .select() - .from(UsersBadges) - .where(eq(UsersBadges.userId, user.id)) - .orderBy(asc(UsersBadges.slotId)) - .catch(() => []); - - const photos = await db - .select() - .from(CameraWeb) - .where(eq(CameraWeb.userId, user.id)) - .orderBy(desc(CameraWeb.timestamp)) - .limit(6) - .catch(() => []); - - const guestbook = await db - .select() - .from(WebsiteUserGuestbooks) - .where(eq(WebsiteUserGuestbooks.profileId, user.id)) - .orderBy(desc(WebsiteUserGuestbooks.createdAt)) - .limit(30) - .catch(() => []); - - // Resolve author usernames/looks for the guestbook entries in one query. - const authorIds = Array.from(new Set(guestbook.map((g) => g.userId))); - const authors = authorIds.length - ? await db - .select({ - id: User.id, - username: User.username, - look: User.look, - }) - .from(User) - .where(inArray(User.id, authorIds)) - .catch(() => []) - : []; const authorById = new Map(authors.map((a) => [a.id, a])); return ( diff --git a/src/app/admin/import/furni/import-furni-client.tsx b/src/app/admin/import/furni/import-furni-client.tsx index 9ad1e626..e88d1c40 100644 --- a/src/app/admin/import/furni/import-furni-client.tsx +++ b/src/app/admin/import/furni/import-furni-client.tsx @@ -60,99 +60,21 @@ import { } from "@/lib/admin/recent-furni-resync"; import { adminFetch } from "@/lib/admin-fetch"; import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel"; +import { + getFurniIconUrl, + getFurniImageUrl, + handleImgError, + STATUS_LABELS, +} from "./import-furni-icons"; +import type { + BatchItemStatus, + BatchSummary, + FurniItem, + FurniStats, + Meta, +} from "./import-furni-types"; import { NitroEditorDialog } from "./nitro-editor-dialog"; - -interface FurniItem { - id: number; - classname: string; - type: string; - name: string; - description: string; - revision: number; - category: string; - alreadyImported: boolean; - nitroExists: boolean; -} - -interface Meta { - currentPage: number; - lastPage: number; - total: number; - perPage: number; -} - -interface BatchItemStatus { - classname: string; - status: - | "pending" - | "started" - | "downloading" - | "converting" - | "writing_db" - | "done" - | "failed"; - message?: string; - warnings?: string[]; - itemId?: number; -} - -interface BatchSummary { - succeeded: number; - failed: number; - warnings: number; - duration: number; -} - -interface FurniStats { - totalInDb: number; - inCatalog: number; - missingNitro: number; - notInCatalog: number; -} - -function getFurniImageUrl(classname: string, revision: number) { - // Use official Habbo CDN for furniture icons - const base = classname.includes("*") - ? classname.substring(0, classname.indexOf("*")) - : classname; - return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`; -} - -function getFurniIconUrl(classname: string, revision: number) { - const iconName = classname.replace(/\*/g, "_"); - return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`; -} - -function handleImgError( - e: React.SyntheticEvent, - classname: string, - revision: number, -) { - const img = e.target as HTMLImageElement; - const iconName = classname.replace(/\*/g, "_"); - if (!img.dataset.fallback) { - img.dataset.fallback = "1"; - img.src = getFurniIconUrl(classname, revision); - } else if (img.dataset.fallback === "1") { - img.dataset.fallback = "2"; - img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`; - } else { - // Show placeholder instead of invisible image - img.style.opacity = "0.3"; - img.src = - 'data:image/svg+xml,'; - } -} - -const STATUS_LABELS: Record = { - pending: "Pending", - started: "Starting", - downloading: "Downloading", - converting: "Converting", - writing_db: "Writing DB", - done: "Done", - failed: "Failed", -}; +import { Pagination } from "./pagination"; export function ImportFurniClient({ source }: { source: FurniImportSource }) { const [searchTerm, setSearchTerm] = useState(""); @@ -884,84 +806,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) { return { completed, done, failed, total: arr.length, percent }; }, [batchProgress]); - function renderPagination() { - if (!meta || meta.lastPage <= 1) return null; - const nodes: React.ReactNode[] = []; - if (meta.currentPage > 1) { - nodes.push( - , - ); - } - for (let p = 1; p <= meta.lastPage; p++) { - if (p === meta.currentPage) { - nodes.push( - , - ); - } else if ( - Math.abs(p - meta.currentPage) <= 2 || - p === 1 || - p === meta.lastPage - ) { - nodes.push( - , - ); - } else if (Math.abs(p - meta.currentPage) === 3) { - nodes.push( - - … - , - ); - } - } - if (meta.currentPage < meta.lastPage) { - nodes.push( - , - ); - } - return ( -
- {nodes} -
- ); - } - return (
@@ -1987,7 +1831,7 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) { )} {/* Pagination */} - {renderPagination()} + {/* Nitro Editor Dialog */} , + classname: string, + revision: number, +) { + const img = e.target as HTMLImageElement; + const iconName = classname.replace(/\*/g, "_"); + if (!img.dataset.fallback) { + img.dataset.fallback = "1"; + img.src = getFurniIconUrl(classname, revision); + } else if (img.dataset.fallback === "1") { + img.dataset.fallback = "2"; + img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`; + } else { + // Show placeholder instead of invisible image + img.style.opacity = "0.3"; + img.src = + 'data:image/svg+xml,'; + } +} + +export const STATUS_LABELS: Record = { + pending: "Pending", + started: "Starting", + downloading: "Downloading", + converting: "Converting", + writing_db: "Writing DB", + done: "Done", + failed: "Failed", +}; diff --git a/src/app/admin/import/furni/import-furni-types.ts b/src/app/admin/import/furni/import-furni-types.ts new file mode 100644 index 00000000..aab52b33 --- /dev/null +++ b/src/app/admin/import/furni/import-furni-types.ts @@ -0,0 +1,47 @@ +export interface FurniItem { + id: number; + classname: string; + type: string; + name: string; + description: string; + revision: number; + category: string; + alreadyImported: boolean; + nitroExists: boolean; +} + +export interface Meta { + currentPage: number; + lastPage: number; + total: number; + perPage: number; +} + +export interface BatchItemStatus { + classname: string; + status: + | "pending" + | "started" + | "downloading" + | "converting" + | "writing_db" + | "done" + | "failed"; + message?: string; + warnings?: string[]; + itemId?: number; +} + +export interface BatchSummary { + succeeded: number; + failed: number; + warnings: number; + duration: number; +} + +export interface FurniStats { + totalInDb: number; + inCatalog: number; + missingNitro: number; + notInCatalog: number; +} diff --git a/src/app/admin/import/furni/pagination.tsx b/src/app/admin/import/furni/pagination.tsx new file mode 100644 index 00000000..3e9cceeb --- /dev/null +++ b/src/app/admin/import/furni/pagination.tsx @@ -0,0 +1,89 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import type { Meta } from "./import-furni-types"; + +export function Pagination({ + meta, + loading, + onPageChange, +}: { + meta: Meta | null; + loading: boolean; + onPageChange: (page: number) => void; +}) { + if (!meta || meta.lastPage <= 1) return null; + const nodes: ReactNode[] = []; + if (meta.currentPage > 1) { + nodes.push( + , + ); + } + for (let p = 1; p <= meta.lastPage; p++) { + if (p === meta.currentPage) { + nodes.push( + , + ); + } else if ( + Math.abs(p - meta.currentPage) <= 2 || + p === 1 || + p === meta.lastPage + ) { + nodes.push( + , + ); + } else if (Math.abs(p - meta.currentPage) === 3) { + nodes.push( + + … + , + ); + } + } + if (meta.currentPage < meta.lastPage) { + nodes.push( + , + ); + } + return ( +
+ {nodes} +
+ ); +} diff --git a/src/app/api/articles/[slug]/route.ts b/src/app/api/articles/[slug]/route.ts index e45271ef..79158a3d 100644 --- a/src/app/api/articles/[slug]/route.ts +++ b/src/app/api/articles/[slug]/route.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, WebsiteArticles } from "@/lib/db"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; /** * GET /api/articles/:slug — single website_article by slug, including the @@ -13,26 +14,38 @@ export async function GET( const { slug } = await params; try { - const [article] = await db - .select({ - id: WebsiteArticles.id, - title: WebsiteArticles.title, - slug: WebsiteArticles.slug, - shortStory: WebsiteArticles.shortStory, - fullStory: WebsiteArticles.fullStory, - image: WebsiteArticles.image, - createdAt: WebsiteArticles.createdAt, - updatedAt: WebsiteArticles.updatedAt, - }) - .from(WebsiteArticles) - .where(eq(WebsiteArticles.slug, slug)) - .limit(1); + const data = await redisCache( + apiCacheKey(`articles:${slug}`), + 60, + async () => { + const [article] = await db + .select({ + id: WebsiteArticles.id, + title: WebsiteArticles.title, + slug: WebsiteArticles.slug, + shortStory: WebsiteArticles.shortStory, + fullStory: WebsiteArticles.fullStory, + image: WebsiteArticles.image, + createdAt: WebsiteArticles.createdAt, + updatedAt: WebsiteArticles.updatedAt, + }) + .from(WebsiteArticles) + .where(eq(WebsiteArticles.slug, slug)) + .limit(1); - if (!article) { + if (!article) { + return null; + } + + return cacheSafe({ data: article }); + }, + ); + + if (data === null) { return apiJson({ error: "Article not found" }, { status: 404 }); } - return apiJson({ data: article }); + return apiJson(data); } catch { // DB unavailable — treat as not found rather than a 500. return apiJson({ error: "Article not found" }, { status: 200 }); diff --git a/src/app/api/articles/route.ts b/src/app/api/articles/route.ts index d6964227..722dabf0 100644 --- a/src/app/api/articles/route.ts +++ b/src/app/api/articles/route.ts @@ -1,6 +1,7 @@ import { count, desc } from "drizzle-orm"; import { apiJson, pagination } from "@/lib/api"; import { db, WebsiteArticles } from "@/lib/db"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; /** * GET /api/articles — paginated list of website_articles, newest first. @@ -12,33 +13,42 @@ export async function GET(req: Request) { const { page, perPage, skip, take } = pagination(sp); try { - const [totalRows, articles] = await Promise.all([ - db.select({ total: count() }).from(WebsiteArticles), - db - .select({ - id: WebsiteArticles.id, - title: WebsiteArticles.title, - slug: WebsiteArticles.slug, - shortStory: WebsiteArticles.shortStory, - image: WebsiteArticles.image, - createdAt: WebsiteArticles.createdAt, - }) - .from(WebsiteArticles) - .orderBy(desc(WebsiteArticles.createdAt)) - .limit(take) - .offset(skip), - ]); - const total = totalRows[0]?.total ?? 0; - - return apiJson({ - data: articles, - meta: { - page, - perPage, - total, - lastPage: Math.max(1, Math.ceil(total / perPage)), + const data = await redisCache( + apiCacheKey(`articles:${page}:${perPage}`), + 60, + async () => { + const [totalRows, articles] = await Promise.all([ + db.select({ total: count() }).from(WebsiteArticles), + db + .select({ + id: WebsiteArticles.id, + title: WebsiteArticles.title, + slug: WebsiteArticles.slug, + shortStory: WebsiteArticles.shortStory, + image: WebsiteArticles.image, + createdAt: WebsiteArticles.createdAt, + }) + .from(WebsiteArticles) + .orderBy(desc(WebsiteArticles.createdAt)) + .limit(take) + .offset(skip), + ]); + return cacheSafe({ + data: articles, + meta: { + page, + perPage, + total: totalRows[0]?.total ?? 0, + lastPage: Math.max( + 1, + Math.ceil((totalRows[0]?.total ?? 0) / perPage), + ), + }, + }); }, - }); + ); + + return apiJson(data); } catch { // DB unavailable — return an empty payload instead of a 500. return apiJson( diff --git a/src/app/api/badges/leaderboard/route.ts b/src/app/api/badges/leaderboard/route.ts index 887bb041..5a4b96b2 100644 --- a/src/app/api/badges/leaderboard/route.ts +++ b/src/app/api/badges/leaderboard/route.ts @@ -5,6 +5,7 @@ import { bearerUserId } from "@/lib/api-auth"; import { auth } from "@/lib/auth"; import { db, User, UsersSettings } from "@/lib/db"; import { logger } from "@/lib/logger"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; type BadgeRarityKey = | "common" @@ -28,6 +29,12 @@ interface BadgeLeaderboardBoard { viewerEntry?: Partial; } +/** The shared, viewer-independent part of a leaderboard (cacheable). */ +type BoardCore = { + entries: BadgeLeaderboardEntry[]; + totalPlayers: number; +}; + interface BadgeLeaderboardStat { badgeCode: string; ownerCount: number; @@ -65,9 +72,7 @@ async function rawRows(query: ReturnType): Promise { return rows; } -async function loadTotalBadgesBoard( - userId: number | null, -): Promise { +async function loadTotalBadgesBoard(): Promise { const rows = await rawRows<{ userId: bigint; username: string; @@ -103,48 +108,10 @@ async function loadTotalBadgesBoard( ) : 0; - let viewerEntry: Partial | undefined; - if (userId) { - const viewerRow = await rawRows<{ cnt: bigint }>( - sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`, - ); - const viewerScore = Number(viewerRow[0]?.cnt ?? 0); - if (viewerScore > 0) { - const viewerRank = - entries.length > 0 - ? Number( - ( - await rawRows<{ cnt: bigint }>(sql` - SELECT COUNT(*) + 1 AS cnt - FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t - WHERE t.total > ${viewerScore} - `) - )[0]?.cnt ?? entries.length + 1, - ) - : 1; - const [viewerUser] = await db - .select({ username: User.username, look: User.look }) - .from(User) - .where(eq(User.id, userId)) - .limit(1); - if (viewerUser) { - viewerEntry = { - userId, - username: viewerUser.username, - figure: viewerUser.look, - score: viewerScore, - rank: viewerRank, - }; - } - } - } - - return { entries, totalPlayers, viewerEntry }; + return { entries, totalPlayers }; } -async function loadAchievementBoard( - userId: number | null, -): Promise { +async function loadAchievementBoard(): Promise { const rows = await rawRows<{ userId: bigint; username: string; @@ -179,46 +146,10 @@ async function loadAchievementBoard( ) : 0; - let viewerEntry: Partial | undefined; - if (userId) { - const [viewerUser] = await db - .select({ username: User.username, look: User.look }) - .from(User) - .where(eq(User.id, userId)) - .limit(1); - if (viewerUser) { - const [viewerSettings] = await db - .select({ achievementScore: UsersSettings.achievementScore }) - .from(UsersSettings) - .where(eq(UsersSettings.userId, userId)) - .limit(1); - if (viewerSettings && viewerSettings.achievementScore > 0) { - const viewerRank = Number( - ( - await rawRows<{ cnt: bigint }>(sql` - SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ${viewerSettings.achievementScore} - `) - )[0]?.cnt ?? entries.length + 1, - ); - viewerEntry = { - userId, - username: viewerUser.username, - figure: viewerUser.look, - score: viewerSettings.achievementScore, - rank: viewerRank, - }; - } - } - } - - return { entries, totalPlayers, viewerEntry }; + return { entries, totalPlayers }; } -async function loadRarityBoard( - _rarity: BadgeRarityKey, - badgeCodes: string[], - userId: number | null, -): Promise { +async function loadRarityBoard(badgeCodes: string[]): Promise { if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 }; const codes = sql.join( @@ -262,41 +193,111 @@ async function loadRarityBoard( ) : 0; - let viewerEntry: Partial | undefined; - if (userId) { - const viewerRow = await rawRows<{ cnt: bigint }>(sql` - SELECT COUNT(*) AS cnt FROM users_badges - WHERE user_id = ${userId} AND badge_code IN (${codes}) - `); - const viewerScore = Number(viewerRow[0]?.cnt ?? 0); - if (viewerScore > 0) { - const [viewerUser] = await db - .select({ username: User.username, look: User.look }) - .from(User) - .where(eq(User.id, userId)) - .limit(1); - if (viewerUser) { - const viewerRank = Number( - ( - await rawRows<{ cnt: bigint }>(sql` - SELECT COUNT(*) + 1 AS cnt - FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${codes}) GROUP BY user_id) t - WHERE t.total > ${viewerScore} - `) - )[0]?.cnt ?? entries.length + 1, - ); - viewerEntry = { - userId, - username: viewerUser.username, - figure: viewerUser.look, - score: viewerScore, - rank: viewerRank, - }; - } - } - } + return { entries, totalPlayers }; +} - return { entries, totalPlayers, viewerEntry }; +async function loadTotalBadgesViewer( + userId: number, +): Promise | undefined> { + const viewerRow = await rawRows<{ cnt: bigint }>( + sql`SELECT COUNT(*) AS cnt FROM users_badges WHERE user_id = ${userId}`, + ); + const viewerScore = Number(viewerRow[0]?.cnt ?? 0); + if (viewerScore <= 0) return undefined; + + const rankRow = await rawRows<{ cnt: bigint }>(sql` + SELECT COUNT(*) + 1 AS cnt + FROM (SELECT user_id, COUNT(*) AS total FROM users_badges GROUP BY user_id) t + WHERE t.total > ${viewerScore} + `); + const rank = Number(rankRow[0]?.cnt ?? 1); + + const [viewerUser] = await db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.id, userId)) + .limit(1); + if (!viewerUser) return undefined; + + return { + userId, + username: viewerUser.username, + figure: viewerUser.look, + score: viewerScore, + rank, + }; +} + +async function loadAchievementViewer( + userId: number, +): Promise | undefined> { + const [viewerUser] = await db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.id, userId)) + .limit(1); + if (!viewerUser) return undefined; + + const [viewerSettings] = await db + .select({ achievementScore: UsersSettings.achievementScore }) + .from(UsersSettings) + .where(eq(UsersSettings.userId, userId)) + .limit(1); + if (!viewerSettings || viewerSettings.achievementScore <= 0) return undefined; + + const rankRow = await rawRows<{ cnt: bigint }>(sql` + SELECT COUNT(*) + 1 AS cnt FROM users_settings WHERE achievement_score > ${viewerSettings.achievementScore} + `); + const rank = Number(rankRow[0]?.cnt ?? 1); + + return { + userId, + username: viewerUser.username, + figure: viewerUser.look, + score: viewerSettings.achievementScore, + rank, + }; +} + +async function loadRarityViewer( + userId: number, + badgeCodes: string[], +): Promise | undefined> { + if (badgeCodes.length === 0) return undefined; + + const codes = sql.join( + badgeCodes.map((c) => sql`${c}`), + sql`, `, + ); + + const viewerRow = await rawRows<{ cnt: bigint }>(sql` + SELECT COUNT(*) AS cnt FROM users_badges + WHERE user_id = ${userId} AND badge_code IN (${codes}) + `); + const viewerScore = Number(viewerRow[0]?.cnt ?? 0); + if (viewerScore <= 0) return undefined; + + const [viewerUser] = await db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.id, userId)) + .limit(1); + if (!viewerUser) return undefined; + + const rankRow = await rawRows<{ cnt: bigint }>(sql` + SELECT COUNT(*) + 1 AS cnt + FROM (SELECT user_id, COUNT(*) AS total FROM users_badges WHERE badge_code IN (${codes}) GROUP BY user_id) t + WHERE t.total > ${viewerScore} + `); + const rank = Number(rankRow[0]?.cnt ?? 1); + + return { + userId, + username: viewerUser.username, + figure: viewerUser.look, + score: viewerScore, + rank, + }; } export async function GET(req: Request) { @@ -308,28 +309,6 @@ export async function GET(req: Request) { userId = session?.user?.id ? Number(session.user.id) : null; } - const badgeStatsRaw = await rawRows<{ - badgeCode: string; - ownerCount: bigint; - }>(sql` - SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount - FROM users_badges - GROUP BY badge_code - `); - - const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({ - badgeCode: r.badgeCode, - ownerCount: Number(r.ownerCount), - rarity: assignRarity(Number(r.ownerCount)), - })); - - const badgesByRarity = new Map(); - for (const stat of badgeStats) { - const list = badgesByRarity.get(stat.rarity) ?? []; - list.push(stat.badgeCode); - badgesByRarity.set(stat.rarity, list); - } - const rarityKeys: BadgeRarityKey[] = [ "common", "rare", @@ -339,24 +318,109 @@ export async function GET(req: Request) { "unique", ]; - const [totalBadges, achievementLevel, ...rarityBoards] = await Promise.all([ - loadTotalBadgesBoard(userId), - loadAchievementBoard(userId), - ...rarityKeys.map((rk) => - loadRarityBoard(rk, badgesByRarity.get(rk) ?? [], userId), - ), - ]); + // The heavy global aggregation (badge rarity stats + the 8 leaderboards) + // is user-independent, so it is cached and recomputed once per TTL instead + // of on every request. The viewer's own rank is derived per request. + const global = await redisCache( + apiCacheKey("badges:leaderboard:global"), + 120, + async () => { + const badgeStatsRaw = await rawRows<{ + badgeCode: string; + ownerCount: bigint; + }>(sql` + SELECT badge_code AS badgeCode, COUNT(DISTINCT user_id) AS ownerCount + FROM users_badges + GROUP BY badge_code + `); - const rarity = Object.fromEntries( - // eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index - rarityKeys.map((rk, i) => [rk, rarityBoards[i]]), - ) as Record; + const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({ + badgeCode: r.badgeCode, + ownerCount: Number(r.ownerCount), + rarity: assignRarity(Number(r.ownerCount)), + })); + + const badgesByRarity = new Map(); + for (const stat of badgeStats) { + const list = badgesByRarity.get(stat.rarity) ?? []; + list.push(stat.badgeCode); + badgesByRarity.set(stat.rarity, list); + } + + const [totalBadges, achievementLevel, ...rarityBoards] = + await Promise.all([ + loadTotalBadgesBoard(), + loadAchievementBoard(), + ...rarityKeys.map((rk) => + loadRarityBoard(badgesByRarity.get(rk) ?? []), + ), + ]); + + const rarity = Object.fromEntries( + // eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index + rarityKeys.map((rk, i) => [rk, rarityBoards[i]]), + ) as Record; + + return cacheSafe({ badgeStats, totalBadges, achievementLevel, rarity }); + }, + ); + + // Per-viewer rank entries (only when a user is signed in). + let totalBadgesViewer: Partial | undefined; + let achievementViewer: Partial | undefined; + let rarityViewer: Record< + BadgeRarityKey, + Partial | undefined + > = { + common: undefined, + rare: undefined, + epic: undefined, + legendary: undefined, + mythical: undefined, + unique: undefined, + }; + + if (userId) { + const badgesByRarity = new Map(); + for (const stat of global.badgeStats) { + const list = badgesByRarity.get(stat.rarity) ?? []; + list.push(stat.badgeCode); + badgesByRarity.set(stat.rarity, list); + } + + const [tb, ach, ...rvs] = await Promise.all([ + loadTotalBadgesViewer(userId), + loadAchievementViewer(userId), + ...rarityKeys.map((rk) => + loadRarityViewer(userId, badgesByRarity.get(rk) ?? []), + ), + ]); + totalBadgesViewer = tb; + achievementViewer = ach; + rarityViewer = Object.fromEntries( + // eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index + rarityKeys.map((rk, i) => [rk, rvs[i]]), + ) as Record | undefined>; + } return apiJson({ viewerUserId: userId ?? 0, - badgeStats, + badgeStats: global.badgeStats, thresholds: THRESHOLDS, - leaderboards: { totalBadges, achievementLevel, rarity }, + leaderboards: { + totalBadges: { ...global.totalBadges, viewerEntry: totalBadgesViewer }, + achievementLevel: { + ...global.achievementLevel, + viewerEntry: achievementViewer, + }, + rarity: Object.fromEntries( + // eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const + rarityKeys.map((rk) => [ + rk, + { ...global.rarity[rk], viewerEntry: rarityViewer[rk] }, + ]), + ) as Record, + }, }); } catch (err) { logger.error("Badge leaderboard error", { diff --git a/src/app/api/guilds/[id]/route.ts b/src/app/api/guilds/[id]/route.ts index b2a2c0f1..3187c178 100644 --- a/src/app/api/guilds/[id]/route.ts +++ b/src/app/api/guilds/[id]/route.ts @@ -5,6 +5,7 @@ import { count, eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, Guilds, GuildsMembers } from "@/lib/db"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; export async function GET( _req: Request, @@ -18,38 +19,50 @@ export async function GET( const guildId = Number(id); try { - const [guild] = await db - .select({ - id: Guilds.id, - name: Guilds.name, - description: Guilds.description, - userId: Guilds.userId, - roomId: Guilds.roomId, - badge: Guilds.badge, - dateCreated: Guilds.dateCreated, - }) - .from(Guilds) - .where(eq(Guilds.id, guildId)) - .limit(1); + const data = await redisCache( + apiCacheKey(`guilds:${id}`), + 120, + async () => { + const [guild] = await db + .select({ + id: Guilds.id, + name: Guilds.name, + description: Guilds.description, + userId: Guilds.userId, + roomId: Guilds.roomId, + badge: Guilds.badge, + dateCreated: Guilds.dateCreated, + }) + .from(Guilds) + .where(eq(Guilds.id, guildId)) + .limit(1); - if (!guild) { + if (!guild) { + return null; + } + + // Member count is a separate guarded query (no relation is modelled). + let memberCount = 0; + try { + const [row] = await db + .select({ total: count() }) + .from(GuildsMembers) + .where(eq(GuildsMembers.guildId, guild.id)); + memberCount = row?.total ?? 0; + } catch { + memberCount = 0; + } + + const { userId, ...rest } = guild; + return cacheSafe({ data: { ...rest, ownerId: userId, memberCount } }); + }, + ); + + if (data === null) { return apiJson({ error: "Guild not found" }, { status: 404 }); } - // Member count is a separate guarded query (no relation is modelled). - let memberCount = 0; - try { - const [row] = await db - .select({ total: count() }) - .from(GuildsMembers) - .where(eq(GuildsMembers.guildId, guild.id)); - memberCount = row?.total ?? 0; - } catch { - memberCount = 0; - } - - const { userId, ...rest } = guild; - return apiJson({ data: { ...rest, ownerId: userId, memberCount } }); + return apiJson(data); } catch { // DB unavailable — treat as not found rather than a 500. return apiJson({ error: "Guild not found" }, { status: 200 }); diff --git a/src/app/api/guilds/route.ts b/src/app/api/guilds/route.ts index 63ef2854..b7cce013 100644 --- a/src/app/api/guilds/route.ts +++ b/src/app/api/guilds/route.ts @@ -5,43 +5,54 @@ import { count, desc } from "drizzle-orm"; import { apiJson, pagination } from "@/lib/api"; import { db, Guilds } from "@/lib/db"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; export async function GET(req: Request) { const sp = new URL(req.url).searchParams; const { page, perPage, skip, take } = pagination(sp); try { - const [totalRows, rows] = await Promise.all([ - db.select({ total: count() }).from(Guilds), - db - .select({ - id: Guilds.id, - name: Guilds.name, - description: Guilds.description, - userId: Guilds.userId, - }) - .from(Guilds) - .orderBy(desc(Guilds.id)) - .limit(take) - .offset(skip), - ]); - const total = totalRows[0]?.total ?? 0; + const data = await redisCache( + apiCacheKey(`guilds:${page}:${perPage}`), + 120, + async () => { + const [totalRows, rows] = await Promise.all([ + db.select({ total: count() }).from(Guilds), + db + .select({ + id: Guilds.id, + name: Guilds.name, + description: Guilds.description, + userId: Guilds.userId, + }) + .from(Guilds) + .orderBy(desc(Guilds.id)) + .limit(take) + .offset(skip), + ]); - // Rename userId → ownerId for the public payload. - const data = rows.map(({ userId, ...rest }) => ({ - ...rest, - ownerId: userId, - })); + // Rename userId → ownerId for the public payload. + const renamed = rows.map(({ userId, ...rest }) => ({ + ...rest, + ownerId: userId, + })); - return apiJson({ - data, - meta: { - page, - perPage, - total, - lastPage: Math.max(1, Math.ceil(total / perPage)), + return cacheSafe({ + data: renamed, + meta: { + page, + perPage, + total: totalRows[0]?.total ?? 0, + lastPage: Math.max( + 1, + Math.ceil((totalRows[0]?.total ?? 0) / perPage), + ), + }, + }); }, - }); + ); + + return apiJson(data); } catch { // DB unreachable — never 500; return an empty, well-formed payload. return apiJson( diff --git a/src/app/api/home/route.ts b/src/app/api/home/route.ts index 8bea5ad1..fca4e88c 100644 --- a/src/app/api/home/route.ts +++ b/src/app/api/home/route.ts @@ -3,7 +3,7 @@ import { apiJson } from "@/lib/api"; import { FALLBACK_HOTEL_NAME } from "@/lib/brand"; import { db, User, WebsiteArticles } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; -import { apiCacheKey, redisCache } from "@/lib/redis-cache"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; /** * GET /api/home — combined landing payload: the latest 4 website_articles and @@ -29,7 +29,11 @@ export async function GET(_req: Request) { db.select({ total: count() }).from(User).where(eq(User.online, "1")), resolveHotelName(), ]); - return { articles, online: onlineRows[0]?.total ?? 0, hotelName }; + return cacheSafe({ + articles, + online: onlineRows[0]?.total ?? 0, + hotelName, + }); }); return apiJson(data); } catch { diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 48bdf857..09c795c6 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -1,6 +1,7 @@ import { desc, eq, inArray } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, User, UsersCurrency } from "@/lib/db"; +import { apiCacheKey, redisCache } from "@/lib/redis-cache"; // Public REST API — leaderboard. Mirrors src/app/leaderboard/page.tsx. // AtomCMS-faithful currency type ids (see UsersCurrency): @@ -75,11 +76,15 @@ export async function GET(req: Request) { ? requested : "credits"; - const rows = - type === "credits" - ? await loadCreditsRows() - : // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets" - await loadCurrencyRows(CURRENCY_TYPE[type]); + const rows = await redisCache( + apiCacheKey(`leaderboard:${type}`), + 60, + async () => + type === "credits" + ? await loadCreditsRows() + : // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets" + await loadCurrencyRows(CURRENCY_TYPE[type]), + ); return apiJson({ type, data: rows }, { status: 200 }); } catch { diff --git a/src/app/api/photos/route.ts b/src/app/api/photos/route.ts index 90399d37..f1fd212c 100644 --- a/src/app/api/photos/route.ts +++ b/src/app/api/photos/route.ts @@ -1,6 +1,7 @@ import { count, desc } from "drizzle-orm"; import { apiJson, pagination } from "@/lib/api"; import { CameraWeb, db } from "@/lib/db"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; /** * GET /api/photos — recent community photos (camera_web), newest first. @@ -12,31 +13,40 @@ export async function GET(req: Request) { const { page, perPage, skip, take } = pagination(sp); try { - const [totalRows, photos] = await Promise.all([ - db.select({ total: count() }).from(CameraWeb), - db - .select({ - id: CameraWeb.id, - userId: CameraWeb.userId, - url: CameraWeb.url, - timestamp: CameraWeb.timestamp, - }) - .from(CameraWeb) - .orderBy(desc(CameraWeb.timestamp)) - .limit(take) - .offset(skip), - ]); - const total = totalRows[0]?.total ?? 0; - - return apiJson({ - data: photos, - meta: { - page, - perPage, - total, - lastPage: Math.max(1, Math.ceil(total / perPage)), + const data = await redisCache( + apiCacheKey(`photos:${page}:${perPage}`), + 60, + async () => { + const [totalRows, photos] = await Promise.all([ + db.select({ total: count() }).from(CameraWeb), + db + .select({ + id: CameraWeb.id, + userId: CameraWeb.userId, + url: CameraWeb.url, + timestamp: CameraWeb.timestamp, + }) + .from(CameraWeb) + .orderBy(desc(CameraWeb.timestamp)) + .limit(take) + .offset(skip), + ]); + return cacheSafe({ + data: photos, + meta: { + page, + perPage, + total: totalRows[0]?.total ?? 0, + lastPage: Math.max( + 1, + Math.ceil((totalRows[0]?.total ?? 0) / perPage), + ), + }, + }); }, - }); + ); + + return apiJson(data); } catch { // DB unavailable — return an empty payload instead of a 500. return apiJson( diff --git a/src/app/api/radio/current-dj/route.ts b/src/app/api/radio/current-dj/route.ts index a40160a3..f316f99a 100644 --- a/src/app/api/radio/current-dj/route.ts +++ b/src/app/api/radio/current-dj/route.ts @@ -1,33 +1,46 @@ import { eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, User } from "@/lib/db"; +import { apiCacheKey, redisCache } from "@/lib/redis-cache"; import { siteSettings } from "@/lib/services/site-settings"; // Current on-air DJ. The DJ is set manually via the radio_current_dj_id setting // (Manual DJ user ID). When set, resolve that user's username/look; otherwise // there is no DJ on air. Mirrors the AtomCMS radio "on air" widget. +// The radio widget polls this endpoint frequently — a short cache avoids a DB +// hit per poll. The DJ only changes when a moderator switches it manually. +const GET_TTL_SECONDS = 5; + export async function GET(_req: Request) { try { - const raw = await siteSettings.get("radio_current_dj_id", ""); - const id = Number(raw); + const dj = await redisCache( + apiCacheKey("radio:current-dj"), + GET_TTL_SECONDS, + async () => { + const raw = await siteSettings.get("radio_current_dj_id", ""); + const id = Number(raw); - // No DJ configured (empty / non-numeric / zero). - if (!raw || !Number.isFinite(id) || id <= 0) { - return apiJson({ dj: null }); - } + // No DJ configured (empty / non-numeric / zero). + if (!raw || !Number.isFinite(id) || id <= 0) { + return null; + } - const [user] = await db - .select({ username: User.username, look: User.look }) - .from(User) - .where(eq(User.id, id)) - .limit(1); + const [user] = await db + .select({ username: User.username, look: User.look }) + .from(User) + .where(eq(User.id, id)) + .limit(1); - if (!user) { - return apiJson({ dj: null }); - } + if (!user) { + return null; + } - return apiJson({ dj: { username: user.username, look: user.look } }); + return { username: user.username, look: user.look }; + }, + ); + + return apiJson({ dj }); } catch { // DB / settings unavailable — no DJ rather than a 500. return apiJson({ dj: null }, { status: 200 }); diff --git a/src/app/api/radio/points/leaderboard/route.ts b/src/app/api/radio/points/leaderboard/route.ts index 4feb23f9..333b7b73 100644 --- a/src/app/api/radio/points/leaderboard/route.ts +++ b/src/app/api/radio/points/leaderboard/route.ts @@ -1,6 +1,7 @@ -import { inArray, sum } from "drizzle-orm"; +import { desc, inArray, sum } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, RadioListenerPoints, User } from "@/lib/db"; +import { apiCacheKey, redisCache } from "@/lib/redis-cache"; // Radio listener-points leaderboard: the top 20 users by total points, summed // across radio_listener_points and joined to users for username/look. Public @@ -8,43 +9,46 @@ import { db, RadioListenerPoints, User } from "@/lib/db"; export async function GET(_req: Request) { try { - // Sum points per user. Sort/slice in JS so we stay adapter-agnostic about - // aggregate ordering, then resolve the top 20 to usernames/looks. - const grouped = await db - .select({ - userId: RadioListenerPoints.userId, - points: sum(RadioListenerPoints.points), - }) - .from(RadioListenerPoints) - .groupBy(RadioListenerPoints.userId); + // Cached 60s: the leaderboard only changes while people listen, so a + // short TTL keeps it fresh without re-scanning the table per request. + const data = await redisCache( + apiCacheKey("radio:points:leaderboard"), + 60, + async () => { + // Sum points per user and take the top 20 in SQL instead of + // pulling the whole table into JS to sort/slice. + const grouped = await db + .select({ + userId: RadioListenerPoints.userId, + points: sum(RadioListenerPoints.points), + }) + .from(RadioListenerPoints) + .groupBy(RadioListenerPoints.userId) + .orderBy(desc(sum(RadioListenerPoints.points))) + .limit(20); - const ranked = grouped - .map((g) => ({ - userId: Number(g.userId), - points: Number(g.points ?? 0), - })) - .sort((a, b) => b.points - a.points) - .slice(0, 20); + if (grouped.length === 0) { + return []; + } - if (ranked.length === 0) { - return apiJson({ data: [] }); - } + const userIds = grouped.map((r) => Number(r.userId)); + const users = await db + .select({ id: User.id, username: User.username, look: User.look }) + .from(User) + .where(inArray(User.id, userIds)); + const userById = new Map(users.map((u) => [u.id, u])); - const userIds = ranked.map((r) => r.userId); - const users = await db - .select({ id: User.id, username: User.username, look: User.look }) - .from(User) - .where(inArray(User.id, userIds)); - const userById = new Map(users.map((u) => [u.id, u])); - - const data = ranked.map((r) => { - const u = userById.get(r.userId); - return { - username: u?.username ?? null, - look: u?.look ?? null, - points: r.points, - }; - }); + return grouped.map((r) => { + const uid = Number(r.userId); + const u = userById.get(uid); + return { + username: u?.username ?? null, + look: u?.look ?? null, + points: Number(r.points ?? 0), + }; + }); + }, + ); return apiJson({ data }); } catch { diff --git a/src/app/api/radio/shouts/route.ts b/src/app/api/radio/shouts/route.ts index 82351aa2..fc1fc33d 100644 --- a/src/app/api/radio/shouts/route.ts +++ b/src/app/api/radio/shouts/route.ts @@ -2,6 +2,7 @@ import { desc, inArray } from "drizzle-orm"; import { apiError, apiJson } from "@/lib/api"; import { bearerUserId } from "@/lib/api-auth"; import { db, RadioShouts, User } from "@/lib/db"; +import { apiCacheKey, redisCache } from "@/lib/redis-cache"; // Latest 50 radio shouts with their author's username/look resolved. Mirrors the // query behind the public /radio/shouts page (radio_shouts ordered by created_at @@ -10,41 +11,53 @@ import { db, RadioShouts, User } from "@/lib/db"; // Max shout length (radio_shouts.message is TEXT; cap to keep posts sane). const MAX_MESSAGE_LENGTH = 255; +// The shout feed is polled by the radio widget every few seconds — a short +// cache keeps it cheap without delaying new shouts noticeably. +const GET_TTL_SECONDS = 5; + export async function GET(_req: Request) { try { - const shouts = await db - .select({ - id: RadioShouts.id, - userId: RadioShouts.userId, - message: RadioShouts.message, - createdAt: RadioShouts.createdAt, - }) - .from(RadioShouts) - .orderBy(desc(RadioShouts.createdAt)) - .limit(50); + const data = await redisCache( + apiCacheKey("radio:shouts"), + GET_TTL_SECONDS, + async () => { + const shouts = await db + .select({ + id: RadioShouts.id, + userId: RadioShouts.userId, + message: RadioShouts.message, + createdAt: RadioShouts.createdAt, + }) + .from(RadioShouts) + .orderBy(desc(RadioShouts.createdAt)) + .limit(50); - // Resolve author usernames/looks. radio_shouts.user_id is an UnsignedBigInt - // while users.id is an Int, so narrow to Number for the lookup. - const authorIds = Array.from(new Set(shouts.map((s) => Number(s.userId)))); - const authors = authorIds.length - ? await db - .select({ id: User.id, username: User.username, look: User.look }) - .from(User) - .where(inArray(User.id, authorIds)) - : []; - const authorById = new Map(authors.map((a) => [a.id, a])); + // Resolve author usernames/looks. radio_shouts.user_id is an UnsignedBigInt + // while users.id is an Int, so narrow to Number for the lookup. + const authorIds = Array.from( + new Set(shouts.map((s) => Number(s.userId))), + ); + const authors = authorIds.length + ? await db + .select({ id: User.id, username: User.username, look: User.look }) + .from(User) + .where(inArray(User.id, authorIds)) + : []; + const authorById = new Map(authors.map((a) => [a.id, a])); - const data = shouts.map((s) => { - const author = authorById.get(Number(s.userId)); - return { - id: s.id, - userId: s.userId, - username: author?.username ?? null, - look: author?.look ?? null, - message: s.message, - createdAt: s.createdAt, - }; - }); + return shouts.map((s) => { + const author = authorById.get(Number(s.userId)); + return { + id: s.id, + userId: s.userId, + username: author?.username ?? null, + look: author?.look ?? null, + message: s.message, + createdAt: s.createdAt, + }; + }); + }, + ); return apiJson({ shouts: data }); } catch { diff --git a/src/app/api/shop/categories/route.ts b/src/app/api/shop/categories/route.ts index 916ffe7e..1c2ba1be 100644 --- a/src/app/api/shop/categories/route.ts +++ b/src/app/api/shop/categories/route.ts @@ -3,22 +3,30 @@ import { asc } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, WebsiteShopCategories } from "@/lib/db"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; export async function GET(_req: Request) { try { - const data = await db - .select({ - id: WebsiteShopCategories.id, - name: WebsiteShopCategories.name, - description: WebsiteShopCategories.description, - icon: WebsiteShopCategories.icon, - order: WebsiteShopCategories.order, - }) - .from(WebsiteShopCategories) - .orderBy( - asc(WebsiteShopCategories.order), - asc(WebsiteShopCategories.name), - ); + const data = await redisCache( + apiCacheKey("shop:categories"), + 60, + async () => + cacheSafe( + await db + .select({ + id: WebsiteShopCategories.id, + name: WebsiteShopCategories.name, + description: WebsiteShopCategories.description, + icon: WebsiteShopCategories.icon, + order: WebsiteShopCategories.order, + }) + .from(WebsiteShopCategories) + .orderBy( + asc(WebsiteShopCategories.order), + asc(WebsiteShopCategories.name), + ), + ), + ); return apiJson({ data }); } catch { diff --git a/src/app/api/shop/route.ts b/src/app/api/shop/route.ts index 85ee2e5c..cbf51546 100644 --- a/src/app/api/shop/route.ts +++ b/src/app/api/shop/route.ts @@ -4,6 +4,7 @@ import { asc, count, eq } from "drizzle-orm"; import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api"; import { db, WebsiteShopArticles } from "@/lib/db"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; export async function GET(req: Request) { const sp = new URL(req.url).searchParams; @@ -24,46 +25,55 @@ export async function GET(req: Request) { ? eq(WebsiteShopArticles.categoryId, categoryId) : undefined; - const [totalRows, data] = await Promise.all([ - db.select({ total: count() }).from(WebsiteShopArticles).where(where), - db - .select({ - id: WebsiteShopArticles.id, - categoryId: WebsiteShopArticles.categoryId, - name: WebsiteShopArticles.name, - info: WebsiteShopArticles.info, - iconUrl: WebsiteShopArticles.iconUrl, - color: WebsiteShopArticles.color, - costs: WebsiteShopArticles.costs, - giveRank: WebsiteShopArticles.giveRank, - isGiftable: WebsiteShopArticles.isGiftable, - credits: WebsiteShopArticles.credits, - duckets: WebsiteShopArticles.duckets, - diamonds: WebsiteShopArticles.diamonds, - badges: WebsiteShopArticles.badges, - furniture: WebsiteShopArticles.furniture, - position: WebsiteShopArticles.position, - }) - .from(WebsiteShopArticles) - .where(where) - .orderBy( - asc(WebsiteShopArticles.position), - asc(WebsiteShopArticles.name), - ) - .limit(take) - .offset(skip), - ]); - const total = totalRows[0]?.total ?? 0; - - return apiJson({ - data, - meta: { - page, - perPage, - total, - lastPage: Math.max(1, Math.ceil(total / perPage)), + const data = await redisCache( + apiCacheKey(`shop:${categoryId ?? "all"}:${page}:${perPage}`), + 60, + async () => { + const [totalRows, rows] = await Promise.all([ + db.select({ total: count() }).from(WebsiteShopArticles).where(where), + db + .select({ + id: WebsiteShopArticles.id, + categoryId: WebsiteShopArticles.categoryId, + name: WebsiteShopArticles.name, + info: WebsiteShopArticles.info, + iconUrl: WebsiteShopArticles.iconUrl, + color: WebsiteShopArticles.color, + costs: WebsiteShopArticles.costs, + giveRank: WebsiteShopArticles.giveRank, + isGiftable: WebsiteShopArticles.isGiftable, + credits: WebsiteShopArticles.credits, + duckets: WebsiteShopArticles.duckets, + diamonds: WebsiteShopArticles.diamonds, + badges: WebsiteShopArticles.badges, + furniture: WebsiteShopArticles.furniture, + position: WebsiteShopArticles.position, + }) + .from(WebsiteShopArticles) + .where(where) + .orderBy( + asc(WebsiteShopArticles.position), + asc(WebsiteShopArticles.name), + ) + .limit(take) + .offset(skip), + ]); + return cacheSafe({ + data: rows, + meta: { + page, + perPage, + total: totalRows[0]?.total ?? 0, + lastPage: Math.max( + 1, + Math.ceil((totalRows[0]?.total ?? 0) / perPage), + ), + }, + }); }, - }); + ); + + return apiJson(data); } catch { // DB unreachable — never 500; return an empty, well-formed payload. return apiJson( diff --git a/src/app/api/staff/route.ts b/src/app/api/staff/route.ts index ef6e5e8a..ad4036bc 100644 --- a/src/app/api/staff/route.ts +++ b/src/app/api/staff/route.ts @@ -1,6 +1,7 @@ import { asc, desc, gte } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, User } from "@/lib/db"; +import { apiCacheKey, redisCache } from "@/lib/redis-cache"; import { siteSettings } from "@/lib/services/site-settings"; // Public REST API — staff list. Mirrors src/app/staff/page.tsx: users whose @@ -11,17 +12,19 @@ export async function GET(_req: Request) { const minStaffRank = Number(await siteSettings.get("min_staff_rank", "7")) || 7; - const staff = await db - .select({ - username: User.username, - look: User.look, - rank: User.rank, - motto: User.motto, - }) - .from(User) - .where(gte(User.rank, minStaffRank)) - .orderBy(desc(User.rank), asc(User.username)) - .limit(100); + const staff = await redisCache(apiCacheKey("staff"), 300, () => + db + .select({ + username: User.username, + look: User.look, + rank: User.rank, + motto: User.motto, + }) + .from(User) + .where(gte(User.rank, minStaffRank)) + .orderBy(desc(User.rank), asc(User.username)) + .limit(100), + ); return apiJson({ data: staff }, { status: 200 }); } catch { diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index d08d8810..4eb58ba2 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -1,23 +1,28 @@ import { asc, eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, WebsiteTeams } from "@/lib/db"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; // Public REST API — website teams (staff ranks). Mirrors src/app/staff/page.tsx: // visible ranks (hiddenRank=false) ordered by id. export async function GET(_req: Request) { try { - const rows = await db - .select({ - id: WebsiteTeams.id, - rankName: WebsiteTeams.rankName, - badge: WebsiteTeams.badge, - jobDescription: WebsiteTeams.jobDescription, - staffColor: WebsiteTeams.staffColor, - }) - .from(WebsiteTeams) - .where(eq(WebsiteTeams.hiddenRank, false)) - .orderBy(asc(WebsiteTeams.id)); + const rows = await redisCache(apiCacheKey("teams"), 300, async () => + cacheSafe( + await db + .select({ + id: WebsiteTeams.id, + rankName: WebsiteTeams.rankName, + badge: WebsiteTeams.badge, + jobDescription: WebsiteTeams.jobDescription, + staffColor: WebsiteTeams.staffColor, + }) + .from(WebsiteTeams) + .where(eq(WebsiteTeams.hiddenRank, false)) + .orderBy(asc(WebsiteTeams.id)), + ), + ); // apiJson serialises BigInt ids → string automatically. return apiJson({ data: rows }, { status: 200 }); diff --git a/src/app/api/users/[username]/route.ts b/src/app/api/users/[username]/route.ts index 3708e0d6..26761409 100644 --- a/src/app/api/users/[username]/route.ts +++ b/src/app/api/users/[username]/route.ts @@ -8,6 +8,7 @@ import { eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, User } from "@/lib/db"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; export async function GET( _req: Request, @@ -16,33 +17,45 @@ export async function GET( const { username } = await params; try { - const [user] = await db - .select({ - username: User.username, - look: User.look, - motto: User.motto, - rank: User.rank, - credits: User.credits, - online: User.online, - accountCreated: User.accountCreated, - }) - .from(User) - .where(eq(User.username, username)) - .limit(1); + const user = await redisCache( + apiCacheKey(`users:${username}`), + 30, + async () => { + const [row] = await db + .select({ + username: User.username, + look: User.look, + motto: User.motto, + rank: User.rank, + credits: User.credits, + online: User.online, + accountCreated: User.accountCreated, + }) + .from(User) + .where(eq(User.username, username)) + .limit(1); + + if (!row) { + return null; + } + + return cacheSafe({ + username: row.username, + look: row.look, + motto: row.motto, + rank: row.rank, + credits: row.credits, + online: row.online === "1", + accountCreated: row.accountCreated, + }); + }, + ); if (!user) { return apiJson({ error: "User not found" }, { status: 404 }); } - return apiJson({ - username: user.username, - look: user.look, - motto: user.motto, - rank: user.rank, - credits: user.credits, - online: user.online === "1", - accountCreated: user.accountCreated, - }); + return apiJson(user); } catch { // DB unreachable — behave as "not found" rather than 500. return apiJson({ error: "User not found" }, { status: 404 }); diff --git a/src/app/api/values/[id]/route.ts b/src/app/api/values/[id]/route.ts index 4d9f6d34..d4ca7884 100644 --- a/src/app/api/values/[id]/route.ts +++ b/src/app/api/values/[id]/route.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; export async function GET( _req: Request, @@ -24,52 +25,64 @@ export async function GET( } try { - const [value] = await db - .select({ - id: WebsiteRareValues.id, - categoryId: WebsiteRareValues.categoryId, - itemId: WebsiteRareValues.itemId, - name: WebsiteRareValues.name, - creditValue: WebsiteRareValues.creditValue, - currencyValue: WebsiteRareValues.currencyValue, - currencyType: WebsiteRareValues.currencyType, - furnitureIcon: WebsiteRareValues.furnitureIcon, - createdAt: WebsiteRareValues.createdAt, - updatedAt: WebsiteRareValues.updatedAt, - }) - .from(WebsiteRareValues) - .where(eq(WebsiteRareValues.id, valueId)) - .limit(1); + const data = await redisCache( + apiCacheKey(`values:${id}`), + 300, + async () => { + const [value] = await db + .select({ + id: WebsiteRareValues.id, + categoryId: WebsiteRareValues.categoryId, + itemId: WebsiteRareValues.itemId, + name: WebsiteRareValues.name, + creditValue: WebsiteRareValues.creditValue, + currencyValue: WebsiteRareValues.currencyValue, + currencyType: WebsiteRareValues.currencyType, + furnitureIcon: WebsiteRareValues.furnitureIcon, + createdAt: WebsiteRareValues.createdAt, + updatedAt: WebsiteRareValues.updatedAt, + }) + .from(WebsiteRareValues) + .where(eq(WebsiteRareValues.id, valueId)) + .limit(1); - if (!value) { + if (!value) { + return null; + } + + // No relation is modelled between the value and its category, so resolve the + // category in a second guarded query. + let category: { + id: bigint; + name: string; + badge: string; + priority: number; + } | null = null; + try { + const [row] = await db + .select({ + id: WebsiteRareValueCategories.id, + name: WebsiteRareValueCategories.name, + badge: WebsiteRareValueCategories.badge, + priority: WebsiteRareValueCategories.priority, + }) + .from(WebsiteRareValueCategories) + .where(eq(WebsiteRareValueCategories.id, value.categoryId)) + .limit(1); + category = row ?? null; + } catch { + category = null; + } + + return cacheSafe({ data: { ...value, category } }); + }, + ); + + if (data === null) { return apiJson({ error: "Rare value not found" }, { status: 404 }); } - // No relation is modelled between the value and its category, so resolve the - // category in a second guarded query. - let category: { - id: bigint; - name: string; - badge: string; - priority: number; - } | null = null; - try { - const [row] = await db - .select({ - id: WebsiteRareValueCategories.id, - name: WebsiteRareValueCategories.name, - badge: WebsiteRareValueCategories.badge, - priority: WebsiteRareValueCategories.priority, - }) - .from(WebsiteRareValueCategories) - .where(eq(WebsiteRareValueCategories.id, value.categoryId)) - .limit(1); - category = row ?? null; - } catch { - category = null; - } - - return apiJson({ data: { ...value, category } }); + return apiJson(data); } catch { // DB unavailable — treat as not found rather than a 500. return apiJson({ error: "Rare value not found" }, { status: 200 }); diff --git a/src/app/api/values/categories/route.ts b/src/app/api/values/categories/route.ts index e2e16efe..0b925bb5 100644 --- a/src/app/api/values/categories/route.ts +++ b/src/app/api/values/categories/route.ts @@ -4,21 +4,29 @@ import { asc } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, WebsiteRareValueCategories } from "@/lib/db"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; export async function GET(_req: Request) { try { - const data = await db - .select({ - id: WebsiteRareValueCategories.id, - name: WebsiteRareValueCategories.name, - badge: WebsiteRareValueCategories.badge, - priority: WebsiteRareValueCategories.priority, - }) - .from(WebsiteRareValueCategories) - .orderBy( - asc(WebsiteRareValueCategories.priority), - asc(WebsiteRareValueCategories.name), - ); + const data = await redisCache( + apiCacheKey("values:categories"), + 300, + async () => + cacheSafe( + await db + .select({ + id: WebsiteRareValueCategories.id, + name: WebsiteRareValueCategories.name, + badge: WebsiteRareValueCategories.badge, + priority: WebsiteRareValueCategories.priority, + }) + .from(WebsiteRareValueCategories) + .orderBy( + asc(WebsiteRareValueCategories.priority), + asc(WebsiteRareValueCategories.name), + ), + ), + ); return apiJson({ data }); } catch { diff --git a/src/app/api/values/route.ts b/src/app/api/values/route.ts index a50c2db7..90cc8115 100644 --- a/src/app/api/values/route.ts +++ b/src/app/api/values/route.ts @@ -4,6 +4,7 @@ import { asc, count, eq } from "drizzle-orm"; import { apiError, apiJson, pagination, positiveBigInt } from "@/lib/api"; import { db, WebsiteRareValues } from "@/lib/db"; +import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; export async function GET(req: Request) { const sp = new URL(req.url).searchParams; @@ -24,36 +25,45 @@ export async function GET(req: Request) { ? eq(WebsiteRareValues.categoryId, categoryId) : undefined; - const [totalRows, data] = await Promise.all([ - db.select({ total: count() }).from(WebsiteRareValues).where(where), - db - .select({ - id: WebsiteRareValues.id, - categoryId: WebsiteRareValues.categoryId, - itemId: WebsiteRareValues.itemId, - name: WebsiteRareValues.name, - creditValue: WebsiteRareValues.creditValue, - currencyValue: WebsiteRareValues.currencyValue, - currencyType: WebsiteRareValues.currencyType, - furnitureIcon: WebsiteRareValues.furnitureIcon, - }) - .from(WebsiteRareValues) - .where(where) - .orderBy(asc(WebsiteRareValues.name)) - .limit(take) - .offset(skip), - ]); - const total = totalRows[0]?.total ?? 0; - - return apiJson({ - data, - meta: { - page, - perPage, - total, - lastPage: Math.max(1, Math.ceil(total / perPage)), + const data = await redisCache( + apiCacheKey(`values:${categoryId ?? "all"}:${page}:${perPage}`), + 300, + async () => { + const [totalRows, rows] = await Promise.all([ + db.select({ total: count() }).from(WebsiteRareValues).where(where), + db + .select({ + id: WebsiteRareValues.id, + categoryId: WebsiteRareValues.categoryId, + itemId: WebsiteRareValues.itemId, + name: WebsiteRareValues.name, + creditValue: WebsiteRareValues.creditValue, + currencyValue: WebsiteRareValues.currencyValue, + currencyType: WebsiteRareValues.currencyType, + furnitureIcon: WebsiteRareValues.furnitureIcon, + }) + .from(WebsiteRareValues) + .where(where) + .orderBy(asc(WebsiteRareValues.name)) + .limit(take) + .offset(skip), + ]); + return cacheSafe({ + data: rows, + meta: { + page, + perPage, + total: totalRows[0]?.total ?? 0, + lastPage: Math.max( + 1, + Math.ceil((totalRows[0]?.total ?? 0) / perPage), + ), + }, + }); }, - }); + ); + + return apiJson(data); } catch { // DB unreachable — never 500; return an empty, well-formed payload. return apiJson( diff --git a/src/components/admin/studio/batch-progress.tsx b/src/components/admin/studio/batch-progress.tsx new file mode 100644 index 00000000..9c6867f2 --- /dev/null +++ b/src/components/admin/studio/batch-progress.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Check, Loader2, X } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import type { BatchItemStatus } from "./studio-types"; + +export function BatchProgress({ + progress, + done, + succeeded, + failed, +}: { + progress: Map; + done: boolean; + succeeded: number; + failed: number; +}) { + const entries = [...progress.entries()]; + return ( +
+
+ + Importing… + + {done ? ( + 0 + ? "text-[var(--admin-warning)]" + : "text-[var(--admin-success)]", + )} + > + ✓ {succeeded} ok{failed > 0 ? ` · ${failed} failed` : ""} + + ) : ( + + )} +
+
+ {entries.map(([classname, s]) => ( +
+ {s.status === "done" ? ( + + ) : s.status === "failed" ? ( + + ) : ( + + )} + + {classname} + + + {s.status.replace(/_/g, " ")} + +
+ ))} +
+
+ ); +} diff --git a/src/components/admin/studio/catalog-rail.tsx b/src/components/admin/studio/catalog-rail.tsx new file mode 100644 index 00000000..67e364a8 --- /dev/null +++ b/src/components/admin/studio/catalog-rail.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { ChevronDown, ChevronRight, FolderTree } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "@/lib/utils"; +import type { TreeNode } from "@/types/catalog"; + +export function CatalogRail({ + tree, + selectedId, + onSelect, +}: { + tree: TreeNode[]; + selectedId: number | null; + onSelect: (id: number | null) => void; +}) { + const [collapsed, setCollapsed] = useState>(new Set()); + + const visible = useMemo(() => { + const hidden = new Set(); + for (const node of tree) { + if (hidden.has(node.parentId)) { + hidden.add(node.id); + continue; + } + if (collapsed.has(node.parentId)) { + hidden.add(node.id); + } + } + return tree.filter((n) => !hidden.has(n.id)); + }, [tree, collapsed]); + + const toggle = (id: number) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( + + ); +} diff --git a/src/components/admin/studio/checkbox-dot.tsx b/src/components/admin/studio/checkbox-dot.tsx new file mode 100644 index 00000000..c987049b --- /dev/null +++ b/src/components/admin/studio/checkbox-dot.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Check } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export function CheckboxDot({ checked }: { checked: boolean }) { + return ( + + {checked ? ( + + ) : null} + + ); +} diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 38b37f71..1685993e 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -2,12 +2,9 @@ import { Check, - ChevronDown, - ChevronRight, CircleAlert, CloudDownload, Download, - FolderTree, Layers, Loader2, Package, @@ -47,251 +44,20 @@ import { import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel"; import { cn } from "@/lib/utils"; import type { TreeNode } from "@/types/catalog"; - -interface FurniItem { - id: number; - classname: string; - type: string; - name: string; - description: string; - revision: number; - category: string; - alreadyImported: boolean; - nitroExists: boolean; - /** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */ - iconUrl?: string; -} - -interface Meta { - currentPage: number; - lastPage: number; - total: number; - perPage: number; -} - -interface FurniStats { - totalInDb: number; - inCatalog: number; - notInCatalog: number; - missingNitro: number; -} - -interface BatchItemStatus { - classname: string; - status: - | "pending" - | "started" - | "downloading" - | "converting" - | "writing_db" - | "done" - | "failed"; - message?: string; - warnings?: string[]; - itemId?: number; -} - -function getFurniImageUrl(item: FurniItem) { - if (item.iconUrl) return item.iconUrl; - const base = item.classname.includes("*") - ? item.classname.substring(0, item.classname.indexOf("*")) - : item.classname; - return `https://images.habbo.com/dcr/hof_furni/${item.revision}/${encodeURIComponent(base)}_icon.png`; -} - -function getFurniIconUrl(classname: string, revision: number) { - const iconName = classname.replace(/\*/g, "_"); - return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`; -} - -function handleImgError( - e: React.SyntheticEvent, - item: FurniItem, -) { - const img = e.target as HTMLImageElement; - const iconName = item.classname.replace(/\*/g, "_"); - if (!img.dataset.fallback) { - img.dataset.fallback = "1"; - img.src = getFurniIconUrl(item.classname, item.revision); - } else if (img.dataset.fallback === "1") { - img.dataset.fallback = "2"; - img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`; - } else { - img.style.opacity = "0.3"; - img.src = - 'data:image/svg+xml,'; - } -} - -const TYPE_LABELS: Record = { - flooritem: "Floor", - wallitem: "Wall", -}; - -// ── Catalog structure rail ───────────────────────────────────────── - -function CatalogRail({ - tree, - selectedId, - onSelect, -}: { - tree: TreeNode[]; - selectedId: number | null; - onSelect: (id: number | null) => void; -}) { - const [collapsed, setCollapsed] = useState>(new Set()); - - const visible = useMemo(() => { - const hidden = new Set(); - for (const node of tree) { - if (hidden.has(node.parentId)) { - hidden.add(node.id); - continue; - } - if (collapsed.has(node.parentId)) { - hidden.add(node.id); - } - } - return tree.filter((n) => !hidden.has(n.id)); - }, [tree, collapsed]); - - const toggle = (id: number) => { - setCollapsed((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - return ( - - ); -} - -// ── Batch progress panel ─────────────────────────────────────────── - -function BatchProgress({ - progress, - done, - succeeded, - failed, -}: { - progress: Map; - done: boolean; - succeeded: number; - failed: number; -}) { - const entries = [...progress.entries()]; - return ( -
-
- - Importing… - - {done ? ( - 0 - ? "text-[var(--admin-warning)]" - : "text-[var(--admin-success)]", - )} - > - ✓ {succeeded} ok{failed > 0 ? ` · ${failed} failed` : ""} - - ) : ( - - )} -
-
- {entries.map(([cn, s]) => ( -
- {s.status === "done" ? ( - - ) : s.status === "failed" ? ( - - ) : ( - - )} - {cn} - - {s.status.replace(/_/g, " ")} - -
- ))} -
-
- ); -} - -// ── Studio workspace ─────────────────────────────────────────────── +import { BatchProgress } from "./batch-progress"; +import { CatalogRail } from "./catalog-rail"; +import { CheckboxDot } from "./checkbox-dot"; +import { + getFurniImageUrl, + handleImgError, + TYPE_LABELS, +} from "./studio-furni-icons"; +import type { + BatchItemStatus, + FurniItem, + FurniStats, + Meta, +} from "./studio-types"; export function StudioClient({ source, @@ -1129,20 +895,3 @@ export function StudioClient({
); } - -function CheckboxDot({ checked }: { checked: boolean }) { - return ( - - {checked ? ( - - ) : null} - - ); -} diff --git a/src/components/admin/studio/studio-furni-icons.ts b/src/components/admin/studio/studio-furni-icons.ts new file mode 100644 index 00000000..282e5cd7 --- /dev/null +++ b/src/components/admin/studio/studio-furni-icons.ts @@ -0,0 +1,40 @@ +import type { SyntheticEvent } from "react"; + +import type { FurniItem } from "./studio-types"; + +export function getFurniImageUrl(item: FurniItem) { + if (item.iconUrl) return item.iconUrl; + const base = item.classname.includes("*") + ? item.classname.substring(0, item.classname.indexOf("*")) + : item.classname; + return `https://images.habbo.com/dcr/hof_furni/${item.revision}/${encodeURIComponent(base)}_icon.png`; +} + +export function getFurniIconUrl(classname: string, revision: number) { + const iconName = classname.replace(/\*/g, "_"); + return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`; +} + +export function handleImgError( + e: SyntheticEvent, + item: FurniItem, +) { + const img = e.target as HTMLImageElement; + const iconName = item.classname.replace(/\*/g, "_"); + if (!img.dataset.fallback) { + img.dataset.fallback = "1"; + img.src = getFurniIconUrl(item.classname, item.revision); + } else if (img.dataset.fallback === "1") { + img.dataset.fallback = "2"; + img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`; + } else { + img.style.opacity = "0.3"; + img.src = + 'data:image/svg+xml,'; + } +} + +export const TYPE_LABELS: Record = { + flooritem: "Floor", + wallitem: "Wall", +}; diff --git a/src/components/admin/studio/studio-types.ts b/src/components/admin/studio/studio-types.ts new file mode 100644 index 00000000..bec4d705 --- /dev/null +++ b/src/components/admin/studio/studio-types.ts @@ -0,0 +1,42 @@ +export interface FurniItem { + id: number; + classname: string; + type: string; + name: string; + description: string; + revision: number; + category: string; + alreadyImported: boolean; + nitroExists: boolean; + /** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */ + iconUrl?: string; +} + +export interface Meta { + currentPage: number; + lastPage: number; + total: number; + perPage: number; +} + +export interface FurniStats { + totalInDb: number; + inCatalog: number; + notInCatalog: number; + missingNitro: number; +} + +export interface BatchItemStatus { + classname: string; + status: + | "pending" + | "started" + | "downloading" + | "converting" + | "writing_db" + | "done" + | "failed"; + message?: string; + warnings?: string[]; + itemId?: number; +} diff --git a/src/lib/cache.test.ts b/src/lib/cache.test.ts new file mode 100644 index 00000000..0ac37bff --- /dev/null +++ b/src/lib/cache.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/redis", () => ({ + redis: null, +})); + +import { cached, invalidateMemory } from "./cache"; + +describe("cached (memory-only, no Redis)", () => { + it("computes once and serves the cached value", async () => { + const fn = vi.fn(async () => 42); + const key = `once-${Math.random()}`; + expect(await cached(key, 10_000, fn)).toBe(42); + expect(await cached(key, 10_000, fn)).toBe(42); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("dedupes concurrent misses into a single computation", async () => { + let resolveFn: (value: number) => void = () => {}; + const fn = vi.fn( + () => + new Promise((resolve) => { + resolveFn = resolve; + }), + ); + const key = `dedupe-${Math.random()}`; + const p1 = cached(key, 10_000, fn); + const p2 = cached(key, 10_000, fn); + const p3 = cached(key, 10_000, fn); + resolveFn(7); + expect(await Promise.all([p1, p2, p3])).toEqual([7, 7, 7]); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("recomputes after the TTL expires", async () => { + let count = 0; + const fn = vi.fn(async () => ++count); + const key = `ttl-${Math.random()}`; + expect(await cached(key, 1, fn)).toBe(1); + expect(await cached(key, 1, fn)).toBe(1); + await new Promise((r) => setTimeout(r, 10)); + expect(await cached(key, 1, fn)).toBe(2); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("invalidates a key so the next read recomputes", async () => { + let count = 0; + const fn = vi.fn(async () => ++count); + const key = `inv-${Math.random()}`; + expect(await cached(key, 10_000, fn)).toBe(1); + invalidateMemory(key); + expect(await cached(key, 10_000, fn)).toBe(2); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("evicts the oldest entry when the in-memory cache is full", async () => { + const fn = vi.fn(async () => 1); + for (let i = 0; i < 600; i++) { + await cached(`bulk-${i}`, 10_000, fn); + } + // Key "bulk-0" was evicted (insertion order), so it must recompute. + await cached("bulk-0", 10_000, fn); + expect(fn).toHaveBeenCalledTimes(601); + }); +}); diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 844eccd6..2c5434ac 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -5,9 +5,40 @@ import { redis } from "@/lib/redis"; type CacheEntry = { data: T; expiresAt: number }; const memory = new Map>(); +// Cap the in-process map so dynamic keys (leaderboard currencies, article +// slugs, …) can never grow it without bound. Oldest entries are evicted. +const MAX_MEMORY_ENTRIES = 500; + +// Single-flight: a key being (re)computed is awaited by concurrent callers +// instead of each starting its own `fn()` (cache-stampede protection). +const inFlight = new Map>(); + /** Drop a key from the in-process cache (used when an upstream value changes). */ export function invalidateMemory(key: string): void { memory.delete(key); + inFlight.delete(key); +} + +function pruneExpired(now: number): void { + for (const [key, entry] of memory) { + if (entry.expiresAt <= now) memory.delete(key); + } +} + +function setMemory(key: string, entry: CacheEntry): void { + if (memory.has(key)) { + memory.set(key, entry); + return; + } + if (memory.size >= MAX_MEMORY_ENTRIES) { + // Drop expired entries first, then evict the oldest (insertion order). + pruneExpired(Date.now()); + if (memory.size >= MAX_MEMORY_ENTRIES) { + const oldest = memory.keys().next().value; + if (oldest !== undefined) memory.delete(oldest); + } + } + memory.set(key, entry); } /** @@ -30,30 +61,41 @@ export async function cached( return existing.data as T; } - // Redis path (shared across instances). - if (redis && redis.status !== "end") { - try { - const cached = await redis.get(key); - if (cached !== null && cached !== undefined) { - const data = JSON.parse(cached) as T; - memory.set(key, { data, expiresAt: Date.now() + ttlMs }); - return data; + // Single-flight: a concurrent request already recomputing this key. + const pending = inFlight.get(key); + if (pending) return (await pending) as T; + + const compute = (async (): Promise => { + // Redis path (shared across instances). + if (redis && redis.status !== "end") { + try { + const cached = await redis.get(key); + if (cached !== null && cached !== undefined) { + const data = JSON.parse(cached) as T; + setMemory(key, { data, expiresAt: Date.now() + ttlMs }); + return data; + } + } catch { + /* fall through to fn */ } - } catch { - /* fall through to fn */ } - } - const data = await fn(); + const data = await fn(); - if (redis && redis.status !== "end") { - try { - await redis.setex(key, ttlSec, JSON.stringify(data)); - } catch { - /* non-critical: memory cache still works */ + if (redis && redis.status !== "end") { + try { + await redis.setex(key, ttlSec, JSON.stringify(data)); + } catch { + /* non-critical: memory cache still works */ + } } - } - memory.set(key, { data, expiresAt: Date.now() + ttlMs }); + setMemory(key, { data, expiresAt: Date.now() + ttlMs }); - return data; + return data; + })().finally(() => { + inFlight.delete(key); + }); + + inFlight.set(key, compute); + return compute; } diff --git a/src/lib/redis-cache.ts b/src/lib/redis-cache.ts index bc279695..9c4ae681 100644 --- a/src/lib/redis-cache.ts +++ b/src/lib/redis-cache.ts @@ -20,3 +20,17 @@ export async function redisCache( export function apiCacheKey(path: string): string { return `api:${path}`; } + +/** + * Deep-convert a payload to a JSON-cache-safe form: BigInt → string (matching + * the apiJson() wire format) and Date → ISO string, since JSON.stringify throws + * on BigInt. Use for payloads cached through redisCache() that are serialised + * straight to a response and never consumed by page logic. + */ +export function cacheSafe(value: T): T { + return JSON.parse( + JSON.stringify(value, (_k, v) => + typeof v === "bigint" ? v.toString() : v, + ), + ) as T; +} diff --git a/src/lib/services/site-settings.ts b/src/lib/services/site-settings.ts index e0fe9242..92cf885a 100644 --- a/src/lib/services/site-settings.ts +++ b/src/lib/services/site-settings.ts @@ -29,6 +29,10 @@ const IS_PRERENDER = class SiteSettings { private cache: { map: Map; expiresAt: number } | null = null; + // Single-flight: one request (re)loads the map, the rest await it — the + // root layout reads settings on every render, so concurrent misses must + // not each hammer Redis/DB (cache-stampede protection). + private inFlight: Promise> | null = null; private async loadFromDb(): Promise> { try { @@ -42,13 +46,7 @@ class SiteSettings { } } - private async load(): Promise> { - if (this.cache !== null) { - if (IS_PRERENDER || this.cache.expiresAt > Date.now()) { - return this.cache.map; - } - } - + private async loadFromCacheOrDb(): Promise> { if (redis) { try { const cached = await redis.get(REDIS_CACHE_KEY); @@ -85,6 +83,22 @@ class SiteSettings { return map; } + private async load(): Promise> { + if (this.cache !== null) { + if (IS_PRERENDER || this.cache.expiresAt > Date.now()) { + return this.cache.map; + } + } + + if (this.inFlight) return this.inFlight; + + const run = this.loadFromCacheOrDb().finally(() => { + this.inFlight = null; + }); + this.inFlight = run; + return run; + } + async getAll(): Promise> { return await this.load(); } @@ -127,6 +141,7 @@ class SiteSettings { async reload(): Promise { this.cache = null; + this.inFlight = null; if (redis) { try { await redis.del(REDIS_CACHE_KEY);