# Spec di design — Fase 3.0: Fondamenta dominio (motore eventi)

**Data:** 2026-06-09 · **Stato:** approvato (design) · **Autore:** Claude + Paolo
**Base:** master spec [`2026-06-07-tta-piattaforma-design.md`](2026-06-07-tta-piattaforma-design.md) (architettura engine già approvata) · modello dati [`../../06-modello-dati.md`](../../06-modello-dati.md) · ADR stack [`../../decisioni/2026-06-07-stack-architettura.md`](../../decisioni/2026-06-07-stack-architettura.md)
**Premessa:** Fasi 1 (auth), 2a (vetrina), 2b (anagrafiche) complete e in produzione. Paolo ha scelto la sequenza **F3.0 (fondamenta) → F3.1 (engine Bolgia) → F3.2 (admin)**. Questo spec copre **solo F3.0**.

---

## 1. Obiettivo e ambito
Costruire lo **strato dati + configurazione** su cui poggeranno engine (F3.1), admin (F3.2), giocatore, vetrina-dal-DB e migrazione. **Nessuna UI, nessun algoritmo di torneo**: solo schema relazionale, tipi, validazione `config`, infrastruttura DB e guardia ruoli.

**Deliverable testabile a sé:** lo schema applica su Neon; le FK reggono; Zod **valida** i 3 preset Bolgia e **rifiuta** una config incoerente; la guardia ruoli protegge una rotta; `check`/`build`/`test` verdi.

**Dentro:**
- Schema Drizzle completo per il sottoinsieme **V1 Bolgia-first** (tabelle elencate §3).
- Enum `kind` **completo** (tutti e 4 i valori), ma in F3.0 non si implementa nessun engine (vengono in F3.1).
- Zod `config` discriminato su `kind` + **FormatTemplate** Bolgia 13/7/4 come costanti TS validate.
- Switch del client Drizzle a **`neon-serverless`** (transazioni) + **migrazioni versionate**.
- `user.ruolo` + helper di **autorizzazione** (`requireRole`) e sessione in `event.locals`.

**Fuori (YAGNI / fasi successive):**
- Tabelle `Team`/`TeamMembership` (Palio) e colonna `bracket_node` (Duello): marcate post-V1 nel master spec → si aggiungono col loro engine. `Match.bracketNodeId` e `MatchParticipant.teamId` **non** esistono ancora.
- Qualsiasi funzione engine (seeding, round-robin, standings, promo/retro) → **F3.1**.
- Qualsiasi UI (admin, dashboard, vetrina-dal-DB) → F3.2+.
- Import dello storico → F3.5.

