Add performance optimizations and component refactors
CI / check (push) Successful in 34s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m23s

- 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
This commit is contained in:
openhands committed 2026-08-17 22:02:21 +02:00
1 parent 54f2bc5e0c
commit f285a7cd98
42 files changed
+1616 -1205

No files matched your search

+28 -15
View File
@@ -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 });
+39 -35
View File
@@ -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 {
+44 -31
View File
@@ -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 {