fix(public): distinguish unavailable data from empty results and missing pages
CI / check (push) Successful in 56s
CI / deploy (push) Successful in 1m30s
CI / publish-container (push) Successful in 44s

This commit is contained in:
Simo committed 2026-09-11 00:39:53 +02:00
1 parent db4acbb46e
commit 445ef13846
16 files changed
+608 -169

No files matched your search

+22
View File
@@ -114,3 +114,25 @@ change is required. Browser checks use real components with simulated data at
New UI copy is supplied in English, Italian and Dutch; other locales receive English fallback strings. No new runtime dependencies. Browser fixtures use real UI components with simulated server responses; the database migration and production behavior have not been exercised on the live hotel.
Validation for this increment: 1,589 tests passed, six skipped; TypeScript, Biome, translation contracts and fixture production build passed. Browser checks covered user/settings/news forms, permission preview, CMS errors, integrity preview and history restore at 1280 and 390 pixels. Double submission, stale preview, blocked navigation, field focus and horizontal overflow were checked with simulated server actions. No live database writes or deployment were performed.
## September 11: public pages and staff workflows
- Docker stages now follow the exact `.nvmrc` release, enforced by the toolchain check.
- `/news` supports search, ordering by effective publication date and real pagination.
- `/events` supports upcoming/ongoing/completed filters, explicit UTC week windows, local displayed times and personal registrations.
- `/search` searches users, open rooms, published news and events with independent pagination and partial failure states.
- `/me` shows support replies, incoming friend requests, the next registered event and available referral rewards. Reply availability does not claim unread status.
- Profile privacy is managed in `/settings`. Wallet values are private by default; visitors do not receive hidden sections in HTML. Photo galleries initially show six photos and can expand to the loaded limit of 24.
- Ticket desks support waiting-for-staff and assignment filters, with elapsed time since the latest reply.
- HK table views save filters, order and visible columns per account and table (maximum 20). Existing session column preferences remain available until a named view is applied.
- Official and clone synchronization run through the existing durable import queue. Reloading restores history; interrupted uncertain writes still require inspection before repair. Successful items are not repeated.
- Publication preflight validates URL syntax/protocols and schedules, shows affected page links and keeps existing article previews. It does not claim remote URLs are reachable. Drafts remain savable. Partial event updates preserve omitted fields.
- Admin APIs return `x-operation-id`; server errors, staff audit records and import jobs share correlation context. Error and audit screens link to each other. Older records without this context remain readable.
- Public reads distinguish unavailability from empty results and real 404s, preserving independently available sections on home, dashboard, staff, photos, rankings and groups/forums.
### Data and verification
Additive migrations `0029_admin_table_views.sql` and `0030_profile_privacy.sql` run through the existing deployment migration runner. They create CMS-owned tables and do not change emulator user settings. Keep the existing shared storage volume and background jobs worker for durable imports.
Browser verification used real components with controlled data fixtures at 1280 and 390 pixels, including failure and partial-result cases. Production compilation and full lint were checked locally. No production content was created during those checks; real authenticated content and external source availability remain environment-dependent.
@@ -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
+32 -8
View File
@@ -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>
+29 -8
View File
@@ -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) => (
+9 -5
View File
@@ -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" }}>
+22 -5
View File
@@ -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
View File
@@ -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>
);
+9 -5
View File
@@ -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} />
+9 -5
View File
@@ -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" }}>
+36 -43
View File
@@ -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>
);
+14
View File
@@ -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>
);
}
+239
View File
@@ -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");
});
});
+30
View File
@@ -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([]);
});
+6 -2
View File
@@ -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 [];
}
+14 -1
View File
@@ -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" };
+14 -10
View File
@@ -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,