## 2. Decisioni d'impianto (approvate)
1. **YAGNI schema**: si creano le tabelle per Bolgia+migrazione+read-views; si **deferiscono** `Team`/`TeamMembership`/`bracketNode`. Enum `kind` resta completo (è gratis).
2. **FormatTemplate = costanti TS Zod-validate** (non una tabella `format_template`). Il "zero-deploy per cambiare formula" resta a livello evento (l'admin **duplica un evento esistente**). Rivedibile in tabella in futuro.
3. **Client Drizzle unico su `neon-serverless` (Pool)** → transazioni disponibili ovunque. Better Auth resta sullo stesso `db` (adapter agnostico al driver).
4. **Migrazioni versionate** (`drizzle-kit generate` + `migrate`, file in `drizzle/`) al posto di `push`. **Baseline pulita**: il DB non ha dati reali (0 utenti/0 player verificato), quindi la prima migrazione ricrea l'intero schema da zero (passo distruttivo ma sicuro, autorizzato dal controller).
5. **Guardia ruoli centralizzata** in `event.locals` (chiude il debito "guardia per-pagina" di Fase 2).

## 3. Schema relazionale (sottoinsieme V1)
Convenzioni: `id serial pk`, `createdAt timestamp notNull defaultNow()`, naming colonne snake_case (campi Drizzle camelCase). FK esplicite. JSONB **tipizzato** con `$type<z.infer<…>>()`.

**Persone / Auth (estensione dell'esistente)**
- **user** (Better Auth): aggiungere **`ruolo`** `text` notNull default `'player'` (valori logici `player|admin|superadmin|developer`, vincolati a livello app + Zod; non `pgEnum` per restare estendibili). Resto invariato.
- **player** (esiste): invariato (`userId?`, `nickTdg unique`, `nomeAppTta notNull unique`, `attivo`, `note`, `createdAt`).
- **playerAlias** (nuova, solo per l'importer F3.5): `playerId → player`, `aliasNick text?`, `aliasNomeApp text?`, `fonte text?`.

**Struttura eventi**
- **season**: `identificativo text notNull unique` (EST26…), `tipoPeriodo` enum `(estiva|autunnale|invernale)`, `anno int notNull`, `isCurrent bool notNull default false`, `startedAt?`, `endedAt?`.
- **event**: `slug text notNull unique`, `tipo text notNull` (hint UI: BOLGIA|TORNEO_GENERALE|DUELLO|PALIO|SUPERCOPPA|CUSTOM — **text**, non enum), `seasonId? → season`, `nome text notNull`, `stato` enum `(bozza|iscrizioni|in_corso|concluso) default bozza`, `regolamentoVersione text?`, `formatConfig jsonb` (template/default ereditabile, tipizzato), `createdBy? → user.id`.
- **phase**: `eventId → event notNull`, `ordine int notNull`, `kind` enum `(GIRONI_ROUND_ROBIN|BRACKET_ELIMINAZIONE|A_SQUADRE|PARTITA_SINGOLA) notNull`, `nome text?`, `stato` enum `(pending|seeding|running|closed) default pending`, `config jsonb` (tipizzato, validato Zod), `seedingSource jsonb?`.
- **group** (girone/serie): `phaseId → phase notNull`, `livello text notNull` (**stringa**: A|B1|…|H), `nomeTematico text?`, `dimensione int notNull`, `ordine int notNull`, `rankBase int notNull` (offset ranking globale: A=1, B=14…).

**Gioco**
- **match**: `phaseId → phase notNull`, `groupId? → group`, `serieIndex int?` (best-of), `nGiocatori int notNull` (2|3|4), `stato` enum `(programmata|in_corso|conclusa|resign|timeout) default programmata`, `linkAppCge text?`, `dataAvvioPrevista timestamp?`, `dataAvvioEffettiva timestamp?`. *(`bracketNodeId` deferito.)*
- **matchParticipant**: `matchId → match notNull`, `playerId? → player`, `isBot bool notNull default false`, `posizione int?` (1..N), `puntiCultura int?`, `puntiTorneo doublePrecision?` (derivato; può essere frazionario per parità spartite), `resign bool notNull default false`. *(`teamId` deferito.)*
- **standing**: `phaseId → phase notNull`, `groupId → group notNull`, `playerId → player notNull`, `puntiTorneo doublePrecision`, `puntiSpareggio doublePrecision`, `posizioneFinale int?`, `esito jsonb?`. Cache ricomputabile dai `matchParticipant`.

**Storico / disciplina**
- **ranking** (append-only): `playerId → player notNull`, `seasonId → season notNull`, `serie text?`, `posizioneGlobale int notNull`, `isCoda bool notNull default false`, `note text?` (rit/sql/-13/amm), `sourcePhaseId? → phase`. Storico immutabile, **sorgente del seeding** futuro; mai aggiornato in place.
- **sanction**: `playerId → player notNull`, `eventId? → event`, `tipo` enum `(timeout|ammonizione|squalifica|penalizzazione|ban)`, `valore jsonb` (es. `{rankingDelta:-5}`), `giustificato bool notNull default false`, `note text?`.

**Iscrizioni / trasversali**
- **eventRegistration**: `eventId → event notNull`, `playerId → player notNull`, `stato` enum `(richiesta|confermata|rifiutata|rinuncia|in_coda) default richiesta`, **unique(eventId, playerId)**.
- **notification**: `userId → user.id notNull`, `tipo text notNull`, `payload jsonb?`, `canale` enum `(email|push) default email`, `inviataAt timestamp?`, `lettaAt timestamp?`.
- **content**: `titolo text notNull`, `corpo text?`, `tipo` enum `(notizia|pagina) default notizia`, `pubblicataAt timestamp?`, `autoreId? → user.id`.

**Enum Postgres** (`pgEnum`): `tipo_periodo`, `event_stato`, `phase_kind`, `phase_stato`, `match_stato`, `sanction_tipo`, `registration_stato`, `notification_canale`, `content_tipo`. **Restano `text`** (estendibili, non enum): `user.ruolo`, `event.tipo`.

**Indici**: FK principali (`phase.eventId`, `group.phaseId`, `match.phaseId`, `match.groupId`, `matchParticipant.matchId`, `standing.groupId`, `ranking.playerId`, `ranking.seasonId`, `eventRegistration.eventId`); `season.isCurrent` parziale dove utile. (Dettaglio nel piano.)

## 4. Configurazione `config` + Zod (la "formula come dato")
**Regola d'oro:** nel JSONB solo ciò che **non** si interroga mai in SQL (composizione partite, tabella punti, regole promo/retro, multipli ottimali); tutto ciò che si filtra/ordina/aggrega resta **colonna** (punti, posizione, livello, dimensione, rank_base).

**`PhaseConfig` = `z.discriminatedUnion('kind', …)`** con uno schema per `kind`:
- **`GIRONI_ROUND_ROBIN`** (implementato come schema completo):
  - `dimensione: number.int().positive()` (es. 13/7/4)
  - `incontriPerCoppia: number.int().positive()` (es. 2)
  - `composizione: { n: number.int().positive(); giocatori: 2|3|4 }[]` — **programma per giocatore** (es. `[{n:6,giocatori:3},{n:4,giocatori:4}]`)
  - `tabellaPunti: Record<'2'|'3'|'4', number[]>` (es. `{'2':[4,0],'3':[10,4,0],'4':[11,6,2,0]}`)
  - `promoRetro?: { default: {promo:number; retro:number}; inverno?: {promo?:number; retro?:number} }`
  - `coda?: { multipliOttimali: number[] }` (es. 52/65/77)
  - **`.refine()` strutturali (coerenza, NON satisfiability):** ogni `giocatori ∈ {2,3,4}`; **`dimensione ≥ max(giocatori usati)`** (≥, non >: una Bolgia-4 ha gironi da 4 con partite a 4); `tabellaPunti` copre ogni `giocatori` usato e ha **esattamente `giocatori` voci** per chiave (es. chiave `'3'` → 3 punteggi); `composizione` non vuota. Il refine è applicato **sull'unione discriminata** (Zod non ammette membri `ZodEffects` dentro `discriminatedUnion`). *(La verifica "ogni coppia si incontra `incontriPerCoppia` volte" è satisfiability di round-robin → responsabilità dell'engine **F3.1**, qui non si fa.)*
- **`PARTITA_SINGOLA`** (implementato, banale): `{ nGiocatori: 2|3|4 }`.
- **`BRACKET_ELIMINAZIONE`**, **`A_SQUADRE`** (deferiti): schema **placeholder** permissivo `z.object({}).passthrough()` così l'unione è completa, ma nessun engine li consuma in V1.

Drizzle tipizza `phase.config` con `$type<PhaseConfig>()` e `event.formatConfig` con un tipo template analogo. **Unica fonte di verità TS+Zod**; validazione fail-fast effettuata da chi scrive (admin/seed), non dallo schema DB.

**FormatTemplate (costanti TS):** `BOLGIA_13`, `BOLGIA_7`, `BOLGIA_4` — oggetti `GIRONI_ROUND_ROBIN` **validati a load-time** (un preset incoerente fa fallire i test). Valori da master spec §8:
- `BOLGIA_13`: `dimensione 13`, `incontriPerCoppia 2`, `composizione [{n:6,giocatori:3},{n:4,giocatori:4}]`, `tabellaPunti {'2':[4,0],'3':[10,4,0],'4':[11,6,2,0]}`, `coda.multipliOttimali [52,65,77]`.
- `BOLGIA_7`: `dimensione 7`, `composizione [{n:6,giocatori:3}]` (incontriPerCoppia da confermare in F3.1; default coerente).
- `BOLGIA_4`: `dimensione 4`, `composizione [{n:3,giocatori:2},{n:3,giocatori:3},{n:2,giocatori:4}]`.

> ⚠️ I valori esatti di `incontriPerCoppia`/composizione per 7 e 4 sono **plausibili default** dal master spec; la loro **satisfiability** verrà verificata e, se serve, corretta in F3.1 (dove vive `generateRoundRobin`). In F3.0 contano solo per testare che Zod li accetti strutturalmente.

## 5. Infrastruttura DB
- **Dipendenza nuova: `zod`.**
- **`src/lib/server/db/index.ts`** → da `neon-http` a **`neon-serverless`**: `import { Pool } from '@neondatabase/serverless'` + `drizzle` da `drizzle-orm/neon-serverless`, `new Pool({ connectionString: env.DATABASE_URL })`. Espone `db` (+ eventuale helper `withTransaction` se comodo). Better Auth continua a ricevere lo stesso `db`.
- **Migrazioni versionate:** script `db:generate` (`drizzle-kit generate`) e `db:migrate` (`drizzle-kit migrate`); cartella `drizzle/` committata. `drizzle.config.ts` punta alla cartella schema (vedi §6). `db:push` resta solo come comodità dev, **non** è il percorso ufficiale.
- **Baseline pulita** (passo controller, autorizzato): essendo il DB privo di dati reali, si **droppano** le tabelle app esistenti e si applica la **prima migrazione** che ricrea l'intero schema (auth+player+nuove). Risultato: storico migrazioni coerente da zero.

## 6. Organizzazione file (refactor schema in cartella)
Lo schema cresce ~15 tabelle → si passa da `schema.ts` unico a **cartella `src/lib/server/db/schema/`** con file per area (responsabilità coese):
- `enums.ts` (pgEnum condivisi) · `auth.ts` (sposta l'attuale `auth-schema.ts`, + `ruolo`) · `player.ts` (player + playerAlias) · `events.ts` (season, event, phase, group) · `matches.ts` (match, matchParticipant, standing) · `ranking.ts` (ranking, sanction) · `misc.ts` (eventRegistration, notification, content) · `index.ts` (re-export di tutto).
- Config/template: **`src/lib/server/config/phase-config.ts`** (Zod + tipi) · **`src/lib/server/config/templates.ts`** (BOLGIA_13/7/4) · relativi `*.test.ts`.
- Autorizzazione: **`src/lib/server/authz.ts`** (gerarchia ruoli + `requireRole`/`requireAdmin`).
- `drizzle.config.ts` → `schema: './src/lib/server/db/schema'`. `db/index.ts` importa `* as schema` dalla cartella.
- Aggiornare gli import esistenti (`auth.ts`, `players.ts`, test) al nuovo percorso.

## 7. Autorizzazione e sessione (`event.locals`)
- **`src/app.d.ts`**: `App.Locals = { user: SessionUser | null; session: Session | null }`.
- **`src/hooks.server.ts`**: caricare la sessione una volta (`auth.api.getSession({ headers })`, saltando quando `building`) e popolare `event.locals.user/session`, poi delegare a `svelteKitHandler`.
- **`src/lib/server/authz.ts`**: gerarchia `player < admin < superadmin < developer` (mappa ordinale); `hasRole(user, min)`; `requireRole(locals, min)` → `throw redirect(302,'/login')` se non loggato, `throw error(403)` se ruolo insufficiente; `requireAdmin = requireRole(_, 'admin')`.
- **Refactor leggero (chiude il debito):** `dashboard/+page.server.ts` e `profilo/+page.server.ts` usano `event.locals` invece di richiamare `getSession` per pagina. Comportamento invariato, una sola lettura sessione per richiesta.

## 8. Test (gate)
- **Unit (offline)** — `config/*.test.ts`: i 3 preset Bolgia passano la validazione; una config incoerente è **rifiutata** (es. `tabellaPunti['3']` con 2 voci; `composizione` con `giocatori:5`; `dimensione` ≤ max giocatori); `PARTITA_SINGOLA` valida/ rifiuta; `discriminatedUnion` instrada sul `kind` giusto.
- **Unit (offline)** — `authz.test.ts`: ordinamento gerarchia, `hasRole` ai bordi.
- **Integration (opt-in, Neon)** — `db/*.integration.test.ts`: inserire `season → event → phase(kind, config) → group → match → matchParticipant` e rileggere (FK + JSONB tipizzato round-trip); `eventRegistration` rispetta `unique(eventId,playerId)`; cleanup in `afterAll`.
- **Gate**: `pnpm check` 0/0 · `pnpm test` verde offline · `pnpm build` ok · migrazione applica su Neon.

## 9. Rischi e mitigazioni
- **Better Auth + neon-serverless**: l'adapter riceve il `db` Drizzle, agnostico al driver → atteso compatibile; verifica E2E (login/signup) dopo lo switch.
- **Adozione migrazioni su DB creato con `push`**: risolta con baseline pulita (0 dati reali). Passo distruttivo esplicito e autorizzato.
- **`hooks.server.ts` getSession per richiesta**: costo DB per richiesta dinamica, accettabile a questa scala; le pagine pubbliche prerenderizzate sono statiche (hook non incide).
- **`doublePrecision` per punti frazionari**: i valori sono multipli di 0.5/percentuali, la precisione float è sufficiente per ordinare; nessun confronto di uguaglianza esatta.

## 10. Fuori scope / rimandato (riepilogo)
Engine (seeding/round-robin/standings/promo-retro) → F3.1. UI admin/giocatore → F3.2/3.3. Vetrina-dal-DB → F3.4. Import storico → F3.5. Notifiche cron → F3.6. `Team`/`TeamMembership`/`bracketNode` → con engine Palio/Duello (post-V1).
</content>
