fix(public): distinguish unavailable data from empty results and missing pages
This commit is contained in:
1 parent
db4acbb46e
commit
445ef13846
16 files changed
+524
-85
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
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}>
|
||||
|
||||
+36
-11
@@ -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>
|
||||
{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,7 +426,12 @@ export default async function Home() {
|
||||
/>
|
||||
</SurfaceCard>
|
||||
|
||||
{recentUsers.length > 0 && (
|
||||
{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"
|
||||
@@ -445,6 +461,7 @@ export default async function Home() {
|
||||
))}
|
||||
</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,7 +536,12 @@ export default async function Home() {
|
||||
</div>
|
||||
|
||||
{/* ── Recent photos ── */}
|
||||
{recentPhotos.length > 0 && (
|
||||
{recentPhotos === null ? (
|
||||
<SurfaceCard title={tp("recentPhotos")} bodyClassName="p-4">
|
||||
<PublicLoadError href="/" />
|
||||
</SurfaceCard>
|
||||
) : (
|
||||
recentPhotos.length > 0 && (
|
||||
<Reveal>
|
||||
<SurfaceCard
|
||||
title={tp("recentPhotos")}
|
||||
@@ -547,6 +571,7 @@ export default async function Home() {
|
||||
</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,7 +118,12 @@ export default async function StaffPage() {
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
{teams.length > 0 && (
|
||||
{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")}
|
||||
@@ -157,6 +149,7 @@ export default async function StaffPage() {
|
||||
))}
|
||||
</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