diff --git a/docs/cms-upgrade-2026-09.md b/docs/cms-upgrade-2026-09.md index f91f845e..3cdc53b1 100644 --- a/docs/cms-upgrade-2026-09.md +++ b/docs/cms-upgrade-2026-09.md @@ -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. diff --git a/src/app/(site)/guilds/[id]/forum/[threadId]/page.tsx b/src/app/(site)/guilds/[id]/forum/[threadId]/page.tsx index fe0fb2c4..f5dc495a 100644 --- a/src/app/(site)/guilds/[id]/forum/[threadId]/page.tsx +++ b/src/app/(site)/guilds/[id]/forum/[threadId]/page.tsx @@ -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 ( +
+ + + +
+ ); } 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({ - {posts.length === 0 ? ( + {authorsFailed && ( + + )} + {postsFailed ? ( + + ) : posts.length === 0 ? ( {t("postsEmpty")} ) : (
    ; @@ -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 ( +
    + + + +
    + ); } 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({ - - {threads.length === 0 ? ( + + {authorsFailed && ( + + )} + {threadsFailed ? ( + + ) : threads.length === 0 ? ( {t("empty")} ) : ( diff --git a/src/app/(site)/guilds/[id]/page.tsx b/src/app/(site)/guilds/[id]/page.tsx index 40ea33c1..281b3f8c 100644 --- a/src/app/(site)/guilds/[id]/page.tsx +++ b/src/app/(site)/guilds/[id]/page.tsx @@ -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 ( +
    + + + +
    + ); } 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({
    + {membersFailed && } {members.length === 0 ? ( - {t("noMembers")} + membersFailed ? null : ( + {t("noMembers")} + ) ) : (
    {members.map((m) => ( diff --git a/src/app/(site)/guilds/page.tsx b/src/app/(site)/guilds/page.tsx index 8481b979..f700f58a 100644 --- a/src/app/(site)/guilds/page.tsx +++ b/src/app/(site)/guilds/page.tsx @@ -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 { const t = await getTranslations("pages.guilds"); @@ -26,7 +28,7 @@ type GuildCard = { badge: string; }; -async function getGuilds(): Promise { +async function getGuilds(): Promise { try { return await db .select({ @@ -38,8 +40,8 @@ async function getGuilds(): Promise { .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() {
    - - {guilds.length === 0 ? ( + + {guilds === null ? ( + + ) : guilds.length === 0 ? ( {t("empty")} ) : (
    diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index 25939a9f..306028e9 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -267,7 +267,11 @@ export default async function MePage({ actionLabel={t("allFriends")} bodyClassName="p-5" > - {onlineFriends.length === 0 ? ( + {data.friendsUnavailable ? ( +

    + {t("loadError")} +

    + ) : onlineFriends.length === 0 ? (

    {t("noOnlineFriends")}

    ) : (
    @@ -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) => (
    @@ -400,7 +413,11 @@ export default async function MePage({ - {badges.length === 0 ? ( + {data.badgesUnavailable ? ( +

    + {t("loadError")} +

    + ) : badges.length === 0 ? (

    {t("noBadges")}

    ) : (
    diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index 8572b8c8..77e7c6f8 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -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() { - + {online === null ? ( + {te("unavailable")} + ) : ( + + )}

    - {s.live ? ( + {s.value === null ? ( + {te("unavailable")} + ) : s.live ? ( ) : ( @@ -415,36 +426,42 @@ export default async function Home() { /> - {recentUsers.length > 0 && ( - -
    - {recentUsers.map((u) => ( -
    - - - {u.username} - -
    - ))} -
    + {recentUsers === null ? ( + + + ) : ( + recentUsers.length > 0 && ( + +
    + {recentUsers.map((u) => ( +
    + + + {u.username} + +
    + ))} +
    +
    + ) )} @@ -456,7 +473,9 @@ export default async function Home() { actionHref="/news" actionLabel={tp("allNews")} > - {articles.length > 0 ? ( + {articles === null ? ( + + ) : articles.length > 0 ? (
    {articles.slice(0, 4).map((a) => ( {/* ── Recent photos ── */} - {recentPhotos.length > 0 && ( - - -
    - {recentPhotos.slice(0, 4).map((p) => ( - - - - ))} -
    -
    -
    + {recentPhotos === null ? ( + + + + ) : ( + recentPhotos.length > 0 && ( + + +
    + {recentPhotos.slice(0, 4).map((p) => ( + + + + ))} +
    +
    +
    + ) )}
    ); diff --git a/src/app/(site)/photos/page.tsx b/src/app/(site)/photos/page.tsx index e9f59f0a..77dac002 100644 --- a/src/app/(site)/photos/page.tsx +++ b/src/app/(site)/photos/page.tsx @@ -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 { 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() { - {items.length === 0 ? ( + {photos === null ? ( + + ) : items.length === 0 ? ( {t("noPhotos")} ) : ( diff --git a/src/app/(site)/rankings/page.tsx b/src/app/(site)/rankings/page.tsx index 7c0c6872..204cc126 100644 --- a/src/app/(site)/rankings/page.tsx +++ b/src/app/(site)/rankings/page.tsx @@ -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 { 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 (
    - - {users.length === 0 ? ( + + {users === null ? ( + + ) : users.length === 0 ? ( {t("emptyState")} ) : (
    diff --git a/src/app/(site)/staff/page.tsx b/src/app/(site)/staff/page.tsx index 80a5b19a..e6426b71 100644 --- a/src/app/(site)/staff/page.tsx +++ b/src/app/(site)/staff/page.tsx @@ -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 { }; } -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() { )} - {teams.length > 0 && ( -
    -

    - {t("ranksTitle")} -

    -

    {t("ranksSubtitle")}

    -
      - {teams.map((team) => ( -
    • - - -
    • - ))} -
    -
    + {teams === null ? ( + + + + ) : ( + teams.length > 0 && ( +
    +

    + {t("ranksTitle")} +

    +

    {t("ranksSubtitle")}

    +
      + {teams.map((team) => ( +
    • + + +
    • + ))} +
    +
    + ) )}
    ); diff --git a/src/components/public/load-error.tsx b/src/components/public/load-error.tsx new file mode 100644 index 00000000..4a456959 --- /dev/null +++ b/src/components/public/load-error.tsx @@ -0,0 +1,14 @@ +"use client"; +import { useTranslations } from "next-intl"; + +export function PublicLoadError({ href }: { href: string }) { + const t = useTranslations("publicData"); + return ( +
    +

    {t("loadError")}

    + + {t("retry")} + +
    + ); +} diff --git a/src/lib/public-pages-errors.test.tsx b/src/lib/public-pages-errors.test.tsx new file mode 100644 index 00000000..9ce1cf13 --- /dev/null +++ b/src/lib/public-pages-errors.test.tsx @@ -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(), + rows: {} as Record, + 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[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["then"]>) => + load().then(...args), + catch: (...args: Parameters["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) { + return renderToStaticMarkup( + + {await page()} + , + ); +} +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"); + }); +}); diff --git a/src/lib/services/news-list.test.ts b/src/lib/services/news-list.test.ts new file mode 100644 index 00000000..f467fcd6 --- /dev/null +++ b/src/lib/services/news-list.test.ts @@ -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([]); +}); diff --git a/src/lib/services/news-list.ts b/src/lib/services/news-list.ts index 498ccd67..1dd0c2be 100644 --- a/src/lib/services/news-list.ts +++ b/src/lib/services/news-list.ts @@ -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 { +export async function getNewsList( + limit: number, + options: { throwOnError?: boolean } = {}, +): Promise { try { const rows = await cacheNews(CACHE_KEY, TTL_MS, () => db @@ -62,6 +65,7 @@ export async function getNewsList(limit: number): Promise { })) .slice(0, limit); } catch (error) { + if (options.throwOnError) throw error; logger.error("Public news query failed", { module: "news", error }); return []; } diff --git a/src/lib/services/user-dashboard.test.ts b/src/lib/services/user-dashboard.test.ts index 6a483fdb..82d16701 100644 --- a/src/lib/services/user-dashboard.test.ts +++ b/src/lib/services/user-dashboard.test.ts @@ -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" }; diff --git a/src/lib/services/user-dashboard.ts b/src/lib/services/user-dashboard.ts index 3fc75e47..379b0e34 100644 --- a/src/lib/services/user-dashboard.ts +++ b/src/lib/services/user-dashboard.ts @@ -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,