# 08 — Firebase Schema (Firestore) > ⚠️ CRÍTICO: Este schema deve ser compatível com o que a app cliente já lê. > Antes de desenvolver, confirmar com o utilizador se este schema corresponde ao que já existe na Firebase. --- ## Coleções Principais ``` firestore/ ├── users/ # Utilizadores admin ├── clubs/ # Clubes da liga ├── players/ # Jogadores ├── seasons/ # Temporadas ├── rounds/ # Jornadas ├── games/ # Jogos │ └── {gameId}/ │ └── events/ # Eventos do jogo (subcollection) └── standings/ # Classificação ``` --- ## Schema Detalhado ### `users/{userId}` ```typescript type User = { uid: string; // Firebase Auth UID email: string; displayName: string; role: "admin" | "viewer"; createdAt: Timestamp; } ``` --- ### `clubs/{clubId}` ```typescript type Club = { id: string; // auto-generated name: string; // "FC Porto" shortName: string; // "FCP" city: string; // "Porto" stadium: string; // "Estádio do Dragão" primaryColor: string; // "#004899" (hex) secondaryColor: string; // "#FFFFFF" (hex) crestUrl: string; // Firebase Storage URL foundedYear: number; // 1893 active: boolean; createdAt: Timestamp; updatedAt: Timestamp; } ``` --- ### `players/{playerId}` ```typescript type Player = { id: string; clubId: string; // ref to clubs/{clubId} name: string; // "Mehdi Taremi" shortName: string; // "Taremi" nationality: string; // "Iran" dateOfBirth: Timestamp; position: "GK" | "DEF" | "MID" | "FWD"; jerseyNumber: number; photoUrl: string; // Firebase Storage URL status: "active" | "injured" | "suspended" | "inactive"; // Estatísticas (atualizadas a cada evento) stats: { goals: number; assists: number; yellowCards: number; redCards: number; gamesPlayed: number; minutesPlayed: number; }; createdAt: Timestamp; updatedAt: Timestamp; } ``` --- ### `seasons/{seasonId}` ```typescript type Season = { id: string; name: string; // "2024/2025" startDate: Timestamp; endDate: Timestamp; active: boolean; // apenas uma temporada ativa de cada vez totalRounds: number; // 34 currentRound: number; // 15 } ``` --- ### `rounds/{roundId}` ```typescript type Round = { id: string; seasonId: string; number: number; // 15 name: string; // "Jornada 15" startDate: Timestamp; endDate: Timestamp; status: "upcoming" | "active" | "completed"; } ``` --- ### `games/{gameId}` ```typescript type Game = { id: string; seasonId: string; roundId: string; roundNumber: number; // desnormalizado para queries mais simples homeClubId: string; awayClubId: string; // Desnormalizado para evitar joins no cliente homeClubName: string; awayClubName: string; homeClubShortName: string; awayClubShortName: string; homeClubCrestUrl: string; awayClubCrestUrl: string; scheduledAt: Timestamp; // data e hora agendada startedAt: Timestamp | null; endedAt: Timestamp | null; status: "scheduled" | "live" | "halftime" | "finished" | "postponed" | "cancelled"; score: { home: number; // 0 away: number; // 0 }; currentMinute: number | null; // minuto atual do jogo (se live) venue: string; // nome do estádio createdAt: Timestamp; updatedAt: Timestamp; } ``` --- ### `games/{gameId}/events/{eventId}` (subcollection) ```typescript type GameEvent = { id: string; gameId: string; type: "goal" | "yellow_card" | "red_card" | "substitution" | "penalty_missed" | "own_goal"; minute: number; // 23 extraTime: number | null; // minutos de compensação (ex: 90+3 → minute:90, extraTime:3) // Para golos e cartões playerId: string | null; playerName: string | null; // desnormalizado clubId: string; // Específico de golos goalType: "normal" | "penalty" | "free_kick" | "header" | null; assistPlayerId: string | null; assistPlayerName: string | null; // Específico de substituições playerOutId: string | null; playerInId: string | null; playerOutName: string | null; playerInName: string | null; createdAt: Timestamp; createdBy: string; // userId do admin que registou } ``` --- ### `standings/{seasonId}` ```typescript type Standings = { seasonId: string; updatedAt: Timestamp; table: StandingsEntry[]; // ordenado por pontos } type StandingsEntry = { position: number; clubId: string; clubName: string; // desnormalizado clubShortName: string; clubCrestUrl: string; played: number; won: number; drawn: number; lost: number; goalsFor: number; goalsAgainst: number; goalDifference: number; // calculado: goalsFor - goalsAgainst points: number; // calculado: won*3 + drawn*1 // Últimos 5 jogos (para a app cliente mostrar forma) form: ("W" | "D" | "L")[]; // ["W", "W", "D", "L", "W"] // Override manual (admin pode forçar um valor) manualOverride: boolean; } ``` --- ## Queries Mais Usadas ```typescript // Jogos de uma jornada query(collection(db, "games"), where("roundId", "==", roundId), orderBy("scheduledAt") ) // Jogo live atual query(collection(db, "games"), where("status", "==", "live"), limit(1) ) // Eventos de um jogo (ordenados) query(collection(db, "games", gameId, "events"), orderBy("minute"), orderBy("createdAt") ) // Jogadores de um clube query(collection(db, "players"), where("clubId", "==", clubId), where("status", "==", "active"), orderBy("jerseyNumber") ) // Top artilheiros query(collection(db, "players"), orderBy("stats.goals", "desc"), limit(20) ) ``` --- ## Índices Firestore Necessários ``` // firestore.indexes.json { "indexes": [ { "collectionGroup": "games", "queryScope": "COLLECTION", "fields": [ { "fieldPath": "roundId", "order": "ASCENDING" }, { "fieldPath": "scheduledAt", "order": "ASCENDING" } ] }, { "collectionGroup": "players", "queryScope": "COLLECTION", "fields": [ { "fieldPath": "clubId", "order": "ASCENDING" }, { "fieldPath": "stats.goals", "order": "DESCENDING" } ] }, { "collectionGroup": "players", "queryScope": "COLLECTION", "fields": [ { "fieldPath": "clubId", "order": "ASCENDING" }, { "fieldPath": "jerseyNumber", "order": "ASCENDING" } ] } ] } ``` --- ## Algoritmo de Recálculo de Standings Chamado após cada jogo finalizado ou manualmente: ```typescript async function recalculateStandings(seasonId: string, db: Firestore) { // 1. Buscar todos os jogos terminados da temporada const games = await getDocs(query( collection(db, "games"), where("seasonId", "==", seasonId), where("status", "==", "finished") )) // 2. Inicializar stats por clube const stats: Record = {} // 3. Iterar todos os jogos e acumular stats for (const game of games.docs) { const { homeClubId, awayClubId, score } = game.data() // ... calcular vitória/empate/derrota, golos, etc. } // 4. Ordenar: pontos DESC, goalDifference DESC, goalsFor DESC const table = Object.values(stats).sort((a, b) => b.points - a.points || b.goalDifference - a.goalDifference || b.goalsFor - a.goalsFor ).map((entry, i) => ({ ...entry, position: i + 1 })) // 5. Atualizar documento de standings await setDoc(doc(db, "standings", seasonId), { seasonId, table, updatedAt: serverTimestamp() }) } ```