Compare commits
20
Commits
90f16403fb
...
fb6bde279e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb6bde279e | ||
|
|
5c709c1a12 | ||
|
|
78d41daf4f | ||
|
|
8047e58ae0 | ||
|
|
d8f334007b | ||
|
|
2b8ba340a6 | ||
|
|
ee9f3f1b8c | ||
|
|
847972b582 | ||
|
|
93b61d422a | ||
|
|
fe0c938358 | ||
|
|
0addafa76f | ||
|
|
94673090f1 | ||
|
|
50a712c276 | ||
|
|
77f587b53b | ||
|
|
ee21278e82 | ||
|
|
d5035cbf55 | ||
|
|
a8bde94153 | ||
|
|
41ed3a1f0f | ||
|
|
208dd5f2f6 | ||
|
|
d4a02fb606 |
@@ -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
|
||||
|
||||
+21
-2
@@ -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 |
|
||||
@@ -51,10 +70,10 @@ non in questo file.
|
||||
## Turni e notifiche
|
||||
|
||||
| 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. | |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
{
|
||||
"name": "tanstack_start_ts",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
|
||||
+20
-25
@@ -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.",
|
||||
self.registration.showNotification(dati?.title ?? "CrAPP", {
|
||||
body: dati?.body ?? "Apri l'app per i dettagli.",
|
||||
icon: "/icon-192.png",
|
||||
badge: "/icon-192.png",
|
||||
tag: "turno-palloni",
|
||||
});
|
||||
})(),
|
||||
// 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+37
-26
@@ -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) {
|
||||
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);
|
||||
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.`,
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+13
-55
@@ -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)),
|
||||
return useMemo(
|
||||
() => presenzeUltimoMese(giocatoreId, eventi, presenze),
|
||||
[eventi, presenze, 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]);
|
||||
}
|
||||
|
||||
/** 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
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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é lì 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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
+61
-8
@@ -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ì |
|
||||
| `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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
// 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("/api/public/sollecita-presenze", { eventoId: "" })).status,
|
||||
400,
|
||||
"eventoId vuoto non è valido",
|
||||
(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");
|
||||
}
|
||||
|
||||
@@ -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 lì 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user