fix(public): distinguish unavailable data from empty results and missing pages
This commit is contained in:
1 parent
db4acbb46e
commit
445ef13846
16 files changed
+608
-169
No files matched your search
@@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import { replyToThread } from "@/actions/social";
|
||||
import Link from "@/components/link";
|
||||
import { PublicLoadError } from "@/components/public/load-error";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
User,
|
||||
} from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { publicReadFailure } from "@/lib/services/public-read";
|
||||
|
||||
type SearchParams = Promise<{ replied?: string; error?: string }>;
|
||||
|
||||
@@ -60,6 +62,8 @@ export default async function GuildForumThreadPage({
|
||||
pinned: number | null;
|
||||
openerId: number | null;
|
||||
} | null = null;
|
||||
let postsFailed = false;
|
||||
let authorsFailed = false;
|
||||
let posts: {
|
||||
id: number;
|
||||
userId: number;
|
||||
@@ -96,9 +100,15 @@ export default async function GuildForumThreadPage({
|
||||
]);
|
||||
guild = guildRow;
|
||||
thread = threadRow;
|
||||
} catch {
|
||||
guild = null;
|
||||
thread = null;
|
||||
} catch (error) {
|
||||
publicReadFailure("guild.thread")(error);
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<ContentCard>
|
||||
<PublicLoadError href={`/guilds/${guildId}/forum/${threadId}`} />
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!guild || !thread) notFound();
|
||||
@@ -122,7 +132,9 @@ export default async function GuildForumThreadPage({
|
||||
asc(GuildsForumsComments.createdAt),
|
||||
asc(GuildsForumsComments.id),
|
||||
);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
publicReadFailure("guild.posts")(error);
|
||||
postsFailed = true;
|
||||
posts = [];
|
||||
}
|
||||
|
||||
@@ -137,7 +149,9 @@ export default async function GuildForumThreadPage({
|
||||
.from(User)
|
||||
.where(inArray(User.id, authorIds))
|
||||
: [];
|
||||
} catch {
|
||||
} catch (error) {
|
||||
publicReadFailure("guild.post-authors")(error);
|
||||
authorsFailed = true;
|
||||
users = [];
|
||||
}
|
||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
@@ -195,10 +209,17 @@ export default async function GuildForumThreadPage({
|
||||
<ContentCard
|
||||
icon="📝"
|
||||
title={t("postsTitle")}
|
||||
subtitle={t("postsSubtitle", { count: posts.length })}
|
||||
padded={posts.length === 0}
|
||||
subtitle={
|
||||
postsFailed ? undefined : t("postsSubtitle", { count: posts.length })
|
||||
}
|
||||
padded={posts.length === 0 || authorsFailed}
|
||||
>
|
||||
{posts.length === 0 ? (
|
||||
{authorsFailed && (
|
||||
<PublicLoadError href={`/guilds/${guild.id}/forum/${thread.id}`} />
|
||||
)}
|
||||
{postsFailed ? (
|
||||
<PublicLoadError href={`/guilds/${guild.id}/forum/${thread.id}`} />
|
||||
) : posts.length === 0 ? (
|
||||
<EmptyState icon="📝">{t("postsEmpty")}</EmptyState>
|
||||
) : (
|
||||
<ul
|
||||
|
||||
@@ -3,9 +3,11 @@ import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { CSSProperties } from "react";
|
||||
import Link from "@/components/link";
|
||||
import { PublicLoadError } from "@/components/public/load-error";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { db, Guilds, GuildsForumsThreads, User } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { publicReadFailure } from "@/lib/services/public-read";
|
||||
|
||||
type SearchParams = Promise<{ posted?: string }>;
|
||||
|
||||
@@ -49,7 +51,7 @@ export default async function GuildForumPage({
|
||||
const guildId = Number(id);
|
||||
if (!Number.isInteger(guildId) || guildId <= 0) notFound();
|
||||
|
||||
// Guild header (read-only). A DB hiccup degrades to notFound rather than 500.
|
||||
// Missing guilds and unavailable guild data have distinct outcomes.
|
||||
let guild: { id: number; name: string; badge: string } | null = null;
|
||||
try {
|
||||
const [row] = await db
|
||||
@@ -62,13 +64,22 @@ export default async function GuildForumPage({
|
||||
.where(eq(Guilds.id, guildId))
|
||||
.limit(1);
|
||||
guild = row ?? null;
|
||||
} catch {
|
||||
guild = null;
|
||||
} catch (error) {
|
||||
publicReadFailure("guild.forum")(error);
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<ContentCard>
|
||||
<PublicLoadError href={`/guilds/${guildId}/forum`} />
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!guild) notFound();
|
||||
|
||||
// Threads for this guild. Pinned first, then most-recently active.
|
||||
let threadsFailed = false;
|
||||
let authorsFailed = false;
|
||||
let threads: ThreadRow[] = [];
|
||||
try {
|
||||
threads = await db
|
||||
@@ -95,7 +106,9 @@ export default async function GuildForumPage({
|
||||
desc(GuildsForumsThreads.id),
|
||||
)
|
||||
.limit(100);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
publicReadFailure("guild.threads")(error);
|
||||
threadsFailed = true;
|
||||
threads = [];
|
||||
}
|
||||
|
||||
@@ -113,7 +126,9 @@ export default async function GuildForumPage({
|
||||
.from(User)
|
||||
.where(inArray(User.id, openerIds))
|
||||
: [];
|
||||
} catch {
|
||||
} catch (error) {
|
||||
publicReadFailure("guild.thread-authors")(error);
|
||||
authorsFailed = true;
|
||||
users = [];
|
||||
}
|
||||
|
||||
@@ -134,7 +149,11 @@ export default async function GuildForumPage({
|
||||
<ContentCard
|
||||
icon="🗣️"
|
||||
title={t("forumTitle", { guild: guild.name || t("unnamedGuild") })}
|
||||
subtitle={`${threads.length} ${threads.length === 1 ? t("threadSingular") : t("threadPlural")}`}
|
||||
subtitle={
|
||||
threadsFailed
|
||||
? undefined
|
||||
: `${threads.length} ${threads.length === 1 ? t("threadSingular") : t("threadPlural")}`
|
||||
}
|
||||
action={
|
||||
<Link
|
||||
href={`/guilds/${guild.id}/forum/new`}
|
||||
@@ -145,8 +164,13 @@ export default async function GuildForumPage({
|
||||
}
|
||||
/>
|
||||
|
||||
<ContentCard padded={threads.length === 0}>
|
||||
{threads.length === 0 ? (
|
||||
<ContentCard padded={threads.length === 0 || authorsFailed}>
|
||||
{authorsFailed && (
|
||||
<PublicLoadError href={`/guilds/${guild.id}/forum`} />
|
||||
)}
|
||||
{threadsFailed ? (
|
||||
<PublicLoadError href={`/guilds/${guild.id}/forum`} />
|
||||
) : threads.length === 0 ? (
|
||||
<EmptyState icon="🗣️">{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<table>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { asc, eq, inArray } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { PublicLoadError } from "@/components/public/load-error";
|
||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { db, Guilds, GuildsMembers, User } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { publicReadFailure } from "@/lib/services/public-read";
|
||||
|
||||
type GuildView = {
|
||||
id: number;
|
||||
@@ -50,14 +52,22 @@ export default async function GuildPage({
|
||||
.where(eq(Guilds.id, guildId))
|
||||
.limit(1);
|
||||
guild = row ?? null;
|
||||
} catch {
|
||||
guild = null;
|
||||
} catch (error) {
|
||||
publicReadFailure("guild.detail")(error);
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<ContentCard>
|
||||
<PublicLoadError href={`/guilds/${guildId}`} />
|
||||
</ContentCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!guild) notFound();
|
||||
|
||||
// Members come from guilds_members; there is no relation in the schema, so we
|
||||
// join the users table manually by id.
|
||||
let membersFailed = false;
|
||||
let memberRows: { userId: number; memberSince: number }[] = [];
|
||||
try {
|
||||
memberRows = await db
|
||||
@@ -69,7 +79,9 @@ export default async function GuildPage({
|
||||
.where(eq(GuildsMembers.guildId, guild.id))
|
||||
.orderBy(asc(GuildsMembers.memberSince))
|
||||
.limit(200);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
publicReadFailure("guild.members")(error);
|
||||
membersFailed = true;
|
||||
memberRows = [];
|
||||
}
|
||||
|
||||
@@ -93,7 +105,9 @@ export default async function GuildPage({
|
||||
.from(User)
|
||||
.where(inArray(User.id, memberIds))
|
||||
: [];
|
||||
} catch {
|
||||
} catch (error) {
|
||||
publicReadFailure("guild.member-users")(error);
|
||||
membersFailed = true;
|
||||
users = [];
|
||||
}
|
||||
|
||||
@@ -159,7 +173,7 @@ export default async function GuildPage({
|
||||
<div className="stat-grid" style={{ marginTop: "1.1rem" }}>
|
||||
<StatBlock
|
||||
icon="👥"
|
||||
value={members.length}
|
||||
value={membersFailed ? "—" : members.length}
|
||||
label={
|
||||
members.length === 1 ? t("memberSingular") : t("memberPlural")
|
||||
}
|
||||
@@ -185,11 +199,18 @@ export default async function GuildPage({
|
||||
<ContentCard
|
||||
icon="👥"
|
||||
title={t("membersTitle")}
|
||||
subtitle={`${members.length} ${members.length === 1 ? t("memberLowerSingular") : t("memberLowerPlural")}`}
|
||||
padded={members.length === 0}
|
||||
subtitle={
|
||||
membersFailed
|
||||
? undefined
|
||||
: `${members.length} ${members.length === 1 ? t("memberLowerSingular") : t("memberLowerPlural")}`
|
||||
}
|
||||
padded={members.length === 0 || membersFailed}
|
||||
>
|
||||
{membersFailed && <PublicLoadError href={`/guilds/${guild.id}`} />}
|
||||
{members.length === 0 ? (
|
||||
<EmptyState icon="👥">{t("noMembers")}</EmptyState>
|
||||
membersFailed ? null : (
|
||||
<EmptyState icon="👥">{t("noMembers")}</EmptyState>
|
||||
)
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
{members.map((m) => (
|
||||
|
||||
@@ -2,9 +2,11 @@ import { desc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { PublicLoadError } from "@/components/public/load-error";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { db, Guilds } from "@/lib/db";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { publicReadFailure } from "@/lib/services/public-read";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.guilds");
|
||||
@@ -26,7 +28,7 @@ type GuildCard = {
|
||||
badge: string;
|
||||
};
|
||||
|
||||
async function getGuilds(): Promise<GuildCard[]> {
|
||||
async function getGuilds(): Promise<GuildCard[] | null> {
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
@@ -38,8 +40,8 @@ async function getGuilds(): Promise<GuildCard[]> {
|
||||
.from(Guilds)
|
||||
.orderBy(desc(Guilds.id))
|
||||
.limit(40);
|
||||
} catch {
|
||||
return [];
|
||||
} catch (error) {
|
||||
return publicReadFailure("guilds")(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +53,10 @@ export default async function GuildsPage() {
|
||||
<main className="page-grid">
|
||||
<ContentCard icon="🚪" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={guilds.length === 0}>
|
||||
{guilds.length === 0 ? (
|
||||
<ContentCard padded={!guilds?.length}>
|
||||
{guilds === null ? (
|
||||
<PublicLoadError href="/guilds" />
|
||||
) : guilds.length === 0 ? (
|
||||
<EmptyState icon="🚪">{t("empty")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
|
||||
@@ -267,7 +267,11 @@ export default async function MePage({
|
||||
actionLabel={t("allFriends")}
|
||||
bodyClassName="p-5"
|
||||
>
|
||||
{onlineFriends.length === 0 ? (
|
||||
{data.friendsUnavailable ? (
|
||||
<p role="status" className="muted">
|
||||
{t("loadError")}
|
||||
</p>
|
||||
) : onlineFriends.length === 0 ? (
|
||||
<p className="muted">{t("noOnlineFriends")}</p>
|
||||
) : (
|
||||
<div className={styles.friends}>
|
||||
@@ -324,13 +328,22 @@ export default async function MePage({
|
||||
{[
|
||||
{
|
||||
label: t("achievements"),
|
||||
value: format.number(userSettings?.achievementScore ?? 0),
|
||||
value: data.userSettingsUnavailable
|
||||
? "—"
|
||||
: format.number(userSettings?.achievementScore ?? 0),
|
||||
},
|
||||
{
|
||||
label: t("respects"),
|
||||
value: format.number(userSettings?.respectsReceived ?? 0),
|
||||
value: data.userSettingsUnavailable
|
||||
? "—"
|
||||
: format.number(userSettings?.respectsReceived ?? 0),
|
||||
},
|
||||
{
|
||||
label: t("friends"),
|
||||
value: data.friendsUnavailable
|
||||
? "—"
|
||||
: format.number(friendCount),
|
||||
},
|
||||
{ label: t("friends"), value: format.number(friendCount) },
|
||||
{ label: t("lastLogin"), value: date(user.lastLogin) },
|
||||
].map((stat) => (
|
||||
<div key={stat.label}>
|
||||
@@ -400,7 +413,11 @@ export default async function MePage({
|
||||
</details>
|
||||
</SurfaceCard>
|
||||
<SurfaceCard title={t("badges")} bodyClassName="p-5">
|
||||
{badges.length === 0 ? (
|
||||
{data.badgesUnavailable ? (
|
||||
<p role="status" className="muted">
|
||||
{t("loadError")}
|
||||
</p>
|
||||
) : badges.length === 0 ? (
|
||||
<p className="muted">{t("noBadges")}</p>
|
||||
) : (
|
||||
<div className={styles.badges}>
|
||||
|
||||
+94
-69
@@ -4,6 +4,8 @@ import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { PublicLoadError } from "@/components/public/load-error";
|
||||
import { publicReadFailure } from "@/lib/services/public-read";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Home",
|
||||
@@ -53,34 +55,36 @@ async function getHotelData() {
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0),
|
||||
).catch(() => 0),
|
||||
).catch(publicReadFailure("home.online")),
|
||||
cached("total_users", 300_000, () =>
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.then((rows) => rows[0]?.total ?? 0),
|
||||
).catch(() => 0),
|
||||
).catch(publicReadFailure("home.users")),
|
||||
cached("total_rooms", 300_000, () =>
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(Rooms)
|
||||
.then((rows) => rows[0]?.total ?? 0),
|
||||
).catch(() => 0),
|
||||
getNewsList(4),
|
||||
).catch(publicReadFailure("home.rooms")),
|
||||
getNewsList(4, { throwOnError: true }).catch(
|
||||
publicReadFailure("home.news"),
|
||||
),
|
||||
cached("home_online_users", 15_000, () =>
|
||||
db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(12),
|
||||
).catch(() => []),
|
||||
).catch(publicReadFailure("home.recent-users")),
|
||||
cached("home_recent_photos", 60_000, () =>
|
||||
db
|
||||
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||
.from(CameraWeb)
|
||||
.orderBy(desc(CameraWeb.timestamp))
|
||||
.limit(4),
|
||||
).catch(() => []),
|
||||
).catch(publicReadFailure("home.photos")),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -100,6 +104,7 @@ export default async function Home() {
|
||||
if (session?.user?.id) redirect("/me");
|
||||
|
||||
const tp = await getTranslations("pages.home");
|
||||
const te = await getTranslations("publicData");
|
||||
const th = await getTranslations("header");
|
||||
const {
|
||||
hotelName,
|
||||
@@ -251,7 +256,11 @@ export default async function Home() {
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
||||
</span>
|
||||
<LiveOnlineCount initial={online} hotelName={hotelName} />
|
||||
{online === null ? (
|
||||
<span>{te("unavailable")}</span>
|
||||
) : (
|
||||
<LiveOnlineCount initial={online} hotelName={hotelName} />
|
||||
)}
|
||||
</div>
|
||||
<h1
|
||||
className="text-2xl font-black leading-[1.05] tracking-tight mb-2 sm:text-3xl md:text-5xl lg:text-6xl"
|
||||
@@ -368,7 +377,7 @@ export default async function Home() {
|
||||
live: true,
|
||||
},
|
||||
{
|
||||
value: articles.length,
|
||||
value: articles?.length ?? null,
|
||||
label: tp("statsArticles"),
|
||||
icon: "/assets/images/icons/article.gif",
|
||||
live: false,
|
||||
@@ -379,7 +388,9 @@ export default async function Home() {
|
||||
className="relative p-3 sm:p-5 text-center transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<div className="mb-0.5 text-2xl sm:text-3xl font-black tracking-tight md:text-4xl">
|
||||
{s.live ? (
|
||||
{s.value === null ? (
|
||||
<span className="text-base">{te("unavailable")}</span>
|
||||
) : s.live ? (
|
||||
<LiveOnlineCounter initial={s.value} />
|
||||
) : (
|
||||
<AnimatedCounter value={s.value} />
|
||||
@@ -415,36 +426,42 @@ export default async function Home() {
|
||||
/>
|
||||
</SurfaceCard>
|
||||
|
||||
{recentUsers.length > 0 && (
|
||||
<SurfaceCard
|
||||
title={tp("recentUsers")}
|
||||
icon="/assets/images/icons/navigation/goody.png"
|
||||
actionHref="/community"
|
||||
actionLabel={tp("allUsers")}
|
||||
bodyClassName="p-3.5 sm:p-4"
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||
{recentUsers.map((u) => (
|
||||
<div
|
||||
key={u.username}
|
||||
className="flex flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 transition-colors hover:bg-[color-mix(in_srgb,var(--color-primary)_5%,transparent)]"
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
alt={u.username}
|
||||
options={{ direction: 2 }}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<span
|
||||
className="max-w-[64px] truncate text-center text-[9px] sm:text-[10px] font-bold leading-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{u.username}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{recentUsers === null ? (
|
||||
<SurfaceCard title={tp("recentUsers")} bodyClassName="p-4">
|
||||
<PublicLoadError href="/" />
|
||||
</SurfaceCard>
|
||||
) : (
|
||||
recentUsers.length > 0 && (
|
||||
<SurfaceCard
|
||||
title={tp("recentUsers")}
|
||||
icon="/assets/images/icons/navigation/goody.png"
|
||||
actionHref="/community"
|
||||
actionLabel={tp("allUsers")}
|
||||
bodyClassName="p-3.5 sm:p-4"
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||
{recentUsers.map((u) => (
|
||||
<div
|
||||
key={u.username}
|
||||
className="flex flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 transition-colors hover:bg-[color-mix(in_srgb,var(--color-primary)_5%,transparent)]"
|
||||
>
|
||||
<UserAvatarThumbnail
|
||||
figure={u.look}
|
||||
alt={u.username}
|
||||
options={{ direction: 2 }}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<span
|
||||
className="max-w-[64px] truncate text-center text-[9px] sm:text-[10px] font-bold leading-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{u.username}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
)
|
||||
)}
|
||||
</Reveal>
|
||||
|
||||
@@ -456,7 +473,9 @@ export default async function Home() {
|
||||
actionHref="/news"
|
||||
actionLabel={tp("allNews")}
|
||||
>
|
||||
{articles.length > 0 ? (
|
||||
{articles === null ? (
|
||||
<PublicLoadError href="/" />
|
||||
) : articles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
{articles.slice(0, 4).map((a) => (
|
||||
<Link
|
||||
@@ -517,36 +536,42 @@ export default async function Home() {
|
||||
</div>
|
||||
|
||||
{/* ── Recent photos ── */}
|
||||
{recentPhotos.length > 0 && (
|
||||
<Reveal>
|
||||
<SurfaceCard
|
||||
title={tp("recentPhotos")}
|
||||
icon="/assets/images/icons/camera.png"
|
||||
>
|
||||
<div className="mx-auto grid max-w-md grid-cols-4 gap-2">
|
||||
{recentPhotos.slice(0, 4).map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href="/photos"
|
||||
className="block aspect-square overflow-hidden rounded-xl border transition-all duration-200 hover:shadow-md hover:scale-[1.02]"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={p.url}
|
||||
alt=""
|
||||
width={200}
|
||||
height={200}
|
||||
className="h-full w-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</Reveal>
|
||||
{recentPhotos === null ? (
|
||||
<SurfaceCard title={tp("recentPhotos")} bodyClassName="p-4">
|
||||
<PublicLoadError href="/" />
|
||||
</SurfaceCard>
|
||||
) : (
|
||||
recentPhotos.length > 0 && (
|
||||
<Reveal>
|
||||
<SurfaceCard
|
||||
title={tp("recentPhotos")}
|
||||
icon="/assets/images/icons/camera.png"
|
||||
>
|
||||
<div className="mx-auto grid max-w-md grid-cols-4 gap-2">
|
||||
{recentPhotos.slice(0, 4).map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href="/photos"
|
||||
className="block aspect-square overflow-hidden rounded-xl border transition-all duration-200 hover:shadow-md hover:scale-[1.02]"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={p.url}
|
||||
alt=""
|
||||
width={200}
|
||||
height={200}
|
||||
className="h-full w-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</Reveal>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { PublicLoadError } from "@/components/public/load-error";
|
||||
import {
|
||||
type LightboxPhoto,
|
||||
PhotoLightbox,
|
||||
@@ -9,6 +10,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { CameraWeb, db } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { publicReadFailure } from "@/lib/services/public-read";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.photos");
|
||||
@@ -34,18 +36,18 @@ type Photo = {
|
||||
export default async function PhotosPage() {
|
||||
const t = await getTranslations("pages.photos");
|
||||
|
||||
let photos: Photo[] = [];
|
||||
let photos: Photo[] | null = [];
|
||||
try {
|
||||
photos = await cached("photos:grid", 60_000, async () =>
|
||||
db.select().from(CameraWeb).orderBy(desc(CameraWeb.timestamp)).limit(48),
|
||||
);
|
||||
} catch {
|
||||
photos = [];
|
||||
} catch (error) {
|
||||
photos = publicReadFailure("photos")(error);
|
||||
}
|
||||
|
||||
// Pre-shape for the client lightbox: translate captions server-side so the
|
||||
// client component stays free of i18n/db dependencies.
|
||||
const items: LightboxPhoto[] = photos.map((p) => ({
|
||||
const items: LightboxPhoto[] = (photos ?? []).map((p) => ({
|
||||
id: String(p.id),
|
||||
url: p.url,
|
||||
alt: t("photoAlt", { id: p.userId }),
|
||||
@@ -58,7 +60,9 @@ export default async function PhotosPage() {
|
||||
<ContentCard icon="📸" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={items.length === 0}>
|
||||
{items.length === 0 ? (
|
||||
{photos === null ? (
|
||||
<PublicLoadError href="/photos" />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState icon="📸">{t("noPhotos")}</EmptyState>
|
||||
) : (
|
||||
<PhotoLightbox photos={items} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { desc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { PublicLoadError } from "@/components/public/load-error";
|
||||
import {
|
||||
ContentCard,
|
||||
EmptyState,
|
||||
@@ -12,6 +13,7 @@ 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";
|
||||
import { publicReadFailure } from "@/lib/services/public-read";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.rankings");
|
||||
@@ -36,7 +38,7 @@ type TopUser = {
|
||||
|
||||
export default async function RankingsPage() {
|
||||
const t = await getTranslations("pages.rankings");
|
||||
let users: TopUser[] = [];
|
||||
let users: TopUser[] | null = [];
|
||||
try {
|
||||
users = await cached("rankings:top", 60_000, async () =>
|
||||
db
|
||||
@@ -51,16 +53,18 @@ export default async function RankingsPage() {
|
||||
.orderBy(desc(User.credits))
|
||||
.limit(12),
|
||||
);
|
||||
} catch {
|
||||
users = [];
|
||||
} catch (error) {
|
||||
users = publicReadFailure("rankings")(error);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="page-grid">
|
||||
<ContentCard icon="🏆" title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<ContentCard padded={users.length === 0}>
|
||||
{users.length === 0 ? (
|
||||
<ContentCard padded={!users?.length}>
|
||||
{users === null ? (
|
||||
<PublicLoadError href="/rankings" />
|
||||
) : users.length === 0 ? (
|
||||
<EmptyState icon="🏆">{t("emptyState")}</EmptyState>
|
||||
) : (
|
||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { asc, desc, eq, gte } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { PublicLoadError } from "@/components/public/load-error";
|
||||
import { ProfileImage } from "@/components/shared/profile-image";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { db, User, WebsiteTeams } from "@/lib/db";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { publicReadFailure } from "@/lib/services/public-read";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import styles from "./staff.module.css";
|
||||
|
||||
@@ -22,21 +24,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
};
|
||||
}
|
||||
|
||||
type StaffMember = {
|
||||
username: string;
|
||||
look: string;
|
||||
rank: number;
|
||||
motto: string;
|
||||
};
|
||||
|
||||
type Team = {
|
||||
id: string;
|
||||
rankName: string;
|
||||
badge: string | null;
|
||||
jobDescription: string | null;
|
||||
staffColor: string;
|
||||
};
|
||||
|
||||
export default async function StaffPage() {
|
||||
const t = await getTranslations("pages.staff");
|
||||
const minStaffRank =
|
||||
@@ -55,7 +42,7 @@ export default async function StaffPage() {
|
||||
.where(gte(User.rank, minStaffRank))
|
||||
.orderBy(desc(User.rank), asc(User.username))
|
||||
.limit(100)
|
||||
.catch((): StaffMember[] | null => null),
|
||||
.catch(publicReadFailure("staff.members")),
|
||||
db
|
||||
.select({
|
||||
id: WebsiteTeams.id,
|
||||
@@ -68,7 +55,7 @@ export default async function StaffPage() {
|
||||
.where(eq(WebsiteTeams.hiddenRank, false))
|
||||
.orderBy(asc(WebsiteTeams.id))
|
||||
.then((rows) => rows.map((row) => ({ ...row, id: String(row.id) })))
|
||||
.catch((): Team[] => []),
|
||||
.catch(publicReadFailure("staff.teams")),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -131,32 +118,38 @@ export default async function StaffPage() {
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
{teams.length > 0 && (
|
||||
<section aria-labelledby="staff-teams">
|
||||
<h2 id="staff-teams" className={styles.heading}>
|
||||
{t("ranksTitle")}
|
||||
</h2>
|
||||
<p className={styles.subtitle}>{t("ranksSubtitle")}</p>
|
||||
<ul className={styles.grid}>
|
||||
{teams.map((team) => (
|
||||
<li key={team.id}>
|
||||
<SurfaceCard className={styles.team}>
|
||||
<span
|
||||
className={styles.accent}
|
||||
style={{
|
||||
backgroundColor: /^#[0-9a-f]{6}$/i.test(team.staffColor)
|
||||
? team.staffColor
|
||||
: "var(--color-primary)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3>{team.rankName}</h3>
|
||||
{team.jobDescription && <p>{team.jobDescription}</p>}
|
||||
</SurfaceCard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
{teams === null ? (
|
||||
<SurfaceCard className={styles.notice}>
|
||||
<PublicLoadError href="/staff" />
|
||||
</SurfaceCard>
|
||||
) : (
|
||||
teams.length > 0 && (
|
||||
<section aria-labelledby="staff-teams">
|
||||
<h2 id="staff-teams" className={styles.heading}>
|
||||
{t("ranksTitle")}
|
||||
</h2>
|
||||
<p className={styles.subtitle}>{t("ranksSubtitle")}</p>
|
||||
<ul className={styles.grid}>
|
||||
{teams.map((team) => (
|
||||
<li key={team.id}>
|
||||
<SurfaceCard className={styles.team}>
|
||||
<span
|
||||
className={styles.accent}
|
||||
style={{
|
||||
backgroundColor: /^#[0-9a-f]{6}$/i.test(team.staffColor)
|
||||
? team.staffColor
|
||||
: "var(--color-primary)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3>{team.rankName}</h3>
|
||||
{team.jobDescription && <p>{team.jobDescription}</p>}
|
||||
</SurfaceCard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function PublicLoadError({ href }: { href: string }) {
|
||||
const t = useTranslations("publicData");
|
||||
return (
|
||||
<div role="alert" className="flex flex-wrap items-center gap-3">
|
||||
<p>{t("loadError")}</p>
|
||||
<a href={href} className="btn btn-outline">
|
||||
{t("retry")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { createTranslator, NextIntlClientProvider } from "next-intl";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import GuildThread from "@/app/(site)/guilds/[id]/forum/[threadId]/page";
|
||||
import GuildForum from "@/app/(site)/guilds/[id]/forum/page";
|
||||
import GuildDetail from "@/app/(site)/guilds/[id]/page";
|
||||
import messages from "@/messages/en.json";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
fail: new Set<string>(),
|
||||
rows: {} as Record<string, unknown[]>,
|
||||
log: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: () => {
|
||||
throw Error("NOT_FOUND");
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth", () => ({ auth: async () => null }));
|
||||
vi.mock("@/actions/social", () => ({ replyToThread: async () => {} }));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cached: (_key: string, _ttl: number, load: () => unknown) => load(),
|
||||
}));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { get: async () => "7" },
|
||||
}));
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: async (
|
||||
namespace:
|
||||
| "pages.staff"
|
||||
| "pages.rankings"
|
||||
| "pages.photos"
|
||||
| "pages.guilds"
|
||||
| "pages.guild"
|
||||
| "pages.guildForum"
|
||||
| "pages.guildForumThread",
|
||||
) => createTranslator({ locale: "en", messages, namespace }),
|
||||
}));
|
||||
vi.mock("@/components/shared/profile-image", () => ({
|
||||
ProfileImage: () => null,
|
||||
}));
|
||||
vi.mock("@/components/shared/user-avatar-thumbnail", () => ({
|
||||
UserAvatarThumbnail: () => null,
|
||||
}));
|
||||
vi.mock("@/components/public/photo-lightbox", () => ({
|
||||
PhotoLightbox: () => null,
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { getTableName } = await import("drizzle-orm");
|
||||
return {
|
||||
...schema,
|
||||
db: {
|
||||
select: () => {
|
||||
let name = "";
|
||||
const load = async () => {
|
||||
if (state.fail.has(name)) throw Error("fixture unavailable");
|
||||
if (state.rows[name]) return state.rows[name];
|
||||
return name === "users"
|
||||
? [
|
||||
{
|
||||
username: "Available member",
|
||||
look: "",
|
||||
rank: 7,
|
||||
motto: "Hello",
|
||||
credits: 100,
|
||||
online: "1",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
};
|
||||
const query = {
|
||||
from: (table: Parameters<typeof getTableName>[0]) => {
|
||||
name = getTableName(table);
|
||||
return query;
|
||||
},
|
||||
where: () => query,
|
||||
orderBy: () => query,
|
||||
limit: () => query,
|
||||
// biome-ignore lint/suspicious/noThenProperty: models Drizzle lazy query execution
|
||||
then: (...args: Parameters<ReturnType<typeof load>["then"]>) =>
|
||||
load().then(...args),
|
||||
catch: (...args: Parameters<ReturnType<typeof load>["catch"]>) =>
|
||||
load().catch(...args),
|
||||
};
|
||||
return query;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import Guilds from "@/app/(site)/guilds/page";
|
||||
import Photos from "@/app/(site)/photos/page";
|
||||
import Rankings from "@/app/(site)/rankings/page";
|
||||
import Staff from "@/app/(site)/staff/page";
|
||||
import { publicReadFailure } from "@/lib/services/public-read";
|
||||
|
||||
const copy = {
|
||||
loadError: "Section unavailable",
|
||||
retry: "Retry",
|
||||
unavailable: "Unavailable",
|
||||
};
|
||||
async function render(page: () => Promise<React.ReactNode>) {
|
||||
return renderToStaticMarkup(
|
||||
<NextIntlClientProvider
|
||||
locale="en"
|
||||
messages={{ ...messages, publicData: copy }}
|
||||
>
|
||||
{await page()}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
describe("public page read failures", () => {
|
||||
beforeEach(() => {
|
||||
state.fail.clear();
|
||||
state.rows = {};
|
||||
state.log.mockClear();
|
||||
});
|
||||
it.each([
|
||||
[Rankings, "users"],
|
||||
[Photos, "camera_web"],
|
||||
[Guilds, "guilds"],
|
||||
] as const)(
|
||||
"shows failure and retry instead of empty results",
|
||||
async (page, table) => {
|
||||
state.fail.add(table);
|
||||
const html = await render(page);
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("Section unavailable");
|
||||
expect(html).toContain("Retry");
|
||||
expect(state.log).toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it.each([Photos, Guilds])(
|
||||
"keeps genuine empty results distinct",
|
||||
async (page) => {
|
||||
const html = await render(page);
|
||||
expect(html).not.toContain('role="alert"');
|
||||
expect(state.log).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("preserves staff members when team metadata fails", async () => {
|
||||
state.fail.add("website_teams");
|
||||
const html = await render(Staff);
|
||||
expect(html).toContain("Available member");
|
||||
expect(html).toContain("Section unavailable");
|
||||
expect(state.log).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ section: "staff.teams" }),
|
||||
);
|
||||
});
|
||||
it("returns null and logs section context without changing successful values", async () => {
|
||||
const fail = publicReadFailure("home.news");
|
||||
const error = Error("unavailable");
|
||||
expect(await Promise.reject(error).catch(fail)).toBeNull();
|
||||
expect(await Promise.resolve([]).catch(fail)).toEqual([]);
|
||||
expect(await Promise.resolve(0).catch(fail)).toBe(0);
|
||||
expect(state.log).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
const detailPages = [
|
||||
() => GuildDetail({ params: Promise.resolve({ id: "1" }) }),
|
||||
() =>
|
||||
GuildForum({
|
||||
params: Promise.resolve({ id: "1" }),
|
||||
searchParams: Promise.resolve({}),
|
||||
}),
|
||||
() =>
|
||||
GuildThread({
|
||||
params: Promise.resolve({ id: "1", threadId: "2" }),
|
||||
searchParams: Promise.resolve({}),
|
||||
}),
|
||||
];
|
||||
describe("guild detail failures", () => {
|
||||
beforeEach(() => {
|
||||
state.fail.clear();
|
||||
state.log.mockClear();
|
||||
state.rows = {
|
||||
guilds: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Available guild",
|
||||
description: "Community",
|
||||
badge: "",
|
||||
userId: 7,
|
||||
dateCreated: 1000,
|
||||
},
|
||||
],
|
||||
guilds_forums_threads: [
|
||||
{
|
||||
id: 2,
|
||||
subject: "Available thread",
|
||||
locked: 0,
|
||||
pinned: 0,
|
||||
openerId: 7,
|
||||
postsCount: 1,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
},
|
||||
],
|
||||
guilds_forums_comments: [
|
||||
{ id: 3, userId: 7, message: "Available post", createdAt: 1000 },
|
||||
],
|
||||
};
|
||||
});
|
||||
it.each(detailPages)(
|
||||
"shows database failures without claiming a missing guild",
|
||||
async (page) => {
|
||||
state.fail.add("guilds");
|
||||
const html = await render(page);
|
||||
expect(html).toContain("Section unavailable");
|
||||
},
|
||||
);
|
||||
it.each(detailPages)("keeps real missing guilds as 404", async (page) => {
|
||||
state.rows.guilds = [];
|
||||
await expect(render(page)).rejects.toThrow("NOT_FOUND");
|
||||
});
|
||||
it("keeps guild metadata when members fail and avoids a false zero", async () => {
|
||||
state.fail.add("guilds_members");
|
||||
const html = await render(detailPages[0]);
|
||||
expect(html).toContain("Available guild");
|
||||
expect(html).toContain("Section unavailable");
|
||||
expect(html).toContain("—");
|
||||
});
|
||||
it("keeps forum threads when author names fail", async () => {
|
||||
state.fail.add("users");
|
||||
const html = await render(detailPages[1]);
|
||||
expect(html).toContain("Available thread");
|
||||
expect(html).toContain("Section unavailable");
|
||||
});
|
||||
it("keeps the thread header when posts fail", async () => {
|
||||
state.fail.add("guilds_forums_comments");
|
||||
const html = await render(detailPages[2]);
|
||||
expect(html).toContain("Available thread");
|
||||
expect(html).toContain("Section unavailable");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ fail: false }));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("@/lib/services/news-cache", () => ({
|
||||
cacheNews: async () => {
|
||||
if (state.fail) throw Error("unavailable");
|
||||
return [];
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({ ...(await import("@/db/schema")), db: {} }));
|
||||
|
||||
import { getNewsList } from "./news-list";
|
||||
|
||||
beforeEach(() => {
|
||||
state.fail = false;
|
||||
});
|
||||
it("preserves an empty successful list in strict mode", async () => {
|
||||
expect(await getNewsList(4, { throwOnError: true })).toEqual([]);
|
||||
});
|
||||
it("lets the homepage distinguish a failed news section", async () => {
|
||||
state.fail = true;
|
||||
await expect(getNewsList(4, { throwOnError: true })).rejects.toThrow(
|
||||
"unavailable",
|
||||
);
|
||||
});
|
||||
it("preserves the legacy fallback for other callers", async () => {
|
||||
state.fail = true;
|
||||
expect(await getNewsList(4)).toEqual([]);
|
||||
});
|
||||
@@ -26,12 +26,15 @@ const CACHE_KEY = "news_list";
|
||||
const FETCH_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Shared, cached news list used by both the homepage and the news archive so
|
||||
* Shared, cached news preview list used by the homepage and other widgets so
|
||||
* a single "news_list" cache entry serves both routes. Returns at most
|
||||
* `limit` rows with `createdAt` normalized to a Date (the cache round-trip
|
||||
* serializes timestamps to ISO strings).
|
||||
*/
|
||||
export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
||||
export async function getNewsList(
|
||||
limit: number,
|
||||
options: { throwOnError?: boolean } = {},
|
||||
): Promise<NewsListItem[]> {
|
||||
try {
|
||||
const rows = await cacheNews<NewsListRow[]>(CACHE_KEY, TTL_MS, () =>
|
||||
db
|
||||
@@ -62,6 +65,7 @@ export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
||||
}))
|
||||
.slice(0, limit);
|
||||
} catch (error) {
|
||||
if (options.throwOnError) throw error;
|
||||
logger.error("Public news query failed", { module: "news", error });
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
fail: false,
|
||||
friends: [] as { id: number; username: string; online: string }[],
|
||||
}));
|
||||
vi.mock("@/lib/hotel-name", () => ({ resolveHotelName: async () => "Hotel" }));
|
||||
@@ -30,7 +31,8 @@ vi.mock("@/lib/db", async () => {
|
||||
innerJoin: () => chain,
|
||||
orderBy: () => chain,
|
||||
limit: () => chain,
|
||||
catch: () => Promise.resolve(rows),
|
||||
catch: (fallback: () => unknown) =>
|
||||
Promise.resolve(state.fail ? fallback() : rows),
|
||||
// biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are intentionally awaitable.
|
||||
then: (resolve: (value: unknown[]) => unknown) =>
|
||||
Promise.resolve(rows).then(resolve),
|
||||
@@ -46,6 +48,17 @@ import { loadUserDashboard } from "./user-dashboard";
|
||||
describe("dashboard friendships", () => {
|
||||
beforeEach(() => {
|
||||
state.friends = [];
|
||||
state.fail = false;
|
||||
});
|
||||
it("marks unavailable sections instead of presenting them as empty data", async () => {
|
||||
state.fail = true;
|
||||
const data = await loadUserDashboard(1);
|
||||
expect(data).toMatchObject({
|
||||
friendsUnavailable: true,
|
||||
badgesUnavailable: true,
|
||||
userSettingsUnavailable: true,
|
||||
referralsRows: null,
|
||||
});
|
||||
});
|
||||
it("counts each friend once when both directional rows are returned", async () => {
|
||||
const wait = { id: 2, username: "wait", online: "1" };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { publicReadFailure } from "./public-read";
|
||||
import "server-only";
|
||||
import { and, asc, count, desc, eq, gt, inArray, or } from "drizzle-orm";
|
||||
import {
|
||||
@@ -65,7 +66,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
.from(Rooms)
|
||||
.orderBy(desc(Rooms.id))
|
||||
.limit(6)
|
||||
.catch(() => null),
|
||||
.catch(publicReadFailure("dashboard.rooms")),
|
||||
db
|
||||
.select({
|
||||
badgeCode: UsersBadges.badgeCode,
|
||||
@@ -74,7 +75,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
.from(UsersBadges)
|
||||
.where(and(eq(UsersBadges.userId, userId), gt(UsersBadges.slotId, 0)))
|
||||
.orderBy(asc(UsersBadges.slotId))
|
||||
.catch(() => []),
|
||||
.catch(publicReadFailure("dashboard.badges")),
|
||||
db
|
||||
.select({
|
||||
achievementScore: UsersSettings.achievementScore,
|
||||
@@ -83,19 +84,19 @@ export async function loadUserDashboard(userId: number) {
|
||||
.from(UsersSettings)
|
||||
.where(eq(UsersSettings.userId, userId))
|
||||
.limit(1)
|
||||
.catch(() => []),
|
||||
.catch(publicReadFailure("dashboard.settings")),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(MessengerOffline)
|
||||
.where(eq(MessengerOffline.userId, userId))
|
||||
.catch(() => null),
|
||||
.catch(publicReadFailure("dashboard.unread")),
|
||||
db
|
||||
.select({ referralsTotal: UserReferrals.referralsTotal })
|
||||
.from(UserReferrals)
|
||||
.where(eq(UserReferrals.userId, userId))
|
||||
.orderBy(desc(UserReferrals.id))
|
||||
.limit(1)
|
||||
.catch(() => []),
|
||||
.catch(publicReadFailure("dashboard.referrals")),
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
@@ -124,7 +125,7 @@ export async function loadUserDashboard(userId: number) {
|
||||
eq(MessengerFriendships.userTwoId, userId),
|
||||
),
|
||||
)
|
||||
.catch(() => []),
|
||||
.catch(publicReadFailure("dashboard.friends")),
|
||||
db
|
||||
.select({ type: UsersCurrency.type, amount: UsersCurrency.amount })
|
||||
.from(UsersCurrency)
|
||||
@@ -134,13 +135,13 @@ export async function loadUserDashboard(userId: number) {
|
||||
inArray(UsersCurrency.type, [0, 5]),
|
||||
),
|
||||
)
|
||||
.catch(() => null),
|
||||
.catch(publicReadFailure("dashboard.currency")),
|
||||
]);
|
||||
|
||||
// Each friendship can have two directional rows; expose users, not edges.
|
||||
const uniqueFriends = [
|
||||
...new Map(
|
||||
friends
|
||||
(friends ?? [])
|
||||
.filter((friend) => friend.id !== userId)
|
||||
.map((friend) => [friend.id, friend]),
|
||||
).values(),
|
||||
@@ -155,8 +156,11 @@ export async function loadUserDashboard(userId: number) {
|
||||
currencySettings,
|
||||
alertRaw,
|
||||
recentRooms,
|
||||
badges,
|
||||
userSettingsRows,
|
||||
badges: badges ?? [],
|
||||
badgesUnavailable: badges === null,
|
||||
userSettingsRows: userSettingsRows ?? [],
|
||||
userSettingsUnavailable: userSettingsRows === null,
|
||||
friendsUnavailable: friends === null,
|
||||
friendCountRows: [{ value: uniqueFriends.length }],
|
||||
unreadCountRows,
|
||||
referralsRows,
|
||||
|
||||
Reference in new issue
Block a user