diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f8690af..5daf001 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -80,6 +80,10 @@ Prima versione, pre-release. - L'MVP di una partita richiede ora un quorum minimo di 2 voti totali (`VOTI_MINIMI_MVP`) oltre al margine netto già richiesto: un solo voto non assegna più la vittoria (DD-028). Alcuni conteggi `mvp` già mostrati possono scendere per effetto della nuova regola. +- Cancellare un evento pulisce ora a cascata, tramite trigger database, tutte le tabelle + collegate (presenze, pagelle, MVP, badge social, turni palloni, scout) invece di lasciarle + come righe orfane (migration `m14_pulizia_dati_evento_cancellato`, DD-029). Le righe orfane + generate da cancellazioni precedenti a questa migration non vengono bonificate. ### Sicurezza diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 5587c34..a86e21c 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -46,7 +46,7 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa | Tabella | Scopo | Note | | ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. | +| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. | | `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. | | `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). | | `presenze` | Presenze agli eventi. | Come sopra (DD-014). | diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 7d280a2..3673578 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -44,6 +44,7 @@ Serve a rispondere a domande del tipo: | [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato | | [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati | | [DD-028](#dd-028--soglia-minima-di-campione-per-media-voto-e-mvp-in-home) | Soglia minima Media voto e MVP | +| [DD-029](#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati) | Pulizia a cascata evento cancellato | **In valutazione** @@ -1076,3 +1077,60 @@ votasse perché il votato "vincesse" nettamente, senza nessun quorum di partecip **Riesame** Se la squadra segnala che il quorum di 2 voti per l'MVP è troppo permissivo o troppo severo, o se si vuole applicare la stessa soglia di Media voto anche alle StatTile di profilo e squadra. + +### DD-029 — Cancellare un evento pulisce a cascata i dati collegati + +**Data:** 9 settembre 2026 +**Stato:** Accettata + +**Contesto** +Un audit del modulo Obiettivi ha verificato che gli obiettivi in sé non hanno bisogno di +nessuna pulizia quando un evento viene cancellato: sono ricalcolati a runtime sull'elenco +eventi corrente (`obiettivi.ts`), quindi un evento sparito da `eventi_app` semplicemente +smette di contare. Il problema è un livello sotto: `useEliminaEvento()` +(`src/lib/eventi.ts`) cancella solo la riga in `eventi_app`. Nessuna delle otto tabelle +collegate (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, +`badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) ha mai +avuto una foreign key verso `eventi_app(id)`: le loro righe restavano orfane a database. + +Oggi è innocuo per le statistiche, perché nessun calcolo legge quelle tabelle se non partendo +dall'elenco eventi corrente. Ma è un rischio latente: un id evento futuro identico a uno +passato (generato come `"e" + timestamp`, collisione improbabile ma non impossibile) +erediterebbe dati vecchi non suoi; e un calcolo futuro che iterasse direttamente una di quelle +tabelle, invece di partire da `eventi_app`, conterebbe anche le righe orfane. + +**Decisione** +Migration `m14_pulizia_dati_evento_cancellato`: un trigger `AFTER DELETE ON eventi_app` +cancella a cascata le righe corrispondenti (`evento_id`/`match_id = id evento cancellato`) +nelle otto tabelle collegate, tramite una funzione `SECURITY DEFINER`. Agisce a database, non +in `useEliminaEvento()`: protegge anche chi cancella un evento scrivendo direttamente su +PostgREST, non solo chi passa dal bottone dell'app. + +**Alternative scartate** + +- Foreign key con `ON DELETE CASCADE` verso `eventi_app(id)` → non applicabile subito: + `mvp.md` documenta che storicamente `match_id` in `mvp_voti`/`pagelle_voti`/ + `badge_social_voti` a volte conteneva l'id di una sessione Scout o di una partita CSI, non + l'id evento CrAPP. Un vincolo FK avrebbe rifiutato la migration alla prima riga storica + disallineata; il trigger non valida i dati esistenti, solo le cancellazioni da qui in avanti. +- Cancellazione manuale nelle otto tabelle dentro `useEliminaEvento()` → fragile: va tenuta + aggiornata a mano ogni volta che un nuovo modulo aggiunge una tabella con `evento_id`, e non + protegge chi scrive/cancella direttamente su PostgREST. +- Bonificare anche le righe orfane già esistenti nella stessa migration → rimandato: tocca dati + reali già scritti, è un intervento più delicato che merita una migration a sé, non urgente + perché quelle righe sono già invisibili a ogni calcolo attuale. + +**Conseguenze** + +- Da questa migration in poi, cancellare un evento (da qualunque punto, app o REST diretto) + ripulisce automaticamente tutte le tabelle collegate. +- Le righe orfane generate da cancellazioni **precedenti** a questa migration restano nel + database: il trigger previene il problema da qui in avanti, non ripulisce lo storico. +- `test/integration/pulizia-evento.test.ts` è la definizione eseguibile del comportamento: + scrive una riga in ciascuna delle otto tabelle, cancella l'evento e verifica che spariscano + tutte. + +**Riesame** +Se in futuro si vuole bonificare anche lo storico di righe orfane già esistenti, o se una +nuova tabella collegata a un evento non viene aggiunta al trigger quando creata (va aggiornata +a mano, non c'è un meccanismo che lo forzi). diff --git a/docs/modules/obiettivi-squadra.md b/docs/modules/obiettivi-squadra.md index 3696716..ae1a917 100644 --- a/docs/modules/obiettivi-squadra.md +++ b/docs/modules/obiettivi-squadra.md @@ -21,6 +21,14 @@ Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts` `pagelle_voti`, i risultati ufficiali CSI, le serie di presenza). `obiettiviOrdinati()` li ordina mettendo i completati in coda e gli altri per progresso decrescente. +Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi +sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento +sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia +esplicita. Il problema che *sembrava* riguardare gli obiettivi era in realtà nelle tabelle +collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la +cancellazione: risolto a livello database con un trigger (migration +`m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi. + `obiettiviSquadra(rosa, ctx, oggi)` accetta un terzo parametro opzionale `oggi: Date` (default `new Date()`) per iniettare una data deterministica nei test — usato dai due obiettivi con mese corrente dinamico (vedi sotto). diff --git a/supabase/migrations/20260909120000_m14_pulizia_dati_evento_cancellato.sql b/supabase/migrations/20260909120000_m14_pulizia_dati_evento_cancellato.sql new file mode 100644 index 0000000..e48f692 --- /dev/null +++ b/supabase/migrations/20260909120000_m14_pulizia_dati_evento_cancellato.sql @@ -0,0 +1,44 @@ +-- M14 — Pulizia a cascata dei dati collegati quando un evento viene cancellato +-- +-- Un audit del modulo Obiettivi ha verificato che gli obiettivi in sé non hanno bisogno di +-- nessuna pulizia: sono ricalcolati a runtime sull'elenco eventi corrente (`obiettivi.ts`), e +-- un evento cancellato semplicemente sparisce da quell'elenco. Il problema è un livello sotto: +-- `useEliminaEvento()` (`src/lib/eventi.ts`) cancella solo la riga in `eventi_app`, lasciando +-- orfane le righe collegate in `risposte_presenze`, `cacche_partita`, `mvp_voti`, +-- `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live` e +-- `scout_partite` — nessuna di queste ha mai avuto una foreign key verso `eventi_app(id)`. +-- +-- Una FK con ON DELETE CASCADE non è applicabile oggi: `mvp.md` documenta che storicamente +-- `match_id` in `mvp_voti`/`pagelle_voti`/`badge_social_voti` a volte conteneva l'id di una +-- sessione Scout o di una partita CSI, non l'id evento CrAPP — un vincolo FK rifiuterebbe la +-- migration alla prima riga storica disallineata. Un trigger non valida i dati esistenti, +-- solo le cancellazioni da qui in avanti, quindi è applicabile senza bonificare prima lo +-- storico (bonifica che resta un lavoro separato, se mai servirà). +-- +-- SECURITY DEFINER: le policy DELETE di alcune tabelle collegate potrebbero in futuro +-- restringersi (oggi sono tutte aperte, DD-023); il trigger deve continuare a pulire a +-- prescindere da chi ha eseguito la DELETE su eventi_app. + +CREATE OR REPLACE FUNCTION public.pulisci_dati_evento_cancellato() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + DELETE FROM public.risposte_presenze WHERE evento_id = OLD.id; + DELETE FROM public.cacche_partita WHERE evento_id = OLD.id; + DELETE FROM public.mvp_voti WHERE match_id = OLD.id; + DELETE FROM public.pagelle_voti WHERE match_id = OLD.id; + DELETE FROM public.badge_social_voti WHERE match_id = OLD.id; + DELETE FROM public.turni_palloni WHERE evento_id = OLD.id; + DELETE FROM public.scout_sessioni WHERE evento_id = OLD.id; + DELETE FROM public.scout_live WHERE evento_id = OLD.id; + DELETE FROM public.scout_partite WHERE evento_id = OLD.id; + RETURN OLD; +END; +$$; + +CREATE TRIGGER eventi_app_pulisci_dati_collegati + AFTER DELETE ON public.eventi_app + FOR EACH ROW EXECUTE FUNCTION public.pulisci_dati_evento_cancellato(); diff --git a/test/README.md b/test/README.md index f45fa96..05fe8d2 100644 --- a/test/README.md +++ b/test/README.md @@ -26,7 +26,7 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push | Cartella | Cosa verifica | Serve rete? | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route) | No | -| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`) e le letture lato server delle route push (`lettori-server`) sul database locale | Sì | +| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) sul database locale | Sì | | `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | | `helpers/` | Avvio del server di test e mini-harness condiviso | — | @@ -46,6 +46,7 @@ bun test/integration/permessi.test.ts # permessi per ruolo sulle tabelle bun test/integration/permessi-route.test.ts # chi può far partire le notifiche bun test/integration/scritture.test.ts # semantica degli upsert e vincoli bun test/integration/lettori-server.test.ts # le letture server delle route push +bun test/integration/pulizia-evento.test.ts # cascata alla cancellazione di un evento (M14) ``` `permessi-route` avvia il server di sviluppo **puntato al database locale** invece che al diff --git a/test/integration/pulizia-evento.test.ts b/test/integration/pulizia-evento.test.ts new file mode 100644 index 0000000..facef88 --- /dev/null +++ b/test/integration/pulizia-evento.test.ts @@ -0,0 +1,151 @@ +/** + * Pulizia a cascata dei dati collegati alla cancellazione di un evento (M14): + * `bun test/integration/pulizia-evento.test.ts`. + * + * Scrive una riga per ciascuna delle 9 tabelle collegate a un evento (`risposte_presenze`, + * `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, + * `scout_sessioni`, `scout_live`, `scout_partite`), cancella l'evento e verifica che il + * trigger `eventi_app_pulisci_dati_collegati` le abbia rimosse tutte. Prima di M14 queste + * righe restavano orfane a database (`docs/DESIGN_DECISIONS.md`, DD-029). + * + * Gira solo sullo stack locale (`npx supabase start`): usa id con il prefisso + * `test-pulizia-evento`, che nessun dato vero può avere. + */ +import assert from "node:assert/strict"; +import { statoLocale } from "../helpers/locale"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const locale = statoLocale(); + +if (!locale) { + salta("pulizia dati evento cancellato", "stack locale non attivo (npx supabase start)"); + riepilogo("pulizia-evento"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`pulizia dati evento cancellato su ${URL_BASE}`); + + const ID_EVENTO = "test-pulizia-evento-e1"; + + const rest = (percorso: string, init?: RequestInit) => + fetch(`${URL_BASE}/rest/v1/${percorso}`, { + ...init, + headers: { + apikey: SERVIZIO, + Authorization: `Bearer ${SERVIZIO}`, + "content-type": "application/json", + Prefer: "return=minimal", + ...(init?.headers ?? {}), + }, + }); + + async function inserisci(tabella: string, riga: Record) { + const res = await rest(tabella, { method: "POST", body: JSON.stringify(riga) }); + if (!res.ok) throw new Error(`insert su ${tabella}: ${res.status} ${await res.text()}`); + } + + async function conta(tabella: string, filtro: string): Promise { + const res = await rest(`${tabella}?${filtro}&select=*`, { + headers: { Prefer: "count=exact" }, + }); + return Number(res.headers.get("content-range")?.split("/")[1] ?? 0); + } + + const TABELLE_EVENTO_ID = [ + "risposte_presenze", + "cacche_partita", + "turni_palloni", + "scout_sessioni", + "scout_live", + "scout_partite", + ]; + const TABELLE_MATCH_ID = ["mvp_voti", "pagelle_voti", "badge_social_voti"]; + + async function pulisciTutto() { + await rest(`eventi_app?id=eq.${ID_EVENTO}`, { method: "DELETE" }); + for (const t of TABELLE_EVENTO_ID) { + await rest(`${t}?evento_id=eq.${ID_EVENTO}`, { method: "DELETE" }); + } + for (const t of TABELLE_MATCH_ID) { + await rest(`${t}?match_id=eq.${ID_EVENTO}`, { method: "DELETE" }); + } + } + + try { + await prova("cancellare un evento pulisce a cascata tutte le tabelle collegate", async () => { + await inserisci("eventi_app", { + id: ID_EVENTO, + tipo: "allenamento", + titolo: "Test pulizia", + data: "2026-09-01", + }); + + await inserisci("risposte_presenze", { + evento_id: ID_EVENTO, + giocatore_id: "test-g1", + stato: "presente", + }); + await inserisci("cacche_partita", { + evento_id: ID_EVENTO, + giocatore_id: "test-g1", + quantita: 2, + }); + await inserisci("turni_palloni", { evento_id: ID_EVENTO, giocatore_id: "test-g1" }); + await inserisci("scout_sessioni", { + evento_id: ID_EVENTO, + giocatore_id: "test-g1", + giocatore_nome: "Uno", + }); + await inserisci("scout_live", { evento_id: ID_EVENTO, stato: {} }); + await inserisci("scout_partite", { + id: `${ID_EVENTO}-scout`, + evento_id: ID_EVENTO, + data: "2026-09-01", + avversario: "Test", + set_nostri: 3, + set_loro: 0, + }); + await inserisci("mvp_voti", { + match_id: ID_EVENTO, + votante_id: "test-g1", + votato_id: "test-g2", + votato_nome: "Due", + }); + await inserisci("pagelle_voti", { + match_id: ID_EVENTO, + votante_id: "test-g1", + votato_id: "test-g2", + voto: 7, + }); + await inserisci("badge_social_voti", { + match_id: ID_EVENTO, + categoria: "top", + votante_id: "test-g1", + votato_id: "test-g2", + votato_nome: "Due", + }); + + // Tutte le righe esistono prima della cancellazione. + for (const t of TABELLE_EVENTO_ID) { + assert.equal(await conta(t, `evento_id=eq.${ID_EVENTO}`), 1, `${t}: riga presente`); + } + for (const t of TABELLE_MATCH_ID) { + assert.equal(await conta(t, `match_id=eq.${ID_EVENTO}`), 1, `${t}: riga presente`); + } + + const res = await rest(`eventi_app?id=eq.${ID_EVENTO}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`delete evento: ${res.status} ${await res.text()}`); + + // Il trigger deve aver ripulito tutte le righe collegate. + for (const t of TABELLE_EVENTO_ID) { + assert.equal(await conta(t, `evento_id=eq.${ID_EVENTO}`), 0, `${t}: pulita a cascata`); + } + for (const t of TABELLE_MATCH_ID) { + assert.equal(await conta(t, `match_id=eq.${ID_EVENTO}`), 0, `${t}: pulita a cascata`); + } + }); + } finally { + await pulisciTutto(); + } + + riepilogo("pulizia-evento"); +}