import { db } from '@/lib/db' import { tournaments, teams, matches, goals, groupStandings, stadiums, squads } from '@/lib/db/schema' import { slugify, getIso } from '@/lib/iso-codes' import { eq, and, desc, asc, sql, ilike, or, isNotNull, lt, lte, gt, gte } from 'drizzle-orm' function teamWithSlug(t: typeof teams.$inferSelect) { return { ...t, slug: slugify(t.name), iso2: t.iso2 ?? getIso(t.name) } } async function getTeamById(id: number) { const rows = await db.select().from(teams).where(eq(teams.id, id)).limit(1) return rows[0] ? teamWithSlug(rows[0]) : null } function isLive(dateStr: string | null, timeStr: string | null): boolean { if (!dateStr) return false const now = new Date() if (now.toISOString().slice(0, 10) !== dateStr) return false if (!timeStr) return true // Parse "15:00 UTC-5" → convert to UTC kickoff time const m = timeStr.match(/^(\d{1,2}):(\d{2})(?:\s*UTC([+-]\d+(?:\.\d+)?))?/) if (!m) return true const localH = parseInt(m[1]) const localMin = parseInt(m[2]) const tzOffset = m[3] ? parseFloat(m[3]) : 0 // e.g. -5 for UTC-5 // UTC = local time - tz offset (15:00 UTC-5 → 15:00 - (-5h) = 20:00 UTC) const kickoff = new Date(`${dateStr}T00:00:00Z`) kickoff.setUTCMinutes(kickoff.getUTCMinutes() + localH * 60 + localMin - tzOffset * 60) const diffMin = (now.getTime() - kickoff.getTime()) / 60000 return diffMin >= -5 && diffMin <= 125 } async function hydrateMatch(row: typeof matches.$inferSelect) { const [t1, t2] = await Promise.all([getTeamById(row.team1Id), getTeamById(row.team2Id)]) const matchGoals = await db.select().from(goals).where(eq(goals.matchId, row.id)).orderBy(asc(goals.minute)) const goalsHydrated = await Promise.all(matchGoals.map(async g => ({ ...g, team: (await getTeamById(g.teamId)) ?? { id: g.teamId, name: '', slug: '', iso2: null, fifaCode: null, continent: null, confederation: null }, isPenalty: g.isPenalty ?? false, isOwnGoal: g.isOwnGoal ?? false, minuteOffset: g.minuteOffset ?? 0, }))) const ft = row.scoreFtHome !== null ? [row.scoreFtHome, row.scoreFtAway!] : null const ht = row.scoreHtHome !== null ? [row.scoreHtHome, row.scoreHtAway!] : null const et = row.scoreEtHome !== null ? [row.scoreEtHome, row.scoreEtAway!] : null const p = row.scorePHome !== null ? [row.scorePHome, row.scorePAway!] : null const margin = ft ? Math.abs(ft[0] - ft[1]) : null const totalGoalsCount = ft ? ft[0] + ft[1] : null return { ...row, year: row.tournamentYear, group: row.groupName, time: row.timeLocal, stadium: null, team1: t1!, team2: t2!, scoreFt: ft, scoreHt: ht, scoreEt: et, scoreP: p, goals: goalsHydrated, isLive: isLive(row.date, row.timeLocal), isQualiPlayoff: row.isQualiPlayoff ?? false, margin, totalGoals: totalGoalsCount, } } function isMissingTable(e: unknown): boolean { const msg = e instanceof Error ? e.message : String(e) return msg.includes('relation') && msg.includes('does not exist') } export const resolvers = { Query: { async tournaments() { try { const rows = await db.select().from(tournaments).orderBy(desc(tournaments.year)) return rows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null })) } catch (e) { if (isMissingTable(e)) return []; throw e } }, async tournament(_: unknown, { year }: { year: number }) { try { const rows = await db.select().from(tournaments).where(eq(tournaments.year, year)).limit(1) if (!rows[0]) return null return { ...rows[0], avgGoalsPerGame: rows[0].avgGoalsPerGame ? parseFloat(rows[0].avgGoalsPerGame) : null } } catch (e) { if (isMissingTable(e)) return null; throw e } }, async matches(_: unknown, args: { year?: number; group?: string; round?: string; isQuali?: boolean; teamId?: number }) { try { const conditions = [] if (args.year) conditions.push(eq(matches.tournamentYear, args.year)) if (args.group) conditions.push(eq(matches.groupName, args.group)) if (args.round) conditions.push(eq(matches.round, args.round)) if (args.isQuali != null) conditions.push(eq(matches.isQualiPlayoff, args.isQuali)) if (args.teamId) conditions.push(or(eq(matches.team1Id, args.teamId), eq(matches.team2Id, args.teamId))!) const rows = await db.select().from(matches) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(asc(matches.date), asc(matches.id)) return Promise.all(rows.map(hydrateMatch)) } catch (e) { if (isMissingTable(e)) return []; throw e } }, async match(_: unknown, { id }: { id: number }) { try { const rows = await db.select().from(matches).where(eq(matches.id, id)).limit(1) return rows[0] ? hydrateMatch(rows[0]) : null } catch (e) { if (isMissingTable(e)) return null; throw e } }, async liveMatches() { try { const today = new Date().toISOString().slice(0, 10) const rows = await db.select().from(matches) .where(and(eq(matches.date, today), eq(matches.isQualiPlayoff, false))) .orderBy(asc(matches.id)) const hydrated = await Promise.all(rows.map(hydrateMatch)) return hydrated.filter(m => m.isLive) } catch (e) { if (isMissingTable(e)) return []; throw e } }, async recentMatches(_: unknown, { limit = 10 }: { limit?: number }) { try { const today = new Date().toISOString().slice(0, 10) const rows = await db.select().from(matches) .where(and( lte(matches.date, today), isNotNull(matches.scoreFtHome), eq(matches.isQualiPlayoff, false), )) .orderBy(desc(matches.date), desc(matches.id)) .limit(limit) return Promise.all(rows.map(hydrateMatch)) } catch (e) { if (isMissingTable(e)) return []; throw e } }, async upcomingMatches(_: unknown, { limit = 10 }: { limit?: number }) { try { const today = new Date().toISOString().slice(0, 10) const rows = await db.select().from(matches) .where(and( gte(matches.date, today), sql`${matches.scoreFtHome} IS NULL`, eq(matches.isQualiPlayoff, false), )) .orderBy(asc(matches.date), asc(matches.id)) .limit(limit) return Promise.all(rows.map(hydrateMatch)) } catch (e) { if (isMissingTable(e)) return []; throw e } }, async teams() { try { const rows = await db.select().from(teams).orderBy(asc(teams.name)) return rows.map(teamWithSlug) } catch (e) { if (isMissingTable(e)) return []; throw e } }, async team(_: unknown, { slug }: { slug: string }) { try { const rows = await db.select().from(teams) const found = rows.find(r => slugify(r.name) === slug) return found ? teamWithSlug(found) : null } catch (e) { if (isMissingTable(e)) return null; throw e } }, async topScorers(_: unknown, { year, limit = 20, teamId }: { year?: number; limit?: number; teamId?: number }) { try { const conditions = sql` ${year ? sql`AND m.tournament_year = ${year}` : sql``} ${teamId ? sql`AND g.team_id = ${teamId}` : sql``} AND m.is_quali_playoff = false ` const rows = await db.execute(sql` SELECT g.player_name, g.team_id, COUNT(*)::int AS goals, SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties, SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals, COUNT(DISTINCT m.tournament_year)::int AS tournaments FROM goals g JOIN matches m ON g.match_id = m.id WHERE true ${conditions} GROUP BY g.player_name, g.team_id ORDER BY goals DESC LIMIT ${limit} `) return Promise.all(rows.map(async (r: Record) => ({ playerName: r.player_name, goals: r.goals, penalties: r.penalties, ownGoals: r.own_goals, tournaments: r.tournaments, team: r.team_id ? await getTeamById(r.team_id as number) : null, }))) } catch (e) { if (isMissingTable(e)) return []; throw e } }, async player(_: unknown, { name }: { name: string }) { const rows = await db.execute(sql` SELECT g.player_name, g.team_id, COUNT(*)::int AS goals, SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties, SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals, COUNT(DISTINCT m.tournament_year)::int AS tournaments FROM goals g JOIN matches m ON g.match_id = m.id WHERE LOWER(g.player_name) LIKE LOWER(${`%${name}%`}) AND m.is_quali_playoff = false GROUP BY g.player_name, g.team_id ORDER BY goals DESC LIMIT 1 `) if (!rows[0]) return null const r = rows[0] as Record return { playerName: r.player_name, goals: r.goals, penalties: r.penalties, ownGoals: r.own_goals, tournaments: r.tournaments, team: r.team_id ? await getTeamById(r.team_id as number) : null, } }, async hatTricks(_: unknown, { year }: { year?: number }) { const conditions = year ? sql`AND m.tournament_year = ${year}` : sql`` const rows = await db.execute(sql` SELECT g.player_name, g.team_id, g.match_id, COUNT(*)::int AS goals, m.tournament_year AS year, m.round, CASE WHEN m.team1_id = g.team_id THEN m.team2_id ELSE m.team1_id END AS opponent_id FROM goals g JOIN matches m ON g.match_id = m.id WHERE m.is_quali_playoff = false ${conditions} GROUP BY g.player_name, g.team_id, g.match_id, m.tournament_year, m.round, m.team1_id, m.team2_id HAVING COUNT(*) >= 3 ORDER BY goals DESC, m.tournament_year DESC `) return Promise.all(rows.map(async (r: Record) => ({ playerName: r.player_name, year: r.year, round: r.round, goals: r.goals, team: r.team_id ? await getTeamById(r.team_id as number) : null, opponent: r.opponent_id ? await getTeamById(r.opponent_id as number) : null, }))) }, async groupStandings(_: unknown, { year }: { year: number }) { try { const rows = await db.select({ groupName: groupStandings.groupName, pos: groupStandings.pos, teamId: groupStandings.teamId, played: groupStandings.played, won: groupStandings.won, drawn: groupStandings.drawn, lost: groupStandings.lost, goalsFor: groupStandings.goalsFor, goalsAgainst: groupStandings.goalsAgainst, goalDiff: groupStandings.goalDiff, pts: groupStandings.pts, }).from(groupStandings) .where(eq(groupStandings.tournamentYear, year)) .orderBy(asc(groupStandings.groupName), asc(groupStandings.pos)) return Promise.all(rows.map(async r => ({ ...r, played: r.played ?? 0, won: r.won ?? 0, drawn: r.drawn ?? 0, lost: r.lost ?? 0, goalsFor: r.goalsFor ?? 0, goalsAgainst: r.goalsAgainst ?? 0, goalDiff: r.goalDiff ?? 0, pts: r.pts ?? 0, team: (await getTeamById(r.teamId))!, }))) } catch (e) { if (isMissingTable(e)) return []; throw e } }, async stadiums(_: unknown, { year }: { year?: number }) { const rows = year ? await db.select().from(stadiums).where(eq(stadiums.tournamentYear, year)) : await db.select().from(stadiums).orderBy(asc(stadiums.name)) return rows.map(r => ({ ...r, matchCount: 0 })) }, async squads(_: unknown, { year, team }: { year: number; team?: string }) { const rows = await db.select().from(squads).where(eq(squads.tournamentYear, year)).orderBy(asc(squads.shirtNumber)) const filtered = team ? rows.filter(r => r.teamId !== null) : rows return Promise.all(filtered.map(async r => { const t = await getTeamById(r.teamId) const dob = r.dateOfBirth const age = dob ? Math.floor((Date.now() - new Date(dob).getTime()) / (1000 * 60 * 60 * 24 * 365.25)) : null return { ...r, team: t!, age } })) }, async tournamentStats() { const empty = { totalTournaments: 0, totalMatches: 0, totalGoals: 0, avgGoalsPerGame: null, mostGoalsInTournament: null, highestScoringMatch: null, biggestWin: null, mostTitles: null } try { const [totals] = await db.execute(sql` SELECT COUNT(DISTINCT t.year)::int AS total_tournaments, COUNT(DISTINCT m.id)::int AS total_matches, COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::int AS total_goals, ROUND(COALESCE(SUM(m.score_ft_home + m.score_ft_away), 0)::numeric / NULLIF(COUNT(DISTINCT m.id), 0), 2)::float AS avg_goals FROM tournaments t LEFT JOIN matches m ON m.tournament_year = t.year AND m.is_quali_playoff = false AND m.score_ft_home IS NOT NULL `) const r = totals as Record return { ...empty, totalTournaments: r.total_tournaments, totalMatches: r.total_matches, totalGoals: r.total_goals, avgGoalsPerGame: r.avg_goals } } catch (e) { if (isMissingTable(e)) return empty; throw e } }, async goalsByMinute() { const rows = await db.execute(sql` SELECT CASE WHEN g.minute BETWEEN 0 AND 15 THEN '0-15' WHEN g.minute BETWEEN 16 AND 30 THEN '16-30' WHEN g.minute BETWEEN 31 AND 45 THEN '31-45' WHEN g.minute BETWEEN 46 AND 60 THEN '46-60' WHEN g.minute BETWEEN 61 AND 75 THEN '61-75' WHEN g.minute BETWEEN 76 AND 90 THEN '76-90' WHEN g.minute > 90 THEN '90+' ELSE 'Unknown' END AS bucket, COUNT(*)::int AS count FROM goals g JOIN matches m ON g.match_id = m.id WHERE g.minute IS NOT NULL AND m.is_quali_playoff = false GROUP BY bucket ORDER BY MIN(g.minute) `) return rows as unknown as { bucket: string; count: number }[] }, async confederationStats() { const rows = await db.execute(sql` SELECT t.confederation, COUNT(DISTINCT m.tournament_year || '-' || t.id)::int AS appearances, COUNT(DISTINCT CASE WHEN tr.winner = t.name THEN tr.year END)::int AS titles, COALESCE(SUM( CASE WHEN m.team1_id = t.id THEN COALESCE(m.score_ft_home, 0) WHEN m.team2_id = t.id THEN COALESCE(m.score_ft_away, 0) ELSE 0 END ), 0)::int AS total_goals FROM teams t JOIN matches m ON (m.team1_id = t.id OR m.team2_id = t.id) AND m.is_quali_playoff = false JOIN tournaments tr ON tr.year = m.tournament_year WHERE t.confederation IS NOT NULL GROUP BY t.confederation ORDER BY appearances DESC `) return (rows as Record[]).map(r => ({ confederation: r.confederation, appearances: r.appearances, titles: r.titles, totalGoals: r.total_goals, })) }, async biggestWins(_: unknown, { limit = 10 }: { limit?: number }) { const rows = await db.select().from(matches) .where(and(isNotNull(matches.scoreFtHome), eq(matches.isQualiPlayoff, false))) .orderBy(desc(sql`ABS(${matches.scoreFtHome} - ${matches.scoreFtAway})`), desc(matches.date)) .limit(limit) return Promise.all(rows.map(hydrateMatch)) }, async highestScoringMatches(_: unknown, { limit = 10 }: { limit?: number }) { const rows = await db.select().from(matches) .where(and(isNotNull(matches.scoreFtHome), eq(matches.isQualiPlayoff, false))) .orderBy(desc(sql`${matches.scoreFtHome} + ${matches.scoreFtAway}`), desc(matches.date)) .limit(limit) return Promise.all(rows.map(hydrateMatch)) }, async extraTimeStats() { const [r] = await db.execute(sql` SELECT COUNT(*)::int AS total_knockout, SUM(CASE WHEN score_et_home IS NOT NULL THEN 1 ELSE 0 END)::int AS went_et, SUM(CASE WHEN score_p_home IS NOT NULL THEN 1 ELSE 0 END)::int AS went_pens FROM matches WHERE group_name IS NULL AND is_quali_playoff = false AND score_ft_home IS NOT NULL `) const row = r as Record const total = row.total_knockout || 1 return { totalKnockoutMatches: row.total_knockout, wentToExtraTime: row.went_et, wentToPenalties: row.went_pens, extraTimePct: Math.round((row.went_et / total) * 100), penaltiesPct: Math.round((row.went_pens / total) * 100), } }, async search(_: unknown, { query }: { query: string }) { if (!query || query.trim().length < 2) return { tournaments: [], teams: [], players: [], matches: [] } const q = `%${query.trim()}%` const [tourRows, teamRows, playerRows, matchRows] = await Promise.all([ db.select().from(tournaments).where(ilike(tournaments.host, q)).limit(5), db.select().from(teams).where(or(ilike(teams.name, q), ilike(teams.fifaCode!, q))).limit(8), db.execute(sql` SELECT g.player_name, g.team_id, COUNT(*)::int AS goals, SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties, SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals, COUNT(DISTINCT m.tournament_year)::int AS tournaments FROM goals g JOIN matches m ON g.match_id = m.id WHERE LOWER(g.player_name) LIKE LOWER(${q}) AND m.is_quali_playoff = false GROUP BY g.player_name, g.team_id ORDER BY goals DESC LIMIT 8 `), db.select().from(matches) .where(and( or(ilike(matches.round, q), ilike(matches.groupName!, q)), eq(matches.isQualiPlayoff, false), )) .limit(5), ]) return { tournaments: tourRows.map(r => ({ ...r, avgGoalsPerGame: r.avgGoalsPerGame ? parseFloat(r.avgGoalsPerGame) : null })), teams: teamRows.map(teamWithSlug), players: await Promise.all(playerRows.map(async (r: Record) => ({ playerName: r.player_name, goals: r.goals, penalties: r.penalties, ownGoals: r.own_goals, tournaments: r.tournaments, team: r.team_id ? await getTeamById(r.team_id as number) : null, }))), matches: await Promise.all(matchRows.map(hydrateMatch)), } }, }, Tournament: { async topScorers(parent: { year: number }, { limit = 10 }: { limit?: number }) { const rows = await db.execute(sql` SELECT g.player_name, g.team_id, COUNT(*)::int AS goals, SUM(CASE WHEN g.is_penalty THEN 1 ELSE 0 END)::int AS penalties, SUM(CASE WHEN g.is_own_goal THEN 1 ELSE 0 END)::int AS own_goals, 1 AS tournaments FROM goals g JOIN matches m ON g.match_id = m.id WHERE m.tournament_year = ${parent.year} AND m.is_quali_playoff = false GROUP BY g.player_name, g.team_id ORDER BY goals DESC LIMIT ${limit} `) return Promise.all(rows.map(async (r: Record) => ({ playerName: r.player_name, goals: r.goals, penalties: r.penalties, ownGoals: r.own_goals, tournaments: r.tournaments, team: r.team_id ? await getTeamById(r.team_id as number) : null, }))) }, async matches(parent: { year: number }) { const rows = await db.select().from(matches) .where(and(eq(matches.tournamentYear, parent.year), eq(matches.isQualiPlayoff, false))) .orderBy(asc(matches.date), asc(matches.id)) return Promise.all(rows.map(hydrateMatch)) }, }, Team: { async stats(parent: { id: number; name: string }) { const [r] = await db.execute(sql` SELECT COUNT(DISTINCT m.tournament_year)::int AS appearances, SUM(CASE WHEN m.team1_id = ${parent.id} AND m.score_ft_home > m.score_ft_away THEN 1 WHEN m.team2_id = ${parent.id} AND m.score_ft_away > m.score_ft_home THEN 1 ELSE 0 END)::int AS wins, SUM(CASE WHEN m.score_ft_home = m.score_ft_away AND m.score_ft_home IS NOT NULL THEN 1 ELSE 0 END)::int AS draws, SUM(CASE WHEN m.team1_id = ${parent.id} AND m.score_ft_home < m.score_ft_away THEN 1 WHEN m.team2_id = ${parent.id} AND m.score_ft_away < m.score_ft_home THEN 1 ELSE 0 END)::int AS losses, SUM(CASE WHEN m.team1_id = ${parent.id} THEN COALESCE(m.score_ft_home, 0) WHEN m.team2_id = ${parent.id} THEN COALESCE(m.score_ft_away, 0) ELSE 0 END)::int AS goals_for, SUM(CASE WHEN m.team1_id = ${parent.id} THEN COALESCE(m.score_ft_away, 0) WHEN m.team2_id = ${parent.id} THEN COALESCE(m.score_ft_home, 0) ELSE 0 END)::int AS goals_against, COUNT(DISTINCT CASE WHEN t.winner = ${parent.name} THEN t.year END)::int AS titles FROM matches m JOIN tournaments t ON t.year = m.tournament_year WHERE (m.team1_id = ${parent.id} OR m.team2_id = ${parent.id}) AND m.is_quali_playoff = false AND m.score_ft_home IS NOT NULL `) const row = r as Record const played = (row.wins ?? 0) + (row.draws ?? 0) + (row.losses ?? 0) return { appearances: row.appearances ?? 0, wins: row.wins ?? 0, draws: row.draws ?? 0, losses: row.losses ?? 0, goalsFor: row.goals_for ?? 0, goalsAgainst: row.goals_against ?? 0, goalDiff: (row.goals_for ?? 0) - (row.goals_against ?? 0), titles: row.titles ?? 0, winPct: played > 0 ? Math.round(((row.wins ?? 0) / played) * 100) : 0, } }, }, }