Compare commits
9 Commits
6fddbbab68
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e67f6b073c | ||
| 569ea8833b | |||
| aff5fa2ea4 | |||
| 3f125e5761 | |||
| 5305dc37e7 | |||
| e51ff24944 | |||
| 3baec5ac0f | |||
| a54a74d7de | |||
| 7b95998b05 |
@@ -122,12 +122,25 @@ export const Route = createRootRouteWithContext<{
|
|||||||
context.queryClient,
|
context.queryClient,
|
||||||
playerQueries.auth()
|
playerQueries.auth()
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('__root beforeLoad auth data:', auth);
|
|
||||||
|
|
||||||
return { auth };
|
return { auth };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('__root beforeLoad error:', error);
|
if (typeof window !== 'undefined') {
|
||||||
|
const { doesSessionExist, attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
|
||||||
|
|
||||||
|
const sessionExists = await doesSessionExist();
|
||||||
|
if (sessionExists) {
|
||||||
|
try {
|
||||||
|
await attemptRefreshingSession();
|
||||||
|
const auth = await ensureServerQueryData(
|
||||||
|
context.queryClient,
|
||||||
|
playerQueries.auth()
|
||||||
|
);
|
||||||
|
return { auth };
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,14 +5,10 @@ import { Flex, Loader } from "@mantine/core";
|
|||||||
|
|
||||||
export const Route = createFileRoute("/_authed")({
|
export const Route = createFileRoute("/_authed")({
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
console.log('_authed beforeLoad context:', context.auth);
|
|
||||||
|
|
||||||
if (!context.auth?.user) {
|
if (!context.auth?.user) {
|
||||||
console.log('_authed: No user in context, redirecting to login');
|
|
||||||
throw redirect({ to: "/login" });
|
throw redirect({ to: "/login" });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('_authed: User found, allowing access');
|
|
||||||
return {
|
return {
|
||||||
auth: {
|
auth: {
|
||||||
...context.auth,
|
...context.auth,
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ function RouteComponent() {
|
|||||||
tournamentId={tournament.id}
|
tournamentId={tournament.id}
|
||||||
hasKnockoutBracket={knockoutBracketPopulated}
|
hasKnockoutBracket={knockoutBracketPopulated}
|
||||||
isRegional={tournament.regional}
|
isRegional={tournament.regional}
|
||||||
|
groupConfig={tournament.group_config}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div>
|
<div>
|
||||||
@@ -122,6 +123,7 @@ function RouteComponent() {
|
|||||||
tournamentId={tournament.id}
|
tournamentId={tournament.id}
|
||||||
hasKnockoutBracket={knockoutBracketPopulated}
|
hasKnockoutBracket={knockoutBracketPopulated}
|
||||||
isRegional={tournament.regional}
|
isRegional={tournament.regional}
|
||||||
|
groupConfig={tournament.group_config}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ function RouteComponent() {
|
|||||||
groups={tournament.groups || []}
|
groups={tournament.groups || []}
|
||||||
matches={tournament.matches || []}
|
matches={tournament.matches || []}
|
||||||
isRegional={tournament.regional}
|
isRegional={tournament.regional}
|
||||||
|
groupConfig={tournament.group_config}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import FullScreenLoader from '@/components/full-screen-loader'
|
import FullScreenLoader from '@/components/full-screen-loader'
|
||||||
import { refreshManager } from '@/lib/supertokens/refresh-manager'
|
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
|
||||||
|
import { resetRefreshFlag, getOrCreateRefreshPromise } from '@/lib/supertokens/client'
|
||||||
import { logger } from '@/lib/supertokens'
|
import { logger } from '@/lib/supertokens'
|
||||||
|
|
||||||
export const Route = createFileRoute('/refresh-session')({
|
export const Route = createFileRoute('/refresh-session')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
function clearSuperTokensCookies() {
|
|
||||||
const cookieNames = ['sAccessToken', 'sRefreshToken', 'sIdRefreshToken', 'sFrontToken'];
|
|
||||||
const cookieDomain = (window as any).__COOKIE_DOMAIN__ || undefined;
|
|
||||||
|
|
||||||
cookieNames.forEach(name => {
|
|
||||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
|
||||||
|
|
||||||
if (cookieDomain) {
|
|
||||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${cookieDomain}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const hasAttemptedRef = useRef(false);
|
const hasAttemptedRef = useRef(false);
|
||||||
|
|
||||||
@@ -32,17 +20,9 @@ function RouteComponent() {
|
|||||||
try {
|
try {
|
||||||
logger.info("Refresh session route: starting refresh");
|
logger.info("Refresh session route: starting refresh");
|
||||||
|
|
||||||
const cookies = document.cookie.split(';');
|
const refreshed = await getOrCreateRefreshPromise(async () => {
|
||||||
const accessTokenCookies = cookies.filter(c => c.trim().startsWith('sAccessToken='));
|
return await attemptRefreshingSession();
|
||||||
|
});
|
||||||
if (accessTokenCookies.length > 1) {
|
|
||||||
logger.warn(`Found ${accessTokenCookies.length} access tokens, clearing all before refresh`);
|
|
||||||
clearSuperTokensCookies();
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshed = await refreshManager.refresh();
|
|
||||||
|
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
logger.info("Refresh session route: refresh successful");
|
logger.info("Refresh session route: refresh successful");
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { refreshManager } from '@/lib/supertokens/refresh-manager';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { doesSessionExist } from 'supertokens-web-js/recipe/session';
|
||||||
|
import { getOrCreateRefreshPromise } from '@/lib/supertokens/client';
|
||||||
|
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
|
||||||
import { logger } from '@/lib/supertokens';
|
import { logger } from '@/lib/supertokens';
|
||||||
import { ensureSuperTokensFrontend } from '@/lib/supertokens/client';
|
|
||||||
|
|
||||||
export function SessionMonitor() {
|
export function SessionMonitor() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const lastRefreshTimeRef = useRef<number>(0);
|
const lastRefreshTimeRef = useRef<number>(0);
|
||||||
const REFRESH_COOLDOWN = 5 * 1000;
|
const REFRESH_COOLDOWN = 30 * 1000;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -19,27 +22,23 @@ export function SessionMonitor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timeSinceLastRefresh = now - lastRefreshTimeRef.current;
|
if (now - lastRefreshTimeRef.current < REFRESH_COOLDOWN) {
|
||||||
|
logger.info('Session monitor: skipping refresh (cooldown)');
|
||||||
if (timeSinceLastRefresh < REFRESH_COOLDOWN) {
|
|
||||||
logger.info(`Session monitor: skipping refresh (refreshed ${timeSinceLastRefresh}ms ago)`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSuperTokensFrontend();
|
|
||||||
|
|
||||||
const { doesSessionExist } = await import('supertokens-web-js/recipe/session');
|
|
||||||
|
|
||||||
const sessionExists = await doesSessionExist();
|
const sessionExists = await doesSessionExist();
|
||||||
if (!sessionExists) {
|
if (!sessionExists) {
|
||||||
logger.info('Session monitor: no session exists, skipping refresh');
|
logger.info('Session monitor: no session exists, skipping refresh');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Session monitor: tab became visible, checking session freshness');
|
logger.info('Session monitor: tab became visible, refreshing session');
|
||||||
|
|
||||||
const refreshed = await refreshManager.refresh();
|
const refreshed = await getOrCreateRefreshPromise(async () => {
|
||||||
|
return await attemptRefreshingSession();
|
||||||
|
});
|
||||||
|
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
lastRefreshTimeRef.current = Date.now();
|
lastRefreshTimeRef.current = Date.now();
|
||||||
@@ -47,17 +46,19 @@ export function SessionMonitor() {
|
|||||||
} else {
|
} else {
|
||||||
logger.warn('Session monitor: refresh returned false');
|
logger.warn('Session monitor: refresh returned false');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
logger.error('Session monitor: error refreshing session', error?.message);
|
logger.error('Session monitor: error refreshing session', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleVisibilityChange();
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [navigate]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
|
|||||||
</Text>
|
</Text>
|
||||||
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} />
|
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} />
|
||||||
</div>
|
</div>
|
||||||
{bracket.losers && (
|
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="md" m={16}>
|
<Text fw={600} size="md" m={16}>
|
||||||
Losers Bracket
|
Losers Bracket
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
const numGroups = groupConfig.num_groups;
|
const numGroups = groupConfig.num_groups;
|
||||||
const advancePerGroup = groupConfig.advance_per_group;
|
const advancePerGroup = groupConfig.advance_per_group;
|
||||||
|
|
||||||
|
const totalQualifiedTeams = numGroups * advancePerGroup;
|
||||||
|
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(totalQualifiedTeams)));
|
||||||
|
const wildcardsNeeded = nextPowerOf2 - totalQualifiedTeams;
|
||||||
|
|
||||||
|
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
|
||||||
|
const wildcardNumber = seed - totalQualifiedTeams;
|
||||||
|
return `Wildcard ${wildcardNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
const pairIndex = Math.floor((seed - 1) / 2);
|
const pairIndex = Math.floor((seed - 1) / 2);
|
||||||
const isFirstInPair = (seed - 1) % 2 === 0;
|
const isFirstInPair = (seed - 1) % 2 === 0;
|
||||||
|
|
||||||
|
|||||||
@@ -270,15 +270,120 @@ async function populateKnockoutBracketInternal(tournamentId: string, groupConfig
|
|||||||
|
|
||||||
const orderedTeamIds: string[] = [];
|
const orderedTeamIds: string[] = [];
|
||||||
const maxRank = groupConfig.advance_per_group;
|
const maxRank = groupConfig.advance_per_group;
|
||||||
|
const numGroups = groupConfig.num_groups;
|
||||||
|
|
||||||
for (let rank = 1; rank <= maxRank; rank++) {
|
const teamsByGroup: string[][] = [];
|
||||||
const teamsAtRank = qualifiedTeams
|
for (let g = 0; g < numGroups; g++) {
|
||||||
.filter(t => t.rank === rank)
|
teamsByGroup[g] = [];
|
||||||
.sort((a, b) => a.groupOrder - b.groupOrder);
|
|
||||||
orderedTeamIds.push(...teamsAtRank.map(t => t.teamId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Ordered team IDs', { orderedTeamIds });
|
for (const qualified of qualifiedTeams) {
|
||||||
|
teamsByGroup[qualified.groupOrder][qualified.rank - 1] = qualified.teamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalQualifiedTeams = numGroups * maxRank;
|
||||||
|
for (let i = 0; i < totalQualifiedTeams / 2; i++) {
|
||||||
|
const group1 = i % numGroups;
|
||||||
|
const rankIndex1 = Math.floor(i / numGroups);
|
||||||
|
|
||||||
|
const group2 = (i + 1) % numGroups;
|
||||||
|
const rankIndex2 = maxRank - 1 - rankIndex1;
|
||||||
|
|
||||||
|
const team1 = teamsByGroup[group1]?.[rankIndex1];
|
||||||
|
const team2 = teamsByGroup[group2]?.[rankIndex2];
|
||||||
|
|
||||||
|
if (team1) orderedTeamIds.push(team1);
|
||||||
|
if (team2) orderedTeamIds.push(team2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const knockoutTeamCount = orderedTeamIds.length;
|
||||||
|
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
|
||||||
|
const wildcardsNeeded = nextPowerOf2 - knockoutTeamCount;
|
||||||
|
|
||||||
|
if (wildcardsNeeded > 0) {
|
||||||
|
logger.info('Wildcards needed', { knockoutTeamCount, nextPowerOf2, wildcardsNeeded });
|
||||||
|
|
||||||
|
const allNonQualifiedTeams: Array<{ teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }> = [];
|
||||||
|
const qualifiedTeamIds = new Set(qualifiedTeams.map(t => t.teamId));
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const groupMatches = await pbAdmin.getMatchesByGroup(group.id);
|
||||||
|
const completedMatches = groupMatches.filter(m => m.status === "ended");
|
||||||
|
|
||||||
|
const standings = new Map<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
|
||||||
|
|
||||||
|
for (const team of group.teams || []) {
|
||||||
|
const teamId = typeof team === 'string' ? team : team.id;
|
||||||
|
|
||||||
|
if (qualifiedTeamIds.has(teamId)) continue;
|
||||||
|
|
||||||
|
standings.set(teamId, {
|
||||||
|
teamId,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
cups_for: 0,
|
||||||
|
cups_against: 0,
|
||||||
|
cup_differential: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of completedMatches) {
|
||||||
|
if (!match.home || !match.away) continue;
|
||||||
|
|
||||||
|
const homeStanding = standings.get(match.home.id);
|
||||||
|
const awayStanding = standings.get(match.away.id);
|
||||||
|
|
||||||
|
if (homeStanding) {
|
||||||
|
homeStanding.cups_for += match.home_cups;
|
||||||
|
homeStanding.cups_against += match.away_cups;
|
||||||
|
homeStanding.cup_differential = homeStanding.cups_for - homeStanding.cups_against;
|
||||||
|
|
||||||
|
if (match.home_cups > match.away_cups) {
|
||||||
|
homeStanding.wins++;
|
||||||
|
} else {
|
||||||
|
homeStanding.losses++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (awayStanding) {
|
||||||
|
awayStanding.cups_for += match.away_cups;
|
||||||
|
awayStanding.cups_against += match.home_cups;
|
||||||
|
awayStanding.cup_differential = awayStanding.cups_for - awayStanding.cups_against;
|
||||||
|
|
||||||
|
if (match.away_cups > match.home_cups) {
|
||||||
|
awayStanding.wins++;
|
||||||
|
} else {
|
||||||
|
awayStanding.losses++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allNonQualifiedTeams.push(...Array.from(standings.values()));
|
||||||
|
}
|
||||||
|
|
||||||
|
allNonQualifiedTeams.sort((a, b) => {
|
||||||
|
if (b.wins !== a.wins) return b.wins - a.wins;
|
||||||
|
if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
|
||||||
|
if (b.cups_for !== a.cups_for) return b.cups_for - a.cups_for;
|
||||||
|
return a.teamId.localeCompare(b.teamId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const wildcardTeams = allNonQualifiedTeams.slice(0, wildcardsNeeded);
|
||||||
|
const wildcardTeamIds = wildcardTeams.map(t => t.teamId);
|
||||||
|
|
||||||
|
orderedTeamIds.push(...wildcardTeamIds);
|
||||||
|
|
||||||
|
logger.info('Added wildcard teams', {
|
||||||
|
wildcardTeams: wildcardTeams.map(t => ({
|
||||||
|
teamId: t.teamId,
|
||||||
|
wins: t.wins,
|
||||||
|
cupDiff: t.cup_differential,
|
||||||
|
cupsFor: t.cups_for
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Ordered team IDs (with wildcards)', { orderedTeamIds, totalTeams: orderedTeamIds.length });
|
||||||
|
|
||||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
const knockoutMatches = (tournament.matches || [])
|
const knockoutMatches = (tournament.matches || [])
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server";
|
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server";
|
||||||
|
import { logger } from '@/lib/supertokens';
|
||||||
|
|
||||||
|
let queryRefreshRedirect: Promise<void> | null = null;
|
||||||
|
|
||||||
export const playerKeys = {
|
export const playerKeys = {
|
||||||
auth: ['auth'],
|
auth: ['auth'],
|
||||||
@@ -61,7 +64,37 @@ export const useMe = () => {
|
|||||||
staleTime: 30 * 1000,
|
staleTime: 30 * 1000,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
retry: 3,
|
retry: (failureCount, error: any) => {
|
||||||
|
if (error?.response?.status === 401) {
|
||||||
|
const errorData = error?.response?.data;
|
||||||
|
if (errorData?.error === 'SESSION_REFRESH_REQUIRED') {
|
||||||
|
logger.warn("Query detected SESSION_REFRESH_REQUIRED");
|
||||||
|
|
||||||
|
if (!queryRefreshRedirect) {
|
||||||
|
const currentUrl = window.location.pathname + window.location.search;
|
||||||
|
logger.info("Query initiating refresh redirect to:", currentUrl);
|
||||||
|
|
||||||
|
queryRefreshRedirect = new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
||||||
|
resolve();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
queryRefreshRedirect.finally(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
queryRefreshRedirect = null;
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.info("Query: refresh redirect already in progress");
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failureCount < 3;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,34 +26,13 @@ export const fetchMe = createServerFn()
|
|||||||
phone: context.phone
|
phone: context.phone
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.info("FetchMe: Error caught", {
|
// logger.info("FetchMe: Session error", error)
|
||||||
message: error?.message,
|
if (error?.response?.status === 401) {
|
||||||
isResponse: error instanceof Response,
|
|
||||||
status: error instanceof Response ? error.status : error?.response?.status
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error instanceof Response) {
|
|
||||||
const status = error.status;
|
|
||||||
if (status === 440) {
|
|
||||||
logger.info("FetchMe: Session refresh required (440)");
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error?.response?.status === 440 || error?.response?.status === 401) {
|
|
||||||
const errorData = error?.response?.data;
|
const errorData = error?.response?.data;
|
||||||
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
||||||
logger.info("FetchMe: Session refresh required (legacy)");
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error?.message === "Unauthenticated") {
|
|
||||||
logger.info("FetchMe: No authenticated user (expected when not logged in)");
|
|
||||||
return { user: undefined, roles: [], metadata: {}, phone: undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn("FetchMe: Unexpected error, returning default", error);
|
|
||||||
return { user: undefined, roles: [], metadata: {}, phone: undefined };
|
return { user: undefined, roles: [], metadata: {}, phone: undefined };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert } from "@mantine/core";
|
import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert, Badge } from "@mantine/core";
|
||||||
import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react";
|
import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import { Group } from "../types";
|
import { Group, GroupConfig } from "../types";
|
||||||
import GroupMatchCard from "./group-match-card";
|
import GroupMatchCard from "./group-match-card";
|
||||||
import TeamAvatar from "@/components/team-avatar";
|
import TeamAvatar from "@/components/team-avatar";
|
||||||
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||||
@@ -17,6 +17,7 @@ interface GroupStageViewProps {
|
|||||||
tournamentId?: string;
|
tournamentId?: string;
|
||||||
hasKnockoutBracket?: boolean;
|
hasKnockoutBracket?: boolean;
|
||||||
isRegional?: boolean;
|
isRegional?: boolean;
|
||||||
|
groupConfig?: GroupConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamStanding {
|
interface TeamStanding {
|
||||||
@@ -37,6 +38,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
tournamentId,
|
tournamentId,
|
||||||
hasKnockoutBracket,
|
hasKnockoutBracket,
|
||||||
isRegional,
|
isRegional,
|
||||||
|
groupConfig,
|
||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
|
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
|
||||||
@@ -239,6 +241,57 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allGroupStandings = useMemo(() => {
|
||||||
|
return sortedGroups.map((group) => ({
|
||||||
|
groupId: group.id,
|
||||||
|
groupOrder: group.order,
|
||||||
|
standings: getTeamStandings(group.id, group.teams || []),
|
||||||
|
}));
|
||||||
|
}, [sortedGroups, matchesByGroup]);
|
||||||
|
|
||||||
|
const advancingTeams = useMemo(() => {
|
||||||
|
if (!groupConfig) return { qualifiedTeams: new Set<string>(), wildcardTeams: new Set<string>() };
|
||||||
|
|
||||||
|
const advancePerGroup = groupConfig.advance_per_group;
|
||||||
|
const qualifiedTeams = new Set<string>();
|
||||||
|
const wildcardTeams = new Set<string>();
|
||||||
|
|
||||||
|
allGroupStandings.forEach(({ standings }) => {
|
||||||
|
standings.slice(0, advancePerGroup).forEach((standing) => {
|
||||||
|
qualifiedTeams.add(standing.teamId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalQualified = qualifiedTeams.size;
|
||||||
|
const knockoutTeamCount = totalQualified;
|
||||||
|
|
||||||
|
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
|
||||||
|
const wildcardsNeeded = nextPowerOf2 - knockoutTeamCount;
|
||||||
|
|
||||||
|
if (wildcardsNeeded > 0) {
|
||||||
|
const allNonQualifiedTeams: TeamStanding[] = [];
|
||||||
|
|
||||||
|
allGroupStandings.forEach(({ standings }) => {
|
||||||
|
standings.slice(advancePerGroup).forEach((standing) => {
|
||||||
|
allNonQualifiedTeams.push(standing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
allNonQualifiedTeams.sort((a, b) => {
|
||||||
|
if (b.wins !== a.wins) return b.wins - a.wins;
|
||||||
|
if (b.cupDifference !== a.cupDifference) return b.cupDifference - a.cupDifference;
|
||||||
|
if (b.cupsFor !== a.cupsFor) return b.cupsFor - a.cupsFor;
|
||||||
|
return a.teamId.localeCompare(b.teamId);
|
||||||
|
});
|
||||||
|
|
||||||
|
allNonQualifiedTeams.slice(0, wildcardsNeeded).forEach((standing) => {
|
||||||
|
wildcardTeams.add(standing.teamId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { qualifiedTeams, wildcardTeams };
|
||||||
|
}, [allGroupStandings, groupConfig]);
|
||||||
|
|
||||||
if (sortedGroups.length === 0) {
|
if (sortedGroups.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box p="md">
|
<Box p="md">
|
||||||
@@ -321,7 +374,12 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
<Collapse in={expandedTeams[group.id]}>
|
<Collapse in={expandedTeams[group.id]}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
{standings.length > 0 ? (
|
{standings.length > 0 ? (
|
||||||
standings.map((standing, index) => (
|
standings.map((standing, index) => {
|
||||||
|
const isQualified = advancingTeams.qualifiedTeams.has(standing.teamId);
|
||||||
|
const isWildcard = advancingTeams.wildcardTeams.has(standing.teamId);
|
||||||
|
const isAdvancing = isQualified || isWildcard;
|
||||||
|
|
||||||
|
return (
|
||||||
<MantineGroup
|
<MantineGroup
|
||||||
key={standing.teamId}
|
key={standing.teamId}
|
||||||
gap="sm"
|
gap="sm"
|
||||||
@@ -331,16 +389,30 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
py="xs"
|
py="xs"
|
||||||
style={{
|
style={{
|
||||||
borderTop: index > 0 ? '1px solid var(--mantine-color-default-border)' : 'none',
|
borderTop: index > 0 ? '1px solid var(--mantine-color-default-border)' : 'none',
|
||||||
|
backgroundColor: isAdvancing ? 'var(--mantine-primary-color-light)' : undefined,
|
||||||
|
borderLeft: isAdvancing ? '3px solid var(--mantine-primary-color-filled)' : '3px solid transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" fw={700} c="dimmed" w={24} ta="center">
|
<Text size="sm" fw={700} c="dimmed" w={24} ta="center">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</Text>
|
</Text>
|
||||||
<TeamAvatar team={standing.team} size={28} radius="sm" isRegional={isRegional} />
|
<TeamAvatar team={standing.team} size={28} radius="sm" isRegional={isRegional} />
|
||||||
<Text size="sm" fw={500} style={{ flex: 1 }} lineClamp={1}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} lineClamp={1}>
|
||||||
{standing.teamName}
|
{standing.teamName}
|
||||||
</Text>
|
</Text>
|
||||||
|
</Box>
|
||||||
<MantineGroup gap="xs" wrap="nowrap">
|
<MantineGroup gap="xs" wrap="nowrap">
|
||||||
|
{isWildcard && (
|
||||||
|
<Badge size="xs" color="yellow" variant="light">
|
||||||
|
WC
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isQualified && (
|
||||||
|
<Badge size="xs" variant="light">
|
||||||
|
Q
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<Text size="xs" c="dimmed" fw={500} miw={35} ta="center">
|
<Text size="xs" c="dimmed" fw={500} miw={35} ta="center">
|
||||||
{standing.wins}-{standing.losses}
|
{standing.wins}-{standing.losses}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -358,7 +430,8 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</MantineGroup>
|
</MantineGroup>
|
||||||
</MantineGroup>
|
</MantineGroup>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||||
No teams assigned
|
No teams assigned
|
||||||
|
|||||||
@@ -80,12 +80,16 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
|
||||||
|
const bracketSize = nextPowerOf2;
|
||||||
|
const wildcardsNeeded = bracketSize - knockoutTeamCount;
|
||||||
|
|
||||||
let bracketTemplate: any;
|
let bracketTemplate: any;
|
||||||
if (Object.keys(brackets).includes(knockoutTeamCount.toString())) {
|
if (Object.keys(brackets).includes(bracketSize.toString())) {
|
||||||
bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets];
|
bracketTemplate = brackets[bracketSize as keyof typeof brackets];
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount);
|
bracketTemplate = generateSingleEliminationBracket(bracketSize);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -113,6 +117,10 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
|
|||||||
seedLabels[seedIndex++] = `${groupName2} ${rankSuffix2}`;
|
seedLabels[seedIndex++] = `${groupName2} ${rankSuffix2}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < wildcardsNeeded; i++) {
|
||||||
|
seedLabels[seedIndex++] = `Wildcard ${i + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
const ordersMap: Record<number, number> = {};
|
const ordersMap: Record<number, number> = {};
|
||||||
bracketTemplate.winners.forEach((round: any[]) => {
|
bracketTemplate.winners.forEach((round: any[]) => {
|
||||||
round.forEach((match: any) => {
|
round.forEach((match: any) => {
|
||||||
@@ -265,10 +273,11 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
|
|||||||
<Box>
|
<Box>
|
||||||
<Divider mb="lg" />
|
<Divider mb="lg" />
|
||||||
<Title order={3} ta="center" mb="md">
|
<Title order={3} ta="center" mb="md">
|
||||||
Knockout Bracket Preview ({knockoutTeamCount} Teams)
|
Knockout Bracket Preview ({selectedConfig?.knockout_size} Teams)
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="sm" c="dimmed" ta="center" mb="lg">
|
<Text size="sm" c="dimmed" ta="center" mb="lg">
|
||||||
Top {selectedConfig?.advance_per_group} team{selectedConfig?.advance_per_group !== 1 ? 's' : ''} from each group will advance
|
Top {selectedConfig?.advance_per_group} team{selectedConfig?.advance_per_group !== 1 ? 's' : ''} from each group will advance
|
||||||
|
{selectedConfig?.wildcards_needed ? ` + ${selectedConfig.wildcards_needed} wildcard${selectedConfig.wildcards_needed > 1 ? 's' : ''}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { logger } from ".";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
import { fa } from "zod/v4/locales";
|
|
||||||
import brackets from "@/features/bracket/utils";
|
import brackets from "@/features/bracket/utils";
|
||||||
import { MatchInput } from "@/features/matches/types";
|
import { MatchInput } from "@/features/matches/types";
|
||||||
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
|
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
|
||||||
@@ -540,10 +539,18 @@ export const generateKnockoutBracket = createServerFn()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = [];
|
const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = [];
|
||||||
|
const allStandings: { standing: GroupStanding; groupOrder: number }[] = [];
|
||||||
|
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
const standings = await calculateGroupStandings(group.id);
|
const standings = await calculateGroupStandings(group.id);
|
||||||
|
|
||||||
|
for (let i = 0; i < standings.length; i++) {
|
||||||
|
allStandings.push({
|
||||||
|
standing: standings[i],
|
||||||
|
groupOrder: group.order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const topTeams = standings.slice(0, tournament.group_config.advance_per_group);
|
const topTeams = standings.slice(0, tournament.group_config.advance_per_group);
|
||||||
for (const standing of topTeams) {
|
for (const standing of topTeams) {
|
||||||
qualifiedTeams.push({
|
qualifiedTeams.push({
|
||||||
@@ -582,7 +589,42 @@ export const generateKnockoutBracket = createServerFn()
|
|||||||
if (team2) orderedTeamIds.push(team2);
|
if (team2) orderedTeamIds.push(team2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const teamCount = orderedTeamIds.length;
|
let teamCount = orderedTeamIds.length;
|
||||||
|
|
||||||
|
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(teamCount)));
|
||||||
|
const wildcardsNeeded = nextPowerOf2 - teamCount;
|
||||||
|
|
||||||
|
if (wildcardsNeeded > 0) {
|
||||||
|
const qualifiedTeamIds = new Set(qualifiedTeams.map(t => t.teamId));
|
||||||
|
const wildcardCandidates = allStandings
|
||||||
|
.filter(s => !qualifiedTeamIds.has(s.standing.team.id))
|
||||||
|
.map(s => s.standing);
|
||||||
|
|
||||||
|
wildcardCandidates.sort((a, b) => {
|
||||||
|
if (b.wins !== a.wins) return b.wins - a.wins;
|
||||||
|
if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
|
||||||
|
if (b.cups_for !== a.cups_for) return b.cups_for - a.cups_for;
|
||||||
|
return a.team.id.localeCompare(b.team.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const wildcardTeams = wildcardCandidates.slice(0, wildcardsNeeded);
|
||||||
|
const wildcardTeamIds = wildcardTeams.map(t => t.team.id);
|
||||||
|
|
||||||
|
orderedTeamIds.push(...wildcardTeamIds);
|
||||||
|
teamCount = orderedTeamIds.length;
|
||||||
|
|
||||||
|
logger.info('Added wildcard teams to knockout bracket', {
|
||||||
|
tournamentId: data.tournamentId,
|
||||||
|
wildcardsNeeded,
|
||||||
|
wildcardTeams: wildcardTeams.map(t => ({
|
||||||
|
id: t.team.id,
|
||||||
|
name: t.team.name,
|
||||||
|
wins: t.wins,
|
||||||
|
cupDiff: t.cup_differential,
|
||||||
|
cupsFor: t.cups_for
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let bracketTemplate: any;
|
let bracketTemplate: any;
|
||||||
if (Object.keys(brackets).includes(teamCount.toString())) {
|
if (Object.keys(brackets).includes(teamCount.toString())) {
|
||||||
@@ -774,14 +816,25 @@ export const generateGroupStage = createServerFn()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
|
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
|
||||||
|
|
||||||
|
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
|
||||||
|
const bracketSize = nextPowerOf2;
|
||||||
|
|
||||||
let bracketTemplate: any;
|
let bracketTemplate: any;
|
||||||
|
|
||||||
if (Object.keys(brackets).includes(knockoutTeamCount.toString())) {
|
if (Object.keys(brackets).includes(bracketSize.toString())) {
|
||||||
bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets];
|
bracketTemplate = brackets[bracketSize as keyof typeof brackets];
|
||||||
} else {
|
} else {
|
||||||
bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount);
|
bracketTemplate = generateSingleEliminationBracket(bracketSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Creating knockout bracket template', {
|
||||||
|
tournamentId: data.tournamentId,
|
||||||
|
knockoutTeamCount,
|
||||||
|
bracketSize,
|
||||||
|
wildcardsNeeded: bracketSize - knockoutTeamCount
|
||||||
|
});
|
||||||
|
|
||||||
const knockoutMatches: any[] = [];
|
const knockoutMatches: any[] = [];
|
||||||
|
|
||||||
bracketTemplate.winners.forEach((round: any[]) => {
|
bracketTemplate.winners.forEach((round: any[]) => {
|
||||||
|
|||||||
@@ -26,20 +26,25 @@ export function calculateGroupConfigurations(teamCount: number): GroupConfigOpti
|
|||||||
const configs: GroupConfigOption[] = [];
|
const configs: GroupConfigOption[] = [];
|
||||||
|
|
||||||
for (let teamsPerGroup = 3; teamsPerGroup <= Math.min(6, teamCount); teamsPerGroup++) {
|
for (let teamsPerGroup = 3; teamsPerGroup <= Math.min(6, teamCount); teamsPerGroup++) {
|
||||||
const numGroups = Math.floor(teamCount / teamsPerGroup);
|
const numGroupsFloor = Math.floor(teamCount / teamsPerGroup);
|
||||||
const remainder = teamCount % teamsPerGroup;
|
const numGroupsCeil = Math.ceil(teamCount / teamsPerGroup);
|
||||||
|
|
||||||
|
const groupOptions = new Set([numGroupsFloor, numGroupsCeil]);
|
||||||
|
|
||||||
|
for (const numGroups of groupOptions) {
|
||||||
if (numGroups < 2) continue;
|
if (numGroups < 2) continue;
|
||||||
|
|
||||||
if (remainder > numGroups) continue;
|
const baseTeamsPerGroup = Math.floor(teamCount / numGroups);
|
||||||
|
const groupsWithExtra = teamCount % numGroups;
|
||||||
|
|
||||||
const groupsWithExtra = remainder;
|
const minGroupSize = baseTeamsPerGroup;
|
||||||
|
const maxGroupSize = baseTeamsPerGroup + (groupsWithExtra > 0 ? 1 : 0);
|
||||||
|
|
||||||
|
if (minGroupSize < 3 || maxGroupSize > 6) continue;
|
||||||
|
|
||||||
const groupsAtBaseSize = numGroups - groupsWithExtra;
|
|
||||||
const minGroupSize = groupsAtBaseSize > 0 ? teamsPerGroup : teamsPerGroup + 1;
|
|
||||||
const matchesGuaranteed = minGroupSize - 1;
|
const matchesGuaranteed = minGroupSize - 1;
|
||||||
|
|
||||||
for (let advancePerGroup = 1; advancePerGroup <= Math.min(3, teamsPerGroup - 1); advancePerGroup++) {
|
for (let advancePerGroup = 1; advancePerGroup <= Math.min(3, minGroupSize - 1); advancePerGroup++) {
|
||||||
const teamsAdvancing = numGroups * advancePerGroup;
|
const teamsAdvancing = numGroups * advancePerGroup;
|
||||||
|
|
||||||
if (teamsAdvancing < 4 || teamsAdvancing > 32) continue;
|
if (teamsAdvancing < 4 || teamsAdvancing > 32) continue;
|
||||||
@@ -51,13 +56,13 @@ export function calculateGroupConfigurations(teamCount: number): GroupConfigOpti
|
|||||||
|
|
||||||
let totalGroupMatches = 0;
|
let totalGroupMatches = 0;
|
||||||
for (let i = 0; i < numGroups; i++) {
|
for (let i = 0; i < numGroups; i++) {
|
||||||
const groupSize = teamsPerGroup + (i < groupsWithExtra ? 1 : 0);
|
const groupSize = baseTeamsPerGroup + (i < groupsWithExtra ? 1 : 0);
|
||||||
totalGroupMatches += (groupSize * (groupSize - 1)) / 2;
|
totalGroupMatches += (groupSize * (groupSize - 1)) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = generateDescription({
|
const description = generateDescription({
|
||||||
num_groups: numGroups,
|
num_groups: numGroups,
|
||||||
teams_per_group: teamsPerGroup,
|
teams_per_group: baseTeamsPerGroup,
|
||||||
groups_with_extra: groupsWithExtra,
|
groups_with_extra: groupsWithExtra,
|
||||||
advance_per_group: advancePerGroup,
|
advance_per_group: advancePerGroup,
|
||||||
matches_guaranteed: matchesGuaranteed,
|
matches_guaranteed: matchesGuaranteed,
|
||||||
@@ -67,7 +72,7 @@ export function calculateGroupConfigurations(teamCount: number): GroupConfigOpti
|
|||||||
|
|
||||||
configs.push({
|
configs.push({
|
||||||
num_groups: numGroups,
|
num_groups: numGroups,
|
||||||
teams_per_group: teamsPerGroup,
|
teams_per_group: baseTeamsPerGroup,
|
||||||
advance_per_group: advancePerGroup,
|
advance_per_group: advancePerGroup,
|
||||||
matches_guaranteed: matchesGuaranteed,
|
matches_guaranteed: matchesGuaranteed,
|
||||||
seeding_method: "random",
|
seeding_method: "random",
|
||||||
@@ -79,6 +84,7 @@ export function calculateGroupConfigurations(teamCount: number): GroupConfigOpti
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueConfigs = new Map<string, GroupConfigOption>();
|
const uniqueConfigs = new Map<string, GroupConfigOption>();
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,34 @@ import Passwordless from "supertokens-web-js/recipe/passwordless";
|
|||||||
import { appInfo } from "./config";
|
import { appInfo } from "./config";
|
||||||
import { logger } from "./";
|
import { logger } from "./";
|
||||||
|
|
||||||
|
let refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
export const resetRefreshFlag = () => {
|
export const resetRefreshFlag = () => {
|
||||||
logger.warn("resetRefreshFlag is deprecated. Use refreshManager.reset() instead.");
|
refreshPromise = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const getOrCreateRefreshPromise = (refreshFn: () => Promise<boolean>): Promise<boolean> => {
|
export const getOrCreateRefreshPromise = (refreshFn: () => Promise<boolean>): Promise<boolean> => {
|
||||||
logger.warn("getOrCreateRefreshPromise is deprecated. Use refreshManager.refresh() instead.");
|
if (refreshPromise) {
|
||||||
return refreshFn();
|
logger.info("Reusing existing refresh promise");
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Creating new refresh promise");
|
||||||
|
refreshPromise = refreshFn()
|
||||||
|
.then((result) => {
|
||||||
|
logger.info("Refresh completed successfully:", result);
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
}, 500);
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error("Refresh failed:", error);
|
||||||
|
refreshPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return refreshPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const frontendConfig = () => {
|
export const frontendConfig = () => {
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
|
|
||||||
import { logger } from './index';
|
|
||||||
|
|
||||||
class SessionRefreshManager {
|
|
||||||
private refreshPromise: Promise<boolean> | null = null;
|
|
||||||
private redirectPromise: Promise<void> | null = null;
|
|
||||||
private lastRefreshTime: number = 0;
|
|
||||||
private readonly MIN_REFRESH_INTERVAL = 1000;
|
|
||||||
|
|
||||||
async refresh(): Promise<boolean> {
|
|
||||||
if (this.refreshPromise) {
|
|
||||||
logger.info('RefreshManager: Reusing existing refresh promise');
|
|
||||||
return this.refreshPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeSinceLastRefresh = Date.now() - this.lastRefreshTime;
|
|
||||||
if (timeSinceLastRefresh < this.MIN_REFRESH_INTERVAL) {
|
|
||||||
logger.info(`RefreshManager: Skipping refresh (last refresh ${timeSinceLastRefresh}ms ago)`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('RefreshManager: Starting new session refresh');
|
|
||||||
this.refreshPromise = attemptRefreshingSession()
|
|
||||||
.then((result) => {
|
|
||||||
logger.info('RefreshManager: Refresh completed successfully:', result);
|
|
||||||
this.lastRefreshTime = Date.now();
|
|
||||||
return result;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
logger.error('RefreshManager: Refresh failed:', error);
|
|
||||||
throw error;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.refreshPromise = null;
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.refreshPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async redirectToRefresh(currentPath: string): Promise<void> {
|
|
||||||
if (this.redirectPromise) {
|
|
||||||
logger.info('RefreshManager: Redirect already in progress, waiting...');
|
|
||||||
return this.redirectPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('RefreshManager: Initiating refresh redirect to:', currentPath);
|
|
||||||
this.redirectPromise = new Promise<void>((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentPath)}`;
|
|
||||||
resolve();
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.redirectPromise.finally(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.redirectPromise = null;
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.redirectPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
logger.info('RefreshManager: Resetting state');
|
|
||||||
this.refreshPromise = null;
|
|
||||||
this.redirectPromise = null;
|
|
||||||
this.lastRefreshTime = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTimeSinceLastRefresh(): number {
|
|
||||||
return Date.now() - this.lastRefreshTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const refreshManager = new SessionRefreshManager();
|
|
||||||
@@ -28,26 +28,6 @@ export const backendConfig = (): TypeInput => {
|
|||||||
olderCookieDomain: undefined,
|
olderCookieDomain: undefined,
|
||||||
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
|
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
|
||||||
|
|
||||||
sessionExpiredStatusCode: 440,
|
|
||||||
invalidClaimStatusCode: 403,
|
|
||||||
|
|
||||||
override: {
|
|
||||||
functions: (originalImplementation) => ({
|
|
||||||
...originalImplementation,
|
|
||||||
refreshSession: async (input) => {
|
|
||||||
logger.info('Backend: Refresh session attempt');
|
|
||||||
try {
|
|
||||||
const result = await originalImplementation.refreshSession(input);
|
|
||||||
logger.info('Backend: Refresh session successful');
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Backend: Refresh session failed:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Debug only
|
// Debug only
|
||||||
exposeAccessTokenToFrontendInCookieBasedAuth: process.env.NODE_ENV !== "production",
|
exposeAccessTokenToFrontendInCookieBasedAuth: process.env.NODE_ENV !== "production",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
|
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
|
||||||
import { ServerResult } from "../types";
|
import { ServerResult } from "../types";
|
||||||
import toast from '@/lib/sonner'
|
import toast from '@/lib/sonner'
|
||||||
import { handleQueryError } from '../utils/global-error-handler';
|
import { logger } from '@/lib/supertokens'
|
||||||
|
|
||||||
|
|
||||||
|
let sessionRefreshRedirect: Promise<void> | null = null;
|
||||||
|
|
||||||
export function useServerMutation<TData, TVariables = unknown>(
|
export function useServerMutation<TData, TVariables = unknown>(
|
||||||
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
|
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
|
||||||
@@ -36,7 +39,41 @@ export function useServerMutation<TData, TVariables = unknown>(
|
|||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await handleQueryError(error);
|
if (error?.response?.status === 401) {
|
||||||
|
try {
|
||||||
|
const errorData = typeof error.response.data === 'string'
|
||||||
|
? JSON.parse(error.response.data)
|
||||||
|
: error.response.data;
|
||||||
|
|
||||||
|
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
||||||
|
logger.warn("Mutation detected SESSION_REFRESH_REQUIRED");
|
||||||
|
|
||||||
|
if (!sessionRefreshRedirect) {
|
||||||
|
const currentUrl = window.location.pathname + window.location.search;
|
||||||
|
logger.info("Mutation initiating refresh redirect to:", currentUrl);
|
||||||
|
|
||||||
|
sessionRefreshRedirect = new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
||||||
|
resolve();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionRefreshRedirect.finally(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
sessionRefreshRedirect = null;
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.info("Mutation: refresh redirect already in progress, waiting...");
|
||||||
|
await sessionRefreshRedirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("SESSION_REFRESH_REQUIRED");
|
||||||
|
}
|
||||||
|
} catch (parseError) {}
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query";
|
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||||
import { ServerResult } from "../types";
|
import { ServerResult } from "../types";
|
||||||
import toast from '@/lib/sonner'
|
import toast from '@/lib/sonner'
|
||||||
import { handleQueryError } from '../utils/global-error-handler';
|
|
||||||
|
|
||||||
export function useServerQuery<TData>(
|
export function useServerQuery<TData>(
|
||||||
options: {
|
options: {
|
||||||
@@ -18,7 +17,6 @@ export function useServerQuery<TData>(
|
|||||||
...queryOptions,
|
...queryOptions,
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
|
||||||
const result = await queryFn();
|
const result = await queryFn();
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -29,10 +27,6 @@ export function useServerQuery<TData>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
} catch (error: any) {
|
|
||||||
await handleQueryError(error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { QueryKey, UseQueryOptions, useSuspenseQuery } from "@tanstack/react-query";
|
import { QueryKey, UseQueryOptions, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { ServerResult } from "../types";
|
import { ServerResult } from "../types";
|
||||||
import toast from '@/lib/sonner'
|
import toast from '@/lib/sonner'
|
||||||
import { handleQueryError } from '../utils/global-error-handler';
|
|
||||||
|
|
||||||
export function useServerSuspenseQuery<TData>(
|
export function useServerSuspenseQuery<TData>(
|
||||||
options: {
|
options: {
|
||||||
@@ -17,7 +16,6 @@ export function useServerSuspenseQuery<TData>(
|
|||||||
...queryOptions,
|
...queryOptions,
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
|
||||||
const result = await queryFn();
|
const result = await queryFn();
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -28,10 +26,6 @@ export function useServerSuspenseQuery<TData>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
} catch (error: any) {
|
|
||||||
await handleQueryError(error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { refreshManager } from '@/lib/supertokens/refresh-manager';
|
|
||||||
import { logger } from '@/lib/supertokens';
|
|
||||||
|
|
||||||
export async function handleQueryError(error: any): Promise<void> {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!error || typeof error !== 'object') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Response) {
|
|
||||||
const status = error.status;
|
|
||||||
|
|
||||||
if (status === 440) {
|
|
||||||
try {
|
|
||||||
const errorData = await error.json();
|
|
||||||
|
|
||||||
if (errorData?.error === 'SESSION_REFRESH_REQUIRED' && errorData?.shouldRetry === true) {
|
|
||||||
logger.warn('Query detected SESSION_REFRESH_REQUIRED (Response), initiating redirect');
|
|
||||||
|
|
||||||
const currentUrl = window.location.pathname + window.location.search;
|
|
||||||
await refreshManager.redirectToRefresh(currentUrl);
|
|
||||||
|
|
||||||
throw new Error('Redirecting to refresh session');
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = error?.response?.status;
|
|
||||||
|
|
||||||
if (status === 440) {
|
|
||||||
try {
|
|
||||||
let errorData = error?.response?.data;
|
|
||||||
|
|
||||||
if (typeof errorData === 'string') {
|
|
||||||
try {
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorData?.error === 'SESSION_REFRESH_REQUIRED' && errorData?.shouldRetry === true) {
|
|
||||||
logger.warn('Query detected SESSION_REFRESH_REQUIRED (legacy format), initiating redirect');
|
|
||||||
|
|
||||||
const currentUrl = window.location.pathname + window.location.search;
|
|
||||||
await refreshManager.redirectToRefresh(currentUrl);
|
|
||||||
|
|
||||||
throw new Error('Redirecting to refresh session');
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
@@ -110,15 +110,11 @@ export const superTokensFunctionMiddleware = createMiddleware({
|
|||||||
throw new Response(
|
throw new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "SESSION_REFRESH_REQUIRED",
|
error: "SESSION_REFRESH_REQUIRED",
|
||||||
message: "Session needs to be refreshed",
|
message: "Session needs to be refreshed"
|
||||||
shouldRetry: true
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 440,
|
status: 401,
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" }
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Session-Expired": "true"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,15 +141,11 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({
|
|||||||
throw new Response(
|
throw new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "SESSION_REFRESH_REQUIRED",
|
error: "SESSION_REFRESH_REQUIRED",
|
||||||
message: "Session needs to be refreshed",
|
message: "Session needs to be refreshed"
|
||||||
shouldRetry: true
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 440,
|
status: 401,
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" }
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Session-Expired": "true"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user