From 078216f36d523b76f107249e7d74a7dd24b91fee Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 9 Aug 2026 21:13:37 +0700 Subject: [PATCH] refactor(profile): isolate resilient guild reads --- app/profile/page.tsx | 40 +++----------- lib/profile/guild-profile.test.ts | 57 ++++++++++++++++++++ lib/profile/guild-profile.ts | 89 +++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 34 deletions(-) create mode 100644 lib/profile/guild-profile.test.ts create mode 100644 lib/profile/guild-profile.ts diff --git a/app/profile/page.tsx b/app/profile/page.tsx index b7eda7d..a82572d 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -4,13 +4,7 @@ import { getServerSession } from "next-auth"; import { ArrowLeft } from "lucide-react"; import { authOptions } from "@/lib/auth/auth-options"; import { getSessionDiscordId } from "@/lib/auth/auth"; -import { getDiscordMemberProfile } from "@/lib/discord/discord"; -import { getCurrentDiscordVoiceChannels } from "@/lib/leaderboard/discord"; -import { - getMemberVoiceStats, - getMemberXpStats, -} from "@/lib/leaderboard/database"; -import { buildLevelProgress } from "@/lib/leaderboard/level-progress"; +import { loadGuildProfile } from "@/lib/profile/guild-profile"; import { ProfileLoginCard } from "@/components/profile/profile-login-card"; import { ProfilePassport } from "@/components/profile/profile-passport"; import { RealtimeRefresh } from "@/components/realtime-refresh"; @@ -26,11 +20,6 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; -async function loadVoiceStats(discordId: string) { - const channels = await getCurrentDiscordVoiceChannels(); - return getMemberVoiceStats(discordId, channels.map((channel) => channel.id)); -} - export default async function ProfilePage() { const session = await getServerSession(authOptions); const discordId = getSessionDiscordId(session); @@ -38,24 +27,7 @@ export default async function ProfilePage() { let content = ; if (session?.user && discordId) { - const [profileResult, xpResult, voiceResult] = await Promise.allSettled([ - getDiscordMemberProfile(discordId), - getMemberXpStats(discordId), - loadVoiceStats(discordId), - ]); - const profile = profileResult.status === "fulfilled" ? profileResult.value : null; - const xpStats = xpResult.status === "fulfilled" ? xpResult.value : null; - const voiceStats = voiceResult.status === "fulfilled" ? voiceResult.value : null; - - if (profileResult.status === "rejected") { - console.error("Failed to load own Discord profile", profileResult.reason); - } - if (xpResult.status === "rejected") { - console.error("Failed to load own XP stats", xpResult.reason); - } - if (voiceResult.status === "rejected") { - console.error("Failed to load own voice stats", voiceResult.reason); - } + const guildProfile = await loadGuildProfile(discordId); content = ( <> @@ -63,10 +35,10 @@ export default async function ProfilePage() { ); diff --git a/lib/profile/guild-profile.test.ts b/lib/profile/guild-profile.test.ts new file mode 100644 index 0000000..a9a1dd9 --- /dev/null +++ b/lib/profile/guild-profile.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { createGuildProfileReader } from "@/lib/profile/guild-profile"; + +describe("Guild profile read module", () => { + test("assembles independent sections and current voice scope", async () => { + const reader = createGuildProfileReader({ + getProfile: async () => ({ + id: "member-1", + username: "member", + globalName: "Member", + avatarUrl: null, + avatarDecorationUrl: null, + bannerUrl: null, + accentColor: null, + primaryGuild: null, + roles: [], + }), + getXp: async () => ({ level: 2, totalXp: "1200", rank: 4 }), + getCurrentVoiceChannels: async () => [ + { id: "voice-1", name: "Lounge", type: 2, position: 1 }, + { id: "stage-1", name: "Stage", type: 13, position: 2 }, + ], + getVoice: async (_discordId, channelIds) => ({ + totalSeconds: channelIds.join(",") === "voice-1,stage-1" ? "90" : "0", + rank: 3, + }), + }); + + const result = await reader("member-1"); + + expect(result.profile?.id).toBe("member-1"); + expect(result.xp).toMatchObject({ level: 2, totalXp: "1200", rank: 4 }); + expect(result.voice).toEqual({ totalSeconds: "90", rank: 3 }); + expect(result.profileUnavailable).toBeFalse(); + }); + + test("keeps healthy sections when one source fails", async () => { + const failures: string[] = []; + const reader = createGuildProfileReader({ + getProfile: async () => null, + getXp: async () => { + throw new Error("leaderboard unavailable"); + }, + getCurrentVoiceChannels: async () => [], + getVoice: async () => ({ totalSeconds: "0", rank: null }), + reportFailure: (source) => failures.push(source), + }); + + const result = await reader("member-1"); + + expect(result.profile).toBeNull(); + expect(result.profileUnavailable).toBeTrue(); + expect(result.xp).toBeNull(); + expect(result.voice).toEqual({ totalSeconds: "0", rank: null }); + expect(failures).toEqual(["xp"]); + }); +}); diff --git a/lib/profile/guild-profile.ts b/lib/profile/guild-profile.ts new file mode 100644 index 0000000..da2c3a2 --- /dev/null +++ b/lib/profile/guild-profile.ts @@ -0,0 +1,89 @@ +import type { DiscordMemberProfile } from "@/lib/discord/discord"; +import type { DiscordVoiceChannel } from "@/lib/leaderboard/discord"; +import type { + getMemberVoiceStats, + getMemberXpStats, +} from "@/lib/leaderboard/database"; +import { buildLevelProgress, type LevelProgress } from "@/lib/leaderboard/level-progress"; + +export type GuildProfileXp = LevelProgress & { rank: number | null }; + +export type GuildProfileVoice = Awaited>; + +export type GuildProfileRead = { + profile: DiscordMemberProfile | null; + profileUnavailable: boolean; + xp: GuildProfileXp | null; + voice: GuildProfileVoice | null; +}; + +export type GuildProfileSources = { + getProfile: (discordId: string) => Promise; + getXp: (discordId: string) => ReturnType; + getVoice: ( + discordId: string, + currentChannelIds: string[], + ) => ReturnType; + getCurrentVoiceChannels: () => Promise; + reportFailure?: (source: "profile" | "xp" | "voice", error: unknown) => void; +}; + +function defaultReportFailure(source: string, error: unknown) { + console.error(`Failed to load own Guild profile ${source} data`, error); +} + +export function createGuildProfileReader( + sources: GuildProfileSources, +) { + return async function loadGuildProfile(discordId: string): Promise { + const reportFailure = sources.reportFailure ?? defaultReportFailure; + const [profileResult, xpResult, voiceResult] = await Promise.allSettled([ + sources.getProfile(discordId), + sources.getXp(discordId), + sources.getCurrentVoiceChannels().then((channels) => + sources.getVoice(discordId, channels.map((channel) => channel.id)), + ), + ]); + + if (profileResult.status === "rejected") { + reportFailure("profile", profileResult.reason); + } + if (xpResult.status === "rejected") { + reportFailure("xp", xpResult.reason); + } + if (voiceResult.status === "rejected") { + reportFailure("voice", voiceResult.reason); + } + + const profile = profileResult.status === "fulfilled" ? profileResult.value : null; + const xpStats = xpResult.status === "fulfilled" ? xpResult.value : null; + const voice = voiceResult.status === "fulfilled" ? voiceResult.value : null; + + return { + profile, + profileUnavailable: !profile, + xp: xpStats + ? { + ...buildLevelProgress(xpStats.level, xpStats.totalXp), + rank: xpStats.rank, + } + : null, + voice, + }; + }; +} + +export async function loadGuildProfile(discordId: string) { + const [discord, leaderboardDiscord, leaderboardDatabase] = await Promise.all([ + import("@/lib/discord/discord"), + import("@/lib/leaderboard/discord"), + import("@/lib/leaderboard/database"), + ]); + + return createGuildProfileReader({ + getProfile: discord.getDiscordMemberProfile, + getXp: leaderboardDatabase.getMemberXpStats, + getVoice: leaderboardDatabase.getMemberVoiceStats, + getCurrentVoiceChannels: leaderboardDiscord.getCurrentDiscordVoiceChannels, + })(discordId); +}