20 Commits
Author SHA1 Message Date
davideandClaude Opus 5 fb6bde279e Toglie la notifica di prova dalle Opzioni.
Era impalcatura per diagnosticare "le push non arrivano": ha fatto il suo
lavoro, la causa e' documentata nei limiti noti. Lasciare il pulsante avrebbe
tenuto in piedi anche la route push-prova e notificaDiProva, senza piu' nessuno
che le usa.

Via il pulsante, la funzione client, la route e i test che la coprivano. Resta
in git se dovesse servire di nuovo.

inviaPush continua a tornare { stato, corpo } e a loggare i rifiuti: serve alle
tre route che mandano notifiche, non solo alla prova.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:16:41 +02:00
davideandClaude Opus 5 5c709c1a12 Annota la causa vera del "non arriva" su Android: Chrome non svegliabile.
Su Android non riceve la webapp installata: riceve Chrome, tramite Google Play
Services. Il WebAPK e' solo l'identita' con cui la notifica viene mostrata. Se
il sistema non puo' avviare Chrome, il messaggio resta in coda e compare tutto
insieme al lancio successivo — "arriva solo quando riapro l'app".

Verificato sul campo su Motorola: con Chrome vivo in secondo piano la push
arriva ad app chiusa e schermo bloccato, WebAPK compreso. Quindi server,
cifratura, service worker, permesso notifiche e canale erano gia' corretti e
l'unica condizione che fallisce e' Chrome non in esecuzione. Mettere "Senza
restrizioni" solo su CrAPP non basta e depista.

Documentato anche come misurarlo con chrome://gcm-internals, incluso il
tranello: tenere quella scheda aperta tiene Chrome vivo e falsa la prova.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:57:05 +02:00
davideandClaude Opus 5 78d41daf4f Ritarda di 10 secondi la notifica di prova, per poter chiudere l'app.
Il pulsante si preme con l'app aperta, quindi la push arrivava sempre in primo
piano: l'unico caso che non serve provare. Ora la route aspetta `ritardoMs`
(10s di default, 25 al massimo) prima di inviare, e il toast dice di chiudere
l'app subito.

L'attesa tiene aperta la funzione, quindi il tetto e' la durata massima
concessa dall'hosting: annotato come ponytail nel codice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:44:29 +02:00
davideandClaude Opus 5 8047e58ae0 Mostra l'ora italiana nella notifica di prova.
Il server gira in UTC: `toLocaleTimeString("it-IT")` senza `timeZone` formattava
nel fuso del server, quindi la prova diceva 12:40 con un telefono che segnava le
14:40 e sembrava un orologio sfasato. E' solo il messaggio di prova: i testi
delle notifiche vere prendono data e ora dai campi dell'evento, non
dall'orologio del server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:42:05 +02:00
davideandClaude Opus 5 d8f334007b Fa arrivare le push ad app chiusa e rende diagnosticabile quando non arrivano.
Il testo della notifica viaggia ora cifrato dentro la push (aes128gcm, RFC
8188/8291) invece di essere recuperato dal service worker con una fetch al
risveglio. Era quella fetch a non chiudersi in tempo: ad app chiusa il browser
tiene vivo il worker pochi secondi, showNotification non veniva mai chiamata e
non compariva niente, mentre ad app aperta con la rete calda sembrava tutto a
posto. La POST porta anche Urgency: high, che chiede la consegna immediata
invece di far accumulare i messaggi fino al risveglio del dispositivo.

Cadono i pezzi che esistevano solo per rimediare al payload vuoto: la route
push-messaggio, la coda promemoria_push con la sua scadenza a 12 ore,
messaggioPalloniOggi() e il timeout nel worker. Tutti e tre i mittenti avevano
gia il testo pronto prima di inviare.

Il worker si aggiorna da solo all'avvio e a ogni ritorno in primo piano
(mantieniWorkerPushAggiornato): nella webapp installata quello vecchio puo
sopravvivere a lungo, e senza questo un dispositivo resterebbe fermo alla
versione che va a cercare il testo in rete.

Profilo -> Opzioni ha "Mandami una notifica di prova", visibile solo a notifiche
attive: manda una push a questo dispositivo e riporta stato HTTP, corpo della
risposta e se l'endpoint risulta davvero in push_subscriptions. Senza, "non
arriva" era cieco: ogni prova richiedeva un admin, un evento nello stato giusto
e una seconda persona, e la risposta del servizio push veniva buttata via.
inviaPush torna { stato, corpo } e logga il corpo sui rifiuti.

Dalle prove sul campo: a parita di server, iPhone installato da Home riceve ad
app chiusa. Su Android installato come webapp resta da verificare: il WebAPK e
un'app Android a se, con permesso notifiche (Android 13+) e voce batteria
distinti da quelli di Chrome. Annotato nei limiti noti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:24:07 +02:00
davideandClaude Opus 5 2b8ba340a6 Fa scadere i promemoria in coda e smette di dare la colpa alle iscrizioni.
Provando il promemoria palloni in produzione la notifica risultava inviata ma
non arrivava. La causa non era il codice: l'iscrizione di destinazione era
scaduta, FCM l'ha accettata con 2xx e ha buttato via il messaggio, e un minuto
dopo il dispositivo si è re-iscritto con un endpoint nuovo. Un 2xx dal server
push non significa consegnato, e non manda il 404/410 che farebbe pulire
push_subscriptions: è annotato fra i limiti noti, perché dal server non è
distinguibile.

Il difetto vero l'ha fatto emergere quella caccia. promemoria_push si svuota
solo quando il dispositivo legge il messaggio, quindi se la push non arriva mai
la riga resta per sempre — e push-messaggio serve la coda con priorità sul testo
calcolato. In produzione ce n'erano dieci, la più vecchia del 2 settembre: alla
notifica successiva, di qualunque tipo, quel telefono avrebbe mostrato un
sollecito presenze per un evento già passato.

Ora un promemoria vale 12 ore. La riga si cancella comunque alla prima lettura,
scaduta o no: cancellare solo le fresche lascerebbe le vecchie in coda a
dirottare ogni notifica futura, cioè il bug. Così la coda si smaltisce da sola e
le righe orfane già in produzione non vanno ripulite a mano.

Il messaggio del pulsante distingue infine i due casi che prima confondeva:
nessuna iscrizione fra gli incaricati, oppure iscrizioni presenti e invio non
riuscito. Il primo è informativo, il secondo è un errore — dirlo sbagliato
manda a cercare il problema dalla parte opposta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:25:21 +02:00
davideandClaude Opus 5 ee9f3f1b8c Il promemoria palloni lo manda un admin dalla pagina evento (DD-025).
La route era disegnata per un cron quotidiano: calcolava chi è di turno oggi e
gli mandava una push. Ma nel repository nessun cron esiste, e palloni.md lo
annotava già come "da verificare lato hosting": nei fatti quel promemoria non è
mai partito. Il segreto condiviso introdotto ieri proteggeva una porta che
nessuno apriva, al prezzo di una variabile d'ambiente da configurare ovunque.

Ora la fa partire un amministratore dal pulsante "Avvisa chi è di turno", dentro
il riquadro palloni dell'evento. La route accetta un eventoId e avvisa i
destinatari di quell'evento invece della giornata corrente: chi deve prendere i
palloni e chi deve riportarli, con un testo diverso per ciascuno. Stesso
precedente di apri-sondaggio, manuale fin dalla v1.0.6.

Il testo va in coda su promemoria_push prima dell'invio. Serve: la push parte
vuota e il service worker chiede a push-messaggio cosa mostrare, ma quella route
sa raccontare solo la giornata corrente, quindi un avviso mandato il martedì per
il sabato arriverebbe con il testo generico.

avvisiPalloniEvento() è la nuova funzione pura, con i suoi test: due destinatari
con testi diversi, un avviso solo quando sono la stessa persona, niente avvisi
per un evento inesistente o senza turni. Un'asserzione verifica che il testo non
dica mai "oggi", perché può arrivare giorni prima.

richiediSegreto e CRON_SEGRETO spariscono: tutte e tre le route di notifica
usano ora richiediAdmin, e non resta nessuna variabile da configurare.
destinatariPromemoriaPalloni() resta in palloni-core.ts con i suoi test perché
push-messaggio continua a usarla per il testo calcolato al volo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:15:39 +02:00
davideandClaude Opus 5 847972b582 Chiede le credenziali alle route che avvisano tutta la squadra (DD-024).
Le route in src/routes/api/public/ girano con la service role e saltano la RLS,
quindi DD-023 non le copre. Nessuna faceva un controllo di accesso: cercando
"authorization" in quella cartella l'unico header era lo User-Agent con cui
csi.ts chiama il portale CSI. Chiunque conoscesse l'URL poteva far suonare i
telefoni della squadra, e promemoria-palloni accetta perfino una POST con il
corpo vuoto.

La difesa apparente delle altre due — serve un id evento valido — non è una
difesa: l'id è "e" più il timestamp in base 36, compare negli URL che la squadra
si scambia ed è elencabile da qualsiasi utente loggato.

auth-route.server.ts porta i due controlli, diversi perché i chiamanti sono
diversi. apri-sondaggio e sollecita-presenze usano richiediAdmin: token della
sessione verificato con auth.getUser, poi ruolo admin da user_roles, la stessa
fonte di ruoli.ts. Il controllo precede la validazione dell'input, così la
risposta non rivela nemmeno se un evento esiste. promemoria-palloni usa
richiediSegreto, perché la chiama un cron che una sessione non ce l'ha: se
CRON_SEGRETO non è configurata la route resta chiusa con 503, perché una porta
che si riapre da sola quando manca una variabile non se ne accorge nessuno.

csi, push-config, push-subscribe e push-messaggio restano aperte: le chiamano il
browser prima del login e il service worker, dove qualsiasi segreto finirebbe
nel bundle.

Lato client i due pulsanti admin mandano il token con intestazioniAutenticate(),
letto al momento della chiamata e non da uno stato React.

permessi-route.test.ts copre il giro intero — nessun token, giocatore, admin —
avviando il server di sviluppo puntato al database locale, perché servono utenti
veri. Il controllo positivo è il 404: l'admin supera l'accesso e arriva alla
validazione. In api.test.ts restano i rifiuti che non richiedono un utente e
sparisce la verifica della validazione di sollecita-presenze, che ora sta dietro
all'accesso.

I limiti noti di palloni.md sono aggiornati: il secret che il piano originale
prevedeva ora c'è. Resta vero che nessun cron chiama la route, quindi il
promemoria quotidiano non parte da solo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:05:39 +02:00
davideandClaude Opus 5 93b61d422a Limita le scritture del database a chi le fa (M11, DD-023).
Le tabelle della v1.0 sono nate con policy USING (true) per anon e
authenticated. M4 ha tolto il GRANT ad anon e la cosa è passata per "ora è
chiuso", ma per gli autenticati non era rimasto nessun limite. Verificato sul
database locale con un utente appena creato, senza ruolo e senza slot nella
rosa: POST su eventi_app risponde 201, DELETE risponde 200. Qualsiasi giocatore
loggato poteva svuotare il calendario o riscrivere il voto di un altro parlando
direttamente con PostgREST, saltando l'interfaccia che quei pulsanti glieli
nasconde. Il permesso viveva solo nei componenti, cioè nel posto che un
attaccante non usa.

M11 fa dire alle policy quello che l'interfaccia già fa: eventi_app agli admin,
risposte_presenze e cacche_partita alla propria riga, i tre voti al proprio
votante_id. L'admin resta incluso ovunque, perché DD-017 gli riconosce già il
diritto di agire al posto del giocatore.

turni_palloni e le tabelle scout restano aperte di proposito: nell'interfaccia
non hanno nessun gate, quindi stringerle sarebbe una funzionalità nuova e non
una messa in sicurezza. Un test lo fissa, così se il gate arriva qualcuno se ne
accorge.

L'identità è lo slot di giocatori_squadra collegato all'account, con lo stesso
EXISTS delle policy dei profili: mio_giocatore_id() di M2 era già stata rimossa
dalla migration di correzione e non va reintrodotta.

I cinque test nuovi in permessi.test.ts hanno ognuno il proprio controllo
positivo — l'admin crea l'evento, il giocatore salva la propria presenza —
perché un database che rifiuta tutto passerebbe qualsiasi test di sola
negazione. Provata con db reset da zero; non applicata in produzione.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:51:27 +02:00
davideandClaude Opus 5 fe0c938358 Verifica che gli upsert dell'app scrivano sulla riga giusta.
Ogni salvataggio di CrAPP è un upsert con un onConflict scritto a mano nei hook
di src/lib/. Se quella chiave non corrisponde al vincolo UNIQUE della tabella
non arriva nessun errore: il database sovrascrive la riga sbagliata, e il difetto
si vede settimane dopo in una media che non torna. Nessuna delle sedici
scritture era mai stata eseguita da un test.

test/integration/scritture.test.ts ripete le stesse chiamate dei hook contro il
database locale e conta cosa resta nella tabella. Le due regole opposte che
nessuno verificava: le pagelle tengono un voto per ogni votato — se il conflitto
fosse su (match, votante) ogni voto cancellerebbe il precedente — mentre l'MVP
ne tiene uno solo per votante e partita. Più badge social per categoria, cacche,
turni palloni, risposte presenze (con l'istante che alimenta la serie di
conferme) e lo stato jsonb dello scout, che viene sostituito e non fuso. In più
i due CHECK su cui l'app conta: niente autovoto, voto fra 1 e 10.

Le righe usano il prefisso test-scritture e spariscono in un finally. La lettura
delle credenziali locali passa da test/helpers/locale.ts, ora che la usano due
file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:41:26 +02:00
davideandClaude Opus 5 0addafa76f Separa il calcolo delle presenze del mese dagli hook e lo copre con i test.
La percentuale di presenze degli ultimi 30 giorni decide le convocazioni, ma
stava dentro due useMemo — scritta due volte, una per giocatore e una per la
rosa — e nessun test la toccava. Le tre regole che la determinano non sono
ovvie: la finestra è di 30 giorni, il ritardo conta come presenza, e convocati
vuoto significa tutta la rosa attiva.

presenze-mese-core.ts contiene ora il calcolo puro, presenze-mese.ts solo il
collegamento agli hook. Comportamento invariato, inclusa la differenza fra i due
percorsi: per giocatore si filtrano i convocati, per la rosa l'elenco vuoto si
espande agli attivi.

Il test fissa le tre regole e i casi che le rompono: nessun evento nella
finestra dà 0 e non NaN, chi non è convocato resta fuori dal denominatore, chi
non ha mai risposto ci resta dentro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:41:14 +02:00
davideandClaude Opus 5 94673090f1 Verifica i permessi per ruolo contro il database Supabase locale.
Le policy RLS scritte su auth.uid() e il trigger di DD-016 non erano coperti da
nessun test: schema-profili prova solo l'utente anonimo, e sul database di .env
non si può scrivere perché è quello di produzione.

Il nuovo test/integration/permessi.test.ts crea utenti veri sullo stack locale
(npx supabase start) e interroga il database come loro: un giocatore vede e
modifica solo il proprio profilo, non ne cancella, non cambia numero e ruolo
mentre reclama uno slot, non prende lo slot di un altro, non si assegna il ruolo
admin e non vede i ruoli altrui. Un controllo positivo sull'admin evita il falso
verde di un database completamente chiuso.

Prende URL e chiavi da `supabase status` invece che da .env e si ferma se l'URL
non è locale: un .env puntato alla produzione non deve poter trasformare un test
in una scrittura sul database vero. Senza stack locale si salta con il motivo,
quindi la suite resta verde su una macchina senza Docker. Ogni test ripristina
in un finally lo stato che tocca, così si rilancia senza db reset.

