341 lines
7.6 KiB
Markdown
341 lines
7.6 KiB
Markdown
# 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<string, StandingsEntry> = {}
|
|
|
|
// 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()
|
|
})
|
|
}
|
|
```
|