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

+29 -16
View File
@@ -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 });
+36 -26
View File
@@ -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(
+214 -150
View File
@@ -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<BadgeLeaderboardEntry>;
}
/** 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<T>(query: ReturnType<typeof sql>): Promise<T[]> {
return rows;
}
async function loadTotalBadgesBoard(
userId: number | null,
): Promise<BadgeLeaderboardBoard> {
async function loadTotalBadgesBoard(): Promise<BoardCore> {
const rows = await rawRows<{
userId: bigint;
username: string;
@@ -103,48 +108,10 @@ async function loadTotalBadgesBoard(
)
: 0;
let viewerEntry: Partial<BadgeLeaderboardEntry> | 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<BadgeLeaderboardBoard> {
async function loadAchievementBoard(): Promise<BoardCore> {
const rows = await rawRows<{
userId: bigint;
username: string;
@@ -179,46 +146,10 @@ async function loadAchievementBoard(
)
: 0;
let viewerEntry: Partial<BadgeLeaderboardEntry> | 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<BadgeLeaderboardBoard> {
async function loadRarityBoard(badgeCodes: string[]): Promise<BoardCore> {
if (badgeCodes.length === 0) return { entries: [], totalPlayers: 0 };
const codes = sql.join(
@@ -262,41 +193,111 @@ async function loadRarityBoard(
)
: 0;
let viewerEntry: Partial<BadgeLeaderboardEntry> | 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<Partial<BadgeLeaderboardEntry> | 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<Partial<BadgeLeaderboardEntry> | 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<Partial<BadgeLeaderboardEntry> | 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<BadgeRarityKey, string[]>();
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<BadgeRarityKey, BadgeLeaderboardBoard>;
const badgeStats: BadgeLeaderboardStat[] = badgeStatsRaw.map((r) => ({
badgeCode: r.badgeCode,
ownerCount: Number(r.ownerCount),
rarity: assignRarity(Number(r.ownerCount)),
}));
const badgesByRarity = new Map<BadgeRarityKey, string[]>();
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<BadgeRarityKey, BoardCore>;
return cacheSafe({ badgeStats, totalBadges, achievementLevel, rarity });
},
);
// Per-viewer rank entries (only when a user is signed in).
let totalBadgesViewer: Partial<BadgeLeaderboardEntry> | undefined;
let achievementViewer: Partial<BadgeLeaderboardEntry> | undefined;
let rarityViewer: Record<
BadgeRarityKey,
Partial<BadgeLeaderboardEntry> | undefined
> = {
common: undefined,
rare: undefined,
epic: undefined,
legendary: undefined,
mythical: undefined,
unique: undefined,
};
if (userId) {
const badgesByRarity = new Map<BadgeRarityKey, string[]>();
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<BadgeRarityKey, Partial<BadgeLeaderboardEntry> | 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<BadgeRarityKey, BadgeLeaderboardBoard>,
},
});
} catch (err) {
logger.error("Badge leaderboard error", {
+41 -28
View File
@@ -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 });
+39 -28
View File
@@ -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(
+6 -2
View File
@@ -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 {
+10 -5
View File
@@ -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 {
+34 -24
View File
@@ -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(
+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 {
+21 -13
View File
@@ -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 {
+49 -39
View File
@@ -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(
+14 -11
View File
@@ -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 {
+16 -11
View File
@@ -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 });
+35 -22
View File
@@ -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 });
+55 -42
View File
@@ -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 });
+20 -12
View File
@@ -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 {
+39 -29
View File
@@ -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(