test/README.md documenta il flusso Docker e corregge la convenzione: non è più
«nessun test scrive sul database» ma «sul database di .env non scrive nessuno».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:22:45 +02:00
davideandClaude Sonnet 5 50a712c276 Allinea l'altezza di input, select e date nei form a un'unica misura.
input[type="date"] resta più alto di un input di testo anche con
appearance-none: h-10 esplicito su classiInput allinea tutti i campi.
La textarea note usa h-auto per non perdere le due righe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 11:07:15 +02:00
davideandClaude Sonnet 5 77f587b53b Uniforma l'altezza dei <select> agli altri campi dei form su Android.
Su Android il controllo nativo di <select> ignora parte del padding di
classiInput e risultava più alto o più basso degli input accanto.
appearance-none lo riporta a una scatola CSS normale; la freccia va
ridisegnata a mano perché appearance-none la fa sparire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:57:05 +02:00
davideandClaude Sonnet 5 ee21278e82 Rimuove mem/, superata dalla documentazione in docs/.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:43:16 +02:00
davideandClaude Sonnet 5 d5035cbf55 Mostra la versione dell'app nella tab Opzioni del profilo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:40:34 +02:00
davideandClaude Sonnet 5 a8bde94153 Il link "Storico" in Ultima partita apre lo storico partite in classifica, non la rosa.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:34:03 +02:00
davideandClaude Sonnet 5 41ed3a1f0f Conta le presenze solo sugli eventi già passati, non su quelli odierni o futuri.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:29:34 +02:00
davideandClaude Sonnet 5 208dd5f2f6 Blocca la modifica delle presenze e il sollecito per eventi già passati.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:27:12 +02:00
davideandClaude Sonnet 5 d4a02fb606 Rimuove il link duplicato per tornare al calendario, basta la navbar in basso.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:21:14 +02:00
50 changed files with 2393 additions and 405 deletions
+9
View File
@@ -6,6 +6,15 @@ qui: sta in [ROADMAP.md](ROADMAP.md).
## Versione attuale — agosto 2026
### Le notifiche push arrivano anche ad app chiusa
- Il testo della notifica viaggia ora cifrato **dentro** la push (`aes128gcm`, RFC 8291)
invece di essere recuperato dal service worker con una fetch al risveglio: era quella fetch
a non arrivare mai in tempo a telefono bloccato, e la notifica non compariva affatto
(DD-026).
- Spariscono la route `/api/public/push-messaggio`, la coda `promemoria_push` e
`messaggioPalloniOggi()`: esistevano solo per rimediare al payload vuoto.
### Lo Scout Live si apre dalla pagina della partita
- Tolto dalla home, era rimasto senza nessun link: `/scout` si raggiungeva solo scrivendo
+23 -4
View File
@@ -5,6 +5,25 @@ sono le migration in `supabase/migrations/`: **una tabella nuova va documentata
stessa modifica che la crea**. Le funzionalità future stanno in [ROADMAP.md](ROADMAP.md),
non in questo file.
## Permessi di scrittura
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023). La **lettura**
resta aperta a tutti gli autenticati su ogni tabella di questo elenco; `anon` non arriva a
nessuna di esse da M4 (DD-011).
| Tabella | Chi può scrivere |
| ---------------------------------------------------------------- | ------------------------------------------------------------------- |
| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) |
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), più gli admin |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) |
| `user_roles` | solo admin |
L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`.
La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
## Anagrafica e utenti
| Tabella | Scopo | Note |
@@ -50,11 +69,11 @@ non in questo file.
## Turni e notifiche
| Tabella | Scopo | Note |
| -------------------- | --------------------------------------------- | ---- |
| Tabella | Scopo | Note |
| -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Storico dei promemoria inviati. | |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
## Funzioni speciali
+222
View File
@@ -38,6 +38,10 @@ Serve a rispondere a domande del tipo:
| [DD-020](#dd-020--una-funzione-modificata-senza-test-non-è-finita) | Test obbligatori e verdi |
| [DD-021](#dd-021--molle-interrompibili-al-posto-delle-animazioni-a-durata-fissa) | Molle interrompibili con motion |
| [DD-022](#dd-022--lapp-è-solo-chiara) | App solo chiara |
| [DD-023](#dd-023--ogni-scrittura-è-limitata-a-chi-la-fa) | Scritture limitate per ruolo |
| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate |
| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale |
| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato |
**In valutazione**
@@ -748,3 +752,221 @@ CrAPP è un'app solo chiara. Il blocco `.dark` è rimosso e `:root` dichiara
**Riesame**
Se arriva una richiesta reale dalla squadra, o se si gioca in palestre al buio abbastanza
spesso da rendere il tema scuro una funzione e non un vezzo.
### DD-023 — Ogni scrittura è limitata a chi la fa
**Data:** 6 settembre 2026
**Stato:** Accettata
**Contesto**
Le tabelle della v1.0 sono nate con policy `USING (true)` per `anon, authenticated`. M4
(DD-011) ha tolto il GRANT ad `anon`, e la cosa è stata letta come «ora è chiuso». Non lo
era: per gli autenticati non c'era rimasto nessun limite. Verificato sul database locale con
un utente appena creato, senza ruolo e senza slot nella rosa: `POST /rest/v1/eventi_app`
201, `DELETE` → 200. Qualsiasi giocatore loggato poteva svuotare il calendario della squadra
o riscrivere il voto pagella di un altro, parlando direttamente con PostgREST — senza
passare dall'interfaccia, che quei pulsanti glieli nasconde.
Il permesso viveva quindi solo nei componenti (`useIsAdmin()`, `io.id`), cioè nel posto che
un attaccante non usa.
**Decisione**
Le policy rispecchiano quello che l'interfaccia già fa. Tre gruppi:
- **solo admin**: `eventi_app`. Nell'app li scrive unicamente la rotta `/eventi`, che è già
riservata agli amministratori.
- **solo la propria riga**: `risposte_presenze`, `cacche_partita` (per `giocatore_id`),
`pagelle_voti`, `mvp_voti`, `badge_social_voti` (per `votante_id`). L'admin resta incluso,
perché DD-017 gli riconosce già il diritto di agire al posto del giocatore.
- **invariate**: `turni_palloni` e le tre tabelle scout. Nell'interfaccia non hanno nessun
gate — il turno palloni se lo passa chiunque, e chiunque può aprire lo Scout Live —
quindi stringerle sarebbe una funzionalità nuova, non una messa in sicurezza.
L'identità è lo slot di `giocatori_squadra` collegato all'account, espresso con lo stesso
`EXISTS` che usano le policy dei profili — la funzione `mio_giocatore_id()` di M2 è stata
rimossa dalla migration di correzione e non si reintroduce. Regge perché `io.id` non è una
scelta libera: dopo DD-011 e DD-018 il `localStorage` viene forzato sullo slot collegato
all'account (`benvenuto.tsx`), e senza slot non si entra.
**Alternative scartate**
- Lasciare tutto aperto e documentarlo → si può difendere per una squadra di venti persone
che si conoscono, ma non regge il primo account che passa di mano o il primo telefono
perso, e rende ogni bug indistinguibile da un dispetto.
- Controllare i permessi nelle route server → CrAPP scrive dal client con supabase-js. Ci
vorrebbe un livello API che oggi non esiste, per ottenere quello che la RLS fa da sola.
- Stringere anche palloni e scout → cambierebbe come funziona la squadra, e nessuno l'ha
chiesto.
**Conseguenze**
- Un giocatore che non ha ancora collegato lo slot non scrive più niente: l'`EXISTS` non
trova nessuna riga. È lo stesso muro di `/benvenuto`, ora applicato anche al database.
- La lettura resta aperta a tutti gli autenticati, pagelle comprese: chi interroga PostgREST
può ancora vedere **chi** ha votato cosa. L'anonimato delle pagelle è una scelta di
interfaccia, non una garanzia del database, e questa migration non lo cambia.
- I test in `test/integration/permessi.test.ts` diventano la definizione eseguibile di questa
tabella dei permessi.
**Riesame**
Se l'anonimato delle pagelle deve diventare reale (servirebbe una vista aggregata e la
chiusura della lettura riga per riga), o se turni e scout acquistano un gate
nell'interfaccia: allora le loro policy devono seguirlo.
### DD-024 — Le route che avvisano la squadra chiedono le credenziali
**Data:** 6 settembre 2026
**Stato:** Accettata
**Contesto**
Le route in `src/routes/api/public/` girano con la service role e saltano la RLS: DD-023 non
le tocca. Nessuna di loro faceva un controllo di accesso — cercando `authorization` in quella
cartella l'unico header era lo User-Agent con cui `csi.ts` chiama il portale CSI. Chiunque
conoscesse l'URL poteva quindi far suonare i telefoni di tutta la squadra:
`promemoria-palloni` accetta perfino una POST con il corpo vuoto.
La difesa apparente delle altre due — «serve un id evento valido» — non è una difesa: l'id è
`e` + il timestamp in base 36 (`nuovoIdEvento()`), compare negli URL che la squadra si
scambia, ed è elencabile da qualsiasi utente loggato.
Il danno non è furto di dati: i testi delle notifiche li costruisce il server. È molestia e
consumo della quota push. Non è però una ragione per lasciare la porta aperta.
**Decisione**
Le tre route che inviano notifiche chiedono le credenziali, con due controlli diversi perché
i chiamanti sono diversi (`src/lib/auth-route.server.ts`):
- `apri-sondaggio` e `sollecita-presenze``richiediAdmin`: token della sessione Supabase
verificato con `auth.getUser`, poi ruolo `admin` letto da `user_roles`, la stessa fonte di
`src/lib/ruoli.ts` (DD-011). `401` senza token valido, `403` con token ma senza ruolo. Il
controllo viene **prima** della validazione dell'input, così la risposta non rivela
nemmeno se un evento esiste.
- `promemoria-palloni` → all'inizio `richiediSegreto`, con un segreto da cron; sostituito
subito dopo da `richiediAdmin` quando la route è diventata manuale (DD-025).
`csi`, `push-config` e `push-subscribe` restano aperte: le chiama il browser prima del login,
dove qualsiasi segreto sarebbe pubblico. (`push-messaggio` esisteva per lo stesso motivo ed è
sparita con DD-026.)
**Alternative scartate**
- Un segreto condiviso anche per le due route dell'app → finirebbe nel bundle JavaScript,
cioè pubblico.
- Il middleware `requireSupabaseAuth` già presente nel repository → è
`createMiddleware({ type: "function" })`, protegge le server function di TanStack Start. In
CrAPP `createServerFn` non compare da nessuna parte: quel file non è mai stato eseguito,
e non si applica comunque alle route in `src/routes/api/`.
- Fidarsi dell'id evento come credenziale → è un timestamp in un URL condiviso.
**Conseguenze**
- Nessuna variabile d'ambiente da configurare: dopo DD-025 tutte e tre le route usano lo
stesso controllo sul ruolo.
- I due pulsanti dell'app mandano ora il token con `intestazioniAutenticate()`
(`src/lib/auth.ts`), letto al momento della chiamata e non da uno stato React, così non si
spedisce un token scaduto.
- `api.test.ts` non può più verificare la validazione dell'input di `sollecita-presenze`,
che ora sta dietro all'accesso: quel pezzo si è spostato in
`test/integration/permessi-route.test.ts`, che gira sullo stack locale perché ha bisogno
di utenti veri.
**Riesame**
Se un giorno l'app userà `createServerFn`, il middleware già presente diventa la strada
naturale e questi controlli vanno riletti alla sua luce.
### DD-025 — Il promemoria palloni lo manda l'admin, per un evento
**Data:** 6 settembre 2026
**Stato:** Accettata
**Contesto**
`promemoria-palloni` era disegnata per un cron quotidiano: calcolava chi è di turno **oggi** e
gli mandava una push. DD-024 l'aveva chiusa con un segreto condiviso, coerente con quel
disegno. Ma nel repository non c'è nessun cron, e `docs/modules/palloni.md` lo annotava già
come «da verificare lato hosting»: nei fatti quel promemoria non è mai partito. Una route che
funziona e che nessuno chiama.
Nel frattempo l'app aveva già il precedente giusto: `apri-sondaggio` è manuale fin dall'inizio
(«Nessun cron: l'invio è manuale», CHANGELOG v1.0.6).
**Decisione**
Il promemoria lo fa partire un amministratore dal pulsante «Avvisa chi è di turno», dentro il
riquadro palloni della pagina evento. La route accetta un `eventoId` e avvisa i destinatari di
**quell'evento** — chi deve prendere i palloni e chi deve riportarli — invece della giornata
corrente. Il controllo di accesso diventa `richiediAdmin` come le altre due, e
`richiediSegreto` con la sua variabile `CRON_SEGRETO` spariscono.
Il testo dell'avviso viaggia dentro la push, cifrato nel payload (DD-026): il service worker
lo mostra così com'è, quindi un avviso mandato il martedì per il sabato arriva col testo
giusto.
**Alternative scartate**
- Un pulsante «manda il promemoria di oggi» in Dashboard → rispecchia la route com'era, ma
premuto un martedì qualsiasi risponderebbe «inviate: 0» e sembrerebbe rotto. L'admin
ragiona per evento, non per giornata.
- Tenere il cron e configurarlo davvero → più lavoro, una variabile d'ambiente da gestire in
ogni ambiente, e nessuno l'aveva chiesto. Un pulsante che funziona batte uno scheduler che
non esiste.
- Calcolare il testo al volo sulla giornata corrente → funziona solo se l'avviso parte il
giorno stesso, cioè proprio il vincolo da cui volevamo uscire.
**Conseguenze**
- Il promemoria è ora una scelta consapevole di un admin, non un automatismo: se nessuno preme
il pulsante, non parte niente. È un passo indietro rispetto all'idea originale, ma un passo
avanti rispetto alla realtà, dove non partiva mai.
- `destinatariPromemoriaPalloni()` non è più usata dalla route ma resta in `palloni-core.ts`.
- Sparisce `CRON_SEGRETO`: nessuna variabile d'ambiente nuova da configurare in nessun
ambiente.
**Riesame**
Se la squadra si accorge che l'admin si dimentica di premere il pulsante. A quel punto il cron
torna utile, e con lui il segreto: DD-024 descrive già come farlo.
---
### DD-026 — Il testo della notifica viaggia dentro la push
**Stato:** accettata · **Data:** settembre 2026
**Contesto**
Le push partivano senza payload: il service worker, appena svegliato, chiedeva a
`/api/public/push-messaggio` che cosa mostrare. Ad app aperta funzionava; ad app chiusa e
telefono bloccato non arrivava niente. È lo scenario per cui il browser dà al worker pochi
secondi di vita: una fetch verso un endpoint che interroga Supabase è esattamente ciò che non
riesce a chiudersi in tempo, e senza `showNotification` non compare nulla. `Urgency: high` e
un timeout di 3 secondi sul recupero del testo avevano attenuato il sintomo, non la causa.
**Decisione**
Titolo e testo viaggiano cifrati nel corpo della push (`aes128gcm`, RFC 8188/8291) con le
chiavi `p256dh` e `auth` già salvate in `push_subscriptions`. Il service worker legge
`event.data.json()` e mostra la notifica: zero rete, zero attesa.
Cadono di conseguenza la route `push-messaggio`, la coda `promemoria_push` (con la sua
scadenza a 12 ore), `messaggioPalloniOggi()` e il timeout nel service worker: tutti pezzi che
esistevano solo per rimediare al payload vuoto. Tutti e tre i mittenti
(`apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`) avevano già il testo pronto
prima di inviare.
**Alternative scartate**
- Usare la libreria `web-push` → dipende da `node:crypto`, e il build nitro ha come target
Cloudflare. La cifratura sta in ~40 righe di Web Crypto, già disponibile ovunque.
- Allungare ancora il timeout della fetch → allunga anche il tempo in cui il worker può
morire prima di mostrare qualcosa. Il problema era la fetch, non la sua durata.
**Conseguenze**
- La notifica non dipende più dalla rete al momento del risveglio, e nemmeno da una funzione
serverless che risponda in fretta a freddo.
- Sparisce l'unico punto in cui chiunque conoscesse un endpoint push poteva leggere il
messaggio destinato a quel dispositivo.
- Il payload sta sotto i 4 KB: i testi attuali sono ampiamente dentro, ma un messaggio molto
lungo andrebbe accorciato.
- La tabella `promemoria_push` resta nel database, ora inutilizzata: va rimossa con una
migrazione quando si tocca lo schema.
**Riesame**
Se servisse mandare payload più grandi del limite del protocollo, o se un servizio push
smettesse di accettare corpi cifrati (nessuno lo fa: è lo standard).
+3
View File
@@ -5,6 +5,9 @@ Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previ
## In corso
- Correzione push Android pronta in locale: aggiornamento del worker nelle sessioni della
webapp e test del canale cifrato verificati. Resta la verifica su Motorola installato,
ad app chiusa e schermo bloccato; procedura e limiti in [Notifiche](modules/notifiche.md).
- Documentazione tecnica del progetto.
- Autenticazione Google e dashboard amministratore: il codice è in produzione su `main` e il
login è l'unica via d'accesso. La migration M4, che chiude gli accessi `anon` alle
+81 -23
View File
@@ -3,8 +3,7 @@
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/push-client.ts`,
`src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
`src/routes/api/public/push-messaggio.ts`, `src/routes/api/public/push-subscribe.ts`,
`public/push-sw.js`
`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`
---
@@ -21,9 +20,9 @@ indipendenti:
## Dati
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`), `promemoria_push` (coda
"consuma e cancella" del testo da mostrare — nonostante il nome, **non** è uno storico
persistente: la riga viene eliminata non appena letta dal service worker).
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`, con le chiavi `p256dh` e
`auth` con cui si cifra il payload per quel dispositivo). La tabella `promemoria_push` non è
più usata da nessuno: serviva da coda del testo quando la push partiva vuota (DD-026).
---
@@ -40,24 +39,52 @@ smart in app, che usano lo stesso service worker.
4. `POST /api/public/push-subscribe` registra endpoint e chiavi in `push_subscriptions`
(upsert).
All'avvio e quando l'app torna visibile viene richiesto l'aggiornamento della registrazione
push esistente con `ServiceWorkerRegistration.update()`. Non si chiede un nuovo permesso,
non si ricrea la sottoscrizione e non si cambia l'endpoint: anche chi ha già attivato le
notifiche deve ricevere le correzioni del worker senza spegnere e riaccendere l'interruttore.
Gli aggiornamenti contemporanei sono accorpati; un errore di rete non blocca l'app e si
riprova al ritorno in primo piano. Il worker attende `skipWaiting()` durante l'installazione.
Il browser controlla anche autonomamente gli aggiornamenti: questa richiesta esplicita
copre in particolare le sessioni lunghe della webapp (vedi il
[ciclo di vita del service worker](https://web.dev/articles/service-worker-lifecycle)).
---
## Ruolo delle tre route pubbliche
- **`push-config`** — espone la sola chiave pubblica VAPID.
- **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo.
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: mette in coda su
`promemoria_push` l'avviso di apertura del sondaggio pre-partita per **tutti** i dispositivi
iscritti e manda la push (vedi [Scout Live](scout-live.md)).
- **`push-messaggio`** — non invia nulla: il service worker la interroga **al momento della
ricezione** di una push (che arriva sempre "vuota", senza testo, per compatibilità) per
sapere quale messaggio mostrare. Priorità: un messaggio in coda su `promemoria_push`
(scritto da `sollecita-presenze`, vedi [Presenze](presenze.md)), altrimenti il messaggio
calcolato al volo sul turno palloni (vedi [Palloni](palloni.md)).
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a **tutti** i
dispositivi iscritti l'avviso di apertura del sondaggio pre-partita (vedi
[Scout Live](scout-live.md)).
L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un JWT VAPID
(ECDSA P-256) e fa una POST senza corpo all'endpoint push del browser; è riusato identico da
`sollecita-presenze.ts` e `promemoria-palloni.ts`.
(ECDSA P-256), cifra `{title, body}` per il dispositivo destinatario e fa una POST
all'endpoint push del browser; è riusato identico da `sollecita-presenze.ts`,
`promemoria-palloni.ts` e `apri-sondaggio.ts`.
Il testo viaggia **dentro** la push, cifrato in `aes128gcm` (RFC 8188/8291) con le chiavi del
dispositivo: il service worker fa `event.data.json()` e mostra la notifica senza toccare la
rete. È il punto decisivo per la consegna ad app chiusa — il browser sveglia il worker per
pochi secondi, e una fetch per recuperare il testo lo faceva morire prima di
`showNotification` (DD-026).
La POST porta `Urgency: high`. Con l'urgenza predefinita ("normal") un telefono in risparmio
energetico accumula i messaggi fino al risveglio: la notifica arriva solo quando il
dispositivo è già attivo — cioè, nella pratica, solo con l'app aperta.
### Chi può farle partire (DD-024, DD-025)
Queste route usano la service role e saltano la RLS, quindi il permesso deve stare nella
route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi il controllo
è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna
variabile d'ambiente.
| Route | Controllo | Chi la chiama |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante riservato agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
---
@@ -75,23 +102,54 @@ ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
è iscritto o no. Separare i canali richiederebbe schema e UI dedicati.
- `promemoria_push` è descritta altrove come "storico" ma nel codice è una coda che si
autocancella alla lettura: non conserva nulla.
- Nessuna verifica di autenticazione su `push-messaggio` (chiunque conosca un endpoint push
valido può leggerne il messaggio) né su `promemoria-palloni`.
- La tabella `promemoria_push` è rimasta nel database ma non la usa più nessuno (DD-026): va
eliminata con una migrazione alla prossima occasione.
- **Un 2xx dal server push non significa consegnato.** FCM accetta con 201 anche verso
registrazioni scadute e poi butta via il messaggio, senza il 404/410 che farebbe pulire
`push_subscriptions`. Il conteggio "inviate a N dispositivi" va letto come "accettate da N
server push", non come "arrivate a N telefoni".
- Compatibilità iOS/Safari non gestita esplicitamente nel codice (nessun branch dedicato):
serve l'installazione da schermata Home per funzionare, ma l'app non lo segnala
esplicitamente.
esplicitamente. È il primo sospetto quando una notifica non arriva ad app chiusa su iPhone.
- **Su Android non riceve la webapp: riceve il browser.** Il WebAPK è solo l'identità con
cui la notifica viene mostrata; la connessione con i server push la tiene Chrome, tramite
Google Play Services. Se Android non può avviare Chrome, il messaggio resta in coda e
compare tutto insieme al lancio successivo — il sintomo classico è «arriva solo quando
riapro l'app». Un 201 dal servizio push non lo distingue in alcun modo da una consegna
riuscita.
Verificato sul campo (settembre 2026, Motorola): con Chrome vivo in secondo piano la push
arriva ad app chiusa e schermo bloccato, WebAPK compreso — quindi server, cifratura,
service worker, permesso notifiche e canale erano già corretti. L'unica condizione che
fallisce è **Chrome non in esecuzione**. La cura sta in Impostazioni → App → **Chrome**
Batteria → «Senza restrizioni», più Impostazioni → Batteria → «Batteria adattiva»
disattivata. Mettere «Senza restrizioni» solo su CrAPP non basta e depista.
**Come misurarlo invece di indovinare:** `chrome://gcm-internals` sul telefono, sezione
«Receive Message Log». Se la riga porta l'orario dell'invio, il messaggio era arrivato e
non è stato mostrato (permesso o canale); se porta l'orario in cui si è riaperta l'app,
non era stato consegnato (risveglio, quindi batteria). Attenzione: tenere quella scheda
aperta **tiene Chrome vivo**, quindi falsa la prova stretta — per quella, nessuna scheda
aperta e Chrome tolto dai recenti.
- Su Motorola verificare anche le restrizioni del **browser che ha installato CrAPP** e,
dove presente, Impostazioni → Batteria → Ottimizzazione standby app. Il produttore
documenta la limitazione dei processi in background
([guida Motorola](https://help.motorola.com/hc/3505/14/global/en-us/CG2007980805.html)).
È una possibile causa del sintomo, non una diagnosi verificata sul dispositivo: il
codice web non può rimuovere questi vincoli. La verifica richiede un invio da un altro
dispositivo mentre CrAPP è chiusa e lo schermo del Motorola è bloccato. Il pulsante di
prova invia subito, quindi da solo non dimostra la ricezione in background.
- Le notifiche smart dipendono da un service worker già registrato: se il giocatore non ha
mai attivato le push, `notificaSistema()` non ha un `reg` a cui appoggiarsi e la notifica
locale non viene mai mostrata, anche con permesso concesso.
- Payload push sempre vuoto: ogni notifica richiede una fetch aggiuntiva (`push-messaggio`)
per ottenere il testo, quindi serve rete disponibile anche solo per mostrare il messaggio.
- Il payload cifrato non può superare i ~4 KB: i testi attuali stanno larghi, ma un messaggio
molto lungo verrebbe rifiutato dal servizio push.
---
## Evoluzioni possibili
- Preferenze per canale (palloni, solleciti, smart), se servono davvero alla squadra.
- Aggiungere autenticazione alle route pubbliche coinvolte.
- Eliminare `promemoria_push` con una migrazione.
- Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home).
+12 -12
View File
@@ -42,22 +42,23 @@ compaiono.
## Route API pubblica `/api/public/promemoria-palloni`
Pensata per essere chiamata quotidianamente da uno scheduler esterno (pg_cron o simile,
secondo `docs/PORTABILITA.md`), non da nessun componente client. Calcola i destinatari del
giorno — chi deve **prendere** i palloni oggi e chi deve **riportarli** (l'incaricato
dell'evento precedente) — e invia loro una push "vuota" (`src/lib/webpush.server.ts`); il
testo effettivo viene calcolato al volo dal service worker interrogando
`/api/public/push-messaggio` (vedi [Notifiche](notifiche.md)).
La fa partire un **amministratore** dal pulsante «Avvisa chi è di turno» dentro il riquadro
palloni dell'evento (`TurnoPalloni.tsx`), riservato agli admin (DD-025). Riceve l'`eventoId`,
e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve **prendere** i
palloni e chi deve **riportarli** (l'incaricato dell'evento precedente), con un testo diverso
per ciascuno.
Titolo e testo viaggiano cifrati dentro la push, quindi il service worker li mostra senza
nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
[Notifiche](notifiche.md)).
---
## Limiti noti
- **Nessuna verifica di autenticazione/secret** sulla route `promemoria-palloni`: chiunque
può invocarla via POST diretto, nonostante il piano originale prevedesse una protezione
con secret.
- **Nessun cron nel repository**: lo scheduling effettivo (se esiste) è configurato fuori dal
codice versionato — da verificare lato Supabase/hosting.
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche
possono contare turni mai effettivamente convalidati da nessuno.
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
@@ -67,7 +68,6 @@ testo effettivo viene calcolato al volo dal service worker interrogando
## Evoluzioni possibili
- Aggiungere un secret/header di autorizzazione alla route pubblica.
- Versionare il cron (es. una migration con `cron.schedule`) invece di configurarlo solo
lato dashboard.
- Escludere dalla rotazione chi ha già dichiarato assenza per l'evento.
+1 -1
View File
@@ -54,7 +54,7 @@ Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
src/routes/api/public/sollecita-presenze.ts
├─ legge l'evento (eventi_app) e le risposte già date
├─ calcola i destinatari: giocatori attivi senza risposta o con "forse"
├─ per ciascuno registra il messaggio in promemoria_push e invia una push
├─ per ciascuno invia una push col testo cifrato nel payload
│ (src/lib/webpush.server.ts)
└─ elimina le iscrizioni push scadute (404/410)
```
-4
View File
@@ -1,4 +0,0 @@
Le regole di progetto non vivono più qui: sono in `docs/`.
- Efficienza cloud → `docs/EFFICIENZA_CLOUD.md`
- Portabilità → `docs/PORTABILITA.md`
+1
View File
@@ -1,5 +1,6 @@
{
"name": "tanstack_start_ts",
"version": "0.1.0",
"private": true,
"sideEffects": false,
"type": "module",
+22 -27
View File
@@ -1,36 +1,31 @@
// Service worker dedicato alle notifiche push del turno palloni.
// Service worker dedicato alle notifiche push di CrAPP.
// Non memorizza nella cache pagine o asset dell'app.
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("install", (event) => event.waitUntil(self.skipWaiting()));
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
async function testoNotifica() {
try {
const sub = await self.registration.pushManager.getSubscription();
if (!sub) return null;
const res = await fetch("/api/public/push-messaggio", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
self.addEventListener("push", (event) => {
// Il testo arriva cifrato dentro la push: nessuna rete, nessuna attesa. A dispositivo
// dormiente il browser sveglia il worker per pochi secondi e una fetch di troppo lo fa
// terminare prima di `showNotification` — la notifica non appare affatto, mentre ad app
// aperta, con la rete calda, sembra funzionare tutto.
let dati = null;
try {
dati = event.data?.json() ?? null;
} catch {
dati = null;
}
event.waitUntil(
(async () => {
const dati = await testoNotifica();
await self.registration.showNotification(dati?.title ?? "CrAPP · Turno palloni", {
body: dati?.body ?? "Controlla il turno palloni di oggi.",
icon: "/icon-192.png",
badge: "/icon-192.png",
tag: "turno-palloni",
});
})(),
self.registration.showNotification(dati?.title ?? "CrAPP", {
body: dati?.body ?? "Apri l'app per i dettagli.",
icon: "/icon-192.png",
badge: "/icon-192.png",
// Il tag fa sostituire la notifica precedente invece di accumularne una pila;
// `renotify` fa sì che la sostituzione avvisi di nuovo, altrimenti la seconda
// notifica comparirebbe in silenzio e sembrerebbe non essere mai arrivata.
tag: "crapp-notifica",
renotify: true,
}),
);
});
+4 -2
View File
@@ -8,6 +8,7 @@ import { Barra } from "@/components/motion/Barra";
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { dataOggi } from "@/lib/scout-live";
const tipoMeta = {
partita: { label: "Partita", className: "bg-accent text-accent-foreground" },
@@ -51,6 +52,7 @@ export function EventoCard({
const totale = rosa.length;
const perc = totale ? Math.round((presentiVeri / totale) * 100) : 0;
const isCompleanno = evento.tipo === "compleanno";
const passato = evento.data < dataOggi();
if (isCompleanno) {
return (
@@ -130,7 +132,7 @@ export function EventoCard({
<button
key={s}
type="button"
disabled={salva.isPending}
disabled={salva.isPending || passato}
onClick={() =>
salva.mutate({
eventoId: evento.id,
@@ -142,7 +144,7 @@ export function EventoCard({
className={cn(
// min-h-11: sono i controlli più toccati dell'app, sotto i
// 44px si sbaglia bersaglio.
"min-h-11 rounded-full border border-border px-3 text-xs font-semibold transition-all active:scale-95",
"min-h-11 rounded-full border border-border px-3 text-xs font-semibold transition-all active:scale-95 disabled:opacity-50",
attivo
? cn(meta.className, "border-transparent shadow-card")
: "bg-background text-muted-foreground",
@@ -3,7 +3,7 @@ import { Link } from "@tanstack/react-router";
import { Check, Eye, Loader2, Upload } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Campo, classiInput, SezioneTendina } from "@/components/crapp/ui-bits";
import { Campo, classiInput, Select, SezioneTendina } from "@/components/crapp/ui-bits";
import { Reveal } from "@/components/motion/Reveal";
import {
caricaFile,
@@ -180,10 +180,9 @@ export function CampiProfilo({
<Intestazione titolo="Documento di identità" completa={sezioni.documento} />
<div className="grid grid-cols-2 gap-3">
<Campo label="Tipo">
<select
<Select
value={corrente.documentoTipo ?? ""}
onChange={(e) => aggiorna({ documentoTipo: e.target.value })}
className={classiInput}
>
<option value=""></option>
{TIPI_DOCUMENTO.map((t) => (
@@ -191,7 +190,7 @@ export function CampiProfilo({
{t}
</option>
))}
</select>
</Select>
</Campo>
<Campo label="Numero">
<input
+8 -5
View File
@@ -9,17 +9,20 @@ import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useRosa } from "@/lib/rosa";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { dataOggi } from "@/lib/scout-live";
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
export function RosaPresenze({ eventoId }: { eventoId: string }) {
export function RosaPresenze({ eventoId, data }: { eventoId: string; data: string }) {
const { risposte, isPending } = usePresenzeEvento(eventoId);
const salva = useSalvaPresenza();
const io = useGiocatoreCorrente();
const admin = useIsAdmin();
const rosa = useRosa();
const [sollecito, setSollecito] = useState(false);
const passato = data < dataOggi();
const mancanti = rosa.filter((g) => !risposte[g.id]);
const risposteN = rosa.length - mancanti.length;
@@ -31,7 +34,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
try {
const res = await fetch("/api/public/sollecita-presenze", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ eventoId, da: io?.nome }),
});
if (!res.ok) throw new Error();
@@ -91,12 +94,12 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
<button
key={s}
type="button"
disabled={salva.isPending}
disabled={salva.isPending || passato}
onClick={() =>
salva.mutate({ eventoId, giocatoreId: io.id, stato: attivo ? null : s })
}
className={cn(
"rounded-full border border-border px-2.5 py-1.5 text-xs font-semibold transition-all active:scale-95",
"rounded-full border border-border px-2.5 py-1.5 text-xs font-semibold transition-all active:scale-95 disabled:opacity-50",
attivo
? cn(statoMeta[s].className, "border-transparent shadow-card")
: "bg-background text-muted-foreground",
@@ -114,7 +117,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
<button
type="button"
onClick={sollecita}
disabled={sollecito || daSollecitare === 0}
disabled={sollecito || daSollecitare === 0 || passato}
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
>
{sollecito ? (
+2 -1
View File
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { mediaPartita, sondaggioAperto, useCacche, useSalvaCacche } from "@/lib/cacche";
@@ -51,7 +52,7 @@ export function SondaggioCacche({
try {
const res = await fetch("/api/public/apri-sondaggio", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ eventoId }),
});
if (!res.ok) throw new Error();
+49 -1
View File
@@ -1,17 +1,21 @@
import { useState } from "react";
import { Check, CircleDot, Loader2, Pencil } from "lucide-react";
import { BellRing, Check, CircleDot, Loader2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/crapp/Avatar";
import { intestazioniAutenticate } from "@/lib/auth";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
import { useIsAdmin } from "@/lib/ruoli";
import { useGiocatoreCorrente } from "@/lib/user-store";
export function TurnoPalloni({ eventoId }: { eventoId: string }) {
const [aperto, setAperto] = useState(false);
const [avviso, setAvviso] = useState(false);
const { salvati, turni, isPending } = useTurniPalloni();
const assegna = useAssegnaTurno();
const io = useGiocatoreCorrente();
const admin = useIsAdmin();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
@@ -30,6 +34,34 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
);
}
/** Avvisa via push chi è di turno per questo evento, e chi deve riportare i palloni. */
async function avvisa() {
setAvviso(true);
try {
const res = await fetch("/api/public/promemoria-palloni", {
method: "POST",
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ eventoId }),
});
if (!res.ok) throw new Error();
const dati = (await res.json()) as { inviate: number; destinatari: number };
if (dati.inviate > 0) {
toast.success(`Promemoria inviato a ${dati.inviate} dispositivi`);
} else if (dati.destinatari === 0) {
toast.info("Nessun dispositivo con le notifiche attive tra gli incaricati");
} else {
// I dispositivi ci sono ma l'invio non è riuscito: quasi sempre le chiavi VAPID
// mancanti (in locale non sono configurate). Dirlo, invece di dare la colpa
// all'assenza di iscrizioni.
toast.error(`Invio non riuscito verso ${dati.destinatari} dispositivi`);
}
} catch {
toast.error("Non sono riuscito a inviare il promemoria");
} finally {
setAvviso(false);
}
}
return (
<div className="mt-3 rounded-2xl bg-secondary/60 p-2.5">
<button
@@ -78,6 +110,22 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
))}
</div>
) : null}
{admin && giocatore ? (
<button
type="button"
onClick={avvisa}
disabled={avviso}
className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-xs font-bold uppercase text-foreground disabled:opacity-50"
>
{avviso ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<BellRing className="h-3.5 w-3.5" />
)}
Avvisa chi è di turno
</button>
) : null}
</div>
);
}
+23 -2
View File
@@ -156,9 +156,14 @@ export function SezioneTendina({
);
}
/** Classi condivise di input, select e textarea nei form dell'app. */
/**
* Classi condivise di input, select e textarea nei form dell'app. `h-10`:
* `input[type="date"]` ha al suo interno segmenti e icona nativi che, anche
* con `appearance-none`, restano un filo più alti di un input di testo a
* parità di padding un'altezza esplicita allinea tutti i campi tra loro.
*/
export const classiInput =
"w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm";
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm";
/**
* Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label
@@ -177,6 +182,22 @@ export function Campo({ label, children }: { label: string; children: ReactNode
);
}
/**
* `<select>` nativo con la stessa altezza degli altri campi del form. Il
* controllo nativo di un `<select>` ignora parzialmente il padding di
* `classiInput` (soprattutto su Android), risultando più alto o più basso
* degli input accanto: `appearance-none` lo riporta a una scatola CSS
* normale, la freccia va poi ridisegnata a mano perché sparisce con lui.
*/
export function Select(props: ComponentPropsWithoutRef<"select">) {
return (
<span className="relative block">
<select {...props} className={cn(classiInput, "appearance-none pr-8", props.className)} />
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</span>
);
}
export function StatoBadge({ stato, className }: { stato: Stato; className?: string }) {
const meta = statoMeta[stato];
return (
+48
View File
@@ -0,0 +1,48 @@
/**
* Controllo di accesso per le route in `src/routes/api/public/` che inviano notifiche
* a tutta la squadra (DD-024).
*
* Quelle route usano la service role e saltano la RLS: senza un controllo qui, chiunque
* conosca l'URL può far suonare i telefoni di tutti. L'id di un evento non è un segreto
* è un timestamp in base 36 e compare negli URL che la squadra si scambia quindi non
* può fare da credenziale.
*
* Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli
* amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della
* sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
* proseguire, altrimenti la `Response` di rifiuto già pronta.
*/
/** Il token della sessione Supabase, se la richiesta ne porta uno ben formato. */
function tokenDaRichiesta(request: Request): string | null {
const intestazione = request.headers.get("authorization");
if (!intestazione?.startsWith("Bearer ")) return null;
const token = intestazione.slice("Bearer ".length).trim();
// Un JWT ha tre segmenti: scartarlo qui evita una chiamata di rete per ogni rumore.
return token && token.split(".").length === 3 ? token : null;
}
/**
* Lascia passare solo un amministratore autenticato. `401` se manca o non vale il token,
* `403` se il token è buono ma l'utente non è admin.
*/
export async function richiediAdmin(request: Request): Promise<Response | null> {
const token = tokenDaRichiesta(request);
if (!token) return new Response("Autenticazione richiesta", { status: 401 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: utente, error } = await supabaseAdmin.auth.getUser(token);
if (error || !utente?.user) return new Response("Sessione non valida", { status: 401 });
// Stessa fonte di `src/lib/ruoli.ts`: i permessi stanno solo in `user_roles` (DD-011).
const { data: ruolo } = await supabaseAdmin
.from("user_roles")
.select("role")
.eq("user_id", utente.user.id)
.eq("role", "admin")
.maybeSingle();
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
return null;
}
+11
View File
@@ -47,6 +47,17 @@ export function useSessione() {
};
}
/**
* Intestazioni con il token della sessione, per le route server che verificano il ruolo
* (DD-024). Letta al momento della chiamata e non da uno stato React, così non si spedisce
* un token già scaduto.
*/
export async function intestazioniAutenticate(): Promise<Record<string, string>> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function accediConGoogle(): Promise<void> {
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
+38 -27
View File
@@ -107,35 +107,46 @@ export function destinatariPromemoriaPalloni(
return [...destinatari];
}
/** Testo del push per un giocatore: priorità a "riporta oggi", poi "tocca a te", poi generico. */
export function messaggioPalloniOggi(
/**
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025).
*
* Diverso da `destinatariPromemoriaPalloni`, che guarda la giornata di oggi: qui l'admin
* sceglie l'evento dalla sua pagina, quindi il messaggio nomina quell'evento e non "oggi".
* Il testo viaggia cifrato dentro la push, quindi il service worker lo mostra senza rete.
*/
export function avvisiPalloniEvento(
turni: Record<string, string>,
eventi: Evento[],
oggi: string,
mioId: string,
nome: string,
): { title: string; body: string } {
for (const evento of eventiDelGiorno(eventi, oggi)) {
const prima = eventoPrecedente(eventi, evento.id);
if (prima && turni[prima.id] === mioId) {
return {
title: "Porta i palloni oggi",
body: `${evento.titolo} · ${evento.ora}. I palloni li hai tu dalla volta scorsa.`,
};
}
if (turni[evento.id] === mioId) {
const dopo = eventoSuccessivo(eventi, evento.id);
return {
title: "Tocca a te prendere i palloni",
body: dopo
? `A fine ${evento.titolo} porta a casa i palloni e riportali il ${formatData(dopo.data)}.`
: `A fine ${evento.titolo} porta a casa i palloni.`,
};
}
eventoId: string,
): Array<{ giocatoreId: string; titolo: string; testo: string }> {
const evento = eventiPalloni(eventi).find((e) => e.id === eventoId);
if (!evento) return [];
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
const quando = `${evento.titolo} · ${formatData(evento.data)} alle ${evento.ora}`;
const incaricato = turni[evento.id];
if (incaricato) {
const dopo = eventoSuccessivo(eventi, evento.id);
avvisi.push({
giocatoreId: incaricato,
titolo: "Tocca a te prendere i palloni",
testo: dopo
? `${quando}: a fine evento porti a casa i palloni e li riporti il ${formatData(dopo.data)}.`
: `${quando}: a fine evento porti a casa i palloni.`,
});
}
return {
title: "CrAPP · Turno palloni",
body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.",
};
// Chi li ha presi la volta scorsa deve ricordarsi di portarli.
const prima = eventoPrecedente(eventi, evento.id);
const precedente = prima ? turni[prima.id] : undefined;
if (precedente && precedente !== incaricato) {
avvisi.push({
giocatoreId: precedente,
titolo: "Porta i palloni",
testo: `${quando}: i palloni li hai tu dalla volta scorsa.`,
});
}
return avvisi;
}
+72
View File
@@ -0,0 +1,72 @@
import type { Evento } from "./eventi";
import type { MappaPresenze } from "./presenze";
/**
* Calcolo puro della percentuale di presenze dell'ultimo mese, separato dagli hook
* (`presenze-mese.ts`) per poterlo testare: le tre regole che decidono il numero
* finestra di 30 giorni, il ritardo che conta come presenza, `convocati` vuoto uguale
* a tutta la rosa stanno qui.
*/
export type StatistichePresenza = { presenti: number; totali: number; percentuale: number };
const NESSUNA: StatistichePresenza = { presenti: 0, totali: 0, percentuale: 0 };
/** Chi è arrivato in ritardo era comunque all'allenamento: conta come presente. */
const eraPresente = (stato: string | undefined) => stato === "presente" || stato === "ritardo";
/** Finestra di 30 giorni che si chiude oggi, in date ISO. */
export function finestraMese(oggi: Date = new Date()): { da: string; a: string } {
const inizio = new Date(oggi);
inizio.setDate(inizio.getDate() - 30);
return { da: inizio.toISOString().slice(0, 10), a: oggi.toISOString().slice(0, 10) };
}
/** Partite e allenamenti dentro la finestra: gli altri eventi non contano. */
function eventiDelMese(eventi: Evento[], oggi: Date): Evento[] {
const { da, a } = finestraMese(oggi);
return eventi.filter(
(e) => (e.tipo === "partita" || e.tipo === "allenamento") && e.data >= da && e.data <= a,
);
}
function conPercentuale(presenti: number, totali: number): StatistichePresenza {
return { presenti, totali, percentuale: totali ? Math.round((presenti / totali) * 100) : 0 };
}
/** Presenze dell'ultimo mese di un singolo giocatore. */
export function presenzeUltimoMese(
giocatoreId: string | undefined,
eventi: Evento[],
presenze: MappaPresenze,
oggi: Date = new Date(),
): StatistichePresenza {
if (!giocatoreId) return NESSUNA;
const rilevanti = eventiDelMese(eventi, oggi).filter(
(e) => e.convocati.length === 0 || e.convocati.includes(giocatoreId),
);
const presenti = rilevanti.filter((e) => eraPresente(presenze[e.id]?.[giocatoreId])).length;
return conPercentuale(presenti, rilevanti.length);
}
/**
* Presenze dell'ultimo mese di tutta la rosa, in una mappa per id. Un evento senza
* convocati vale per tutti gli attivi, uno con l'elenco solo per i convocati.
*/
export function presenzeUltimoMeseTutti(
eventi: Evento[],
presenze: MappaPresenze,
idRosa: string[],
oggi: Date = new Date(),
): Record<string, StatistichePresenza> {
const out: Record<string, StatistichePresenza> = {};
for (const e of eventiDelMese(eventi, oggi)) {
for (const id of e.convocati.length > 0 ? e.convocati : idRosa) {
const rec = (out[id] ??= { presenti: 0, totali: 0, percentuale: 0 });
rec.totali += 1;
if (eraPresente(presenze[e.id]?.[id])) rec.presenti += 1;
}
}
for (const [id, rec] of Object.entries(out)) out[id] = conPercentuale(rec.presenti, rec.totali);
return out;
}
+14 -56
View File
@@ -2,77 +2,35 @@ import { useMemo } from "react";
import { useEventi } from "./eventi";
import { useRispostePresenze } from "./presenze";
import { useGiocatoriSquadra } from "./giocatori-squadra";
import {
presenzeUltimoMese,
presenzeUltimoMeseTutti,
type StatistichePresenza,
} from "./presenze-mese-core";
/**
* Percentuale di presenze dell'ultimo mese (30 giorni), utile per le convocazioni.
* Usa solo cache già in memoria: nessuna query aggiuntiva.
* Usa solo cache già in memoria: nessuna query aggiuntiva. Il calcolo sta in
* `presenze-mese-core.ts`, qui c'è solo il collegamento agli hook.
*/
export function usePresenzeUltimoMese(giocatoreId: string | undefined) {
export function usePresenzeUltimoMese(giocatoreId: string | undefined): StatistichePresenza {
const { eventi } = useEventi();
const { presenze } = useRispostePresenze();
return useMemo(() => {
if (!giocatoreId) return { presenti: 0, totali: 0, percentuale: 0 };
const oggi = new Date();
const inizio = new Date(oggi);
inizio.setDate(inizio.getDate() - 30);
const da = inizio.toISOString().slice(0, 10);
const a = oggi.toISOString().slice(0, 10);
const rilevanti = eventi.filter(
(e) =>
(e.tipo === "partita" || e.tipo === "allenamento") &&
e.data >= da &&
e.data <= a &&
(e.convocati.length === 0 || e.convocati.includes(giocatoreId)),
);
const presenti = rilevanti.filter((e) => {
const stato = presenze[e.id]?.[giocatoreId];
return stato === "presente" || stato === "ritardo";
}).length;
const totali = rilevanti.length;
return {
presenti,
totali,
percentuale: totali ? Math.round((presenti / totali) * 100) : 0,
};
}, [eventi, presenze, giocatoreId]);
return useMemo(
() => presenzeUltimoMese(giocatoreId, eventi, presenze),
[eventi, presenze, giocatoreId],
);
}
/** Percentuale presenze ultimi 30 giorni per tutti i giocatori (mappa per id). */
export function usePresenzeUltimoMeseTutti(): Record<
string,
{ presenti: number; totali: number; percentuale: number }
> {
export function usePresenzeUltimoMeseTutti(): Record<string, StatistichePresenza> {
const { eventi } = useEventi();
const { presenze } = useRispostePresenze();
const { righe: squadra } = useGiocatoriSquadra();
return useMemo(() => {
const oggi = new Date();
const inizio = new Date(oggi);
inizio.setDate(inizio.getDate() - 30);
const da = inizio.toISOString().slice(0, 10);
const a = oggi.toISOString().slice(0, 10);
const idRosa = squadra.filter((g) => g.attivo).map((g) => g.id);
const rilevanti = eventi.filter(
(e) => (e.tipo === "partita" || e.tipo === "allenamento") && e.data >= da && e.data <= a,
);
const out: Record<string, { presenti: number; totali: number; percentuale: number }> = {};
for (const e of rilevanti) {
const ids = e.convocati.length > 0 ? e.convocati : idRosa;
for (const id of ids) {
const rec = (out[id] ??= { presenti: 0, totali: 0, percentuale: 0 });
rec.totali += 1;
const stato = presenze[e.id]?.[id];
if (stato === "presente" || stato === "ritardo") rec.presenti += 1;
}
}
for (const rec of Object.values(out)) {
rec.percentuale = rec.totali ? Math.round((rec.presenti / rec.totali) * 100) : 0;
}
return out;
return presenzeUltimoMeseTutti(eventi, presenze, idRosa);
}, [eventi, presenze, squadra]);
}
+4 -1
View File
@@ -3,6 +3,7 @@ import { supabase } from "@/integrations/supabase/client";
import type { Stato } from "./crapp-data";
import type { Evento } from "./eventi";
import { aggiornaSerie } from "./serie";
import { dataOggi } from "./scout-live";
export const PRESENZE_KEY = ["risposte-presenze"] as const;
@@ -12,11 +13,13 @@ export type MappaPresenze = Record<string, Record<string, Stato>>;
/** eventoId -> giocatoreId -> istante della prima risposta (ISO). */
export type MappaTempiRisposta = Record<string, Record<string, string>>;
/** Allenamenti e partite CrAPP che contano per le statistiche di presenza. */
/** Allenamenti e partite CrAPP già passati, che contano per le statistiche di presenza. */
function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string) {
const oggi = dataOggi();
return eventi.filter(
(e) =>
(e.tipo === "partita" || e.tipo === "allenamento") &&
e.data < oggi &&
(giocatoreId === undefined || e.convocati.length === 0 || e.convocati.includes(giocatoreId)),
);
}
+24
View File
@@ -21,6 +21,30 @@ export function pushSupportato() {
);
}
/** Aggiorna il worker già installato anche nelle sessioni lunghe della webapp. */
export function mantieniWorkerPushAggiornato(): () => void {
if (!pushSupportato()) return () => {};
let inCorso = false;
const aggiorna = async () => {
if (document.visibilityState !== "visible" || inCorso) return;
inCorso = true;
try {
const reg = await navigator.serviceWorker.getRegistration("/push-sw.js");
// Non registrare né iscrivere chi non ha mai attivato le notifiche.
await reg?.update();
} catch {
// Offline: il worker attivo resta valido. Si riprova al prossimo ritorno nell'app.
} finally {
inCorso = false;
}
};
void aggiorna();
document.addEventListener("visibilitychange", aggiorna);
return () => document.removeEventListener("visibilitychange", aggiorna);
}
export async function statoNotifiche(): Promise<boolean> {
if (!pushSupportato()) return false;
const reg = await navigator.serviceWorker.getRegistration("/push-sw.js");
+115 -7
View File
@@ -1,4 +1,4 @@
/** Invio di notifiche web push (senza payload) firmate con VAPID, compatibile con il runtime edge. */
/** Invio di notifiche web push con payload cifrato (RFC 8291), firmate con VAPID, compatibile con il runtime edge. */
function base64UrlDecode(value: string): Uint8Array {
const padding = "=".repeat((4 - (value.length % 4)) % 4);
@@ -17,6 +17,16 @@ function base64UrlEncode(bytes: Uint8Array): string {
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function concat(...parti: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parti.reduce((n, p) => n + p.length, 0));
let offset = 0;
for (const parte of parti) {
out.set(parte, offset);
offset += parte.length;
}
return out;
}
function encodeJson(value: unknown): string {
return base64UrlEncode(new TextEncoder().encode(JSON.stringify(value)));
}
@@ -60,23 +70,121 @@ async function creaVapidJwt(
return `${header}.${payload}.${base64UrlEncode(new Uint8Array(firma))}`;
}
/** Invia una notifica "vuota": il service worker recupera poi il testo aggiornato. */
export async function inviaPush(endpoint: string): Promise<number> {
/** Un dispositivo iscritto: endpoint del servizio push e chiavi con cui cifrare per lui. */
export type IscrizionePush = { endpoint: string; p256dh: string; auth: string };
async function hkdf(salt: Uint8Array, ikm: Uint8Array, info: Uint8Array, byte: number) {
const chiave = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, [
"deriveBits",
]);
const bits = await crypto.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: salt as BufferSource, info: info as BufferSource },
chiave,
byte * 8,
);
return new Uint8Array(bits);
}
function etichetta(testo: string): Uint8Array {
return concat(new TextEncoder().encode(testo), new Uint8Array([0]));
}
/**
* Cifra il testo della notifica nel corpo della push, in formato `aes128gcm`.
*
* Esportata solo per i test: il payload viaggia dentro la push proprio perché il service
* worker non abbia bisogno della rete per sapere cosa mostrare a dispositivo dormiente
* il browser lo sveglia per pochi secondi e una fetch di troppo lo fa morire prima di
* `showNotification`, cioè niente notifica ad app chiusa.
*/
export async function cifraPayload(
iscrizione: IscrizionePush,
testoInChiaro: string,
): Promise<Uint8Array> {
const uaPublic = base64UrlDecode(iscrizione.p256dh);
const authSecret = base64UrlDecode(iscrizione.auth);
const salt = crypto.getRandomValues(new Uint8Array(16));
const effimera = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
"deriveBits",
]);
const asPublic = new Uint8Array(await crypto.subtle.exportKey("raw", effimera.publicKey));
const uaKey = await crypto.subtle.importKey(
"raw",
uaPublic as BufferSource,
{ name: "ECDH", namedCurve: "P-256" },
false,
[],
);
const condiviso = new Uint8Array(
await crypto.subtle.deriveBits({ name: "ECDH", public: uaKey }, effimera.privateKey, 256),
);
const ikm = await hkdf(
authSecret,
condiviso,
concat(etichetta("WebPush: info"), uaPublic, asPublic),
32,
);
const cek = await hkdf(salt, ikm, etichetta("Content-Encoding: aes128gcm"), 16);
const nonce = await hkdf(salt, ikm, etichetta("Content-Encoding: nonce"), 12);
// Il delimitatore 0x02 chiude l'ultimo (e unico) record: il testo sta sotto i 4 KB.
const chiaro = concat(new TextEncoder().encode(testoInChiaro), new Uint8Array([2]));
const aes = await crypto.subtle.importKey("raw", cek as BufferSource, "AES-GCM", false, [
"encrypt",
]);
const cifrato = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce as BufferSource },
aes,
chiaro as BufferSource,
),
);
// Intestazione RFC 8188: salt(16) | record size(4) | lunghezza chiave(1) | chiave(65).
const dimensioneRecord = new Uint8Array(4);
new DataView(dimensioneRecord.buffer).setUint32(0, 4096);
return concat(salt, dimensioneRecord, new Uint8Array([asPublic.length]), asPublic, cifrato);
}
/**
* Invia la notifica: titolo e testo viaggiano cifrati dentro la push.
*
* Torna anche il corpo della risposta, non solo lo stato: un servizio push che rifiuta
* spiega il perché dentro, e buttarlo via lasciava i guasti indistinguibili fra loro.
*/
export async function inviaPush(
iscrizione: IscrizionePush,
titolo: string,
testo: string,
): Promise<{ stato: number; corpo: string }> {
const publicKey = process.env["VAPID_PUBLIC_KEY"];
const privateKey = process.env["VAPID_PRIVATE_KEY"];
const subject = process.env["VAPID_SUBJECT"] ?? "mailto:crapp@crapvolley.it";
if (!publicKey || !privateKey) throw new Error("Chiavi VAPID non configurate");
const audience = new URL(endpoint).origin;
const audience = new URL(iscrizione.endpoint).origin;
const jwt = await creaVapidJwt(audience, subject, publicKey, privateKey);
const corpo = await cifraPayload(iscrizione, JSON.stringify({ title: titolo, body: testo }));
const res = await fetch(endpoint, {
const res = await fetch(iscrizione.endpoint, {
method: "POST",
headers: {
TTL: "86400",
// Senza `Urgency` il servizio push usa "normal", e un telefono in risparmio
// energetico (doze) accumula i messaggi normali fino al risveglio: la notifica
// arriva solo quando il dispositivo è già attivo, cioè quando l'app è aperta.
// "high" chiede la consegna immediata anche a schermo spento (RFC 8030 §5.3).
Urgency: "high",
Authorization: `vapid t=${jwt}, k=${publicKey}`,
"Content-Length": "0",
"Content-Encoding": "aes128gcm",
"Content-Type": "application/octet-stream",
},
body: corpo as BodyInit,
});
return res.status;
const risposta = (await res.text().catch(() => "")).slice(0, 500);
if (!res.ok) console.error("inviaPush", res.status, risposta);
return { stato: res.status, corpo: risposta };
}
-21
View File
@@ -24,7 +24,6 @@ import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
import { Route as ApiPublicPushMessaggioRouteImport } from './routes/api/public/push-messaggio'
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
import { Route as ApiPublicSollecitaPresenzeRouteImport } from './routes/api/public/sollecita-presenze'
@@ -104,11 +103,6 @@ const ApiPublicPushConfigRoute = ApiPublicPushConfigRouteImport.update({
path: '/api/public/push-config',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicPushMessaggioRoute = ApiPublicPushMessaggioRouteImport.update({
id: '/api/public/push-messaggio',
path: '/api/public/push-messaggio',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicPushSubscribeRoute = ApiPublicPushSubscribeRouteImport.update({
id: '/api/public/push-subscribe',
path: '/api/public/push-subscribe',
@@ -137,7 +131,6 @@ export interface FileRoutesByFullPath {
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute
'/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
}
@@ -157,7 +150,6 @@ export interface FileRoutesByTo {
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute
'/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
}
@@ -178,7 +170,6 @@ export interface FileRoutesById {
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute
'/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
}
@@ -200,7 +191,6 @@ export interface FileRouteTypes {
| '/api/public/csi'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
| '/api/public/push-messaggio'
| '/api/public/push-subscribe'
| '/api/public/sollecita-presenze'
fileRoutesByTo: FileRoutesByTo
@@ -220,7 +210,6 @@ export interface FileRouteTypes {
| '/api/public/csi'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
| '/api/public/push-messaggio'
| '/api/public/push-subscribe'
| '/api/public/sollecita-presenze'
id:
@@ -240,7 +229,6 @@ export interface FileRouteTypes {
| '/api/public/csi'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
| '/api/public/push-messaggio'
| '/api/public/push-subscribe'
| '/api/public/sollecita-presenze'
fileRoutesById: FileRoutesById
@@ -261,7 +249,6 @@ export interface RootRouteChildren {
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
ApiPublicPushMessaggioRoute: typeof ApiPublicPushMessaggioRoute
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
ApiPublicSollecitaPresenzeRoute: typeof ApiPublicSollecitaPresenzeRoute
}
@@ -373,13 +360,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiPublicPushConfigRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/push-messaggio': {
id: '/api/public/push-messaggio'
path: '/api/public/push-messaggio'
fullPath: '/api/public/push-messaggio'
preLoaderRoute: typeof ApiPublicPushMessaggioRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/push-subscribe': {
id: '/api/public/push-subscribe'
path: '/api/public/push-subscribe'
@@ -413,7 +393,6 @@ const rootRouteChildren: RootRouteChildren = {
ApiPublicCsiRoute: ApiPublicCsiRoute,
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
ApiPublicPushMessaggioRoute: ApiPublicPushMessaggioRoute,
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
ApiPublicSollecitaPresenzeRoute: ApiPublicSollecitaPresenzeRoute,
}
+3
View File
@@ -19,6 +19,7 @@ import { Toaster } from "../components/ui/sonner";
import { TeamLogo } from "../components/crapp/ui-bits";
import { useGiocatoreBase } from "../lib/user-store";
import { useSessione } from "../lib/auth";
import { mantieniWorkerPushAggiornato } from "../lib/push-client";
function NotFoundComponent() {
return (
@@ -175,6 +176,8 @@ function AppShell() {
const [mounted, setMounted] = useState(false);
const isBenvenuto = location.pathname === "/benvenuto";
useEffect(mantieniWorkerPushAggiornato, []);
// Senza sessione Google non si entra: l'identità la dà il login, non la scelta del nome
// (DD-011). Si aspetta `pronta`, altrimenti il primo render sloggato rimbalzerebbe fuori
// chi ha già la sessione in localStorage.
+12 -10
View File
@@ -16,7 +16,14 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Campo, classiInput, PageHeader, Section, StatTile } from "@/components/crapp/ui-bits";
import {
Campo,
classiInput,
PageHeader,
Section,
Select,
StatTile,
} from "@/components/crapp/ui-bits";
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
import {
nomeCompleto,
@@ -212,10 +219,9 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
/>
</Campo>
<Campo label="Ruolo">
<select
<Select
value={squadraCorrente.ruolo}
onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })}
className={classiInput}
>
<option value=""></option>
{RUOLI.map((r) => (
@@ -223,7 +229,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
{r}
</option>
))}
</select>
</Select>
</Campo>
</div>
<Campo label="Email (collegamento automatico al login, DD-018)">
@@ -536,18 +542,14 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
/>
</Campo>
<Campo label="Ruolo">
<select
value={dati.ruolo}
onChange={(e) => setDati({ ...dati, ruolo: e.target.value })}
className={classiInput}
>
<Select value={dati.ruolo} onChange={(e) => setDati({ ...dati, ruolo: e.target.value })}>
<option value=""></option>
{RUOLI.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</Select>
</Campo>
</div>
<Campo label="Email (collegamento automatico al login, opzionale)">
+3 -18
View File
@@ -1,5 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, MapPin, Clock, Users, Dumbbell, CalendarDays } from "lucide-react";
import { createFileRoute } from "@tanstack/react-router";
import { MapPin, Clock, Users, Dumbbell, CalendarDays } from "lucide-react";
import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { formatData } from "@/lib/crapp-data";
import { convocatiEvento, useEvento } from "@/lib/eventi";
@@ -43,12 +43,6 @@ function AllenamentoDetail() {
if (!evento) {
return (
<div className="px-5 pt-8">
<Link
to="/calendario"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" /> Torna al calendario
</Link>
<p className="mt-8 text-center text-sm text-muted-foreground">Allenamento non trovato</p>
</div>
);
@@ -56,15 +50,6 @@ function AllenamentoDetail() {
return (
<>
<div className="px-5 pt-4">
<Link
to="/calendario"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" /> Calendario
</Link>
</div>
<PageHeader titolo="Allenamento" sottotitolo={formatData(evento.data)} />
<Section titolo={evento.titolo}>
@@ -106,7 +91,7 @@ function AllenamentoDetail() {
</Section>
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} />
<RosaPresenze eventoId={evento.id} data={evento.data} />
</Section>
</>
);
+6 -5
View File
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { leggiEventi } from "@/lib/eventi.server";
import { inviaPush } from "@/lib/webpush.server";
@@ -10,6 +11,9 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
server: {
handlers: {
POST: async ({ request }) => {
const negato = await richiediAdmin(request);
if (negato) return negato;
const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
@@ -20,7 +24,7 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint");
.select("endpoint, p256dh, auth");
const titolo = "💩 Sondaggio pre-partita aperto";
const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`;
@@ -28,10 +32,7 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
try {
await supabaseAdmin
.from("promemoria_push")
.insert({ endpoint: iscrizione.endpoint, titolo, testo });
const stato = await inviaPush(iscrizione.endpoint);
const { stato } = await inviaPush(iscrizione, titolo, testo);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
+32 -11
View File
@@ -1,14 +1,32 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { nomeCompleto } from "@/lib/giocatori-squadra";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
import { completaTurni, destinatariPromemoriaPalloni, oggiISO } from "@/lib/palloni-core";
import { avvisiPalloniEvento, completaTurni } from "@/lib/palloni-core";
import { inviaPush } from "@/lib/webpush.server";
import { leggiEventi } from "@/lib/eventi.server";
const schema = z.object({ eventoId: z.string().min(1).max(50) });
/**
* Promemoria del turno palloni per un evento: lo fa partire un admin dalla pagina
* dell'evento (DD-025). Il testo viaggia cifrato dentro la push.
*/
export const Route = createFileRoute("/api/public/promemoria-palloni")({
server: {
handlers: {
POST: async () => {
POST: async ({ request }) => {
const negato = await richiediAdmin(request);
if (negato) return negato;
const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const eventi = await leggiEventi();
const evento = eventi.find((e) => e.id === parsed.data.eventoId);
if (!evento) return new Response("Evento non trovato", { status: 404 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: righe } = await supabaseAdmin
@@ -16,27 +34,30 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
.select("evento_id, giocatore_id");
const salvati: Record<string, string> = {};
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
const eventi = await leggiEventi();
const squadra = await leggiGiocatoriSquadra();
const rosa = squadra
.filter((g) => g.attivo)
.map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
const turni = completaTurni(salvati, eventi, rosa);
const oggi = oggiISO();
const destinatari = destinatariPromemoriaPalloni(turni, eventi, oggi);
if (destinatari.length === 0) return Response.json({ inviate: 0 });
const avvisi = avvisiPalloniEvento(turni, eventi, evento.id);
if (avvisi.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, giocatore_id")
.in("giocatore_id", destinatari);
.select("endpoint, giocatore_id, p256dh, auth")
.in(
"giocatore_id",
avvisi.map((a) => a.giocatoreId),
);
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
const avviso = avvisi.find((a) => a.giocatoreId === iscrizione.giocatore_id);
if (!avviso) continue;
try {
const stato = await inviaPush(iscrizione.endpoint);
const { stato } = await inviaPush(iscrizione, avviso.titolo, avviso.testo);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
@@ -50,7 +71,7 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
}
}
return Response.json({ inviate });
return Response.json({ inviate, destinatari: (iscrizioni ?? []).length });
},
},
},
-62
View File
@@ -1,62 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { nomeCompleto } from "@/lib/giocatori-squadra";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
import { completaTurni, messaggioPalloniOggi, oggiISO } from "@/lib/palloni-core";
import { leggiEventi } from "@/lib/eventi.server";
const schema = z.object({ endpoint: z.string().url().max(1000) });
export const Route = createFileRoute("/api/public/push-messaggio")({
server: {
handlers: {
POST: async ({ request }) => {
const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
// Messaggio in coda (es. sollecito presenze): ha la precedenza e viene consumato.
const { data: promemoria } = await supabaseAdmin
.from("promemoria_push")
.select("id, titolo, testo")
.eq("endpoint", parsed.data.endpoint)
.order("creato_il", { ascending: false })
.limit(1)
.maybeSingle();
if (promemoria) {
await supabaseAdmin.from("promemoria_push").delete().eq("endpoint", parsed.data.endpoint);
return Response.json({ title: promemoria.titolo, body: promemoria.testo });
}
const { data: iscrizione } = await supabaseAdmin
.from("push_subscriptions")
.select("giocatore_id")
.eq("endpoint", parsed.data.endpoint)
.maybeSingle();
if (!iscrizione)
return Response.json({ title: "CrAPP", body: "Controlla il turno palloni." });
const { data: righe } = await supabaseAdmin
.from("turni_palloni")
.select("evento_id, giocatore_id");
const salvati: Record<string, string> = {};
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
const eventi = await leggiEventi();
const squadra = await leggiGiocatoriSquadra();
const rosa = squadra
.filter((g) => g.attivo)
.map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
const turni = completaTurni(salvati, eventi, rosa);
const oggi = oggiISO();
const mioId = iscrizione.giocatore_id;
const giocatore = squadra.find((g) => g.id === mioId);
const nome = giocatore ? nomeCompleto(giocatore) : "";
return Response.json(messaggioPalloniOggi(turni, eventi, oggi, mioId, nome));
},
},
},
});
+6 -5
View File
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { formatData } from "@/lib/crapp-data";
import { leggiEventi } from "@/lib/eventi.server";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
@@ -14,6 +15,9 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
server: {
handlers: {
POST: async ({ request }) => {
const negato = await richiediAdmin(request);
if (negato) return negato;
const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
@@ -42,7 +46,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, giocatore_id")
.select("endpoint, p256dh, auth")
.in("giocatore_id", destinatari);
const titolo = "Manca la tua risposta";
@@ -53,10 +57,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
try {
await supabaseAdmin
.from("promemoria_push")
.insert({ endpoint: iscrizione.endpoint, titolo, testo });
const stato = await inviaPush(iscrizione.endpoint);
const { stato } = await inviaPush(iscrizione, titolo, testo);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
+13 -1
View File
@@ -10,7 +10,18 @@ import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-co
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
import { useEventi } from "@/lib/eventi";
const TAB_CLASSIFICA = ["classifica", "storico"] as const;
type TabClassifica = (typeof TAB_CLASSIFICA)[number];
function isTabClassifica(v: unknown): v is TabClassifica {
return typeof v === "string" && (TAB_CLASSIFICA as readonly string[]).includes(v);
}
export const Route = createFileRoute("/classifica")({
validateSearch: (search: Record<string, unknown>): { tab?: TabClassifica } => {
const tab = isTabClassifica(search["tab"]) ? search["tab"] : undefined;
return tab ? { tab } : {};
},
head: () => ({
meta: [
{ title: "Classifica campionato — CrAPP" },
@@ -33,6 +44,7 @@ function formatAggiornamento(iso: string) {
}
function Classifica() {
const { tab } = Route.useSearch();
const scoutMatches = useScoutMatches();
const { data: csi } = useCsi();
const votiMvp = useVotiMvp();
@@ -70,7 +82,7 @@ function Classifica() {
/>
<BarraSottosezioni
defaultId="classifica"
defaultId={tab ?? "classifica"}
voci={[
{
id: "classifica",
+3 -12
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
import { createFileRoute } from "@tanstack/react-router";
import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Campo, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits";
@@ -101,15 +101,6 @@ function GestioneEventi() {
return (
<>
<div className="px-5 pt-4">
<Link
to="/calendario"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" /> Calendario
</Link>
</div>
<PageHeader titolo="Gestione eventi" sottotitolo={`${eventi.length} eventi in calendario`} />
<div className="px-5 pt-4">
@@ -189,7 +180,7 @@ function GestioneEventi() {
maxLength={300}
rows={2}
onChange={(e) => aggiorna({ note: e.target.value })}
className={classiInput}
className={cn(classiInput, "h-auto")}
/>
</Campo>
+2 -1
View File
@@ -147,7 +147,8 @@ function Index() {
indice={4}
azione={
<Link
to="/squadra"
to="/classifica"
search={{ tab: "storico" }}
className="inline-flex items-center text-xs font-semibold text-accent"
>
Storico <ChevronRight className="h-4 w-4" />
+3 -18
View File
@@ -1,5 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowLeft, MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react";
import { createFileRoute } from "@tanstack/react-router";
import { MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { formatData } from "@/lib/crapp-data";
@@ -59,12 +59,6 @@ function PartitaDetail() {
if (!evento) {
return (
<div className="px-5 pt-8">
<Link
to="/calendario"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" /> Torna al calendario
</Link>
<p className="mt-8 text-center text-sm text-muted-foreground">Partita non trovata</p>
</div>
);
@@ -97,15 +91,6 @@ function PartitaDetail() {
return (
<>
<div className="px-5 pt-4">
<Link
to="/calendario"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" /> Calendario
</Link>
</div>
<PageHeader
titolo={evento.campionato ? "Partita" : "Amichevole"}
sottotitolo={formatData(evento.data)}
@@ -278,7 +263,7 @@ function PartitaDetail() {
) : null}
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} />
<RosaPresenze eventoId={evento.id} data={evento.data} />
</Section>
</>
);
+2
View File
@@ -28,6 +28,7 @@ import { resetGiocatore } from "@/lib/user-store";
import { esci } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { Reveal } from "@/components/motion/Reveal";
import { version as APP_VERSION } from "../../package.json";
const TAB_PROFILO = ["stagione", "badge", "documenti", "impostazioni"] as const;
type TabProfilo = (typeof TAB_PROFILO)[number];
@@ -298,6 +299,7 @@ function Profilo() {
<Lightbulb className="h-4 w-4 text-muted-foreground" />
</a>
</div>
<p className="text-center text-xs text-muted-foreground">CrAPP v{APP_VERSION}</p>
<button
type="button"
onClick={logout}
@@ -0,0 +1,155 @@
-- M11 — Le scritture seguono i permessi dell'interfaccia (DD-023).
--
-- Le tabelle della v1.0 avevano policy `USING (true)` per anon e authenticated. M4 ha
-- tolto il GRANT ad anon, ma per gli autenticati non era rimasto nessun limite: un
-- giocatore qualsiasi poteva cancellare tutti gli eventi o riscrivere il voto di un altro
-- parlando direttamente con PostgREST, senza passare dall'interfaccia che quei pulsanti
-- glieli nasconde.
--
-- Da qui in poi le policy dicono quello che i componenti già fanno:
-- eventi_app -> solo admin (la rotta /eventi è loro)
-- risposte_presenze, cacche_partita -> la propria riga (giocatore_id)
-- pagelle_voti, mvp_voti, badge_social_voti -> il proprio voto (votante_id)
-- turni_palloni, scout_* -> invariate: nell'app non hanno gate
--
-- La lettura non cambia: resta aperta a tutti gli autenticati.
--
-- L'identità è lo slot di `giocatori_squadra` collegato all'account, con lo stesso
-- EXISTS usato dalle policy dei profili (migration di correzione, DD-017): la funzione
-- `mio_giocatore_id()` di M2 lì è stata rimossa e non va reintrodotta. Regge perché dopo
-- DD-018 il giocatore selezionato sul dispositivo è sempre lo slot dell'account.
-- --- eventi: li gestisce chi ha /eventi, cioè un amministratore ---------------------
DROP POLICY "Chiunque puo creare eventi" ON public.eventi_app;
DROP POLICY "Chiunque puo modificare eventi" ON public.eventi_app;
DROP POLICY "Chiunque puo eliminare eventi" ON public.eventi_app;
CREATE POLICY "Gli admin gestiscono gli eventi" ON public.eventi_app
FOR ALL TO authenticated
USING (public.has_role(auth.uid(), 'admin'::public.app_role))
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
-- --- presenze: ognuno risponde per sé ----------------------------------------------
DROP POLICY "Chiunque puo salvare la propria presenza" ON public.risposte_presenze;
DROP POLICY "Chiunque puo aggiornare la propria presenza" ON public.risposte_presenze;
DROP POLICY "Chiunque puo togliere la propria presenza" ON public.risposte_presenze;
CREATE POLICY "Ognuno gestisce la propria presenza" ON public.risposte_presenze
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
)
);
CREATE POLICY "Gli admin gestiscono tutte le presenze" ON public.risposte_presenze
FOR ALL TO authenticated
USING (public.has_role(auth.uid(), 'admin'::public.app_role))
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
-- --- cacche: ognuno dichiara le proprie --------------------------------------------
DROP POLICY "Chiunque puo dichiarare le cacche" ON public.cacche_partita;
DROP POLICY "Chiunque puo aggiornare le cacche" ON public.cacche_partita;
DROP POLICY "Chiunque puo cancellare le cacche" ON public.cacche_partita;
CREATE POLICY "Ognuno dichiara le proprie cacche" ON public.cacche_partita
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
)
);
CREATE POLICY "Gli admin gestiscono tutte le cacche" ON public.cacche_partita
FOR ALL TO authenticated
USING (public.has_role(auth.uid(), 'admin'::public.app_role))
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
-- --- pagelle: ognuno firma i propri voti -------------------------------------------
DROP POLICY "Chiunque puo votare le pagelle" ON public.pagelle_voti;
DROP POLICY "Chiunque puo cambiare il proprio voto pagella" ON public.pagelle_voti;
DROP POLICY "Chiunque puo togliere il proprio voto pagella" ON public.pagelle_voti;
CREATE POLICY "Ognuno gestisce i propri voti pagella" ON public.pagelle_voti
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
);
CREATE POLICY "Gli admin gestiscono tutte le pagelle" ON public.pagelle_voti
FOR ALL TO authenticated
USING (public.has_role(auth.uid(), 'admin'::public.app_role))
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
-- --- MVP ----------------------------------------------------------------------------
DROP POLICY "Chiunque puo votare l MVP" ON public.mvp_voti;
DROP POLICY "Chiunque puo cambiare il proprio voto" ON public.mvp_voti;
DROP POLICY "Chiunque puo togliere il proprio voto" ON public.mvp_voti;
CREATE POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
);
CREATE POLICY "Gli admin gestiscono tutti i voti MVP" ON public.mvp_voti
FOR ALL TO authenticated
USING (public.has_role(auth.uid(), 'admin'::public.app_role))
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
-- --- badge social -------------------------------------------------------------------
DROP POLICY "Chiunque puo votare i badge social" ON public.badge_social_voti;
DROP POLICY "Chiunque puo cambiare il proprio voto social" ON public.badge_social_voti;
DROP POLICY "Chiunque puo togliere il proprio voto social" ON public.badge_social_voti;
CREATE POLICY "Ognuno gestisce i propri voti social" ON public.badge_social_voti
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
);
CREATE POLICY "Gli admin gestiscono tutti i voti social" ON public.badge_social_voti
FOR ALL TO authenticated
USING (public.has_role(auth.uid(), 'admin'::public.app_role))
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
+65 -12
View File
@@ -15,23 +15,76 @@ bun test/unit/badges.test.ts # un singolo file
Il runner (`test/run.ts`) esegue ogni file in un processo separato, così un test
non può inquinare gli altri.
Per le push, `unit/push-sw.test.ts` esegue il service worker reale in contesti isolati,
senza pagina né rete: verifica la notifica al risveglio e le promesse di `waitUntil`.
`unit/push-client.test.ts` verifica anche l'aggiornamento del worker all'avvio e al ritorno
in primo piano, inclusi errori offline e pulizia del listener. Sono test del codice:
la consegna effettiva a schermo bloccato richiede un telefono e il servizio push reale.
## Struttura
| 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) | 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 | 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 | — |
| 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) | 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 (`permessi`), accesso alle route di notifica (`permessi-route`) e semantica degli upsert (`scritture`) 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 | — |
## Database locale in Docker
Alcune cose non si possono verificare senza scrivere: le policy RLS scritte su
`auth.uid()`, i trigger, i vincoli. Contro il progetto cloud quelle scritture non
si fanno, quindi servono un database usa e getta e utenti veri.
```bash
npx supabase start # avvia lo stack in Docker (migration applicate + seed.sql)
npx supabase status # URL e chiavi locali; Studio su http://127.0.0.1:54323
npx supabase db reset # ricrea il database da zero se i dati si sporcano
npx supabase stop # spegne tutto
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
```
`permessi-route` avvia il server di sviluppo **puntato al database locale** invece che al
progetto di `.env`: gli serve creare utenti veri per provare i tre casi (nessun token,
giocatore, amministratore).
Il primo `start` scarica le immagini (qualche minuto), i successivi partono in
una decina di secondi. Le mail finiscono in Mailpit (http://127.0.0.1:54324),
non escono dalla macchina.
I test che scrivono **non leggono `.env`**: prendono URL e chiavi da
`supabase status` (helper `test/helpers/locale.ts`) e si fermano se l'URL non è
`127.0.0.1`. È una cintura di sicurezza, non una comodità: così un `.env`
puntato alla produzione non può trasformare un test in una scrittura sul
database vero.
Ognuno ripristina lo stato che tocca in un `finally` — utenti creati, slot della
rosa, colonne modificate, e per `scritture` tutte le righe con il prefisso
`test-scritture`, che nessun dato vero può avere. Così la suite si rilancia
all'infinito senza un `db reset` in mezzo.
Due cose scoperte scrivendo questi test, utili a chi ne aggiunge:
- la **service key non è un amministratore**: per il trigger
`enforce_giocatori_squadra_update` (DD-016) `auth.uid()` è NULL, quindi ogni
UPDATE su `giocatori_squadra` fatto con la service key viene rifiutato. Per
collegare uno slot a un account serve il JWT di un utente con ruolo `admin`;
- su un UPDATE o un DELETE che non tocca nessuna riga PostgREST risponde 2xx.
Il codice di stato non basta: serve `Prefer: return=representation` e contare
le righe, oppure rileggere il dato.
## Convenzioni
- **Nessun test scrive sul database.** Integration ed e2e fanno solo letture e
verifiche di validazione: si possono lanciare anche contro l'ambiente reale.
L'unica eccezione apparente è `schema-profili`, che _tenta_ scritture da utente
anonimo proprio per dimostrare che la RLS le respinge, e poi rilegge la riga per
verificare che non sia cambiata: su un UPDATE a zero righe PostgREST risponde 2xx,
quindi lo stato conta più del codice di risposta.
- **Sul database configurato in `.env` nessun test scrive.** Integration ed e2e
fanno solo letture e verifiche di validazione: si possono lanciare anche contro
l'ambiente reale. L'unica eccezione apparente è `schema-profili`, che _tenta_
scritture da utente anonimo proprio per dimostrare che la RLS le respinge, e poi
rilegge la riga per verificare che non sia cambiata: su un UPDATE a zero righe
PostgREST risponde 2xx, quindi lo stato conta più del codice di risposta.
I test che scrivono davvero girano solo sul database locale (vedi sopra).
- I test legati alla migration M2 si **saltano da soli** dove quella migration non è
ancora applicata, indicandolo nel motivo. Per vederli tutti verdi serve un
database che la contenga: `npx supabase start` ne crea uno in locale.
+7 -1
View File
@@ -96,8 +96,14 @@ try {
await prova("il service worker e la sua configurazione si parlano", async () => {
const sw = await (await fetch(url("/push-sw.js"))).text();
assert.match(sw, /\/api\/public\/push-messaggio/, "endpoint invocato dal worker");
// DD-026: ora il worker riceve il testo cifrato, senza recuperarlo dalla rete.
assert.match(sw, /event\.data\?\.json\(\)/, "legge il payload della push");
assert.match(sw, /showNotification/, "il worker mostra la notifica");
assert.doesNotMatch(
sw,
/\bfetch\s*\(|\/api\/public\/push-messaggio/,
"mostrare la push non deve richiedere rete ad app chiusa",
);
const config = await fetch(url("/api/public/push-config"));
assert.equal(config.status, 200, "la chiave pubblica VAPID è interrogabile");
});
+28
View File
@@ -0,0 +1,28 @@
import { spawnSync } from "node:child_process";
/**
* Credenziali dello stack Supabase locale (`npx supabase start`), lette da
* `supabase status` e non da `.env`.
*
* I test che scrivono usano questo helper e non le variabili d'ambiente: un `.env`
* puntato alla produzione non deve poter trasformare un test in una scrittura sul
* database vero. Torna `null` e il test si salta se lo stack non è attivo o se
* per qualsiasi motivo l'URL non è locale.
*/
export type StackLocale = { url: string; anon: string; servizio: string };
export function statoLocale(): StackLocale | null {
const esito = spawnSync("npx", ["supabase", "status", "-o", "env"], {
encoding: "utf8",
timeout: 60_000,
});
if (esito.status !== 0) return null;
const leggi = (nome: string) =>
new RegExp(`^${nome}="?([^"\\n]+)"?$`, "m").exec(esito.stdout)?.[1] ?? "";
const url = leggi("API_URL");
const anon = leggi("ANON_KEY");
const servizio = leggi("SERVICE_ROLE_KEY");
if (!url || !anon || !servizio) return null;
if (!/^https?:\/\/(127\.0\.0\.1|localhost)/.test(url)) return null;
return { url, anon, servizio };
}
+43 -21
View File
@@ -109,31 +109,53 @@ try {
assert.equal(res.status, 400, "anche la cancellazione valida l'input");
});
await prova("push-messaggio e sollecita-presenze rifiutano i payload non validi", async () => {
assert.equal((await postJson("/api/public/push-messaggio", {})).status, 400);
assert.equal((await postJson("/api/public/sollecita-presenze", {})).status, 400);
assert.equal(
(await postJson("/api/public/sollecita-presenze", { eventoId: "" })).status,
400,
"eventoId vuoto non è valido",
);
// DD-026: il testo è nel payload; la vecchia route non deve più essere esposta.
await prova("push-messaggio è stata rimossa", async () => {
assert.equal((await postJson("/api/public/push-messaggio", {})).status, 404);
});
// --- le route che mandano notifiche a tutti (DD-024) ------------------------
// Il controllo di accesso viene prima della validazione: senza credenziali la
// risposta non deve nemmeno dire se l'evento esiste.
await prova("le route che avvisano la squadra chiedono le credenziali", async () => {
const route = [
"/api/public/sollecita-presenze",
"/api/public/apri-sondaggio",
"/api/public/promemoria-palloni",
];
for (const percorso of route) {
assert.equal(
(await postJson(percorso, { eventoId: "non-esiste" })).status,
401,
`${percorso} senza token`,
);
}
});
await prova("un token malformato non passa", async () => {
const res = await fetch(url("/api/public/sollecita-presenze"), {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer non-un-jwt" },
body: JSON.stringify({ eventoId: "non-esiste" }),
});
assert.equal(res.status, 401, "tre segmenti separati da punto, o niente");
});
await prova("uno schema diverso da Bearer non passa", async () => {
const res = await fetch(url("/api/public/sollecita-presenze"), {
method: "POST",
headers: { "content-type": "application/json", authorization: "Basic aGVsbG86d29ybGQ=" },
body: JSON.stringify({ eventoId: "non-esiste" }),
});
assert.equal(res.status, 401);
});
// --- endpoint che leggono dal database -------------------------------------
if (haSupabase()) {
await prova("push-messaggio risponde con il messaggio di default", async () => {
const res = await postJson("/api/public/push-messaggio", {
endpoint: `https://push.example/test-${Date.now()}`,
});
assert.equal(res.status, 200);
const dati = (await json(res)) as { title?: string; body?: string };
assert.ok(dati.title && dati.body, "un endpoint sconosciuto riceve comunque un testo");
});
await prova("sollecita-presenze su un evento inesistente risponde 404", async () => {
const res = await postJson("/api/public/sollecita-presenze", { eventoId: "non-esiste" });
assert.equal(res.status, 404);
});
// La validazione dell'input di sollecita-presenze (400 sul corpo vuoto, 404
// sull'evento inesistente) ora sta dietro al controllo di accesso: serve un
// token di amministratore, che questo file non ha. La copre
// `permessi-route.test.ts` sullo stack locale.
} else {
salta("endpoint con database", "SUPABASE_URL/SERVICE_ROLE_KEY non configurate");
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Chi può far partire una notifica a tutta la squadra: `bun test/integration/permessi-route.test.ts`.
*
* Le route in `src/routes/api/public/` girano con la service role e saltano la RLS, quindi
* il permesso deve stare nella route (DD-024). `api.test.ts` verifica solo i rifiuti che
* non richiedono un utente; qui si prova il giro completo nessun token, token di un
* giocatore normale, token di un amministratore e per farlo serve un database dove si
* possano creare utenti veri.
*
* Quindi: solo stack locale (`npx supabase start`), e il server di sviluppo viene avviato
* puntato invece che al progetto cloud di `.env`.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { avviaServer } from "../helpers/server";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("permessi delle route", "stack locale non attivo (npx supabase start)");
riepilogo("permessi route");
} else {
const { url: SUPABASE, anon: ANON, servizio: SERVIZIO } = locale;
// Il server di sviluppo eredita queste: le route leggono i nomi senza prefisso.
process.env["SUPABASE_URL"] = SUPABASE;
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
const PASSWORD = "prova-route-123";
const idUtenti: string[] = [];
const authAdmin = { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` };
async function creaUtente(email: string): Promise<string> {
const res = await fetch(`${SUPABASE}/auth/v1/admin/users`, {
method: "POST",
headers: { ...authAdmin, "content-type": "application/json" },
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
idUtenti.push(corpo.id);
return corpo.id;
}
async function accedi(email: string): Promise<string> {
const res = await fetch(`${SUPABASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password: PASSWORD }),
});
const corpo = (await res.json()) as { access_token?: string };
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
return corpo.access_token;
}
const emailGiocatore = `test-route-giocatore-${Date.now()}@example.test`;
const emailAdmin = `test-route-admin-${Date.now()}@example.test`;
await creaUtente(emailGiocatore);
const idAdmin = await creaUtente(emailAdmin);
await fetch(`${SUPABASE}/rest/v1/user_roles`, {
method: "POST",
headers: { ...authAdmin, "content-type": "application/json" },
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
});
const tokenGiocatore = await accedi(emailGiocatore);
const tokenAdmin = await accedi(emailAdmin);
const server = await avviaServer();
console.log(`permessi route su ${server.baseUrl} (database ${SUPABASE})`);
/** Le tre route che mandano notifiche: stesso controllo, stesso corpo (DD-024, DD-025). */
const ROUTE = [
"/api/public/sollecita-presenze",
"/api/public/apri-sondaggio",
"/api/public/promemoria-palloni",
];
const chiama = (percorso: string, intestazioni: Record<string, string> = {}) =>
fetch(`${server.baseUrl}${percorso}`, {
method: "POST",
headers: { "content-type": "application/json", ...intestazioni },
body: JSON.stringify({ eventoId: "non-esiste" }),
});
try {
await prova("senza token la route non risponde nemmeno se l'evento esiste", async () => {
for (const percorso of ROUTE) assert.equal((await chiama(percorso)).status, 401, percorso);
});
await prova("un giocatore autenticato non avvisa la squadra", async () => {
for (const percorso of ROUTE) {
const res = await chiama(percorso, { authorization: `Bearer ${tokenGiocatore}` });
assert.equal(res.status, 403, `${percorso}: token valido ma senza ruolo`);
}
});
// Controllo positivo: l'admin deve superare il controllo di accesso e arrivare alla
// validazione dell'input. Il 404 dice esattamente questo — è passato, e l'evento
// inventato non esiste.
await prova("un amministratore passa e arriva alla validazione", async () => {
for (const percorso of ROUTE) {
const res = await chiama(percorso, { authorization: `Bearer ${tokenAdmin}` });
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
}
});
} finally {
server.stop();
for (const id of idUtenti) {
await fetch(`${SUPABASE}/rest/v1/user_roles?user_id=eq.${id}`, {
method: "DELETE",
headers: authAdmin,
});
await fetch(`${SUPABASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: authAdmin,
});
}
riepilogo("permessi route");
}
}
+383
View File
@@ -0,0 +1,383 @@
/**
* Permessi per ruolo sul database locale: `bun test/integration/permessi.test.ts`.
*
* A differenza di `schema-profili`, che si limita a provare l'utente anonimo, qui si
* creano utenti veri e si interroga il database *come loro*: è l'unico modo per
* verificare policy scritte su `auth.uid()`. È anche la definizione eseguibile della
* tabella dei permessi di DD-023 (migration M11).
*
* Il test **scrive**, quindi gira solo contro l'istanza locale di `npx supabase start`:
* le credenziali le legge da `supabase status`, non da `.env`, così non può puntare per
* sbaglio alla produzione. Senza stack locale si salta con il motivo.
*
* Stato toccato e ripristinato alla fine: gli utenti creati (cancellati), gli slot
* reclamati in `giocatori_squadra`, il telefono del profilo g1 e le righe con il
* prefisso `test-permessi`.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("permessi per ruolo", "stack locale non attivo (npx supabase start)");
riepilogo("permessi");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`permessi su ${URL_BASE}`);
const rest = (percorso: string, token: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: token === SERVIZIO ? SERVIZIO : ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
/** Numero di righe toccate da una scrittura: con `return=representation` è il corpo. */
const righeToccate = async (res: Response): Promise<number> => {
if (!res.ok) return 0;
const corpo = (await res.json()) as unknown[];
return Array.isArray(corpo) ? corpo.length : 0;
};
async function creaUtente(email: string, password: string): Promise<string> {
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ email, password, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string; msg?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
return corpo.id;
}
async function accedi(email: string, password: string): Promise<string> {
const res = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
const corpo = (await res.json()) as { access_token?: string };
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
return corpo.access_token;
}
const eliminaUtente = (id: string) =>
fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
const PASSWORD = "prova-permessi-123";
const PREFISSO = "test-permessi";
const idUtenti: string[] = [];
let telefonoOriginale: string | null = null;
let tokenAdmin = "";
try {
// --- preparazione: un giocatore collegato a g1, un amministratore --------------
const emailGiocatore = `test-giocatore-${Date.now()}@example.test`;
const emailAdmin = `test-admin-${Date.now()}@example.test`;
const idGiocatore = await creaUtente(emailGiocatore, PASSWORD);
const idAdmin = await creaUtente(emailAdmin, PASSWORD);
idUtenti.push(idGiocatore, idAdmin);
await rest("user_roles", SERVIZIO, {
method: "POST",
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
});
const tokenGiocatore = await accedi(emailGiocatore, PASSWORD);
tokenAdmin = await accedi(emailAdmin, PASSWORD);
// La rosa si tocca con il JWT dell'admin, non con la service key: per il trigger
// `enforce_giocatori_squadra_update` la service key non è un amministratore
// (`auth.uid()` è NULL) e vede rifiutato qualsiasi UPDATE.
const collega = (id: string, utente: string | null) =>
rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ auth_user_id: utente }),
});
assert.equal(await righeToccate(await collega("g1", idGiocatore)), 1, "g1 collegato al test");
assert.equal(await righeToccate(await collega("g2", idAdmin)), 1, "g2 collegato all'admin");
const primaProfilo = await rest(
"profili_giocatore?giocatore_id=eq.g1&select=telefono",
SERVIZIO,
);
telefonoOriginale =
((await primaProfilo.json()) as Array<{ telefono: string | null }>)[0]?.telefono ?? null;
// --- profili: dati personali, la RLS è l'unica barriera -----------------------
// Senza queste due il documento d'identità di un compagno sarebbe leggibile da
// chiunque abbia un account.
await prova("il giocatore vede solo il proprio profilo", async () => {
const res = await rest("profili_giocatore?select=giocatore_id", tokenGiocatore);
assert.equal(res.status, 200);
const righe = (await res.json()) as Array<{ giocatore_id: string }>;
assert.deepEqual(
righe.map((r) => r.giocatore_id),
["g1"],
"solo il proprio profilo, mai quello degli altri",
);
});
await prova("il giocatore non modifica il profilo di un altro", async () => {
const res = await rest("profili_giocatore?giocatore_id=eq.g2", tokenGiocatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ telefono: "999" }),
});
assert.equal(await righeToccate(res), 0, "nessuna riga altrui aggiornata");
const dopo = await rest("profili_giocatore?giocatore_id=eq.g2&select=telefono", SERVIZIO);
const righe = (await dopo.json()) as Array<{ telefono: string | null }>;
assert.notEqual(righe[0]?.telefono, "999", "il telefono di g2 è rimasto quello di prima");
});
// Controllo positivo: senza questo, un database completamente rotto passerebbe
// tutti i test di negazione qui sopra.
await prova("il giocatore aggiorna il proprio profilo", async () => {
const res = await rest("profili_giocatore?giocatore_id=eq.g1", tokenGiocatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ telefono: "3331234567" }),
});
assert.equal(await righeToccate(res), 1, "il proprio profilo si aggiorna");
});
await prova("il giocatore non cancella profili", async () => {
const res = await rest("profili_giocatore?giocatore_id=eq.g2", tokenGiocatore, {
method: "DELETE",
headers: { Prefer: "return=representation" },
});
assert.equal(await righeToccate(res), 0, "la cancellazione è riservata agli admin");
const dopo = await rest("profili_giocatore?giocatore_id=eq.g2&select=giocatore_id", SERVIZIO);
assert.equal(((await dopo.json()) as unknown[]).length, 1, "il profilo g2 esiste ancora");
});
await prova("l'amministratore vede tutti i profili", async () => {
const res = await rest("profili_giocatore?select=giocatore_id", tokenAdmin);
assert.equal(res.status, 200);
const righe = (await res.json()) as unknown[];
assert.ok(righe.length > 1, `l'admin vede l'intero elenco, ne ha visti ${righe.length}`);
});
// --- rosa: il trigger di DD-016 ------------------------------------------------
// La policy da sola lascerebbe passare un UPDATE che cambia anche numero e ruolo:
// a chiudere il buco è il trigger `enforce_giocatori_squadra_update`.
await prova(
"reclamando uno slot libero il giocatore non cambia anche i suoi dati",
async () => {
const res = await rest("giocatori_squadra?id=eq.g3", tokenGiocatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ auth_user_id: idGiocatore, numero: 99 }),
});
assert.ok(!res.ok, `il trigger deve rifiutare, invece ha risposto ${res.status}`);
const dopo = await rest("giocatori_squadra?id=eq.g3&select=numero,auth_user_id", SERVIZIO);
const righe = (await dopo.json()) as Array<{ numero: number; auth_user_id: string | null }>;
assert.notEqual(righe[0]?.numero, 99, "il numero di maglia non è cambiato");
assert.equal(righe[0]?.auth_user_id ?? null, null, "lo slot g3 è rimasto libero");
},
);
await prova("il giocatore non prende lo slot già assegnato a un altro", async () => {
const res = await rest("giocatori_squadra?id=eq.g2", tokenGiocatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ auth_user_id: idGiocatore }),
});
assert.equal(await righeToccate(res), 0, "gli slot già occupati non si rivendicano");
const dopo = await rest("giocatori_squadra?id=eq.g2&select=auth_user_id", SERVIZIO);
const righe = (await dopo.json()) as Array<{ auth_user_id: string | null }>;
assert.equal(righe[0]?.auth_user_id, idAdmin, "g2 è rimasto del suo proprietario");
});
// --- ruoli: la scalata di privilegi --------------------------------------------
await prova("il giocatore non si assegna il ruolo admin", async () => {
const res = await rest("user_roles", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ user_id: idGiocatore, role: "admin" }),
});
assert.ok(!res.ok, `l'auto-promozione deve fallire, invece ha risposto ${res.status}`);
const dopo = await rest(`user_roles?user_id=eq.${idGiocatore}&select=role`, SERVIZIO);
assert.equal(((await dopo.json()) as unknown[]).length, 0, "nessun ruolo assegnato");
});
await prova("il giocatore non vede i ruoli degli altri", async () => {
const res = await rest("user_roles?select=user_id,role", tokenGiocatore);
assert.equal(res.status, 200);
const righe = (await res.json()) as Array<{ user_id: string }>;
assert.ok(
righe.every((r) => r.user_id === idGiocatore),
"l'elenco degli amministratori non è pubblico",
);
});
// --- M11: le scritture seguono i permessi dell'interfaccia (DD-023) -------------
// Prima di M11 ognuna di queste andava a buon fine: le policy della v1.0 erano
// `USING (true)` per chiunque fosse autenticato.
const EVENTO = `${PREFISSO}-evento`;
await prova("gli eventi li crea e li cancella solo un amministratore", async () => {
const daGiocatore = await rest("eventi_app", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
id: `${PREFISSO}-abusivo`,
tipo: "allenamento",
titolo: "Non deve esistere",
data: "2026-01-01",
ora: "20:00",
luogo: "",
}),
});
assert.ok(!daGiocatore.ok, `creazione da giocatore rifiutata (${daGiocatore.status})`);
// Controllo positivo: l'admin deve poterlo fare, altrimenti l'app è rotta.
const daAdmin = await rest("eventi_app", tokenAdmin, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
id: EVENTO,
tipo: "allenamento",
titolo: "Allenamento di prova",
data: "2026-01-01",
ora: "20:00",
luogo: "Palestra",
}),
});
assert.equal(await righeToccate(daAdmin), 1, "l'admin crea gli eventi");
const cancella = await rest(`eventi_app?id=eq.${EVENTO}`, tokenGiocatore, {
method: "DELETE",
headers: { Prefer: "return=representation" },
});
assert.equal(await righeToccate(cancella), 0, "il giocatore non svuota il calendario");
const dopo = await rest(`eventi_app?id=eq.${EVENTO}&select=id`, SERVIZIO);
assert.equal(((await dopo.json()) as unknown[]).length, 1, "l'evento è ancora lì");
});
await prova("ognuno risponde alla convocazione solo per sé", async () => {
const mia = await rest("risposte_presenze", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g1", stato: "presente" }),
});
assert.equal(await righeToccate(mia), 1, "la propria risposta si salva");
const altrui = await rest("risposte_presenze", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g5", stato: "assente" }),
});
assert.ok(!altrui.ok, `nessuno risponde al posto di un altro (${altrui.status})`);
});
await prova("i voti si firmano con il proprio nome", async () => {
const mio = await rest("pagelle_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO,
votante_id: "g1",
votato_id: "g5",
voto: 7,
}),
});
assert.equal(await righeToccate(mio), 1, "il proprio voto si registra");
const falso = await rest("pagelle_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO,
votante_id: "g5",
votato_id: "g1",
voto: 10,
}),
});
assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`);
const mvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO,
votante_id: "g5",
votato_id: "g1",
votato_nome: "Uno",
}),
});
assert.ok(!mvp.ok, `vale anche per l'MVP (${mvp.status})`);
});
await prova("le cacche le dichiara il diretto interessato", async () => {
const mie = await rest("cacche_partita", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g1", quantita: 2 }),
});
assert.equal(await righeToccate(mie), 1, "le proprie si dichiarano");
const altrui = await rest("cacche_partita", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g5", quantita: 9 }),
});
assert.ok(!altrui.ok, `quelle di un altro no (${altrui.status})`);
});
// I turni palloni restano aperti di proposito: nell'interfaccia il turno se lo passa
// chiunque, senza gate. Se un giorno arriva il gate, questo test va cambiato.
await prova("il turno palloni resta assegnabile da chiunque sia autenticato", async () => {
const res = await rest("turni_palloni", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g5", aggiornato_da: "g1" }),
});
assert.equal(await righeToccate(res), 1, "DD-023 lascia questa tabella invariata");
});
} finally {
// Ripristino: prima le righe create (la service role passa sopra alle policy di M11),
// poi gli slot (serve il JWT admin, il trigger rifiuta la service key), il telefono e
// infine gli utenti.
for (const tabella of ["risposte_presenze", "cacche_partita", "turni_palloni"]) {
await rest(`${tabella}?evento_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
}
for (const tabella of ["pagelle_voti", "mvp_voti"]) {
await rest(`${tabella}?match_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
}
await rest(`eventi_app?id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
for (const id of ["g1", "g2"]) {
if (tokenAdmin) {
await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify({ auth_user_id: null }),
});
}
}
await rest("profili_giocatore?giocatore_id=eq.g1", SERVIZIO, {
method: "PATCH",
body: JSON.stringify({ telefono: telefonoOriginale }),
});
for (const id of idUtenti) {
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await eliminaUtente(id);
}
riepilogo("permessi");
}
}
+281
View File
@@ -0,0 +1,281 @@
/**
* Le scritture dell'app contro il database locale: `bun test/integration/scritture.test.ts`.
*
* Ogni salvataggio di CrAPP è un `upsert` con un `onConflict` scritto a mano nei hook di
* `src/lib/`. Se quella chiave non corrisponde al vincolo UNIQUE della tabella non arriva
* nessun errore: il database sovrascrive la riga sbagliata, e il bug si vede solo settimane
* dopo in una media che non torna. Qui si ripetono le stesse chiamate dei hook e si conta
* cosa resta nella tabella.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-scritture`, 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("scritture sul database", "stack locale non attivo (npx supabase start)");
riepilogo("scritture");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`scritture su ${URL_BASE}`);
const PREFISSO = "test-scritture";
const TABELLE = [
"pagelle_voti",
"mvp_voti",
"cacche_partita",
"badge_social_voti",
"turni_palloni",
"risposte_presenze",
"scout_live",
] as const;
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
/**
* Lo stesso `upsert` che fa supabase-js: `onConflict` diventa `on_conflict` nella query
* e `resolution=merge-duplicates` nell'header.
*/
async function upsert(tabella: string, onConflict: string, riga: Record<string, unknown>) {
const res = await rest(`${tabella}?on_conflict=${onConflict}`, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify(riga),
});
if (!res.ok) throw new Error(`upsert su ${tabella}: ${res.status} ${await res.text()}`);
return (await res.json()) as Array<Record<string, unknown>>;
}
const leggi = async (tabella: string, filtro: string) =>
(await (await rest(`${tabella}?${filtro}`)).json()) as Array<Record<string, unknown>>;
try {
// --- pagelle: la chiave più facile da sbagliare -------------------------------
// Un votante dà un voto a ogni compagno nella stessa partita: se il conflitto
// fosse su (match, votante) ogni voto cancellerebbe il precedente.
await prova("le pagelle tengono un voto per ogni votato, non uno per votante", async () => {
const match = `${PREFISSO}-m1`;
const chiave = "match_id,votante_id,votato_id";
await upsert("pagelle_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g2",
voto: 6,
});
await upsert("pagelle_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g3",
voto: 8,
});
const righe = await leggi("pagelle_voti", `match_id=eq.${match}&select=votato_id,voto`);
assert.equal(righe.length, 2, "due votati, due righe");
await upsert("pagelle_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g2",
voto: 9,
});
const dopo = await leggi("pagelle_voti", `match_id=eq.${match}&votato_id=eq.g2&select=voto`);
assert.equal(dopo.length, 1, "cambiare voto non crea una riga nuova");
assert.equal(dopo[0]?.["voto"], 9, "il voto è quello aggiornato");
});
await prova("le pagelle rifiutano l'autovoto e i voti fuori scala", async () => {
const match = `${PREFISSO}-m2`;
const chiave = "match_id,votante_id,votato_id";
await assert.rejects(
() =>
upsert("pagelle_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g1",
voto: 10,
}),
"nessuno si vota da solo (pagelle_no_autovoto)",
);
await assert.rejects(
() =>
upsert("pagelle_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g2",
voto: 11,
}),
"il voto sta fra 1 e 10 (pagelle_voto_range)",
);
});
// --- MVP: qui la regola è l'opposta -------------------------------------------
await prova("l'MVP tiene un solo voto per votante e partita", async () => {
const match = `${PREFISSO}-m3`;
const chiave = "match_id,votante_id";
await upsert("mvp_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g2",
votato_nome: "Due",
});
await upsert("mvp_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g3",
votato_nome: "Tre",
});
const righe = await leggi("mvp_voti", `match_id=eq.${match}&select=votato_id`);
assert.equal(righe.length, 1, "cambiare idea sostituisce il voto, non lo aggiunge");
assert.equal(righe[0]?.["votato_id"], "g3", "vale l'ultimo votato");
});
// --- badge social: una preferenza per categoria --------------------------------
await prova("i badge social tengono un voto per categoria", async () => {
const match = `${PREFISSO}-m4`;
const chiave = "match_id,categoria,votante_id";
await upsert("badge_social_voti", chiave, {
match_id: match,
categoria: "sorriso",
votante_id: "g1",
votato_id: "g2",
votato_nome: "Due",
});
await upsert("badge_social_voti", chiave, {
match_id: match,
categoria: "urlo",
votante_id: "g1",
votato_id: "g2",
votato_nome: "Due",
});
const righe = await leggi("badge_social_voti", `match_id=eq.${match}&select=categoria`);
assert.equal(righe.length, 2, "categorie diverse, righe diverse");
await upsert("badge_social_voti", chiave, {
match_id: match,
categoria: "urlo",
votante_id: "g1",
votato_id: "g4",
votato_nome: "Quattro",
});
const urlo = await leggi(
"badge_social_voti",
`match_id=eq.${match}&categoria=eq.urlo&select=votato_id`,
);
assert.equal(urlo.length, 1, "nella stessa categoria si sostituisce");
assert.equal(urlo[0]?.["votato_id"], "g4");
});
// --- cacche: una dichiarazione per giocatore e partita --------------------------
await prova("le cacche tengono una quantità per giocatore e partita", async () => {
const evento = `${PREFISSO}-e1`;
const chiave = "evento_id,giocatore_id";
await upsert("cacche_partita", chiave, {
evento_id: evento,
giocatore_id: "g1",
quantita: 2,
});
await upsert("cacche_partita", chiave, {
evento_id: evento,
giocatore_id: "g1",
quantita: 3,
});
await upsert("cacche_partita", chiave, {
evento_id: evento,
giocatore_id: "g2",
quantita: 1,
});
const righe = await leggi(
"cacche_partita",
`evento_id=eq.${evento}&select=giocatore_id,quantita&order=giocatore_id`,
);
assert.equal(righe.length, 2, "un giocatore una riga");
assert.equal(righe[0]?.["quantita"], 3, "la seconda dichiarazione sostituisce la prima");
});
// --- turni palloni: un solo incaricato per evento ------------------------------
await prova("il turno palloni resta uno per evento", async () => {
const evento = `${PREFISSO}-e2`;
await upsert("turni_palloni", "evento_id", {
evento_id: evento,
giocatore_id: "g1",
aggiornato_da: "g1",
});
await upsert("turni_palloni", "evento_id", {
evento_id: evento,
giocatore_id: "g5",
aggiornato_da: "g9",
});
const righe = await leggi(
"turni_palloni",
`evento_id=eq.${evento}&select=giocatore_id,aggiornato_da`,
);
assert.equal(righe.length, 1, "riassegnare non aggiunge un secondo incaricato");
assert.equal(righe[0]?.["giocatore_id"], "g5", "vale l'ultima assegnazione");
assert.equal(righe[0]?.["aggiornato_da"], "g9", "e si sa chi l'ha fatta");
});
// --- presenze: la risposta si cambia fino all'ultimo ---------------------------
await prova("la risposta di presenza si aggiorna, non si duplica", async () => {
const evento = `${PREFISSO}-e3`;
const chiave = "evento_id,giocatore_id";
const prima = await upsert("risposte_presenze", chiave, {
evento_id: evento,
giocatore_id: "g1",
stato: "presente",
aggiornato_il: new Date("2026-01-01T18:00:00Z").toISOString(),
});
await upsert("risposte_presenze", chiave, {
evento_id: evento,
giocatore_id: "g1",
stato: "assente",
aggiornato_il: new Date("2026-01-01T19:00:00Z").toISOString(),
});
const righe = await leggi(
"risposte_presenze",
`evento_id=eq.${evento}&select=stato,aggiornato_il`,
);
assert.equal(righe.length, 1, "una risposta per giocatore");
assert.equal(righe[0]?.["stato"], "assente", "vale l'ultima risposta");
assert.notEqual(
righe[0]?.["aggiornato_il"],
prima[0]?.["aggiornato_il"],
"l'istante della risposta si muove: è quello che alimenta la serie di conferme",
);
});
// --- scout live: lo stato viene sostituito, non fuso ----------------------------
await prova("lo scout live sostituisce lo stato invece di fonderlo", async () => {
const evento = `${PREFISSO}-e4`;
await upsert("scout_live", "evento_id", {
evento_id: evento,
stato: { set: 1, punti: 10 },
});
await upsert("scout_live", "evento_id", { evento_id: evento, stato: { set: 2 } });
const righe = await leggi("scout_live", `evento_id=eq.${evento}&select=stato`);
assert.equal(righe.length, 1);
assert.deepEqual(
righe[0]?.["stato"],
{ set: 2 },
"il jsonb viene rimpiazzato: i campi del set precedente non restano appesi",
);
});
} finally {
for (const tabella of TABELLE) {
const colonna = tabella.endsWith("_voti") ? "match_id" : "evento_id";
await rest(`${tabella}?${colonna}=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("scritture");
}
}
+29 -26
View File
@@ -1,16 +1,16 @@
/** Check dei turni palloni: `bun src/lib/palloni-core.test.ts`. */
import assert from "node:assert/strict";
import { formatData, giocatori } from "@/lib/crapp-data";
import { giocatori } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import {
completaTurni,
conteggioTurni,
avvisiPalloniEvento,
destinatariPromemoriaPalloni,
eventiDelGiorno,
eventiPalloni,
eventoPrecedente,
eventoSuccessivo,
messaggioPalloniOggi,
oggiISO,
} from "@/lib/palloni-core";
@@ -139,37 +139,40 @@ assert.deepEqual(
"stessa persona oggi e alla volta prima: un solo avviso",
);
// --- messaggioPalloniOggi -----------------------------------------------------
// --- avvisiPalloniEvento ------------------------------------------------------
// Il promemoria per un evento scelto dall'admin: due destinatari, con testi diversi.
const avvisi = avvisiPalloniEvento(turniPush, eventiPush, "p2");
assert.deepEqual(
messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g1", "Mario"),
{
title: "Porta i palloni oggi",
body: "Evento p2 · 21:00. I palloni li hai tu dalla volta scorsa.",
},
"chi li aveva alla volta prima deve riportarli oggi",
avvisi.map((a) => a.giocatoreId),
["g2", "g1"],
"l'incaricato di questo evento, e chi ha i palloni dalla volta prima",
);
assert.match(avvisi[0]!.titolo, /prendere i palloni/);
assert.match(avvisi[1]!.titolo, /Porta i palloni/);
assert.ok(
avvisi.every((a) => !/oggi/i.test(a.testo)),
"il testo nomina l'evento, non 'oggi': può arrivare giorni prima",
);
assert.deepEqual(
avvisiPalloniEvento(turniPush, eventiPush, "p1").map((a) => a.giocatoreId),
["g1"],
"il primo evento non ha un precedente da avvisare",
);
assert.deepEqual(
messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g2", "Luca"),
{
title: "Tocca a te prendere i palloni",
body: `A fine Evento p2 porta a casa i palloni e riportali il ${formatData("2026-02-03")}.`,
},
"l'incaricato di oggi sa quando riportarli, se c'è un evento successivo",
avvisiPalloniEvento({ p1: "g1", p2: "g1" }, eventiPush, "p2").map((a) => a.giocatoreId),
["g1"],
"stessa persona nei due ruoli: un avviso solo, non due notifiche uguali",
);
assert.deepEqual(
messaggioPalloniOggi(turniPush, eventiPush, "2026-02-03", "g3", "Anna"),
{ title: "Tocca a te prendere i palloni", body: "A fine Evento p3 porta a casa i palloni." },
"senza evento successivo il messaggio non promette una data",
avvisiPalloniEvento(turniPush, eventiPush, "non-esiste"),
[],
"un evento inesistente non produce avvisi",
);
assert.deepEqual(
messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g9", "Sara"),
{ title: "CrAPP · Turno palloni", body: "Sara, controlla il turno palloni nel calendario." },
"chi non è coinvolto oggi riceve il messaggio generico col proprio nome",
);
assert.deepEqual(
messaggioPalloniOggi(turniPush, eventiPush, "2026-05-01", "g9", ""),
{ title: "CrAPP · Turno palloni", body: "Controlla il calendario." },
"senza eventi in quella data e senza nome noto, il messaggio resta generico",
avvisiPalloniEvento({}, eventiPush, "p2"),
[],
"senza turni assegnati non c'è nessuno da avvisare",
);
console.log("palloni-core: ok");
+103
View File
@@ -0,0 +1,103 @@
/** Check delle presenze dell'ultimo mese: `bun test/unit/presenze-mese.test.ts`. */
import assert from "node:assert/strict";
import type { Evento } from "@/lib/eventi";
import type { MappaPresenze } from "@/lib/presenze";
import {
finestraMese,
presenzeUltimoMese,
presenzeUltimoMeseTutti,
} from "@/lib/presenze-mese-core";
const ev = (id: string, tipo: Evento["tipo"], data: string, convocati: string[] = []): Evento => ({
id,
tipo,
titolo: id,
luogo: "",
data,
ora: "20:00",
note: "",
convocati,
campionato: false,
casa: true,
pagelleChiuse: false,
});
const OGGI = new Date("2026-09-04T12:00:00Z");
// --- la finestra ------------------------------------------------------------------
const { da, a } = finestraMese(OGGI);
assert.equal(a, "2026-09-04", "la finestra si chiude oggi");
assert.equal(da, "2026-08-05", "e si apre 30 giorni prima");
// --- un singolo giocatore ----------------------------------------------------------
const eventi: Evento[] = [
ev("a1", "allenamento", "2026-08-06"), // dentro
ev("a2", "allenamento", "2026-08-20"), // dentro, ritardo
ev("p1", "partita", "2026-09-01"), // dentro, assente
ev("a0", "allenamento", "2026-07-30"), // fuori: più vecchio di 30 giorni
ev("c1", "compleanno", "2026-08-15"), // dentro, ma non è né partita né allenamento
ev("a3", "allenamento", "2026-08-25", ["g2"]), // dentro, ma g1 non è convocato
];
const presenze: MappaPresenze = {
a1: { g1: "presente", g2: "presente" },
a2: { g1: "ritardo", g2: "assente" },
p1: { g1: "assente", g2: "presente" },
a0: { g1: "presente" },
c1: { g1: "presente" },
a3: { g2: "presente" },
};
const g1 = presenzeUltimoMese("g1", eventi, presenze, OGGI);
assert.deepEqual(
g1,
{ presenti: 2, totali: 3, percentuale: 67 },
"3 eventi rilevanti su 6: il ritardo conta come presenza, la percentuale è arrotondata",
);
assert.equal(
presenzeUltimoMese("g1", [ev("a0", "allenamento", "2026-07-30")], presenze, OGGI).percentuale,
0,
"senza eventi nella finestra la percentuale è 0, non NaN",
);
assert.deepEqual(
presenzeUltimoMese(undefined, eventi, presenze, OGGI),
{ presenti: 0, totali: 0, percentuale: 0 },
"senza giocatore selezionato non si calcola niente",
);
assert.equal(
presenzeUltimoMese("g9", eventi, presenze, OGGI).totali,
3,
"chi non ha mai risposto ha comunque il denominatore degli eventi aperti a tutti",
);
// --- tutta la rosa ------------------------------------------------------------------
const tutti = presenzeUltimoMeseTutti(eventi, presenze, ["g1", "g2"], OGGI);
assert.deepEqual(tutti["g1"], g1, "la mappa e il calcolo singolo dicono la stessa cosa su g1");
assert.deepEqual(
tutti["g2"],
{ presenti: 3, totali: 4, percentuale: 75 },
"g2 ha anche l'allenamento in cui era l'unico convocato",
);
// Il caso che distingue le due regole: un evento con l'elenco dei convocati non
// entra nel denominatore di chi non è stato chiamato.
const soloConvocati = presenzeUltimoMeseTutti(
[ev("a3", "allenamento", "2026-08-25", ["g2"])],
presenze,
["g1", "g2"],
OGGI,
);
assert.equal(soloConvocati["g1"], undefined, "chi non è convocato non compare nella mappa");
assert.equal(soloConvocati["g2"]?.totali, 1, "il convocato sì");
assert.deepEqual(
presenzeUltimoMeseTutti([], presenze, ["g1"], OGGI),
{},
"senza eventi la mappa è vuota",
);
console.log("presenze mese: ok");
+104
View File
@@ -7,6 +7,7 @@ import assert from "node:assert/strict";
import {
attivaNotifiche,
disattivaNotifiche,
mantieniWorkerPushAggiornato,
pushSupportato,
statoNotifiche,
} from "@/lib/push-client";
@@ -24,4 +25,107 @@ await assert.rejects(() => attivaNotifiche("g1"), /Notifiche non supportate su q
// --- disattivaNotifiche: no-op silenzioso, nessun errore ----------------------
await assert.doesNotReject(() => disattivaNotifiche());
// Il mantenimento del worker è innocuo anche durante SSR.
assert.doesNotThrow(mantieniWorkerPushAggiornato());
const originali = new Map(
["window", "navigator", "document"].map((nome) => [
nome,
Object.getOwnPropertyDescriptor(globalThis, nome),
]),
);
const documento = Object.assign(new EventTarget(), { visibilityState: "visible" });
let letture = 0;
let aggiornamenti = 0;
let presente = true;
let errore = false;
let sblocca: (() => void) | undefined;
let attesa: Promise<void> | undefined;
let interrompi = () => {};
const ciclo = () => new Promise((resolve) => setTimeout(resolve, 0));
const visibilita = async (stato: string) => {
documento.visibilityState = stato;
documento.dispatchEvent(new Event("visibilitychange"));
await ciclo();
};
try {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { PushManager: {}, Notification: {} },
});
Object.defineProperty(globalThis, "document", { configurable: true, value: documento });
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
serviceWorker: {
async getRegistration(url: string) {
assert.equal(url, "/push-sw.js");
letture++;
return presente
? {
async update() {
aggiornamenti++;
if (errore) throw new Error("offline");
await attesa;
},
}
: undefined;
},
// Un aggiornamento non deve cancellare o sostituire la registrazione.
register() {
assert.fail("non deve registrare un nuovo worker");
},
},
},
});
interrompi = mantieniWorkerPushAggiornato();
await ciclo();
assert.equal(aggiornamenti, 1, "aggiorna il worker esistente all'avvio");
await visibilita("hidden");
assert.equal(letture, 1, "nessun lavoro quando l'app viene chiusa");
await visibilita("visible");
assert.equal(aggiornamenti, 2, "controlla gli aggiornamenti al ritorno nella webapp");
errore = true;
await visibilita("visible");
assert.equal(aggiornamenti, 3, "un errore offline non blocca l'app");
errore = false;
await visibilita("visible");
assert.equal(aggiornamenti, 4, "riprova dopo l'errore");
attesa = new Promise<void>((resolve) => (sblocca = resolve));
await visibilita("visible");
await visibilita("visible");
assert.equal(aggiornamenti, 5, "accorpa gli aggiornamenti sovrapposti");
sblocca!();
await ciclo();
attesa = undefined;
presente = false;
await visibilita("visible");
assert.equal(aggiornamenti, 5, "nessuna nuova iscrizione senza registrazione");
interrompi();
const prima = letture;
await visibilita("visible");
assert.equal(letture, prima, "rimuove il listener allo smontaggio");
documento.visibilityState = "hidden";
presente = true;
interrompi = mantieniWorkerPushAggiornato();
await ciclo();
assert.equal(letture, prima, "non aggiorna se parte già in background");
await visibilita("visible");
assert.equal(aggiornamenti, 6);
} finally {
interrompi();
for (const [nome, originale] of originali) {
if (originale) Object.defineProperty(globalThis, nome, originale);
else Reflect.deleteProperty(globalThis, nome);
}
}
console.log("push-client: ok");
+82
View File
@@ -0,0 +1,82 @@
/** Esegue il worker reale senza pagina, DOM, rete o stato lasciato dall'app. */
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
const codice = readFileSync(new URL("../../public/push-sw.js", import.meta.url), "utf8");
type Evento = { data?: { json: () => unknown } | null; waitUntil: (p: Promise<unknown>) => void };
function avviaWorker() {
const listener = new Map<string, (event: Evento) => void>();
const notifiche: Array<{ titolo: string; opzioni: NotificationOptions }> = [];
let completa: (() => void) | undefined;
const mostrata = new Promise<void>((resolve) => (completa = resolve));
const installata = Promise.resolve();
const attivata = Promise.resolve();
runInNewContext(codice, {
self: {
addEventListener: (nome: string, callback: (event: Evento) => void) =>
listener.set(nome, callback),
skipWaiting: () => installata,
clients: {
claim: () => attivata,
matchAll: () => assert.fail("la push deve funzionare senza consultare finestre aperte"),
},
registration: {
showNotification(titolo: string, opzioni: NotificationOptions) {
notifiche.push({ titolo, opzioni });
return mostrata;
},
},
},
fetch: () => assert.fail("nessuna rete per mostrare la notifica"),
});
return { listener, notifiche, mostrata, completa: completa!, installata, attivata };
}
// L'installazione e l'attivazione tengono vivo il worker fino al completamento.
const avvio = avviaWorker();
for (const [nome, promessa] of [
["install", avvio.installata],
["activate", avvio.attivata],
] as const) {
let attesa: Promise<unknown> | undefined;
avvio.listener.get(nome)!({ waitUntil: (p) => (attesa = p) });
assert.equal(attesa, promessa, `${nome}: la promessa è collegata a waitUntil`);
await attesa;
}
const casi: Array<{ data: Evento["data"]; titolo: string; testo: string }> = [
{
data: { json: () => ({ title: "🏐 Palloni", body: "Sabato tocca a te." }) },
titolo: "🏐 Palloni",
testo: "Sabato tocca a te.",
},
{ data: null, titolo: "CrAPP", testo: "Apri l'app per i dettagli." },
{
data: {
json: () => {
throw new SyntaxError("payload non JSON");
},
},
titolo: "CrAPP",
testo: "Apri l'app per i dettagli.",
},
];
for (const { data, titolo, testo } of casi) {
// Nuovo contesto a ogni consegna: simula il risveglio dopo la terminazione del worker.
const worker = avviaWorker();
let attesa: Promise<unknown> | undefined;
worker.listener.get("push")!({ data, waitUntil: (p) => (attesa = p) });
assert.equal(worker.notifiche.length, 1, "mostra subito una notifica anche senza l'app");
assert.equal(attesa, worker.mostrata, "waitUntil copre tutta showNotification");
const notifica = worker.notifiche[0]!;
assert.equal(notifica.titolo, titolo);
assert.equal(notifica.opzioni.body, testo);
assert.ok(notifica.opzioni.tag, "il tag permette di sostituire la notifica precedente");
assert.equal(notifica.opzioni.renotify, true, "anche una sostituzione avvisa l'utente");
worker.completa();
await attesa;
}
console.log("push-sw: ok");
+115 -4
View File
@@ -2,7 +2,9 @@
* Check dell'invio push server-side: `bun test/unit/webpush-server.test.ts`.
* `inviaPush` firma un JWT VAPID con Web Crypto e fa una POST all'endpoint del
* browser: qui generiamo una vera coppia di chiavi P-256 e sostituiamo `fetch`
* per intercettare la richiesta, così il test non tocca mai la rete.
* per intercettare la richiesta, così il test non tocca mai la rete. Il corpo
* cifrato viene decifrato con la chiave del "dispositivo": se la cifratura fosse
* sbagliata il browser scarterebbe la push in silenzio e non arriverebbe niente.
*/
import assert from "node:assert/strict";
import { inviaPush } from "@/lib/webpush.server";
@@ -13,6 +15,33 @@ function base64UrlEncode(bytes: Uint8Array): string {
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function concat(...parti: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parti.reduce((n, p) => n + p.length, 0));
let offset = 0;
for (const parte of parti) {
out.set(parte, offset);
offset += parte.length;
}
return out;
}
function etichetta(testo: string): Uint8Array {
return concat(new TextEncoder().encode(testo), new Uint8Array([0]));
}
async function hkdf(salt: Uint8Array, ikm: Uint8Array, info: Uint8Array, byte: number) {
const chiave = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, [
"deriveBits",
]);
return new Uint8Array(
await crypto.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: salt as BufferSource, info: info as BufferSource },
chiave,
byte * 8,
),
);
}
async function chiaviVapidDiProva() {
const coppia = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
"sign",
@@ -23,6 +52,64 @@ async function chiaviVapidDiProva() {
return { publicKey: base64UrlEncode(raw), privateKey: jwk.d! };
}
/** Il "dispositivo": chiavi che finirebbero in `push_subscriptions`, più la privata. */
async function dispositivoDiProva() {
const coppia = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
"deriveBits",
]);
const pubblica = new Uint8Array(await crypto.subtle.exportKey("raw", coppia.publicKey));
const auth = crypto.getRandomValues(new Uint8Array(16));
return {
iscrizione: {
endpoint: "https://push.example/abc123",
p256dh: base64UrlEncode(pubblica),
auth: base64UrlEncode(auth),
},
pubblica,
auth,
privata: coppia.privateKey,
};
}
/** Fa quello che fa il browser alla ricezione: apre il record aes128gcm (RFC 8188/8291). */
async function decifra(corpo: Uint8Array, device: Awaited<ReturnType<typeof dispositivoDiProva>>) {
const salt = corpo.slice(0, 16);
const lunghezzaChiave = corpo[20]!;
const asPublic = corpo.slice(21, 21 + lunghezzaChiave);
const cifrato = corpo.slice(21 + lunghezzaChiave);
const asKey = await crypto.subtle.importKey(
"raw",
asPublic as BufferSource,
{ name: "ECDH", namedCurve: "P-256" },
false,
[],
);
const condiviso = new Uint8Array(
await crypto.subtle.deriveBits({ name: "ECDH", public: asKey }, device.privata, 256),
);
const ikm = await hkdf(
device.auth,
condiviso,
concat(etichetta("WebPush: info"), device.pubblica, asPublic),
32,
);
const cek = await hkdf(salt, ikm, etichetta("Content-Encoding: aes128gcm"), 16);
const nonce = await hkdf(salt, ikm, etichetta("Content-Encoding: nonce"), 12);
const aes = await crypto.subtle.importKey("raw", cek as BufferSource, "AES-GCM", false, [
"decrypt",
]);
const chiaro = new Uint8Array(
await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce as BufferSource },
aes,
cifrato as BufferSource,
),
);
assert.equal(chiaro.at(-1), 2, "l'ultimo record finisce col delimitatore 0x02");
return new TextDecoder().decode(chiaro.slice(0, -1));
}
const originali = {
pub: process.env["VAPID_PUBLIC_KEY"],
priv: process.env["VAPID_PRIVATE_KEY"],
@@ -40,11 +127,16 @@ function ripristinaEnv() {
}
}
const device = await dispositivoDiProva();
try {
// --- senza chiavi configurate: rifiuta subito, senza tentare la firma -------
delete process.env["VAPID_PUBLIC_KEY"];
delete process.env["VAPID_PRIVATE_KEY"];
await assert.rejects(() => inviaPush("https://push.example/ep"), /Chiavi VAPID non configurate/);
await assert.rejects(
() => inviaPush(device.iscrizione, "t", "b"),
/Chiavi VAPID non configurate/,
);
// --- con le chiavi: firma il JWT e chiama fetch con l'header vapid ----------
const { publicKey, privateKey } = await chiaviVapidDiProva();
@@ -60,15 +152,25 @@ try {
}) as typeof fetch;
try {
const stato = await inviaPush("https://push.example/abc123");
const { stato, corpo: risposta } = await inviaPush(
device.iscrizione,
"Porta i palloni",
"Stasera tocca a te.",
);
assert.equal(stato, 201, "restituisce lo status della risposta");
assert.equal(typeof risposta, "string", "riporta anche il corpo, per capire i rifiuti");
assert.ok(richiesta, "ha chiamato fetch");
assert.equal(richiesta!.url, "https://push.example/abc123");
assert.equal(richiesta!.init.method, "POST");
const headers = new Headers(richiesta!.init.headers);
assert.equal(headers.get("TTL"), "86400");
assert.equal(headers.get("Content-Length"), "0");
assert.equal(headers.get("Content-Encoding"), "aes128gcm");
assert.equal(
headers.get("Urgency"),
"high",
"con l'urgenza normale un telefono in doze accumula i messaggi fino al risveglio: la notifica arriverebbe solo a dispositivo già attivo",
);
const auth = headers.get("Authorization")!;
assert.ok(auth.startsWith("vapid t="), "usa lo schema vapid con il token JWT");
@@ -83,6 +185,15 @@ try {
assert.equal(claim.aud, "https://push.example", "l'audience è l'origine dell'endpoint");
assert.equal(claim.sub, "mailto:test@example.com");
assert.ok(claim.exp > Date.now() / 1000, "il token scade nel futuro");
// --- il payload: il testo viaggia dentro la push, non lo si va a cercare --
const corpo = new Uint8Array(richiesta!.init.body as ArrayBuffer);
assert.equal(corpo[20], 65, "l'intestazione dichiara la chiave effimera da 65 byte");
assert.deepEqual(
JSON.parse(await decifra(corpo, device)),
{ title: "Porta i palloni", body: "Stasera tocca a te." },
"il dispositivo legge titolo e testo senza nessuna chiamata di rete",
);
} finally {
globalThis.fetch = fetchOriginale;
}