diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 21cae1a..acbc51c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -86,6 +86,9 @@ Prima versione, pre-release. - Bonificate una tantum le righe orfane lasciate da cancellazioni precedenti a M14 (migration `m15_bonifica_dati_evento_orfani`), senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI, che restano dati storici legittimi (DD-029). +- La bonifica sopra è ora anche una funzione richiamabile, `bonifica_dati_evento_orfani()` + (migration `m16_funzione_bonifica_dati_evento_orfani`, riservata al service role), coperta + da test di integrazione invece che verificata solo a mano (DD-029). ### Sicurezza diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 8239575..c600c5b 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. 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. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI. | +| `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. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. | | `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 2942a4b..1d3d7fd 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -1142,6 +1142,16 @@ migration non li tocca. Verificato manualmente sul database locale prima di appl di test su id Scout è sopravvissuto alla bonifica, un voto di test su id evento CrAPP orfano è stato rimosso. +**Aggiornamento (9 settembre 2026)** — la verifica manuale di M15 non lasciava nessuna rete di +sicurezza automatica per il futuro, a differenza del resto del progetto (DD-020). Migration +`m16_funzione_bonifica_dati_evento_orfani` rende lo stesso corpo una funzione +`bonifica_dati_evento_orfani()` (riservata al `service_role`, non richiamabile dall'app), +coperta da `test/integration/bonifica-evento.test.ts`: inserisce una riga orfana e una storica +su id Scout/CSI, richiama la funzione via RPC e verifica che tocchi solo la prima. Non serve +richiamarla di nuovo ora (M15 ha già ripulito lo storico): resta pronta come intervento di +manutenzione se in futuro il trigger di M14 smettesse di funzionare o emergesse un altro batch +di orfani. + **Riesame** 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/supabase/migrations/20260909140000_m16_funzione_bonifica_dati_evento_orfani.sql b/supabase/migrations/20260909140000_m16_funzione_bonifica_dati_evento_orfani.sql new file mode 100644 index 0000000..5aa8e65 --- /dev/null +++ b/supabase/migrations/20260909140000_m16_funzione_bonifica_dati_evento_orfani.sql @@ -0,0 +1,61 @@ +-- M16 — La bonifica di M15 diventa una funzione richiamabile e testabile +-- +-- M15 ha ripulito una tantum le righe orfane lasciate da eventi cancellati prima di M14 +-- (DD-029), con un blocco di DELETE eseguito una sola volta: non restava nulla da richiamare +-- né da testare in automatico, solo una verifica manuale fatta prima di applicarla. +-- +-- Qui lo stesso corpo diventa la funzione `bonifica_dati_evento_orfani()`: se in futuro il +-- trigger di M14 venisse per errore rimosso o disattivato, o emergesse un altro batch di +-- orfani per un motivo imprevisto, si può rilanciare `select bonifica_dati_evento_orfani();` +-- invece di riscrivere da capo la stessa query delicata — e la sua logica resta coperta da +-- un test di integrazione (`test/integration/bonifica-evento.test.ts`) invece che verificata +-- a mano una tantum. +-- +-- Riservata al service role: non è un'azione che un giocatore o un admin devono poter +-- richiamare dall'app, solo un intervento di manutenzione database. + +CREATE OR REPLACE FUNCTION public.bonifica_dati_evento_orfani() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + DELETE FROM public.risposte_presenze + WHERE evento_id NOT IN (SELECT id FROM public.eventi_app); + + DELETE FROM public.cacche_partita + WHERE evento_id NOT IN (SELECT id FROM public.eventi_app); + + DELETE FROM public.turni_palloni + WHERE evento_id NOT IN (SELECT id FROM public.eventi_app); + + DELETE FROM public.scout_sessioni + WHERE evento_id NOT IN (SELECT id FROM public.eventi_app); + + DELETE FROM public.scout_live + WHERE evento_id NOT IN (SELECT id FROM public.eventi_app); + + DELETE FROM public.scout_partite + WHERE evento_id IS NOT NULL + AND evento_id NOT IN (SELECT id FROM public.eventi_app); + + -- Solo i match_id nel formato di nuovoIdEvento() ("e" + timestamp base36): i vecchi voti + -- storici su id Scout ("s" + timestamp) o CSI (numerico o "data-squadra-squadra") non + -- rientrano nel filtro e restano intatti (vedi M15 per il dettaglio). + DELETE FROM public.mvp_voti + WHERE match_id ~ '^e[0-9a-z]+$' + AND match_id NOT IN (SELECT id FROM public.eventi_app); + + DELETE FROM public.pagelle_voti + WHERE match_id ~ '^e[0-9a-z]+$' + AND match_id NOT IN (SELECT id FROM public.eventi_app); + + DELETE FROM public.badge_social_voti + WHERE match_id ~ '^e[0-9a-z]+$' + AND match_id NOT IN (SELECT id FROM public.eventi_app); +END; +$$; + +REVOKE ALL ON FUNCTION public.bonifica_dati_evento_orfani() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.bonifica_dati_evento_orfani() TO service_role; diff --git a/test/README.md b/test/README.md index 05fe8d2..85ef903 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`), 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ì | +| `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) e la funzione di bonifica delle righe orfane (`bonifica-evento`, M16) 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,7 +46,8 @@ 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) +bun test/integration/pulizia-evento.test.ts # cascata alla cancellazione di un evento (M14) +bun test/integration/bonifica-evento.test.ts # funzione di bonifica righe orfane (M16) ``` `permessi-route` avvia il server di sviluppo **puntato al database locale** invece che al diff --git a/test/integration/bonifica-evento.test.ts b/test/integration/bonifica-evento.test.ts new file mode 100644 index 0000000..4bfac1d --- /dev/null +++ b/test/integration/bonifica-evento.test.ts @@ -0,0 +1,152 @@ +/** + * Funzione `bonifica_dati_evento_orfani()` (M16): `bun test/integration/bonifica-evento.test.ts`. + * + * M15 ha ripulito una tantum le righe orfane lasciate da eventi cancellati prima del trigger + * di M14 (DD-029); M16 ha reso permanente la stessa logica come funzione RPC, così resta + * richiamabile e testabile invece che verificata a mano una volta sola. + * + * Il punto delicato: per `mvp_voti`/`pagelle_voti`/`badge_social_voti` la funzione deve + * cancellare solo i `match_id` nel formato id evento CrAPP ("e" + timestamp base36) senza + * corrispondenza in `eventi_app` — mai i vecchi voti storici su id Scout ("s" + timestamp) o + * CSI (numerico), che sono dati legittimi mai collegati a un evento CrAPP (`docs/modules/mvp.md`). + * Questo test copre esattamente quella distinzione. + * + * Gira solo sullo stack locale (`npx supabase start`): usa id con il prefisso + * `test-bonifica-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("bonifica dati evento orfani", "stack locale non attivo (npx supabase start)"); + riepilogo("bonifica-evento"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`bonifica dati evento orfani su ${URL_BASE}`); + + const PREFISSO = "test-bonifica-evento"; + // Deve rispettare ^e[0-9a-z]+$ (formato di nuovoIdEvento()): un id con trattini non + // verrebbe mai filtrato dalla funzione, quindi non testerebbe la regola che conta. + const ORFANO = `etestbonificaevento${Date.now().toString(36)}`; + const STORICO_SCOUT = `s${Date.now()}`; // formato id Scout storico: va preservato + const STORICO_CSI = "42"; // formato id CSI storico (numerico): va preservato + + 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 esiste(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) > 0; + } + + async function pulisci() { + await rest(`risposte_presenze?evento_id=eq.${ORFANO}`, { method: "DELETE" }); + await rest(`mvp_voti?match_id=eq.${ORFANO}`, { method: "DELETE" }); + await rest(`mvp_voti?match_id=eq.${STORICO_SCOUT}`, { method: "DELETE" }); + await rest(`pagelle_voti?match_id=eq.${ORFANO}`, { method: "DELETE" }); + await rest(`pagelle_voti?match_id=eq.${STORICO_CSI}&votante_id=eq.${PREFISSO}-va`, { + method: "DELETE", + }); + } + + try { + await prova( + "bonifica_dati_evento_orfani() rimuove solo gli orfani veri, non lo storico Scout/CSI", + async () => { + // Riga orfana: id in formato evento CrAPP, nessun evento corrispondente. + await inserisci("risposte_presenze", { + evento_id: ORFANO, + giocatore_id: `${PREFISSO}-g1`, + stato: "presente", + }); + await inserisci("mvp_voti", { + match_id: ORFANO, + votante_id: `${PREFISSO}-va`, + votato_id: `${PREFISSO}-vb`, + votato_nome: "Orfano", + }); + await inserisci("pagelle_voti", { + match_id: ORFANO, + votante_id: `${PREFISSO}-va`, + votato_id: `${PREFISSO}-vb`, + voto: 6, + }); + + // Voti storici legittimi su id Scout/CSI: nessun evento CrAPP li ha mai referenziati. + await inserisci("mvp_voti", { + match_id: STORICO_SCOUT, + votante_id: `${PREFISSO}-va`, + votato_id: `${PREFISSO}-vb`, + votato_nome: "Storico", + }); + await inserisci("pagelle_voti", { + match_id: STORICO_CSI, + votante_id: `${PREFISSO}-va`, + votato_id: `${PREFISSO}-vb`, + voto: 8, + }); + + const res = await fetch(`${URL_BASE}/rest/v1/rpc/bonifica_dati_evento_orfani`, { + method: "POST", + headers: { + apikey: SERVIZIO, + Authorization: `Bearer ${SERVIZIO}`, + "content-type": "application/json", + }, + body: "{}", + }); + if (!res.ok) + throw new Error(`rpc bonifica_dati_evento_orfani: ${res.status} ${await res.text()}`); + + assert.equal( + await esiste("risposte_presenze", `evento_id=eq.${ORFANO}`), + false, + "riga orfana rimossa", + ); + assert.equal( + await esiste("mvp_voti", `match_id=eq.${ORFANO}`), + false, + "voto MVP orfano rimosso", + ); + assert.equal( + await esiste("pagelle_voti", `match_id=eq.${ORFANO}`), + false, + "voto pagella orfano rimosso", + ); + assert.equal( + await esiste("mvp_voti", `match_id=eq.${STORICO_SCOUT}`), + true, + "voto MVP storico su id Scout preservato", + ); + assert.equal( + await esiste("pagelle_voti", `match_id=eq.${STORICO_CSI}`), + true, + "voto pagella storico su id CSI preservato", + ); + }, + ); + } finally { + await pulisci(); + } + + riepilogo("bonifica-evento"); +}