refactor(profile): isolate resilient guild reads
This commit is contained in:
+6
-34
@@ -4,13 +4,7 @@ import { getServerSession } from "next-auth";
|
|||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { authOptions } from "@/lib/auth/auth-options";
|
import { authOptions } from "@/lib/auth/auth-options";
|
||||||
import { getSessionDiscordId } from "@/lib/auth/auth";
|
import { getSessionDiscordId } from "@/lib/auth/auth";
|
||||||
import { getDiscordMemberProfile } from "@/lib/discord/discord";
|
import { loadGuildProfile } from "@/lib/profile/guild-profile";
|
||||||
import { getCurrentDiscordVoiceChannels } from "@/lib/leaderboard/discord";
|
|
||||||
import {
|
|
||||||
getMemberVoiceStats,
|
|
||||||
getMemberXpStats,
|
|
||||||
} from "@/lib/leaderboard/database";
|
|
||||||
import { buildLevelProgress } from "@/lib/leaderboard/level-progress";
|
|
||||||
import { ProfileLoginCard } from "@/components/profile/profile-login-card";
|
import { ProfileLoginCard } from "@/components/profile/profile-login-card";
|
||||||
import { ProfilePassport } from "@/components/profile/profile-passport";
|
import { ProfilePassport } from "@/components/profile/profile-passport";
|
||||||
import { RealtimeRefresh } from "@/components/realtime-refresh";
|
import { RealtimeRefresh } from "@/components/realtime-refresh";
|
||||||
@@ -26,11 +20,6 @@ export const metadata: Metadata = {
|
|||||||
robots: { index: false, follow: false },
|
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() {
|
export default async function ProfilePage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
const discordId = getSessionDiscordId(session);
|
const discordId = getSessionDiscordId(session);
|
||||||
@@ -38,24 +27,7 @@ export default async function ProfilePage() {
|
|||||||
let content = <ProfileLoginCard />;
|
let content = <ProfileLoginCard />;
|
||||||
|
|
||||||
if (session?.user && discordId) {
|
if (session?.user && discordId) {
|
||||||
const [profileResult, xpResult, voiceResult] = await Promise.allSettled([
|
const guildProfile = await loadGuildProfile(discordId);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
content = (
|
content = (
|
||||||
<>
|
<>
|
||||||
@@ -63,10 +35,10 @@ export default async function ProfilePage() {
|
|||||||
<ProfilePassport
|
<ProfilePassport
|
||||||
userName={session.user.name ?? ""}
|
userName={session.user.name ?? ""}
|
||||||
userImage={session.user.image ?? ""}
|
userImage={session.user.image ?? ""}
|
||||||
profile={profile}
|
profile={guildProfile.profile}
|
||||||
profileUnavailable={!profile}
|
profileUnavailable={guildProfile.profileUnavailable}
|
||||||
xp={xpStats ? { ...buildLevelProgress(xpStats.level, xpStats.totalXp), rank: xpStats.rank } : null}
|
xp={guildProfile.xp}
|
||||||
voice={voiceStats}
|
voice={guildProfile.voice}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ReturnType<typeof getMemberVoiceStats>>;
|
||||||
|
|
||||||
|
export type GuildProfileRead = {
|
||||||
|
profile: DiscordMemberProfile | null;
|
||||||
|
profileUnavailable: boolean;
|
||||||
|
xp: GuildProfileXp | null;
|
||||||
|
voice: GuildProfileVoice | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GuildProfileSources = {
|
||||||
|
getProfile: (discordId: string) => Promise<DiscordMemberProfile | null>;
|
||||||
|
getXp: (discordId: string) => ReturnType<typeof getMemberXpStats>;
|
||||||
|
getVoice: (
|
||||||
|
discordId: string,
|
||||||
|
currentChannelIds: string[],
|
||||||
|
) => ReturnType<typeof getMemberVoiceStats>;
|
||||||
|
getCurrentVoiceChannels: () => Promise<DiscordVoiceChannel[]>;
|
||||||
|
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<GuildProfileRead> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user