Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c12011d330 | ||
|
|
7143c3d2eb | ||
|
|
71b2d97fae | ||
|
|
9b4e3e4b45 | ||
|
|
563142a30a | ||
|
|
07624f6b9f | ||
|
|
ded71f567b | ||
|
|
b6dee87eb3 | ||
|
|
4760e8650c | ||
|
|
e9013b703b | ||
|
|
58eba642f7 | ||
|
|
969dc1400c | ||
|
|
b7d3fde225 | ||
|
|
2a0b611459 | ||
|
|
cf667453a7 | ||
|
|
39918f2eb7 | ||
|
|
a72eb087d1 | ||
|
|
34edfc662e | ||
|
|
e0a9d19a02 | ||
|
|
3c2f9cf876 | ||
|
|
93d70f4879 | ||
|
|
20ca6ba5a6 | ||
|
|
342b457ee7 | ||
|
|
a6defc1a49 | ||
|
|
0a2112a9f3 | ||
|
|
a51cdb43e4 | ||
|
|
49d4d9de31 | ||
|
|
9cc4c7e47a | ||
|
|
46bcb21cdb | ||
|
|
15a23dfaf4 | ||
|
|
2ec72fd08f | ||
|
|
15a010855e | ||
|
|
dda4aff746 | ||
|
|
e1e4ca0df6 | ||
|
|
ae5d790011 | ||
|
|
33ed2398ab | ||
|
|
c6437a605c | ||
|
|
431530af2a | ||
|
|
61d4dacb3a | ||
|
|
cdd433cb15 | ||
|
|
3f2ca30775 | ||
|
|
4d036f532d | ||
|
|
0314560885 | ||
|
|
0286961e92 | ||
|
|
6372ef9c8d | ||
|
|
fcff950006 | ||
|
|
99ca3b6495 | ||
|
|
457a0fe23d | ||
|
|
d8fc6ef04f | ||
|
|
e99d853987 | ||
|
|
efca4243b6 | ||
|
|
abd6fafc0c | ||
|
|
d52ceeb0f9 | ||
|
|
a9f146d77e |
No files matched your search
+11
-1
@@ -40,4 +40,14 @@ dist-ssr
|
||||
# Optional
|
||||
.vercel
|
||||
# Supabase CLI local state
|
||||
supabase/.temp/
|
||||
supabase/.temp/
|
||||
|
||||
# Backup locali del database (contengono dati reali degli utenti):
|
||||
# ignora tutto il contenuto generato, ma tieni in git lo script e il README.
|
||||
backups/*
|
||||
!backups/backup.sh
|
||||
!backups/download-storage.mjs
|
||||
!backups/storage-core.ts
|
||||
!backups/README.md
|
||||
# Automazioni operative locali (backup cifrati, playbook con dati sensibili)
|
||||
ops/
|
||||
@@ -1,38 +0,0 @@
|
||||
# Obiettivi di squadra: sezione in Squadra + widget in home
|
||||
|
||||
## Stato attuale
|
||||
|
||||
Al momento c'è un solo "obiettivo di squadra" ed è hardcoded nella home (`src/routes/index.tsx`, riga 136): **"90% di presenze ad agosto"** con una barra finta all'82%. Non esiste uno schema dati né una sezione dedicata, e non è collegato a calendario, presenze o statistiche. Le voci "obiettivi" nelle descrizioni di Squadra e Profilo si riferiscono ai badge individuali, non a obiettivi di squadra.
|
||||
|
||||
## Cosa faccio
|
||||
|
||||
1. **Modello dati locale** in `src/lib/crapp-data.ts`
|
||||
|
||||
- Nuovo tipo `ObiettivoSquadra`: id, titolo, descrizione, target, valore attuale, unità, scadenza (opzionale), icona.
|
||||
- Array `obiettiviSquadra` con gli obiettivi demo della stagione.
|
||||
|
||||
2. **Sezione "Obiettivi di squadra" dentro la scheda Squadra** (`src/routes/squadra.tsx`)
|
||||
|
||||
- Nuova sezione con la lista completa degli obiettivi: barra di progresso, percentuale, stato (in corso / completato).
|
||||
- Ordinati con gli obiettivi in corso in cima e i completati in fondo.
|
||||
- Nessuna nuova rotta e nessuna modifica al bottom nav.
|
||||
|
||||
3. **Widget home dinamico** (`src/routes/index.tsx`)
|
||||
|
||||
- Sostituisce l'obiettivo hardcoded: mostra sempre il primo obiettivo in corso preso dalla lista.
|
||||
- Progresso calcolato dai dati reali dove possibile (es. presenze derivate dagli eventi).
|
||||
|
||||
4. **Obiettivi demo iniziali**
|
||||
|
||||
- 90% di presenze ad agosto (collegato agli eventi di agosto).
|
||||
- 70% di risposte entro 24h nel prossimo mese.
|
||||
- Prima vittoria del campionato (collegato allo storico match).
|
||||
- 5 vittorie in campionato (collegato allo storico match).
|
||||
- 10 vittorie in campionato (collegato allo storico match).
|
||||
- 1 evento di squadra al mese (pizzata, ecc.).
|
||||
|
||||
## Cosa non cambia
|
||||
|
||||
- Resta un prototipo offline: i dati restano in `src/lib/crapp-data.ts`.
|
||||
- I badge individuali restano come sono in `src/lib/badges.ts` e nella rosa di `src/routes/squadra.tsx`.
|
||||
- Bottom nav e rotte invariate.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Rendere CrAPP operativa: migrazione dal prototipo al Cloud
|
||||
|
||||
## Stato attuale
|
||||
|
||||
L'app è un prototipo UI con alcune funzioni già collegate a Lovable Cloud:
|
||||
|
||||
- Già sul Cloud: voti MVP (`mvp_voti`), iscrizioni push (`push_subscriptions`), sessioni scout (`scout_sessioni`), turni palloni (`turni_palloni`).
|
||||
- Ancora in locale come dati demo: rosa giocatori, calendario eventi, storico partite, classifica CSI, statistiche individuali, presenze/assenze, badge.
|
||||
|
||||
## Cosa serve per renderla operativa
|
||||
|
||||
1. **Rosa e profili giocatori sul database**
|
||||
- Creare tabella `profiles` (o `giocatori`) con nome, numero maglia, ruolo, data di nascita, foto profilo.
|
||||
- Collegare ogni riga all'utente autenticato corrispondente.
|
||||
- Rimuovere la rosa statica da `src/lib/crapp-data.ts` e caricarla dal backend.
|
||||
|
||||
2. **Autenticazione reale**
|
||||
- Sostituire la semplice selezione "Chi sei?" in `localStorage` con login email/password o OAuth (Google).
|
||||
- Ogni giocatore accede con le proprie credenziali e vede solo i propri dati modificabili.
|
||||
- Necessaria per garantire che uno scout o un voto MVP provenga davvero da quel giocatore.
|
||||
|
||||
3. **Calendario eventi persistente**
|
||||
- Tabella `eventi` con tipo, titolo, data, ora, luogo, avversario, casa/fuori.
|
||||
- Tabella `presenze` (evento_id, giocatore_id, stato, aggiornato_il).
|
||||
- I compleanni possono restare derivati dalla data di nascita dei giocatori.
|
||||
|
||||
4. **Statistiche e badge dinamici**
|
||||
- Tabella `statistiche` o `azioni_scout` (evento_id, giocatore_id, tipo azione, valore, creato_il).
|
||||
- I badge vengono calcolati in tempo reale dalle statistiche accumulate, senza valori fissi in `crapp-data.ts`.
|
||||
|
||||
5. **Scout live collegato ai dati reali**
|
||||
- Le azioni registrate in `scout.tsx` devono scrivere sulle tabelle eventi/statistiche.
|
||||
- Mantenere il lock di modifica singolo e l'attivazione solo il giorno della partita.
|
||||
|
||||
6. **Classifica CSI**
|
||||
- Tabella `classifica` aggiornata manualmente da un admin o importata dal sito CSI quando disponibile.
|
||||
- Per ora nessuna API CSI ufficiale: si inserisce a mano o si copia/incolla.
|
||||
|
||||
7. **Notifiche push definitive**
|
||||
- Verificare che i cron job inviino correttamente i promemoria palloni.
|
||||
- Aggiungere notifiche per conferma eventi, promemoria presenze, MVP votabile.
|
||||
|
||||
8. **Ruoli e permessi**
|
||||
- Definire chi può creare/modificare eventi (capitano/admin).
|
||||
- Chi può fare scout (designato per partita).
|
||||
- Chi può modificare i turni palloni (tutti, come da tua richiesta).
|
||||
|
||||
## Cosa resta salvato nel Cloud
|
||||
|
||||
Sì: tutto ciò che viene scritto sulle tabelle Lovable Cloud/Supabase resta salvato online e condiviso tra tutti i dispositivi.
|
||||
|
||||
I dati demo in `src/lib/crapp-data.ts` invece no: sono file statici, quindi ogni aggiornamento dell'app li sovrascrive e ogni telefono li vede identici.
|
||||
|
||||
## Decisioni da prendere insieme
|
||||
|
||||
- Vuoi abilitare login email/password per ogni giocatore, o preferisci mantenere la selezione "Chi sei?" senza password per semplicità?
|
||||
- Chi gestirà inserimento eventi e aggiornamento classifica: solo alcuni o tutta la squadra?
|
||||
- Vuoi procedere per fasi (prima rosa + calendario + presenze, poi statistiche) o tutto insieme?
|
||||
@@ -1,25 +0,0 @@
|
||||
# Ridimensionamento badge nella lista squadra
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Rendere i badge accanto al nome del giocatore nella lista squadra più compatti e meno invasivi, mantenendo lo stile stilizzato (icone Lucide colorate per grado) e lasciando la scheda espansa con una dimensione leggibile.
|
||||
|
||||
## Modifiche previste
|
||||
|
||||
1. **Lista squadra (`src/routes/squadra.tsx`)**
|
||||
- Ridurre le icone badge sbloccati mostrate accanto al nome da `h-4 w-4` (16px) a `h-3 w-3` (12px).
|
||||
- Mantenere il massimo di 3 badge visibili e il contatore `+N` con testo ridotto a `text-[9px]` per armonizzare.
|
||||
- Lasciare invariati avatar, nome, ruolo ed età.
|
||||
|
||||
2. **Scheda giocatore espansa (`src/routes/squadra.tsx`)**
|
||||
- Portare le icone badge dalla dimensione attuale a `h-4 w-4` (16px), leggibili ma non troppo grandi.
|
||||
- Mantenere card, colori per grado (bronzo/argento/oro) e testi descrittivi.
|
||||
|
||||
3. **Verifica**
|
||||
- Controllare la preview su `/squadra` per confermare che i badge in lista siano discreti e la scheda espansa rimanga leggibile.
|
||||
- Eseguire build per assicurarsi che non ci siano errori di tipo o stile.
|
||||
|
||||
## Cosa non cambia
|
||||
|
||||
- Colori dei gradi, soglie badge, logica di sblocco e votazione MVP.
|
||||
- Layout generale della pagina e bottom navigation.
|
||||
@@ -1,14 +0,0 @@
|
||||
# Rimuovere placeholder "Livello 7" dal Profilo
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Eliminare il testo statico "Livello 7" dalla scheda profilo, dato che non è collegato a nessun calcolo reale e l'utente preferisce toglierlo per ora.
|
||||
|
||||
## Modifica
|
||||
|
||||
- `src/routes/profilo.tsx`: rimuovere il paragrafo `<p className="font-display text-2xl leading-none">Livello 7</p>` (riga 116) e, se necessario, riallineare il layout circostante per evitare spazi vuoti strani.
|
||||
|
||||
## Verifica
|
||||
|
||||
- Build senza errori.
|
||||
- Preview della pagina Profilo: nessun riferimento a "Livello" visibile.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Selezione giocatore al primo avvio
|
||||
|
||||
Aggiungere un flusso di onboarding che chiede "Chi sei?" la prima volta che l'app viene aperta, memorizzando la scelta in `localStorage`. Il profilo e la home si aggiorneranno automaticamente in base al giocatore selezionato.
|
||||
|
||||
## Cosa cambia
|
||||
|
||||
1. **Nuovo store `src/lib/user-store.ts`**
|
||||
- Persiste in `localStorage` l'`id` del giocatore scelto.
|
||||
- Espone `useGiocatoreCorrente()` che restituisce il giocatore selezionato o `null`.
|
||||
- Espone `impostaGiocatore(id)` e `resetGiocatore()`.
|
||||
|
||||
2. **Nuova route `/benvenuto`**
|
||||
- Schermata full-screen con logo, titolo "Benvenuto in CrAPP" e lista scrollabile della rosa.
|
||||
- Ogni riga mostra iniziali, nome, ruolo e numero maglia.
|
||||
- Al tap su un giocatore, lo store viene aggiornato e l'utente viene portato a `/`.
|
||||
- Non mostra la bottom navigation.
|
||||
|
||||
3. **Reindirizzamento condizionato in `__root.tsx`**
|
||||
- Se non è ancora stato selezionato un giocatore, qualunque route apre `/benvenuto`.
|
||||
- Dopo la scelta, l'app funziona normalmente.
|
||||
|
||||
4. **Sostituzione di `giocatoreCorrente` con `useGiocatoreCorrente()`**
|
||||
- Aggiornare `src/routes/index.tsx` per salutare il giocatore selezionato e mostrarne le statistiche rapide.
|
||||
- Aggiornare `src/routes/profilo.tsx` per renderlo il profilo personale del giocatore scelto.
|
||||
|
||||
5. **Cambio utente dalle impostazioni**
|
||||
- In `src/routes/profilo.tsx`, aggiungere una voce "Cambia giocatore" che resetta la selezione e porta a `/benvenuto`.
|
||||
|
||||
## Note tecniche
|
||||
|
||||
- `localStorage` viene letto solo lato client, usando `useSyncExternalStore` per evitare mismatch di hydration.
|
||||
- La rosa reale è già presente in `src/lib/crapp-data.ts` (`giocatori`).
|
||||
- La costante esportata `giocatoreCorrente` verrà rimossa; i componenti consumeranno il nuovo hook.
|
||||
- Nessun backend richiesto: resta un prototipo locale.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Turno palloni nel calendario + promemoria
|
||||
|
||||
Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra tutti, con rotazione automatica proposta dall'app e promemoria (in-app e push).
|
||||
|
||||
## Cosa vedrà la squadra
|
||||
|
||||
- **Riga "Palloni" su ogni evento** (allenamento e partita) nella card del calendario e in Home: avatar + nome dell'incaricato, oppure "Da assegnare".
|
||||
- **Chiunque può cambiarlo**: tocco sulla riga, si apre la lista della rosa, si sceglie il nome. La modifica è immediata e visibile a tutti.
|
||||
- **Proposta automatica a rotazione**: l'app suggerisce chi non ha ancora fatto il turno di recente (o è stato meno volte incaricato). Il suggerimento è solo una proposta: resta sempre modificabile.
|
||||
- **Storico turni** nella scheda squadra/giocatore: quante volte ciascuno ha portato i palloni.
|
||||
- **Due promemoria per l'incaricato**:
|
||||
1. il giorno stesso dell'evento in cui li deve **prendere** a fine allenamento/partita;
|
||||
2. il giorno dell'evento successivo, per ricordargli di **riportarli**.
|
||||
- I promemoria arrivano come banner ben visibile in Home e, per chi attiva le notifiche, come notifica push sul telefono.
|
||||
|
||||
## Impostazione tecnica
|
||||
|
||||
**Backend (Lovable Cloud)**
|
||||
|
||||
- Attivazione di Lovable Cloud.
|
||||
- Tabella `eventi` (spostando i dati demo attuali su database) o, in alternativa minima, tabella `turni_palloni` con `evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`. Scelgo la seconda per limitare il refactor: gli eventi restano in `crapp-data.ts` finché non si passa a calendario dinamico.
|
||||
- Tabella `push_subscriptions` (giocatore_id, endpoint, chiavi) per le notifiche.
|
||||
- Grant espliciti + RLS: lettura e scrittura aperte a tutti gli utenti dell'app (nessun login previsto oggi → policy per `anon` limitate a queste tabelle, nessun dato personale sensibile).
|
||||
- Server functions in `src/lib/palloni.functions.ts`: `getTurni`, `setTurno`, `suggerisciTurno`.
|
||||
|
||||
**Frontend**
|
||||
|
||||
- Nuovo componente `TurnoPalloni` usato in `EventoCard` e in Home.
|
||||
- Lettura via TanStack Query (`ensureQueryData` nel loader, `useSuspenseQuery` nel componente), invalidazione dopo la modifica.
|
||||
- Banner promemoria in Home basato su data odierna + evento successivo, mostrato solo al giocatore selezionato in `user-store`.
|
||||
|
||||
**Notifiche push**
|
||||
|
||||
- Service worker dedicato al messaging (separato dalla PWA esistente), chiavi VAPID salvate come secret.
|
||||
- Schermata in Profilo: "Attiva notifiche palloni" con richiesta di permesso.
|
||||
- Invio schedulato tramite un endpoint `src/routes/api/public/promemoria-palloni.ts` protetto da secret, richiamato una volta al giorno da un job pianificato (pg_cron).
|
||||
- Nota: su iPhone le notifiche push funzionano solo se l'app è installata dalla schermata Home.
|
||||
|
||||
## Ordine di lavoro
|
||||
|
||||
1. Attivare Lovable Cloud e creare tabelle + policy.
|
||||
2. Server functions + UI del turno palloni (assegnazione manuale condivisa).
|
||||
3. Rotazione automatica suggerita + storico turni.
|
||||
4. Banner promemoria in-app.
|
||||
5. Notifiche push + job giornaliero.
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"template": "tanstack_start_ts_current",
|
||||
"revision": "tanstack_start_ts_current-9e5645c506e5"
|
||||
}
|
||||
@@ -30,6 +30,7 @@ Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone
|
||||
npm run dev # vite dev su http://localhost:8080
|
||||
npm run build # build di produzione (nitro)
|
||||
npm run lint # eslint (include prettier come regola)
|
||||
npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file)
|
||||
npm run format # prettier --write .
|
||||
npm run test # suite di test (test/); npm run test:all per quella completa
|
||||
|
||||
@@ -59,7 +60,7 @@ copre più il comportamento nuovo va aggiornata nello stesso lavoro.
|
||||
Prima di dire che hai finito:
|
||||
|
||||
1. i test delle funzioni aggiunte o modificate esistono e sono verdi;
|
||||
2. `npm run lint` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e);
|
||||
2. `npm run lint`, `npm run typecheck` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e);
|
||||
3. la documentazione toccata dalla modifica è aggiornata (vedi Test e Tracciabilità);
|
||||
4. hai detto all'utente cosa hai cambiato, cosa hai lasciato fuori e quali rischi vedi.
|
||||
|
||||
|
||||
+59
-9
@@ -1,6 +1,6 @@
|
||||
# Project State
|
||||
|
||||
Ultimo aggiornamento: 09/09/2026
|
||||
Ultimo aggiornamento: 29/09/2026
|
||||
|
||||
## Stato generale
|
||||
|
||||
@@ -10,9 +10,12 @@ Backend migrato al nuovo Supabase proprietario. Autenticazione Google, dashboard
|
||||
amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`.
|
||||
Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora
|
||||
sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati
|
||||
reali (M9). Prima versione pre-release rilasciata (0.9.0, vedi `docs/CHANGELOG.md`). Cancellare
|
||||
un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane da
|
||||
cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
|
||||
reali (M9). Prima versione pre-release rilasciata (0.9.0); la prima versione stabile, 1.0.0, ha
|
||||
portato il ruolo allenatore. La versione corrente è la 1.2.1: notifiche su tre canali (push, in-app
|
||||
ed email), promemoria che non si ripetono, turno palloni e solleciti presenze automatici (vedi
|
||||
`docs/CHANGELOG.md`).
|
||||
Cancellare un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane
|
||||
da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
|
||||
|
||||
---
|
||||
|
||||
@@ -23,8 +26,12 @@ cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
|
||||
- Cursor e Claude Code come ambienti di sviluppo
|
||||
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
|
||||
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
|
||||
- 27 migration in `supabase/migrations/`, fino a `m16_funzione_bonifica_dati_evento_orfani`
|
||||
(09/09/2026)
|
||||
- 37 migration in `supabase/migrations/`, fino a `m26_notifiche_automatiche` (30/09/2026), tutte
|
||||
applicate in produzione, verificato con `npx supabase migration list` il 30/09/2026
|
||||
- Worker delle notifiche email (`mailer/`) in funzione su un Raspberry Pi con Docker dal
|
||||
29/09/2026: invia via Gmail le notifiche di `notifiche_utente`. Installazione e gestione in
|
||||
[docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md); il codice con l'interruttore «Email» in Profilo è
|
||||
su `develop`, non ancora su `main`
|
||||
- Sviluppo locale verificato con il nuovo Supabase
|
||||
|
||||
---
|
||||
@@ -42,6 +49,49 @@ cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
|
||||
- Schema v1.0 e migration da M1 a M16 applicate al nuovo Supabase
|
||||
(`m16_funzione_bonifica_dati_evento_orfani` in produzione dal 09/09/2026, verificata con
|
||||
`npx supabase migration list`)
|
||||
- Migration `m17_notifiche_utente` (15/09/2026, DD-030): nuova tabella `notifiche_utente`
|
||||
per il centro notifiche in-app — **in produzione** dal 15/09/2026 (è additiva: non tocca
|
||||
schema o comportamento esistenti, vedi DD-030). Il codice che la usa (centro notifiche,
|
||||
route aggiornate) non è ancora deployato: la tabella si riempie già in background
|
||||
- Migration `m18_nascita_pubblica_giocatori_squadra` (15/09/2026, DD-031): nuova colonna
|
||||
`giocatori_squadra.nascita`, sincronizzata da `profili_giocatore.data_nascita` — corregge
|
||||
l'"Invalid Date" in Squadra per chi inserisce la propria nascita dal Profilo invece del
|
||||
seed storico. **In produzione** dal 15/09/2026, ma il backfill copriva solo il seed
|
||||
storico: chi aveva già un profilo compilato prima di M18 è rimasto scoperto
|
||||
- Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il
|
||||
backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata
|
||||
in `npx supabase migration list` del 24/09/2026)
|
||||
- Migration `m25_promemoria_solo_giocatori` (29/09/2026, DD-039): `giocatori_destinatari_evento()`
|
||||
include solo i giocatori attivi, quindi l'allenatore non riceve più i promemoria degli eventi
|
||||
(li riceveva sempre dalla `m21`). Cambia solo quella funzione; verificata in locale con
|
||||
`npx supabase db reset` e i test di integrazione, **non ancora in produzione**
|
||||
- Migration `m24_push_promemoria_e_sondaggio` (29/09/2026, DD-038): tabella `notifiche_push_coda`
|
||||
con trigger e due funzioni per il worker (la push dei promemoria a 24 e 3 ore), e nuovo tipo
|
||||
`sondaggio_cacche` in `notifiche_utente` (il sondaggio pre-partita compare in-app e per email).
|
||||
Additiva; verificata in locale con `npx supabase db reset`, i test di integrazione e una prova
|
||||
end-to-end del worker con un servizio push finto, **non ancora in produzione**. Richiede il
|
||||
worker ricostruito e le chiavi VAPID nel suo `.env` (vedi `docs/WORKER_EMAIL.md`)
|
||||
- Migration `m23_promemoria_gia_generati` (29/09/2026, DD-037): tabella
|
||||
`promemoria_eventi_generati` e nuova `genera_promemoria_eventi()`, così un promemoria a 24 o 3
|
||||
ore eliminato dal centro notifiche non viene più rigenerato dal cron (e non riaccoda mail).
|
||||
Corregge un difetto presente dalla `m17`, emerso con l'arrivo delle email. Additiva; verificata
|
||||
in locale con `npx supabase db reset` e `test/integration/promemoria-eventi.test.ts`, **non
|
||||
ancora in produzione**
|
||||
- Migration `m22_notifiche_email` (29/09/2026, DD-036): tabelle `notifiche_email_coda` e
|
||||
`preferenze_utente`, trigger che accoda ogni notifica di `notifiche_utente` e tre funzioni
|
||||
RPC riservate alla service role, per il worker email. Additiva: non tocca lo schema
|
||||
esistente. Verificata in locale con `npx supabase db reset`, test unit e di integrazione e
|
||||
una prova end-to-end con il worker in Docker contro Mailpit, poi **in produzione** dal
|
||||
29/09/2026 (`supabase db push`) con il worker in funzione
|
||||
- Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (24/09/2026, DD-034): ruolo
|
||||
allenatore (colonna `giocatori_squadra.tipo`, valore `allenatore` di `app_role`, policy e
|
||||
trigger) — verificate in locale con `npx supabase db reset` e `npm run test:all`, **in
|
||||
produzione** dal 24/09/2026 (`supabase db push`). Compatibili con la 0.9.2: senza allenatori
|
||||
registrati non cambiano nulla. Il codice che le usa non è ancora deployato
|
||||
- Slot `g18` «Beta Tester» impostato come allenatore in produzione il 24/09/2026 (colonna
|
||||
`tipo`, ruolo assegnato dal trigger di M21). Numero 99 e ruolo «Palleggiatore»
|
||||
lasciati apposta finché gira la 0.9.2, che lo mostra ancora come giocatore: da svuotare
|
||||
da `/admin` dopo il deploy del codice nuovo
|
||||
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
|
||||
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
|
||||
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
|
||||
@@ -156,9 +206,9 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin.
|
||||
|
||||
## Prossimo sviluppo
|
||||
|
||||
Niente di assegnato: tutto quello che era in lavorazione è chiuso, tesseramento CSI incluso
|
||||
(numero e data di tessera registrabili da `/admin`, migration `m8_tesseramento_csi`). Le voci
|
||||
ancora aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
Niente di assegnato: l'avviso certificati in scadenza (DD-035, 1.1.0), le notifiche su tre
|
||||
canali (DD-036–DD-039, 1.2.0) e quelle automatiche di palloni e solleciti (DD-040, 1.2.1) sono implementati (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ npm run dev # http://localhost:8080
|
||||
```bash
|
||||
npm run build # build di produzione
|
||||
npm run lint # eslint (include prettier)
|
||||
npm run typecheck # controllo dei tipi TypeScript
|
||||
npm run test # test unit; npm run test:all per la suite completa
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Backup di disaster recovery
|
||||
|
||||
Script per scaricare su questo host un backup completo del progetto Supabase:
|
||||
schema DB, dati, utenti auth e file storage. Serve per poter ripartire in fretta
|
||||
se il progetto Supabase (o Vercel) venisse perso.
|
||||
|
||||
## Preparazione, passo per passo
|
||||
|
||||
Da fare una volta sola per host (poi il backup si rilancia semplicemente con
|
||||
`./backups/backup.sh`).
|
||||
|
||||
1. **Installa/aggiorna la CLI Supabase** (via `npx`, non serve installarla a parte):
|
||||
```bash
|
||||
npx supabase --version
|
||||
```
|
||||
2. **Accedi con il tuo account Supabase**:
|
||||
```bash
|
||||
npx supabase login
|
||||
```
|
||||
Si apre il browser per l'OAuth; il token resta salvato in locale.
|
||||
3. **Collega il repo al progetto Supabase** (il `project_id` è già in
|
||||
`supabase/config.toml`, quindi in genere basta):
|
||||
```bash
|
||||
npx supabase link --project-ref <project_id>
|
||||
```
|
||||
Verifica che sia andato a buon fine con `npx supabase projects list`
|
||||
(il progetto deve comparire con `"linked":true`).
|
||||
4. **Crea/controlla il file `.env`** nella root del repo (accanto a
|
||||
`package.json`, non dentro `backups/`) con almeno queste due chiavi:
|
||||
```
|
||||
VITE_SUPABASE_URL="https://<project_id>.supabase.co"
|
||||
SUPABASE_SERVICE_ROLE_KEY="<service role key>"
|
||||
```
|
||||
Le trovi su [supabase.com/dashboard](https://supabase.com/dashboard) → progetto →
|
||||
Project Settings → API. La **service role key** è quella con permessi pieni
|
||||
(bypassa le RLS): serve solo per scaricare i file dai bucket, non va mai
|
||||
esposta lato client né committata.
|
||||
5. **Verifica che gpg sia installato** (serve per cifrare, di default):
|
||||
```bash
|
||||
gpg --version
|
||||
```
|
||||
Su Debian/Ubuntu: `sudo apt install gnupg` se manca.
|
||||
|
||||
A questo punto sei pronto per lanciare il backup vero e proprio (vedi sotto).
|
||||
|
||||
## Uso
|
||||
|
||||
```bash
|
||||
./backups/backup.sh # crea backups/YYYYMMDD_HHMM/, poi comprime e cifra
|
||||
# in backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
|
||||
# passphrase a te scelta) e cancella cartella+tar.gz
|
||||
# in chiaro
|
||||
./backups/backup.sh --no-encrypt # come sopra ma lascia solo il tar.gz in chiaro
|
||||
# (nessuna passphrase richiesta)
|
||||
```
|
||||
|
||||
La passphrase non è salvata da nessuna parte: la scegli tu al momento (gpg la
|
||||
chiede due volte, per conferma) e ti serve identica per decifrare in seguito —
|
||||
se la perdi, il backup cifrato è irrecuperabile. Tienila in un password manager.
|
||||
|
||||
Ogni esecuzione crea una cartella/archivio nuovo con timestamp: non sovrascrive
|
||||
backup precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a
|
||||
mano quando non servono più.
|
||||
|
||||
Se un passaggio fallisce (un dump o anche un solo file dello storage non scaricato),
|
||||
lo script si ferma con un errore e **non** stampa «Backup completato»: un backup
|
||||
incompleto non va considerato valido, rilancialo.
|
||||
|
||||
Il backup contiene:
|
||||
|
||||
| File/cartella | Contenuto |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `schema_public.sql` | schema delle tabelle applicative (tabelle, funzioni, RLS) |
|
||||
| `data_public.sql` | dati delle tabelle applicative |
|
||||
| `schema_auth.sql` | schema utenti Supabase Auth |
|
||||
| `data_auth.sql` | utenti reali, incluso l'hash della password |
|
||||
| `storage/` | tutti i file dei bucket (`avatar-giocatori`, `profili-giocatore`: foto, documenti d'identità, certificati) |
|
||||
|
||||
## Decomprimere
|
||||
|
||||
Archivio cifrato (caso di default), chiede la passphrase scelta al momento
|
||||
del backup:
|
||||
|
||||
```bash
|
||||
gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz -C backups/
|
||||
```
|
||||
|
||||
Ricrea `backups/AAAAMMGG_hhmm/` con dentro i file elencati sopra.
|
||||
|
||||
Archivio non cifrato (generato con `--no-encrypt`):
|
||||
|
||||
```bash
|
||||
tar xzf backups/AAAAMMGG_hhmm.tar.gz -C backups/
|
||||
```
|
||||
|
||||
## Ripristino
|
||||
|
||||
1. Nuovo progetto Supabase (o `supabase start` in locale via Docker).
|
||||
2. Importa nell'ordine: `schema_public.sql` → `schema_auth.sql` →
|
||||
`data_auth.sql` → `data_public.sql` (gli utenti auth prima dei dati
|
||||
pubblici, perché `giocatori.auth_user_id` referenzia `auth.users.id`).
|
||||
3. Ricrea i bucket `avatar-giocatori` e `profili-giocatore` e ricarica il
|
||||
contenuto di `storage/`.
|
||||
4. Reimposta le variabili d'ambiente su Vercel/locale (non incluse in questo
|
||||
backup — vedi sotto).
|
||||
5. Redeploy dal repo git (il codice non serve backupparlo qui, è già su
|
||||
GitHub e Gitea).
|
||||
|
||||
## Cosa NON è incluso
|
||||
|
||||
- **Variabili d'ambiente Vercel**: richiede `vercel login` interattivo, non
|
||||
automatizzabile da questo script. Esportale a mano con `vercel env pull`.
|
||||
- **Codice applicativo**: vive nel repo git, non nel database.
|
||||
|
||||
## Sicurezza
|
||||
|
||||
Questi backup contengono password hash, documenti d'identità e altri dati
|
||||
personali reali. Per questo:
|
||||
|
||||
- la cartella `backups/` è in `.gitignore` (tranne questo README e gli script:
|
||||
quelli sì restano in git) — **non committare mai i dati generati**;
|
||||
- se sposti un backup fuori da questo host, usa l'archivio cifrato (default)
|
||||
invece del tar.gz in chiaro generato con `--no-encrypt`;
|
||||
- cancella i backup in chiaro quando non ti servono più.
|
||||
|
||||
## File di questa cartella
|
||||
|
||||
| File | Ruolo |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `backup.sh` | Punto di ingresso: esegue dump e download in ordine |
|
||||
| `download-storage.mjs` | Scarica i file dei bucket (usa la service role key del `.env`) |
|
||||
| `storage-core.ts` | Logica pura (lettura del `.env`, elenco dei file), coperta da `test/unit/backup-storage.test.ts` |
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Backup completo di disaster recovery: schema + dati DB, utenti auth, file storage.
|
||||
# Uso:
|
||||
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/, comprime e cifra con gpg (chiede una passphrase)
|
||||
# ./backups/backup.sh --no-encrypt come sopra ma lascia solo il tar.gz in chiaro
|
||||
#
|
||||
# Richiede: essere già collegati al progetto Supabase (supabase login + link, già fatto
|
||||
# in questo repo), avere .env con VITE_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY, e bun
|
||||
# installato (esegue download-storage.mjs, che importa un modulo .ts).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STAMP="$(date +%Y%m%d_%H%M)"
|
||||
RUN_DIR="$SCRIPT_DIR/$STAMP"
|
||||
|
||||
mkdir -p "$RUN_DIR"
|
||||
echo "==> Backup in $RUN_DIR"
|
||||
|
||||
echo "==> Dump schema pubblico"
|
||||
npx supabase db dump --linked -f "$RUN_DIR/schema_public.sql"
|
||||
|
||||
echo "==> Dump dati pubblici"
|
||||
npx supabase db dump --linked --data-only -f "$RUN_DIR/data_public.sql"
|
||||
|
||||
echo "==> Dump schema auth"
|
||||
npx supabase db dump --linked --schema auth -f "$RUN_DIR/schema_auth.sql"
|
||||
|
||||
echo "==> Dump dati auth (utenti)"
|
||||
npx supabase db dump --linked --schema auth --data-only -f "$RUN_DIR/data_auth.sql"
|
||||
|
||||
echo "==> Download file storage (bucket)"
|
||||
bun "$SCRIPT_DIR/download-storage.mjs" "$RUN_DIR/storage"
|
||||
|
||||
echo "==> Backup completato: $RUN_DIR"
|
||||
|
||||
TARBALL="$RUN_DIR.tar.gz"
|
||||
echo "==> Comprimo in $TARBALL"
|
||||
tar -C "$SCRIPT_DIR" -czf "$TARBALL" "$STAMP"
|
||||
|
||||
if [[ "${1:-}" == "--no-encrypt" ]]; then
|
||||
echo "==> Archivio pronto: $TARBALL"
|
||||
else
|
||||
ARCHIVE="$TARBALL.gpg"
|
||||
echo "==> Cifro in $ARCHIVE (verrà chiesta una passphrase)"
|
||||
gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE" "$TARBALL"
|
||||
rm -rf "$RUN_DIR" "$TARBALL"
|
||||
echo "==> Archivio cifrato pronto: $ARCHIVE"
|
||||
echo " Per estrarlo: gpg -d $ARCHIVE | tar xz"
|
||||
fi
|
||||
@@ -0,0 +1,45 @@
|
||||
// Scarica tutti i file dei bucket Supabase Storage per il backup di disaster recovery.
|
||||
// Uso: node backups/download-storage.mjs [cartella-output]
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { elencaFile, leggiEnv } from "./storage-core.ts";
|
||||
|
||||
const env = leggiEnv(await readFile(new URL("../.env", import.meta.url), "utf8"));
|
||||
for (const [chiave, valore] of Object.entries(env)) process.env[chiave] ??= valore;
|
||||
|
||||
const BUCKETS = ["avatar-giocatori", "profili-giocatore"];
|
||||
const OUT_DIR = process.argv[2] ?? new URL("./storage/", import.meta.url).pathname;
|
||||
|
||||
const supabase = createClient(process.env.VITE_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
|
||||
|
||||
const elencaCartella = (bucket) => async (prefisso) => {
|
||||
const { data, error } = await supabase.storage.from(bucket).list(prefisso, { limit: 1000 });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
};
|
||||
|
||||
const falliti = [];
|
||||
|
||||
for (const bucket of BUCKETS) {
|
||||
console.log(`Bucket: ${bucket}`);
|
||||
const files = await elencaFile(elencaCartella(bucket));
|
||||
console.log(` ${files.length} file trovati`);
|
||||
for (const path of files) {
|
||||
const { data, error } = await supabase.storage.from(bucket).download(path);
|
||||
if (error) {
|
||||
console.error(` ERRORE ${path}:`, error.message);
|
||||
falliti.push(`${bucket}/${path}`);
|
||||
continue;
|
||||
}
|
||||
const destPath = join(OUT_DIR, bucket, path);
|
||||
await mkdir(dirname(destPath), { recursive: true });
|
||||
await writeFile(destPath, Buffer.from(await data.arrayBuffer()));
|
||||
console.log(` scaricato: ${bucket}/${path}`);
|
||||
}
|
||||
}
|
||||
if (falliti.length) {
|
||||
console.error(`${falliti.length} file non scaricati: il backup storage è incompleto.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Fatto.");
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Logica pura del backup storage, separata dalla rete per poterla testare. */
|
||||
|
||||
/** Righe `CHIAVE=valore` di un file `.env`; toglie le virgolette solo se aprono e chiudono. */
|
||||
export function leggiEnv(testo: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const riga of testo.split("\n")) {
|
||||
const match = /^([\w.]+)=(.*)$/.exec(riga.trim());
|
||||
if (!match) continue;
|
||||
const [, chiave, grezzo] = match as unknown as [string, string, string];
|
||||
const tra = /^(["'])(.*)\1$/.exec(grezzo);
|
||||
env[chiave] = tra ? tra[2]! : grezzo;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export type Voce = { name: string; id: string | null };
|
||||
|
||||
/** Tutti i percorsi dei file sotto `prefisso`; Supabase segnala le cartelle con `id: null`. */
|
||||
export async function elencaFile(
|
||||
lista: (prefisso: string) => Promise<Voce[]>,
|
||||
prefisso = "",
|
||||
): Promise<string[]> {
|
||||
const file: string[] = [];
|
||||
for (const voce of await lista(prefisso)) {
|
||||
const percorso = prefisso ? `${prefisso}/${voce.name}` : voce.name;
|
||||
if (voce.id === null) file.push(...(await elencaFile(lista, percorso)));
|
||||
else file.push(percorso);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
"": {
|
||||
"name": "crapp",
|
||||
"dependencies": {
|
||||
"@lovable.dev/cloud-auth-js": "^1.1.2",
|
||||
"@supabase/supabase-js": "^2.111.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.101.1",
|
||||
@@ -27,7 +26,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@lovable.dev/vite-tanstack-config": "2.8.5",
|
||||
"@tanstack/devtools-vite": "^0.8.3",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^19.2.0",
|
||||
@@ -130,14 +129,6 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="],
|
||||
|
||||
"@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.2.1", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-JdADRwpEJA5t0ggXbd96dND23Wsp69Af9lVSV5m7MnMGxJZlPgqAqz7HUKcfJESfI5M5yFZOl9e0x76JMMDOAg=="],
|
||||
|
||||
"@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.3.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.3.5.tgz", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-LxCj6JIbYRQ6peMm2aVn/bhHLkwZ5eZ4Cn6gmh7LfjaplqZHlA24nhCowgt6ZLfbckc4d8tSy0He7H8EiFEXag=="],
|
||||
|
||||
"@lovable.dev/vite-tanstack-config": ["@lovable.dev/vite-tanstack-config@2.8.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-2.8.5.tgz", { "dependencies": { "@lovable.dev/vite-plugin-dev-server-bridge": "^1.2.1", "@lovable.dev/vite-plugin-hmr-gate": "^1.3.4", "@tanstack/devtools-vite": "^0.8.1", "lightningcss": "^1.30.0" }, "peerDependencies": { "@tailwindcss/vite": ">=4.0.0", "@tanstack/react-start": ">=1.100.0", "@vitejs/plugin-react": ">=4.0.0", "nitro": ">=3.0.260603-beta", "vite": ">=5.0.0 <9.0.0", "vite-tsconfig-paths": ">=6.0.0" }, "optionalPeers": ["nitro"] }, "sha512-qPNxEXjvRsrbJHKgHxuLWpRW/gu2nIM+62qjTUCbCno5nRPMKyJQNsgjh48qjAkS6T0MRIkY/3BENNXeGsi5hw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="],
|
||||
|
||||
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
||||
@@ -424,7 +415,7 @@
|
||||
|
||||
"canvas-confetti": ["canvas-confetti@1.9.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
@@ -800,10 +791,6 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tanstack/devtools-bundler-core/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"@tanstack/devtools-vite/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@tanstack/router-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
@@ -820,6 +807,8 @@
|
||||
|
||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="],
|
||||
|
||||
"h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@ saveTextLockfile = true
|
||||
minimumReleaseAge = 86400
|
||||
# Each entry bypasses the 24h guard for one package — confirm with the user
|
||||
# before adding any.
|
||||
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config", "@lovable.dev/mcp-js", "@lovable.dev/vite-plugin-dev-server-bridge", "@lovable.dev/vite-plugin-hmr-gate", "@lovable.dev/email-js", "@lovable.dev/webhooks-js"]
|
||||
minimumReleaseAgeExcludes = []
|
||||
@@ -108,6 +108,7 @@ lanciato, così swipe come quello del calendario atterrano dove il gesto stava a
|
||||
npm run dev # vite dev su http://localhost:8080
|
||||
npm run build # build di produzione (nitro)
|
||||
npm run lint # eslint (include prettier come regola)
|
||||
npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file)
|
||||
npm run format # prettier --write .
|
||||
npm run test # test unit (veloci, senza rete né database)
|
||||
npm run test:integration # route server vere
|
||||
|
||||
+219
-3
@@ -6,12 +6,228 @@ e le voci sono divise per categoria (Aggiunto, Modificato, Sicurezza...). L'elen
|
||||
completo delle funzionalità, fatte e previste, sta in [ROADMAP.md](ROADMAP.md); qui si
|
||||
registra solo _quando_ una voce è stata rilasciata e con quale versione.
|
||||
|
||||
Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Il progetto è pre-1.0 (`0.y.z`):
|
||||
finché resta sotto `1.0.0` un aumento di `y` può includere anche cambi non compatibili
|
||||
all'indietro.
|
||||
Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Dalla `1.0.0` si segue il
|
||||
[versionamento semantico](https://semver.org/lang/it/): `x` cresce con i cambi non
|
||||
compatibili all'indietro, `y` con le funzionalità nuove, `z` con le sole correzioni. Le
|
||||
versioni `0.y.z` erano pre-release.
|
||||
|
||||
## [Non rilasciato]
|
||||
|
||||
## [1.2.1] - 2026-09-30
|
||||
|
||||
Turno palloni e sollecito presenze partono anche da soli, a orari fissi prima dell'evento, e i testi di
|
||||
tutte le notifiche legate agli eventi sono più formali e completi (DD-040). La migration `m26` è applicata in
|
||||
produzione dal 30/09/2026; questa versione porta il codice dell'app che la accompagna.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Turno palloni automatico** — 12, 6 e 3 ore prima di allenamenti e partite con un incaricato confermato,
|
||||
all'incaricato («incarico assegnato») e a chi aveva i palloni all'evento precedente («riconsegna»), se è
|
||||
un'altra persona. Se l'incaricato cambia, chi era stato avvisato riceve una sola volta «Turno palloni:
|
||||
incarico revocato». Arriva come push e nel centro notifiche, non per email. Il pulsante «Avvisa chi è di
|
||||
turno» resta.
|
||||
- **Sollecito presenze automatico** — 24, 12 e 6 ore prima dell'evento, ai convocati (o a tutta la rosa
|
||||
attiva se non ce ne sono) che in quel momento non hanno risposto o hanno risposto «forse», mai
|
||||
all'allenatore; chi risponde smette di riceverlo. Un evento creato tardi riceve solo il sollecito della sua
|
||||
fascia. Push, notifica in app ed email. Il pulsante «Sollecita» resta.
|
||||
- **Migration `m26_notifiche_automatiche`** — tipi di notifica nuovi (`turno_palloni_12h`, `_6h`, `_3h`,
|
||||
`turno_palloni_revocato`, `sollecita_presenze_24h`, `_12h`, `_6h`), funzioni SQL dei job e due job
|
||||
`pg_cron` ogni 15 minuti (`avvisi-palloni-automatici`, `solleciti-presenze-automatici`), riservati alla
|
||||
service role. Ogni notifica automatica si genera una sola volta per giocatore, evento e tipo, anche se
|
||||
viene eliminata (registro `promemoria_eventi_generati`). Le fasce sono contigue: un evento creato tardi
|
||||
non riceve tutte le notifiche insieme.
|
||||
- **Test** — nuovo test di integrazione `notifiche-automatiche` (26 prove) sui job veri del database:
|
||||
finestre, destinatari, doppioni, revoca, code push ed email, permessi, pianificazione, e confronto con i
|
||||
testi e l'ordine del codice TypeScript. Nuovi test unitari di testi, destinatari, date e a capo delle
|
||||
notifiche; aggiornati i test di promemoria, code push ed email, worker e route dei pulsanti.
|
||||
- **Documentazione** — DD-040 in `DESIGN_DECISIONS.md`; sezione «Testi delle notifiche» nel catalogo di
|
||||
`modules/notifiche.md`; tabella delle funzioni SQL e dei job in `DATABASE.md`.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Testi delle notifiche** — i promemoria a 24 e 3 ore dicono «domani» se l'evento è domani, «oggi»
|
||||
altrimenti, con data, ora e luogo. Turno palloni e sollecito hanno righe etichettate (Evento, Data, Ora,
|
||||
Luogo, Incarico, Azione). Stessi testi per i pulsanti e per i job. I promemoria già generati non cambiano.
|
||||
- **Sollecito manuale** — il pulsante «Sollecita» ora segue i convocati dell'evento, come l'automatico,
|
||||
invece di tutti i giocatori attivi; il testo dice la risposta attuale e chi lo ha chiesto. Il turno
|
||||
palloni manuale non parte più per email.
|
||||
- **Turno palloni manuale** — senza un incaricato per l'evento non parte più nessun avviso, nemmeno quello
|
||||
di riconsegna, come già descritto nel catalogo.
|
||||
- **Ordine degli eventi dei palloni** — data, poi ora, poi id (prima solo la data): «evento precedente» e
|
||||
«successivo» coincidono con quelli dei job del database.
|
||||
- **Centro notifiche** — le righe del corpo di una notifica vanno a capo.
|
||||
- Il sollecito e il turno palloni non sono più descritti come «solo manuali» nel catalogo delle notifiche.
|
||||
|
||||
### Corretto
|
||||
|
||||
- `PROJECT_STATE.md` dava `m23`–`m25` come non applicate in produzione: lo sono dal 29/09/2026.
|
||||
|
||||
## [1.2.0] - 2026-09-29
|
||||
|
||||
Le notifiche arrivano su tre canali (push, centro notifiche in-app ed email) e non si ripetono più.
|
||||
Le migration `m22`–`m25` sono già in produzione dal 29/09/2026 e il worker delle email è in funzione;
|
||||
questa versione porta il codice dell'app che le usa.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Notifiche email** — ogni notifica del centro notifiche in-app (messaggi dello staff, promemoria
|
||||
a 24 e 3 ore, turno palloni, sollecito presenze, sondaggio pre-partita) arriva anche per email
|
||||
all'indirizzo Gmail con cui il giocatore si è registrato, gratis e senza aprire porte sul
|
||||
router: un worker Docker (`mailer/`) sull'host di casa legge una coda su Supabase (migration
|
||||
`m22_notifiche_email`) e invia tramite un account Gmail dedicato, con nuovi tentativi in caso di
|
||||
errore e un tetto giornaliero sotto il limite di Gmail. In Profilo → Opzioni c'è l'interruttore
|
||||
«Email», acceso di default, per account (tabella `preferenze_utente`; DD-036).
|
||||
- **Push dei promemoria** — i promemoria a 24 e 3 ore, che arrivavano solo in-app e per email,
|
||||
partono ora anche come push a tutti i dispositivi iscritti del giocatore. Nascono nel database,
|
||||
che non può firmare una push, quindi la manda lo stesso worker con lo stesso modulo dell'app
|
||||
(migration `m24_push_promemoria_e_sondaggio`, DD-038). Le iscrizioni scadute vengono eliminate e
|
||||
una push rimasta in coda oltre 3 ore si scarta. Il worker va ricostruito e configurato con le chiavi
|
||||
VAPID dell'app (vedi [WORKER_EMAIL.md](WORKER_EMAIL.md)); senza, manda solo le email.
|
||||
- **Sondaggio pre-partita nel centro notifiche e per email** — prima era solo una push. Ora compare
|
||||
anche tra le notifiche in-app e arriva per email ai giocatori attivi, allenatori esclusi (DD-038).
|
||||
- **Documentazione e test** — nuovo [WORKER_EMAIL.md](WORKER_EMAIL.md) (installazione, gestione,
|
||||
diagnosi, prova in locale, spostamento su un altro host); catalogo delle notifiche in
|
||||
[notifiche.md](modules/notifiche.md), con cosa le fa partire, a chi arrivano e su quali canali;
|
||||
tabella delle notifiche dell'allenatore in [allenatore.md](modules/allenatore.md); decisioni
|
||||
DD-036, DD-037, DD-038 e DD-039. Nuovi test di integrazione per la coda delle email, il registro
|
||||
dei promemoria, la coda push, il sondaggio e i destinatari per ruolo (promemoria, messaggio dello
|
||||
staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), più i test unitari del
|
||||
worker.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **L'allenatore non riceve più i promemoria degli eventi** (24 e 3 ore prima, DD-039): per lui non
|
||||
c'è nulla da confermare, dato che non risponde alle presenze e non è convocabile, e per una cena di
|
||||
squadra il promemoria non aveva senso. Riceve ancora i messaggi dello staff. Migration
|
||||
`m25_promemoria_solo_giocatori`.
|
||||
- **Messaggio del pulsante «Sondaggio»** — se nessun dispositivo ha le notifiche attive dice che
|
||||
l'avviso è stato inviato in app e per email, invece di «Nessun dispositivo con le notifiche
|
||||
attive».
|
||||
- **`ops/` fuori da git** — il playbook Ansible locale per il backup cifrato non è più tracciato.
|
||||
|
||||
### Corretto
|
||||
|
||||
- **I promemoria dell'evento non ricompaiono più se li elimini** — il promemoria a 24 e a 3 ore
|
||||
eliminato dal centro notifiche veniva rigenerato dal job automatico al giro successivo, come non
|
||||
letto e, con le email, con una nuova mail a ogni giro fino all'inizio dell'evento. Ora il job
|
||||
ricorda ciò che ha già generato (migration `m23_promemoria_gia_generati`, DD-037). Il difetto
|
||||
esisteva dal centro notifiche (`m17`) e si è notato con l'arrivo delle email.
|
||||
|
||||
## [1.1.1] - 2026-09-28
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Keepalive giornaliero** — un Vercel Cron Job chiama `/api/public/keepalive` una volta
|
||||
al giorno con una lettura minima, per evitare che il progetto Supabase free tier vada in
|
||||
pausa dopo 7 giorni senza richieste API.
|
||||
- **Scheda evento** — gli eventi extra-campo (es. cena di squadra) hanno ora una pagina di
|
||||
dettaglio propria, come allenamenti e partite, con l'elenco nominativo di chi ha risposto
|
||||
presente/assente/forse invece del solo conteggio in card. "Infortunato" non compare più
|
||||
tra le opzioni né nei badge/riepiloghi per questi eventi, dato che non è una risposta
|
||||
pertinente.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Backup completo** — la cifratura (gpg, `tar.gz.gpg`) è ora il comportamento di default
|
||||
invece che opt-in con `--encrypt`, dato che i dump contengono dati sensibili (hash
|
||||
password, documenti d'identità); si disattiva con `--no-encrypt`. Aggiunte le istruzioni
|
||||
di decompressione nel README.
|
||||
|
||||
### Corretto
|
||||
|
||||
- **Squadra** — la card dell'allenatore nella Rosa è collassata come quella dei giocatori
|
||||
(nome e ruolo in riga chiusa, data di nascita solo aprendo il dettaglio) invece di
|
||||
mostrare subito tutto.
|
||||
- **Squadra** — uno scroll verticale veloce nella tab Stats non fa più scattare per errore
|
||||
il cambio tab verso Obiettivi.
|
||||
- **Turno palloni** — gli eventi generici (es. cena di squadra) non entrano più nel turno
|
||||
palloni: niente più proposta automatica, promemoria push o conteggio per eventi che non
|
||||
sono allenamenti o partite.
|
||||
|
||||
## [1.1.0] - 2026-09-27
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Avviso certificati in scadenza** — in Home, giallo nei 7 giorni prima e nero dopo la
|
||||
scadenza del certificato medico. Gli admin vedono i nomi di tutta la rosa (non
|
||||
cliccabile); il giocatore interessato vede solo il proprio e il tap apre
|
||||
`/profilo?tab=documenti`. Calcolato al volo da `certificato_scadenza`: nessuna migration,
|
||||
nessuna push (DD-035).
|
||||
|
||||
## [1.0.0] - 2026-09-24
|
||||
|
||||
Prima versione stabile. Oltre al ruolo allenatore riunisce il lavoro fatto dalla 0.9.2:
|
||||
centro notifiche in-app, messaggi dell'admin e la nuova Gestione eventi a calendario.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Ruolo allenatore** — l'admin registra un allenatore da `/admin` (tipo «Allenatore», con
|
||||
l'email Gmail) e lui si collega al primo accesso come i giocatori. Gestisce gli eventi e
|
||||
sollecita le presenze; ha un profilo ridotto (dati personali e foto, senza stagione e
|
||||
badge), compare in Squadra con la dicitura «Allenatore» e non vede badge né cacche.
|
||||
Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (DD-034).
|
||||
- **Centro notifiche in-app** — lo storico delle notifiche dentro l'app, indipendente dalla
|
||||
push: messaggi dell'admin, promemoria automatici 24 ore e 3 ore prima di un evento, turno
|
||||
palloni e sollecito presenze. Ogni notifica si toglie con uno swipe o con la ×. Migration
|
||||
`m17_notifiche_utente` (DD-030).
|
||||
- **Messaggi dell'admin** — dalla tab Notifiche della dashboard l'admin manda un messaggio
|
||||
libero a tutta la squadra o a un solo giocatore; la tab elenca anche chi non ha le
|
||||
notifiche attive.
|
||||
- **Gestione eventi a calendario** — `/eventi` è una griglia mensile: si tocca un giorno per
|
||||
vederne gli eventi e crearne, modificarne o eliminarne uno. Prima di salvare la modifica
|
||||
di un evento esistente compare una conferma, come per l'eliminazione.
|
||||
- **Backup completo** — uno script scarica schema e dati del database e i file dello
|
||||
storage, per poter ripartire se si perde Supabase o Vercel (vedi `PORTABILITA.md`).
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Notifiche in-app** — il numero delle non lette è un pallino rosso sull'avatar del
|
||||
profilo, al posto della campanella in alto a destra. Toccando il pallino si apre l'elenco
|
||||
(leggi ed elimina), toccando l'avatar si va al profilo come prima.
|
||||
- **Campionato** — l'header riporta la stagione delle gare CSI (es. «Stagione 2025/26») e lo
|
||||
storico partite è diviso per stagione.
|
||||
- **Gestione eventi** — tolta la lista "Eventi in calendario": resta solo il calendario
|
||||
mensile, che si scorre anche con lo swipe come quello della squadra.
|
||||
- **Gestione eventi** — nel form il giorno toccato sul calendario è già fissato: resta da
|
||||
scegliere solo l'ora («Cambia» per spostare l'evento in un altro giorno). Il campo
|
||||
«Luogo» di un nuovo evento parte vuoto invece che con «Palestra Comunale».
|
||||
|
||||
### Corretto
|
||||
|
||||
- **Squadra** — la data di nascita inserita dal proprio Profilo compare a tutta la squadra
|
||||
(e nei compleanni del Calendario) invece di «Invalid Date»; allineati anche i profili
|
||||
compilati prima della correzione. Migration `m18_nascita_pubblica_giocatori_squadra` e
|
||||
`m19_backfill_nascita_da_profili_esistenti` (DD-031).
|
||||
- **Squadra** — la rosa non si ricalcola più a ogni render mentre i voti MVP si caricano.
|
||||
|
||||
## [0.9.2] - 2026-09-10
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Dashboard amministratore** — nuova tab "Notifiche" che mostra quanti giocatori hanno
|
||||
le notifiche push attive e chi sono.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Dashboard amministratore** — le sezioni impilate diventano un menu di tab scorrevole a
|
||||
pillole (come Squadra e Campionato); nell'elenco Profili resta aperta una sola scheda
|
||||
alla volta.
|
||||
- **Profilo** — testi dei campi amministrativi semplificati (label email, rimossa la nota
|
||||
su chi vede quei dati).
|
||||
|
||||
### Rimosso
|
||||
|
||||
- Le dipendenze e il codice legati all'editor Lovable (login social e reporting errori
|
||||
verso l'editor): l'app non ci gira più.
|
||||
|
||||
## [0.9.1] - 2026-09-10
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Storico partite** — ogni scheda mostra il logo accanto al nome di entrambe le squadre
|
||||
(CRAP e avversario), risultato e parziali in ordine casa–ospite (verde/rosso restano
|
||||
vittoria/sconfitta CRAP) e un chevron a destra per chiarire che la riga apre il dettaglio.
|
||||
|
||||
## [0.9.0] - 2026-09-09
|
||||
|
||||
Prima versione pre-release: lo sviluppo precedente non era versionato a parte, quindi
|
||||
|
||||
+57
-30
@@ -7,31 +7,37 @@ non in questo file.
|
||||
|
||||
## Permessi di scrittura
|
||||
|
||||
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023). La **lettura**
|
||||
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023) e il ruolo
|
||||
allenatore di `m21_ruolo_allenatore` (DD-034). 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`), se votante e votato sono convocati all'evento (`m13`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
|
||||
| `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 |
|
||||
| Tabella | Chi può scrivere |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
|
||||
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin |
|
||||
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
|
||||
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
|
||||
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
|
||||
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
|
||||
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
|
||||
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
|
||||
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
|
||||
| `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); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
|
||||
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
|
||||
|
||||
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 |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde, e come sorgente della data di nascita (non ancora una colonna di questa tabella). Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. |
|
||||
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
|
||||
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
|
||||
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde. Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. La colonna `nascita` (migration `m18_nascita_pubblica_giocatori_squadra`, DD-031) è pubblica a tutta la rosa attiva (stessa policy di lettura delle altre colonne): non si scrive mai da qui, un trigger su `profili_giocatore` (`sincronizza_nascita_pubblica`) la tiene allineata a `data_nascita` — impostata, aggiornata o azzerata dallo stesso form Profilo che scrive quella colonna. Il trigger copre solo le scritture successive a M18: `m19_backfill_nascita_da_profili_esistenti` ha allineato una tantum anche chi aveva già compilato il profilo prima di quella migration. La colonna `tipo` (migration `m21_ruolo_allenatore`, DD-034) distingue `giocatore` da `allenatore`: l'allenatore ha uno slot per collegarsi e per i dati personali, ma è fuori dalla rosa di gioco (`inRosa()` nel codice), non ha numero di maglia (`numero` è NULL, ammesso solo per lui dal vincolo `giocatori_squadra_numero_giocatori`) e ha `ruolo` vuoto. `tipo` lo scrive solo un admin. |
|
||||
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
|
||||
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
|
||||
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). Il valore `allenatore` di `app_role` (migration `m20_ruolo_allenatore_enum`) non si inserisce a mano: lo aggiunge e lo toglie il trigger `sincronizza_ruolo_allenatore` quando uno slot con `tipo = 'allenatore'` viene collegato, scollegato, disattivato o cambia tipo (DD-034). |
|
||||
|
||||
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
|
||||
|
||||
@@ -44,12 +50,12 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
|
||||
|
||||
## Eventi e presenze
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
|
||||
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
|
||||
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
|
||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
|
||||
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
|
||||
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
|
||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||
|
||||
## Scout
|
||||
|
||||
@@ -61,19 +67,40 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
|
||||
|
||||
## Votazioni
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
|
||||
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. |
|
||||
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. |
|
||||
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
|
||||
|
||||
## 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` | 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. |
|
||||
| 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` | 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. |
|
||||
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Cinque sorgenti (le prime quattro dalla `m17`, il sondaggio dalla `m24`), tutte in parallelo alla push esistente tranne i promemoria, la cui push la manda il worker (`notifiche_push_coda`): messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`) e il sondaggio pre-partita (`apri-sondaggio.ts`, tipo `sondaggio_cacche`, `m24`), questi ultimi tre avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. I promemoria evento arrivano solo ai giocatori attivi: ai convocati, o a tutti se `convocati` è vuoto (`giocatori_destinatari_evento()`, riscritta da M21 per includere sempre gli allenatori e da M25 per escluderli, DD-039); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. Migration `m26_notifiche_automatiche` (DD-040): il vincolo `CHECK` sul tipo ammette anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e `sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job; `turno_palloni` e `sollecita_presenze` restano i tipi dei pulsanti. Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi automatici ma non i due dei pulsanti. |
|
||||
| `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). Con DD-040 (migration `m26`) il trigger salta i tipi del turno palloni, che non mandano email. |
|
||||
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. |
|
||||
| `promemoria_eventi_generati` | Registro dei promemoria evento (24 e 3 ore) già generati, per giocatore, evento e tipo. | Migration `m23_promemoria_gia_generati` (DD-037). Chiave `(giocatore_id, evento_id, tipo)` con `ON DELETE CASCADE` verso `giocatori_squadra` ed `eventi_app` (sparisce con l'evento, come le altre tabelle collegate, DD-029); `generato_il`. Scritta solo da `genera_promemoria_eventi()`, che crea la notifica in `notifiche_utente` soltanto per le righe appena registrate: eliminare la notifica dal centro notifiche non fa rigenerare il promemoria né accoda una nuova mail. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. La migration registra come già generati i promemoria presenti al momento dell'applicazione. Con DD-040 (migration `m26`) il `CHECK` sul tipo ammette anche i tipi dei palloni (12h, 6h, 3h, revocato) e dei solleciti (24h, 12h, 6h); è anche la memoria che permette la revoca. |
|
||||
| `notifiche_push_coda` | Coda delle push per i promemoria a 24 e 3 ore: una riga per notifica, con stato d'invio. | Migration `m24_push_promemoria_e_sondaggio` (DD-038). Stessa struttura di `notifiche_email_coda`: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`, stati `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`, `tentativi`, `prossimo_tentativo`, `errore` (solo codici HTTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_push_promemoria` (`AFTER INSERT`, solo tipi `evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano la push dall'app; nessun backfill). Le usa solo il worker `mailer/` con la service role, tramite `prendi_push_promemoria(p_max)` (lotto con `FOR UPDATE SKIP LOCKED` con testo e dispositivi iscritti del giocatore; salta i giocatori senza dispositivi e i promemoria creati da oltre 3 ore, recupera le righe ferme `in_invio` da più di 10 minuti) ed `esito_push_promemoria(...)`, entrambe `SECURITY DEFINER` riservate a `service_role`. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. Con DD-040 (migration `m26`) accoda anche turno palloni e solleciti presenze. |
|
||||
|
||||
### Funzioni SQL e job delle notifiche
|
||||
|
||||
Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano funzioni `SECURITY DEFINER`
|
||||
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
|
||||
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
|
||||
|
||||
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
||||
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
|
||||
| `genera_avvisi_palloni()` (M26), con `genera_avvisi_palloni_fascia()` | Avvisi del turno palloni a 12, 6 e 3 ore per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
|
||||
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
|
||||
| `genera_solleciti_presenze()` (M26), con `..._fascia()` | Solleciti a 24, 12 e 6 ore ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
|
||||
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
|
||||
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
|
||||
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
|
||||
|
||||
## Funzioni speciali
|
||||
|
||||
|
||||
+732
-29
@@ -16,35 +16,46 @@ Serve a rispondere a domande del tipo:
|
||||
|
||||
**Accettate**
|
||||
|
||||
| ID | Titolo |
|
||||
| --------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable |
|
||||
| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first |
|
||||
| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive |
|
||||
| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first |
|
||||
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
|
||||
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
|
||||
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
|
||||
| [DD-009](#dd-009--tesseramento-csi-manuale-integrazione-api-in-una-fase-successiva) | CSI manuale, poi integrazione API |
|
||||
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati) | Niente storico certificati |
|
||||
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
|
||||
| [DD-012](#dd-012--rimandare-la-migrazione-degli-id-giocatore) | Rimandare la migrazione ID |
|
||||
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
|
||||
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
|
||||
| [DD-016](#dd-016--schema-dati-profilo-giocatore-f0) | Schema dati Profilo Giocatore |
|
||||
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
|
||||
| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email |
|
||||
| [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) | Il branch lo decide l'utente |
|
||||
| [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 |
|
||||
| [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati |
|
||||
| [DD-028](#dd-028--soglia-minima-di-campione-per-media-voto-e-mvp-in-home) | Soglia minima Media voto e MVP |
|
||||
| [DD-029](#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati) | Pulizia a cascata evento cancellato |
|
||||
| ID | Titolo |
|
||||
| ------------------------------------------------------------------------------------------------ | ------------------------------------- |
|
||||
| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable |
|
||||
| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first |
|
||||
| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive |
|
||||
| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first |
|
||||
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
|
||||
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
|
||||
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
|
||||
| [DD-009](#dd-009--tesseramento-csi-manuale-integrazione-api-in-una-fase-successiva) | CSI manuale, poi integrazione API |
|
||||
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati) | Niente storico certificati |
|
||||
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
|
||||
| [DD-012](#dd-012--rimandare-la-migrazione-degli-id-giocatore) | Rimandare la migrazione ID |
|
||||
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
|
||||
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
|
||||
| [DD-016](#dd-016--schema-dati-profilo-giocatore-f0) | Schema dati Profilo Giocatore |
|
||||
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
|
||||
| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email |
|
||||
| [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) | Il branch lo decide l'utente |
|
||||
| [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 |
|
||||
| [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati |
|
||||
| [DD-028](#dd-028--soglia-minima-di-campione-per-media-voto-e-mvp-in-home) | Soglia minima Media voto e MVP |
|
||||
| [DD-029](#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati) | Pulizia a cascata evento cancellato |
|
||||
| [DD-030](#dd-030--centro-notifiche-in-app-storico-separato-dalla-push-letto-solo-con-rls) | Centro notifiche in-app |
|
||||
| [DD-031](#dd-031--data-di-nascita-pubblica-una-colonna-sincronizzata-non-una-seconda-rls-aperta) | Nascita pubblica sincronizzata |
|
||||
| [DD-032](#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare) | Stagione CSI dalle date delle gare |
|
||||
| [DD-033](#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco) | Gestione eventi solo da calendario |
|
||||
| [DD-034](#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore) | Ruolo allenatore |
|
||||
| [DD-035](#dd-035--avviso-certificati-calcolato-in-home-visibile-solo-al-titolare-e-agli-admin) | Avviso certificati in Home |
|
||||
| [DD-036](#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa) | Notifiche email via Gmail |
|
||||
| [DD-037](#dd-037--i-promemoria-evento-ricordano-di-essere-già-stati-generati) | Promemoria evento senza ritorni |
|
||||
| [DD-038](#dd-038--ogni-notifica-ha-tre-canali-la-push-dei-promemoria-parte-dal-worker) | Tre canali per ogni notifica |
|
||||
| [DD-039](#dd-039--lallenatore-non-riceve-i-promemoria-degli-eventi) | Niente promemoria all'allenatore |
|
||||
| [DD-040](#dd-040--promemoria-con-oggi-o-domani-turno-palloni-e-solleciti-automatici) | Palloni e solleciti automatici |
|
||||
|
||||
**In valutazione**
|
||||
|
||||
@@ -929,6 +940,9 @@ torna utile, e con lui il segreto: DD-024 descrive già come farlo.
|
||||
|
||||
---
|
||||
|
||||
**Aggiornamento (30 settembre 2026)** — DD-040 affianca al pulsante un invio automatico a 12, 6 e 3 ore
|
||||
prima dell'evento, per gli eventi con un incaricato confermato; il pulsante e la route restano.
|
||||
|
||||
### DD-026 — Il testo della notifica viaggia dentro la push
|
||||
|
||||
**Stato:** accettata · **Data:** settembre 2026
|
||||
@@ -1155,3 +1169,692 @@ di orfani.
|
||||
**Riesame**
|
||||
Se una nuova tabella collegata a un evento non viene aggiunta al trigger quando creata (va
|
||||
aggiornata a mano, non c'è un meccanismo che lo forzi).
|
||||
|
||||
### DD-030 — Centro notifiche in-app: storico separato dalla push, letto solo con RLS
|
||||
|
||||
**Data:** 15 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Le notifiche esistenti (`docs/modules/notifiche.md`) sono solo push OS/browser: arrivano se
|
||||
il dispositivo ha attivato l'interruttore in Profilo, sono effimere (nessuno storico) e non
|
||||
hanno uno stato letto/non letto — chi non attiva le push, o cambia dispositivo, semplicemente
|
||||
non le vede mai. Serviva un canale visibile sempre, dentro l'app, con un badge accanto al
|
||||
profilo che segnali quante cose non lette ci sono.
|
||||
|
||||
**Decisione**
|
||||
Nuova tabella `notifiche_utente` (migration `m17_notifiche_utente`), popolata da quattro
|
||||
sorgenti lato database — mai dal client:
|
||||
|
||||
1. messaggio libero dell'admin (`notifica-personalizzata.ts`, in parallelo alla push);
|
||||
2. promemoria automatico prima di un evento, 24h e 3h prima, due `cron.schedule`
|
||||
(estensione `pg_cron`, abilitata dal 31/07/2026 e usata per la prima volta qui);
|
||||
3. turno palloni (`promemoria-palloni.ts`), in parallelo alla push;
|
||||
4. sollecito presenze (`sollecita-presenze.ts`), in parallelo alla push.
|
||||
|
||||
Lettura e scrittura (segna come letta, elimina) passano dal client Supabase autenticato con
|
||||
RLS (`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()` in
|
||||
`src/lib/notifiche-utente.ts`), senza una route API dedicata — stesso pattern di
|
||||
`useSalvaEvento()` in `eventi.ts`. Nessuna policy INSERT per `authenticated`: le righe nascono
|
||||
solo da funzioni `SECURITY DEFINER` o dalla service role, mai da un client.
|
||||
|
||||
Pallino con il conteggio sull'avatar del profilo (`PallinoNotifiche`,
|
||||
`src/components/crapp/ui-bits.tsx`; in origine una campanella separata, tolta per liberare
|
||||
l'header: il tap sull'avatar resta l'accesso al profilo, quello sul pallino apre il
|
||||
pannello), pannello a comparsa costruito a mano (nel progetto non c'è una libreria dropdown). Ogni riga si elimina
|
||||
con uno swipe o una ×: non c'è pulizia automatica delle notifiche vecchie, quindi è l'unico
|
||||
modo per un giocatore di toglierle di torno per sempre.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Notificare anche sulla sola creazione di un evento, non solo sull'avvicinarsi della data →
|
||||
scartata su richiesta esplicita: conta quando l'evento sta per succedere, non il momento in
|
||||
cui è stato messo in calendario. La prima versione aveva un trigger `AFTER INSERT ON
|
||||
eventi_app` per questo, rimosso prima del rilascio.
|
||||
- Una route API dedicata per leggere/segnare come lette (come le route push esistenti) →
|
||||
scartata: qui non serve la service role (niente da nascondere al giocatore stesso), quindi
|
||||
la RLS basta ed evita di duplicare la logica di lettura già scritta lato client per gli
|
||||
eventi.
|
||||
- Pulizia automatica delle notifiche vecchie con un cron di retention (es. elimina le lette
|
||||
dopo 30 giorni) → scartata per ora a favore dello swipe manuale: più diretto da capire per
|
||||
chi usa l'app, e la tabella resta comunque piccola per una sola squadra. Resta un'estensione
|
||||
naturale se in futuro servisse.
|
||||
- Semplice `INSERT` invece di `upsert` per turno palloni e sollecito presenze → scartata: un
|
||||
admin che preme due volte lo stesso pulsante per lo stesso evento avrebbe urtato contro il
|
||||
vincolo `UNIQUE (giocatore_id, evento_id, tipo)`. L'upsert aggiorna la riga esistente
|
||||
(testo e `creato_il` freschi, `letta` di nuovo `false`), così ripremere il pulsante si
|
||||
comporta come un rinvio vero, non un errore silenzioso.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Il pallino sull'avatar riflette solo ciò che sta in `notifiche_utente`: un dispositivo senza
|
||||
push attive vede comunque tutto, perché il canale è del tutto indipendente.
|
||||
- `eventi_app.ora` è testo libero e non validato dal form (`<input type="time">` si può
|
||||
svuotare): la funzione `genera_promemoria_eventi()` usa `ora_evento_a_time()`, che assorbe
|
||||
l'errore di cast riga per riga invece di far fallire l'intera generazione dei promemoria per
|
||||
tutti gli eventi quando uno solo ha l'ora scritta male.
|
||||
- `test/integration/notifiche-utente.test.ts` è la definizione eseguibile del comportamento:
|
||||
promemoria 24h/3h e deduplica, tolleranza a un'ora malformata, RLS su lettura/segna
|
||||
letta/elimina, e le tre route (`notifica-personalizzata`, `promemoria-palloni`,
|
||||
`sollecita-presenze`) che scrivono lo storico in-app.
|
||||
|
||||
**Riesame**
|
||||
Se la tabella crescesse in modo sensibile (squadre molto più numerose, o giocatori che non
|
||||
fanno mai swipe), va aggiunta la pulizia automatica scartata sopra.
|
||||
|
||||
### DD-031 — Data di nascita pubblica: una colonna sincronizzata, non una seconda RLS aperta
|
||||
|
||||
**Data:** 15 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
In "Squadra" la data di nascita mostrava **"Invalid Date"** per chi l'aveva inserita da
|
||||
solo dal proprio Profilo. Causa: due fonti mai collegate. I 17 giocatori del seed storico
|
||||
hanno la nascita in `nascitaPerId`, una mappa statica pubblica bundlata nel client
|
||||
(`src/lib/crapp-data.ts`) — il commento sulla riga la descriveva esplicitamente come
|
||||
stopgap: _"`giocatori_squadra` non ha ancora questa colonna (DD-015 follow-up)"_. Un
|
||||
giocatore aggiunto dopo la migrazione DD-015 inserisce invece la nascita dal form Profilo,
|
||||
che scrive su `profili_giocatore.data_nascita` — tabella con RLS "solo il proprio profilo o
|
||||
admin" (M2), mai letta da `useRosa()`/`useAnagraficaRosa()` (`src/lib/rosa.ts`), e comunque
|
||||
non visibile ai compagni per via della RLS. Per quel giocatore `nascitaPerId[g.id]` era
|
||||
`undefined` → `""` → `formatData("")` in `src/routes/squadra.tsx` produceva `Invalid Date`.
|
||||
|
||||
**Decisione**
|
||||
Migration `m18_nascita_pubblica_giocatori_squadra`: nuova colonna `giocatori_squadra.nascita`
|
||||
(quella prevista da DD-015), pubblica a tutta la rosa attiva con la stessa policy di lettura
|
||||
già in vigore per le altre colonne. Un trigger `SECURITY DEFINER` su `profili_giocatore`
|
||||
(`sincronizza_nascita_pubblica`, AFTER INSERT/UPDATE/DELETE) la tiene allineata a
|
||||
`data_nascita`: un solo input nel form Profilo (`CampiProfilo`), due destinazioni — quella
|
||||
amministrativa esistente (RLS invariata: sé stesso o admin) e questo specchio pubblico.
|
||||
`profili_giocatore` resta l'unica tabella che il client scrive per la nascita; il client non
|
||||
tocca mai `giocatori_squadra.nascita` direttamente.
|
||||
|
||||
Essendo `SECURITY DEFINER` di proprietà dell'owner della tabella, la scrittura del trigger
|
||||
bypassa la RLS di `giocatori_squadra` automaticamente — nessuna nuova policy lì. Serve però
|
||||
un nuovo ramo in `enforce_giocatori_squadra_update()` (il trigger `BEFORE UPDATE` che oggi
|
||||
rifiuta qualunque modifica a uno slot già collegato da parte di chi non è admin): accetta un
|
||||
update dove cambia **solo** `nascita` e il chiamante è il proprietario dello slot
|
||||
(`OLD.auth_user_id = auth.uid()`). Senza quel ramo l'update annidato del trigger di sync
|
||||
fallirebbe con l'eccezione già in vigore.
|
||||
|
||||
Backfill una tantum dei 17 storici con gli stessi valori di `nascitaPerId`, poi rimozione di
|
||||
quell'export (`crapp-data.ts`): era descritto fin dall'inizio come temporaneo, in attesa
|
||||
esattamente di questa colonna.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Allentare la RLS di `profili_giocatore` per rendere `data_nascita` leggibile a tutta la
|
||||
squadra → scartata: quella tabella porta anche indirizzo, telefono, documento d'identità
|
||||
nello stesso record — aprirne la lettura avrebbe esposto molto più della sola nascita.
|
||||
- Un secondo campo "data di nascita pubblica" nel form Profilo, distinto da quello
|
||||
amministrativo → scartata: due campi per lo stesso dato reale avrebbero richiesto
|
||||
spiegare all'utente perché ne esistono due, con rischio di disallineamento se ne compila
|
||||
uno solo.
|
||||
- Far scrivere `giocatori_squadra.nascita` direttamente dal client (nuova RLS policy per il
|
||||
proprietario dello slot) invece di sincronizzarla da `profili_giocatore` → scartata: la
|
||||
RLS di update per un non-admin resta `USING (auth_user_id IS NULL)` (solo reclamare uno
|
||||
slot libero); aprirla anche a righe già collegate avrebbe ampliato la superficie scrivibile
|
||||
dal client su una tabella con un trigger di sicurezza già delicato (DD-016/DD-023), per un
|
||||
guadagno che il trigger di sync ottiene comunque senza toccare la RLS.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Chiunque compili la propria data di nascita dal Profilo la vede comparire in Squadra (e
|
||||
nei compleanni del Calendario) esattamente come i 17 storici — non più un dato "di serie
|
||||
B".
|
||||
- Cancellare il profilo (da sé o da un admin) azzera anche la nascita pubblica: non resta un
|
||||
valore pubblico orfano di un profilo che non esiste più.
|
||||
- Un giocatore senza nascita nota (profilo mai compilato) non genera più "Invalid Date":
|
||||
`squadra.tsx` mostra la riga della data di nascita solo se `g.nascita` è valorizzata,
|
||||
stesso criterio già usato da `compleanniEventi()` per escludere chi non ha ancora un dato.
|
||||
- `test/integration/nascita-pubblica.test.ts` è la definizione eseguibile del comportamento:
|
||||
sync alla scrittura, visibilità a un giocatore terzo (non solo l'interessato o l'admin),
|
||||
azzeramento alla cancellazione, blocco della scrittura diretta dal client.
|
||||
|
||||
**Riesame**
|
||||
Se in futuro serve differenziare "chi può vedere la nascita" (es. solo squadra, non
|
||||
pubblico) va rivista insieme alla policy di lettura generale di `giocatori_squadra`, non solo
|
||||
per questa colonna.
|
||||
|
||||
**Aggiornamento (15 settembre 2026, stesso giorno)** — dopo aver applicato M18 in produzione
|
||||
(`supabase db push`), la nascita in Squadra compariva solo per i 17 giocatori storici, non
|
||||
per chi aveva già compilato il proprio profilo prima della migration: il trigger di sync
|
||||
copre solo le scritture _future_ su `profili_giocatore`, non retroattivamente, e M18
|
||||
backfillava a mano solo il seed storico. Migration `m19_backfill_nascita_da_profili_esistenti`
|
||||
allinea una tantum `giocatori_squadra.nascita` a `profili_giocatore.data_nascita` per tutti i
|
||||
profili già esistenti, con la stessa disattivazione temporanea del trigger di sicurezza usata
|
||||
in M18. Da qui in poi il gap non si può ripresentare: ogni scrittura successiva passa dal
|
||||
trigger di sync, questo backfill serve solo per lo storico antecedente a M18.
|
||||
|
||||
A differenza della bonifica eventi orfani (DD-029, M15→M16), qui il backfill non è stato
|
||||
reso una funzione richiamabile: resta SQL diretto nella migration. La copertura di test
|
||||
(`test/integration/backfill-nascita-esistenti.test.ts`) verifica comunque lo stesso
|
||||
comportamento eseguendo lo stesso pattern SQL via `psql` nel container Postgres locale —
|
||||
scelta necessaria perché senza una funzione RPC, PostgREST non espone un update
|
||||
multi-tabella come questo.
|
||||
|
||||
### DD-032 — La stagione CSI si ricava dalle date delle gare
|
||||
|
||||
**Data:** 24 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
La pagina Campionato (`/classifica`) non diceva a quale stagione si riferissero classifica e
|
||||
storico partite. Il portale CSI non espone il nome della stagione da nessuna parte:
|
||||
`project-sheets.php`, `league_details.php`, `team-main.php` e il feed
|
||||
`getEventsByTeamId.php` riportano solo il nome della competizione (verificato il
|
||||
24/09/2026). Il `project_id` cambia a ogni stagione ed è aggiornato a mano in
|
||||
`csi-core.ts`.
|
||||
|
||||
**Decisione**
|
||||
`stagioneDi()` in `src/lib/csi-core.ts` ricava la stagione ("2025/26") dalla data di una
|
||||
gara, con l'anno che cambia ad agosto; `stagioneDaPartite()` usa la prima gara del
|
||||
campionato letto (tutte le gare, non solo quelle giocate, così a inizio stagione basta il
|
||||
calendario). L'header di `/classifica` mostra quella stagione, e lo storico partite è
|
||||
diviso per stagione con `raggruppaPerStagione()`: le gare dello Scout Live locale, usate
|
||||
quando il CSI non ne ha, possono essere di stagioni diverse.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Una costante `CSI_STAGIONE` accanto a `CSI_PROJECT_ID` → un secondo valore da ricordarsi
|
||||
di aggiornare a ogni stagione, che può divergere dal `project_id`; ricavata dai dati
|
||||
segue da sola.
|
||||
- Usare la stagione "in corso" per la squadra (la data di oggi) → sbaglierebbe proprio nel
|
||||
periodo di passaggio: a settembre 2026 il CSI mostra ancora i dati 2025/26.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Oggi Campionato mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la
|
||||
stagione scritta nel codice, mostrano «Stagione 2026/27»: il CSI non ha ancora creato le
|
||||
pagine della nuova stagione. È voluto — Campionato riporta la stagione dei dati che
|
||||
mostra (dettagli in [collegamento-csi.md](modules/collegamento-csi.md)).
|
||||
- Quando si aggiornano i `project_id` l'etichetta si aggiorna senza toccare altro codice.
|
||||
|
||||
**Riesame**
|
||||
Se il CSI iniziasse a esporre il nome della stagione, leggerlo da lì; se una stagione CSI
|
||||
dovesse iniziare prima di agosto, spostare la soglia in `stagioneDi()`.
|
||||
|
||||
### DD-033 — Gestione eventi: solo calendario, giorno fissato dal tocco
|
||||
|
||||
**Data:** 24 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
`/eventi` mostrava sotto la griglia mensile anche la lista cronologica di tutti gli eventi
|
||||
("Eventi in calendario"). La lista non aveva azioni proprie (toccare una riga apriva lo
|
||||
stesso drawer del giorno) e allungava la pagina con informazioni già nel calendario. Nel
|
||||
form, poi, il campo «Data» era già precompilato con il giorno toccato ma sembrava da
|
||||
compilare: l'amministratore doveva scegliere solo l'ora.
|
||||
|
||||
**Decisione**
|
||||
La pagina mostra solo il calendario, che si scorre di mese in mese con le frecce o con lo
|
||||
swipe (stessa fisica a molla di `/calendario`, disattivata con `useMotoRidotto()`). Nel form
|
||||
il giorno compare come testo fisso e resta da scegliere l'ora; il campo data compare solo
|
||||
toccando «Cambia», per spostare un evento in un altro giorno.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Togliere del tutto il campo data → non si potrebbe più spostare un evento esistente, una
|
||||
funzione che oggi c'è (regola anti-regressione).
|
||||
- Tenere la lista ma più corta (solo i prossimi eventi) → i prossimi eventi sono già in home
|
||||
e in `/calendario`; qui sarebbe stata un doppione.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Per trovare un evento lontano bisogna scorrere fino al suo mese; il totale degli eventi
|
||||
resta nel sottotitolo dell'header.
|
||||
- Caricamento ed errore di `useEventi()` compaiono dentro la card del calendario, dove prima
|
||||
stava la lista.
|
||||
|
||||
**Riesame**
|
||||
Se gli eventi crescono tanto da rendere scomodo lo scorrere dei mesi, valutare una ricerca
|
||||
per titolo invece di reintrodurre la lista completa.
|
||||
|
||||
### DD-034 — L'allenatore è uno slot della squadra con tipo diverso, non un giocatore
|
||||
|
||||
**Data:** 24 settembre 2026
|
||||
**Stato:** Accettata (implementata con le migration M20 e M21)
|
||||
|
||||
**Contesto**
|
||||
Serve un account per l'allenatore: deve gestire gli eventi (oggi solo admin, `m11`) ma non è
|
||||
un giocatore. Non ha statistiche, badge né stagione, non vota e non viene votato. L'admin
|
||||
vuole registrarlo come i giocatori, inserendo la sua email Gmail. Specifica completa in
|
||||
[modules/allenatore.md](modules/allenatore.md).
|
||||
|
||||
**Decisione**
|
||||
L'allenatore è una riga di `giocatori_squadra` con una nuova colonna `tipo = 'allenatore'`
|
||||
(default `'giocatore'`). Così riusa senza cambiarli il collegamento per email (DD-018),
|
||||
`profili_giocatore` per i dati personali, il bucket avatar e il compleanno sincronizzato
|
||||
(DD-031). Il permesso di gestire gli eventi arriva da un nuovo valore `allenatore` di
|
||||
`app_role` in `user_roles` (DD-011), scritto da una funzione `SECURITY DEFINER` quando si
|
||||
collega uno slot di tipo allenatore: è quindi l'admin a concederlo, registrando lo slot.
|
||||
"Rosa" diventa `attivo e tipo = 'giocatore'` ovunque.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Una tabella `staff` separata → isola l'allenatore da voti e rosa senza toccare i filtri, ma
|
||||
duplica collegamento per email, profilo, avatar e form admin. Resta l'alternativa se i
|
||||
filtri sulla rosa si rivelano troppi o fragili.
|
||||
- Registrare l'allenatore come admin → gli darebbe dati personali e documenti di tutta la
|
||||
squadra (DD-017), molto più del necessario.
|
||||
- Distinguere l'allenatore dal campo `ruolo` (ruolo in campo) → mescola due concetti e il
|
||||
campo è testo libero.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Ogni lettura della rosa (client, server, SQL di voto e promemoria) va filtrata per `tipo`:
|
||||
un punto dimenticato rende l'allenatore votabile o lo conta tra i convocati. L'elenco sta
|
||||
nella specifica e va coperto dai test di `permessi.test.ts`.
|
||||
- Il trigger sugli slot cambia: `tipo` lo scrive solo l'admin; nome e cognome li scrive anche
|
||||
l'allenatore sul proprio slot.
|
||||
- `numero` diventa nullabile a database, ma solo per gli allenatori (vincolo di M21); nell'app
|
||||
resta un numero, 0 per l'allenatore, per non toccare i tipi di tutta la rosa.
|
||||
- Due fonti distinte, ciascuna per il suo scopo: i permessi (eventi, sollecito) da
|
||||
`user_roles`, la visibilità (badge, cacche, stagione) dal tipo del proprio slot, che è
|
||||
immediato e non aspetta la query dei ruoli.
|
||||
- L'allenatore non vede le tab Stagione e Badge del Profilo, né Obiettivi e Badge di Squadra.
|
||||
- In Squadra compare nella tab Rosa con «Allenatore» al posto del ruolo, ricavato da `tipo`;
|
||||
riceve i promemoria degli eventi (dal 29/09/2026 non più: DD-039) e può sollecitare le presenze,
|
||||
non il turno palloni.
|
||||
- Non vede badge né classifiche o sondaggi sulle cacche, in nessuna schermata.
|
||||
|
||||
**Riesame**
|
||||
Se arriva un secondo tipo di staff (dirigente, segreteria) o se i filtri sulla rosa diventano
|
||||
fonte di bug, passare alla tabella `staff` separata.
|
||||
|
||||
### DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin
|
||||
|
||||
**Data:** 24 settembre 2026
|
||||
**Stato:** Accettata (implementata, versione 1.1.0)
|
||||
|
||||
**Contesto**
|
||||
Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma
|
||||
oggi ci si accorge che è scaduto solo aprendo la tab Profili di `/admin`: spesso quando il
|
||||
giocatore è già fuori regola. La richiesta è un avviso in Home, giallo nei 7 giorni prima
|
||||
della scadenza e nero dopo, con i nomi dei giocatori, che sparisca da solo quando il
|
||||
certificato viene rinnovato. Specifica completa in
|
||||
[modules/profilo-giocatore.md](modules/profilo-giocatore.md#avviso-certificati).
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. L'avviso si **calcola al volo** dalla data già salvata, con una funzione pura in
|
||||
`profili-core.ts`: nessuna tabella, nessuno stato «avvisato», nessuna migration.
|
||||
2. Lo vedono **solo gli admin**, con i nomi di tutta la rosa, e **il giocatore interessato**,
|
||||
riferito solo a sé. L'allenatore e i compagni non lo vedono.
|
||||
3. L'avviso dello staff **non è cliccabile**; quello personale apre
|
||||
`/profilo?tab=documenti`.
|
||||
4. Soglia fissa di **7 giorni** in una costante; il giorno della scadenza il certificato vale
|
||||
ancora (come `statoScadenza()`).
|
||||
5. **Niente push**: l'avviso vive solo in Home.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Mostrarlo anche all'allenatore → il certificato è un dato sanitario che oggi l'allenatore
|
||||
non vede (DD-034); servirebbe una funzione `SECURITY DEFINER` apposta per un'informazione
|
||||
che all'allenatore non serve ad agire.
|
||||
- Salvare gli avvisi in una tabella o nel centro notifiche (DD-030) → uno stato da tenere
|
||||
allineato alla data, che si disallinea appena il giocatore corregge la scadenza; calcolarlo
|
||||
è gratis e sempre giusto.
|
||||
- Avviso dello staff cliccabile verso `/admin?tab=profili` → l'admin non può caricare il file
|
||||
al posto del giocatore (DD-017), quindi il tocco non gli farebbe fare nulla in più, e
|
||||
richiederebbe di aggiungere a `/admin` la lettura della tab dall'URL.
|
||||
- Mostrare al massimo alcuni nomi e un «e altri N» → a inizio stagione ne scadono molti
|
||||
insieme ed è proprio lì che l'elenco completo serve.
|
||||
- Push quotidiana a chi ha il certificato in scadenza → servirebbe un cron, che oggi non
|
||||
esiste per nessuna push (DD-025), e le push vanno riservate agli eventi importanti
|
||||
([EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md)).
|
||||
- Includere i certificati mancanti → è un problema diverso (profilo incompleto), già coperto
|
||||
dal widget «Completa il tuo profilo» e dalla tab Profili.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Nessuna query nuova: si riusa `useProfili()`, già in cache per Profilo e `/admin`, e le RLS
|
||||
esistenti decidono quanti profili arrivano (tutti all'admin, il proprio al giocatore).
|
||||
- Il rinnovo non richiede nessuna azione di chiusura: cambiata la data, l'avviso si aggiorna
|
||||
da solo.
|
||||
- Chi non apre l'app non viene avvisato: l'avviso raggiunge solo chi entra in Home.
|
||||
- Un admin che è anche giocatore vede solo l'avviso dello staff, dove compare il suo nome.
|
||||
|
||||
**Riesame**
|
||||
Se gli avvisi in Home non bastano (certificati che scadono comunque senza essere rinnovati),
|
||||
valutare una push, legata a un cron o a un invio manuale dell'admin come per i palloni. Se 7
|
||||
giorni si rivelano pochi per prenotare una visita, alzare la costante o renderla regolabile.
|
||||
|
||||
### DD-036 — Notifiche email via Gmail, da un worker Docker sull'host di casa
|
||||
|
||||
**Data:** 29 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Le notifiche di CrAPP arrivano solo con la push (che dipende da Chrome vivo in background,
|
||||
vedi i limiti in `docs/modules/notifiche.md`) e nel centro notifiche in-app, che si vede solo
|
||||
aprendo l'app. Chi non ha attivato la push o ha un telefono aggressivo sul risparmio
|
||||
energetico non riceve nulla ad app chiusa. Serve un canale che arrivi comunque, **gratis**, ai
|
||||
giocatori registrati, che sono tutti su Gmail perché l'accesso è con Google. L'app gira su
|
||||
Vercel, che non esegue container né processi sempre attivi.
|
||||
|
||||
**Decisione**
|
||||
Le email partono da un **worker Docker sull'host di casa** che legge una coda su Supabase e
|
||||
invia tramite `smtp.gmail.com` con un account Gmail dedicato e una password per app.
|
||||
|
||||
- La coda è `notifiche_email_coda`, riempita da un trigger su `notifiche_utente`: ogni
|
||||
notifica già esistente, di qualunque tipo e per qualunque destinatario (admin compresi,
|
||||
con la stessa logica degli altri), diventa anche una mail. Le sorgenti di M17 non cambiano,
|
||||
non ne nascono di nuove e l'app non conosce le email. Il certificato in scadenza resta
|
||||
fuori: è una card in Home, non una notifica (DD-035).
|
||||
- Il worker fa solo polling **in uscita** con la service role: nessuna porta aperta sull'host.
|
||||
- La coda è una tabella a parte, con RLS senza policy, perché la policy `UPDATE` di
|
||||
`notifiche_utente` lascerebbe a un client la possibilità di rimettere in coda una mail.
|
||||
- Canale **opt-out**: acceso di default per tutti, con un interruttore per account in
|
||||
Profilo → Opzioni. La preferenza sta in una tabella nuova, `preferenze_utente`, una riga
|
||||
per account (non per slot); l'assenza di riga vale «acceso».
|
||||
- Specifica completa in [notifiche-email.md](modules/notifiche-email.md); installazione e
|
||||
gestione in [WORKER_EMAIL.md](WORKER_EMAIL.md).
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Inviare direttamente dal proprio IP con un MTA (Postfix) → scartata: porta 25 bloccata dai
|
||||
provider residenziali, IP nelle blocklist, niente reverse DNS; le mail finirebbero in spam o
|
||||
sarebbero rifiutate.
|
||||
- Relay Brevo o Resend → scartati per ora: il piano gratuito funziona ma per una buona
|
||||
consegna richiede un dominio con SPF/DKIM/DMARC, che oggi non c'è. Restano l'alternativa
|
||||
se servirà un mittente della squadra.
|
||||
- Supabase Edge Function con `pg_cron` e un relay → scartata su richiesta esplicita di un
|
||||
servizio dockerizzato sull'host; resta la strada senza host acceso, ma dipende comunque da un
|
||||
relay esterno.
|
||||
- Vercel Cron → scartato: sul piano gratuito gira al massimo una volta al giorno, troppo poco
|
||||
per i promemoria a 24h e 3h.
|
||||
- Chiamare l'host dall'app (webhook verso casa) → scartato: richiederebbe un IP pubblico o un
|
||||
tunnel e una superficie d'attacco in più; il polling in uscita non ne ha.
|
||||
- Colonne di stato sulla riga di `notifiche_utente` → scartate per la RLS spiegata sopra.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Costo zero, ma il canale email dipende da un host acceso: se è spento le mail arrivano in
|
||||
ritardo (mai perse). Consegna _almeno una volta_: in caso di crash tra invio e
|
||||
registrazione può arrivare un doppione.
|
||||
- Il tetto di circa 500 destinatari al giorno di Gmail è largo per una squadra, ma è un limite
|
||||
reale: con molte squadre o riepiloghi frequenti andrebbe rivisto.
|
||||
- Nuova migration (`m22_notifiche_email`), nuova cartella `mailer/` (worker bun con la sola
|
||||
dipendenza `nodemailer`, separato dall'app) e un segreto in più (la service role sull'host).
|
||||
L'app non guadagna nessuna dipendenza e le route di M17 non cambiano.
|
||||
- Il certificato in scadenza resta fuori dalle email: non è una notifica (DD-035), quindi non
|
||||
passa da `notifiche_utente`. Renderlo una notifica sarebbe una decisione a parte.
|
||||
- Il tetto giornaliero è una finestra mobile di 24 ore, `MAIL_LIMITE_GIORNO` (400 di default,
|
||||
sotto i circa 500 di Gmail).
|
||||
- Le email dei giocatori si usano per un secondo scopo oltre al login: da qui l'opt-out e
|
||||
l'indicazione in ogni mail su come disattivarlo.
|
||||
- Una tabella in più, `preferenze_utente`, pensata per accogliere in futuro anche le
|
||||
preferenze per tipo di notifica senza toccare `giocatori_squadra`.
|
||||
- In produzione dal 29/09/2026 (migration e worker su un Raspberry Pi). Dalla prova reale: la
|
||||
prima mail, da un account Gmail nuovo, è arrivata ma nella cartella spam, e la password per app
|
||||
non era disponibile finché l'account aveva le sole passkey come secondo passaggio (dettagli in
|
||||
[WORKER_EMAIL.md](WORKER_EMAIL.md#account-gmail-e-password-per-app)).
|
||||
|
||||
**Riesame**
|
||||
Se serve un mittente col dominio della squadra, se le mail finiscono in spam anche dopo qualche
|
||||
giorno di uso, se si supera il tetto giornaliero di Gmail, o se l'host di casa si rivela poco
|
||||
affidabile: passare a un relay con dominio e, in quel caso, valutare Edge Function più `pg_cron`
|
||||
al posto del container.
|
||||
|
||||
### DD-037 — I promemoria evento ricordano di essere già stati generati
|
||||
|
||||
**Data:** 29 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
I promemoria a 24 e a 3 ore (M17, DD-030) evitavano i doppioni con `ON CONFLICT DO NOTHING` sul
|
||||
vincolo `UNIQUE (giocatore_id, evento_id, tipo)` di `notifiche_utente`: la riga della notifica
|
||||
era l'unica traccia che il promemoria fosse già stato generato. Il giocatore però può eliminarla
|
||||
(swipe o ×, DD-030). Senza più la riga, al giro successivo del job (ogni ora per il promemoria a
|
||||
24 ore, ogni 15 minuti per quello a 3 ore, finché l'evento non inizia) il promemoria veniva
|
||||
rigenerato come non letto. Con il canale email (DD-036) ogni rigenerazione accodava anche una
|
||||
mail nuova: un giocatore che eliminava un promemoria ne riceveva una ogni ora fino all'inizio
|
||||
dell'evento. Il difetto è stato trovato controllando la logica il 29/09/2026, il giorno in cui il
|
||||
worker email è entrato in produzione, e riprodotto sul database locale.
|
||||
|
||||
**Decisione**
|
||||
Un registro separato, `promemoria_eventi_generati` (migration `m23_promemoria_gia_generati`), con
|
||||
una riga per `(giocatore_id, evento_id, tipo)` scritta dalla funzione `genera_promemoria_eventi()`.
|
||||
La notifica in-app si crea solo per le righe **appena registrate**, quindi eliminarla non fa
|
||||
ripartire nulla. Il registro è indipendente da `notifiche_utente`, senza policy per i client, e
|
||||
sparisce con l'evento o con il giocatore (`ON DELETE CASCADE`). I promemoria già presenti al
|
||||
momento della migration sono stati registrati come generati, altrimenti la prima notifica
|
||||
eliminata dopo la migration sarebbe tornata come prima. Il vincolo `UNIQUE` di `notifiche_utente`
|
||||
resta come seconda difesa. Cron, criteri di selezione, testi e destinatari sono invariati.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Non eliminare davvero la notifica (nasconderla con un flag) → scartata: cambia il significato
|
||||
dell'eliminazione per tutte le sorgenti, tocca la RLS e le query del client, e la tabella
|
||||
crescerebbe di righe che il giocatore non vede più.
|
||||
- Togliere l'eliminazione dai soli promemoria, lasciando «segna come letta» → scartata: a un
|
||||
giocatore che vuole ripulire l'elenco resterebbero notifiche non eliminabili, incoerenti con
|
||||
le altre.
|
||||
- Un flag «già generato» sulla riga dell'evento → scartata: il promemoria è per giocatore, e chi
|
||||
viene aggiunto ai convocati dopo deve comunque riceverlo.
|
||||
- Confrontare `creato_il` o lo stato «letta» per capire se ricrearlo → scartata: non distingue
|
||||
una notifica mai generata da una eliminata.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Eliminare un promemoria lo fa sparire per sempre, e non parte nessuna mail in più. Il numero
|
||||
massimo di mail automatiche per giocatore ed evento resta due (24 e 3 ore).
|
||||
- Una tabella in più, che cresce di una riga per giocatore, evento e tipo e si svuota con
|
||||
l'evento.
|
||||
- Se un evento viene spostato dopo che il promemoria è partito, non ne parte uno nuovo per la
|
||||
data nuova: è lo stesso comportamento di prima, ora esplicito.
|
||||
- `test/integration/promemoria-eventi.test.ts` è la definizione eseguibile: il promemoria
|
||||
eliminato non ricompare, non riaccoda mail, i due tipi sono indipendenti, un convocato aggiunto
|
||||
dopo lo riceve comunque, il registro non è leggibile né scrivibile da un client e sparisce con
|
||||
l'evento. Sul database con la funzione vecchia il test fallisce.
|
||||
|
||||
**Riesame**
|
||||
Se gli eventi potessero essere spostati con frequenza e servisse un nuovo promemoria per la data
|
||||
nuova, il registro va agganciato anche alla data di inizio, oltre a giocatore, evento e tipo.
|
||||
|
||||
### DD-038 — Ogni notifica ha tre canali: la push dei promemoria parte dal worker
|
||||
|
||||
**Data:** 29 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il requisito è che ogni notifica arrivi su tutti e tre i canali: push, centro notifiche in-app ed
|
||||
email. Due casi non lo rispettavano ([catalogo](modules/notifiche.md#catalogo-delle-notifiche)):
|
||||
|
||||
- i **promemoria a 24 e 3 ore** nascono dentro il database (job `pg_cron`, M17), che non può
|
||||
firmare né cifrare una push Web (VAPID, RFC 8291): arrivavano solo in-app e per email;
|
||||
- il **sondaggio pre-partita** partiva solo come push e non scriveva in `notifiche_utente`, quindi
|
||||
non compariva in-app e, dato che le email nascono da quella tabella (DD-036), non arrivava per
|
||||
email.
|
||||
|
||||
Le altre notifiche (messaggio dello staff, turno palloni, sollecito presenze) partono dall'app su
|
||||
Vercel e avevano già i tre canali.
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. **Sondaggio pre-partita:** la route `apri-sondaggio` scrive anche `notifiche_utente` (tipo
|
||||
nuovo `sondaggio_cacche`, un `upsert` per giocatore ed evento come le altre), per i giocatori
|
||||
attivi che non sono allenatori. La mail parte dal trigger di M22 senza altro lavoro. La push
|
||||
resta com'è. Il vincolo `CHECK` sul tipo si estende con la migration `m24`.
|
||||
2. **Push dei promemoria:** la manda il **worker `mailer/`**, con un secondo canale accanto alle
|
||||
email. Un trigger accoda ogni promemoria a 24 o 3 ore in `notifiche_push_coda` (stessa
|
||||
struttura di `notifiche_email_coda`); il worker prende il lotto con `FOR UPDATE SKIP LOCKED`,
|
||||
manda la push a tutti i dispositivi iscritti del giocatore e registra l'esito. Usa **lo stesso
|
||||
modulo dell'app** (`src/lib/webpush.server.ts`, senza dipendenze) invece di una copia: il
|
||||
container si costruisce dalla radice del repository. Le iscrizioni che rispondono 404 o 410
|
||||
vengono eliminate, come nelle route. Le push in coda da oltre 3 ore si scartano: un promemoria
|
||||
arrivato a evento iniziato è solo rumore.
|
||||
3. La push dei promemoria non dipende dall'interruttore «Email»: dipende solo dai dispositivi
|
||||
iscritti, come le altre push.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Far chiamare all'app dal database (`pg_net`, già abilitata) una route che manda la push →
|
||||
scartata: il database dipenderebbe dall'indirizzo pubblico di Vercel e da un segreto condiviso,
|
||||
e `pg_net` è un'estensione del provider (regola 4 di [PORTABILITA.md](PORTABILITA.md)).
|
||||
- Vercel Cron → scartato: sul piano gratuito gira una volta al giorno, come già notato in DD-036.
|
||||
- Copiare il modulo push nel worker → scartata: due versioni da tenere allineate per codice che
|
||||
cifra e firma.
|
||||
- Mandare dal worker la push di tutte le notifiche → scartata: le altre partono già dall'app, si
|
||||
avrebbero push doppie.
|
||||
- Un unico stato di coda per email e push → scartata: le due consegne hanno esiti, tentativi e
|
||||
destinatari diversi (una push per dispositivo, un'email per giocatore).
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Il worker non fa più solo email: gestisce due canali (il documento operativo resta
|
||||
[WORKER_EMAIL.md](WORKER_EMAIL.md)). Serve la chiave VAPID privata anche sull'host, un segreto in
|
||||
più. Senza le chiavi il worker parte lo stesso e manda solo le email, segnalandolo nel log.
|
||||
- La push dei promemoria dipende dall'host di casa acceso, come le email: se è spento parte alla
|
||||
ripartenza, ma non oltre le 3 ore dalla creazione.
|
||||
- Una tabella nuova (`notifiche_push_coda`) e una migration (`m24_push_promemoria_e_sondaggio`).
|
||||
- Il sondaggio compare nel centro notifiche e arriva per email ai giocatori attivi, non agli
|
||||
allenatori; la push continua ad andare a tutti i dispositivi iscritti tranne quelli degli
|
||||
allenatori, anche di giocatori non più attivi.
|
||||
- Le altre notifiche non cambiano canali e non ricevono push doppie.
|
||||
|
||||
**Aggiornamento (30 settembre 2026)** — DD-040 esclude dalle email le notifiche del turno palloni: per
|
||||
quei tipi i canali sono due (push e in-app).
|
||||
|
||||
**Riesame**
|
||||
Se l'host di casa si rivela poco affidabile per una notifica sensibile al tempo come la push, o se
|
||||
il database dovesse comunque chiamare l'app per altro, valutare di nuovo la chiamata dal database
|
||||
all'app.
|
||||
|
||||
### DD-039 — L'allenatore non riceve i promemoria degli eventi
|
||||
|
||||
**Data:** 29 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
DD-034 stabilì che l'allenatore riceve i promemoria a 24 e 3 ore prima di un evento, sempre, convocato
|
||||
o no (`giocatori_destinatari_evento()` riscritta da M21). Ma un promemoria serve a ricordare di
|
||||
esserci, e l'allenatore non può rispondere alle presenze, non è convocabile e non compare tra i
|
||||
partecipanti (DD-034). L'incoerenza si vede negli eventi extra-campo, come una cena di squadra:
|
||||
riceveva l'avviso (in-app, per email e, dopo M24, come push) senza poter dire se ci sarebbe stato né
|
||||
comparire nell'elenco.
|
||||
|
||||
**Decisione**
|
||||
I destinatari dei promemoria sono i **giocatori attivi**: i convocati, o tutta la rosa se
|
||||
`convocati` è vuoto. L'allenatore non li riceve, per nessun tipo di evento. Cambia solo la funzione
|
||||
SQL (migration `m25_promemoria_solo_giocatori`, `g.tipo = 'giocatore'` al posto di «o allenatore
|
||||
sempre»); cron, criteri di selezione, registro dei promemoria (DD-037), code email e push e testi
|
||||
restano invariati.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Toglierli solo per gli eventi extra-campo → scartata su indicazione esplicita di toglierli per
|
||||
gli eventi in generale: anche per un allenamento o una partita l'allenatore non è tra i
|
||||
partecipanti.
|
||||
- Farlo rispondere agli eventi extra-campo, così da tenere il promemoria → scartata: servono
|
||||
risposte presenze e regole del database per una categoria nuova, e il conteggio dei presenti.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Le notifiche che l'allenatore riceve dal server sono i **messaggi dello staff** e nient'altro
|
||||
(sollecito, turno palloni, sondaggio e notifiche smart non lo riguardano già).
|
||||
- I promemoria già generati per un allenatore prima della migration non si toccano.
|
||||
- Un allenatore non è avvisato dell'inizio di un evento che gestisce: sa quando è perché lo ha
|
||||
creato lui o lo vede in Calendario.
|
||||
- Sostituisce la parte di DD-034 sui promemoria; il resto di DD-034 resta valido.
|
||||
|
||||
**Riesame**
|
||||
Se l'allenatore dovesse poter partecipare a certi eventi (per esempio la cena), si riapre insieme
|
||||
alla sua possibilità di rispondere.
|
||||
|
||||
### DD-040 — Promemoria con «oggi» o «domani», turno palloni e solleciti automatici
|
||||
|
||||
**Data:** 30 settembre 2026
|
||||
**Stato:** Accettata (implementata e verificata in locale: migration `m26`, job, route e test; la
|
||||
migration non è ancora applicata in produzione)
|
||||
|
||||
**Contesto**
|
||||
Quattro notifiche su sei partivano da un pulsante premuto da un admin (DD-025 per i palloni): se nessuno
|
||||
lo premeva, nessuno veniva avvisato. I testi erano inoltre brevi e informali, e i promemoria non dicevano
|
||||
quando e dove. Si vuole che turno palloni e sollecito presenze partano anche da soli, a orari fissi prima
|
||||
dell'evento, e che i testi siano più formali e completi. I testi nuovi sono nel
|
||||
[catalogo](modules/notifiche.md#testi-delle-notifiche).
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. **Promemoria a 24 e 3 ore:** nuovo testo con data, ora e luogo. La parola nel titolo e nella riga
|
||||
«Data» è **«domani»** se la data dell'evento è quella di domani (fuso `Europe/Rome`), **«oggi»**
|
||||
altrimenti. Vale per entrambi i promemoria, quindi anche quello a 3 ore di un evento dopo la
|
||||
mezzanotte dice «domani». La parola la calcola la funzione SQL quando genera la notifica.
|
||||
2. **Turno palloni automatico.** Un job `pg_cron` ogni 15 minuti avvisa per gli eventi di tipo allenamento
|
||||
o partita, in tre tempi con finestre che non si sovrappongono: **12 ore** (tra 12 e 6 ore prima
|
||||
dell'inizio), **6 ore** (tra 6 e 3) e **3 ore** (meno di 3). Parte **solo se per l'evento c'è un
|
||||
incaricato confermato** in `turni_palloni`: le proposte automatiche di `completaTurni()` non salvate non
|
||||
contano. Gli avvisi sono due, con lo stesso testo nei tre tempi: «incarico assegnato» all'incaricato e
|
||||
«riconsegna» a chi aveva i palloni all'evento precedente, se è un'altra persona.
|
||||
- **Pulsante manuale.** «Avvisa chi è di turno» e la route `promemoria-palloni` **restano**: tipo
|
||||
`turno_palloni`, testo nuovo, push mandata dalla route e non dalla coda. È una notifica in più;
|
||||
riceverne due (automatica e manuale) va bene.
|
||||
- **Incaricato cambiato dopo un avviso.** Il nuovo incaricato riceve il suo avviso al giro successivo,
|
||||
con il tipo della finestra in cui cade. Chi aveva già ricevuto un avviso e non è più destinatario
|
||||
riceve **una sola volta** «Turno palloni: incarico revocato» (`turno_palloni_revocato`), se
|
||||
l'evento non è iniziato. Il job lo scopre confrontando i destinatari attuali con il **registro** degli
|
||||
avvisi automatici già generati (non con le notifiche, che il giocatore può aver eliminato), senza
|
||||
trigger su `turni_palloni`. Gli avvisi del pulsante non contano: possono riguardare una proposta non
|
||||
confermata.
|
||||
- **Niente email.** Gli avvisi palloni (automatici, revoca e pulsante) arrivano come push e nel centro
|
||||
notifiche, **non per email**: il trigger `notifiche_utente_accoda_email` salta i tipi
|
||||
`turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato`. È un'eccezione a
|
||||
DD-038 («ogni notifica ha tre canali») solo per questi tipi; l'interruttore «Email» non cambia.
|
||||
3. **Sollecito presenze automatico in tre tempi.** Lo stesso job manda il sollecito **24 ore** (tra 24 e
|
||||
12 ore prima), **12 ore** (tra 12 e 6) e **6 ore** (meno di 6) prima, al massimo una notifica per
|
||||
giocatore e per tipo. Lo riceve solo chi in quel momento non ha risposto o ha risposto «forse» ed è
|
||||
destinatario dell'evento: i convocati, o tutta la rosa attiva se `convocati` è vuoto, mai l'allenatore.
|
||||
Chi risponde presente, assente o in ritardo smette di riceverli; chi resta su «forse» li riceve tutti e
|
||||
tre. Un evento creato a meno di 12 ore dall'inizio riceve solo il sollecito della sua finestra.
|
||||
- **Pulsante manuale.** «Sollecita» e la route `sollecita-presenze` **restano**, anche per l'allenatore:
|
||||
tipo `sollecita_presenze`, push mandata dalla route, testo nuovo con in più la riga «Richiesta di:
|
||||
_Nome_». I destinatari sono **gli stessi dell'automatico** (convocati senza risposta o con «forse»);
|
||||
oggi la route guarda invece tutti i giocatori attivi.
|
||||
4. **Stessa infrastruttura dei promemoria** (DD-037, DD-038): le notifiche automatiche nascono nel
|
||||
database, il registro `promemoria_eventi_generati` impedisce i doppioni anche se il giocatore le
|
||||
elimina, la push la manda il worker dalla coda `notifiche_push_coda`, l'email (quando c'è) nasce dal
|
||||
trigger su `notifiche_utente`. Tipi nuovi: `sollecita_presenze_24h`, `_12h`, `_6h`;
|
||||
`turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato`. Il `turno_palloni` del pulsante resta.
|
||||
5. **Migration `m26`:** estende i `CHECK` sui tipi di `notifiche_utente` e del registro, il trigger della
|
||||
coda push, il trigger della coda email (che salta i tipi dei palloni) e ridefinisce la funzione dei
|
||||
promemoria. Le funzioni nuove riusano `giocatori_destinatari_evento()`.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Togliere i pulsanti manuali (turno palloni e sollecito) una volta arrivati gli automatici → scartata su
|
||||
indicazione esplicita: servono all'admin per rimandare un avviso o sollecitare fuori dagli orari fissi.
|
||||
- Mandare tutti i solleciti (o gli avvisi palloni) a un evento creato tardi → scartata: più messaggi nel
|
||||
giro di pochi minuti sono rumore.
|
||||
- «Domani» e «oggi» fissi nei due testi → scartata: il job delle 24 ore scatta anche per eventi che
|
||||
iniziano oggi, e quello delle 3 ore attraversa la mezzanotte.
|
||||
- Calcolare il testo nell'app invece che nel database → scartata: le notifiche nascono nei job `pg_cron`,
|
||||
che non chiamano l'app (DD-038).
|
||||
- Spostare nel worker il calcolo di evento precedente e successivo dei palloni → scartata: un secondo
|
||||
punto che scrive in `notifiche_utente`, per un vantaggio piccolo.
|
||||
- Ritirare l'avviso già consegnato al vecchio incaricato → scartata: per la push non si può, e in-app
|
||||
confonde. Si manda la revoca.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Cambia la regola «quattro notifiche su sei dipendono da un gesto manuale»: restano solo manuali il
|
||||
messaggio dello staff e il sondaggio pre-partita; per turno palloni e sollecito gli automatici si
|
||||
aggiungono ai pulsanti, e un giocatore può ricevere lo stesso avviso due volte.
|
||||
- **Logica duplicata:** «evento precedente» e «evento successivo» per i palloni, in `palloni-core.ts`
|
||||
(usato dal pulsante e dalla schermata), sono riscritti in SQL per i job (`genera_avvisi_palloni_fascia`).
|
||||
Ordine comune: data, ora, id. Se si cambia una regola in una sola delle due versioni, pulsante e job danno
|
||||
risposte diverse: `notifiche-automatiche.test.ts` confronta testi e destinatari sugli stessi dati, anche a
|
||||
parità di data e ora.
|
||||
- `destinatariSollecito()` riceve anche i convocati e serve al sollecito manuale; un giocatore non
|
||||
convocato non è più sollecitato. `avvisiPalloniEvento()` resta come riferimento per i test finché il SQL
|
||||
non la sostituisce del tutto.
|
||||
- Se un incaricato revocato viene poi riassegnato non riceve un secondo avviso, perché il registro lo ha
|
||||
già generato per quella finestra: la revoca resta l'ultima notifica che ha letto.
|
||||
- Le mail automatiche per evento e giocatore sono al massimo **cinque** (due promemoria, tre solleciti),
|
||||
più quelle dei pulsanti (sollecito, sondaggio). Il turno palloni non ne manda.
|
||||
- Il testo dei promemoria già generati non cambia (il registro impedisce di rigenerarli).
|
||||
|
||||
**Riesame**
|
||||
Dopo la prima stagione di uso: se i solleciti o gli avvisi palloni risultano troppi, ridurre le
|
||||
finestre o escludere chi ha risposto «forse» più di una volta.
|
||||
+19
-10
@@ -5,16 +5,16 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
|
||||
|
||||
## Stato attuale
|
||||
|
||||
| Componente | Portabile? | Note |
|
||||
| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
|
||||
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
|
||||
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
|
||||
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
|
||||
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
|
||||
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
|
||||
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
|
||||
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
|
||||
| Componente | Portabile? | Note |
|
||||
| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
|
||||
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
|
||||
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
|
||||
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
|
||||
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
|
||||
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
|
||||
| Worker email (`mailer/`) | Sì | Container su qualsiasi host con Docker; parla solo con Supabase (RPC), con un server SMTP a scelta e con i servizi push dei browser ([WORKER_EMAIL.md](WORKER_EMAIL.md)). |
|
||||
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
|
||||
|
||||
## Regole da rispettare nelle prossime modifiche
|
||||
|
||||
@@ -28,6 +28,15 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
|
||||
5. Job pianificati richiamabili via semplice HTTP POST (es. `/api/public/promemoria-palloni`),
|
||||
così funzionano con cron di sistema, pg_cron o qualsiasi scheduler.
|
||||
|
||||
## Backup e disaster recovery
|
||||
|
||||
Lo script [`backups/backup.sh`](../backups/backup.sh) scarica su un host locale schema e
|
||||
dati del database (`public` e `auth`), utenti Supabase Auth e file dei bucket storage, in
|
||||
modo da poter ripartire in fretta se il progetto Supabase (o Vercel) venisse perso.
|
||||
Uso, contenuto del backup e procedura di ripristino sono descritti in
|
||||
[`backups/README.md`](../backups/README.md). Non ancora automatizzato: l'export delle
|
||||
variabili d'ambiente Vercel (richiede login interattivo).
|
||||
|
||||
## Migrazione su server proprio (sintesi)
|
||||
|
||||
1. `bun run build` (o `npm run build`) → avvio del server Node generato.
|
||||
|
||||
+11
-10
@@ -6,16 +6,17 @@ ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--svi
|
||||
|
||||
## Dove sta cosa
|
||||
|
||||
| Documento | Risponde a |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto, cosa è previsto, cosa resta un'idea |
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
|
||||
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
|
||||
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
|
||||
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio |
|
||||
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
|
||||
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
|
||||
| Documento | Risponde a |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
|
||||
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto, cosa è previsto, cosa resta un'idea |
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
|
||||
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
|
||||
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
|
||||
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio (backup/disaster recovery incluso) |
|
||||
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
|
||||
| [WORKER_EMAIL.md](WORKER_EMAIL.md) | Come si installa, si gestisce e si diagnostica il worker che invia le notifiche per email |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
|
||||
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
|
||||
|
||||
Le regole vincolanti per gli assistenti AI stanno in [AGENTS.md](../AGENTS.md);
|
||||
lo stato corrente del lavoro in [PROJECT_STATE.md](../PROJECT_STATE.md).
|
||||
|
||||
+14
-1
@@ -6,7 +6,7 @@ il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata e con q
|
||||
|
||||
## Fatto
|
||||
|
||||
Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md`).
|
||||
Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
||||
|
||||
- [x] Gestione squadra
|
||||
- [x] Calendario
|
||||
@@ -21,6 +21,9 @@ Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md`
|
||||
- [x] Turno palloni
|
||||
- [x] Infortuni — conteggio eventi saltati, in forma minima
|
||||
- [x] Notifiche Push (promemoria intelligenti)
|
||||
- [x] Centro notifiche in-app — pallino con il numero delle non lette sull'avatar del
|
||||
profilo (il tap sul pallino apre l'elenco), indipendente dalla push (messaggi admin, promemoria evento, turno palloni,
|
||||
sollecito presenze)
|
||||
- [x] Dashboard amministratore
|
||||
- [x] Download CSV dati
|
||||
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
|
||||
@@ -33,6 +36,16 @@ Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md`
|
||||
- [x] Risultati campionato
|
||||
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
|
||||
calcolata dal CSI
|
||||
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
|
||||
stagione e badge ([specifica](modules/allenatore.md), DD-034)
|
||||
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la
|
||||
scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio
|
||||
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035)
|
||||
- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati,
|
||||
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
|
||||
([specifica](modules/notifiche-email.md), DD-036)
|
||||
- [x] Notifiche automatiche — promemoria con «oggi/domani», turno palloni 12, 6 e 3 ore prima, sollecito
|
||||
presenze a 24, 12 e 6 ore (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040)
|
||||
|
||||
## Prossimo
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
# Worker email: installazione e gestione
|
||||
|
||||
Come si mette in funzione, si controlla e si diagnostica il worker (`mailer/`) che invia le notifiche
|
||||
per email e le push delle notifiche che nascono nel database: promemoria a 24 e 3 ore, turno palloni a 12, 6 e 3 ore con revoca, solleciti presenze a 24, 12 e 6 ore (DD-040). Cosa fa il canale email sta in
|
||||
[modules/notifiche-email.md](modules/notifiche-email.md), la push dei promemoria in
|
||||
[modules/notifiche.md](modules/notifiche.md#push-dei-promemoria-m24); perché è fatto così in
|
||||
[DD-036](DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa)
|
||||
e [DD-038](DESIGN_DECISIONS.md#dd-038--ogni-notifica-ha-tre-canali-la-push-dei-promemoria-parte-dal-worker).
|
||||
Il nome del documento resta quello delle email, che sono il canale principale.
|
||||
|
||||
**Stato in produzione:** migration `m22` applicata e worker delle email in funzione dal 29/09/2026,
|
||||
su un Raspberry Pi con Docker. La push dei promemoria (`m24`) richiede le chiavi VAPID nel `.env` del
|
||||
worker e la ricostruzione del container.
|
||||
|
||||
## Cosa serve
|
||||
|
||||
| Requisito | Note |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Un host con Docker | Sempre acceso e con la porta 587 in uscita libera. Nessuna porta in entrata, nessun IP pubblico. |
|
||||
| Account Gmail dedicato | Con verifica in due passaggi e una password per app (vedi sotto). |
|
||||
| Chiave del progetto | La service role di Supabase, oggi nel `.env` della root del repo come `SUPABASE_SERVICE_ROLE_KEY`. |
|
||||
| Indirizzo dell'app | L'https pubblico a cui puntano i link nelle mail (`APP_URL`). |
|
||||
| Chiavi VAPID | Le stesse `VAPID_PUBLIC_KEY` e `VAPID_PRIVATE_KEY` dell'app su Vercel: per la push dei promemoria. |
|
||||
|
||||
Il container usa l'immagine `oven/bun:1-alpine`, disponibile anche per ARM (Raspberry Pi). Si
|
||||
costruisce dalla **radice del repository** (`docker-compose.yml` imposta `context: ..`), perché include
|
||||
anche `src/lib/webpush.server.ts`, lo stesso modulo push dell'app: `docker compose up -d --build`
|
||||
va comunque lanciato dentro `mailer/`. `mailer/Dockerfile.dockerignore` ammette nel contesto solo i
|
||||
file usati, quindi nemmeno il `.env` finisce nell'immagine.
|
||||
|
||||
## Account Gmail e password per app
|
||||
|
||||
Il worker non usa la password dell'account: Google la rifiuterebbe. Serve una **password per app**.
|
||||
|
||||
1. Meglio un account **dedicato** e non quello personale: i messaggi partono da quell'indirizzo.
|
||||
2. Attiva la **verifica in due passaggi** (myaccount.google.com → Sicurezza e accesso).
|
||||
3. Apri direttamente **https://myaccount.google.com/apppasswords**: la voce non compare nei menu.
|
||||
4. Dai un nome (per esempio «CrAPP mailer») e crea. Google mostra **una volta sola** una password di
|
||||
16 caratteri, a gruppi di quattro: copiala.
|
||||
5. Nel `.env` va scritta **senza spazi** (16 caratteri attaccati): con gli spazi il file, letto come
|
||||
script di shell, si rompe. Non va mai incollata in chat né nel repository.
|
||||
|
||||
Si può revocare in ogni momento dalla stessa pagina, senza cambiare la password dell'account.
|
||||
|
||||
**Se la pagina dice «L'impostazione che stai cercando non è disponibile per il tuo account»:**
|
||||
|
||||
| Causa | Cosa fare |
|
||||
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Verifica in due passaggi non attiva, o solo passkey come secondo passaggio | Attiva la verifica e aggiungi anche un'**app di autenticazione** (myaccount.google.com/signinoptions/twosv). |
|
||||
| Protezione avanzata attiva | Disattivala da myaccount.google.com/advanced-protection. |
|
||||
| Account Workspace con l'impostazione disattivata dall'amministratore | Usa un account `@gmail.com` normale. |
|
||||
|
||||
Se nulla funziona, l'alternativa è un altro account Gmail creato da zero, oppure un relay con un
|
||||
dominio proprio (vedi DD-036).
|
||||
|
||||
## Installazione
|
||||
|
||||
1. **Migration in produzione.** `npx supabase db push` applica `m22`. È additiva: non modifica nulla
|
||||
di esistente e non invia mail finché il worker non parte. Dal momento in cui è applicata, le
|
||||
nuove notifiche si accodano: **avvia il worker subito**, nella stessa sessione. Le notifiche
|
||||
accodate e non ancora inviate partirebbero tutte insieme al primo avvio, anche se ormai vecchie
|
||||
(il 29/09/2026 sono partiti così due messaggi di prova dell'admin).
|
||||
|
||||
2. **Configura il worker.** In `mailer/`: `cp .env.example .env`, `chmod 600 .env` e compila:
|
||||
|
||||
| Variabile | Default | Contenuto |
|
||||
| --------------------------------------- | ------------------------------ | -------------------------------------------------------------------- |
|
||||
| `SUPABASE_URL` | — | URL del progetto |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | — | chiave service role: salta la RLS, va trattata come una password |
|
||||
| `SMTP_USER`, `SMTP_PASS` | — | account Gmail dedicato e password per app (vanno insieme) |
|
||||
| `APP_URL` | — | base dei link nelle mail, in https e senza slash finale |
|
||||
| `MAIL_FROM`, `MAIL_FROM_NAME` | `SMTP_USER`, `CrAPP` | indirizzo e nome del mittente |
|
||||
| `POLL_SECONDI` | 30 | intervallo di polling |
|
||||
| `MAIL_LIMITE_GIORNO` | 400 | tetto delle ultime 24 ore, sotto i circa 500 di Gmail |
|
||||
| `MAIL_LOTTO` | 10 | mail prese a ogni giro |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | `smtp.gmail.com`, 587, `false` | server SMTP (`true` solo per la porta 465) |
|
||||
| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | — | chiavi per la push dei promemoria (vanno insieme; senza, solo email) |
|
||||
| `VAPID_SUBJECT` | `mailto:crapp@crapvolley.it` | contatto nell'intestazione VAPID, come nell'app |
|
||||
|
||||
Le chiavi VAPID devono essere **le stesse dell'app**: le iscrizioni push dei dispositivi sono
|
||||
legate alla chiave pubblica con cui sono nate, e con chiavi diverse i servizi push rifiutano l'invio
|
||||
(403). Se mancano il worker parte lo stesso e nel log compare «push dei promemoria: DISATTIVATA».
|
||||
|
||||
`SMTP_USER` e `SMTP_PASS` vanno impostati insieme o nessuno dei due (senza credenziali solo per un
|
||||
server di prova locale). Un numero non valido o una variabile obbligatoria mancante fa uscire il
|
||||
worker con l'elenco degli errori. Il `.env` è ignorato da git.
|
||||
|
||||
**`APP_URL`:** usa un indirizzo stabile, cioè il dominio di produzione o l'alias del branch
|
||||
(`…-git-<branch>-….vercel.app`). L'indirizzo di un singolo deploy (`…-<codice>-….vercel.app`)
|
||||
cambia a ogni pubblicazione e mostra il codice di quel momento. Se il progetto Vercel ha la
|
||||
Deployment Protection attiva, chi apre il link di un'anteprima vede la richiesta di accesso a
|
||||
Vercel.
|
||||
|
||||
3. **Avvia:** `docker compose up -d --build` dentro `mailer/`. Il container ha
|
||||
`restart: unless-stopped`, quindi riparte da solo dopo un riavvio dell'host.
|
||||
|
||||
4. **Verifica:** `docker compose logs` deve mostrare «SMTP raggiungibile e credenziali accettate» e
|
||||
«push dei promemoria: attiva»;
|
||||
dopo circa 30 secondi `docker ps` deve mostrare `healthy`. Poi manda un messaggio a un giocatore
|
||||
dalla dashboard admin e controlla che la riga in coda passi a `inviata` e la mail arrivi.
|
||||
|
||||
## Gestione
|
||||
|
||||
| Cosa | Comando (dentro `mailer/`) |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Vedere il log in diretta | `docker compose logs -f` |
|
||||
| Ultime righe del log | `docker compose logs --tail 50` |
|
||||
| Stato e salute | `docker ps` (colonna STATUS: `healthy` / `unhealthy`) |
|
||||
| Dopo aver cambiato `.env` | `docker compose up -d --force-recreate` (il `.env` è riletto a ogni avvio; non serve ricostruire l'immagine) |
|
||||
| Dopo aver cambiato il codice | `git pull`, poi `docker compose up -d --build` |
|
||||
| Fermarlo | `docker compose down` (le mail restano in coda e partono alla ripartenza) |
|
||||
| Spegnere subito l'accodamento | `DROP TRIGGER notifiche_utente_accoda_email ON public.notifiche_utente;` da SQL: le notifiche tornano come prima di M22. Per riattivarlo si ricrea il trigger come in `m22`. |
|
||||
|
||||
Il log riporta solo id notifica, tipo ed esito (`inviata`, `da riprovare tra N min`, `fallita`,
|
||||
`ciclo fermato`), mai indirizzi né testi.
|
||||
|
||||
## Vedere cosa è partito
|
||||
|
||||
- **Posta inviata dell'account Gmail:** le mail complete, come le ha ricevute il destinatario. È il
|
||||
riscontro più sicuro.
|
||||
- **SQL Editor di Supabase**, per stato e contenuto:
|
||||
|
||||
```sql
|
||||
select c.stato, c.inviata_il, c.tentativi, c.errore,
|
||||
g.nome, g.cognome, n.tipo, n.titolo, n.corpo
|
||||
from notifiche_email_coda c
|
||||
join notifiche_utente n on n.id = c.notifica_id
|
||||
join giocatori_squadra g on g.id = n.giocatore_id
|
||||
order by n.creato_il desc
|
||||
limit 50;
|
||||
```
|
||||
|
||||
Per un riepilogo: `select stato, count(*) from notifiche_email_coda group by stato;`. Lo stesso per le push dei
|
||||
promemoria, con la tabella `notifiche_push_coda` (colonne `stato`, `tentativi`, `errore`, `inviata_il`).
|
||||
Il testo del messaggio sta in `titolo` (oggetto) e `corpo`.
|
||||
|
||||
## Spostare il worker su un altro host
|
||||
|
||||
Il container non conserva nulla: la coda, gli esiti e il conteggio delle ultime 24 ore stanno in
|
||||
Supabase. Cambiare host non fa perdere né duplicare mail.
|
||||
|
||||
1. **Sul nuovo host** servono Docker con il plugin Compose e la porta 587 in uscita libera.
|
||||
L'immagine `oven/bun` esiste sia per ARM sia per x86, quindi l'architettura può cambiare.
|
||||
2. **Porta il codice:** `git clone` del repository (o `git pull` se c'è già) e usa la cartella
|
||||
`mailer/`. Se la copi a mano, salta `node_modules`: l'immagine si costruisce da sola.
|
||||
3. **Copia il `.env`** con un canale sicuro (per esempio `scp`), poi `chmod 600 .env`. Non passa da
|
||||
chat né da git: contiene la service role e la password per app.
|
||||
4. **Spegni il vecchio:** `docker compose down` sul vecchio host.
|
||||
5. **Avvia il nuovo:** `docker compose up -d --build` dentro `mailer/`. Devono comparire «SMTP
|
||||
raggiungibile e credenziali accettate» nel log e, dopo circa 30 secondi, `healthy` in `docker ps`.
|
||||
|
||||
Non serve riconfigurare nulla: `APP_URL`, chiave e password restano quelle del `.env`. Se per un
|
||||
po' girano entrambi i worker non ci sono mail doppie, perché `prendi_notifiche_email` usa
|
||||
`FOR UPDATE SKIP LOCKED`; conviene comunque spegnere il vecchio subito, per non confondere i log.
|
||||
Se il passaggio richiede tempo, le notifiche restano in coda e partono tutte insieme all'avvio del
|
||||
nuovo worker.
|
||||
|
||||
Al primo accesso da un IP nuovo Gmail può chiedere una conferma di sicurezza sull'account. La
|
||||
password per app di solito continua a funzionare; se nel log del nuovo host compare `EAUTH`,
|
||||
controlla la posta dell'account mittente e conferma l'accesso.
|
||||
|
||||
## Diagnosi
|
||||
|
||||
| Sintomo | Causa probabile | Cosa fare |
|
||||
| ---------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| Il log dice `SMTP non verificato (EAUTH)` | Password per app sbagliata, con spazi, o revocata | Controlla la lunghezza (`awk -F= '/^SMTP_PASS=/{print length($2)}' .env` deve dare 16), rigenera se serve. |
|
||||
| Container `unhealthy` | Credenziali rifiutate, oppure Supabase non raggiungibile | `docker compose logs --tail 50`. |
|
||||
| Righe che restano `in_coda` | Worker fermo, oppure tetto giornaliero raggiunto | `docker ps`; il tetto si libera da solo, è una finestra mobile di 24 ore. |
|
||||
| Righe `saltata` | Slot senza account collegato, senza email, o email spente | Normale: nessun destinatario utile. Verifica `giocatori_squadra.email` e `auth_user_id`. |
|
||||
| Righe `fallita` | Errore permanente del server di posta | Leggi `errore`: `550` = indirizzo inesistente o rifiutato. |
|
||||
| La mail arriva ma in spam | Mittente nuovo, nessuna reputazione | «Non è spam» e aggiungere il mittente ai contatti; vedi i limiti noti del modulo. |
|
||||
| Il pulsante «Apri CrAPP» porta a una pagina vecchia o di accesso | `APP_URL` è l'indirizzo di un singolo deploy o di un'anteprima protetta | Usa un alias stabile e ricrea il container. |
|
||||
| L'interruttore «Email» non compare in Profilo | L'app pubblicata non contiene ancora il codice del modulo | Pubblica una versione che lo include. |
|
||||
| Il log dice «push dei promemoria: DISATTIVATA» | Mancano `VAPID_PUBLIC_KEY` e `VAPID_PRIVATE_KEY` nel `.env` | Aggiungile (le stesse dell'app) e ricrea il container. |
|
||||
| Push dei promemoria in `fallita` con errore `403` | Chiavi VAPID diverse da quelle dell'app, o payload rifiutato | Controlla le chiavi; la query sotto mostra l'errore. |
|
||||
| Push dei promemoria `saltata` | Il giocatore non ha dispositivi iscritti, o il promemoria aveva oltre 3 ore | Normale se nessuno ha attivato le notifiche o il worker è stato fermo a lungo. |
|
||||
|
||||
## Prova in locale, senza toccare la produzione
|
||||
|
||||
`npx supabase start` avvia lo stack locale (la migration `m22` si applica con `npx supabase db reset`),
|
||||
con Mailpit su http://127.0.0.1:54324: le mail non escono dalla macchina. Il worker si può
|
||||
lanciare senza Docker con `cd mailer && set -a && . ./.env && set +a && bun mailer.mjs`.
|
||||
|
||||
1. **`mailer/.env` per la prova.** `SUPABASE_URL` e `SUPABASE_SERVICE_ROLE_KEY` si leggono con
|
||||
`npx supabase status -o env` (`API_URL`, `SERVICE_ROLE_KEY`). Per provare **con Gmail vero** si
|
||||
usano le credenziali reali e il worker gira anche senza Docker; il database resta quello locale.
|
||||
Per provare **con Mailpit** bisogna far girare il worker in un container (vedi in fondo): la
|
||||
porta SMTP di Mailpit non è esposta sull'host, solo la sua interfaccia web. In quel caso si
|
||||
omettono `SMTP_USER` e `SMTP_PASS` e si imposta `MAIL_FROM`. `POLL_SECONDI=5` accorcia l'attesa.
|
||||
2. **Un giocatore con un account collegato.** Le mail vanno solo agli slot con `auth_user_id` e con
|
||||
`email`. In locale:
|
||||
|
||||
```bash
|
||||
. <(npx supabase status -o env | grep -E '^(API_URL|SERVICE_ROLE_KEY)=')
|
||||
UID_=$(curl -s -X POST "$API_URL/auth/v1/admin/users" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"email\":\"prova-$(date +%s)@example.test\",\"password\":\"prova-12345\",\"email_confirm\":true}" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
|
||||
docker exec supabase_db_<project_id> psql -U postgres -c \
|
||||
"set session_replication_role = replica; update giocatori_squadra set auth_user_id='$UID_' where id='<slot>';"
|
||||
```
|
||||
|
||||
`session_replication_role = replica` serve perché il trigger di `giocatori_squadra` rifiuta la
|
||||
service key. L'email dello slot deve essere quella che si vuole ricevere; è **unica** nella
|
||||
tabella (`giocatori_squadra_email_unica`): se l'indirizzo compare già in un altro slot del seed,
|
||||
si usa quello slot invece di impostarlo su un secondo.
|
||||
|
||||
3. **Una notifica di prova:**
|
||||
|
||||
```bash
|
||||
docker exec supabase_db_<project_id> psql -U postgres -c \
|
||||
"insert into notifiche_utente (giocatore_id, tipo, titolo, corpo) values ('<slot>','admin','Prova CrAPP','Messaggio di prova');"
|
||||
```
|
||||
|
||||
Entro un ciclo di polling il log mostra `inviata …` e la mail compare in Mailpit (o nella casella).
|
||||
|
||||
4. **Fine prova:** `npx supabase db reset` riporta il database locale pulito.
|
||||
|
||||
Per provare anche l'immagine Docker, il container si collega alla rete di Supabase
|
||||
(`--network supabase_network_<project_id>`) e usa `http://supabase_kong_<project_id>:8000` come
|
||||
`SUPABASE_URL` e `supabase_inbucket_<project_id>` sulla porta 1025 come SMTP.
|
||||
|
||||
Per provare la **push dei promemoria** senza un telefono: si genera una coppia di chiavi VAPID di
|
||||
prova, si avvia un piccolo server HTTP locale che finge di essere il servizio push (risponde 201 su un
|
||||
percorso e 410 su un altro), si inseriscono in `push_subscriptions` due iscrizioni con chiavi ECDH
|
||||
valide che puntano a quel server e si crea un promemoria in `notifiche_utente`. Il worker lo cifra, lo
|
||||
spedisce con `Authorization: vapid …`, `Content-Encoding: aes128gcm`, `Urgency: high` e `TTL`, segna
|
||||
la riga `inviata` ed elimina l'iscrizione che ha risposto 410. Il 29/09/2026 la prova è stata fatta
|
||||
così.
|
||||
|
||||
L'app in sviluppo (`npm run dev`) richiede un Node più recente del 18: su un host con Node 18 Vite
|
||||
non parte, e con lui i test di integrazione che avviano il server. Si può usare bun al posto di Node:
|
||||
`bun --bun x vite dev --port 8080` (con `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` e
|
||||
`SUPABASE_SERVICE_ROLE_KEY` dello stack locale) e poi `BASE_URL=http://localhost:8080` davanti ai
|
||||
test. I test del database e del worker non ne hanno bisogno.
|
||||
|
||||
## Sicurezza
|
||||
|
||||
- Il `.env` contiene la service role e la password per app: permessi `600`, mai in git, mai in chat.
|
||||
- La service role non passa da Vercel: sta solo sull'host del worker. Anche la chiave VAPID
|
||||
**privata** sta sull'host, oltre che su Vercel: stessa attenzione, mai in git né in chat.
|
||||
- Il log delle push riporta solo id notifica e numeri: mai gli endpoint, che identificano il
|
||||
dispositivo del giocatore.
|
||||
- Se la password per app o la service role sono state esposte, si revocano/ruotano subito (Google e
|
||||
dashboard Supabase) e si aggiorna il `.env` con `--force-recreate`.
|
||||
@@ -0,0 +1,211 @@
|
||||
# Modulo — Ruolo Allenatore
|
||||
|
||||
**Stato:** implementato
|
||||
**Decisione:** [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)
|
||||
**Migration:** `m20_ruolo_allenatore_enum`, `m21_ruolo_allenatore`
|
||||
**File principali:** `src/lib/giocatori-squadra.ts` (`tipo`, `inRosa`, `isAllenatore`,
|
||||
`ruoloVisibile`), `src/lib/ruoli.ts`, `src/lib/user-store.ts` (`useSonoAllenatore`,
|
||||
`useGiocatoreInCampo`), `src/lib/auth-route.server.ts`, `src/routes/profilo.tsx`,
|
||||
`src/routes/admin.tsx`, `src/routes/squadra.tsx`, `src/routes/index.tsx`
|
||||
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/profili-core.test.ts`,
|
||||
`test/unit/presenze.test.ts`, `test/integration/permessi-allenatore.test.ts`,
|
||||
`test/integration/permessi-route.test.ts`
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Dare accesso all'app all'allenatore, con permessi diversi da quelli di un giocatore: gestisce
|
||||
gli eventi (allenamenti, partite, amichevoli) e sollecita le presenze, ma non gioca: compare
|
||||
in Squadra come membro con la dicitura «Allenatore», però non è convocabile, non vota, non
|
||||
riceve voti e non ha statistiche, badge o stagione.
|
||||
|
||||
## Registrazione
|
||||
|
||||
Stesso flusso dei giocatori (DD-018), senza passaggi in più:
|
||||
|
||||
1. L'admin, da `/admin` → **Aggiungi**, sceglie il tipo **Allenatore** invece di Giocatore e
|
||||
inserisce nome, cognome ed email Gmail. Numero di maglia e ruolo in campo non si chiedono.
|
||||
2. L'allenatore accede con Google; l'email corrisponde allo slot e il collegamento è
|
||||
automatico. Se l'email non corrisponde, stesso messaggio di errore dei giocatori.
|
||||
3. Entra in Home.
|
||||
|
||||
Il permesso nasce dalla registrazione fatta dall'admin: l'allenatore non può
|
||||
auto-promuoversi, perché il tipo dello slot lo scrive solo un admin (come oggi numero e ruolo).
|
||||
|
||||
## Profilo
|
||||
|
||||
Il profilo dell'allenatore ha solo due tab (quelle del giocatore sono quattro):
|
||||
|
||||
| Tab | Giocatore | Allenatore |
|
||||
| ------------ | --------- | ---------------------------------------------- |
|
||||
| Stagione | sì | **no** |
|
||||
| Badge | sì | **no** |
|
||||
| Documenti | sì | sì, ridotta (vedi sotto); tab iniziale |
|
||||
| Impostazioni | sì | sì, identica (logout, notifiche, segnalazioni) |
|
||||
|
||||
Un link diretto a `/profilo?tab=stagione` o `?tab=badge` apre Documenti.
|
||||
|
||||
### Dati modificabili dall'allenatore
|
||||
|
||||
| Campo | Dove sta oggi | Nota |
|
||||
| ---------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Nome, cognome | `giocatori_squadra` | per un giocatore li scrive solo l'admin; per l'allenatore anche lui sul proprio slot |
|
||||
| Email (contatto) | `profili_giocatore.email` | non è l'email di login, che resta quella registrata dall'admin |
|
||||
| Data di nascita | `profili_giocatore.data_nascita` | sincronizzata in `giocatori_squadra.nascita` come per tutti (DD-031): compleanno in Calendario |
|
||||
| Luogo di nascita | `profili_giocatore.luogo_nascita` | |
|
||||
| Telefono | `profili_giocatore.telefono` | |
|
||||
| Foto profilo | bucket `avatar-giocatori` | stesso cerchio avatar e stesso upload dei giocatori |
|
||||
|
||||
**Non** si mostrano: indirizzo di residenza, documento d'identità, certificato medico, foto
|
||||
tessera, tesseramento CSI.
|
||||
|
||||
### Completamento profilo
|
||||
|
||||
Per l'allenatore il completamento conta solo data e luogo di nascita, telefono ed email, 25%
|
||||
ciascuno (`completamentoAllenatore()`). Il widget in Home segue la stessa regola: sparisce
|
||||
quando quei campi sono compilati.
|
||||
|
||||
## Home
|
||||
|
||||
La Home dell'allenatore tiene ciò che riguarda la squadra e toglie ciò che riguarda il
|
||||
giocatore: restano classifica e bilancio, prossimo impegno e ultima partita; spariscono lo
|
||||
streak personale, il promemoria palloni, «Da confermare», l'obiettivo di squadra e il
|
||||
«Colpo d'occhio» con le statistiche personali. Non compare nemmeno l'avviso certificati: lo
|
||||
vedono solo gli admin e il giocatore interessato (DD-035).
|
||||
|
||||
## Squadra
|
||||
|
||||
L'allenatore compare nella tab **Rosa** insieme ai giocatori, con foto, nome e cognome. Dove
|
||||
per un giocatore c'è il ruolo in campo (palleggiatore, schiacciatore…) per lui c'è scritto
|
||||
**Allenatore**, ricavato da `tipo` e non dal campo `ruolo`. Non ha numero di maglia né
|
||||
statistiche, e non compare nella tab Stats né nelle classifiche.
|
||||
|
||||
Da allenatore, in `/squadra` vede solo **Rosa** e **Stats**: le tab **Obiettivi** e **Badge**
|
||||
non compaiono.
|
||||
|
||||
| Tab | Giocatore | Allenatore |
|
||||
| --------- | --------- | ---------- |
|
||||
| Rosa | sì | sì |
|
||||
| Stats | sì | sì |
|
||||
| Obiettivi | sì | **no** |
|
||||
| Badge | sì | **no** |
|
||||
|
||||
## Badge e cacche nascosti
|
||||
|
||||
L'allenatore non vede **nessun badge** e **nessuna classifica o dato sulle cacche**, in
|
||||
nessuna schermata:
|
||||
|
||||
| Dove | Cosa si nasconde all'allenatore |
|
||||
| ----------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) |
|
||||
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» |
|
||||
| `/partita/$id` | la sezione «Badge votati dai compagni» e il sondaggio cacche (`SondaggioCacche`) |
|
||||
| push «Sondaggio pre-partita aperto» | non parte verso i dispositivi degli allenatori (`apri-sondaggio.ts`) |
|
||||
| `/profilo`, `/squadra` | le tab Badge (già escluse sopra) |
|
||||
|
||||
Presenze, media voto, MVP e palloni restano visibili.
|
||||
|
||||
## Gestione eventi
|
||||
|
||||
L'allenatore ha su `/eventi` gli stessi poteri dell'admin: crea, modifica, sposta ed elimina
|
||||
eventi e sceglie i convocati (l'elenco dei convocati contiene solo giocatori). Nel database
|
||||
la policy di scrittura su `eventi_app` è "admin o allenatore" (M21); la cancellazione a
|
||||
cascata (DD-029) resta invariata. Il bottone «Gestisci eventi» del Calendario compare anche
|
||||
a lui (`usePuoGestireEventi()`).
|
||||
|
||||
Può anche **sollecitare le presenze** di un evento, come l'admin: la route
|
||||
`sollecita-presenze.ts` accetta admin o allenatore (`richiediGestoreEventi()`), e il
|
||||
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso. Con DD-040 si aggiungono solleciti automatici a 24, 12 e 6 ore prima dell'evento, ma il pulsante resta.
|
||||
|
||||
**Restano solo admin:** dashboard `/admin`, dati e documenti dei giocatori, export CSI,
|
||||
notifica personalizzata, promemoria palloni, apertura sondaggio cacche, correzione delle
|
||||
risposte presenze altrui, gestione dei ruoli.
|
||||
|
||||
## Notifiche
|
||||
|
||||
Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i canali di tutti, sta in
|
||||
[notifiche.md](notifiche.md#catalogo-delle-notifiche)):
|
||||
|
||||
| Notifica | L'allenatore la riceve? | Note |
|
||||
| ------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Promemoria a 24 e 3 ore prima di un evento | **no** | Dal 29/09/2026 (m25, DD-039) `giocatori_destinatari_evento()` include solo i giocatori attivi: l'allenatore non può rispondere alle presenze né essere convocato, quindi il promemoria non gli chiederebbe nulla che possa fare (per esempio una cena di squadra). Prima gli arrivava sempre, convocato o no (m21). |
|
||||
| Messaggio dello staff | **sì** | In-app ed email vanno a tutta la rosa attiva, allenatori compresi; la push ai suoi dispositivi iscritti, o solo a lui se il messaggio è per lui. |
|
||||
| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito . |
|
||||
| Turno palloni | no | I turni riguardano solo i giocatori. |
|
||||
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
|
||||
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
|
||||
|
||||
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
|
||||
spento l'interruttore «Email»; la push solo sui dispositivi su cui ha attivato «Notifiche» dal
|
||||
proprio Profilo (l'interruttore è identico a quello dei giocatori). Il centro notifiche in-app
|
||||
funziona come per i giocatori, con i soli messaggi dello staff.
|
||||
|
||||
## Esclusione dalla rosa
|
||||
|
||||
L'allenatore ha uno slot in `giocatori_squadra` (DD-034), quindi ogni lettura della "rosa di
|
||||
gioco" lo esclude con `inRosa()` (`attivo` e `tipo = giocatore`) al posto del vecchio filtro
|
||||
su `attivo`. Fanno eccezione la tab Rosa di Squadra, i compleanni del Calendario
|
||||
(`useAnagraficaRosa({ conAllenatori: true })`) e i destinatari dei messaggi admin, che lo
|
||||
includono.
|
||||
|
||||
| Livello | Dove si esclude l'allenatore |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial`, `useIo()` e `useGiocatoreCorrente()` (quindi le notifiche smart) |
|
||||
| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni |
|
||||
| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` |
|
||||
| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore |
|
||||
| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) |
|
||||
|
||||
Il compleanno dell'allenatore resta visibile in Calendario (è un membro della squadra, non un
|
||||
giocatore).
|
||||
|
||||
## Permessi (riepilogo)
|
||||
|
||||
| Azione | Giocatore | Allenatore | Admin |
|
||||
| ------------------------------------------------------------------- | ---------- | ------------------ | ----------------------------------- |
|
||||
| Modificare nome e cognome | no | sì, i propri | tutti |
|
||||
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
|
||||
| Documento, certificato, foto tessera | i propri | — | lettura/download |
|
||||
| Avviso certificati in Home | il proprio | no | tutta la rosa |
|
||||
| Creare/modificare/eliminare eventi, convocati | no | sì | sì |
|
||||
| Sollecitare le presenze | no | sì | sì |
|
||||
| Promemoria palloni, notifica personalizzata | no | no | sì |
|
||||
| Ricevere i promemoria automatici degli eventi (in-app, email, push) | sì | no (DD-039) | secondo il proprio slot |
|
||||
| Ricevere i messaggi dello staff | sì | sì | secondo il proprio slot |
|
||||
| Ricevere sollecito presenze, turno palloni, sondaggio cacche | sì | no | solo con uno slot giocatore in rosa |
|
||||
| Ricevere le notifiche smart (badge, serie, obiettivi) | sì | no | solo con uno slot giocatore in rosa |
|
||||
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
|
||||
| Tab Stagione e Badge del profilo | sì | no | sì |
|
||||
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
|
||||
| Badge e classifiche/dati sulle cacche | sì | no | sì |
|
||||
| Dashboard `/admin` | no | no | sì |
|
||||
|
||||
Le notifiche sono legate allo **slot** (`giocatori_squadra`), non al ruolo di admin: chi è admin le riceve
|
||||
secondo il proprio slot, come giocatore o come allenatore, e non ne riceve se non ha uno slot.
|
||||
|
||||
## Implementazione
|
||||
|
||||
- **Migration M20** aggiunge `allenatore` all'enum `app_role`: sta da sola perché Postgres non
|
||||
lascia usare un valore di enum nella transazione che lo crea.
|
||||
- **Migration M21**: colonna `giocatori_squadra.tipo`; `numero` nullabile solo per gli
|
||||
allenatori (vincolo `giocatori_squadra_numero_giocatori`); trigger
|
||||
`enforce_giocatori_squadra_update` con `tipo` bloccato e nome/cognome liberi solo sullo slot
|
||||
del proprio allenatore; policy «L'allenatore aggiorna il proprio slot»; trigger
|
||||
`sincronizza_ruolo_allenatore` che aggiunge o toglie la riga `allenatore` in `user_roles` al
|
||||
collegamento, scollegamento, disattivazione o cambio di tipo; policy `eventi_app` admin o
|
||||
allenatore; presenze, cacche e voti chiusi all'allenatore.
|
||||
- **Client**: nell'app `numero` resta un `number` (0 per l'allenatore, NULL a database). I
|
||||
permessi (eventi, sollecito) leggono `user_roles` con `usePuoGestireEventi()`; cosa si vede
|
||||
dipende dal tipo del proprio slot (`useSonoAllenatore()`), che è immediato.
|
||||
- **Admin**: «Aggiungi giocatore o allenatore» con il campo Tipo; per l'allenatore il form non
|
||||
chiede numero e ruolo. Il tipo si sceglie solo alla creazione.
|
||||
|
||||
## Scelte confermate
|
||||
|
||||
- Non riceve i promemoria degli eventi (DD-039; fino alla m24 li riceveva sempre, DD-034). Riceve i
|
||||
messaggi dello staff.
|
||||
- Sollecita le presenze: sì. Promemoria palloni: no.
|
||||
- Badge e cacche: mai visibili all'allenatore, né classifiche né sondaggio.
|
||||
- In Squadra: nessuna sezione Staff; compare in Rosa come gli altri, con «Allenatore» al
|
||||
posto del ruolo.
|
||||
- Vede le risposte presenze e le pagelle dei giocatori: sì, la lettura è già aperta a tutti
|
||||
gli autenticati.
|
||||
@@ -408,11 +408,12 @@ meccanismo:
|
||||
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
|
||||
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
|
||||
- **`conteggioTurni()` non filtra per tipo evento** (`palloni-core.ts:70-82`), a differenza di
|
||||
`eventiPalloni()` che scarta i compleanni. Un turno registrato per errore su un evento fuori
|
||||
dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei palloni. Rischio
|
||||
teorico basso (l'UI non offre questa combinazione), comportamento pinnato da un test dedicato
|
||||
in `palloni-core.test.ts` così che un domani, se serve stringere, non lo si scopra rompendo un
|
||||
test esistente ma leggendo perché quel test lo dimostrava apposta.
|
||||
`eventiPalloni()` che include solo partite e allenamenti. Un turno registrato per errore su
|
||||
un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
|
||||
palloni. Rischio teorico basso — l'UI non offre questa combinazione, dato che
|
||||
`TurnoPalloni`/`completaTurni()`/i promemoria ora si fermano a `eventiPalloni()` — comportamento
|
||||
pinnato da un test dedicato in `palloni-core.test.ts` così che un domani, se serve stringere,
|
||||
non lo si scopra rompendo un test esistente ma leggendo perché quel test lo dimostrava apposta.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+31
-11
@@ -1,10 +1,12 @@
|
||||
# Modulo — Calendario ed Eventi
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/routes/calendario.tsx`
|
||||
(vista mensile, tutti), `src/routes/eventi.tsx` (creazione/modifica, solo admin),
|
||||
`src/components/crapp/EventoCard.tsx` (card condivisa)
|
||||
**Test:** `test/unit/eventi.test.ts`
|
||||
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
|
||||
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
|
||||
`src/routes/eventi.tsx` (creazione/modifica, admin e allenatori), `src/components/crapp/EventoCard.tsx`
|
||||
(card condivisa), `src/routes/allenamento.$id.tsx`, `src/routes/partita.$id.tsx`,
|
||||
`src/routes/evento.$id.tsx` (schede di dettaglio, una per tipo)
|
||||
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
|
||||
|
||||
---
|
||||
|
||||
@@ -19,21 +21,39 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
|
||||
|
||||
- **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
|
||||
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()`
|
||||
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa()`, non righe
|
||||
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
|
||||
true })` — ci sono anche gli allenatori, DD-034 — non righe
|
||||
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
|
||||
di evento, i giorni con più eventi si dividono lo spazio.
|
||||
- **`/eventi`** — "Gestione eventi", riservata agli amministratori (`useIsAdmin()`): crea,
|
||||
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
|
||||
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
||||
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
|
||||
(`usePuoGestireEventi()`, [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)):
|
||||
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
|
||||
(`convocatiEvento()`, vuoto = tutta la rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
||||
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
|
||||
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database).
|
||||
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). La pagina mostra
|
||||
solo una griglia mensile (stessa logica di `/calendario`, tramite le funzioni condivise di
|
||||
`src/lib/calendario.ts`), che si scorre di mese in mese con le frecce o con lo swipe, con
|
||||
la stessa fisica a molla di `/calendario`. Ogni giorno è cliccabile, anche senza eventi, e
|
||||
apre un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento
|
||||
in questo giorno" che apre il form con la data già precompilata: creare, modificare ed
|
||||
eliminare passano solo da lì. Nel form il giorno si legge come testo fisso e resta da
|
||||
scegliere solo l'ora; il campo data compare solo toccando «Cambia», per spostare l'evento. Fino a 0.9.2 sotto la griglia c'era anche la lista
|
||||
cronologica di tutti gli eventi, tolta perché ripeteva il calendario
|
||||
([DD-033](../DESIGN_DECISIONS.md#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco)).
|
||||
|
||||
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
|
||||
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
||||
`linkPerEvento()` decide dove porta il click — `/partita/$id` per una partita (con
|
||||
`/partita-csi/$id` come alternativa "solo CSI" quando non c'è un evento collegato, vedi
|
||||
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, nessun link per eventi ed
|
||||
eventi virtuali (compleanni).
|
||||
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, `/evento/$id` per un evento
|
||||
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
|
||||
`eventi_app`.
|
||||
|
||||
Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`)
|
||||
condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco
|
||||
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
|
||||
`palloni.md`, non pertinente per gli eventi extra-campo) e lo stato `infortunato` escluso
|
||||
dalle risposte solo sulla scheda evento (vedi `presenze.md`).
|
||||
|
||||
## Lettura lato server
|
||||
|
||||
|
||||
@@ -31,18 +31,27 @@ un `project_id`), nessun codice le chiama direttamente.
|
||||
### Identificativi (stagione 2025/26)
|
||||
|
||||
| Cosa | Valore |
|
||||
| ---------------------- | ---------------------------------------- |
|
||||
| Campionato | PVM - Campionato Open Misto Eccellenza |
|
||||
| `project_id` (girone) | `767` |
|
||||
| Coppa | PVM Coppa CSI Misto Silver |
|
||||
| `project_id` (coppa) | `848` |
|
||||
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
|
||||
| `team_id` | `3359` |
|
||||
| Girone | B |
|
||||
| --------------------- | -------------------------------------- |
|
||||
| Campionato | PVM - Campionato Open Misto Eccellenza |
|
||||
| `project_id` (girone) | `767` |
|
||||
| Coppa | PVM Coppa CSI Misto Silver |
|
||||
| `project_id` (coppa) | `848` |
|
||||
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
|
||||
| `team_id` | `3359` |
|
||||
| Girone | B |
|
||||
|
||||
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
|
||||
`src/lib/csi-core.ts`.
|
||||
|
||||
**Stagione 2026/27 non ancora sul portale (verificato il 24/09/2026).** Il CSI non ha ancora
|
||||
creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono
|
||||
ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica`
|
||||
mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel
|
||||
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione
|
||||
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica
|
||||
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
|
||||
a 2026/27 ([DD-032](../DESIGN_DECISIONS.md#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare)).
|
||||
|
||||
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
|
||||
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
|
||||
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
|
||||
@@ -52,11 +61,11 @@ verificato chiamando l'endpoint direttamente, che oggi restituisce sia
|
||||
|
||||
### Endpoint usati dall'app
|
||||
|
||||
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
|
||||
| -------------------------------------------------- | ------- | ------------------------------------ | ------------------------------------ |
|
||||
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
|
||||
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
|
||||
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
|
||||
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
|
||||
| ------------------------------------------------ | ------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
|
||||
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
|
||||
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
|
||||
|
||||
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
|
||||
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
|
||||
@@ -95,11 +104,11 @@ Il referto di ogni gara sul portale (`match_details.php?id=<matchId>`, dove `mat
|
||||
stesso `id` restituito da `getEventsByTeamId.php`) carica a sua volta tre componenti via
|
||||
`assets/js/match.js`:
|
||||
|
||||
| Endpoint | Formato | Uso |
|
||||
| ----------------------------------------------- | ------- | --------------------------------------------------------- |
|
||||
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
|
||||
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
|
||||
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
|
||||
| Endpoint | Formato | Uso |
|
||||
| -------------------------------------------- | ------- | ------------------------------------------------------------------- |
|
||||
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
|
||||
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
|
||||
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
|
||||
|
||||
Altri due componenti della stessa pagina non sono usati: `match-live.php` (diretta testuale
|
||||
punto-per-punto, utile solo a gara in corso) e la lista dettagliata dei precedenti dentro
|
||||
@@ -154,7 +163,9 @@ CSI (portale)
|
||||
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
|
||||
↓
|
||||
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
|
||||
sotto; tab "Storico partite": ogni gara cliccabile)
|
||||
sotto; tab "Storico partite": ogni squadra col proprio logo,
|
||||
chevron di dettaglio sulle gare cliccabili, gare divise
|
||||
per stagione; stagione anche nel sottotitolo dell'header)
|
||||
|
||||
CSI (portale, 3 endpoint)
|
||||
↓ fetch server-side on-demand, cache per-partita 6 ore
|
||||
@@ -238,6 +249,9 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
|
||||
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
|
||||
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi
|
||||
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
|
||||
L'etichetta «Stagione 2025/26» di `/classifica` invece non va toccata: il portale non
|
||||
espone il nome della stagione, quindi `stagioneDi()`/`stagioneDaPartite()` la ricavano
|
||||
dalla data delle gare (l'anno cambia ad agosto) e segue da sola il nuovo `project_id`.
|
||||
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
|
||||
istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di
|
||||
`/api/public/csi-partita/$id`. Sufficiente per una squadra; se serve di più, spostare i
|
||||
@@ -248,7 +262,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
|
||||
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
|
||||
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
|
||||
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
|
||||
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
|
||||
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
|
||||
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
|
||||
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in
|
||||
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
|
||||
@@ -275,7 +289,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
|
||||
obiettivi o3/o4/o5) dipendono da `partiteGiocate(csi.partite)` — se il parsing delle partite
|
||||
si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali.
|
||||
**Il fallback della route non se ne accorgerebbe da solo**: `/api/public/csi` lancia un
|
||||
errore solo se *sia* la classifica *sia* le partite sono vuote insieme
|
||||
errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme
|
||||
(`classifica.length === 0 && partite.length === 0`); se si rompe solo il parsing delle
|
||||
partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200`
|
||||
con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di
|
||||
@@ -298,7 +312,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
|
||||
errore SQL del loro backend in chiaro al posto del JSON
|
||||
(`Query non valida (getProjectTeams): Table 'uqc2os2x_livescore.seasons' doesn't exist`,
|
||||
verificato con `curl` diretto sul loro dominio). `leggiCsi()` (`src/routes/api/public/
|
||||
csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde
|
||||
csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde
|
||||
`503 "CSI non raggiungibile"` (o serve la cache se ce n'è una) — nessun crash, ma nessun
|
||||
dato nuovo finché il portale non torna. **Effetto sulla suite test**: i test di
|
||||
`test/integration/api.test.ts` che leggono il CSI reale sondano `/api/public/csi` una
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# Modulo — Notifiche email
|
||||
|
||||
**Stato:** implementato e in produzione dal 29/09/2026 (migration `m22`, worker in `mailer/`)
|
||||
**File principali:** `supabase/migrations/20260929120000_m22_notifiche_email.sql`,
|
||||
`mailer/mailer.mjs`, `mailer/mailer-core.ts`, `mailer/healthcheck.mjs`, `mailer/Dockerfile`,
|
||||
`mailer/docker-compose.yml`, `src/lib/preferenze-utente.ts`, `src/routes/profilo.tsx`
|
||||
(interruttore «Email»)
|
||||
|
||||
Questo documento descrive **cosa fa** il canale email. Come si installa, si configura e si
|
||||
diagnostica il worker sta in [WORKER_EMAIL.md](../WORKER_EMAIL.md); le motivazioni delle scelte in
|
||||
[DD-036](../DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa).
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Far arrivare le notifiche di CrAPP anche per email, all'indirizzo Gmail con cui il giocatore si è
|
||||
registrato, **a costo zero**. È un terzo canale accanto alla push e al centro notifiche in-app
|
||||
([notifiche.md](notifiche.md)): non li sostituisce e non introduce eventi nuovi. Manda per email
|
||||
**ogni notifica che finisce già in `notifiche_utente`**, di qualunque tipo e per qualunque
|
||||
destinatario.
|
||||
|
||||
## Architettura
|
||||
|
||||
```
|
||||
App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ notifiche_email_coda (Supabase)
|
||||
▲
|
||||
polling in uscita │ RPC con service role
|
||||
│
|
||||
Container Docker sull'host di casa (mailer)
|
||||
│ SMTP 587 (STARTTLS)
|
||||
▼
|
||||
smtp.gmail.com ──▶ casella Gmail del giocatore
|
||||
```
|
||||
|
||||
- **Vercel non ospita Docker**, quindi il worker gira su un host proprio e sta **solo in uscita**:
|
||||
interroga Supabase e parla con Gmail. Nessuna porta aperta sul router, nessun IP pubblico,
|
||||
nessun tunnel.
|
||||
- **L'app non invia mai email direttamente.** Continua a scrivere `notifiche_utente` come prima:
|
||||
le route e i cron di M17 non sono stati toccati. Se l'host è spento le mail restano in coda e
|
||||
partono alla ripartenza: consegna in ritardo, mai persa.
|
||||
- **L'accodamento sta nel database** (un trigger), quindi vale per ogni sorgente, cron `pg_cron`
|
||||
compresi.
|
||||
- Il worker gira con **bun** (immagine `oven/bun:1-alpine`, come il resto del progetto) e ha una
|
||||
sola dipendenza, `nodemailer`. Parla con Supabase via `fetch` sulle funzioni RPC di PostgREST,
|
||||
senza `@supabase/supabase-js`. Ha un proprio `package.json` e `bun.lock`, separati da quelli
|
||||
dell'app, che non guadagna nessuna dipendenza.
|
||||
- La chiave con cui il worker si autentica può essere sia una chiave JWT sia una chiave nel formato
|
||||
nuovo `sb_secret_…`: viene inviata in `apikey` e in `Authorization: Bearer` e Supabase accetta
|
||||
entrambe (verificato sul progetto di produzione).
|
||||
|
||||
## Quali notifiche e a chi
|
||||
|
||||
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, promemoria a 24 ore e a
|
||||
3 ore, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla
|
||||
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
|
||||
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
|
||||
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
|
||||
**non** vengono inviate: il trigger non ha backfill.
|
||||
|
||||
Il [catalogo delle notifiche](notifiche.md#catalogo-delle-notifiche) elenca cosa fa partire ciascuna
|
||||
e i suoi canali: tutte le notifiche generate dal server passano da `notifiche_utente` e quindi
|
||||
arrivano anche per email (DD-038).
|
||||
|
||||
**Chi le riceve:** chi ha la riga in `notifiche_utente`, con la stessa logica delle notifiche
|
||||
in-app. Gli admin non hanno un percorso a parte: ricevono ciò che riceverebbero secondo il proprio
|
||||
slot. L'allenatore riceve solo i messaggi dello staff, non i promemoria degli eventi (DD-039), né il
|
||||
sollecito, il turno palloni e il sondaggio (DD-034, DD-038).
|
||||
|
||||
Il certificato in scadenza **non genera mail**: non è una notifica ma una card calcolata in Home
|
||||
(DD-035). Se un giorno lo si volesse anche per email sarebbe una sorgente nuova di
|
||||
`notifiche_utente`, da decidere a parte.
|
||||
|
||||
**Indirizzo:** `giocatori_squadra.email`, la stessa colonna che collega account e giocatore
|
||||
(DD-018). Uno slot senza `auth_user_id` (giocatore non ancora registrato), senza email o il cui
|
||||
account ha spento le email non riceve nulla: la riga in coda passa a `saltata`, senza errore.
|
||||
|
||||
## Mittente
|
||||
|
||||
Il worker invia con un **account Gmail dedicato**, tramite `smtp.gmail.com:587`, autenticandosi con
|
||||
una **password per app**. Come crearla e i problemi noti stanno in
|
||||
[WORKER_EMAIL.md](../WORKER_EMAIL.md#account-gmail-e-password-per-app).
|
||||
|
||||
Perché Gmail e non un relay come Brevo o Resend: i destinatari sono già su Gmail, quindi la mail
|
||||
parte e arriva dentro la stessa infrastruttura, con SPF e DKIM già a posto e senza comprare né
|
||||
configurare un dominio. Gmail riscrive l'intestazione `From` con l'indirizzo dell'account: il nome
|
||||
visibile è «CrAPP», l'indirizzo no. Il tetto è di circa 500 destinatari al giorno per un account
|
||||
gratuito (valore da verificare); il worker si ferma a `MAIL_LIMITE_GIORNO`. **Non si invia mai
|
||||
direttamente dal proprio IP di casa**: gli IP residenziali sono nelle blocklist e senza reverse DNS
|
||||
le mail finiscono in spam o vengono rifiutate.
|
||||
|
||||
## Dati
|
||||
|
||||
Lo schema completo, con permessi e note, sta in [DATABASE.md](../DATABASE.md); qui il quadro
|
||||
funzionale.
|
||||
|
||||
**`notifiche_email_coda`** — una riga per notifica da mandare (`notifica_id` è chiave primaria e
|
||||
chiave esterna con `ON DELETE CASCADE`: cancellare la notifica, per esempio con lo swipe, toglie
|
||||
anche la mail non ancora partita).
|
||||
|
||||
| Colonna | Note |
|
||||
| -------------------- | ----------------------------------------------------------------------- |
|
||||
| `stato` | `in_coda` · `in_invio` · `inviata` · `fallita` · `saltata` |
|
||||
| `tentativi` | invii falliti finora; non cresce per i rinvii «differiti» (vedi sotto) |
|
||||
| `prossimo_tentativo` | il worker prende la riga solo se `<= now()` (backoff) |
|
||||
| `errore` | solo i codici SMTP dell'ultimo errore, mai il testo delle mail |
|
||||
| `inviata_il` | valorizzato a invio riuscito; alimenta il conteggio delle ultime 24 ore |
|
||||
| `aggiornata_il` | serve a riconoscere le righe rimaste `in_invio` per un worker morto |
|
||||
|
||||
È una tabella a parte e non colonne su `notifiche_utente` perché quella ha una policy `UPDATE` per
|
||||
il giocatore su tutta la riga: un client potrebbe riportare a `in_coda` una mail già partita. La
|
||||
coda ha la RLS attiva **senza alcuna policy** e i permessi tolti a `anon` e `authenticated`: la
|
||||
legge e la scrive solo la service role, cioè il worker.
|
||||
|
||||
**`preferenze_utente`** — una riga per **account** (`auth_user_id`, non lo slot giocatore), per ora
|
||||
con la sola colonna `email_notifiche` (default `true`). **Nessuna riga significa email attive**: la
|
||||
riga nasce solo quando il giocatore tocca l'interruttore, con un upsert; ognuno legge e scrive solo
|
||||
la propria, per RLS. Non sta su `giocatori_squadra`, che ha una riga per slot e il giocatore può
|
||||
modificare solo in parte (DD-016).
|
||||
|
||||
**Funzioni RPC** — tutte `SECURITY DEFINER` e con `EXECUTE` solo alla service role:
|
||||
|
||||
| Funzione | Cosa fa |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `prendi_notifiche_email(p_max)` | Prende fino a `p_max` righe `in_coda` scadute con `FOR UPDATE SKIP LOCKED`, le marca `in_invio` e restituisce indirizzo, oggetto, testo, evento, tipo. |
|
||||
| `esito_notifica_email(id, esito, errore, ora)` | Registra `inviata`, `riprova`, `differita` o `fallita`; agisce solo su righe `in_invio`, così un esito in ritardo non ne sovrascrive uno già deciso. |
|
||||
| `email_inviate_ultime_24h()` | Conta le mail inviate nelle ultime 24 ore, per restare sotto il tetto. |
|
||||
|
||||
## Flusso
|
||||
|
||||
1. Una sorgente M17 inserisce (o aggiorna con `upsert`) una riga in `notifiche_utente`.
|
||||
2. Il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`) la mette in
|
||||
`notifiche_email_coda`. L'`upsert` di turno palloni, sollecito presenze e sondaggio riscrive `creato_il`: è
|
||||
un rinvio voluto e rimette in coda anche la mail. Segnare come letta e l'`ON CONFLICT DO NOTHING`
|
||||
dei cron non toccano quella colonna e non accodano nulla.
|
||||
3. Ogni `POLL_SECONDI` (30) il worker conta le mail delle ultime 24 ore, ne chiede al massimo quante
|
||||
ne restano sotto il tetto (al più `MAIL_LOTTO`) a `prendi_notifiche_email` e le invia una a una.
|
||||
Prima di prendere il lotto la funzione SQL fa due pulizie: riporta a `in_coda` le righe ferme
|
||||
`in_invio` da più di 10 minuti (worker morto a metà) e segna `saltata` quelle senza destinatario
|
||||
o con le email spente.
|
||||
4. Ogni invio registra l'esito con `esito_notifica_email`.
|
||||
|
||||
La preferenza è valutata **al momento dell'invio**, non dell'accodamento: spegnere l'interruttore
|
||||
ferma anche le mail già in coda.
|
||||
|
||||
## Affidabilità
|
||||
|
||||
Il worker classifica l'errore SMTP (`classificaErroreSmtp`):
|
||||
|
||||
| Tipo | Esempi | Cosa succede |
|
||||
| ---------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `temporaneo` | 4xx, timeout, connessione caduta | Riprova con backoff 1 · 5 · 30 · 120 min; al quinto invio fallito passa a `fallita`. |
|
||||
| `permanente` | 5xx sul destinatario o sul messaggio, indirizzo non valido | `fallita` subito, senza altri tentativi. |
|
||||
| `limite` | `550 5.4.5 Daily user sending quota exceeded` | Tutto il lotto torna in coda tra un'ora **senza consumare tentativi**; il ciclo si ferma. |
|
||||
| `configurazione` | credenziali SMTP rifiutate (535, 534, 530, `EAUTH`) | Come sopra ma tra 15 minuti, e il container **non** risulta più sano (vedi sotto). |
|
||||
|
||||
Altre situazioni:
|
||||
|
||||
| Situazione | Comportamento |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Worker fermo o host spento | Le mail restano `in_coda` e partono alla ripartenza. |
|
||||
| Crash tra l'invio e la registrazione | Consegna _almeno una volta_: la riga resta `in_invio`, dopo 10 minuti torna in coda e la mail può arrivare due volte. Mai una perdita. |
|
||||
| Errore nella registrazione dell'esito | Non si tratta come errore SMTP (non parte un secondo invio): il ciclo si interrompe e la riga viene recuperata come sopra. |
|
||||
| Tetto giornaliero raggiunto | Finestra mobile di 24 ore su `inviata_il`, non «fino a mezzanotte»: il worker riprende da solo quando le mail più vecchie escono dalla finestra. |
|
||||
|
||||
`MAIL_LIMITE_GIORNO` vale 400 di default, sotto i circa 500 di Gmail, per lasciare margine ai
|
||||
messaggi che l'account dedicato potesse mandare per altre vie.
|
||||
|
||||
**Salute del container.** `mailer.mjs` scrive un timestamp in `/tmp/mailer-ok` a ogni ciclo
|
||||
riuscito; `healthcheck.mjs` (eseguito da Docker ogni minuto, con 30 secondi di tolleranza
|
||||
all'avvio) segnala non sano il container se l'ultimo è più vecchio di 5 minuti. Un ciclo fermato
|
||||
dalle credenziali SMTP rifiutate **non** aggiorna il file, così dopo qualche minuto `docker ps`
|
||||
mostra `unhealthy` invece di far credere che tutto funzioni. Un ciclo fermato dal solo tetto
|
||||
giornaliero è normale e resta sano.
|
||||
|
||||
## Contenuto delle mail
|
||||
|
||||
Sobrio, in testo semplice più una versione HTML minima leggibile da smartphone: oggetto = titolo
|
||||
della notifica (su una riga sola, massimo 200 caratteri), corpo = corpo della notifica, un pulsante
|
||||
«Apri CrAPP» verso `<APP_URL>/evento/<id>` quando la notifica riguarda un evento e verso la home
|
||||
altrimenti. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni → Email).
|
||||
Nessuna immagine remota, nessun tracciamento delle aperture. Il testo scritto dall'admin viene
|
||||
sempre escapato nell'HTML. Il `Message-ID` è derivato dall'id della notifica.
|
||||
|
||||
## Interfaccia
|
||||
|
||||
Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore **«Email»** (icona busta)
|
||||
che scrive `preferenze_utente` per l'account, con lo stesso stile dell'interruttore «Notifiche»
|
||||
(`useEmailNotifiche()` in `src/lib/preferenze-utente.ts`). Vale per tutti gli slot dell'account.
|
||||
Nessuna schermata nuova.
|
||||
|
||||
## Privacy e sicurezza
|
||||
|
||||
- Le email personali dei giocatori non lasciano Supabase se non verso Gmail. Il worker legge solo
|
||||
l'indirizzo dei destinatari del lotto che sta spedendo, non l'intera rosa.
|
||||
- La service role sta solo sull'host del worker, in un `.env` con permessi `600`; non passa da
|
||||
Vercel.
|
||||
- La password per app si può revocare da Google senza toccare l'account.
|
||||
- I log del worker riportano solo id notifica, tipo ed esito: **mai indirizzi né testi**, e degli
|
||||
errori SMTP solo i codici. Il log del container è limitato a 3 file da 5 MB.
|
||||
- Il canale è **opt-out**: acceso di default, spegnibile dal giocatore. Un interruttore unico, come
|
||||
per la push (nessuna preferenza per tipo).
|
||||
|
||||
## Test
|
||||
|
||||
- **Unit** — [test/unit/mailer-core.test.ts](../../test/unit/mailer-core.test.ts): configurazione,
|
||||
classificazione degli errori SMTP, backoff, tetto giornaliero, composizione della mail (escape,
|
||||
oggetto, link, `Message-ID`), salute, e il ciclo di invio con dipendenze finte (esiti, fermata su
|
||||
tetto e credenziali, registrazione dell'esito che fallisce, log senza dati personali).
|
||||
[test/unit/preferenze-utente.test.ts](../../test/unit/preferenze-utente.test.ts): il default
|
||||
«acceso» senza riga.
|
||||
- **Integrazione** — [test/integration/notifiche-email.test.ts](../../test/integration/notifiche-email.test.ts),
|
||||
sul database locale: trigger su ogni tipo, rinvio da `upsert`, cron che non accoda due volte,
|
||||
cascata alla cancellazione, `prendi_notifiche_email` (destinatario, una sola volta, chiamate
|
||||
concorrenti senza doppioni, slot senza account o senza email, righe orfane recuperate),
|
||||
preferenza (default, spenta, riaccesa, RLS sulle righe altrui), tutti gli esiti, e i permessi (un
|
||||
giocatore o un admin non vedono la coda né chiamano le funzioni del worker).
|
||||
- Nessun test spedisce mail vere: il trasporto SMTP è sostituito da un finto nei test unitari.
|
||||
L'invio reale a Gmail è stato verificato a mano il 29/09/2026 (vedi Limiti noti).
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Il canale email dipende da un host acceso: non c'è alta disponibilità.
|
||||
- Un 250 da Gmail vuol dire «accettata», non «letta»: non c'è conferma di lettura e non se ne vuole
|
||||
una.
|
||||
- **Le prime mail possono finire in spam.** Verificato il 29/09/2026: la mail di prova, da un
|
||||
account nuovo e senza reputazione, è arrivata ma nella cartella spam. Non dipende dalla
|
||||
configurazione (Gmail accetta e consegna) ma dalla reputazione del mittente. Si migliora
|
||||
segnando i messaggi «Non è spam» e aggiungendo il mittente ai contatti; chi riceve la prima mail
|
||||
va avvisato. Se resta un problema serve un mittente con dominio proprio (vedi Evoluzioni).
|
||||
- Un account collegato a più slot giocatore (`auth_user_id` ripetuto) riceve una mail per slot, dato
|
||||
che `notifiche_utente` ha una riga per `giocatore_id`.
|
||||
- Cambiare l'indirizzo in `giocatori_squadra.email` cambia dove vanno le mail future; le righe già
|
||||
`inviata` non si toccano.
|
||||
- `notifiche_email_coda` non ha pulizia automatica, come `notifiche_utente` (DD-030): le righe
|
||||
spariscono quando la notifica viene eliminata.
|
||||
- L'interruttore «Email» compare solo con il codice che lo contiene: finché una versione più vecchia
|
||||
dell'app è in produzione, le mail partono ma non si possono spegnere dall'app.
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Un relay con dominio proprio (Brevo, Resend) se serve un mittente della squadra, se le mail
|
||||
restano in spam o si supera il tetto di Gmail.
|
||||
- Riepilogo settimanale unico al posto di una mail per notifica.
|
||||
- Preferenze per tipo di notifica, nella stessa `preferenze_utente`, insieme a quelle della push.
|
||||
- Rendere il certificato in scadenza una notifica vera (sorgente nuova di `notifiche_utente`).
|
||||
+275
-13
@@ -1,9 +1,11 @@
|
||||
# Modulo — Notifiche
|
||||
|
||||
**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-subscribe.ts`, `public/push-sw.js`
|
||||
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push, più un
|
||||
centro notifiche in-app indipendente, con un pallino sull'avatar del profilo
|
||||
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/notifiche-utente.ts`,
|
||||
`src/lib/push-client.ts`, `src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
|
||||
`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`,
|
||||
`src/components/crapp/ui-bits.tsx` (`LinkProfilo`, `PallinoNotifiche`)
|
||||
|
||||
---
|
||||
|
||||
@@ -12,10 +14,154 @@
|
||||
Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
|
||||
indipendenti:
|
||||
|
||||
- **Push VAPID** — arrivano anche ad app chiusa (turno palloni, sollecito presenze).
|
||||
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
|
||||
presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
||||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
||||
serie e obiettivi appena raggiunti; non è un canale push separato.
|
||||
|
||||
Gli avvisi fissi in Home (turno palloni, certificati in scadenza) non passano da nessuno dei
|
||||
due: sono card calcolate dai dati, che spariscono da sole quando la condizione finisce. In
|
||||
particolare il certificato in scadenza **non** genera push né voci nel centro notifiche
|
||||
(DD-035, specifica in [profilo-giocatore.md](profilo-giocatore.md#avviso-certificati)).
|
||||
|
||||
---
|
||||
|
||||
## Catalogo delle notifiche
|
||||
|
||||
Cosa fa partire una notifica, a chi arriva e su quali canali. Sono i canali indipendenti descritti
|
||||
più sotto: **push** (solo ai dispositivi che hanno attivato «Notifiche»), **centro notifiche
|
||||
in-app** (storico in `notifiche_utente`, per tutti i giocatori) ed **email**
|
||||
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne
|
||||
quelle del turno palloni**, che restano solo push e in-app: DD-040).
|
||||
|
||||
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
|
||||
| 1 | **Promemoria a 24 ore** — «Promemoria evento di _domani_ (o _oggi_): _titolo_», con data, ora e luogo | Job `pg_cron` `promemoria-eventi-24h`, ogni ora, per gli eventi che iniziano entro le prossime 24 ore. Tipo `evento_promemoria_24h`. La push la manda il worker (M24). | I convocati dell'evento, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori (DD-039). | sì (worker) | sì | sì |
|
||||
| 2 | **Promemoria a 3 ore** — stesso testo | Job `pg_cron` `promemoria-eventi-3h`, ogni 15 minuti, per gli eventi che iniziano entro le prossime 3 ore. Tipo `evento_promemoria_3h`. La push la manda il worker (M24). | Come il promemoria a 24 ore. | sì (worker) | sì | sì |
|
||||
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
|
||||
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **12, 6 e 3 ore prima** dell'evento (DD-040): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipi `turno_palloni_12h`, `_6h` e `_3h`, push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) |
|
||||
| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, in tre tempi (DD-040): **24, 12 e 6 ore** prima dell'evento, dallo stesso job; tipi `sollecita_presenze_24h`, `_12h`, `_6h`, push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
|
||||
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
|
||||
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
||||
|
||||
Le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
|
||||
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
||||
automatiche (1, 2, 4 e 5) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
||||
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
||||
lo stesso evento (vedi «Centro notifiche in-app»).
|
||||
|
||||
> **Righe 1, 2, 4 e 5 (DD-040).** Implementate con la migration `m26` (job `pg_cron`, testi, revoca) e
|
||||
> verificate in locale; la migration va ancora applicata in produzione. Finché non lo è, l'app si comporta
|
||||
> come in 1.2.0: promemoria con il testo breve, turno palloni e sollecito presenze solo da pulsante.
|
||||
|
||||
Cosa riceve l'allenatore, notifica per notifica: [allenatore.md](allenatore.md#notifiche).
|
||||
|
||||
### Testi delle notifiche
|
||||
|
||||
Il testo è lo stesso su push, centro notifiche in-app ed email: titolo e corpo si scrivono una
|
||||
volta sola. Nell'email il titolo diventa l'oggetto e il corpo segue il formato di
|
||||
[notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di
|
||||
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
|
||||
|
||||
| # | Notifica | Titolo | Corpo |
|
||||
| --- | ----------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1-2 | Promemoria 24 e 3 ore | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
|
||||
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
|
||||
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
|
||||
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
|
||||
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
|
||||
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
|
||||
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
|
||||
|
||||
Le righe 1-2, 4a-4c e 5 nascono nelle funzioni SQL della migration `m26`
|
||||
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
|
||||
`genera_solleciti_presenze_fascia`); i pulsanti usano gli stessi testi da `palloni-core.ts`
|
||||
(`avvisiPalloniEvento`) e `presenze.ts` (`testoSollecito`), e `notifiche-automatiche.test.ts` verifica che siano
|
||||
identici. In tutte la riga «Luogo» manca se l'evento non ha un luogo. Fino all'applicazione della `m26` in produzione valgono i testi di 1.2.0: promemoria
|
||||
«Promemoria: _titolo_» con corpo «_data_ alle _ora_», turno palloni «Tocca a te prendere i palloni» / «Porta
|
||||
i palloni», sollecito «Manca la tua risposta». Le righe 3 e 6 non cambiano:
|
||||
`notifica-personalizzata.ts` e `cacche.ts` (`avvisoSondaggio`).
|
||||
|
||||
**Notifiche smart (riga 7).** Solo locali: la notifica di sistema ha il titolo preceduto
|
||||
dall'emoji. Non entrano nel centro notifiche e non generano email.
|
||||
|
||||
| Situazione | Titolo | Corpo |
|
||||
| ---------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Badge sbloccato (🏅) | `<badge> <grado>` | `<valore> <unità>: badge sbloccato, complimenti!` |
|
||||
| Badge segreto | `Badge segreto: <nome>` | Testo del badge (`notificaPush`, poi `celebrazione`), altrimenti «Hai scoperto un badge nascosto!» |
|
||||
| A un passo da un badge (🔥) | `Sei a un passo` | `<N> <unità> e sblocchi <badge>.` — scatta con al massimo 2 unità mancanti |
|
||||
| Serie a un traguardo esatto (⚡) | `Serie di <N> <tipo>` | `Continuità da veterano: non fermarti ora.` |
|
||||
| Obiettivo di squadra tra 90% e 99% | `<obiettivo>: <pct>%` | Microtesto di `microcopyObiettivo()`, dipende dall'obiettivo |
|
||||
| Badge social vinto | `<categoria> x<N>` | `I tuoi compagni hanno votato per te.` |
|
||||
|
||||
### Cosa non genera nessuna notifica
|
||||
|
||||
- Creare, spostare, modificare o cancellare un evento: contano solo i promemoria prima
|
||||
dell'inizio (DD-030).
|
||||
- L'arrivo di un risultato, di una pagella o di un voto MVP.
|
||||
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi.
|
||||
- Assenze e infortuni.
|
||||
- La registrazione o il collegamento di un nuovo giocatore.
|
||||
- Il certificato in scadenza: resta una card in Home (DD-035).
|
||||
|
||||
### Comportamenti da conoscere
|
||||
|
||||
Sono l'effetto attuale della logica, non scelte documentate altrove:
|
||||
|
||||
- **I promemoria (1 e 2) non distinguono il tipo di evento:** scattano per allenamenti, partite ed
|
||||
eventi extra-campo (per esempio una cena). Il turno palloni (4), invece, esiste solo per
|
||||
allenamenti e partite.
|
||||
- **Un evento creato a poche ore dall'inizio riceve subito i promemoria che gli spettano:** se
|
||||
mancano meno di 24 ore parte il promemoria «24 ore» al primo giro del job orario, e se ne
|
||||
mancano meno di 3 anche quello «3 ore», a distanza di pochi minuti.
|
||||
- **Ogni promemoria si genera una sola volta per giocatore, evento e tipo, anche se il giocatore
|
||||
lo elimina** (M23, DD-037): il job registra ciò che ha generato in `promemoria_eventi_generati`,
|
||||
una tabella indipendente da `notifiche_utente`. Eliminare la notifica dal centro notifiche non
|
||||
la fa tornare e non accoda una nuova mail. Prima di M23 tornava: la riga eliminata veniva
|
||||
rigenerata al giro successivo del job, con una nuova mail a ogni giro fino all'inizio
|
||||
dell'evento.
|
||||
- **Il registro non segue le modifiche all'evento:** se un evento viene spostato dopo che il
|
||||
promemoria è partito, non ne parte uno nuovo per la data nuova. Chi viene aggiunto ai convocati
|
||||
dopo riceve invece il suo promemoria, perché il registro è per giocatore.
|
||||
- **Le notifiche eliminate non tornano da sole:** la 3 e la 6 riappaiono solo se un admin
|
||||
rimanda il messaggio o riapre il sondaggio; quelle automatiche (1, 2, 4 e 5) non tornano affatto,
|
||||
perché il registro ricorda di averle già generate.
|
||||
- **Un evento già iniziato non genera più promemoria.**
|
||||
- **Due notifiche su sei dipendono solo da un gesto manuale** (3 e 6): se nessuno preme il pulsante,
|
||||
nessuno viene avvisato. Le altre quattro sono automatiche (DD-040); per turno palloni e sollecito il
|
||||
pulsante resta come aggiunta e può arrivare in più rispetto a quelle automatiche.
|
||||
- **Il turno palloni parte solo se l'incaricato è confermato** (DD-040): per una partita la
|
||||
proposta automatica di `completaTurni()` non salvata non basta. Se l'incaricato cambia dopo
|
||||
un avviso, il nuovo riceve il proprio al giro successivo e il precedente riceve una sola volta
|
||||
«Turno palloni: incarico revocato»; se viene poi riassegnato, non riceve un secondo avviso.
|
||||
- **I solleciti cadono in finestre che non si sovrappongono** (DD-040): 24 ore tra 24 e 12 ore
|
||||
prima dell'inizio, 12 ore tra 12 e 6, 6 ore sotto le 6. Un evento creato a meno di 12 ore
|
||||
dall'inizio riceve solo quello della sua finestra. Chi risponde presente, assente o in ritardo
|
||||
smette di riceverli; chi resta su «forse» li riceve tutti e tre.
|
||||
- **Per ogni evento un giocatore riceve al massimo cinque mail automatiche** (DD-040): due
|
||||
promemoria e tre solleciti, più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni
|
||||
(avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app.
|
||||
- **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
|
||||
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
|
||||
|
||||
### Test che verificano il catalogo
|
||||
|
||||
| Cosa | Test |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Promemoria 24 e 3 ore: destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts` |
|
||||
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
||||
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
|
||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||
| Promemoria con «oggi/domani», turno palloni a 12·6·3 ore e revoca, solleciti a 24·12·6 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`) | `integration/notifiche-automatiche.test.ts` (nuovo), `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
|
||||
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
|
||||
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||
|
||||
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
|
||||
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
|
||||
catena `useIo()` → `useRosa()` → `useNotificheSmart()` è fatta di hook e si conosce dalla lettura del
|
||||
codice; l'unico pezzo puro, `inRosa`, è coperto.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
@@ -55,14 +201,15 @@ copre in particolare le sessioni lunghe della webapp (vedi il
|
||||
|
||||
- **`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: manda a **tutti** i
|
||||
dispositivi iscritti l'avviso di apertura del sondaggio pre-partita (vedi
|
||||
[Scout Live](scout-live.md)).
|
||||
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a tutti i dispositivi
|
||||
iscritti (allenatori esclusi) l'avviso di apertura del sondaggio pre-partita e lo scrive anche in
|
||||
`notifiche_utente` per i giocatori attivi, così compare nel centro notifiche e parte per email
|
||||
(DD-038; vedi [Scout Live](scout-live.md)).
|
||||
|
||||
L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un JWT VAPID
|
||||
(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`.
|
||||
`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
|
||||
@@ -81,10 +228,27 @@ route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi
|
||||
è 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 |
|
||||
| Route | Controllo | Chi la chiama |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
|
||||
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
|
||||
|
||||
Con DD-040 `promemoria-palloni` e `sollecita-presenze` restano, accanto ai job automatici (la seconda
|
||||
anche per l'allenatore, `richiediGestoreEventi()`).
|
||||
|
||||
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
|
||||
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
|
||||
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
|
||||
La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate:
|
||||
l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non
|
||||
l'ha ancora attivata.
|
||||
|
||||
`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId`
|
||||
lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a
|
||||
quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto
|
||||
dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione
|
||||
che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard
|
||||
admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore.
|
||||
|
||||
---
|
||||
|
||||
@@ -96,8 +260,106 @@ traguardo esatto, obiettivo di squadra tra il 90 e il 100%, badge social vinto.
|
||||
ha un id deterministico; quelli già mostrati sono salvati in `localStorage` per non
|
||||
ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
|
||||
|
||||
**Chi le riceve.** Solo chi ha selezionato sul dispositivo uno slot **giocatore in rosa**:
|
||||
`CelebrazioneBadge` (montato una volta in `__root.tsx`) passa a `useNotificheSmart()` il giocatore
|
||||
letto da `useGiocatoreCorrente()`, cioè `useIo()`, che lo cerca in `useRosa()`. Quell'elenco esclude
|
||||
gli allenatori (`inRosa`, DD-034), che non hanno statistiche: per loro `useIo()` è `null` e
|
||||
`useNotificheSmart()` esce subito, senza calcolare nulla né mostrare la card celebrativa o la
|
||||
notifica del sistema. Vedi [allenatore.md](allenatore.md#notifiche).
|
||||
|
||||
---
|
||||
|
||||
## Centro notifiche in-app (M17)
|
||||
|
||||
Pallino sull'angolo dell'avatar del profilo in alto a destra (`PallinoNotifiche` dentro
|
||||
`LinkProfilo`, `src/components/crapp/ui-bits.tsx`): rosso con il numero delle non lette; se
|
||||
sono tutte lette resta neutro con il totale, così le lette restano raggiungibili per
|
||||
eliminarle; senza notifiche non compare. Il tap sull'avatar porta sempre a `/profilo`, solo il
|
||||
tap sul pallino apre il pannello (logica in `pallinoNotifiche()`). Fino a 0.9.2 era una
|
||||
campanella separata accanto all'avatar. Indipendente dal canale push sopra:
|
||||
non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico persistente in
|
||||
`notifiche_utente` (vedi [DATABASE.md](../DATABASE.md)) con stato letto/non letto, e non è la
|
||||
stessa cosa delle notifiche smart (quelle restano locali, non salvate a database).
|
||||
|
||||
Cinque sorgenti scrivono in `notifiche_utente`, mai il client (con DD-040 le sorgenti 3 e 4 hanno in più un
|
||||
job `pg_cron` come la 2, che scrive `turno_palloni_12h`/`_6h`/`_3h`/`_revocato` e `sollecita_presenze_24h`/
|
||||
`_12h`/`_6h`; i pulsanti restano e scrivono `turno_palloni` e `sollecita_presenze`):
|
||||
|
||||
1. **Messaggio admin** — `notifica-personalizzata.ts` inserisce una riga per destinatario
|
||||
in parallelo all'invio push esistente (a tutta la rosa attiva se `giocatoreId` è omesso).
|
||||
2. **Promemoria evento** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15
|
||||
minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati
|
||||
(o tutta la rosa attiva se l'evento non ne specifica), deduplicata dal registro
|
||||
`promemoria_eventi_generati` (M23, DD-037; il vincolo `UNIQUE (giocatore_id, evento_id, tipo)`
|
||||
di `notifiche_utente` resta come seconda difesa) così un cron che gira più volte non manda
|
||||
doppioni, nemmeno se il giocatore elimina la notifica. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta
|
||||
l'avvicinarsi della data, non il momento in cui è stato messo in calendario.
|
||||
3. **Turno palloni** — `promemoria-palloni.ts` (bottone riservato agli admin sulla pagina
|
||||
evento, vedi [Turno palloni](palloni.md)) inserisce una riga per ciascun avviso calcolato
|
||||
da `avvisiPalloniEvento()`, in parallelo alla push.
|
||||
4. **Sollecito presenze** — `sollecita-presenze.ts` (stesso innesco manuale) inserisce una
|
||||
riga per ciascun giocatore che non ha ancora risposto, in parallelo alla push.
|
||||
5. **Sondaggio pre-partita** — `apri-sondaggio.ts` (pulsante riservato agli admin sulla pagina
|
||||
della partita) inserisce una riga di tipo `sondaggio_cacche` per ciascun giocatore attivo,
|
||||
allenatori esclusi (`destinatariSondaggio()`), in parallelo alla push (M24, DD-038).
|
||||
|
||||
Turno palloni, sollecito presenze e sondaggio usano un `upsert` su `(giocatore_id, evento_id, tipo)`
|
||||
invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stesso evento, la
|
||||
notifica esistente viene aggiornata (testo e `creato_il` freschi, `letta` riportata a false)
|
||||
invece di duplicarsi o fallire per il vincolo `UNIQUE`.
|
||||
|
||||
Il corpo delle notifiche automatiche è a righe etichettate (Data, Ora, Luogo…): `RigaNotifica` lo mostra con
|
||||
`whitespace-pre-line`, e un test di `unit/notifiche-utente.test.ts` verifica che la classe ci sia.
|
||||
|
||||
Lettura, "segna come letta" ed eliminazione passano dal client Supabase autenticato con RLS
|
||||
(`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()`), senza una route API dedicata
|
||||
— stesso pattern di `useSalvaEvento()` in `src/lib/eventi.ts`. Aprire il pannello segna tutte
|
||||
le notifiche del giocatore selezionato come lette; non c'è un pulsante "segna singola"
|
||||
separato.
|
||||
|
||||
**Non c'è pulizia automatica delle notifiche vecchie** (DD-030): l'unico modo per farle
|
||||
sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla riga
|
||||
(`RigaNotifica` in `ui-bits.tsx`, stessa fisica a molla dello swipe del calendario) o con la
|
||||
× che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la
|
||||
ritrova per sempre nell'elenco, solo segnata come letta.
|
||||
|
||||
## Push dei promemoria (M24)
|
||||
|
||||
I promemoria a 24 e 3 ore nascono nel database (job `pg_cron`), che non può firmare né cifrare una
|
||||
push Web: la manda il worker `mailer/`, lo stesso che invia le email ([WORKER_EMAIL.md](../WORKER_EMAIL.md),
|
||||
DD-038).
|
||||
|
||||
1. Quando il job crea un promemoria, un trigger lo accoda in `notifiche_push_coda` (solo i tipi
|
||||
`evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano già la push
|
||||
dall'app, e una seconda sarebbe un doppione).
|
||||
2. Il worker, a ogni giro, chiama `prendi_push_promemoria()`, che restituisce testo e dispositivi
|
||||
iscritti del giocatore, e manda la push a **tutti** i suoi dispositivi con lo stesso modulo
|
||||
dell'app (`src/lib/webpush.server.ts`), quindi con le stesse regole di cifratura, urgenza e TTL.
|
||||
3. Un solo esito per notifica: `inviata` se almeno un dispositivo accetta; `riprova` con backoff
|
||||
1 · 5 · 30 · 120 minuti sugli errori temporanei (429, 5xx, rete), poi `fallita`; `fallita`
|
||||
subito su un rifiuto permanente (400, 401, 403, 413: tipicamente chiavi VAPID sbagliate);
|
||||
`saltata` se le iscrizioni erano tutte scadute o il giocatore non ne ha.
|
||||
4. Le iscrizioni che rispondono 404 o 410 vengono eliminate da `push_subscriptions`, come fanno le
|
||||
route.
|
||||
5. Una push in coda da oltre 3 ore dalla creazione del promemoria si scarta (`saltata`): a evento
|
||||
ormai iniziato sarebbe solo rumore. Vale se il worker resta fermo a lungo.
|
||||
|
||||
La push dei promemoria dipende solo dai dispositivi iscritti, non dall'interruttore «Email». Se il
|
||||
worker non ha le chiavi VAPID parte lo stesso e manda solo le email, segnalandolo nel log.
|
||||
|
||||
Con DD-040 il trigger accoda in `notifiche_push_coda` anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
||||
`sollecita_presenze_24h`, `_12h` e `_6h`, perché nascono nel database e nessuna route manda la
|
||||
push (quelle dei pulsanti, `turno_palloni` e `sollecita_presenze`, restano fuori dalla coda): le regole di invio, backoff e scarto sono quelle qui sopra. Un sollecito in coda da oltre 3 ore si scarta come un
|
||||
promemoria.
|
||||
|
||||
## Canale email (M22)
|
||||
|
||||
Ogni riga di `notifiche_utente` viene inviata anche per email da un worker esterno, con
|
||||
un interruttore «Email» per account in Profilo → Opzioni: [notifiche-email.md](notifiche-email.md).
|
||||
Non cambia nulla di quanto descritto sopra: push, notifiche smart e centro notifiche restano
|
||||
indipendenti e le sorgenti scrivono `notifiche_utente` come prima. Il certificato in
|
||||
scadenza resta una card in Home e non genera mail (DD-035).
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
|
||||
|
||||
@@ -24,7 +24,7 @@ ordina mettendo i completati in coda e gli altri per progresso decrescente.
|
||||
Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi
|
||||
sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento
|
||||
sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia
|
||||
esplicita. Il problema che *sembrava* riguardare gli obiettivi era in realtà nelle tabelle
|
||||
esplicita. Il problema che _sembrava_ riguardare gli obiettivi era in realtà nelle tabelle
|
||||
collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la
|
||||
cancellazione: risolto a livello database con un trigger (migration
|
||||
`m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi.
|
||||
@@ -37,18 +37,18 @@ corrente dinamico (vedi sotto).
|
||||
|
||||
## Obiettivi definiti
|
||||
|
||||
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||
| ----- | ----------------------------------- | ----------------------------------------------------- | ----------------------- | ------------------------------------------ |
|
||||
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
|
||||
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
||||
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
|
||||
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
||||
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
||||
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
|
||||
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
||||
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
|
||||
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
|
||||
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||
| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- |
|
||||
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
|
||||
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
||||
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
|
||||
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
||||
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
||||
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
|
||||
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
||||
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
|
||||
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
|
||||
|
||||
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
||||
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
||||
@@ -104,7 +104,7 @@ regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocat
|
||||
errori**. Se la risposta cambiasse forma radicalmente (non più un array), `partiteDaEventi()`
|
||||
torna `[]`. In entrambi i casi `o3`/`o4`/`o5` restano bloccati a 0% anche a fronte di vittorie
|
||||
reali, e il fallback della route (`/api/public/csi`) non se ne accorgerebbe da solo: lancia un
|
||||
errore solo se *sia* la classifica *sia* le partite sono vuote insieme, quindi se si rompe solo
|
||||
errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme, quindi se si rompe solo
|
||||
il JSON delle partite mentre la classifica HTML continua a funzionare, la route risponde
|
||||
comunque `200` con `partite: []`.
|
||||
|
||||
@@ -144,13 +144,13 @@ Dettagli completi (endpoint, identificativi di stagione, altri limiti del colleg
|
||||
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
|
||||
da un backend reale, non solo funzione pura con contesto costruito a mano).
|
||||
|
||||
| Obiettivi | Unit test | Integration test |
|
||||
| ------------ | -------------------------------- | ------------------------------------------------------------ |
|
||||
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
|
||||
| Obiettivi | Unit test | Integration test |
|
||||
| ---------- | ----------------------------- | -------------------------------------------------------- |
|
||||
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
|
||||
|
||||
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
|
||||
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
|
||||
|
||||
+27
-13
@@ -10,8 +10,9 @@
|
||||
## Obiettivo
|
||||
|
||||
Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e
|
||||
partite, con proposta automatica, possibilità di modifica manuale e promemoria push il
|
||||
giorno stesso.
|
||||
partite, con proposta automatica, possibilità di modifica manuale e avvisi push, nel centro
|
||||
notifiche e senza email a 12, 6 e 3 ore dall'evento (DD-040). Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
|
||||
compleanni non c'entrano: non richiedono palloni.
|
||||
|
||||
---
|
||||
|
||||
@@ -25,11 +26,14 @@ compaiono.
|
||||
|
||||
## Implementazione
|
||||
|
||||
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** o evento extra senza
|
||||
turno già salvato, il candidato con meno turni fatti, poi quello che non lo fa da più
|
||||
tempo, poi per ordine alfabetico — un algoritmo greedy, non un ordine fisso né solo per
|
||||
data. Gli **allenamenti** non ricevono proposta automatica: restano «da assegnare» finché
|
||||
qualcuno non sceglie un incaricato in `TurnoPalloni` (scelta della squadra).
|
||||
- `eventiPalloni()` (`palloni-core.ts`) ordina gli eventi per data, poi ora, poi id (lo stesso ordine dei job SQL di `m26`: «evento precedente» e «successivo» dipendono da qui) ed è il filtro comune a tutta la pipeline (proposte,
|
||||
conteggi, serie, promemoria): include solo `tipo === "partita"` e `tipo === "allenamento"`,
|
||||
scarta eventi extra-campo e compleanni.
|
||||
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** senza turno già salvato,
|
||||
il candidato con meno turni fatti, poi quello che non lo fa da più tempo, poi per ordine
|
||||
alfabetico — un algoritmo greedy, non un ordine fisso né solo per data. Gli **allenamenti**
|
||||
non ricevono proposta automatica: restano «da assegnare» finché qualcuno non sceglie un
|
||||
incaricato in `TurnoPalloni` (scelta della squadra).
|
||||
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
|
||||
upsert su `evento_id`.
|
||||
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
|
||||
@@ -51,6 +55,13 @@ oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un al
|
||||
|
||||
## Route API pubblica `/api/public/promemoria-palloni`
|
||||
|
||||
> **Affiancata da un invio automatico (DD-040, migration `m26`).** Oltre al pulsante, un job
|
||||
> `pg_cron` ogni 15 minuti manda l'avviso **12, 6 e 3 ore prima** dell'evento, solo se per l'evento c'è un
|
||||
> incaricato confermato in `turni_palloni`. Il pulsante e la route **restano**, con tipo
|
||||
> `turno_palloni` e push dalla route. I due destinatari e i testi nuovi («incarico assegnato» /
|
||||
> «riconsegna», validi per il pulsante e per i job, più la revoca se l'incaricato cambia) sono nel
|
||||
> [catalogo](notifiche.md#testi-delle-notifiche). Il resto della sezione descrive il pulsante.
|
||||
|
||||
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
|
||||
@@ -65,16 +76,19 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **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
|
||||
- **L'avviso automatico parte solo con un incaricato confermato** (DD-040): per una partita la
|
||||
proposta di `completaTurni()` non salvata non basta, e senza incaricato non parte nulla. Il pulsante
|
||||
dell'admin (DD-025) resta per rimandare l'avviso o per una proposta non ancora confermata. `destinatariPromemoriaPalloni()` — la versione "chi è di
|
||||
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
|
||||
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
|
||||
"assente" o "infortunato" per quell'evento.
|
||||
- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che
|
||||
scarta i compleanni): guarda solo `e.data < oggi`. Un turno registrato per errore su un
|
||||
evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
|
||||
palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa combinazione — ma
|
||||
il comportamento attuale è pinnato da un test dedicato in `palloni-core.test.ts`.
|
||||
include solo partite e allenamenti): guarda solo `e.data < oggi`. Un turno registrato per
|
||||
errore su un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge
|
||||
Sherpa dei palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa
|
||||
combinazione, dato che né `TurnoPalloni` né i promemoria automatici propongono più un turno
|
||||
per eventi extra-campo o compleanni — ma il comportamento attuale è pinnato da un test
|
||||
dedicato in `palloni-core.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,8 +24,11 @@ attuale.
|
||||
Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
|
||||
`ritardo`, `infortunato`. Solo `presente` e `ritardo` contano come presenza effettiva nelle
|
||||
statistiche. L'assenza di una riga per `(evento, giocatore)` equivale a "non ha ancora
|
||||
risposto". Sulla card in home/calendario (`EventoCard`) lo stato `infortunato` è offerto solo
|
||||
per partite e allenamenti; sugli eventi extra-campo non è selezionabile (non pertinente).
|
||||
risposto". Lo stato `infortunato` è offerto solo per partite e allenamenti: sugli eventi
|
||||
extra-campo (`tipo === "evento"`, es. cena di squadra) non è pertinente e resta escluso
|
||||
ovunque — badge di riepilogo, gruppi elenco e bottoni di risposta — sia nella card
|
||||
(`EventoCard`, tramite `includeInfortunato()` in `presenze.ts`) sia nella scheda di dettaglio
|
||||
(`RosaPresenze`, prop `tipo`).
|
||||
|
||||
---
|
||||
|
||||
@@ -41,8 +44,9 @@ risposte_presenze (Supabase)
|
||||
useRispostePresenze() → src/lib/presenze.ts (1 query per sessione, staleTime 5 min,
|
||||
↓ legge tutta la tabella)
|
||||
RosaPresenze → src/components/crapp/RosaPresenze.tsx
|
||||
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato)
|
||||
allenamento.$id.tsx / partita.$id.tsx
|
||||
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato;
|
||||
allenamento.$id.tsx / prop `tipo` esclude "infortunato" per gli eventi extra-campo)
|
||||
partita.$id.tsx / evento.$id.tsx
|
||||
|
||||
EventoCard → src/components/crapp/EventoCard.tsx
|
||||
↑ home / calendario (riga compatta di stati; senza `infortunato` se tipo `evento`)
|
||||
@@ -64,14 +68,22 @@ compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
|
||||
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
|
||||
├─ legge l'evento (eventi_app, con i convocati) e le risposte già date
|
||||
├─ destinatariSollecito() → src/lib/presenze.ts
|
||||
│ (attivi senza risposta o con "forse"; funzione pura, testata in unit)
|
||||
│ (convocati, o tutta la rosa se vuoti, senza risposta o con "forse"; funzione pura)
|
||||
├─ testoSollecito() per ciascuno (risposta attuale e «Richiesta di»)
|
||||
├─ per ciascuno invia una push col testo cifrato nel payload
|
||||
│ (src/lib/webpush.server.ts)
|
||||
└─ elimina le iscrizioni push scadute (404/410)
|
||||
```
|
||||
|
||||
> **Sollecito automatico (DD-040, migration `m26`).** Il pulsante «Sollecita» e la route
|
||||
> **restano**, ma `destinatariSollecito()` passa a guardare anche i convocati (convocati che non hanno
|
||||
> risposto o hanno risposto «forse», o tutta la rosa se `convocati` è vuoto); in più un job `pg_cron` manda il sollecito **24, 12 e 6 ore prima** dell'evento a chi in
|
||||
> quel momento non ha risposto o ha risposto «forse», tra i destinatari dell'evento (convocati, o
|
||||
> tutta la rosa attiva; mai l'allenatore). Testo e finestre nel [catalogo](notifiche.md#catalogo-delle-notifiche).
|
||||
> Il diagramma sopra è quello del pulsante, che da DD-040 legge anche `convocati` dell'evento.
|
||||
|
||||
Un evento conta ai fini delle statistiche di presenza solo se è di tipo `partita` o
|
||||
`allenamento` e il giocatore è tra i convocati (o non ci sono convocati specificati, cioè
|
||||
vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
|
||||
@@ -82,9 +94,9 @@ vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
|
||||
|
||||
- Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal
|
||||
server, la UI risponde subito.
|
||||
- Il sollecito è **manuale**: nessun cron nel repository lo richiama automaticamente, parte
|
||||
solo dal bottone admin, e la route verifica il ruolo lato server con `richiediAdmin`
|
||||
(DD-024).
|
||||
- Il sollecito ha due vie (DD-040): tre solleciti **automatici** (24, 12 e 6 ore prima, job `pg_cron`
|
||||
nel database) e il pulsante **manuale** accanto, che non sostituiscono. La route del pulsante verifica
|
||||
il ruolo lato server con `richiediGestoreEventi` (admin o allenatore, DD-024, DD-034).
|
||||
- Ognuno risponde **solo per sé**, e non è più una regola della sola interfaccia: dalla
|
||||
migration `m11_scritture_per_ruolo` la policy di `risposte_presenze` lega la riga allo slot
|
||||
`giocatori_squadra` collegato all'account, con gli amministratori come sola deroga
|
||||
|
||||
@@ -14,6 +14,9 @@ L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al
|
||||
|
||||
## Utenti
|
||||
|
||||
L'allenatore ha un profilo ridotto (solo Docs con i dati personali essenziali e Opzioni):
|
||||
la specifica sta in [allenatore.md](allenatore.md).
|
||||
|
||||
### Giocatore
|
||||
|
||||
Può:
|
||||
@@ -35,7 +38,8 @@ Può:
|
||||
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email)
|
||||
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
|
||||
- scollegare un account da un profilo, liberando lo slot
|
||||
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale)
|
||||
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale),
|
||||
oppure un allenatore (tipo «Allenatore»: niente numero né ruolo, DD-034)
|
||||
- disattivare un giocatore che ha lasciato la squadra, e riattivarlo in caso di errore: la
|
||||
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
|
||||
agganciati al suo id
|
||||
@@ -60,7 +64,7 @@ Se il profilo non è completo compare automaticamente un widget di completamento
|
||||
|
||||
## Home
|
||||
|
||||
Il giocatore visualizza un widget dedicato.
|
||||
Il giocatore visualizza un widget dedicato e, quando serve, l'avviso sul certificato medico.
|
||||
|
||||
### Completa il tuo profilo
|
||||
|
||||
@@ -76,6 +80,128 @@ Quando tutte le sezioni sono complete il widget scompare automaticamente.
|
||||
Il tap apre Profilo sulla sottosezione **Documenti** (`/profilo?tab=documenti`), non
|
||||
sulla tab Stagione.
|
||||
|
||||
### Avviso certificati
|
||||
|
||||
**Stato:** implementato (DD-035).
|
||||
|
||||
Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne
|
||||
accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza
|
||||
già salvata: niente tabella, niente migration, niente push.
|
||||
|
||||
#### Chi lo vede
|
||||
|
||||
| Utente | Cosa vede | Al tocco |
|
||||
| --------------------------- | --------------------------------------------------------------- | ----------------------------- |
|
||||
| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
|
||||
| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
|
||||
| Admin che è anche giocatore | solo l'avviso dello staff, dove compare già il suo nome | niente |
|
||||
| Altri giocatori | niente | — |
|
||||
| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
|
||||
|
||||
L'avviso dello staff non è cliccabile, come quello dei palloni: l'admin non può caricare il
|
||||
certificato al posto del giocatore (DD-017), quindi può solo sollecitarlo, e il giocatore
|
||||
riceve già il proprio avviso. Quello personale porta invece dove si carica il certificato
|
||||
nuovo, come il widget «Completa il tuo profilo».
|
||||
|
||||
I certificati sono dati sanitari: fuori da `/admin` li legge solo il titolare (DD-016).
|
||||
Nessuna policy cambia, perché `useProfili()` restituisce già tutti i profili a un admin e il
|
||||
solo proprio a un giocatore.
|
||||
|
||||
#### Quando compare
|
||||
|
||||
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale), soglia
|
||||
`GIORNI_AVVISO_CERTIFICATO = 7`.
|
||||
|
||||
| Condizione | Stato | Avviso |
|
||||
| -------------------- | ----------- | ------------------ |
|
||||
| `giorni > 7` | valido | nessuno |
|
||||
| `0 ≤ giorni ≤ 7` | in scadenza | giallo (`warning`) |
|
||||
| `giorni < 0` | scaduto | nero (`primary`) |
|
||||
| data o file mancanti | mancante | nessuno |
|
||||
|
||||
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
|
||||
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
|
||||
- L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo.
|
||||
- Il certificato **mancante** non genera avviso: è un problema diverso, già visibile nella
|
||||
tab Profili di `/admin` e nel widget di completamento del giocatore.
|
||||
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
|
||||
cioè `attivo` e `tipo = 'giocatore'`).
|
||||
|
||||
#### Testi
|
||||
|
||||
| Avviso | Titolo (staff / personale) | Riga |
|
||||
| ----------------- | -------------------------- | ------------------------------------------------------------------------ |
|
||||
| Giallo, staff | «Certificati in scadenza» | «Mario Rossi — scade tra 5 giorni», «… — scade domani», «… — scade oggi» |
|
||||
| Nero, staff | «Certificati scaduti» | «Mario Rossi — scaduto il 12/09/2026» |
|
||||
| Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» |
|
||||
| Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» |
|
||||
|
||||
Il numero di giorni scende da solo ogni giorno (7, 6, 5… domani, oggi): l'avviso non
|
||||
conserva niente, rilegge la data a ogni apertura della Home.
|
||||
|
||||
#### Più giocatori
|
||||
|
||||
- Più giocatori nello stesso stato stanno in un **unico avviso**, una riga ciascuno, e
|
||||
compaiono **tutti**: nessun limite al numero di nomi, anche a inizio stagione quando ne
|
||||
scadono molti insieme.
|
||||
- Se ci sono sia certificati in scadenza sia scaduti compaiono **due avvisi**: prima quello
|
||||
nero, poi quello giallo.
|
||||
- Ordine delle righe: dalla scadenza più vicina nel giallo, dal certificato scaduto da più
|
||||
tempo nel nero; a parità di data, alfabetico per cognome e poi per nome. L'ordine è
|
||||
stabile, quindi l'elenco non si rimescola tra un'apertura e l'altra.
|
||||
|
||||
#### Aspetto e posizione
|
||||
|
||||
- Stessa forma del banner palloni (`PromemoriaPalloni`): card arrotondata a tutta larghezza
|
||||
con icona e titolo in `font-display` maiuscolo, righe in testo piccolo sotto.
|
||||
- Giallo: sfondo `bg-warning`, testo `text-warning-foreground`. Nero: sfondo `bg-primary`,
|
||||
testo `text-primary-foreground`. L'app è solo chiara (DD-022), quindi il nero non rischia
|
||||
di confondersi con lo sfondo.
|
||||
- In Home sta subito **sotto il banner palloni** e sopra «Completa il tuo profilo»: prima
|
||||
ciò che scade oggi, poi ciò che manca.
|
||||
|
||||
#### Rinnovo
|
||||
|
||||
Il giocatore rinnova dal proprio profilo, sottosezione Documenti: carica il file nuovo e
|
||||
aggiorna la data. Il salvataggio aggiorna la cache dei profili (`setQueryData`), quindi il
|
||||
suo avviso personale sparisce subito. Nell'avviso dello staff il nome sparisce alla lettura
|
||||
successiva dei profili da parte dell'admin (cache di 30 minuti, `useProfili()`), senza
|
||||
nessuna azione da parte sua. Quando non resta nessuno, l'avviso non compare più.
|
||||
|
||||
Basta aggiornare la data: l'avviso guarda solo quella (e la presenza di un file). Che il
|
||||
file caricato sia davvero quello nuovo resta un controllo dell'admin, come oggi. Anche l'admin
|
||||
può correggere la data dalla scheda del giocatore (DD-017, non il file): l'avviso segue la
|
||||
data, chiunque l'abbia scritta.
|
||||
|
||||
#### Caricamento
|
||||
|
||||
Finché ruoli e profili non sono arrivati, l'avviso non compare: così un admin che è anche
|
||||
giocatore non vede per un attimo l'avviso personale prima di quello dello staff. Se la
|
||||
lettura dei profili fallisce l'avviso semplicemente non compare, senza messaggio d'errore:
|
||||
la Home non deve rompersi per un dato accessorio.
|
||||
|
||||
#### Implementazione
|
||||
|
||||
| Pezzo | Ruolo |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia, 7 |
|
||||
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
|
||||
| `avvisiCertificati(rosa, profili, oggi)` | funzione pura: restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` |
|
||||
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
|
||||
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
|
||||
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
|
||||
|
||||
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
|
||||
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
|
||||
- Nessuna query nuova: `useProfili()` ha la stessa chiave di cache usata da Profilo e
|
||||
`/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi
|
||||
[EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge
|
||||
i profili.
|
||||
- Test in `test/unit/profili-core.test.ts`: soglia (8 giorni no, 7 sì, 0 sì, −1 scaduto),
|
||||
certificato senza file o senza data escluso, giocatore disattivato e allenatore esclusi,
|
||||
ordinamento (per data, poi alfabetico), testi al singolare e al plurale, cambio di mese e
|
||||
di anno nel calcolo dei giorni.
|
||||
|
||||
## Profilo
|
||||
|
||||
Il profilo viene suddiviso in sette aree.
|
||||
@@ -91,7 +217,11 @@ Il profilo viene suddiviso in sette aree.
|
||||
|
||||
**Dati personali** — modificabili dal giocatore.
|
||||
|
||||
- Data di nascita
|
||||
- Data di nascita — oltre a `profili_giocatore.data_nascita`, un trigger la sincronizza in
|
||||
`giocatori_squadra.nascita` (DD-031): a differenza degli altri campi di questa sezione,
|
||||
visibile a **tutta la squadra**, non solo al giocatore stesso o all'admin (alimenta
|
||||
Squadra e i compleanni nel Calendario, vedi `squadra.md`). Cancellare il profilo azzera
|
||||
anche quel valore pubblico.
|
||||
- Luogo di nascita
|
||||
- Indirizzo di residenza
|
||||
- Telefono
|
||||
@@ -124,6 +254,10 @@ Upload.
|
||||
|
||||
Il giocatore può aggiornare liberamente sia la data sia il file.
|
||||
|
||||
Quando mancano 7 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
|
||||
riceve un avviso nella propria Home e compare in quello degli admin (vedi
|
||||
[Avviso certificati](#avviso-certificati)).
|
||||
|
||||
Lo storico non viene mantenuto nella prima versione.
|
||||
|
||||
### Foto tessera
|
||||
@@ -165,9 +299,20 @@ Contiene.
|
||||
## Dashboard amministratore
|
||||
|
||||
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
|
||||
Profilo → Opzioni).
|
||||
Profilo → Opzioni), organizzata in tab scorrevoli a pillole (`BarraSottosezioni`, stesso
|
||||
componente di [Squadra](squadra.md) e Campionato): Squadra, Profili, Disattivati (solo se
|
||||
c'è almeno un giocatore disattivato) e Notifiche.
|
||||
|
||||
Per ogni giocatore vengono mostrati.
|
||||
La tab **Notifiche** mostra quanti giocatori attivi hanno almeno un dispositivo iscritto
|
||||
alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (vedi
|
||||
[Notifiche](notifiche.md)). È solo consultiva: l'attivazione resta un gesto che ogni
|
||||
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
|
||||
di altri.
|
||||
|
||||
Gli allenatori stanno nella stessa tab in un gruppo «Allenatori» a parte, senza documenti né
|
||||
tesseramento, e non entrano nei conteggi della tab Squadra né nell'export CSI (DD-034).
|
||||
|
||||
Per ogni giocatore, nella tab Profili, vengono mostrati.
|
||||
|
||||
- Stato del profilo
|
||||
- Certificato medico
|
||||
@@ -236,6 +381,9 @@ Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
|
||||
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
|
||||
Non carica file al posto di altri.
|
||||
|
||||
**Avviso certificati** — il giocatore vede solo il proprio, l'admin i nomi di tutta la rosa,
|
||||
nessun altro ne vede (vedi [Avviso certificati](#avviso-certificati)).
|
||||
|
||||
## Versione 1
|
||||
|
||||
- Profilo giocatore
|
||||
@@ -250,6 +398,7 @@ Non carica file al posto di altri.
|
||||
## Versioni future
|
||||
|
||||
- Storico certificati medici
|
||||
- Avviso certificati anche via push, o con una soglia regolabile dall'admin (DD-035, Riesame)
|
||||
- Gestione documenti aggiuntivi
|
||||
- Consensi privacy
|
||||
- Firma digitale
|
||||
|
||||
+24
-15
@@ -22,7 +22,11 @@ giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
|
||||
|
||||
- **`useAnagraficaRosa()`** (`rosa.ts`) — solo id, nome, ruolo, numero, data di nascita dei
|
||||
giocatori `attivo`. Serve dove basta sapere chi c'è, es. i compleanni nel Calendario o le
|
||||
liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni.
|
||||
liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni. La data di nascita
|
||||
viene da `giocatori_squadra.nascita` (migration `m18_nascita_pubblica_giocatori_squadra`,
|
||||
DD-031): pubblica a tutta la rosa, sincronizzata automaticamente da un trigger quando il
|
||||
giocatore (o un admin per lui) la salva dal Profilo — vedi `profilo-giocatore.md`. `null`
|
||||
finché non è stata inserita da nessuno.
|
||||
- **`useRosa()`** (`rosa.ts`) — la stessa anagrafica arricchita con tutte le statistiche
|
||||
personali calcolate a runtime: presenze, partite giocate, serie (presenze, allenamenti,
|
||||
partite, conferme, palloni), MVP vinti, media voto pagelle, palloni, cacche, infortuni,
|
||||
@@ -35,19 +39,29 @@ giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
|
||||
|
||||
Entrambe filtrano solo i giocatori `attivo`: chi ha lasciato la squadra resta nel database
|
||||
(presenze, voti, pagelle e badge della stagione restano agganciati al suo id) ma sparisce
|
||||
dagli elenchi correnti.
|
||||
dagli elenchi correnti. Il filtro è `inRosa()` (`giocatori-squadra.ts`), che esclude anche
|
||||
gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano.
|
||||
`useAnagraficaRosa({ conAllenatori: true })` li include, per i compleanni.
|
||||
|
||||
## Allenatori in Squadra
|
||||
|
||||
Nella tab Rosa gli allenatori attivi compaiono in cima, con foto, nome e la dicitura
|
||||
«Allenatore» dove i giocatori hanno il ruolo in campo (`ruoloVisibile()`); niente numero,
|
||||
statistiche né badge, e niente posto nella tab Stats. Chi usa l'app da allenatore vede solo
|
||||
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). La
|
||||
specifica completa sta in [allenatore.md](allenatore.md).
|
||||
|
||||
## Gestione dati squadra (solo amministratore)
|
||||
|
||||
Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)):
|
||||
|
||||
| Azione | Hook | Effetto |
|
||||
| ---------------------- | ------------------------ | ------------------------------------------------------------- |
|
||||
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
|
||||
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database |
|
||||
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
|
||||
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
|
||||
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
|
||||
| Azione | Hook | Effetto |
|
||||
| ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
|
||||
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) |
|
||||
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
|
||||
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
|
||||
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
|
||||
|
||||
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
|
||||
accesso con Google, per corrispondenza email
|
||||
@@ -77,12 +91,7 @@ presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squ
|
||||
|
||||
## Limiti noti
|
||||
|
||||
1. **`giocatori_squadra` non ha ancora una colonna per la data di nascita.** Per i
|
||||
giocatori storici (seed iniziale) la nascita viene letta da `crapp-data.ts`
|
||||
(`nascitaPerId`, lookup per id); un giocatore aggiunto dopo la migrazione non ha nascita
|
||||
nota finché la colonna non esiste (DD-015). Effetto visibile: niente compleanno nel
|
||||
Calendario per quei giocatori.
|
||||
2. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database
|
||||
1. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database
|
||||
non risponde o non è ancora popolato, `rosaFallback()` genera una rosa di riserva dai
|
||||
dati statici storici. Un ambiente nuovo senza dati in `giocatori_squadra` mostra quindi
|
||||
comunque una squadra, non una schermata vuota — ma è la rosa 2025/26 hardcoded, non
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Copia in .env (permessi 600) e compila. Il .env non va mai committato.
|
||||
|
||||
# Progetto Supabase e chiave service role (salta la RLS: trattala come una password)
|
||||
SUPABASE_URL=https://<project-ref>.supabase.co
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# Account Gmail dedicato con verifica in due passaggi e una password per app
|
||||
SMTP_USER=crapp.notifiche@gmail.com
|
||||
SMTP_PASS=
|
||||
|
||||
# Chiavi VAPID per la push dei promemoria (M24): le stesse VAPID_* dell'app su Vercel, altrimenti i
|
||||
# dispositivi rifiutano la push. Vanno impostate insieme; se mancano il worker manda solo le email.
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
# VAPID_SUBJECT=mailto:tuo@indirizzo
|
||||
|
||||
# URL pubblico dell'app: base dei link «Apri CrAPP» nelle mail
|
||||
APP_URL=https://<dominio-dell-app>
|
||||
|
||||
# Facoltative (valori di default)
|
||||
# MAIL_FROM_NAME=CrAPP
|
||||
# MAIL_FROM= # default: SMTP_USER
|
||||
# POLL_SECONDI=30
|
||||
# MAIL_LIMITE_GIORNO=400 # tetto delle ultime 24 ore, sotto i ~500 di Gmail
|
||||
# MAIL_LOTTO=10
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false # true solo per la porta 465
|
||||
@@ -0,0 +1,11 @@
|
||||
# Contesto di build: la radice del repository (vedi docker-compose.yml e Dockerfile.dockerignore).
|
||||
# Il layout dentro l'immagine rispecchia quello del repository, così mailer.mjs importa
|
||||
# ../src/lib/webpush.server.ts allo stesso modo in locale e nel container.
|
||||
FROM oven/bun:1-alpine
|
||||
WORKDIR /app/mailer
|
||||
COPY mailer/package.json mailer/bun.lock mailer/bunfig.toml ./
|
||||
RUN bun install --frozen-lockfile --production
|
||||
COPY mailer/mailer.mjs mailer/mailer-core.ts mailer/healthcheck.mjs ./
|
||||
COPY src/lib/webpush.server.ts /app/src/lib/webpush.server.ts
|
||||
USER bun
|
||||
CMD ["bun", "mailer.mjs"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Il contesto di build è la radice del repository (serve src/lib/webpush.server.ts, lo stesso modulo
|
||||
# push dell'app): si esclude tutto e si ammettono solo i file che l'immagine usa. Così non finiscono
|
||||
# nel contesto né il .env né il resto del progetto.
|
||||
*
|
||||
!mailer/package.json
|
||||
!mailer/bun.lock
|
||||
!mailer/bunfig.toml
|
||||
!mailer/mailer.mjs
|
||||
!mailer/mailer-core.ts
|
||||
!mailer/healthcheck.mjs
|
||||
!src/lib/webpush.server.ts
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "crapp-mailer",
|
||||
"dependencies": {
|
||||
"nodemailer": "^10.0.11",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"nodemailer": ["nodemailer@10.0.11", "", {}, "sha512-/c4P7U7aGpiNu2Rl08q525q2zECkUpSaf2zQPgIStCcAjHAkkQQKwD4sTyi3l91mGcLQrKzL1syXPHymo+Ogtw=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[install]
|
||||
saveTextLockfile = true
|
||||
# 24h supply-chain guard: skip package versions published less than a day ago.
|
||||
minimumReleaseAge = 86400
|
||||
# Each entry bypasses the 24h guard for one package — confirm with the user
|
||||
# before adding any.
|
||||
minimumReleaseAgeExcludes = []
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
mailer:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: mailer/Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
healthcheck:
|
||||
test: ["CMD", "bun", "healthcheck.mjs"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "5m"
|
||||
max-file: "3"
|
||||
@@ -0,0 +1,11 @@
|
||||
// Healthcheck del container: sano se l'ultimo ciclo riuscito è recente (vedi mailer.mjs).
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { staSano } from "./mailer-core.ts";
|
||||
|
||||
const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok";
|
||||
const MAX_MS = 5 * 60_000;
|
||||
|
||||
const ultimo = await readFile(FILE_SALUTE, "utf8")
|
||||
.then((t) => Number(t))
|
||||
.catch(() => null);
|
||||
process.exit(staSano(Number.isFinite(ultimo) ? ultimo : null, Date.now(), MAX_MS) ? 0 : 1);
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* Logica pura del worker email (M22, DD-036), separata da rete e SMTP per poterla testare:
|
||||
* configurazione, classificazione degli errori SMTP, backoff, composizione della mail e il
|
||||
* ciclo di invio con le dipendenze iniettate. `mailer.mjs` fa solo da colla.
|
||||
*/
|
||||
|
||||
/** Riga restituita da `prendi_notifiche_email()`. */
|
||||
export type NotificaEmail = {
|
||||
id_notifica: string;
|
||||
destinatario: string;
|
||||
oggetto: string;
|
||||
testo: string;
|
||||
id_evento: string | null;
|
||||
tipo_notifica: string;
|
||||
tentativi_fatti: number;
|
||||
};
|
||||
|
||||
export type EsitoInvio = "inviata" | "riprova" | "differita" | "fallita";
|
||||
|
||||
export type MailComposta = {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
// --- Configurazione ---------------------------------------------------------------------------
|
||||
|
||||
export type Config = {
|
||||
supabaseUrl: string;
|
||||
serviceRoleKey: string;
|
||||
smtp: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth: { user: string; pass: string } | null;
|
||||
};
|
||||
from: string;
|
||||
fromName: string;
|
||||
appUrl: string;
|
||||
/** Chiavi VAPID per la push dei promemoria (M24); `null` = solo email. */
|
||||
vapid: { publicKey: string; privateKey: string; subject: string | null } | null;
|
||||
pollSecondi: number;
|
||||
limiteGiorno: number;
|
||||
lotto: number;
|
||||
};
|
||||
|
||||
export type LetturaConfig = { config: Config } | { errori: string[] };
|
||||
|
||||
/**
|
||||
* Legge la configurazione dalle variabili d'ambiente. Torna tutti gli errori insieme (mancanti
|
||||
* e non validi) per correggere il `.env` in un solo giro. `SMTP_USER`/`SMTP_PASS` vanno o
|
||||
* entrambi o nessuno dei due: senza servono solo per un server di prova locale (Mailpit).
|
||||
*/
|
||||
export function leggiConfig(env: Record<string, string | undefined>): LetturaConfig {
|
||||
const errori: string[] = [];
|
||||
const testo = (nome: string): string => (env[nome] ?? "").trim();
|
||||
const obbligatoria = (nome: string): string => {
|
||||
const valore = testo(nome);
|
||||
if (!valore) errori.push(`${nome} mancante`);
|
||||
return valore;
|
||||
};
|
||||
const intero = (nome: string, predefinito: number): number => {
|
||||
const grezzo = testo(nome);
|
||||
if (!grezzo) return predefinito;
|
||||
const n = Number(grezzo);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
errori.push(`${nome} deve essere un intero positivo`);
|
||||
return predefinito;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
const supabaseUrl = obbligatoria("SUPABASE_URL").replace(/\/+$/, "");
|
||||
const serviceRoleKey = obbligatoria("SUPABASE_SERVICE_ROLE_KEY");
|
||||
const appUrl = obbligatoria("APP_URL").replace(/\/+$/, "");
|
||||
const user = testo("SMTP_USER");
|
||||
const pass = testo("SMTP_PASS");
|
||||
if (Boolean(user) !== Boolean(pass)) errori.push("SMTP_USER e SMTP_PASS vanno impostati insieme");
|
||||
const from = testo("MAIL_FROM") || user;
|
||||
if (!from) errori.push("MAIL_FROM mancante (o SMTP_USER)");
|
||||
const vapidPubblica = testo("VAPID_PUBLIC_KEY");
|
||||
const vapidPrivata = testo("VAPID_PRIVATE_KEY");
|
||||
if (Boolean(vapidPubblica) !== Boolean(vapidPrivata)) {
|
||||
errori.push("VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY vanno impostate insieme");
|
||||
}
|
||||
const porta = intero("SMTP_PORT", 587);
|
||||
const pollSecondi = intero("POLL_SECONDI", 30);
|
||||
const limiteGiorno = intero("MAIL_LIMITE_GIORNO", 400);
|
||||
const lotto = intero("MAIL_LOTTO", 10);
|
||||
|
||||
if (errori.length > 0) return { errori };
|
||||
return {
|
||||
config: {
|
||||
supabaseUrl,
|
||||
serviceRoleKey,
|
||||
smtp: {
|
||||
host: testo("SMTP_HOST") || "smtp.gmail.com",
|
||||
port: porta,
|
||||
secure: testo("SMTP_SECURE") === "true",
|
||||
auth: user ? { user, pass } : null,
|
||||
},
|
||||
from,
|
||||
fromName: testo("MAIL_FROM_NAME") || "CrAPP",
|
||||
appUrl,
|
||||
vapid:
|
||||
vapidPubblica && vapidPrivata
|
||||
? {
|
||||
publicKey: vapidPubblica,
|
||||
privateKey: vapidPrivata,
|
||||
subject: testo("VAPID_SUBJECT") || null,
|
||||
}
|
||||
: null,
|
||||
pollSecondi,
|
||||
limiteGiorno,
|
||||
lotto,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Errori SMTP e backoff --------------------------------------------------------------------
|
||||
|
||||
export type TipoErrore = "temporaneo" | "permanente" | "configurazione" | "limite";
|
||||
|
||||
type ErroreSmtp = { code?: unknown; responseCode?: unknown; response?: unknown; message?: unknown };
|
||||
|
||||
/**
|
||||
* Distingue cosa fare dopo un invio fallito:
|
||||
* - `limite`: tetto giornaliero di Gmail (`550 5.4.5 Daily user sending quota exceeded`, che
|
||||
* sarebbe un 5xx "permanente" ma non dipende dalla mail): si rimanda senza contare un tentativo;
|
||||
* - `configurazione`: credenziali SMTP rifiutate; stessa cosa, e il worker si ferma;
|
||||
* - `permanente`: 5xx sul destinatario o sul messaggio, inutile riprovare;
|
||||
* - `temporaneo`: 4xx e errori di rete, si riprova con backoff.
|
||||
*/
|
||||
export function classificaErroreSmtp(errore: unknown): TipoErrore {
|
||||
const e = (typeof errore === "object" && errore !== null ? errore : {}) as ErroreSmtp;
|
||||
const codice = typeof e.code === "string" ? e.code : "";
|
||||
const risposta = typeof e.responseCode === "number" ? e.responseCode : 0;
|
||||
const testo = `${typeof e.response === "string" ? e.response : ""} ${
|
||||
typeof e.message === "string" ? e.message : ""
|
||||
}`.toLowerCase();
|
||||
|
||||
if (/5\.4\.5|quota exceeded|sending limit/.test(testo)) return "limite";
|
||||
if (codice === "EAUTH" || [530, 534, 535].includes(risposta)) return "configurazione";
|
||||
if (codice === "EENVELOPE" || risposta >= 500) return "permanente";
|
||||
return "temporaneo";
|
||||
}
|
||||
|
||||
const RITARDI_MINUTI = [1, 5, 30, 120] as const;
|
||||
|
||||
/** Numero massimo di invii per notifica: il primo più un tentativo per ogni ritardo. */
|
||||
export const MAX_TENTATIVI = RITARDI_MINUTI.length + 1;
|
||||
|
||||
/**
|
||||
* Minuti da attendere dopo l'invio fallito numero `tentativiFatti + 1`; `null` quando i
|
||||
* tentativi sono finiti e la mail va segnata come fallita.
|
||||
*/
|
||||
export function ritardoRiprova(tentativiFatti: number): number | null {
|
||||
return RITARDI_MINUTI[tentativiFatti] ?? null;
|
||||
}
|
||||
|
||||
/** Quante mail si possono ancora spedire rispetto al tetto delle ultime 24 ore. */
|
||||
export function invioConsentiti(limiteGiorno: number, inviate24h: number, lotto: number): number {
|
||||
return Math.max(0, Math.min(lotto, limiteGiorno - inviate24h));
|
||||
}
|
||||
|
||||
/** Descrizione breve dell'errore, senza indirizzi né testi delle mail: va nei log. */
|
||||
export function descriviErrore(errore: unknown): string {
|
||||
const e = (typeof errore === "object" && errore !== null ? errore : {}) as ErroreSmtp;
|
||||
const parti = [
|
||||
typeof e.code === "string" ? e.code : "",
|
||||
typeof e.responseCode === "number" ? String(e.responseCode) : "",
|
||||
].filter(Boolean);
|
||||
return parti.length > 0 ? parti.join(" ") : "errore sconosciuto";
|
||||
}
|
||||
|
||||
// --- Composizione della mail ------------------------------------------------------------------
|
||||
|
||||
export function escapeHtml(testo: string): string {
|
||||
return testo
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Oggetto su una riga sola (niente a-capo nelle intestazioni), con un limite di lunghezza. */
|
||||
export function pulisciOggetto(titolo: string): string {
|
||||
const pulito = titolo
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return (pulito || "Notifica CrAPP").slice(0, 200);
|
||||
}
|
||||
|
||||
/** Link da aprire dalla mail: la scheda dell'evento se c'è, altrimenti la home dell'app. */
|
||||
export function linkNotifica(appUrl: string, idEvento: string | null): string {
|
||||
const base = appUrl.replace(/\/+$/, "");
|
||||
return idEvento ? `${base}/evento/${encodeURIComponent(idEvento)}` : base;
|
||||
}
|
||||
|
||||
/** Message-ID deterministico: lo stesso invio ripetuto porta lo stesso identificativo. */
|
||||
export function messageId(idNotifica: string, dominio: string): string {
|
||||
return `<${idNotifica}@${dominio}>`;
|
||||
}
|
||||
|
||||
const AVVISO_DISATTIVAZIONE = "Non vuoi più ricevere queste email? Profilo → Opzioni → Email.";
|
||||
|
||||
export function componiMail(
|
||||
n: NotificaEmail,
|
||||
opzioni: { appUrl: string; dominioMessageId: string },
|
||||
): MailComposta {
|
||||
const link = linkNotifica(opzioni.appUrl, n.id_evento);
|
||||
const subject = pulisciOggetto(n.oggetto);
|
||||
const testo = [n.testo.trim(), `Apri CrAPP: ${link}`, AVVISO_DISATTIVAZIONE]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const corpoHtml = n.testo.trim()
|
||||
? `<p style="margin:0 0 16px">${escapeHtml(n.testo.trim()).replace(/\n/g, "<br>")}</p>`
|
||||
: "";
|
||||
const html = [
|
||||
`<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:16px;line-height:1.5;color:#111;max-width:480px">`,
|
||||
`<h2 style="margin:0 0 12px;font-size:18px">${escapeHtml(subject)}</h2>`,
|
||||
corpoHtml,
|
||||
`<p style="margin:0 0 24px"><a href="${escapeHtml(link)}" style="display:inline-block;padding:12px 20px;border-radius:12px;background:#111;color:#fff;text-decoration:none">Apri CrAPP</a></p>`,
|
||||
`<p style="margin:0;font-size:12px;color:#666">${escapeHtml(AVVISO_DISATTIVAZIONE)}</p>`,
|
||||
`</div>`,
|
||||
].join("");
|
||||
return {
|
||||
to: n.destinatario,
|
||||
subject,
|
||||
text: testo,
|
||||
html,
|
||||
messageId: messageId(n.id_notifica, opzioni.dominioMessageId),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Salute del container ---------------------------------------------------------------------
|
||||
|
||||
/** Il worker è sano se l'ultimo ciclo riuscito non è più vecchio di `maxMs`. */
|
||||
export function staSano(ultimoCicloOk: number | null, adesso: number, maxMs: number): boolean {
|
||||
return ultimoCicloOk !== null && adesso - ultimoCicloOk <= maxMs;
|
||||
}
|
||||
|
||||
// --- Ciclo di invio ---------------------------------------------------------------------------
|
||||
|
||||
export type Dipendenze = {
|
||||
prendi: (max: number) => Promise<NotificaEmail[]>;
|
||||
segna: (id: string, esito: EsitoInvio, errore?: string, prossimo?: Date) => Promise<void>;
|
||||
invia: (mail: MailComposta) => Promise<void>;
|
||||
inviateUltime24h: () => Promise<number>;
|
||||
adesso: () => Date;
|
||||
log: (messaggio: string) => void;
|
||||
};
|
||||
|
||||
export type OpzioniCiclo = {
|
||||
limiteGiorno: number;
|
||||
lotto: number;
|
||||
appUrl: string;
|
||||
dominioMessageId: string;
|
||||
};
|
||||
|
||||
export type RisultatoCiclo = {
|
||||
inviate: number;
|
||||
riprovate: number;
|
||||
fallite: number;
|
||||
differite: number;
|
||||
/** Perché il ciclo si è interrotto prima di finire il lotto, se è successo. */
|
||||
fermato: "limite" | "configurazione" | null;
|
||||
};
|
||||
|
||||
const ATTESA_LIMITE_MINUTI = 60;
|
||||
const ATTESA_CONFIGURAZIONE_MINUTI = 15;
|
||||
|
||||
const dopoMinuti = (adesso: Date, minuti: number) => new Date(adesso.getTime() + minuti * 60_000);
|
||||
|
||||
/**
|
||||
* Un giro: prende fino a un lotto di mail entro il tetto giornaliero e le spedisce una a una.
|
||||
* I log riportano solo id, tipo ed esito: mai indirizzi né testi.
|
||||
*/
|
||||
export async function elaboraCiclo(d: Dipendenze, o: OpzioniCiclo): Promise<RisultatoCiclo> {
|
||||
const risultato: RisultatoCiclo = {
|
||||
inviate: 0,
|
||||
riprovate: 0,
|
||||
fallite: 0,
|
||||
differite: 0,
|
||||
fermato: null,
|
||||
};
|
||||
|
||||
const consentite = invioConsentiti(o.limiteGiorno, await d.inviateUltime24h(), o.lotto);
|
||||
if (consentite === 0) {
|
||||
risultato.fermato = "limite";
|
||||
return risultato;
|
||||
}
|
||||
|
||||
const lotto = await d.prendi(consentite);
|
||||
for (const [indice, notifica] of lotto.entries()) {
|
||||
const mail = componiMail(notifica, o);
|
||||
// Solo `invia` sta nel try: se fallisse la registrazione dell'esito non è un errore SMTP e
|
||||
// non deve far ripartire una mail già consegnata (la riga resta 'in_invio' e viene
|
||||
// recuperata dalla funzione SQL dopo 10 minuti: al peggio un doppione, mai una perdita).
|
||||
let errore: unknown;
|
||||
let consegnata = false;
|
||||
try {
|
||||
await d.invia(mail);
|
||||
consegnata = true;
|
||||
} catch (e) {
|
||||
errore = e;
|
||||
}
|
||||
|
||||
if (consegnata) {
|
||||
await d.segna(notifica.id_notifica, "inviata");
|
||||
risultato.inviate += 1;
|
||||
d.log(`inviata ${notifica.id_notifica} (${notifica.tipo_notifica})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tipo = classificaErroreSmtp(errore);
|
||||
const motivo = descriviErrore(errore);
|
||||
|
||||
if (tipo === "limite" || tipo === "configurazione") {
|
||||
// Non è colpa della mail: si rimanda questa e tutte le successive del lotto senza
|
||||
// consumare tentativi, e il ciclo si ferma.
|
||||
const prossimo = dopoMinuti(
|
||||
d.adesso(),
|
||||
tipo === "limite" ? ATTESA_LIMITE_MINUTI : ATTESA_CONFIGURAZIONE_MINUTI,
|
||||
);
|
||||
for (const restante of lotto.slice(indice)) {
|
||||
await d.segna(restante.id_notifica, "differita", motivo, prossimo);
|
||||
risultato.differite += 1;
|
||||
}
|
||||
risultato.fermato = tipo;
|
||||
d.log(`ciclo fermato (${tipo}, ${motivo}): ${risultato.differite} mail rimandate`);
|
||||
return risultato;
|
||||
}
|
||||
|
||||
const ritardo = tipo === "temporaneo" ? ritardoRiprova(notifica.tentativi_fatti) : null;
|
||||
if (ritardo === null) {
|
||||
await d.segna(notifica.id_notifica, "fallita", motivo);
|
||||
risultato.fallite += 1;
|
||||
d.log(`fallita ${notifica.id_notifica} (${notifica.tipo_notifica}, ${tipo}, ${motivo})`);
|
||||
} else {
|
||||
await d.segna(notifica.id_notifica, "riprova", motivo, dopoMinuti(d.adesso(), ritardo));
|
||||
risultato.riprovate += 1;
|
||||
d.log(`da riprovare ${notifica.id_notifica} tra ${ritardo} min (${motivo})`);
|
||||
}
|
||||
}
|
||||
return risultato;
|
||||
}
|
||||
|
||||
// --- Push dei promemoria (M24) ----------------------------------------------------------------
|
||||
|
||||
/** Un dispositivo iscritto alla push (riga di `push_subscriptions`). */
|
||||
export type IscrizionePush = { endpoint: string; p256dh: string; auth: string };
|
||||
|
||||
/** Riga restituita da `prendi_push_promemoria()`. */
|
||||
export type PushDaInviare = {
|
||||
id_notifica: string;
|
||||
oggetto: string;
|
||||
testo: string;
|
||||
tentativi_fatti: number;
|
||||
iscrizioni: IscrizionePush[];
|
||||
};
|
||||
|
||||
export type EsitoPush = "inviata" | "riprova" | "fallita" | "saltata";
|
||||
|
||||
export type EsitoStatoPush = "ok" | "scaduta" | "temporaneo" | "permanente";
|
||||
|
||||
/**
|
||||
* Cosa vuol dire la risposta del servizio push per quel dispositivo: 2xx accettata; 404 e 410
|
||||
* iscrizione scaduta (va eliminata); 429 e 5xx errore temporaneo; ogni altro 4xx (400, 401, 403,
|
||||
* 413) rifiuto permanente, tipicamente chiavi VAPID sbagliate o payload non valido.
|
||||
*/
|
||||
export function classificaStatoPush(stato: number): EsitoStatoPush {
|
||||
if (stato >= 200 && stato < 300) return "ok";
|
||||
if (stato === 404 || stato === 410) return "scaduta";
|
||||
if (stato === 429 || stato >= 500) return "temporaneo";
|
||||
return "permanente";
|
||||
}
|
||||
|
||||
export type DipendenzePush = {
|
||||
prendi: (max: number) => Promise<PushDaInviare[]>;
|
||||
segna: (id: string, esito: EsitoPush, errore?: string, prossimo?: Date) => Promise<void>;
|
||||
invia: (iscrizione: IscrizionePush, titolo: string, testo: string) => Promise<{ stato: number }>;
|
||||
elimina: (endpoint: string) => Promise<void>;
|
||||
adesso: () => Date;
|
||||
log: (messaggio: string) => void;
|
||||
};
|
||||
|
||||
export type RisultatoCicloPush = {
|
||||
inviate: number;
|
||||
riprovate: number;
|
||||
fallite: number;
|
||||
saltate: number;
|
||||
iscrizioniRimosse: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Un giro delle push dei promemoria: prende un lotto e manda ogni notifica a tutti i dispositivi
|
||||
* del giocatore. L'esito è uno solo per notifica: `inviata` se almeno un dispositivo ha accettato;
|
||||
* altrimenti `riprova` (con backoff, poi `fallita`) se qualcuno ha dato un errore temporaneo o è
|
||||
* fallita la rete; `fallita` se c'è stato solo un rifiuto permanente; `saltata` se le iscrizioni
|
||||
* erano tutte scadute. I log riportano solo id e numeri: mai gli endpoint, che identificano il
|
||||
* dispositivo.
|
||||
*/
|
||||
export async function elaboraCicloPush(
|
||||
d: DipendenzePush,
|
||||
opzioni: { lotto: number },
|
||||
): Promise<RisultatoCicloPush> {
|
||||
const risultato: RisultatoCicloPush = {
|
||||
inviate: 0,
|
||||
riprovate: 0,
|
||||
fallite: 0,
|
||||
saltate: 0,
|
||||
iscrizioniRimosse: 0,
|
||||
};
|
||||
|
||||
for (const push of await d.prendi(opzioni.lotto)) {
|
||||
let accettate = 0;
|
||||
let temporanei = 0;
|
||||
let permanenti = 0;
|
||||
const codici: string[] = [];
|
||||
|
||||
for (const iscrizione of push.iscrizioni) {
|
||||
let stato: number | null = null;
|
||||
try {
|
||||
stato = (await d.invia(iscrizione, push.oggetto, push.testo)).stato;
|
||||
} catch {
|
||||
temporanei += 1;
|
||||
codici.push("rete");
|
||||
continue;
|
||||
}
|
||||
const tipo = classificaStatoPush(stato);
|
||||
if (tipo === "ok") accettate += 1;
|
||||
else {
|
||||
codici.push(String(stato));
|
||||
if (tipo === "temporaneo") temporanei += 1;
|
||||
else if (tipo === "permanente") permanenti += 1;
|
||||
else {
|
||||
try {
|
||||
await d.elimina(iscrizione.endpoint);
|
||||
risultato.iscrizioniRimosse += 1;
|
||||
} catch {
|
||||
d.log(`iscrizione scaduta non eliminata (notifica ${push.id_notifica})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const motivo = codici.join(",") || undefined;
|
||||
if (accettate > 0) {
|
||||
await d.segna(push.id_notifica, "inviata");
|
||||
risultato.inviate += 1;
|
||||
d.log(
|
||||
`push inviata ${push.id_notifica} (${accettate}/${push.iscrizioni.length} dispositivi)`,
|
||||
);
|
||||
} else if (temporanei > 0) {
|
||||
const ritardo = ritardoRiprova(push.tentativi_fatti);
|
||||
if (ritardo === null) {
|
||||
await d.segna(push.id_notifica, "fallita", motivo);
|
||||
risultato.fallite += 1;
|
||||
d.log(`push fallita ${push.id_notifica} (tentativi finiti, ${motivo})`);
|
||||
} else {
|
||||
await d.segna(push.id_notifica, "riprova", motivo, dopoMinuti(d.adesso(), ritardo));
|
||||
risultato.riprovate += 1;
|
||||
d.log(`push da riprovare ${push.id_notifica} tra ${ritardo} min (${motivo})`);
|
||||
}
|
||||
} else if (permanenti > 0) {
|
||||
await d.segna(push.id_notifica, "fallita", motivo);
|
||||
risultato.fallite += 1;
|
||||
d.log(`push fallita ${push.id_notifica} (${motivo})`);
|
||||
} else {
|
||||
await d.segna(push.id_notifica, "saltata", motivo);
|
||||
risultato.saltate += 1;
|
||||
d.log(`push saltata ${push.id_notifica} (nessun dispositivo utile)`);
|
||||
}
|
||||
}
|
||||
return risultato;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Worker delle notifiche (M22 email, M24 push dei promemoria; DD-036, DD-038): legge le code su
|
||||
// Supabase e invia via SMTP e via Web Push. Solo chiamate in uscita, nessuna porta da aprire.
|
||||
// Uso: bun mailer/mailer.mjs. La logica sta in mailer-core.ts; qui c'è solo la colla con rete,
|
||||
// SMTP e il modulo push dell'app (src/lib/webpush.server.ts, lo stesso usato dalle route).
|
||||
import nodemailer from "nodemailer";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { inviaPush } from "../src/lib/webpush.server.ts";
|
||||
import { elaboraCiclo, elaboraCicloPush, leggiConfig } from "./mailer-core.ts";
|
||||
|
||||
const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok";
|
||||
|
||||
const lettura = leggiConfig(process.env);
|
||||
if ("errori" in lettura) {
|
||||
console.error(`Configurazione non valida:\n- ${lettura.errori.join("\n- ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { config } = lettura;
|
||||
|
||||
const log = (messaggio) => console.log(`${new Date().toISOString()} ${messaggio}`);
|
||||
|
||||
async function rpc(nome, corpo) {
|
||||
const risposta = await fetch(`${config.supabaseUrl}/rest/v1/rpc/${nome}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: config.serviceRoleKey,
|
||||
Authorization: `Bearer ${config.serviceRoleKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(corpo),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!risposta.ok) throw new Error(`rpc ${nome}: HTTP ${risposta.status}`);
|
||||
const testo = await risposta.text();
|
||||
return testo ? JSON.parse(testo) : null;
|
||||
}
|
||||
|
||||
/** Elimina un'iscrizione push scaduta (il servizio push ha risposto 404 o 410). */
|
||||
async function eliminaIscrizione(endpoint) {
|
||||
const risposta = await fetch(
|
||||
`${config.supabaseUrl}/rest/v1/push_subscriptions?endpoint=eq.${encodeURIComponent(endpoint)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
apikey: config.serviceRoleKey,
|
||||
Authorization: `Bearer ${config.serviceRoleKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
},
|
||||
);
|
||||
if (!risposta.ok) throw new Error(`delete push_subscriptions: HTTP ${risposta.status}`);
|
||||
}
|
||||
|
||||
const trasporto = nodemailer.createTransport({
|
||||
host: config.smtp.host,
|
||||
port: config.smtp.port,
|
||||
secure: config.smtp.secure,
|
||||
...(config.smtp.auth ? { auth: config.smtp.auth } : {}),
|
||||
});
|
||||
|
||||
const dominioMessageId = config.from.split("@")[1] ?? "crapp.local";
|
||||
|
||||
const dipendenze = {
|
||||
prendi: (max) => rpc("prendi_notifiche_email", { p_max: max }),
|
||||
segna: (id, esito, errore, prossimo) =>
|
||||
rpc("esito_notifica_email", {
|
||||
p_id: id,
|
||||
p_esito: esito,
|
||||
p_errore: errore ?? null,
|
||||
p_prossimo: prossimo ? prossimo.toISOString() : null,
|
||||
}),
|
||||
invia: (mail) =>
|
||||
trasporto.sendMail({
|
||||
from: { name: config.fromName, address: config.from },
|
||||
to: mail.to,
|
||||
subject: mail.subject,
|
||||
text: mail.text,
|
||||
html: mail.html,
|
||||
messageId: mail.messageId,
|
||||
}),
|
||||
inviateUltime24h: () => rpc("email_inviate_ultime_24h", {}),
|
||||
adesso: () => new Date(),
|
||||
log,
|
||||
};
|
||||
|
||||
const dipendenzePush = {
|
||||
prendi: (max) => rpc("prendi_push_promemoria", { p_max: max }),
|
||||
segna: (id, esito, errore, prossimo) =>
|
||||
rpc("esito_push_promemoria", {
|
||||
p_id: id,
|
||||
p_esito: esito,
|
||||
p_errore: errore ?? null,
|
||||
p_prossimo: prossimo ? prossimo.toISOString() : null,
|
||||
}),
|
||||
invia: (iscrizione, titolo, testo) => inviaPush(iscrizione, titolo, testo),
|
||||
elimina: eliminaIscrizione,
|
||||
adesso: () => new Date(),
|
||||
log,
|
||||
};
|
||||
|
||||
const opzioni = {
|
||||
limiteGiorno: config.limiteGiorno,
|
||||
lotto: config.lotto,
|
||||
appUrl: config.appUrl,
|
||||
dominioMessageId,
|
||||
};
|
||||
|
||||
let fermo = false;
|
||||
for (const segnale of ["SIGTERM", "SIGINT"]) process.on(segnale, () => (fermo = true));
|
||||
const attendi = (ms) => new Promise((ok) => setTimeout(ok, ms));
|
||||
|
||||
log(`avviato: SMTP ${config.smtp.host}:${config.smtp.port}, ogni ${config.pollSecondi}s`);
|
||||
log(
|
||||
config.vapid
|
||||
? "push dei promemoria: attiva"
|
||||
: "push dei promemoria: DISATTIVATA (VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY assenti), solo email",
|
||||
);
|
||||
trasporto.verify().then(
|
||||
() => log("SMTP raggiungibile e credenziali accettate"),
|
||||
(errore) => log(`SMTP non verificato (${errore?.code ?? "errore"}): controlla SMTP_* nel .env`),
|
||||
);
|
||||
|
||||
while (!fermo) {
|
||||
try {
|
||||
const esito = await elaboraCiclo(dipendenze, opzioni);
|
||||
// Un ciclo fermato dalle credenziali SMTP non segna il container come sano: dopo qualche
|
||||
// minuto `docker ps` lo mostra "unhealthy" invece di lasciar credere che tutto funzioni.
|
||||
if (esito.fermato !== "configurazione") await writeFile(FILE_SALUTE, String(Date.now()));
|
||||
} catch (errore) {
|
||||
console.error(`${new Date().toISOString()} ciclo fallito: ${errore?.message ?? errore}`);
|
||||
}
|
||||
if (config.vapid) {
|
||||
// Un errore delle push non deve fermare le email né il segnale di salute del worker.
|
||||
try {
|
||||
await elaboraCicloPush(dipendenzePush, { lotto: config.lotto });
|
||||
} catch (errore) {
|
||||
console.error(`${new Date().toISOString()} ciclo push fallito: ${errore?.message ?? errore}`);
|
||||
}
|
||||
}
|
||||
await attendi(config.pollSecondi * 1000);
|
||||
}
|
||||
log("arrestato");
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "crapp-mailer",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Worker delle notifiche email di CrAPP (M22, DD-036)",
|
||||
"scripts": {
|
||||
"start": "bun mailer.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"nodemailer": "^10.0.11"
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "0.9.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
@@ -10,6 +10,7 @@
|
||||
"build:dev": "vite build --mode development",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"test": "bun test/run.ts",
|
||||
"test:integration": "bun test/run.ts integration",
|
||||
@@ -17,7 +18,6 @@
|
||||
"test:all": "bun test/run.ts all"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lovable.dev/cloud-auth-js": "^1.1.2",
|
||||
"@supabase/supabase-js": "^2.111.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.101.1",
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@lovable.dev/vite-tanstack-config": "2.8.5",
|
||||
"@tanstack/devtools-vite": "^0.8.3",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^19.2.0",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useProfili } from "@/lib/profili";
|
||||
import {
|
||||
avvisiCertificati,
|
||||
formatDataBreve,
|
||||
testoScadenza,
|
||||
type AvvisoCertificato,
|
||||
} from "@/lib/profili-core";
|
||||
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { oggiISO } from "@/lib/palloni-core";
|
||||
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
|
||||
function Card({
|
||||
colore,
|
||||
titolo,
|
||||
righe,
|
||||
cliccabile,
|
||||
}: {
|
||||
colore: "warning" | "primary";
|
||||
titolo: string;
|
||||
righe: string[];
|
||||
cliccabile?: boolean;
|
||||
}) {
|
||||
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
|
||||
colore === "warning"
|
||||
? "bg-warning text-warning-foreground"
|
||||
: "bg-primary text-primary-foreground"
|
||||
} ${cliccabile ? "premi" : ""}`;
|
||||
const contenuto = (
|
||||
<>
|
||||
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
|
||||
<AlertCircle className="h-5 w-5" /> {titolo}
|
||||
</p>
|
||||
{righe.map((r) => (
|
||||
<p key={r} className="mt-2 text-xs leading-snug opacity-90">
|
||||
{r}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
return cliccabile ? (
|
||||
<Link to="/profilo" search={{ tab: "documenti" }} className={classi}>
|
||||
{contenuto}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={classi}>{contenuto}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Avviso certificati in scadenza/scaduti (DD-035): calcolato al volo, niente push. */
|
||||
export function AvvisoCertificati() {
|
||||
const admin = useIsAdmin();
|
||||
const ruoliPronti = useRuoliPronti();
|
||||
const base = useGiocatoreBase();
|
||||
const { righe: rosa } = useGiocatoriSquadra();
|
||||
const { profili, isPending } = useProfili();
|
||||
|
||||
if (!ruoliPronti || isPending) return null;
|
||||
|
||||
const oggi = oggiISO();
|
||||
|
||||
if (admin) {
|
||||
const { scaduti, inScadenza } = avvisiCertificati(rosa, profili, oggi);
|
||||
if (scaduti.length === 0 && inScadenza.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{scaduti.length > 0 ? (
|
||||
<Card
|
||||
colore="primary"
|
||||
titolo="Certificati scaduti"
|
||||
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
|
||||
/>
|
||||
) : null}
|
||||
{inScadenza.length > 0 ? (
|
||||
<Card
|
||||
colore="warning"
|
||||
titolo="Certificati in scadenza"
|
||||
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!base) return null;
|
||||
const { scaduti, inScadenza } = avvisiCertificati([base], profili, oggi);
|
||||
const mio = scaduti[0] ?? inScadenza[0];
|
||||
if (!mio) return null;
|
||||
|
||||
return mio.giorni < 0 ? (
|
||||
<Card
|
||||
colore="primary"
|
||||
titolo="Certificato scaduto"
|
||||
cliccabile
|
||||
righe={[
|
||||
`Il tuo certificato medico è scaduto il ${formatDataBreve(mio.scadenza)}: caricane uno nuovo`,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Card
|
||||
colore="warning"
|
||||
titolo="Certificato in scadenza"
|
||||
cliccabile
|
||||
righe={[`Il tuo certificato medico ${testoScadenza(mio.giorni)}`]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
|
||||
return `${a.nome} ${a.cognome} — ${dettaglio}`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { motion, type Transition } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { proietta } from "@/lib/molla";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
@@ -13,7 +13,7 @@ export type VoceSottosezione = {
|
||||
};
|
||||
|
||||
/** Tween breve: evita molle + exit che tengono due pannelli in DOM insieme. */
|
||||
const transizioneTab = { type: "tween" as const, duration: 0.16, ease: [0.25, 0.1, 0.25, 1] };
|
||||
const transizioneTab: Transition = { type: "tween", duration: 0.16, ease: [0.25, 0.1, 0.25, 1] };
|
||||
|
||||
/**
|
||||
* Barra di sottosezioni in un'unica fila e pannello che mostra una sola sezione
|
||||
@@ -116,11 +116,7 @@ export function BarraSottosezioni({
|
||||
}}
|
||||
className={cn(
|
||||
"min-h-11 touch-manipulation whitespace-nowrap text-sm font-bold uppercase tracking-wide transition-colors",
|
||||
riempiLarghezza
|
||||
? "min-w-0 flex-1"
|
||||
: sottolineatura
|
||||
? "shrink-0"
|
||||
: "w-full",
|
||||
riempiLarghezza ? "min-w-0 flex-1" : sottolineatura ? "shrink-0" : "w-full",
|
||||
sottolineatura
|
||||
? cn(
|
||||
"rounded-xl px-1 py-2.5 text-center",
|
||||
@@ -157,6 +153,10 @@ export function BarraSottosezioni({
|
||||
dragElastic={0.12}
|
||||
dragMomentum={false}
|
||||
onDragEnd={(_, info) => {
|
||||
// Uno scroll verticale (anche veloce) ha sempre offset.y ben maggiore di
|
||||
// offset.x: la velocity.x, invece, può essere rumorosa su gesti rapidi e da
|
||||
// sola non basta a distinguere uno swipe reale da un semplice scroll in giù.
|
||||
if (Math.abs(info.offset.x) <= Math.abs(info.offset.y)) return;
|
||||
const arrivo = info.offset.x + proietta(info.velocity.x);
|
||||
if (arrivo < -60) vaiA(indice + 1);
|
||||
else if (arrivo > 60) vaiA(indice - 1);
|
||||
|
||||
@@ -14,9 +14,9 @@ import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { dataOggi } from "@/lib/scout-live";
|
||||
|
||||
const tipoMeta = {
|
||||
@@ -26,9 +26,7 @@ const tipoMeta = {
|
||||
compleanno: { label: "Compleanno", className: "bg-success text-success-foreground" },
|
||||
} as const;
|
||||
|
||||
const statiSportivi: Stato[] = ["presente", "forse", "ritardo", "assente", "infortunato"];
|
||||
/** Eventi extra-campo (pizze, uscite…): l'infortunio non è una risposta pertinente. */
|
||||
const statiEventoExtra: Stato[] = ["presente", "forse", "ritardo", "assente"];
|
||||
const statiBase: Stato[] = ["presente", "forse", "ritardo", "assente"];
|
||||
|
||||
/** Icone compatte per la riga unica dei controlli presenza (mockup). */
|
||||
const iconeStato: Record<Stato, typeof Check> = {
|
||||
@@ -51,6 +49,9 @@ export function linkPerEvento(e: Evento) {
|
||||
label: "Dettaglio allenamento",
|
||||
};
|
||||
}
|
||||
if (e.tipo === "evento") {
|
||||
return { to: "/evento/$id" as const, params: { id: e.id }, label: "Dettaglio evento" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -69,12 +70,12 @@ export function EventoCard({
|
||||
}) {
|
||||
const { risposte } = usePresenzeEvento(evento.id);
|
||||
const salva = useSalvaPresenza();
|
||||
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta): `useGiocatoreBase`
|
||||
// legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
|
||||
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta, `null` per l'allenatore
|
||||
// che non risponde, DD-034): `useGiocatoreInCampo` legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
|
||||
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
|
||||
const io = useGiocatoreBase();
|
||||
const io = useGiocatoreInCampo();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const rosa = squadra.filter(inRosa);
|
||||
const stato = io ? risposte[io.id] : undefined;
|
||||
const presentiVeri = rosa.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
@@ -87,15 +88,11 @@ export function EventoCard({
|
||||
const isCompleanno = evento.tipo === "compleanno";
|
||||
const passato = evento.data < dataOggi();
|
||||
const cliccabile = Boolean(linkTo);
|
||||
/** Solo gli eventi extra-campo non hanno scheda dedicata: le note restano sulla card. */
|
||||
/** Anteprima delle note anche in card per gli eventi extra-campo (oltre al dettaglio). */
|
||||
const noteCard = evento.tipo === "evento" ? evento.note.trim() : "";
|
||||
const stati =
|
||||
evento.tipo === "evento"
|
||||
? // Se resta un vecchio "infortunato", mostra il bottone solo per poterlo togliere.
|
||||
stato === "infortunato"
|
||||
? [...statiEventoExtra, "infortunato" as const]
|
||||
: statiEventoExtra
|
||||
: statiSportivi;
|
||||
const stati = includeInfortunato(evento.tipo, stato)
|
||||
? [...statiBase, "infortunato" as const]
|
||||
: statiBase;
|
||||
|
||||
if (isCompleanno) {
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import type { Giocatore } from "@/lib/crapp-data";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
|
||||
|
||||
const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
@@ -20,8 +20,8 @@ export function Pagelle({
|
||||
convocati: Giocatore[];
|
||||
chiuse?: boolean;
|
||||
}) {
|
||||
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||
const io = useGiocatoreBase();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const { voti: tutti, isPending } = usePagelle();
|
||||
const vota = useVotaPagella();
|
||||
const [apertoPer, setApertoPer] = useState<string | null>(null);
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
useSalvaProfilo,
|
||||
type SezioneFile,
|
||||
} from "@/lib/profili";
|
||||
import { useSalvaNomeAllenatore, type GiocatoreSquadra } from "@/lib/giocatori-squadra";
|
||||
import {
|
||||
completamento,
|
||||
completamentoAllenatore,
|
||||
profiloVuoto,
|
||||
sezioniComplete,
|
||||
type Profilo,
|
||||
@@ -244,6 +246,138 @@ export function CampiProfilo({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* I dati personali dell'allenatore (DD-034): gli stessi campi di `profili_giocatore`, meno
|
||||
* indirizzo, documento, certificato e foto tessera. Condivisi tra Profilo e dashboard admin.
|
||||
*/
|
||||
export function CampiAllenatore({
|
||||
corrente,
|
||||
aggiorna,
|
||||
}: {
|
||||
corrente: Profilo;
|
||||
aggiorna: (patch: Partial<Profilo>) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Data di nascita">
|
||||
<input
|
||||
type="date"
|
||||
value={corrente.dataNascita ?? ""}
|
||||
onChange={(e) => aggiorna({ dataNascita: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Luogo di nascita">
|
||||
<input
|
||||
value={corrente.luogoNascita ?? ""}
|
||||
maxLength={80}
|
||||
onChange={(e) => aggiorna({ luogoNascita: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Telefono">
|
||||
<input
|
||||
type="tel"
|
||||
value={corrente.telefono ?? ""}
|
||||
maxLength={20}
|
||||
onChange={(e) => aggiorna({ telefono: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Email">
|
||||
<input
|
||||
type="email"
|
||||
value={corrente.email ?? ""}
|
||||
maxLength={120}
|
||||
onChange={(e) => aggiorna({ email: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab Docs dell'allenatore: nome e cognome (sul proprio slot, M21) più i dati personali
|
||||
* ridotti. Un solo pulsante salva entrambi.
|
||||
*/
|
||||
export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
|
||||
const { profili } = useProfili();
|
||||
const salvaProfilo = useSalvaProfilo();
|
||||
const salvaNome = useSalvaNomeAllenatore();
|
||||
const [bozza, setBozza] = useState<Profilo | null>(null);
|
||||
const [nome, setNome] = useState<{ nome: string; cognome: string } | null>(null);
|
||||
|
||||
const corrente = bozza ?? profili[g.id] ?? profiloVuoto(g.id);
|
||||
const nomeCorrente = nome ?? { nome: g.nome, cognome: g.cognome };
|
||||
const sporco = bozza !== null || nome !== null;
|
||||
const inCorso = salvaProfilo.isPending || salvaNome.isPending;
|
||||
const perc = completamentoAllenatore(corrente);
|
||||
|
||||
async function salva() {
|
||||
try {
|
||||
if (nome) await salvaNome.mutateAsync({ giocatoreId: g.id, ...nome });
|
||||
if (bozza) await salvaProfilo.mutateAsync(bozza);
|
||||
setNome(null);
|
||||
setBozza(null);
|
||||
toast.success("Profilo aggiornato");
|
||||
} catch (errore) {
|
||||
toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-grad transition-all"
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
|
||||
{perc}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Intestazione titolo="Dati personali" completa={perc === 100} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Nome">
|
||||
<input
|
||||
value={nomeCorrente.nome}
|
||||
maxLength={40}
|
||||
onChange={(e) => setNome({ ...nomeCorrente, nome: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Cognome">
|
||||
<input
|
||||
value={nomeCorrente.cognome}
|
||||
maxLength={40}
|
||||
onChange={(e) => setNome({ ...nomeCorrente, cognome: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<CampiAllenatore corrente={corrente} aggiorna={(p) => setBozza({ ...corrente, ...p })} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void salva()}
|
||||
disabled={!sporco || inCorso}
|
||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{sporco ? "Salva" : "Salvato"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dati amministrativi del giocatore: quello che la dashboard amministratore poi legge.
|
||||
* Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente.
|
||||
@@ -304,10 +438,6 @@ export function ProfiloAmministrativo({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro.
|
||||
</p>
|
||||
|
||||
<CampiProfilo
|
||||
corrente={corrente}
|
||||
aggiorna={aggiorna}
|
||||
@@ -384,13 +514,18 @@ export function ProfiloAmministrativo({
|
||||
*/
|
||||
export function CompletaProfilo({
|
||||
giocatoreId,
|
||||
allenatore = false,
|
||||
indice = 0,
|
||||
}: {
|
||||
giocatoreId: string;
|
||||
/** L'allenatore ha solo i dati personali ridotti (DD-034). */
|
||||
allenatore?: boolean;
|
||||
indice?: number;
|
||||
}) {
|
||||
const { profili, isPending } = useProfili();
|
||||
const perc = completamento(profili[giocatoreId]);
|
||||
const perc = allenatore
|
||||
? completamentoAllenatore(profili[giocatoreId])
|
||||
: completamento(profili[giocatoreId]);
|
||||
if (isPending || perc === 100) return null;
|
||||
|
||||
return (
|
||||
@@ -413,7 +548,9 @@ export function CompletaProfilo({
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
Documento, certificato medico e foto tessera servono per il tesseramento CSI.
|
||||
{allenatore
|
||||
? "Data e luogo di nascita, telefono ed email."
|
||||
: "Documento, certificato medico e foto tessera servono per il tesseramento CSI."}
|
||||
</p>
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { formatData } from "@/lib/crapp-data";
|
||||
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
|
||||
import { useTurniPalloni } from "@/lib/palloni";
|
||||
import { useEventi } from "@/lib/eventi";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
|
||||
/** Avvisi per chi è di turno: prendere i palloni oggi, o riportarli oggi. */
|
||||
export function PromemoriaPalloni() {
|
||||
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. Montato in Home.
|
||||
const io = useGiocatoreBase();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). Montato in Home.
|
||||
const io = useGiocatoreInCampo();
|
||||
const { turni } = useTurniPalloni();
|
||||
const { eventi } = useEventi();
|
||||
if (!io) return null;
|
||||
|
||||
@@ -6,22 +6,34 @@ import { Card } from "@/components/crapp/ui-bits";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||
import type { EventoTipo } from "@/lib/eventi";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { intestazioniAutenticate } from "@/lib/auth";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { usePuoGestireEventi } from "@/lib/ruoli";
|
||||
import { dataOggi } from "@/lib/scout-live";
|
||||
|
||||
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
|
||||
|
||||
export function RosaPresenze({ eventoId, data }: { eventoId: string; data: string }) {
|
||||
export function RosaPresenze({
|
||||
eventoId,
|
||||
data,
|
||||
tipo = "allenamento",
|
||||
}: {
|
||||
eventoId: string;
|
||||
data: string;
|
||||
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
|
||||
tipo?: EventoTipo;
|
||||
}) {
|
||||
const { risposte, isPending } = usePresenzeEvento(eventoId);
|
||||
const salva = useSalvaPresenza();
|
||||
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
|
||||
// niente statistiche di squadra.
|
||||
const io = useGiocatoreBase();
|
||||
const admin = useIsAdmin();
|
||||
const io = useGiocatoreInCampo();
|
||||
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
|
||||
const mittente = useGiocatoreBase();
|
||||
const puoSollecitare = usePuoGestireEventi();
|
||||
const rosa = useAnagraficaRosa();
|
||||
const [sollecito, setSollecito] = useState(false);
|
||||
const passato = data < dataOggi();
|
||||
@@ -30,6 +42,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
const risposteN = rosa.length - mancanti.length;
|
||||
const perc = rosa.length ? Math.round((risposteN / rosa.length) * 100) : 0;
|
||||
const daSollecitare = mancanti.length + rosa.filter((g) => risposte[g.id] === "forse").length;
|
||||
const ordineVisibile = tipo === "evento" ? ordine.filter((s) => s !== "infortunato") : ordine;
|
||||
|
||||
async function sollecita() {
|
||||
setSollecito(true);
|
||||
@@ -37,7 +50,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
const res = await fetch("/api/public/sollecita-presenze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
|
||||
body: JSON.stringify({ eventoId, da: io?.nome }),
|
||||
body: JSON.stringify({ eventoId, da: mittente?.nome }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const dati = (await res.json()) as { inviate: number; destinatari: number };
|
||||
@@ -65,7 +78,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-2" />
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{ordine.map((s) => {
|
||||
{ordineVisibile.map((s) => {
|
||||
const n = rosa.filter((g) => risposte[g.id] === s).length;
|
||||
return (
|
||||
<span
|
||||
@@ -90,7 +103,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
La tua risposta
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{ordine.map((s) => {
|
||||
{ordineVisibile.map((s) => {
|
||||
const attivo = risposte[io.id] === s;
|
||||
return (
|
||||
<button
|
||||
@@ -115,7 +128,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{admin ? (
|
||||
{puoSollecitare ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sollecita}
|
||||
@@ -138,7 +151,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{ordine.map((s) => {
|
||||
{ordineVisibile.map((s) => {
|
||||
const lista = rosa.filter((g) => risposte[g.id] === s);
|
||||
if (lista.length === 0) return null;
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { intestazioniAutenticate } from "@/lib/auth";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import {
|
||||
@@ -27,8 +27,8 @@ export function SondaggioCacche({
|
||||
dataEvento: string;
|
||||
oraEvento: string;
|
||||
}) {
|
||||
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||
const io = useGiocatoreBase();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const { righe } = useCacche();
|
||||
const salva = useSalvaCacche();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
@@ -69,7 +69,7 @@ export function SondaggioCacche({
|
||||
toast.success(
|
||||
dati.inviate > 0
|
||||
? `Notifica inviata a ${dati.inviate} dispositivi`
|
||||
: "Nessun dispositivo con le notifiche attive",
|
||||
: "Avviso inviato in app e per email (nessun dispositivo con le notifiche attive)",
|
||||
);
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a inviare la notifica");
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
@@ -18,7 +18,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
const io = useGiocatoreBase();
|
||||
const admin = useIsAdmin();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const rosa = squadra.filter(inRosa);
|
||||
|
||||
const id = turni[eventoId];
|
||||
const proposto = !salvati[eventoId];
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import { Crown, Vote } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { usePresenzeEvento } from "@/lib/presenze";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import {
|
||||
@@ -25,14 +25,14 @@ import {
|
||||
*/
|
||||
export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
const matchId = evento.id;
|
||||
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||
const io = useGiocatoreBase();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const voti = useVotiMvp();
|
||||
const vota = useVotaMvp();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const { risposte } = usePresenzeEvento(evento.id);
|
||||
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
|
||||
const rosa = squadra.filter((g) => g.attivo && presente(g.id));
|
||||
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
|
||||
const [aperto, setAperto] = useState(false);
|
||||
|
||||
const tutti: VotoMvp[] = voti.data ?? [];
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import { Check, Crown, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import {
|
||||
categorieSocial,
|
||||
conteggioCategoria,
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
|
||||
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */
|
||||
export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||
const io = useGiocatoreBase();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const voti = useVotiSocial();
|
||||
const vota = useVotaSocial();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const rosa = squadra.filter(inRosa);
|
||||
const [aperta, setAperta] = useState<string | null>(null);
|
||||
|
||||
const tutti = voti.data ?? [];
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { inizialiDa, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import { nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
import { molla, proietta } from "@/lib/molla";
|
||||
import {
|
||||
pallinoNotifiche,
|
||||
useEliminaNotifica,
|
||||
useNotificheMie,
|
||||
useSegnaLette,
|
||||
type NotificaUtente,
|
||||
} from "@/lib/notifiche-utente";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { Numero } from "@/components/motion/Numero";
|
||||
@@ -48,6 +65,68 @@ export function Card({
|
||||
return <Tag className={cn("premi rounded-3xl bg-card p-4 shadow-card", className)} {...props} />;
|
||||
}
|
||||
|
||||
/** "or ora" / "tra 3 ore" / "2 giorni fa" — così basta guardare senza fare i conti. */
|
||||
function tempoRelativo(dataIso: string): string {
|
||||
const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" });
|
||||
const minuti = Math.round((new Date(dataIso).getTime() - Date.now()) / 60_000);
|
||||
if (Math.abs(minuti) < 60) return rtf.format(minuti, "minute");
|
||||
const ore = Math.round(minuti / 60);
|
||||
if (Math.abs(ore) < 24) return rtf.format(ore, "hour");
|
||||
return rtf.format(Math.round(ore / 24), "day");
|
||||
}
|
||||
|
||||
/**
|
||||
* Una riga del pannello notifiche, eliminabile con uno swipe a sinistra o con la ×: non
|
||||
* c'è pulizia automatica delle notifiche vecchie (M17), quindi è l'unico modo per un
|
||||
* giocatore di togliersele di torno. Soglia e proiezione della velocità di rilascio come
|
||||
* lo swipe del calendario (`src/routes/calendario.tsx`), ma qui in una sola direzione: non
|
||||
* c'è "indietro", solo "via".
|
||||
*/
|
||||
function RigaNotifica({
|
||||
notifica,
|
||||
onElimina,
|
||||
}: {
|
||||
notifica: NotificaUtente;
|
||||
onElimina: (id: string) => void;
|
||||
}) {
|
||||
const ridotto = useMotoRidotto();
|
||||
return (
|
||||
<motion.li
|
||||
layout={!ridotto}
|
||||
initial={false}
|
||||
exit={{ opacity: 0, height: 0, marginTop: 0, marginBottom: 0 }}
|
||||
transition={molla.ui}
|
||||
className="relative overflow-hidden rounded-xl"
|
||||
>
|
||||
<motion.div
|
||||
drag={ridotto ? false : "x"}
|
||||
dragConstraints={{ left: -80, right: 0 }}
|
||||
dragElastic={{ left: 0.2, right: 0 }}
|
||||
dragMomentum={false}
|
||||
onDragEnd={(_, info) => {
|
||||
const arrivo = info.offset.x + proietta(info.velocity.x);
|
||||
if (arrivo < -60) onElimina(notifica.id);
|
||||
}}
|
||||
className="touch-pan-y bg-card p-3 pr-8 text-sm"
|
||||
>
|
||||
<p className="font-semibold">{notifica.titolo}</p>
|
||||
{notifica.corpo ? (
|
||||
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
|
||||
</motion.div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onElimina(notifica.id)}
|
||||
aria-label="Rimuovi notifica"
|
||||
className="absolute right-1 top-1 rounded-full p-1.5 text-muted-foreground active:scale-90"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è
|
||||
* l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a
|
||||
@@ -57,18 +136,105 @@ export function Card({
|
||||
* iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle,
|
||||
* cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni
|
||||
* pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte.
|
||||
*
|
||||
* Sopra l'avatar sta il pallino del centro notifiche (M17): il tap sull'avatar
|
||||
* porta sempre al profilo, quello sul pallino apre il pannello.
|
||||
*/
|
||||
export function LinkProfilo() {
|
||||
const g = useGiocatoreBase();
|
||||
if (!g) return <TeamLogo className="h-12 w-12" />;
|
||||
return (
|
||||
<Link
|
||||
to="/profilo"
|
||||
aria-label="Il tuo profilo"
|
||||
className="premi shrink-0 rounded-2xl ring-2 ring-primary-foreground/30"
|
||||
>
|
||||
<Avatar id={g.id} fallback={inizialiDa(nomeCompleto(g))} className="h-12 w-12 text-lg" />
|
||||
</Link>
|
||||
<div className="relative shrink-0">
|
||||
<Link
|
||||
to="/profilo"
|
||||
aria-label="Il tuo profilo"
|
||||
className="premi block rounded-2xl ring-2 ring-primary-foreground/30"
|
||||
>
|
||||
<Avatar id={g.id} fallback={inizialiDa(nomeCompleto(g))} className="h-12 w-12 text-lg" />
|
||||
</Link>
|
||||
<PallinoNotifiche />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pallino del centro notifiche (M17) sull'angolo dell'avatar (aspetto deciso da
|
||||
* `pallinoNotifiche()`). Copre quattro sorgenti generate lato database (mai dal client):
|
||||
* messaggi admin, promemoria automatici, turno palloni, sollecito presenze — vedi
|
||||
* `src/lib/notifiche-utente.ts`.
|
||||
*
|
||||
* Nel progetto non c'è una libreria dropdown: il pannello è un `div` posizionato a mano,
|
||||
* chiuso al click fuori o con Escape. Aprirlo segna tutte le notifiche come lette; per
|
||||
* toglierle di mezzo per sempre serve lo swipe o la × di `RigaNotifica`.
|
||||
*/
|
||||
function PallinoNotifiche() {
|
||||
const { notifiche, nonLette } = useNotificheMie();
|
||||
const segnaLette = useSegnaLette();
|
||||
const eliminaNotifica = useEliminaNotifica();
|
||||
const [aperto, setAperto] = useState(false);
|
||||
const riquadro = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aperto) return;
|
||||
const chiudiSeFuori = (e: MouseEvent) => {
|
||||
if (riquadro.current && !riquadro.current.contains(e.target as Node)) setAperto(false);
|
||||
};
|
||||
const chiudiConEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setAperto(false);
|
||||
};
|
||||
document.addEventListener("mousedown", chiudiSeFuori);
|
||||
document.addEventListener("keydown", chiudiConEsc);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", chiudiSeFuori);
|
||||
document.removeEventListener("keydown", chiudiConEsc);
|
||||
};
|
||||
}, [aperto]);
|
||||
|
||||
const pallino = pallinoNotifiche(notifiche.length, nonLette);
|
||||
if (!pallino) return null;
|
||||
|
||||
function alClick() {
|
||||
const stavaChiuso = !aperto;
|
||||
setAperto(stavaChiuso);
|
||||
if (stavaChiuso && nonLette > 0) segnaLette.mutate();
|
||||
}
|
||||
|
||||
// Tolta l'ultima, il pallino sparisce: chiuso anche il pannello, così alla prossima
|
||||
// notifica non si riapre da solo.
|
||||
function elimina(id: string) {
|
||||
if (notifiche.length === 1) setAperto(false);
|
||||
eliminaNotifica.mutate(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={riquadro}>
|
||||
{/* `after:` allarga l'area di tocco oltre i 20px del pallino senza ingrandirlo. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={alClick}
|
||||
aria-label={nonLette > 0 ? `Notifiche, ${nonLette} da leggere` : "Notifiche"}
|
||||
aria-expanded={aperto}
|
||||
className={cn(
|
||||
"absolute -right-1.5 -top-1.5 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[11px] font-bold shadow-pop ring-2 ring-primary-foreground after:absolute after:-inset-3 active:scale-90",
|
||||
pallino.daLeggere
|
||||
? "bg-accent-grad text-accent-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{pallino.testo}
|
||||
</button>
|
||||
{aperto ? (
|
||||
<div className="absolute right-0 top-14 z-50 w-72 max-w-[calc(100vw-2.5rem)] rounded-2xl bg-card p-2 text-foreground shadow-card">
|
||||
<ul className="max-h-80 overflow-y-auto">
|
||||
<AnimatePresence initial={false}>
|
||||
{notifiche.map((n) => (
|
||||
<RigaNotifica key={n.id} notifica={n} onElimina={elimina} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// This file is auto-generated by Lovable. Do not modify it.
|
||||
|
||||
import { createLovableAuth } from "@lovable.dev/cloud-auth-js";
|
||||
import { supabase } from "../supabase/client";
|
||||
const lovableAuth = createLovableAuth();
|
||||
|
||||
type SignInOptions = {
|
||||
redirect_uri?: string;
|
||||
extraParams?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const lovable = {
|
||||
auth: {
|
||||
signInWithOAuth: async (
|
||||
provider: "google" | "apple" | "microsoft" | "lovable",
|
||||
opts?: SignInOptions,
|
||||
) => {
|
||||
const result = await lovableAuth.signInWithOAuth(provider, {
|
||||
redirect_uri: opts?.redirect_uri ?? window.location.origin,
|
||||
extraParams: {
|
||||
...opts?.extraParams,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.redirected) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
await supabase.auth.setSession(result.tokens);
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e : new Error(String(e)) };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -637,7 +637,7 @@ export type Database = {
|
||||
};
|
||||
};
|
||||
Enums: {
|
||||
app_role: "admin" | "user";
|
||||
app_role: "admin" | "user" | "allenatore";
|
||||
};
|
||||
CompositeTypes: {
|
||||
[_ in never]: never;
|
||||
@@ -762,7 +762,7 @@ export const Constants = {
|
||||
},
|
||||
public: {
|
||||
Enums: {
|
||||
app_role: ["admin", "user"],
|
||||
app_role: ["admin", "user", "allenatore"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -7,9 +7,9 @@
|
||||
* è 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ò
|
||||
* Le route che mandano notifiche partono da un pulsante riservato agli amministratori, e il
|
||||
* sollecito presenze anche agli allenatori (DD-034): `richiediAdmin` e `richiediGestoreEventi`
|
||||
* verificano 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.
|
||||
*/
|
||||
|
||||
@@ -24,10 +24,14 @@ function tokenDaRichiesta(request: Request): string | 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.
|
||||
* Lascia passare solo un utente autenticato con almeno uno dei `ruoli`. `401` se manca o non
|
||||
* vale il token, `403` se il token è buono ma il ruolo no.
|
||||
*/
|
||||
export async function richiediAdmin(request: Request): Promise<Response | null> {
|
||||
async function richiediRuolo(
|
||||
request: Request,
|
||||
ruoli: Array<"admin" | "allenatore">,
|
||||
rifiuto: string,
|
||||
): Promise<Response | null> {
|
||||
const token = tokenDaRichiesta(request);
|
||||
if (!token) return new Response("Autenticazione richiesta", { status: 401 });
|
||||
|
||||
@@ -37,13 +41,22 @@ export async function richiediAdmin(request: Request): Promise<Response | null>
|
||||
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
|
||||
const { data: righe } = await supabaseAdmin
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", utente.user.id)
|
||||
.eq("role", "admin")
|
||||
.maybeSingle();
|
||||
.in("role", ruoli);
|
||||
|
||||
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
|
||||
if (!righe?.length) return new Response(rifiuto, { status: 403 });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Solo un amministratore: promemoria palloni, messaggi, sondaggio, notifiche attive. */
|
||||
export function richiediAdmin(request: Request): Promise<Response | null> {
|
||||
return richiediRuolo(request, ["admin"], "Riservato agli amministratori");
|
||||
}
|
||||
|
||||
/** Admin o allenatore: chi gestisce gli eventi può anche sollecitare le presenze (DD-034). */
|
||||
export function richiediGestoreEventi(request: Request): Promise<Response | null> {
|
||||
return richiediRuolo(request, ["admin", "allenatore"], "Riservato a admin e allenatori");
|
||||
}
|
||||
@@ -1,7 +1,33 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { inRosa, type TipoMembro } from "./giocatori-squadra";
|
||||
import { oggiISO } from "./palloni-core";
|
||||
|
||||
/**
|
||||
* Avviso «sondaggio pre-partita aperto» (push, centro notifiche ed email, DD-038): stesso testo su
|
||||
* tutti e tre i canali.
|
||||
*/
|
||||
export function avvisoSondaggio(partita: { titolo: string; ora: string }): {
|
||||
titolo: string;
|
||||
testo: string;
|
||||
} {
|
||||
return {
|
||||
titolo: "💩 Sondaggio pre-partita aperto",
|
||||
testo: `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi riceve l'avviso in-app e per email: i giocatori attivi, mai gli allenatori, che il sondaggio
|
||||
* non riguarda (DD-034). Non dipende dai dispositivi iscritti alla push: l'avviso in-app e la mail
|
||||
* arrivano anche a chi non ha attivato le notifiche.
|
||||
*/
|
||||
export function destinatariSondaggio(
|
||||
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
|
||||
): string[] {
|
||||
return squadra.filter(inRosa).map((g) => g.id);
|
||||
}
|
||||
|
||||
/** Ora di apertura del sondaggio, il giorno stesso della partita. */
|
||||
export const ORA_APERTURA_SONDAGGIO = 8;
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/** Etichette condivise tra `/calendario` (sola lettura) ed `/eventi` (griglia di gestione). */
|
||||
export const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
|
||||
|
||||
export const mesiIT = [
|
||||
"Gennaio",
|
||||
"Febbraio",
|
||||
"Marzo",
|
||||
"Aprile",
|
||||
"Maggio",
|
||||
"Giugno",
|
||||
"Luglio",
|
||||
"Agosto",
|
||||
"Settembre",
|
||||
"Ottobre",
|
||||
"Novembre",
|
||||
"Dicembre",
|
||||
] as const;
|
||||
|
||||
export function pad2(n: number) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
/** Numero di giorni nel mese (0-indicizzato) e offset del primo giorno rispetto a lunedì. */
|
||||
export function giorniDelMese(anno: number, mese: number) {
|
||||
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
|
||||
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
|
||||
const offsetLunedi = (primoGiorno + 6) % 7;
|
||||
return { giorni, offsetLunedi };
|
||||
}
|
||||
|
||||
/** Stato di navigazione mese per mese, condiviso dalle griglie calendario. */
|
||||
export function useMeseNav(initial?: { anno: number; mese: number }) {
|
||||
const oggi = new Date();
|
||||
const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear());
|
||||
const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth());
|
||||
// Serve a far entrare e uscire la griglia dallo stesso lato del gesto:
|
||||
// se un mese esce a sinistra, il precedente deve rientrare da sinistra.
|
||||
const [direzione, setDirezione] = useState(0);
|
||||
|
||||
const precedente = () => {
|
||||
setDirezione(-1);
|
||||
if (mese === 0) {
|
||||
setMese(11);
|
||||
setAnno((a) => a - 1);
|
||||
} else {
|
||||
setMese((m) => m - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const successivo = () => {
|
||||
setDirezione(1);
|
||||
if (mese === 11) {
|
||||
setMese(0);
|
||||
setAnno((a) => a + 1);
|
||||
} else {
|
||||
setMese((m) => m + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return { anno, mese, direzione, precedente, successivo };
|
||||
}
|
||||
@@ -122,14 +122,6 @@ export const giocatori: Giocatore[] = rosaCSI
|
||||
}))
|
||||
.sort((a, b) => dividiNome(a.nome).cognome.localeCompare(dividiNome(b.nome).cognome, "it"));
|
||||
|
||||
/**
|
||||
* Fallback per la data di nascita: `giocatori_squadra` non ha ancora questa colonna
|
||||
* (DD-015 follow-up). Un giocatore aggiunto dopo la migrazione non ha nascita nota.
|
||||
*/
|
||||
export const nascitaPerId: Record<string, string> = Object.fromEntries(
|
||||
giocatori.map((g) => [g.id, g.nascita]),
|
||||
);
|
||||
|
||||
export type RigaClassifica = {
|
||||
pos: number;
|
||||
squadra: string;
|
||||
@@ -141,6 +133,12 @@ export type RigaClassifica = {
|
||||
punti: number;
|
||||
};
|
||||
|
||||
/** Data numerica `GG/MM/AAAA`, come scritta dai testi delle notifiche (stessa del database). */
|
||||
export function formatDataNumerica(iso: string): string {
|
||||
const [anno, mese, giorno] = iso.split("-");
|
||||
return `${giorno}/${mese}/${anno}`;
|
||||
}
|
||||
|
||||
export function formatData(iso: string) {
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" });
|
||||
|
||||
@@ -21,6 +21,43 @@ export const CSI_TEAM_ID = 3359;
|
||||
export const CSI_GIRONE = "Girone B";
|
||||
export const CSI_NOME_SQUADRA = "C.R.A.P. Volley";
|
||||
|
||||
/**
|
||||
* Stagione sportiva di una data `YYYY-MM-DD`, nel formato "2025/26". Il portale CSI non
|
||||
* espone il nome della stagione (né nella classifica né nel calendario della squadra):
|
||||
* si ricava dalla data della gara, con l'anno che cambia ad agosto — le gare di
|
||||
* settembre-dicembre aprono la stagione, quelle di gennaio-luglio la chiudono.
|
||||
*/
|
||||
export function stagioneDi(dataIso: string): string {
|
||||
const anno = Number(dataIso.slice(0, 4));
|
||||
const mese = Number(dataIso.slice(5, 7));
|
||||
const inizio = mese >= 8 ? anno : anno - 1;
|
||||
return `${inizio}/${String((inizio + 1) % 100).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Stagione del campionato letto dal CSI: quella della prima gara, null se non ce ne sono. */
|
||||
export function stagioneDaPartite(partite: ReadonlyArray<{ data: string }>): string | null {
|
||||
const date = partite.map((p) => p.data).filter((d) => /^\d{4}-\d{2}/.test(d));
|
||||
if (date.length === 0) return null;
|
||||
return stagioneDi(date.reduce((a, b) => (a < b ? a : b)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Storico partite diviso per stagione, nell'ordine in cui arrivano: le gare scoutate in
|
||||
* locale possono essere di stagioni diverse, quelle CSI sono tutte dello stesso campionato.
|
||||
*/
|
||||
export function raggruppaPerStagione<T extends { data: string }>(
|
||||
match: readonly T[],
|
||||
): Array<{ stagione: string; match: T[] }> {
|
||||
const gruppi: Array<{ stagione: string; match: T[] }> = [];
|
||||
for (const m of match) {
|
||||
const stagione = stagioneDi(m.data);
|
||||
const gruppo = gruppi.find((g) => g.stagione === stagione);
|
||||
if (gruppo) gruppo.match.push(m);
|
||||
else gruppi.push({ stagione, match: [m] });
|
||||
}
|
||||
return gruppi;
|
||||
}
|
||||
|
||||
export const urlClassifica = (projectId = CSI_PROJECT_ID) =>
|
||||
`${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`;
|
||||
export const urlPartite = (teamId = CSI_TEAM_ID) =>
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ export function eventoVuoto(): Evento {
|
||||
id: nuovoIdEvento(),
|
||||
tipo: "allenamento",
|
||||
titolo: "",
|
||||
luogo: "Palestra Comunale",
|
||||
luogo: "",
|
||||
data: new Date().toISOString().slice(0, 10),
|
||||
ora: "20:30",
|
||||
note: "",
|
||||
|
||||
@@ -7,10 +7,14 @@ import { dividiNome, giocatori } from "./crapp-data";
|
||||
* truth per il collegamento account ↔ giocatore; `crapp-data.ts` resta il fallback finché
|
||||
* la migrazione non è completa (DD-016 regola 1).
|
||||
*/
|
||||
/** Chi gioca e chi allena (DD-034): l'allenatore ha uno slot ma non è nella rosa di gioco. */
|
||||
export type TipoMembro = "giocatore" | "allenatore";
|
||||
|
||||
export type GiocatoreSquadra = {
|
||||
id: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
/** 0 per l'allenatore, che non ha numero di maglia (a database è NULL). */
|
||||
numero: number;
|
||||
ruolo: string;
|
||||
authUserId: string | null;
|
||||
@@ -18,19 +22,24 @@ export type GiocatoreSquadra = {
|
||||
email: string | null;
|
||||
numeroTessera: string | null;
|
||||
dataTessera: string | null;
|
||||
/** Pubblica a tutta la rosa (M18): sincronizzata da `profili_giocatore.data_nascita`. */
|
||||
nascita: string | null;
|
||||
tipo: TipoMembro;
|
||||
};
|
||||
|
||||
export type RigaGiocatoreSquadra = {
|
||||
id: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
numero: number;
|
||||
numero: number | null;
|
||||
ruolo: string;
|
||||
auth_user_id: string | null;
|
||||
attivo: boolean;
|
||||
email: string | null;
|
||||
numero_tessera: string | null;
|
||||
data_tessera: string | null;
|
||||
nascita: string | null;
|
||||
tipo: TipoMembro;
|
||||
};
|
||||
|
||||
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
|
||||
@@ -50,6 +59,8 @@ export function rosaFallback(): GiocatoreSquadra[] {
|
||||
email: null,
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
nascita: g.nascita,
|
||||
tipo: "giocatore" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -57,6 +68,24 @@ export function nomeCompleto(g: GiocatoreSquadra): string {
|
||||
return `${g.nome} ${g.cognome}`.trim();
|
||||
}
|
||||
|
||||
export function isAllenatore(g: Pick<GiocatoreSquadra, "tipo"> | null | undefined): boolean {
|
||||
return g?.tipo === "allenatore";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rosa di gioco: giocatori attivi, senza allenatori (DD-034). È il filtro di presenze,
|
||||
* convocati, voti, palloni, classifiche e badge; gli allenatori compaiono solo dove la
|
||||
* specifica lo dice (Rosa di Squadra, compleanni, promemoria evento).
|
||||
*/
|
||||
export function inRosa(g: Pick<GiocatoreSquadra, "attivo" | "tipo">): boolean {
|
||||
return g.attivo && g.tipo === "giocatore";
|
||||
}
|
||||
|
||||
/** Dove un giocatore ha il ruolo in campo, l'allenatore ha scritto «Allenatore». */
|
||||
export function ruoloVisibile(g: Pick<GiocatoreSquadra, "ruolo" | "tipo">): string {
|
||||
return g.tipo === "allenatore" ? "Allenatore" : g.ruolo;
|
||||
}
|
||||
|
||||
/** Lo slot già collegato a questo account, se esiste. */
|
||||
export function slotDi(
|
||||
righe: GiocatoreSquadra[],
|
||||
@@ -77,7 +106,7 @@ export function slotPerEmail(
|
||||
}
|
||||
|
||||
export const COLONNE_SQUADRA =
|
||||
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera";
|
||||
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera, nascita, tipo";
|
||||
|
||||
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
||||
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
||||
@@ -85,13 +114,15 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
||||
id: r.id,
|
||||
nome: r.nome,
|
||||
cognome: r.cognome,
|
||||
numero: r.numero,
|
||||
numero: r.numero ?? 0,
|
||||
ruolo: r.ruolo,
|
||||
authUserId: r.auth_user_id,
|
||||
attivo: r.attivo,
|
||||
email: r.email,
|
||||
numeroTessera: r.numero_tessera,
|
||||
dataTessera: r.data_tessera,
|
||||
nascita: r.nascita,
|
||||
tipo: r.tipo === "allenatore" ? "allenatore" : "giocatore",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,7 +146,10 @@ export function useGiocatoriSquadra() {
|
||||
|
||||
/** Dati squadra: li gestisce solo un amministratore (DD-017). L'email è quella usata per
|
||||
* il collegamento automatico al primo accesso (DD-018), non il dato personale del profilo. */
|
||||
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo" | "email">;
|
||||
export type DatiSquadra = Pick<
|
||||
GiocatoreSquadra,
|
||||
"nome" | "cognome" | "numero" | "ruolo" | "email" | "tipo"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
|
||||
@@ -125,6 +159,11 @@ export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" |
|
||||
export function validaDatiSquadra(dati: DatiSquadra): string | null {
|
||||
if (!dati.nome.trim()) return "Il nome non può essere vuoto.";
|
||||
if (!dati.cognome.trim()) return "Il cognome non può essere vuoto.";
|
||||
// L'allenatore non ha numero di maglia né ruolo in campo (DD-034).
|
||||
if (dati.tipo === "allenatore") {
|
||||
if (dati.email?.trim() && !dati.email.includes("@")) return "L'email non è valida.";
|
||||
return null;
|
||||
}
|
||||
if (!Number.isInteger(dati.numero) || dati.numero <= 0)
|
||||
return "Il numero di maglia deve essere maggiore di zero.";
|
||||
if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto.";
|
||||
@@ -147,7 +186,19 @@ export function numeroGiaUsato(
|
||||
giocatoreId: string,
|
||||
numero: number,
|
||||
): boolean {
|
||||
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
|
||||
return righe.some((g) => g.id !== giocatoreId && inRosa(g) && g.numero === numero);
|
||||
}
|
||||
|
||||
/** Riga da scrivere: all'allenatore numero NULL e ruolo vuoto, il tipo non cambia qui. */
|
||||
function rigaDatiSquadra(dati: DatiSquadra) {
|
||||
const allenatore = dati.tipo === "allenatore";
|
||||
return {
|
||||
nome: dati.nome.trim(),
|
||||
cognome: dati.cognome.trim(),
|
||||
numero: allenatore ? null : dati.numero,
|
||||
ruolo: allenatore ? "" : dati.ruolo.trim(),
|
||||
email: dati.email?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
|
||||
@@ -155,19 +206,13 @@ export function useSalvaDatiSquadra() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => {
|
||||
const dati = {
|
||||
nome: input.dati.nome.trim(),
|
||||
cognome: input.dati.cognome.trim(),
|
||||
numero: input.dati.numero,
|
||||
ruolo: input.dati.ruolo.trim(),
|
||||
email: input.dati.email?.trim() || null,
|
||||
};
|
||||
const riga = rigaDatiSquadra(input.dati);
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update(dati)
|
||||
.update(riga)
|
||||
.eq("id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
return { giocatoreId: input.giocatoreId, dati };
|
||||
return { giocatoreId: input.giocatoreId, dati: { ...riga, numero: riga.numero ?? 0 } };
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
@@ -220,18 +265,19 @@ export function useAggiungiGiocatore() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { id: string; dati: DatiSquadra }): Promise<GiocatoreSquadra> => {
|
||||
const riga = {
|
||||
id: input.id,
|
||||
nome: input.dati.nome.trim(),
|
||||
cognome: input.dati.cognome.trim(),
|
||||
numero: input.dati.numero,
|
||||
ruolo: input.dati.ruolo.trim(),
|
||||
email: input.dati.email?.trim() || null,
|
||||
};
|
||||
const riga = { id: input.id, tipo: input.dati.tipo, ...rigaDatiSquadra(input.dati) };
|
||||
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga);
|
||||
if (error) throw error;
|
||||
// Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL.
|
||||
return { ...riga, authUserId: null, attivo: true, numeroTessera: null, dataTessera: null };
|
||||
return {
|
||||
...riga,
|
||||
numero: riga.numero ?? 0,
|
||||
authUserId: null,
|
||||
attivo: true,
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
nascita: null,
|
||||
};
|
||||
},
|
||||
// Aggiornamento locale della cache: nessuna rilettura, stesso ordine della query
|
||||
// (`.order("cognome").order("nome")`).
|
||||
@@ -245,6 +291,32 @@ export function useAggiungiGiocatore() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nome e cognome scritti dall'allenatore sul proprio slot (DD-034): per un giocatore sono
|
||||
* dati squadra dell'admin, e il trigger di M21 rifiuta la stessa scrittura.
|
||||
*/
|
||||
export function useSalvaNomeAllenatore() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; nome: string; cognome: string }) => {
|
||||
const dati = { nome: input.nome.trim(), cognome: input.cognome.trim() };
|
||||
if (!dati.nome) throw new Error("Il nome non può essere vuoto.");
|
||||
if (!dati.cognome) throw new Error("Il cognome non può essere vuoto.");
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update(dati)
|
||||
.eq("id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
return { giocatoreId: input.giocatoreId, dati };
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attiva o disattiva un giocatore (es. ha lasciato la squadra): non elimina la riga, così
|
||||
* presenze, voti, pagelle e badge della stagione restano agganciati al suo id.
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
type LovableErrorOptions = {
|
||||
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
|
||||
handled?: boolean;
|
||||
severity?: "error" | "warning" | "info";
|
||||
};
|
||||
|
||||
type LovableEvents = {
|
||||
captureException?: (
|
||||
error: unknown,
|
||||
context?: Record<string, unknown>,
|
||||
options?: LovableErrorOptions,
|
||||
) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__lovableEvents?: LovableEvents;
|
||||
__lovableReportRuntimeError?: (payload: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
filename?: string;
|
||||
}) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.__lovableEvents?.captureException?.(
|
||||
error,
|
||||
{
|
||||
source: "react_error_boundary",
|
||||
route: window.location.pathname,
|
||||
...context,
|
||||
},
|
||||
{
|
||||
mechanism: "react_error_boundary",
|
||||
handled: false,
|
||||
severity: "error",
|
||||
},
|
||||
);
|
||||
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
|
||||
// editor's telemetry never sees them. Forward to lovable.js's reporting hook,
|
||||
// which is present only inside the editor preview.
|
||||
// Loaders and server fns commonly throw a raw Response; String(it) is the
|
||||
// opaque "[object Response]", so pull out the status and URL instead.
|
||||
const message =
|
||||
error instanceof Response
|
||||
? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
window.__lovableReportRuntimeError?.({
|
||||
message,
|
||||
...(stack !== undefined && { stack }),
|
||||
filename: window.location.pathname,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { intestazioniAutenticate } from "./auth";
|
||||
|
||||
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
|
||||
|
||||
async function fetchNotificheAttive(): Promise<Set<string>> {
|
||||
const res = await fetch("/api/public/notifiche-attive", {
|
||||
headers: await intestazioniAutenticate(),
|
||||
});
|
||||
if (!res.ok) throw new Error("Impossibile leggere le notifiche attive");
|
||||
const { giocatoreIds } = (await res.json()) as { giocatoreIds: string[] };
|
||||
return new Set(giocatoreIds);
|
||||
}
|
||||
|
||||
/** Insieme degli id giocatore con almeno un dispositivo iscritto alle notifiche push. */
|
||||
export function useNotificheAttive() {
|
||||
return useQuery({
|
||||
queryKey: NOTIFICHE_ATTIVE_KEY,
|
||||
queryFn: fetchNotificheAttive,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Invia un messaggio push libero a un giocatore, o a tutti se `giocatoreId` è omesso. */
|
||||
export function useInviaNotifica() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ messaggio, giocatoreId }: { messaggio: string; giocatoreId?: string }) => {
|
||||
const res = await fetch("/api/public/notifica-personalizzata", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
|
||||
body: JSON.stringify({ messaggio, giocatoreId }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Impossibile inviare la notifica");
|
||||
return (await res.json()) as { inviate: number; destinatari: number };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Id giocatore distinti tra le righe di `push_subscriptions` (una riga per dispositivo). */
|
||||
export function idsConNotificheAttive(righe: Array<{ giocatore_id: string }>): string[] {
|
||||
return [...new Set(righe.map((r) => r.giocatore_id))];
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
|
||||
import { useGiocatoreId } from "./user-store";
|
||||
|
||||
/**
|
||||
* Centro notifiche in-app (M17): pallino sull'avatar del profilo, con il numero delle non
|
||||
* lette. Cinque sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un
|
||||
* messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h), il
|
||||
* turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in
|
||||
* parallelo alla push esistente). Qui si legge e si segna come letto, con
|
||||
* `supabaseNuoveTabelle` perché `types.ts` non include ancora questa tabella (vedi
|
||||
* client-nuove-tabelle.ts).
|
||||
*/
|
||||
export type TipoNotifica =
|
||||
| "admin"
|
||||
| "evento_promemoria_24h"
|
||||
| "evento_promemoria_3h"
|
||||
| "turno_palloni"
|
||||
| "turno_palloni_12h"
|
||||
| "turno_palloni_6h"
|
||||
| "turno_palloni_3h"
|
||||
| "turno_palloni_revocato"
|
||||
| "sollecita_presenze"
|
||||
| "sollecita_presenze_24h"
|
||||
| "sollecita_presenze_12h"
|
||||
| "sollecita_presenze_6h"
|
||||
| "sondaggio_cacche";
|
||||
|
||||
export type NotificaUtente = {
|
||||
id: string;
|
||||
tipo: TipoNotifica;
|
||||
titolo: string;
|
||||
corpo: string;
|
||||
eventoId: string | null;
|
||||
letta: boolean;
|
||||
creataIl: string;
|
||||
};
|
||||
|
||||
export type RigaNotifica = {
|
||||
id: string;
|
||||
tipo: TipoNotifica;
|
||||
titolo: string;
|
||||
corpo: string;
|
||||
evento_id: string | null;
|
||||
letta: boolean;
|
||||
creato_il: string;
|
||||
};
|
||||
|
||||
const COLONNE = "id, tipo, titolo, corpo, evento_id, letta, creato_il";
|
||||
|
||||
/** Conversione riga database -> modello applicativo. */
|
||||
export function daRiga(r: RigaNotifica): NotificaUtente {
|
||||
return {
|
||||
id: r.id,
|
||||
tipo: r.tipo,
|
||||
titolo: r.titolo,
|
||||
corpo: r.corpo,
|
||||
eventoId: r.evento_id,
|
||||
letta: r.letta,
|
||||
creataIl: r.creato_il,
|
||||
};
|
||||
}
|
||||
|
||||
function chiaveNotifiche(giocatoreId: string | null) {
|
||||
return ["notifiche-utente", giocatoreId] as const;
|
||||
}
|
||||
|
||||
/** Ultime notifiche del giocatore selezionato, più recenti per prime. */
|
||||
export function useNotificheMie() {
|
||||
const giocatoreId = useGiocatoreId();
|
||||
const query = useQuery({
|
||||
queryKey: chiaveNotifiche(giocatoreId),
|
||||
queryFn: async (): Promise<NotificaUtente[]> => {
|
||||
const { data, error } = await supabaseNuoveTabelle
|
||||
.from("notifiche_utente")
|
||||
.select(COLONNE)
|
||||
.eq("giocatore_id", giocatoreId)
|
||||
.order("creato_il", { ascending: false })
|
||||
.limit(30);
|
||||
if (error) throw error;
|
||||
return ((data ?? []) as RigaNotifica[]).map(daRiga);
|
||||
},
|
||||
enabled: !!giocatoreId,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const notifiche = query.data ?? [];
|
||||
return { ...query, notifiche, nonLette: notifiche.filter((n) => !n.letta).length };
|
||||
}
|
||||
|
||||
/** Segna come lette tutte le notifiche non lette del giocatore selezionato. */
|
||||
export function useSegnaLette() {
|
||||
const giocatoreId = useGiocatoreId();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!giocatoreId) return;
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("notifiche_utente")
|
||||
.update({ letta: true })
|
||||
.eq("giocatore_id", giocatoreId)
|
||||
.eq("letta", false);
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.setQueryData<NotificaUtente[]>(chiaveNotifiche(giocatoreId), (prec) =>
|
||||
(prec ?? []).map((n) => ({ ...n, letta: true })),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina una notifica del giocatore selezionato (swipe o pulsante nel pannello). A
|
||||
* differenza di "segna come letta" qui la riga sparisce anche dal database, non solo
|
||||
* dalla vista: non c'è pulizia automatica delle notifiche vecchie, quindi eliminarla è
|
||||
* l'unico modo per un giocatore di toglierla di mezzo per sempre.
|
||||
*/
|
||||
export function useEliminaNotifica() {
|
||||
const giocatoreId = useGiocatoreId();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await supabaseNuoveTabelle.from("notifiche_utente").delete().eq("id", id);
|
||||
if (error) throw error;
|
||||
return id;
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
qc.setQueryData<NotificaUtente[]>(chiaveNotifiche(giocatoreId), (prec) =>
|
||||
(prec ?? []).filter((n) => n.id !== id),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Testo del badge: il conteggio esatto fino a 9, poi "9+" per non farlo esplodere. */
|
||||
export function contatoreBadge(nonLette: number): string {
|
||||
return nonLette > 9 ? "9+" : String(nonLette);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pallino sull'avatar del profilo: rosso con il numero delle non lette; se sono tutte lette
|
||||
* resta neutro con il totale, altrimenti le notifiche già lette non sarebbero più
|
||||
* raggiungibili per eliminarle. Senza notifiche non c'è (`null`).
|
||||
*/
|
||||
export function pallinoNotifiche(
|
||||
totale: number,
|
||||
nonLette: number,
|
||||
): { testo: string; daLeggere: boolean } | null {
|
||||
if (totale === 0) return null;
|
||||
if (nonLette > 0) return { testo: contatoreBadge(nonLette), daLeggere: true };
|
||||
return { testo: contatoreBadge(totale), daLeggere: false };
|
||||
}
|
||||
+41
-25
@@ -1,4 +1,4 @@
|
||||
import { formatData } from "./crapp-data";
|
||||
import { formatDataNumerica } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import { dataOggi } from "./scout-live";
|
||||
import { aggiornaSerie } from "./serie";
|
||||
@@ -8,12 +8,19 @@ export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: st
|
||||
/** Candidato al turno palloni: solo id e nome bastano per assegnare e ordinare. */
|
||||
export type CandidatoTurno = { id: string; nome: string };
|
||||
|
||||
/** Eventi che richiedono i palloni (allenamenti, partite, extra), in ordine di data. */
|
||||
/**
|
||||
* Eventi che richiedono i palloni (solo allenamenti e partite), in ordine di data e, a parità,
|
||||
* di ora. Lo stesso ordine lo usa il database per i job automatici (M26, DD-040).
|
||||
*/
|
||||
export function eventiPalloni(eventi: Evento[]): Evento[] {
|
||||
return eventi
|
||||
.filter((e) => e.tipo !== "compleanno")
|
||||
.filter((e) => e.tipo === "allenamento" || e.tipo === "partita")
|
||||
.slice()
|
||||
.sort((a, b) => a.data.localeCompare(b.data));
|
||||
.sort((a, b) => {
|
||||
if (a.data !== b.data) return a.data < b.data ? -1 : 1;
|
||||
if (a.ora !== b.ora) return a.ora < b.ora ? -1 : 1;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,7 +28,7 @@ export function eventiPalloni(eventi: Evento[]): Evento[] {
|
||||
* meno volte (a parità, chi non lo fa da più tempo).
|
||||
*
|
||||
* Gli **allenamenti** non vengono proposti: restano «da assegnare» finché qualcuno
|
||||
* non conferma un incaricato a mano. Su partite ed eventi extra la rotazione resta.
|
||||
* non conferma un incaricato a mano. Sulle partite la rotazione resta.
|
||||
*/
|
||||
export function completaTurni(
|
||||
turni: Record<string, string>,
|
||||
@@ -139,11 +146,12 @@ export function destinatariPromemoriaPalloni(
|
||||
}
|
||||
|
||||
/**
|
||||
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025).
|
||||
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025, DD-040).
|
||||
*
|
||||
* 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.
|
||||
* Sono gli stessi testi dei job automatici del database (migration M26): all'incaricato
|
||||
* «incarico assegnato», a chi aveva i palloni all'evento precedente (se è un'altra persona)
|
||||
* «riconsegna». Se per l'evento non c'è un incaricato non parte nulla. Il testo viaggia cifrato
|
||||
* dentro la push, quindi il service worker lo mostra senza rete.
|
||||
*/
|
||||
export function avvisiPalloniEvento(
|
||||
turni: Record<string, string>,
|
||||
@@ -153,29 +161,37 @@ export function avvisiPalloniEvento(
|
||||
const evento = eventiPalloni(eventi).find((e) => e.id === eventoId);
|
||||
if (!evento) return [];
|
||||
|
||||
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
|
||||
const quando = `${evento.titolo} · ${formatData(evento.data)} alle ${evento.ora}`;
|
||||
|
||||
const incaricato = turni[evento.id];
|
||||
if (incaricato) {
|
||||
const dopo = eventoSuccessivo(eventi, evento.id);
|
||||
avvisi.push({
|
||||
giocatoreId: incaricato,
|
||||
titolo: "Tocca a te prendere i palloni",
|
||||
testo: dopo
|
||||
? `${quando}: a fine evento porti a casa i palloni e li riporti il ${formatData(dopo.data)}.`
|
||||
: `${quando}: a fine evento porti a casa i palloni.`,
|
||||
});
|
||||
}
|
||||
if (!incaricato) return [];
|
||||
|
||||
// Chi li ha presi la volta scorsa deve ricordarsi di portarli.
|
||||
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
|
||||
const intestazione = [
|
||||
`Evento: ${evento.titolo}`,
|
||||
`Data: ${formatDataNumerica(evento.data)}, ore ${evento.ora}`,
|
||||
];
|
||||
|
||||
const dopo = eventoSuccessivo(eventi, evento.id);
|
||||
avvisi.push({
|
||||
giocatoreId: incaricato,
|
||||
titolo: "Turno palloni: incarico assegnato",
|
||||
testo: [
|
||||
...intestazione,
|
||||
"Incarico: custodia dei palloni al termine dell'evento",
|
||||
...(dopo ? [`Riconsegna: ${formatDataNumerica(dopo.data)}`] : []),
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
// Chi li ha presi la volta scorsa deve ricordarsi di riportarli.
|
||||
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.`,
|
||||
titolo: "Turno palloni: riconsegna",
|
||||
testo: [
|
||||
...intestazione,
|
||||
"Incarico: riconsegna dei palloni in custodia dal turno precedente",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { completaTurni } from "./palloni-core";
|
||||
import { useEventi } from "./eventi";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
|
||||
export const TURNI_KEY = ["turni-palloni"] as const;
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useTurniPalloni() {
|
||||
const { eventi } = useEventi();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const salvati = query.data ?? {};
|
||||
const rosa = squadra.filter((g) => g.attivo).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
const rosa = squadra.filter(inRosa).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
return { ...query, salvati, turni: completaTurni(salvati, eventi, rosa) };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
|
||||
import { useSessione } from "./auth";
|
||||
|
||||
/**
|
||||
* Preferenze del singolo account (M22), non dello slot giocatore: oggi solo l'interruttore
|
||||
* «Email» di Profilo → Opzioni (DD-036). Nessuna riga in `preferenze_utente` vuol dire
|
||||
* «mail attive»: la riga nasce solo quando il giocatore tocca l'interruttore. Lettura e
|
||||
* scrittura passano dalla RLS (ognuno vede e scrive solo la propria), senza route API.
|
||||
*/
|
||||
export type RigaPreferenze = { email_notifiche: boolean };
|
||||
|
||||
/** Assenza di riga = valori di default: le email sono attive. */
|
||||
export function emailAttive(riga: RigaPreferenze | null | undefined): boolean {
|
||||
return riga?.email_notifiche ?? true;
|
||||
}
|
||||
|
||||
const chiavePreferenze = (utenteId: string | null) => ["preferenze-utente", utenteId] as const;
|
||||
|
||||
/** Stato dell'interruttore «Email» dell'account e mutazione per cambiarlo. */
|
||||
export function useEmailNotifiche() {
|
||||
const { utenteId } = useSessione();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: chiavePreferenze(utenteId),
|
||||
queryFn: async (): Promise<RigaPreferenze | null> => {
|
||||
const { data, error } = await supabaseNuoveTabelle
|
||||
.from("preferenze_utente")
|
||||
.select("email_notifiche")
|
||||
.eq("auth_user_id", utenteId)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return (data as RigaPreferenze | null) ?? null;
|
||||
},
|
||||
enabled: !!utenteId,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const imposta = useMutation({
|
||||
mutationFn: async (attive: boolean) => {
|
||||
const { error } = await supabaseNuoveTabelle.from("preferenze_utente").upsert(
|
||||
{
|
||||
auth_user_id: utenteId,
|
||||
email_notifiche: attive,
|
||||
aggiornata_il: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "auth_user_id" },
|
||||
);
|
||||
if (error) throw error;
|
||||
return attive;
|
||||
},
|
||||
onSuccess: (attive) => {
|
||||
const riga: RigaPreferenze = { email_notifiche: attive };
|
||||
qc.setQueryData(chiavePreferenze(utenteId), riga);
|
||||
},
|
||||
});
|
||||
|
||||
return { attive: emailAttive(query.data), pronta: !query.isPending, imposta };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEventi } from "./eventi";
|
||||
import { useRispostePresenze } from "./presenze";
|
||||
import { useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import { inRosa, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import {
|
||||
presenzeUltimoMese,
|
||||
presenzeUltimoMeseTutti,
|
||||
@@ -30,7 +30,7 @@ export function usePresenzeUltimoMeseTutti(): Record<string, StatistichePresenza
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
|
||||
return useMemo(() => {
|
||||
const idRosa = squadra.filter((g) => g.attivo).map((g) => g.id);
|
||||
const idRosa = squadra.filter(inRosa).map((g) => g.id);
|
||||
return presenzeUltimoMeseTutti(eventi, presenze, idRosa);
|
||||
}, [eventi, presenze, squadra]);
|
||||
}
|
||||
+46
-7
@@ -1,7 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import type { Stato } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import { formatDataNumerica, type Stato } from "./crapp-data";
|
||||
import type { Evento, EventoTipo } from "./eventi";
|
||||
import { inRosa, type TipoMembro } from "./giocatori-squadra";
|
||||
import { aggiornaSerie } from "./serie";
|
||||
import { dataOggi } from "./scout-live";
|
||||
|
||||
@@ -71,17 +72,32 @@ export function totaliEventiGiocatore(
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi va sollecitato per un evento: i giocatori attivi che non hanno ancora risposto, più
|
||||
* quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i
|
||||
* palloni: la route `/api/public/sollecita-presenze` la chiama con i dati che ha già letto.
|
||||
* Se "infortunato" va offerto come risposta: sì per allenamenti e partite, no per gli
|
||||
* eventi extra-campo (`"evento"`, es. cena di squadra), dove l'infortunio non è una
|
||||
* risposta pertinente. Resta comunque visibile a chi ce l'ha già (dati storici, o un
|
||||
* evento cambiato tipo dopo la risposta), così può ancora toglierla.
|
||||
*/
|
||||
export function includeInfortunato(tipo: EventoTipo, statoAttuale?: Stato | null): boolean {
|
||||
return tipo !== "evento" || statoAttuale === "infortunato";
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi va sollecitato per un evento (DD-040): i destinatari dell'evento — i convocati, o tutta la
|
||||
* rosa se `convocati` è vuoto — che non hanno ancora risposto o hanno risposto «forse». Mai gli
|
||||
* allenatori (DD-034). Stessa regola del job automatico del database (`giocatori_destinatari_evento`
|
||||
* più `genera_solleciti_presenze`, migration M26): funzione pura, come `avvisiPalloniEvento()` per i
|
||||
* palloni, chiamata dalla route `/api/public/sollecita-presenze` con i dati che ha già letto.
|
||||
*/
|
||||
export function destinatariSollecito(
|
||||
squadra: Array<{ id: string; attivo: boolean }>,
|
||||
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
|
||||
risposte: Array<{ giocatore_id: string; stato: string }>,
|
||||
convocati: string[] = [],
|
||||
): string[] {
|
||||
const stati = new Map(risposte.map((r) => [r.giocatore_id, r.stato]));
|
||||
// L'allenatore non risponde alle presenze, quindi non va sollecitato (DD-034).
|
||||
return squadra
|
||||
.filter((g) => g.attivo)
|
||||
.filter(inRosa)
|
||||
.filter((g) => convocati.length === 0 || convocati.includes(g.id))
|
||||
.filter((g) => {
|
||||
const stato = stati.get(g.id);
|
||||
return stato === undefined || stato === "forse";
|
||||
@@ -89,6 +105,29 @@ export function destinatariSollecito(
|
||||
.map((g) => g.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Titolo e corpo del sollecito, gli stessi del job automatico (migration M26). `da` è il nome di
|
||||
* chi preme il pulsante: compare solo nel sollecito manuale, come riga «Richiesta di».
|
||||
*/
|
||||
export function testoSollecito(
|
||||
evento: Pick<Evento, "titolo" | "data" | "ora" | "luogo">,
|
||||
stato: string | undefined,
|
||||
da?: string,
|
||||
): { titolo: string; testo: string } {
|
||||
const luogo = evento.luogo.trim();
|
||||
return {
|
||||
titolo: `Conferma di partecipazione richiesta: ${evento.titolo}`,
|
||||
testo: [
|
||||
`Data: ${formatDataNumerica(evento.data)}`,
|
||||
`Ora: ${evento.ora}`,
|
||||
...(luogo ? [`Luogo: ${luogo}`] : []),
|
||||
...(da ? [`Richiesta di: ${da}`] : []),
|
||||
`Risposta attuale: ${stato === "forse" ? "forse" : "nessuna"}`,
|
||||
"Azione: indicare presente, assente o in ritardo",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serie di presenze consecutive su eventi già passati, in ordine di data:
|
||||
* ogni presenza (o ritardo) vale +1, qualsiasi altra risposta — o nessuna
|
||||
|
||||
+83
-1
@@ -1,5 +1,5 @@
|
||||
import { rigaCsv } from "./scout-export";
|
||||
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
import { inRosa, nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
|
||||
/**
|
||||
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
|
||||
@@ -144,6 +144,18 @@ export function completamento(p: Profilo | null | undefined): number {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'allenatore compila solo i dati personali ridotti (DD-034): niente indirizzo, documento,
|
||||
* certificato né foto tessera, che servono al tesseramento dei giocatori.
|
||||
*/
|
||||
export const CAMPI_ALLENATORE = ["dataNascita", "luogoNascita", "telefono", "email"] as const;
|
||||
|
||||
/** Percentuale dei campi dell'allenatore compilati: 100 quando ci sono tutti. */
|
||||
export function completamentoAllenatore(p: Profilo | null | undefined): number {
|
||||
const pieni = CAMPI_ALLENATORE.filter((c) => !!p?.[c]?.trim()).length;
|
||||
return Math.round((pieni / CAMPI_ALLENATORE.length) * 100);
|
||||
}
|
||||
|
||||
export type StatoScadenza = "mancante" | "scaduto" | "valido";
|
||||
|
||||
/** Un certificato scaduto blocca il tesseramento: per l'admin non vale come presente. */
|
||||
@@ -203,3 +215,73 @@ export function csvTesseramento(
|
||||
export function etichettaGiocatore(g: GiocatoreSquadra): string {
|
||||
return `#${g.numero} ${nomeCompleto(g)}`;
|
||||
}
|
||||
|
||||
/** Soglia dell'avviso certificati in Home (DD-035). */
|
||||
export const GIORNI_AVVISO_CERTIFICATO = 7;
|
||||
|
||||
/** Giorni di calendario tra due date `AAAA-MM-GG` (ora locale): negativo se `scadenza` è passata. */
|
||||
export function giorniAllaScadenza(scadenza: string, oggi: string): number {
|
||||
const a = new Date(`${scadenza}T00:00:00`);
|
||||
const b = new Date(`${oggi}T00:00:00`);
|
||||
return Math.round((a.getTime() - b.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** Data in formato `GG/MM/AAAA`, per i testi dell'avviso certificati. */
|
||||
export function formatDataBreve(iso: string): string {
|
||||
return new Date(`${iso}T00:00:00`).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Riga dell'avviso certificati per un giocatore (DD-035). */
|
||||
export function testoScadenza(giorni: number): string {
|
||||
if (giorni === 0) return "scade oggi";
|
||||
if (giorni === 1) return "scade domani";
|
||||
return `scade tra ${giorni} giorni`;
|
||||
}
|
||||
|
||||
export type AvvisoCertificato = {
|
||||
giocatoreId: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
scadenza: string;
|
||||
giorni: number;
|
||||
};
|
||||
|
||||
function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number {
|
||||
return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da
|
||||
* `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha
|
||||
* ancora un certificato caricato (è un problema diverso, non un avviso di scadenza).
|
||||
*/
|
||||
export function avvisiCertificati(
|
||||
rosa: GiocatoreSquadra[],
|
||||
profili: Record<string, Profilo>,
|
||||
oggi: string,
|
||||
): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } {
|
||||
const scaduti: AvvisoCertificato[] = [];
|
||||
const inScadenza: AvvisoCertificato[] = [];
|
||||
for (const g of rosa) {
|
||||
if (!inRosa(g)) continue;
|
||||
const p = profili[g.id];
|
||||
if (!p?.certificatoScadenza || !p.certificatoPath) continue;
|
||||
const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
|
||||
if (giorni > GIORNI_AVVISO_CERTIFICATO) continue;
|
||||
const avviso: AvvisoCertificato = {
|
||||
giocatoreId: g.id,
|
||||
nome: g.nome,
|
||||
cognome: g.cognome,
|
||||
scadenza: p.certificatoScadenza,
|
||||
giorni,
|
||||
};
|
||||
(giorni < 0 ? scaduti : inScadenza).push(avviso);
|
||||
}
|
||||
scaduti.sort(confrontaAvvisi);
|
||||
inScadenza.sort(confrontaAvvisi);
|
||||
return { scaduti, inScadenza };
|
||||
}
|
||||
+39
-38
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { nascitaPerId, type Giocatore } from "./crapp-data";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti";
|
||||
import { mediePagelle, usePagelle } from "./pagelle";
|
||||
import { statisticheCacche, useCacche } from "./cacche";
|
||||
@@ -27,26 +27,29 @@ function iniziali(nome: string, cognome: string): string {
|
||||
|
||||
/**
|
||||
* Solo anagrafica (id, nome, ruolo, numero, data di nascita) dei giocatori attivi — es.
|
||||
* per i compleanni nel Calendario o le liste presenze. A differenza di `useRosa` non
|
||||
* legge MVP, pagelle, cacche, palloni né infortuni: evita di montare quei cinque hook e
|
||||
* il relativo `useMemo` solo per l'anagrafica.
|
||||
* per le liste presenze o lo scout. A differenza di `useRosa` non legge MVP, pagelle,
|
||||
* cacche, palloni né infortuni: evita di montare quei cinque hook e il relativo `useMemo`
|
||||
* solo per l'anagrafica. Con `conAllenatori` include anche gli allenatori attivi, per chi
|
||||
* elenca i membri della squadra e non solo chi gioca (compleanni in Calendario, DD-034).
|
||||
*/
|
||||
export function useAnagraficaRosa(): Array<
|
||||
export function useAnagraficaRosa({
|
||||
conAllenatori = false,
|
||||
}: { conAllenatori?: boolean } = {}): Array<
|
||||
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita">
|
||||
> {
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
return useMemo(
|
||||
() =>
|
||||
squadra
|
||||
.filter((g) => g.attivo)
|
||||
.filter((g) => (conAllenatori ? g.attivo : inRosa(g)))
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
nome: nomeCompleto(g),
|
||||
ruolo: g.ruolo,
|
||||
numero: g.numero,
|
||||
nascita: nascitaPerId[g.id] ?? "",
|
||||
nascita: g.nascita ?? "",
|
||||
})),
|
||||
[squadra],
|
||||
[squadra, conAllenatori],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +57,7 @@ export function useAnagraficaRosa(): Array<
|
||||
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
|
||||
* palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra`
|
||||
* (DD-015): solo i giocatori attivi, gli altri restano nel database ma spariscono
|
||||
* dagli elenchi correnti. Usa solo cache già in memoria: nessuna query aggiuntiva
|
||||
* dagli elenchi correnti. Gli allenatori non ci sono (DD-034): non hanno statistiche. Usa solo cache già in memoria: nessuna query aggiuntiva
|
||||
* rispetto a quelle che l'app fa comunque.
|
||||
*/
|
||||
export function useRosa(): Giocatore[] {
|
||||
@@ -67,7 +70,7 @@ export function useRosa(): Giocatore[] {
|
||||
const { eventi } = useEventi();
|
||||
const { presenze: mappaPresenze, tempi } = useRispostePresenze();
|
||||
|
||||
const votiMvp = voti.data ?? [];
|
||||
const votiMvp = voti.data;
|
||||
|
||||
return useMemo(() => {
|
||||
const medie = mediePagelle(pagelle);
|
||||
@@ -76,34 +79,32 @@ export function useRosa(): Giocatore[] {
|
||||
// premiare chi ha davvero portato i palloni, non chi l'algoritmo di rotazione ha
|
||||
// scelto per un evento passato senza che nessuno confermasse nulla.
|
||||
const palloni = conteggioTurni(turniSalvati, eventi);
|
||||
const mvpVinti = mvpVintiPerGiocatore(votiMvp);
|
||||
const mvpVinti = mvpVintiPerGiocatore(votiMvp ?? []);
|
||||
|
||||
return squadra
|
||||
.filter((g) => g.attivo)
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
nome: nomeCompleto(g),
|
||||
numero: g.numero,
|
||||
ruolo: g.ruolo,
|
||||
nascita: nascitaPerId[g.id] ?? "",
|
||||
iniziali: iniziali(g.nome, g.cognome),
|
||||
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
|
||||
totaliEventi: totaliEventiGiocatore(g.id, eventi),
|
||||
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
|
||||
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
|
||||
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
|
||||
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
|
||||
serieConferme: serieConferme(g.id, eventi, tempi),
|
||||
mvp: mvpVinti[g.id] ?? 0,
|
||||
mediaVoto: medie[g.id]?.media ?? 0,
|
||||
votiPagella: medie[g.id]?.voti ?? 0,
|
||||
palloni: palloni[g.id] ?? 0,
|
||||
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
|
||||
cacche: statCacche[g.id]?.giornateTop ?? 0,
|
||||
cacchePartita: statCacche[g.id]?.media ?? 0,
|
||||
infortuni: infortuni[g.id] ?? 0,
|
||||
ritardi: ritardi[g.id] ?? 0,
|
||||
}));
|
||||
return squadra.filter(inRosa).map((g) => ({
|
||||
id: g.id,
|
||||
nome: nomeCompleto(g),
|
||||
numero: g.numero,
|
||||
ruolo: g.ruolo,
|
||||
nascita: g.nascita ?? "",
|
||||
iniziali: iniziali(g.nome, g.cognome),
|
||||
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
|
||||
totaliEventi: totaliEventiGiocatore(g.id, eventi),
|
||||
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
|
||||
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
|
||||
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
|
||||
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
|
||||
serieConferme: serieConferme(g.id, eventi, tempi),
|
||||
mvp: mvpVinti[g.id] ?? 0,
|
||||
mediaVoto: medie[g.id]?.media ?? 0,
|
||||
votiPagella: medie[g.id]?.voti ?? 0,
|
||||
palloni: palloni[g.id] ?? 0,
|
||||
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
|
||||
cacche: statCacche[g.id]?.giornateTop ?? 0,
|
||||
cacchePartita: statCacche[g.id]?.media ?? 0,
|
||||
infortuni: infortuni[g.id] ?? 0,
|
||||
ritardi: ritardi[g.id] ?? 0,
|
||||
}));
|
||||
}, [
|
||||
squadra,
|
||||
votiMvp,
|
||||
|
||||
+39
-15
@@ -2,33 +2,57 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useSessione } from "./auth";
|
||||
|
||||
export const RUOLI_KEY = ["ruolo-admin"] as const;
|
||||
export const RUOLI_KEY = ["ruoli-utente"] as const;
|
||||
|
||||
export type Ruolo = "admin" | "allenatore";
|
||||
|
||||
/**
|
||||
* Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011).
|
||||
* Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare.
|
||||
* Permessi: unica fonte è `user_roles` nel database (DD-011). Nessuna lista di nomi,
|
||||
* altrimenti basterebbe scegliere il nome giusto per amministrare. Il ruolo `allenatore`
|
||||
* lo scrive un trigger quando si collega uno slot di tipo allenatore (M21, DD-034).
|
||||
*/
|
||||
|
||||
/** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */
|
||||
async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> {
|
||||
async function fetchRuoli(utenteId: string | null): Promise<Ruolo[] | null> {
|
||||
if (!utenteId) return null;
|
||||
const { data, error } = await supabase
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", utenteId)
|
||||
.eq("role", "admin")
|
||||
.maybeSingle();
|
||||
.in("role", ["admin", "allenatore"]);
|
||||
if (error) throw error;
|
||||
return !!data;
|
||||
return (data ?? []).map((r) => r.role as Ruolo);
|
||||
}
|
||||
|
||||
function useRuoliQuery() {
|
||||
const { utenteId } = useSessione();
|
||||
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
|
||||
return useQuery({
|
||||
queryKey: [...RUOLI_KEY, utenteId],
|
||||
queryFn: () => fetchRuoli(utenteId),
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function useRuoli(): Ruolo[] {
|
||||
return useRuoliQuery().data ?? [];
|
||||
}
|
||||
|
||||
/** L'avviso certificati (DD-035) aspetta i ruoli: niente flash dell'avviso sbagliato. */
|
||||
export function useRuoliPronti(): boolean {
|
||||
return !useRuoliQuery().isPending;
|
||||
}
|
||||
|
||||
export function useIsAdmin(): boolean {
|
||||
const { utenteId } = useSessione();
|
||||
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
|
||||
const query = useQuery({
|
||||
queryKey: [...RUOLI_KEY, utenteId],
|
||||
queryFn: () => fetchRuoloAdmin(utenteId),
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
return query.data === true;
|
||||
return useRuoli().includes("admin");
|
||||
}
|
||||
|
||||
export function useIsAllenatore(): boolean {
|
||||
return useRuoli().includes("allenatore");
|
||||
}
|
||||
|
||||
/** Chi crea e modifica gli eventi e sollecita le presenze: admin o allenatore (DD-034). */
|
||||
export function usePuoGestireEventi(): boolean {
|
||||
const ruoli = useRuoli();
|
||||
return ruoli.includes("admin") || ruoli.includes("allenatore");
|
||||
}
|
||||
+18
-1
@@ -1,6 +1,6 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import { useGiocatoriSquadra, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
import { isAllenatore, useGiocatoriSquadra, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
import { useIo } from "./rosa";
|
||||
|
||||
const KEY = "crapp-user-v1";
|
||||
@@ -56,6 +56,23 @@ export function useGiocatoreBase(): GiocatoreSquadra | null {
|
||||
return id ? (righe.find((x) => x.id === id) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi usa l'app è un allenatore (DD-034): decide cosa si vede (niente badge, cacche,
|
||||
* stagione, obiettivi). I permessi veri stanno in `user_roles` (`usePuoGestireEventi`).
|
||||
*/
|
||||
export function useSonoAllenatore(): boolean {
|
||||
return isAllenatore(useGiocatoreBase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Il proprio slot solo se è di un giocatore: per rispondere alle presenze, votare,
|
||||
* dichiarare le cacche o portare i palloni. Per l'allenatore è `null`.
|
||||
*/
|
||||
export function useGiocatoreInCampo(): GiocatoreSquadra | null {
|
||||
const g = useGiocatoreBase();
|
||||
return g && !isAllenatore(g) ? g : null;
|
||||
}
|
||||
|
||||
/** Giocatore corrente con statistiche calcolate da dati reali (presenze, MVP, badge, …). */
|
||||
export function useGiocatoreCorrente(): Giocatore | null {
|
||||
return useIo();
|
||||
|
||||
@@ -19,10 +19,14 @@ import { Route as ProfiloRouteImport } from './routes/profilo'
|
||||
import { Route as ScoutRouteImport } from './routes/scout'
|
||||
import { Route as SquadraRouteImport } from './routes/squadra'
|
||||
import { Route as AllenamentoIdRouteImport } from './routes/allenamento.$id'
|
||||
import { Route as EventoIdRouteImport } from './routes/evento.$id'
|
||||
import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
|
||||
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
||||
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
||||
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
||||
import { Route as ApiPublicKeepaliveRouteImport } from './routes/api/public/keepalive'
|
||||
import { Route as ApiPublicNotificaPersonalizzataRouteImport } from './routes/api/public/notifica-personalizzata'
|
||||
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
|
||||
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
||||
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
||||
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
|
||||
@@ -79,6 +83,11 @@ const AllenamentoIdRoute = AllenamentoIdRouteImport.update({
|
||||
path: '/allenamento/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const EventoIdRoute = EventoIdRouteImport.update({
|
||||
id: '/evento/$id',
|
||||
path: '/evento/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PartitaCsiIdRoute = PartitaCsiIdRouteImport.update({
|
||||
id: '/partita-csi/$id',
|
||||
path: '/partita-csi/$id',
|
||||
@@ -99,6 +108,23 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
|
||||
path: '/api/public/csi',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPublicKeepaliveRoute = ApiPublicKeepaliveRouteImport.update({
|
||||
id: '/api/public/keepalive',
|
||||
path: '/api/public/keepalive',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPublicNotificaPersonalizzataRoute =
|
||||
ApiPublicNotificaPersonalizzataRouteImport.update({
|
||||
id: '/api/public/notifica-personalizzata',
|
||||
path: '/api/public/notifica-personalizzata',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPublicNotificheAttiveRoute =
|
||||
ApiPublicNotificheAttiveRouteImport.update({
|
||||
id: '/api/public/notifiche-attive',
|
||||
path: '/api/public/notifiche-attive',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPublicPromemoriaPalloniRoute =
|
||||
ApiPublicPromemoriaPalloniRouteImport.update({
|
||||
id: '/api/public/promemoria-palloni',
|
||||
@@ -138,10 +164,14 @@ export interface FileRoutesByFullPath {
|
||||
'/scout': typeof ScoutRoute
|
||||
'/squadra': typeof SquadraRoute
|
||||
'/allenamento/$id': typeof AllenamentoIdRoute
|
||||
'/evento/$id': typeof EventoIdRoute
|
||||
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
||||
'/partita/$id': typeof PartitaIdRoute
|
||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/keepalive': typeof ApiPublicKeepaliveRoute
|
||||
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||
@@ -159,10 +189,14 @@ export interface FileRoutesByTo {
|
||||
'/scout': typeof ScoutRoute
|
||||
'/squadra': typeof SquadraRoute
|
||||
'/allenamento/$id': typeof AllenamentoIdRoute
|
||||
'/evento/$id': typeof EventoIdRoute
|
||||
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
||||
'/partita/$id': typeof PartitaIdRoute
|
||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/keepalive': typeof ApiPublicKeepaliveRoute
|
||||
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||
@@ -181,10 +215,14 @@ export interface FileRoutesById {
|
||||
'/scout': typeof ScoutRoute
|
||||
'/squadra': typeof SquadraRoute
|
||||
'/allenamento/$id': typeof AllenamentoIdRoute
|
||||
'/evento/$id': typeof EventoIdRoute
|
||||
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
||||
'/partita/$id': typeof PartitaIdRoute
|
||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/keepalive': typeof ApiPublicKeepaliveRoute
|
||||
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||
@@ -204,10 +242,14 @@ export interface FileRouteTypes {
|
||||
| '/scout'
|
||||
| '/squadra'
|
||||
| '/allenamento/$id'
|
||||
| '/evento/$id'
|
||||
| '/partita-csi/$id'
|
||||
| '/partita/$id'
|
||||
| '/api/public/apri-sondaggio'
|
||||
| '/api/public/csi'
|
||||
| '/api/public/keepalive'
|
||||
| '/api/public/notifica-personalizzata'
|
||||
| '/api/public/notifiche-attive'
|
||||
| '/api/public/promemoria-palloni'
|
||||
| '/api/public/push-config'
|
||||
| '/api/public/push-subscribe'
|
||||
@@ -225,10 +267,14 @@ export interface FileRouteTypes {
|
||||
| '/scout'
|
||||
| '/squadra'
|
||||
| '/allenamento/$id'
|
||||
| '/evento/$id'
|
||||
| '/partita-csi/$id'
|
||||
| '/partita/$id'
|
||||
| '/api/public/apri-sondaggio'
|
||||
| '/api/public/csi'
|
||||
| '/api/public/keepalive'
|
||||
| '/api/public/notifica-personalizzata'
|
||||
| '/api/public/notifiche-attive'
|
||||
| '/api/public/promemoria-palloni'
|
||||
| '/api/public/push-config'
|
||||
| '/api/public/push-subscribe'
|
||||
@@ -246,10 +292,14 @@ export interface FileRouteTypes {
|
||||
| '/scout'
|
||||
| '/squadra'
|
||||
| '/allenamento/$id'
|
||||
| '/evento/$id'
|
||||
| '/partita-csi/$id'
|
||||
| '/partita/$id'
|
||||
| '/api/public/apri-sondaggio'
|
||||
| '/api/public/csi'
|
||||
| '/api/public/keepalive'
|
||||
| '/api/public/notifica-personalizzata'
|
||||
| '/api/public/notifiche-attive'
|
||||
| '/api/public/promemoria-palloni'
|
||||
| '/api/public/push-config'
|
||||
| '/api/public/push-subscribe'
|
||||
@@ -268,10 +318,14 @@ export interface RootRouteChildren {
|
||||
ScoutRoute: typeof ScoutRoute
|
||||
SquadraRoute: typeof SquadraRoute
|
||||
AllenamentoIdRoute: typeof AllenamentoIdRoute
|
||||
EventoIdRoute: typeof EventoIdRoute
|
||||
PartitaCsiIdRoute: typeof PartitaCsiIdRoute
|
||||
PartitaIdRoute: typeof PartitaIdRoute
|
||||
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
||||
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
||||
ApiPublicKeepaliveRoute: typeof ApiPublicKeepaliveRoute
|
||||
ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute
|
||||
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
|
||||
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
||||
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
||||
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
|
||||
@@ -351,6 +405,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AllenamentoIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/evento/$id': {
|
||||
id: '/evento/$id'
|
||||
path: '/evento/$id'
|
||||
fullPath: '/evento/$id'
|
||||
preLoaderRoute: typeof EventoIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/partita-csi/$id': {
|
||||
id: '/partita-csi/$id'
|
||||
path: '/partita-csi/$id'
|
||||
@@ -379,6 +440,27 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/public/keepalive': {
|
||||
id: '/api/public/keepalive'
|
||||
path: '/api/public/keepalive'
|
||||
fullPath: '/api/public/keepalive'
|
||||
preLoaderRoute: typeof ApiPublicKeepaliveRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/public/notifica-personalizzata': {
|
||||
id: '/api/public/notifica-personalizzata'
|
||||
path: '/api/public/notifica-personalizzata'
|
||||
fullPath: '/api/public/notifica-personalizzata'
|
||||
preLoaderRoute: typeof ApiPublicNotificaPersonalizzataRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/public/notifiche-attive': {
|
||||
id: '/api/public/notifiche-attive'
|
||||
path: '/api/public/notifiche-attive'
|
||||
fullPath: '/api/public/notifiche-attive'
|
||||
preLoaderRoute: typeof ApiPublicNotificheAttiveRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/public/promemoria-palloni': {
|
||||
id: '/api/public/promemoria-palloni'
|
||||
path: '/api/public/promemoria-palloni'
|
||||
@@ -428,10 +510,14 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ScoutRoute: ScoutRoute,
|
||||
SquadraRoute: SquadraRoute,
|
||||
AllenamentoIdRoute: AllenamentoIdRoute,
|
||||
EventoIdRoute: EventoIdRoute,
|
||||
PartitaCsiIdRoute: PartitaCsiIdRoute,
|
||||
PartitaIdRoute: PartitaIdRoute,
|
||||
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
||||
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
||||
ApiPublicKeepaliveRoute: ApiPublicKeepaliveRoute,
|
||||
ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute,
|
||||
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
|
||||
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
||||
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
||||
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import appCss from "../styles.css?url";
|
||||
import { reportLovableError } from "../lib/lovable-error-reporting";
|
||||
import { BottomNav } from "../components/crapp/BottomNav";
|
||||
import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
@@ -47,9 +46,6 @@ function NotFoundComponent() {
|
||||
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
||||
console.error(error);
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
reportLovableError(error, { boundary: "tanstack_root_error_component" });
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background px-4">
|
||||
|
||||
+425
-188
@@ -2,6 +2,8 @@ import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
BellOff,
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileText,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
Image,
|
||||
Loader2,
|
||||
Lock,
|
||||
Send,
|
||||
Unlink,
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
@@ -16,20 +19,17 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Campo, classiInput, PageHeader, Select, StatTile } from "@/components/crapp/ui-bits";
|
||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||
import { CampiAllenatore, CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import {
|
||||
Campo,
|
||||
classiInput,
|
||||
PageHeader,
|
||||
Section,
|
||||
Select,
|
||||
StatTile,
|
||||
} from "@/components/crapp/ui-bits";
|
||||
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import {
|
||||
inRosa,
|
||||
isAllenatore,
|
||||
nomeCompleto,
|
||||
numeroGiaUsato,
|
||||
prossimoIdGiocatore,
|
||||
RUOLI,
|
||||
ruoloVisibile,
|
||||
useAggiungiGiocatore,
|
||||
useGiocatoriSquadra,
|
||||
useImpostaAttivo,
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
|
||||
import {
|
||||
completamento,
|
||||
completamentoAllenatore,
|
||||
csvTesseramento,
|
||||
profiloVuoto,
|
||||
sezioniComplete,
|
||||
@@ -54,7 +55,17 @@ import {
|
||||
import { oggiISO } from "@/lib/palloni-core";
|
||||
import { scaricaCsv } from "@/lib/scout-export";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useInviaNotifica, useNotificheAttive } from "@/lib/notifiche-admin";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
export const Route = createFileRoute("/admin")({
|
||||
head: () => ({
|
||||
@@ -119,7 +130,10 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
numero: g.numero,
|
||||
ruolo: g.ruolo,
|
||||
email: g.email,
|
||||
tipo: g.tipo,
|
||||
};
|
||||
// L'allenatore non ha numero, ruolo in campo né tesseramento (DD-034).
|
||||
const allenatore = isAllenatore(g);
|
||||
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
|
||||
|
||||
async function confermaSquadra() {
|
||||
@@ -128,7 +142,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
toast.error(errore);
|
||||
return;
|
||||
}
|
||||
if (numeroGiaUsato(righe, g.id, squadraCorrente.numero)) {
|
||||
if (!allenatore && numeroGiaUsato(righe, g.id, squadraCorrente.numero)) {
|
||||
toast.error(`Il numero ${squadraCorrente.numero} è già assegnato a un altro giocatore.`);
|
||||
return;
|
||||
}
|
||||
@@ -209,30 +223,36 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Numero">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={squadraCorrente.numero}
|
||||
onChange={(e) => setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Ruolo">
|
||||
<Select
|
||||
value={squadraCorrente.ruolo}
|
||||
onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{RUOLI.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Campo>
|
||||
{allenatore ? null : (
|
||||
<>
|
||||
<Campo label="Numero">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={squadraCorrente.numero}
|
||||
onChange={(e) =>
|
||||
setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) })
|
||||
}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Ruolo">
|
||||
<Select
|
||||
value={squadraCorrente.ruolo}
|
||||
onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{RUOLI.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Campo>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Campo label="Email (collegamento automatico al login, DD-018)">
|
||||
<Campo label="Email">
|
||||
<input
|
||||
type="email"
|
||||
value={squadraCorrente.email ?? ""}
|
||||
@@ -250,44 +270,61 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
Salva dati squadra
|
||||
</button>
|
||||
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Numero tessera">
|
||||
<input
|
||||
value={tesseramentoCorrente.numeroTessera ?? ""}
|
||||
maxLength={40}
|
||||
placeholder="Non ancora tesserato"
|
||||
onChange={(e) =>
|
||||
setTesseramento({ ...tesseramentoCorrente, numeroTessera: e.target.value || null })
|
||||
}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Data tessera">
|
||||
<input
|
||||
type="date"
|
||||
value={tesseramentoCorrente.dataTessera ?? ""}
|
||||
onChange={(e) =>
|
||||
setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null })
|
||||
}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confermaTesseramento}
|
||||
disabled={!tesseramento || salvaTesseramento.isPending}
|
||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
Salva tesseramento
|
||||
</button>
|
||||
{allenatore ? null : (
|
||||
<>
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Numero tessera">
|
||||
<input
|
||||
value={tesseramentoCorrente.numeroTessera ?? ""}
|
||||
maxLength={40}
|
||||
placeholder="Non ancora tesserato"
|
||||
onChange={(e) =>
|
||||
setTesseramento({
|
||||
...tesseramentoCorrente,
|
||||
numeroTessera: e.target.value || null,
|
||||
})
|
||||
}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Data tessera">
|
||||
<input
|
||||
type="date"
|
||||
value={tesseramentoCorrente.dataTessera ?? ""}
|
||||
onChange={(e) =>
|
||||
setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null })
|
||||
}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confermaTesseramento}
|
||||
disabled={!tesseramento || salvaTesseramento.isPending}
|
||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
Salva tesseramento
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CampiProfilo
|
||||
corrente={profiloCorrente}
|
||||
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
|
||||
sezioni={sezioniComplete(profiloCorrente)}
|
||||
/>
|
||||
{allenatore ? (
|
||||
<>
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">Dati personali</h3>
|
||||
<CampiAllenatore
|
||||
corrente={profiloCorrente}
|
||||
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CampiProfilo
|
||||
corrente={profiloCorrente}
|
||||
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
|
||||
sezioni={sezioniComplete(profiloCorrente)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={confermaProfilo}
|
||||
@@ -384,16 +421,20 @@ function SchedaGiocatore({
|
||||
profilo,
|
||||
oggi,
|
||||
indice,
|
||||
aperta,
|
||||
onToggle,
|
||||
}: {
|
||||
g: GiocatoreSquadra;
|
||||
profilo: Profilo | undefined;
|
||||
oggi: string;
|
||||
indice: number;
|
||||
aperta: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [aperta, setAperta] = useState(false);
|
||||
const nome = nomeCompleto(g);
|
||||
const { ruolo, numero } = g;
|
||||
const perc = completamento(profilo);
|
||||
const { numero } = g;
|
||||
const allenatore = isAllenatore(g);
|
||||
const perc = allenatore ? completamentoAllenatore(profilo) : completamento(profilo);
|
||||
const sezioni = sezioniComplete(profilo);
|
||||
const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi);
|
||||
const fronte = statoScadenza(profilo?.documentoScadenza, profilo?.documentoFrontePath, oggi);
|
||||
@@ -401,18 +442,14 @@ function SchedaGiocatore({
|
||||
|
||||
return (
|
||||
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperta((v) => !v)}
|
||||
className="flex w-full items-center gap-3 text-left"
|
||||
>
|
||||
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 text-left">
|
||||
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums">
|
||||
{numero}
|
||||
{allenatore ? "All" : numero}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold leading-tight">{nome}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ruolo} · profilo {perc}%
|
||||
{ruoloVisibile(g)} · profilo {perc}%
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
@@ -430,55 +467,65 @@ function SchedaGiocatore({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Documento
|
||||
icona={<IdCard className="h-3.5 w-3.5" />}
|
||||
label="Doc fronte"
|
||||
stato={fronte}
|
||||
path={profilo?.documentoFrontePath ?? null}
|
||||
/>
|
||||
<Documento
|
||||
icona={<IdCard className="h-3.5 w-3.5" />}
|
||||
label="Doc retro"
|
||||
stato={retro}
|
||||
path={profilo?.documentoRetroPath ?? null}
|
||||
/>
|
||||
<Documento
|
||||
icona={<FileText className="h-3.5 w-3.5" />}
|
||||
label="Certificato"
|
||||
stato={certificato}
|
||||
path={profilo?.certificatoPath ?? null}
|
||||
/>
|
||||
<Documento
|
||||
icona={<Image className="h-3.5 w-3.5" />}
|
||||
label="Foto"
|
||||
stato={sezioni.foto ? "presente" : "assente"}
|
||||
path={profilo?.fotoPath ?? null}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
|
||||
statoClasse[g.numeroTessera ? "presente" : "assente"],
|
||||
)}
|
||||
>
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
{g.numeroTessera ? "Tesserato" : "Da tesserare"}
|
||||
</span>
|
||||
</div>
|
||||
{allenatore ? null : (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Documento
|
||||
icona={<IdCard className="h-3.5 w-3.5" />}
|
||||
label="Doc fronte"
|
||||
stato={fronte}
|
||||
path={profilo?.documentoFrontePath ?? null}
|
||||
/>
|
||||
<Documento
|
||||
icona={<IdCard className="h-3.5 w-3.5" />}
|
||||
label="Doc retro"
|
||||
stato={retro}
|
||||
path={profilo?.documentoRetroPath ?? null}
|
||||
/>
|
||||
<Documento
|
||||
icona={<FileText className="h-3.5 w-3.5" />}
|
||||
label="Certificato"
|
||||
stato={certificato}
|
||||
path={profilo?.certificatoPath ?? null}
|
||||
/>
|
||||
<Documento
|
||||
icona={<Image className="h-3.5 w-3.5" />}
|
||||
label="Foto"
|
||||
stato={sezioni.foto ? "presente" : "assente"}
|
||||
path={profilo?.fotoPath ?? null}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
|
||||
statoClasse[g.numeroTessera ? "presente" : "assente"],
|
||||
)}
|
||||
>
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
{g.numeroTessera ? "Tesserato" : "Da tesserare"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aperta ? <ModificaGiocatore g={g} profilo={profilo} /> : null}
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
const datiVuoti: DatiSquadra = { nome: "", cognome: "", numero: 1, ruolo: "", email: null };
|
||||
const datiVuoti: DatiSquadra = {
|
||||
nome: "",
|
||||
cognome: "",
|
||||
numero: 1,
|
||||
ruolo: "",
|
||||
email: null,
|
||||
tipo: "giocatore",
|
||||
};
|
||||
|
||||
/** Form per aggiungere un giocatore alla rosa (DD-017): l'id `g<N>` è calcolato in app,
|
||||
* il database non lo genera da solo. */
|
||||
/** Form per aggiungere un giocatore alla rosa (DD-017), o un allenatore (DD-034): l'id
|
||||
* `g<N>` è calcolato in app, il database non lo genera da solo. */
|
||||
function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||
const [aperto, setAperto] = useState(false);
|
||||
const [dati, setDati] = useState<DatiSquadra>(datiVuoti);
|
||||
const aggiungi = useAggiungiGiocatore();
|
||||
const allenatore = dati.tipo === "allenatore";
|
||||
|
||||
async function conferma() {
|
||||
const errore = validaDatiSquadra(dati);
|
||||
@@ -486,13 +533,17 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||
toast.error(errore);
|
||||
return;
|
||||
}
|
||||
if (numeroGiaUsato(righe, "", dati.numero)) {
|
||||
if (!allenatore && numeroGiaUsato(righe, "", dati.numero)) {
|
||||
toast.error(`Il numero ${dati.numero} è già assegnato a un altro giocatore.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await aggiungi.mutateAsync({ id: prossimoIdGiocatore(righe), dati });
|
||||
toast.success(`${dati.nome} ${dati.cognome} aggiunto alla rosa`);
|
||||
toast.success(
|
||||
allenatore
|
||||
? `${dati.nome} ${dati.cognome} aggiunto come allenatore`
|
||||
: `${dati.nome} ${dati.cognome} aggiunto alla rosa`,
|
||||
);
|
||||
setDati(datiVuoti);
|
||||
setAperto(false);
|
||||
} catch (e) {
|
||||
@@ -507,14 +558,27 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||
onClick={() => setAperto(true)}
|
||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" /> Aggiungi giocatore
|
||||
<UserPlus className="h-4 w-4" /> Aggiungi giocatore o allenatore
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 rounded-2xl bg-card p-4 shadow-card">
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">Nuovo giocatore</h3>
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">
|
||||
{allenatore ? "Nuovo allenatore" : "Nuovo giocatore"}
|
||||
</h3>
|
||||
<Campo label="Tipo">
|
||||
<Select
|
||||
value={dati.tipo}
|
||||
onChange={(e) =>
|
||||
setDati({ ...dati, tipo: e.target.value === "allenatore" ? "allenatore" : "giocatore" })
|
||||
}
|
||||
>
|
||||
<option value="giocatore">Giocatore</option>
|
||||
<option value="allenatore">Allenatore</option>
|
||||
</Select>
|
||||
</Campo>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Nome">
|
||||
<input
|
||||
@@ -532,27 +596,34 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Numero">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={dati.numero}
|
||||
onChange={(e) => setDati({ ...dati, numero: Number(e.target.value) })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Ruolo">
|
||||
<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>
|
||||
</Campo>
|
||||
{allenatore ? null : (
|
||||
<>
|
||||
<Campo label="Numero">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={dati.numero}
|
||||
onChange={(e) => setDati({ ...dati, numero: Number(e.target.value) })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Ruolo">
|
||||
<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>
|
||||
</Campo>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Campo label="Email (collegamento automatico al login, opzionale)">
|
||||
<Campo label="Email">
|
||||
<input
|
||||
type="email"
|
||||
value={dati.email ?? ""}
|
||||
@@ -604,7 +675,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold leading-tight">{nomeCompleto(g)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
#{g.numero} · {g.ruolo}
|
||||
{isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -623,7 +694,35 @@ function Dashboard() {
|
||||
const admin = useIsAdmin();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const { profili, isPending } = useProfili();
|
||||
const { data: notificheAttive } = useNotificheAttive();
|
||||
const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
|
||||
const oggi = oggiISO();
|
||||
const inviaNotifica = useInviaNotifica();
|
||||
const [destinatarioMessaggio, setDestinatarioMessaggio] = useState<{
|
||||
id?: string;
|
||||
nome: string;
|
||||
} | null>(null);
|
||||
const [messaggio, setMessaggio] = useState("");
|
||||
|
||||
async function inviaMessaggio() {
|
||||
if (!destinatarioMessaggio || !messaggio.trim()) return;
|
||||
try {
|
||||
const dati = await inviaNotifica.mutateAsync(
|
||||
destinatarioMessaggio.id
|
||||
? { messaggio: messaggio.trim(), giocatoreId: destinatarioMessaggio.id }
|
||||
: { messaggio: messaggio.trim() },
|
||||
);
|
||||
toast.success(
|
||||
dati.inviate > 0
|
||||
? `Notifica inviata a ${dati.inviate} dispositivi`
|
||||
: "Nessun dispositivo con notifiche attive per l'invio",
|
||||
);
|
||||
setDestinatarioMessaggio(null);
|
||||
setMessaggio("");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a inviare la notifica");
|
||||
}
|
||||
}
|
||||
|
||||
if (!admin) {
|
||||
return (
|
||||
@@ -639,7 +738,11 @@ function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
const attivi = squadra.filter((g) => g.attivo);
|
||||
// Conteggi, tesseramento ed export CSI riguardano solo chi gioca (DD-034).
|
||||
const attivi = squadra.filter(inRosa);
|
||||
const allenatori = squadra.filter((g) => g.attivo && isAllenatore(g));
|
||||
// Le notifiche arrivano anche agli allenatori: nella tab Notifiche ci sono tutti.
|
||||
const membri = [...attivi, ...allenatori];
|
||||
const disattivi = squadra.filter((g) => !g.attivo);
|
||||
const completi = attivi.filter((g) => completamento(profili[g.id]) === 100).length;
|
||||
const certificatiOk = attivi.filter(
|
||||
@@ -649,56 +752,190 @@ function Dashboard() {
|
||||
).length;
|
||||
const tesserati = attivi.filter((g) => g.numeroTessera).length;
|
||||
|
||||
const contenutoSquadra = (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatTile valore={attivi.length} label="Giocatori" />
|
||||
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
|
||||
<StatTile
|
||||
valore={`${certificatiOk}/${attivi.length}`}
|
||||
label="Certificati validi"
|
||||
hint="non scaduti"
|
||||
/>
|
||||
<StatTile valore={`${tesserati}/${attivi.length}`} label="Tesserati" hint="CSI" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))}
|
||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
||||
</button>
|
||||
<AggiungiGiocatore righe={squadra} />
|
||||
</>
|
||||
);
|
||||
|
||||
const contenutoProfili = isPending ? (
|
||||
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
Caricamento…
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{attivi.map((g, i) => (
|
||||
<SchedaGiocatore
|
||||
key={g.id}
|
||||
g={g}
|
||||
profilo={profili[g.id]}
|
||||
oggi={oggi}
|
||||
indice={i}
|
||||
aperta={schedaAperta === g.id}
|
||||
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
|
||||
/>
|
||||
))}
|
||||
{allenatori.length > 0 ? (
|
||||
<h3 className="pt-2 font-display text-sm uppercase tracking-wide">Allenatori</h3>
|
||||
) : null}
|
||||
{allenatori.map((g, i) => (
|
||||
<SchedaGiocatore
|
||||
key={g.id}
|
||||
g={g}
|
||||
profilo={profili[g.id]}
|
||||
oggi={oggi}
|
||||
indice={attivi.length + i}
|
||||
aperta={schedaAperta === g.id}
|
||||
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const contenutoDisattivati = (
|
||||
<div className="space-y-2">
|
||||
{disattivi.map((g) => (
|
||||
<GiocatoreDisattivato key={g.id} g={g} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const contenutoNotifiche = notificheAttive ? (
|
||||
<>
|
||||
<StatTile
|
||||
valore={`${membri.filter((g) => notificheAttive.has(g.id)).length}/${membri.length}`}
|
||||
label="Notifiche attive"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDestinatarioMessaggio({ nome: "tutta la squadra" })}
|
||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<Send className="h-4 w-4" /> Invia messaggio a tutti
|
||||
</button>
|
||||
<div className="mt-3 space-y-2">
|
||||
{membri.map((g) => {
|
||||
const attiva = notificheAttive.has(g.id);
|
||||
return (
|
||||
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
|
||||
{attiva ? (
|
||||
<Bell className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
) : (
|
||||
<BellOff className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-sm font-semibold leading-tight",
|
||||
!attiva && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{nomeCompleto(g)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDestinatarioMessaggio({ id: g.id, nome: nomeCompleto(g) })}
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
|
||||
aria-label={`Invia messaggio a ${nomeCompleto(g)}`}
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
Caricamento…
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" />
|
||||
|
||||
<Section titolo="Squadra">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatTile valore={attivi.length} label="Giocatori" />
|
||||
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
|
||||
<StatTile
|
||||
valore={`${certificatiOk}/${attivi.length}`}
|
||||
label="Certificati validi"
|
||||
hint="non scaduti"
|
||||
/>
|
||||
<StatTile valore={`${tesserati}/${attivi.length}`} label="Tesserati" hint="CSI" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))
|
||||
<BarraSottosezioni
|
||||
defaultId="squadra"
|
||||
variante="sottolineatura"
|
||||
riempiLarghezza
|
||||
voci={[
|
||||
{ id: "squadra", label: "Squadra", contenuto: contenutoSquadra },
|
||||
{ id: "profili", label: "Profili", contenuto: contenutoProfili },
|
||||
...(disattivi.length > 0
|
||||
? [{ id: "disattivati", label: "Disattivati", contenuto: contenutoDisattivati }]
|
||||
: []),
|
||||
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
open={!!destinatarioMessaggio}
|
||||
onOpenChange={(aperto) => {
|
||||
if (!aperto) {
|
||||
setDestinatarioMessaggio(null);
|
||||
setMessaggio("");
|
||||
}
|
||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
||||
</button>
|
||||
<AggiungiGiocatore righe={squadra} />
|
||||
</Section>
|
||||
|
||||
<Section titolo="Profili" indice={1}>
|
||||
{isPending ? (
|
||||
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
Caricamento…
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{attivi.map((g, i) => (
|
||||
<SchedaGiocatore key={g.id} g={g} profilo={profili[g.id]} oggi={oggi} indice={i} />
|
||||
))}
|
||||
}}
|
||||
>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Invia messaggio</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{destinatarioMessaggio ? `Destinatario: ${destinatarioMessaggio.nome}.` : ""}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4">
|
||||
<textarea
|
||||
value={messaggio}
|
||||
maxLength={300}
|
||||
rows={3}
|
||||
onChange={(e) => setMessaggio(e.target.value)}
|
||||
placeholder="Scrivi il messaggio da inviare come notifica push…"
|
||||
className={cn(classiInput, "h-auto")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{disattivi.length > 0 ? (
|
||||
<Section titolo="Giocatori disattivati" indice={2}>
|
||||
<div className="space-y-2">
|
||||
{disattivi.map((g) => (
|
||||
<GiocatoreDisattivato key={g.id} g={g} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
<DrawerFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={inviaMessaggio}
|
||||
disabled={inviaNotifica.isPending || !messaggio.trim()}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{inviaNotifica.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}{" "}
|
||||
Invia
|
||||
</button>
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -91,7 +91,7 @@ function AllenamentoDetail() {
|
||||
</Section>
|
||||
|
||||
<Section titolo="Rosa e presenze">
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} />
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
import { avvisoSondaggio, destinatariSondaggio } from "@/lib/cacche";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
import { isAllenatore } from "@/lib/giocatori-squadra";
|
||||
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
|
||||
|
||||
const schema = z.object({ eventoId: z.string().min(1).max(50) });
|
||||
|
||||
@@ -22,15 +26,36 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
|
||||
if (!partita) return new Response("Evento non trovato", { status: 404 });
|
||||
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { data: iscrizioni } = await supabaseAdmin
|
||||
const { data: tutte } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("endpoint, p256dh, auth");
|
||||
.select("endpoint, p256dh, auth, giocatore_id");
|
||||
const squadra = await leggiGiocatoriSquadra();
|
||||
// Il sondaggio cacche non riguarda l'allenatore, che non le vede (DD-034).
|
||||
const allenatori = new Set(squadra.filter(isAllenatore).map((g) => g.id));
|
||||
const iscrizioni = (tutte ?? []).filter((i) => !allenatori.has(i.giocatore_id));
|
||||
|
||||
const titolo = "💩 Sondaggio pre-partita aperto";
|
||||
const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`;
|
||||
const { titolo, testo } = avvisoSondaggio(partita);
|
||||
|
||||
// Storico in-app ed email (DD-038): indipendente dai dispositivi iscritti alla push. Un
|
||||
// upsert perché ripremere il pulsante per la stessa partita deve riportare la notifica a
|
||||
// non letta, non fallire per il vincolo UNIQUE; la mail riparte dal trigger di M22.
|
||||
// `types.ts` non include ancora `notifiche_utente`, stesso aggiramento delle altre route.
|
||||
const client = supabaseAdmin as unknown as SupabaseClient;
|
||||
await client.from("notifiche_utente").upsert(
|
||||
destinatariSondaggio(squadra).map((giocatoreId) => ({
|
||||
giocatore_id: giocatoreId,
|
||||
tipo: "sondaggio_cacche",
|
||||
titolo,
|
||||
corpo: testo,
|
||||
evento_id: partita.id,
|
||||
letta: false,
|
||||
creato_il: new Date().toISOString(),
|
||||
})),
|
||||
{ onConflict: "giocatore_id,evento_id,tipo" },
|
||||
);
|
||||
|
||||
let inviate = 0;
|
||||
for (const iscrizione of iscrizioni ?? []) {
|
||||
for (const iscrizione of iscrizioni) {
|
||||
try {
|
||||
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||
if (stato === 404 || stato === 410) {
|
||||
@@ -46,7 +71,7 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ inviate, destinatari: (iscrizioni ?? []).length });
|
||||
return Response.json({ inviate, destinatari: iscrizioni.length });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
/**
|
||||
* Chiamata dal Vercel Cron Job (vedi vercel.json) per tenere attivo il progetto Supabase
|
||||
* free tier, che va in pausa dopo 7 giorni senza richieste API. Una lettura minima basta:
|
||||
* nessun dato sensibile, nessun effetto collaterale, quindi nessuna autenticazione richiesta.
|
||||
*/
|
||||
export const Route = createFileRoute("/api/public/keepalive")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => {
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { error } = await supabaseAdmin
|
||||
.from("giocatori_squadra")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.limit(1);
|
||||
if (error) {
|
||||
console.error("keepalive", error);
|
||||
return new Response("Errore", { status: 500 });
|
||||
}
|
||||
return new Response("ok");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
|
||||
const schema = z.object({
|
||||
messaggio: z.string().min(1).max(300),
|
||||
giocatoreId: z.string().min(1).max(50).optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/api/public/notifica-personalizzata")({
|
||||
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 });
|
||||
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
// `types.ts` non include ancora `notifiche_utente` (M17): stesso aggiramento di
|
||||
// `giocatori-squadra.server.ts` finché non viene rigenerato.
|
||||
const client = supabaseAdmin as unknown as SupabaseClient;
|
||||
|
||||
let query = supabaseAdmin.from("push_subscriptions").select("endpoint, p256dh, auth");
|
||||
if (parsed.data.giocatoreId) {
|
||||
query = query.eq("giocatore_id", parsed.data.giocatoreId);
|
||||
}
|
||||
const { data: iscrizioni } = await query;
|
||||
|
||||
const titolo = "Messaggio dallo staff";
|
||||
const testo = parsed.data.messaggio.trim();
|
||||
|
||||
let inviate = 0;
|
||||
for (const iscrizione of iscrizioni ?? []) {
|
||||
try {
|
||||
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||
if (stato === 404 || stato === 410) {
|
||||
await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.delete()
|
||||
.eq("endpoint", iscrizione.endpoint);
|
||||
} else if (stato >= 200 && stato < 300) {
|
||||
inviate += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("notifica-personalizzata", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Storico in-app (M17): indipendente dall'esito della push, e non limitato a chi ha
|
||||
// un dispositivo iscritto. Senza `giocatoreId` va a tutta la rosa attiva.
|
||||
let destinatariNotifica: string[];
|
||||
if (parsed.data.giocatoreId) {
|
||||
destinatariNotifica = [parsed.data.giocatoreId];
|
||||
} else {
|
||||
const { data: rosa } = await client
|
||||
.from("giocatori_squadra")
|
||||
.select("id")
|
||||
.eq("attivo", true);
|
||||
destinatariNotifica = ((rosa ?? []) as Array<{ id: string }>).map((r) => r.id);
|
||||
}
|
||||
if (destinatariNotifica.length > 0) {
|
||||
await client.from("notifiche_utente").insert(
|
||||
destinatariNotifica.map((giocatoreId) => ({
|
||||
giocatore_id: giocatoreId,
|
||||
tipo: "admin",
|
||||
titolo,
|
||||
corpo: testo,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ inviate, destinatari: iscrizioni?.length ?? 0 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
import { idsConNotificheAttive } from "@/lib/notifiche-attive.server";
|
||||
|
||||
export const Route = createFileRoute("/api/public/notifiche-attive")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const negato = await richiediAdmin(request);
|
||||
if (negato) return negato;
|
||||
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("giocatore_id");
|
||||
if (error) {
|
||||
console.error("notifiche-attive", error);
|
||||
return new Response("Errore lettura", { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ giocatoreIds: idsConNotificheAttive(data ?? []) });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
import { nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
import { inRosa, nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
|
||||
import { avvisiPalloniEvento, completaTurni } from "@/lib/palloni-core";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
@@ -36,14 +37,30 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
||||
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
|
||||
|
||||
const squadra = await leggiGiocatoriSquadra();
|
||||
const rosa = squadra
|
||||
.filter((g) => g.attivo)
|
||||
.map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
const rosa = squadra.filter(inRosa).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
const turni = completaTurni(salvati, eventi, rosa);
|
||||
|
||||
const avvisi = avvisiPalloniEvento(turni, eventi, evento.id);
|
||||
if (avvisi.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
|
||||
|
||||
// Storico in-app (M17): un upsert perché ripremere il pulsante deve riportare la
|
||||
// notifica a non letta con il testo aggiornato, non fallire per il vincolo UNIQUE.
|
||||
// `types.ts` non include ancora `notifiche_utente`, stesso aggiramento di
|
||||
// `giocatori-squadra.server.ts`.
|
||||
const client = supabaseAdmin as unknown as SupabaseClient;
|
||||
await client.from("notifiche_utente").upsert(
|
||||
avvisi.map((a) => ({
|
||||
giocatore_id: a.giocatoreId,
|
||||
tipo: "turno_palloni",
|
||||
titolo: a.titolo,
|
||||
corpo: a.testo,
|
||||
evento_id: evento.id,
|
||||
letta: false,
|
||||
creato_il: new Date().toISOString(),
|
||||
})),
|
||||
{ onConflict: "giocatore_id,evento_id,tipo" },
|
||||
);
|
||||
|
||||
const { data: iscrizioni } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("endpoint, giocatore_id, p256dh, auth")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { richiediGestoreEventi } from "@/lib/auth-route.server";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
|
||||
import { destinatariSollecito } from "@/lib/presenze";
|
||||
import { destinatariSollecito, testoSollecito } from "@/lib/presenze";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -16,7 +16,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const negato = await richiediAdmin(request);
|
||||
const negato = await richiediGestoreEventi(request);
|
||||
if (negato) return negato;
|
||||
|
||||
const parsed = schema.safeParse(await request.json());
|
||||
@@ -34,24 +34,48 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
.eq("evento_id", evento.id);
|
||||
|
||||
const squadra = await leggiGiocatoriSquadra();
|
||||
const destinatari = destinatariSollecito(squadra, righe ?? []);
|
||||
const destinatari = destinatariSollecito(squadra, righe ?? [], evento.convocati);
|
||||
|
||||
if (destinatari.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
|
||||
|
||||
const { data: iscrizioni } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("endpoint, p256dh, auth")
|
||||
.select("endpoint, giocatore_id, p256dh, auth")
|
||||
.in("giocatore_id", destinatari);
|
||||
|
||||
const titolo = "Manca la tua risposta";
|
||||
const testo = `${evento.titolo} · ${formatData(evento.data)} ore ${evento.ora}. ${
|
||||
parsed.data.da ? `${parsed.data.da} chiede` : "Serve"
|
||||
} una conferma: presente, assente o in ritardo?`;
|
||||
// Il testo dice se il giocatore non ha risposto o ha risposto «forse» (DD-040): è
|
||||
// quindi per destinatario, e la push porta il testo del proprio destinatario.
|
||||
const stati = new Map((righe ?? []).map((r) => [r.giocatore_id, r.stato]));
|
||||
const testi = new Map(
|
||||
destinatari.map((giocatoreId) => [
|
||||
giocatoreId,
|
||||
testoSollecito(evento, stati.get(giocatoreId), parsed.data.da),
|
||||
]),
|
||||
);
|
||||
|
||||
// Storico in-app (M17): un upsert perché ripremere il pulsante deve riportare la
|
||||
// notifica a non letta, non fallire per il vincolo UNIQUE. `types.ts` non include
|
||||
// ancora `notifiche_utente`, stesso aggiramento di `giocatori-squadra.server.ts`.
|
||||
const client = supabaseAdmin as unknown as SupabaseClient;
|
||||
await client.from("notifiche_utente").upsert(
|
||||
destinatari.map((giocatoreId) => ({
|
||||
giocatore_id: giocatoreId,
|
||||
tipo: "sollecita_presenze",
|
||||
titolo: testi.get(giocatoreId)?.titolo,
|
||||
corpo: testi.get(giocatoreId)?.testo,
|
||||
evento_id: evento.id,
|
||||
letta: false,
|
||||
creato_il: new Date().toISOString(),
|
||||
})),
|
||||
{ onConflict: "giocatore_id,evento_id,tipo" },
|
||||
);
|
||||
|
||||
let inviate = 0;
|
||||
for (const iscrizione of iscrizioni ?? []) {
|
||||
const avviso = testi.get(iscrizione.giocatore_id);
|
||||
if (!avviso) continue;
|
||||
try {
|
||||
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||
const { stato } = await inviaPush(iscrizione, avviso.titolo, avviso.testo);
|
||||
if (stato === 404 || stato === 410) {
|
||||
await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
|
||||
@@ -10,7 +10,8 @@ import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { usePuoGestireEventi } from "@/lib/ruoli";
|
||||
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -37,8 +38,6 @@ export const Route = createFileRoute("/calendario")({
|
||||
component: Calendario,
|
||||
});
|
||||
|
||||
const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
|
||||
|
||||
/** Colore per tipo di evento, usato per dividere le celle con più tipi. */
|
||||
const coloreTipo: Record<Evento["tipo"], string> = {
|
||||
partita: "var(--accent)",
|
||||
@@ -47,63 +46,6 @@ const coloreTipo: Record<Evento["tipo"], string> = {
|
||||
compleanno: "var(--success)",
|
||||
};
|
||||
|
||||
const mesiIT = [
|
||||
"Gennaio",
|
||||
"Febbraio",
|
||||
"Marzo",
|
||||
"Aprile",
|
||||
"Maggio",
|
||||
"Giugno",
|
||||
"Luglio",
|
||||
"Agosto",
|
||||
"Settembre",
|
||||
"Ottobre",
|
||||
"Novembre",
|
||||
"Dicembre",
|
||||
] as const;
|
||||
|
||||
function useMeseNav(initial?: { anno: number; mese: number }) {
|
||||
const oggi = new Date();
|
||||
const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear());
|
||||
const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth());
|
||||
// Serve a far entrare e uscire la griglia dallo stesso lato del gesto:
|
||||
// se un mese esce a sinistra, il precedente deve rientrare da sinistra.
|
||||
const [direzione, setDirezione] = useState(0);
|
||||
|
||||
const precedente = () => {
|
||||
setDirezione(-1);
|
||||
if (mese === 0) {
|
||||
setMese(11);
|
||||
setAnno((a) => a - 1);
|
||||
} else {
|
||||
setMese((m) => m - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const successivo = () => {
|
||||
setDirezione(1);
|
||||
if (mese === 11) {
|
||||
setMese(0);
|
||||
setAnno((a) => a + 1);
|
||||
} else {
|
||||
setMese((m) => m + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return { anno, mese, direzione, precedente, successivo };
|
||||
}
|
||||
|
||||
function giorniDelMese(anno: number, mese: number) {
|
||||
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
|
||||
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
|
||||
const offsetLunedi = (primoGiorno + 6) % 7;
|
||||
return { giorni, offsetLunedi };
|
||||
}
|
||||
|
||||
function pad2(n: number) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function Calendario() {
|
||||
// SSR-safe: la data di oggi arriva solo dopo il mount.
|
||||
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
|
||||
@@ -113,9 +55,9 @@ function Calendario() {
|
||||
}, []);
|
||||
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
|
||||
const [drawerAperto, setDrawerAperto] = useState(false);
|
||||
const admin = useIsAdmin();
|
||||
const puoGestire = usePuoGestireEventi();
|
||||
const { eventi } = useEventi();
|
||||
const rosa = useAnagraficaRosa();
|
||||
const rosa = useAnagraficaRosa({ conAllenatori: true });
|
||||
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
|
||||
const ridotto = useMotoRidotto();
|
||||
@@ -302,7 +244,7 @@ function Calendario() {
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
{admin ? (
|
||||
{puoGestire ? (
|
||||
<div className="px-5 pt-4">
|
||||
<Link
|
||||
to="/eventi"
|
||||
|
||||
+171
-82
@@ -3,15 +3,56 @@ import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { AlertTriangle, ChevronRight, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatData, type RigaClassifica } from "@/lib/crapp-data";
|
||||
import { PageHeader } from "@/components/crapp/ui-bits";
|
||||
import { PageHeader, TeamLogo } from "@/components/crapp/ui-bits";
|
||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||
import { useScoutMatches } from "@/lib/scout-store";
|
||||
import { useCsi } from "@/lib/csi";
|
||||
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
||||
import {
|
||||
isNostraSquadra,
|
||||
matchDaPartitaCsi,
|
||||
partiteGiocate,
|
||||
raggruppaPerStagione,
|
||||
stagioneDaPartite,
|
||||
} from "@/lib/csi-core";
|
||||
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
|
||||
import { useEventi } from "@/lib/eventi";
|
||||
import { LogoSquadra } from "@/components/crapp/DettaglioCsi";
|
||||
|
||||
const LOGO_NOI = "/logo-nerorosso.svg";
|
||||
const NOME_NOI = "CRAP Volley";
|
||||
|
||||
/** Logo CRAP o avversario; cerchio con iniziali se il CSI non ha l'immagine. */
|
||||
function LogoPartita({
|
||||
nostro,
|
||||
logoAvversario,
|
||||
avversario,
|
||||
}: {
|
||||
nostro: boolean;
|
||||
logoAvversario: string;
|
||||
avversario: string;
|
||||
}) {
|
||||
if (nostro) {
|
||||
return <TeamLogo src={LOGO_NOI} className="h-8 w-8 rounded-lg shadow-none" />;
|
||||
}
|
||||
if (logoAvversario) {
|
||||
return <LogoSquadra src={logoAvversario} alt={avversario} className="h-8 w-8" />;
|
||||
}
|
||||
const iniziali = avversario
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
return (
|
||||
<span
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-secondary text-[10px] font-bold text-muted-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
{iniziali || "?"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const TAB_CLASSIFICA = ["classifica", "storico"] as const;
|
||||
type TabClassifica = (typeof TAB_CLASSIFICA)[number];
|
||||
|
||||
@@ -92,6 +133,8 @@ function Classifica() {
|
||||
|
||||
const classifica = useMemo(() => csi?.classifica ?? [], [csi]);
|
||||
const classificaCoppa = useMemo(() => csi?.classificaCoppa ?? [], [csi]);
|
||||
// Tutte le gare, non solo quelle giocate: a inizio stagione il calendario c'è già.
|
||||
const stagioneCsi = useMemo(() => stagioneDaPartite(csi?.partite ?? []), [csi]);
|
||||
|
||||
const mvpPerMatch = useMemo(() => vincitoriMvp(votiMvp.data ?? []), [votiMvp.data]);
|
||||
const eventoIdPerData = useMemo(
|
||||
@@ -165,87 +208,127 @@ function Classifica() {
|
||||
Nessuna partita disponibile.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tuttiMatch.map((m) => {
|
||||
const vinta = m.setNostri > m.setLoro;
|
||||
const eventoId = eventoIdPerData.get(m.data);
|
||||
const contenuto = (
|
||||
<>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<LogoSquadra src={m.logoAvversario} alt={m.avversario} className="h-8 w-8" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{m.casa ? "CRAP Volley" : m.avversario} vs{" "}
|
||||
{m.casa ? m.avversario : "CRAP Volley"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatData(m.data)} · MVP {m.mvp || "da votare"}
|
||||
{m.scout ? " · scoutata" : ""}
|
||||
</p>
|
||||
<div className="space-y-5">
|
||||
{raggruppaPerStagione(tuttiMatch).map(({ stagione, match }) => (
|
||||
<section key={stagione} className="space-y-3">
|
||||
<h3 className="px-1 text-sm font-bold text-foreground">Stagione {stagione}</h3>
|
||||
{match.map((m) => {
|
||||
const vinta = m.setNostri > m.setLoro;
|
||||
const eventoId = eventoIdPerData.get(m.data);
|
||||
const cliccabile = Boolean(eventoId) || !m.scout;
|
||||
const casa = {
|
||||
nome: m.casa ? NOME_NOI : m.avversario,
|
||||
nostro: m.casa,
|
||||
};
|
||||
const trasferta = {
|
||||
nome: m.casa ? m.avversario : NOME_NOI,
|
||||
nostro: !m.casa,
|
||||
};
|
||||
// Badge e parziali in ordine casa–ospite; il colore resta sulla vittoria CRAP.
|
||||
const setCasa = m.casa ? m.setNostri : m.setLoro;
|
||||
const setOspite = m.casa ? m.setLoro : m.setNostri;
|
||||
const contenuto = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<LogoPartita
|
||||
nostro={casa.nostro}
|
||||
logoAvversario={m.logoAvversario}
|
||||
avversario={m.avversario}
|
||||
/>
|
||||
<p className="truncate text-sm font-bold">{casa.nome}</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<LogoPartita
|
||||
nostro={trasferta.nostro}
|
||||
logoAvversario={m.logoAvversario}
|
||||
avversario={m.avversario}
|
||||
/>
|
||||
<p className="truncate text-sm font-bold">{trasferta.nome}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatData(m.data)} · MVP {m.mvp || "da votare"}
|
||||
{m.scout ? " · scoutata" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-xl px-2.5 py-1 font-display text-xl",
|
||||
vinta
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-destructive text-destructive-foreground",
|
||||
)}
|
||||
>
|
||||
{setCasa}-{setOspite}
|
||||
</span>
|
||||
{cliccabile ? (
|
||||
<ChevronRight
|
||||
className="h-5 w-5 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-xl px-2.5 py-1 font-display text-lg",
|
||||
vinta
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-destructive text-destructive-foreground",
|
||||
)}
|
||||
>
|
||||
{m.setNostri}-{m.setLoro}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{m.parziali.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
|
||||
p[0] > p[1] ? "bg-secondary" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{m.parziali.map((p, i) => {
|
||||
const puntiCasa = m.casa ? p[0] : p[1];
|
||||
const puntiOspite = m.casa ? p[1] : p[0];
|
||||
const setVintoDaNoi = p[0] > p[1];
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
|
||||
setVintoDaNoi ? "bg-secondary" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{puntiCasa}-{puntiOspite}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{eventoId && !m.mvp ? (
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
|
||||
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const aria = `${casa.nome} vs ${trasferta.nome}: dettaglio partita`;
|
||||
if (eventoId) {
|
||||
return (
|
||||
<Link
|
||||
key={m.id}
|
||||
to="/partita/$id"
|
||||
params={{ id: eventoId }}
|
||||
aria-label={aria}
|
||||
className="premi block rounded-3xl bg-card p-4 shadow-card ring-1 ring-transparent transition-[box-shadow,transform] active:scale-[0.99] hover:ring-accent/30"
|
||||
>
|
||||
{p[0]}-{p[1]}
|
||||
</span>
|
||||
))}
|
||||
{eventoId && !m.mvp ? (
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
|
||||
Vota MVP <ChevronRight className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (eventoId) {
|
||||
return (
|
||||
<Link
|
||||
key={m.id}
|
||||
to="/partita/$id"
|
||||
params={{ id: eventoId }}
|
||||
className="premi block rounded-3xl bg-card p-4 shadow-card active:scale-[0.99]"
|
||||
>
|
||||
{contenuto}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
// Senza evento CrAPP collegato (nessuna creazione automatica dal calendario
|
||||
// CSI, vedi "Evoluzioni possibili"): le gare scoutate localmente non hanno un
|
||||
// corrispettivo sul portale da mostrare, quelle CSI sì.
|
||||
return m.scout ? (
|
||||
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
|
||||
{contenuto}
|
||||
</article>
|
||||
) : (
|
||||
<Link
|
||||
key={m.id}
|
||||
to="/partita-csi/$id"
|
||||
params={{ id: m.id }}
|
||||
className="premi block rounded-3xl bg-card p-4 shadow-card active:scale-[0.99]"
|
||||
>
|
||||
{contenuto}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{contenuto}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
// Senza evento CrAPP collegato (nessuna creazione automatica dal calendario
|
||||
// CSI, vedi "Evoluzioni possibili"): le gare scoutate localmente non hanno un
|
||||
// corrispettivo sul portale da mostrare, quelle CSI sì.
|
||||
return m.scout ? (
|
||||
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
|
||||
{contenuto}
|
||||
</article>
|
||||
) : (
|
||||
<Link
|
||||
key={m.id}
|
||||
to="/partita-csi/$id"
|
||||
params={{ id: m.id }}
|
||||
aria-label={aria}
|
||||
className="premi block rounded-3xl bg-card p-4 shadow-card ring-1 ring-transparent transition-[box-shadow,transform] active:scale-[0.99] hover:ring-accent/30"
|
||||
>
|
||||
{contenuto}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
[tuttiMatch, eventoIdPerData],
|
||||
@@ -255,7 +338,13 @@ function Classifica() {
|
||||
<>
|
||||
<PageHeader
|
||||
titolo="Campionato"
|
||||
sottotitolo={csi ? `${csi.girone} · CSI Bologna` : "CSI Bologna"}
|
||||
sottotitolo={
|
||||
csi
|
||||
? [csi.girone, "CSI Bologna", stagioneCsi && `Stagione ${stagioneCsi}`]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "CSI Bologna"
|
||||
}
|
||||
/>
|
||||
|
||||
<BarraSottosezioni
|
||||
|
||||
+300
-77
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Campo, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { Campo, Card, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -14,7 +15,8 @@ import {
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import {
|
||||
categoriaEvento,
|
||||
daCategoria,
|
||||
@@ -26,7 +28,9 @@ import {
|
||||
type Evento,
|
||||
} from "@/lib/eventi";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { usePuoGestireEventi } from "@/lib/ruoli";
|
||||
import { molla, proietta } from "@/lib/molla";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
|
||||
export const Route = createFileRoute("/eventi")({
|
||||
head: () => ({
|
||||
@@ -57,18 +61,32 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [
|
||||
];
|
||||
|
||||
function GestioneEventi() {
|
||||
// Solo verità (`!io`, gate admin): `useGiocatoreBase` basta, niente statistiche.
|
||||
// Solo verità (`!io`, gate admin o allenatore): `useGiocatoreBase` basta, niente statistiche.
|
||||
const io = useGiocatoreBase();
|
||||
const admin = useIsAdmin();
|
||||
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
|
||||
const puoGestire = usePuoGestireEventi();
|
||||
const { eventi, isPending, isError, error, refetch } = useEventi();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const salva = useSalvaEvento();
|
||||
const elimina = useEliminaEvento();
|
||||
const [bozza, setBozza] = useState<Evento | null>(null);
|
||||
const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const [confermaModifica, setConfermaModifica] = useState(false);
|
||||
const [cambiaGiorno, setCambiaGiorno] = useState(false);
|
||||
const rosa = squadra.filter(inRosa);
|
||||
|
||||
if (!io || !admin) {
|
||||
// SSR-safe: la data di oggi arriva solo dopo il mount.
|
||||
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
|
||||
useEffect(() => {
|
||||
const d = new Date();
|
||||
setOggi({ anno: d.getFullYear(), mese: d.getMonth(), giorno: d.getDate() });
|
||||
}, []);
|
||||
const [giornoSelezionato, setGiornoSelezionato] = useState<string | null>(null);
|
||||
const ridotto = useMotoRidotto();
|
||||
const { anno, mese, direzione, precedente, successivo } = useMeseNav();
|
||||
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
|
||||
|
||||
if (!io || !puoGestire) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
|
||||
@@ -85,18 +103,31 @@ function GestioneEventi() {
|
||||
setBozza((b) => (b ? { ...b, ...patch } : b));
|
||||
}
|
||||
|
||||
async function conferma() {
|
||||
const modificaEsistente = bozza ? eventi.some((e) => e.id === bozza.id) : false;
|
||||
|
||||
function chiediConferma() {
|
||||
if (!bozza) return;
|
||||
if (!bozza.titolo.trim()) {
|
||||
toast.error("Serve un titolo per l'evento");
|
||||
return;
|
||||
}
|
||||
if (modificaEsistente) {
|
||||
setConfermaModifica(true);
|
||||
return;
|
||||
}
|
||||
conferma();
|
||||
}
|
||||
|
||||
async function conferma() {
|
||||
if (!bozza) return;
|
||||
try {
|
||||
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
|
||||
toast.success("Evento salvato");
|
||||
setBozza(null);
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a salvare l'evento");
|
||||
} finally {
|
||||
setConfermaModifica(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,19 +143,153 @@ function GestioneEventi() {
|
||||
}
|
||||
}
|
||||
|
||||
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
|
||||
const eventiPerGiorno = new Map<number, Evento[]>();
|
||||
for (const e of eventi) {
|
||||
if (!e.data.startsWith(mesePrefix)) continue;
|
||||
const g = Number(e.data.slice(8, 10));
|
||||
const lista = eventiPerGiorno.get(g) ?? [];
|
||||
lista.push(e);
|
||||
eventiPerGiorno.set(g, lista);
|
||||
}
|
||||
|
||||
function apriGiorno(giorno: number) {
|
||||
setGiornoSelezionato(`${mesePrefix}-${pad2(giorno)}`);
|
||||
}
|
||||
|
||||
function nuovoNelGiorno() {
|
||||
if (!giornoSelezionato) return;
|
||||
setBozza({ ...eventoVuoto(), data: giornoSelezionato });
|
||||
setCambiaGiorno(false);
|
||||
setGiornoSelezionato(null);
|
||||
}
|
||||
|
||||
function modificaDalGiorno(e: Evento) {
|
||||
setBozza(e);
|
||||
setCambiaGiorno(false);
|
||||
setGiornoSelezionato(null);
|
||||
}
|
||||
|
||||
function eliminaDalGiorno(e: Evento) {
|
||||
setDaEliminare(e);
|
||||
setGiornoSelezionato(null);
|
||||
}
|
||||
|
||||
const eventiGiornoSelezionato = giornoSelezionato
|
||||
? eventi.filter((e) => e.data === giornoSelezionato)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Gestione eventi" sottotitolo={`${eventi.length} eventi in calendario`} />
|
||||
|
||||
<div className="px-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBozza(eventoVuoto())}
|
||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" /> Nuovo evento
|
||||
</button>
|
||||
</div>
|
||||
<Section titolo="Calendario">
|
||||
<Card>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={precedente}
|
||||
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
|
||||
aria-label="Mese precedente"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="font-display-sm text-xl uppercase">
|
||||
{mesiIT[mese]} {anno}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={successivo}
|
||||
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
|
||||
aria-label="Mese successivo"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
|
||||
{giorniIT.map((g, i) => (
|
||||
<span key={i}>{g}</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Swipe tra mesi con la stessa fisica di `/calendario` (vedi il commento lì). */}
|
||||
<div className="relative -mx-1 mt-1 overflow-hidden p-1">
|
||||
<AnimatePresence initial={false} mode="popLayout" custom={direzione}>
|
||||
<motion.div
|
||||
key={mesePrefix}
|
||||
custom={direzione}
|
||||
drag={ridotto ? false : "x"}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.18}
|
||||
dragMomentum={false}
|
||||
onDragEnd={(_, info) => {
|
||||
const arrivo = info.offset.x + proietta(info.velocity.x);
|
||||
if (arrivo < -60) successivo();
|
||||
else if (arrivo > 60) precedente();
|
||||
}}
|
||||
initial={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione * 48 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione * -48 }}
|
||||
transition={ridotto ? { duration: 0.2 } : molla.foglio}
|
||||
className="grid touch-pan-y grid-cols-7 gap-1"
|
||||
>
|
||||
{Array.from({ length: offsetLunedi }).map((_, i) => (
|
||||
<span key={`v${i}`} />
|
||||
))}
|
||||
{Array.from({ length: giorni }).map((_, i) => {
|
||||
const giorno = i + 1;
|
||||
const eventiGiorno = eventiPerGiorno.get(giorno) ?? [];
|
||||
const haEventi = eventiGiorno.length > 0;
|
||||
const isOggi =
|
||||
!!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
|
||||
return (
|
||||
<button
|
||||
key={giorno}
|
||||
type="button"
|
||||
onClick={() => apriGiorno(giorno)}
|
||||
className={cn(
|
||||
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold transition-transform active:scale-90",
|
||||
haEventi
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-secondary text-foreground",
|
||||
isOggi && "ring-2 ring-foreground ring-offset-1 ring-offset-card",
|
||||
)}
|
||||
aria-label={`${giorno} ${mesiIT[mese]}${haEventi ? `: ${eventiGiorno.length} ${eventiGiorno.length === 1 ? "evento" : "eventi"}` : ", nessun evento"}`}
|
||||
aria-current={isOggi ? "date" : undefined}
|
||||
>
|
||||
{giorno}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<p aria-busy="true" className="mt-2 text-xs text-muted-foreground">
|
||||
Carico gli eventi…
|
||||
</p>
|
||||
) : isError ? (
|
||||
<div className="mt-3 space-y-2 text-center text-sm">
|
||||
<p className="text-destructive">
|
||||
Non sono riuscito a caricare gli eventi
|
||||
{error instanceof Error ? `: ${error.message}` : ""}.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="rounded-2xl bg-secondary px-4 py-2 text-xs font-bold uppercase"
|
||||
>
|
||||
Riprova
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Scorri a destra o sinistra per cambiare mese. Tocca un giorno per aggiungere,
|
||||
modificare o eliminare.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
{bozza ? (
|
||||
<Section
|
||||
@@ -154,20 +319,39 @@ function GestioneEventi() {
|
||||
value={bozza.titolo}
|
||||
maxLength={80}
|
||||
onChange={(e) => aggiorna({ titolo: e.target.value })}
|
||||
placeholder="Es. CRAP Volley vs Aurora Nera"
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Data">
|
||||
<input
|
||||
type="date"
|
||||
value={bozza.data}
|
||||
onChange={(e) => aggiorna({ data: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
{/* Il giorno arriva già dal calendario: si mostra e basta, il campo data
|
||||
compare solo per spostare l'evento. */}
|
||||
{cambiaGiorno ? (
|
||||
<Campo label="Giorno">
|
||||
<input
|
||||
type="date"
|
||||
value={bozza.data}
|
||||
onChange={(e) => aggiorna({ data: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Giorno
|
||||
</span>
|
||||
<div className="mt-1 flex h-10 items-center justify-between gap-1">
|
||||
<span className="truncate text-sm font-semibold">{formatData(bozza.data)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCambiaGiorno(true)}
|
||||
className="shrink-0 rounded-full px-2 py-1 text-xs font-bold uppercase text-accent active:scale-95"
|
||||
>
|
||||
Cambia
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Campo label="Ora">
|
||||
<input
|
||||
type="time"
|
||||
@@ -272,7 +456,7 @@ function GestioneEventi() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={conferma}
|
||||
onClick={chiediConferma}
|
||||
disabled={salva.isPending}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
@@ -283,59 +467,98 @@ function GestioneEventi() {
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section titolo="Eventi in calendario">
|
||||
{isPending ? (
|
||||
<p aria-busy="true" className="text-center text-xs text-muted-foreground">
|
||||
Carico gli eventi…
|
||||
</p>
|
||||
) : isError ? (
|
||||
<div className="space-y-2 rounded-3xl bg-card p-4 text-center text-sm shadow-card">
|
||||
<p className="text-destructive">
|
||||
Non sono riuscito a caricare gli eventi
|
||||
{error instanceof Error ? `: ${error.message}` : ""}.
|
||||
</p>
|
||||
<Drawer
|
||||
open={!!giornoSelezionato}
|
||||
onOpenChange={(aperto) => !aperto && setGiornoSelezionato(null)}
|
||||
>
|
||||
<DrawerContent className="rounded-t-[24px] border-border bg-background px-4 pb-6 pt-2">
|
||||
<DrawerHeader className="relative px-0 pb-2 text-left">
|
||||
<DrawerTitle className="font-display-lg text-2xl uppercase">
|
||||
{giornoSelezionato ? formatData(giornoSelezionato) : "Giorno"}
|
||||
</DrawerTitle>
|
||||
<DrawerClose className="absolute right-0 top-0 grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground transition-transform active:scale-90">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Chiudi</span>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<div className="max-h-[50vh] space-y-2 overflow-y-auto py-2">
|
||||
{eventiGiornoSelezionato.length > 0 ? (
|
||||
eventiGiornoSelezionato.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{e.ora} · {e.luogo || "luogo da definire"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => modificaDalGiorno(e)}
|
||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
|
||||
aria-label={`Modifica ${e.titolo}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => eliminaDalGiorno(e)}
|
||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
|
||||
aria-label={`Elimina ${e.titolo}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||
Nessun evento in programma
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nuovoNelGiorno}
|
||||
className="premi mt-1 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
|
||||
</button>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
open={confermaModifica}
|
||||
onOpenChange={(aperto) => !aperto && setConfermaModifica(false)}
|
||||
>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Salvare le modifiche?</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{bozza ? `Aggiorni l'evento "${bozza.titolo.trim() || "senza titolo"}".` : ""}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="rounded-2xl bg-secondary px-4 py-2 text-xs font-bold uppercase"
|
||||
onClick={conferma}
|
||||
disabled={salva.isPending}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
Riprova
|
||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Conferma
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{eventi.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="flex items-center gap-2 rounded-3xl bg-card p-3 shadow-card"
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBozza(e)}
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
|
||||
aria-label={`Modifica ${e.titolo}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDaEliminare(e)}
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
|
||||
aria-label={`Elimina ${e.titolo}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
Annulla
|
||||
</button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer open={!!daEliminare} onOpenChange={(aperto) => !aperto && setDaEliminare(null)}>
|
||||
<DrawerContent>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { MapPin, Clock, Users, PartyPopper } from "lucide-react";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { convocatiEvento, useEvento } from "@/lib/eventi";
|
||||
import { useRosa } from "@/lib/rosa";
|
||||
import { RosaPresenze } from "@/components/crapp/RosaPresenze";
|
||||
import { usePresenzeEvento } from "@/lib/presenze";
|
||||
|
||||
export const Route = createFileRoute("/evento/$id")({
|
||||
head: () => {
|
||||
const titolo = "Dettaglio evento";
|
||||
return {
|
||||
meta: [
|
||||
{ title: `${titolo} — CrAPP` },
|
||||
{
|
||||
name: "description",
|
||||
content: "Dettaglio evento extra-campo, orario, luogo e presenze del CRAP Volley.",
|
||||
},
|
||||
{ property: "og:title", content: `${titolo} — CrAPP` },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "Dettaglio evento extra-campo, orario, luogo e presenze del CRAP Volley.",
|
||||
},
|
||||
{ property: "og:type", content: "website" },
|
||||
{ name: "twitter:card", content: "summary" },
|
||||
],
|
||||
};
|
||||
},
|
||||
component: EventoDetail,
|
||||
});
|
||||
|
||||
function EventoDetail() {
|
||||
const { id } = Route.useParams();
|
||||
const { evento } = useEvento(id);
|
||||
const { risposte } = usePresenzeEvento(id);
|
||||
const rosa = useRosa();
|
||||
const presentiVeri = rosa.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
).length;
|
||||
|
||||
if (!evento) {
|
||||
return (
|
||||
<div className="px-5 pt-8">
|
||||
<p className="mt-8 text-center text-sm text-muted-foreground">Evento non trovato</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Evento" sottotitolo={formatData(evento.data)} />
|
||||
|
||||
<Section titolo={evento.titolo}>
|
||||
<div className="rounded-3xl bg-card p-5 shadow-card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-warning/15 text-warning">
|
||||
<PartyPopper className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Evento extra-campo
|
||||
</p>
|
||||
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" /> {evento.ora}
|
||||
</span>
|
||||
<span className="ml-4 inline-flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" /> {evento.luogo}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{evento.note ? (
|
||||
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
|
||||
{evento.note}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
||||
<Users className="h-4 w-4" />
|
||||
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section titolo="Rosa e presenze">
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+81
-61
@@ -1,7 +1,9 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Flame, ChevronRight } from "lucide-react";
|
||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
|
||||
import { AvvisoCertificati } from "@/components/crapp/AvvisoCertificati";
|
||||
import { Card, LinkProfilo, Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
|
||||
import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
@@ -11,6 +13,8 @@ import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
||||
import { useEventi, type Evento } from "@/lib/eventi";
|
||||
import { useRispostePresenze } from "@/lib/presenze";
|
||||
import { useIo, useObiettivi } from "@/lib/rosa";
|
||||
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useCsi } from "@/lib/csi";
|
||||
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
||||
import { useScoutMatches } from "@/lib/scout-store";
|
||||
@@ -38,7 +42,10 @@ export const Route = createFileRoute("/")({
|
||||
});
|
||||
|
||||
function Index() {
|
||||
// `useIo` c'è solo per i giocatori; l'allenatore ha lo slot ma non statistiche (DD-034).
|
||||
const base = useGiocatoreBase();
|
||||
const giocatore = useIo();
|
||||
const allenatore = isAllenatore(base);
|
||||
const { eventi } = useEventi();
|
||||
const { presenze } = useRispostePresenze();
|
||||
const oggi = new Date().toISOString().slice(0, 10);
|
||||
@@ -65,7 +72,8 @@ function Index() {
|
||||
? { ...ultimaBase, mvp: (eventoUltima && mvpPerMatch[eventoUltima.id]) ?? "" }
|
||||
: null;
|
||||
|
||||
if (!giocatore) return null;
|
||||
if (!base || (!giocatore && !allenatore)) return null;
|
||||
const nome = giocatore?.nome ?? nomeCompleto(base);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -78,7 +86,7 @@ function Index() {
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
|
||||
Ciao {giocatore.nome.split(" ")[0]}
|
||||
Ciao {nome.split(" ")[0]}
|
||||
</p>
|
||||
<h1 className="font-display-lg text-4xl uppercase leading-none">CRAP Volley</h1>
|
||||
</div>
|
||||
@@ -86,7 +94,9 @@ function Index() {
|
||||
<LinkProfilo />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-3 gap-2 text-center">
|
||||
<div
|
||||
className={cn("mt-6 grid gap-2 text-center", giocatore ? "grid-cols-3" : "grid-cols-2")}
|
||||
>
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="font-display text-2xl leading-none">{noi ? `${noi.pos}º` : "—"}</p>
|
||||
<p className="text-xs uppercase text-primary-foreground/80">In classifica</p>
|
||||
@@ -97,19 +107,23 @@ function Index() {
|
||||
</p>
|
||||
<p className="text-xs uppercase text-primary-foreground/80">Bilancio W-L</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="inline-flex items-center gap-1 font-display text-2xl leading-none">
|
||||
<Flame className="h-4 w-4 text-accent" />
|
||||
{giocatore.streak}
|
||||
</p>
|
||||
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
|
||||
</div>
|
||||
{giocatore ? (
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="inline-flex items-center gap-1 font-display text-2xl leading-none">
|
||||
<Flame className="h-4 w-4 text-accent" />
|
||||
{giocatore.streak}
|
||||
</p>
|
||||
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<PromemoriaPalloni />
|
||||
|
||||
<CompletaProfilo giocatoreId={giocatore.id} indice={1} />
|
||||
<AvvisoCertificati />
|
||||
|
||||
<CompletaProfilo giocatoreId={base.id} allenatore={allenatore} indice={1} />
|
||||
|
||||
<Section
|
||||
titolo="Prossimo impegno"
|
||||
@@ -132,20 +146,22 @@ function Index() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section titolo="Da confermare" indice={3}>
|
||||
<div className="space-y-3">
|
||||
{daConfermare.length > 0 ? (
|
||||
daConfermare.map((e) => {
|
||||
const link = linkPerEvento(e);
|
||||
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
||||
})
|
||||
) : (
|
||||
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
||||
Nient'altro da confermare: sei in pari.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
{giocatore ? (
|
||||
<Section titolo="Da confermare" indice={3}>
|
||||
<div className="space-y-3">
|
||||
{daConfermare.length > 0 ? (
|
||||
daConfermare.map((e) => {
|
||||
const link = linkPerEvento(e);
|
||||
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
||||
})
|
||||
) : (
|
||||
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
||||
Nient'altro da confermare: sei in pari.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section
|
||||
titolo="Ultima partita"
|
||||
@@ -207,43 +223,47 @@ function Index() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
titolo="Obiettivo di squadra"
|
||||
indice={5}
|
||||
azione={
|
||||
<Link
|
||||
to="/squadra"
|
||||
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
||||
>
|
||||
Tutti <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{obiettivo ? (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 text-sm font-bold">
|
||||
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
|
||||
</div>
|
||||
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
|
||||
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-accent">
|
||||
{microcopyObiettivo(obiettivo)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
</Section>
|
||||
{allenatore ? null : (
|
||||
<Section
|
||||
titolo="Obiettivo di squadra"
|
||||
indice={5}
|
||||
azione={
|
||||
<Link
|
||||
to="/squadra"
|
||||
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
||||
>
|
||||
Tutti <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{obiettivo ? (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 text-sm font-bold">
|
||||
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
|
||||
</div>
|
||||
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
|
||||
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-accent">
|
||||
{microcopyObiettivo(obiettivo)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section titolo="Colpo d'occhio" indice={6}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile valore={giocatore.presenze} label="Presenze" />
|
||||
<StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" />
|
||||
<StatTile valore={giocatore.mvp} label="MVP" />
|
||||
</div>
|
||||
</Section>
|
||||
{giocatore ? (
|
||||
<Section titolo="Colpo d'occhio" indice={6}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile valore={giocatore.presenze} label="Presenze" />
|
||||
<StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" />
|
||||
<StatTile valore={giocatore.mvp} label="MVP" />
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { ScoutEntry } from "@/components/crapp/ScoutEntry";
|
||||
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
|
||||
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useSonoAllenatore } from "@/lib/user-store";
|
||||
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
|
||||
import { VotoSocial } from "@/components/crapp/VotoSocial";
|
||||
import { TurnoPalloni } from "@/components/crapp/TurnoPalloni";
|
||||
@@ -47,6 +48,7 @@ function PartitaDetail() {
|
||||
const { id } = Route.useParams();
|
||||
const { evento } = useEvento(id);
|
||||
const admin = useIsAdmin();
|
||||
const sonoAllenatore = useSonoAllenatore();
|
||||
const scoutMatches = useScoutMatches();
|
||||
const { data: csi } = useCsi();
|
||||
const { risposte } = usePresenzeEvento(id);
|
||||
@@ -203,9 +205,12 @@ function PartitaDetail() {
|
||||
</Section>
|
||||
|
||||
{match ? (
|
||||
<Section titolo="Badge votati dai compagni">
|
||||
<VotoSocial matchId={match.id} />
|
||||
</Section>
|
||||
// Badge e cacche non si mostrano all'allenatore (DD-034).
|
||||
sonoAllenatore ? null : (
|
||||
<Section titolo="Badge votati dai compagni">
|
||||
<VotoSocial matchId={match.id} />
|
||||
</Section>
|
||||
)
|
||||
) : (
|
||||
<Section titolo="In programma">
|
||||
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
@@ -219,9 +224,11 @@ function PartitaDetail() {
|
||||
<ScoutEntry eventoId={evento.id} />
|
||||
</Section>
|
||||
|
||||
<Section titolo="Sondaggio pre-partita">
|
||||
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
|
||||
</Section>
|
||||
{sonoAllenatore ? null : (
|
||||
<Section titolo="Sondaggio pre-partita">
|
||||
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{match ? (
|
||||
<Section titolo="Pagelle di fine partita">
|
||||
@@ -288,7 +295,7 @@ function PartitaDetail() {
|
||||
) : null}
|
||||
|
||||
<Section titolo="Rosa e presenze">
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} />
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
+271
-182
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Flame, Trash2, Bell, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react";
|
||||
import { Flame, Trash2, Bell, Mail, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, PageHeader, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
|
||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
} from "@/lib/avatar-store";
|
||||
import { SerieGriglia } from "@/components/crapp/SerieCard";
|
||||
import { CollezioneBadge } from "@/components/crapp/CollezioneBadge";
|
||||
import { ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { ProfiloAllenatore, ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { useVotiSocial } from "@/lib/badge-social";
|
||||
import { inizialiDa } from "@/lib/crapp-data";
|
||||
import { useIo } from "@/lib/rosa";
|
||||
import { usePresenzeUltimoMese } from "@/lib/presenze-mese";
|
||||
import {
|
||||
@@ -24,9 +25,16 @@ import {
|
||||
pushSupportato,
|
||||
statoNotifiche,
|
||||
} from "@/lib/push-client";
|
||||
import { resetGiocatore } from "@/lib/user-store";
|
||||
import { resetGiocatore, useGiocatoreBase } from "@/lib/user-store";
|
||||
import {
|
||||
isAllenatore,
|
||||
nomeCompleto,
|
||||
ruoloVisibile,
|
||||
type GiocatoreSquadra,
|
||||
} from "@/lib/giocatori-squadra";
|
||||
import { esci } from "@/lib/auth";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useEmailNotifiche } from "@/lib/preferenze-utente";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { version as APP_VERSION } from "../../package.json";
|
||||
|
||||
@@ -60,130 +68,29 @@ export const Route = createFileRoute("/profilo")({
|
||||
});
|
||||
|
||||
function Profilo() {
|
||||
// L'allenatore ha un profilo ridotto: solo Docs e Opzioni, niente stagione né badge (DD-034).
|
||||
const base = useGiocatoreBase();
|
||||
if (base && isAllenatore(base)) return <ProfiloAllenatorePagina g={base} />;
|
||||
return <ProfiloGiocatore />;
|
||||
}
|
||||
|
||||
function ProfiloGiocatore() {
|
||||
const { tab } = Route.useSearch();
|
||||
const tabIniziale = tab ?? "stagione";
|
||||
const votiSocial = useVotiSocial();
|
||||
const g = useIo();
|
||||
const admin = useIsAdmin();
|
||||
const ultimoMese = usePresenzeUltimoMese(g?.id);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fotoEsiste = useAvatarEsiste(g?.id);
|
||||
const impostaAvatarEsiste = useImpostaAvatarEsiste();
|
||||
const [bust, setBust] = useState(0);
|
||||
const [notifiche, setNotifiche] = useState(false);
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
const [supportate, setSupportate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setSupportate(pushSupportato());
|
||||
statoNotifiche()
|
||||
.then(setNotifiche)
|
||||
.catch(() => setNotifiche(false));
|
||||
}, []);
|
||||
|
||||
if (!g) return null;
|
||||
|
||||
async function cambiaNotifiche() {
|
||||
if (!g || inCorso) return;
|
||||
setInCorso(true);
|
||||
try {
|
||||
if (notifiche) {
|
||||
await disattivaNotifiche();
|
||||
setNotifiche(false);
|
||||
toast.success("Notifiche disattivate");
|
||||
} else {
|
||||
await attivaNotifiche(g.id);
|
||||
setNotifiche(true);
|
||||
toast.success("Notifiche attive");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Notifiche non disponibili");
|
||||
} finally {
|
||||
setInCorso(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await esci();
|
||||
resetGiocatore();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
|
||||
}
|
||||
}
|
||||
|
||||
const percPresenze = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0;
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file || !g) return;
|
||||
try {
|
||||
await caricaAvatar(g.id, file);
|
||||
setBust(Date.now());
|
||||
impostaAvatarEsiste(g.id, true);
|
||||
toast.success("Immagine profilo aggiornata");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a caricare l'immagine");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sul profilo il logo torna in home al posto del link al profilo. */}
|
||||
<PageHeader
|
||||
titolo={g.nome}
|
||||
sottotitolo={`#${g.numero} · ${g.ruolo}`}
|
||||
azione={
|
||||
<Link to="/" aria-label="Vai alla home" className="premi shrink-0 rounded-2xl">
|
||||
<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<PageHeader titolo={g.nome} sottotitolo={`#${g.numero} · ${g.ruolo}`} azione={<LinkHome />} />
|
||||
|
||||
<Reveal className="-mt-6 px-5">
|
||||
<Card>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Avatar id={g.id} fallback={g.iniziali} className="h-20 w-20 text-2xl" bust={bust} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={onFile}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
|
||||
>
|
||||
Cambia immagine profilo
|
||||
</button>
|
||||
{fotoEsiste.data ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!confirm("Rimuovere l'immagine profilo?")) return;
|
||||
try {
|
||||
await rimuoviAvatar(g.id);
|
||||
setBust(Date.now());
|
||||
impostaAvatarEsiste(g.id, false);
|
||||
toast.success("Immagine rimossa");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a rimuovere l'immagine");
|
||||
}
|
||||
}}
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/15 text-destructive"
|
||||
aria-label="Rimuovi immagine"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
<CardAvatar id={g.id} fallback={g.iniziali} />
|
||||
|
||||
<BarraSottosezioni
|
||||
key={tabIniziale}
|
||||
@@ -236,78 +143,260 @@ function Profilo() {
|
||||
{
|
||||
id: "impostazioni",
|
||||
label: "Opzioni",
|
||||
contenuto: (
|
||||
<div className="space-y-3">
|
||||
<div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cambiaNotifiche}
|
||||
disabled={!supportate || inCorso}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">Notifiche</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{supportate
|
||||
? notifiche
|
||||
? "Attive su questo dispositivo"
|
||||
: "Palloni, solleciti presenze e avvisi in app"
|
||||
: "Non supportate su questo dispositivo"}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
|
||||
notifiche
|
||||
? "bg-accent-grad text-accent-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
{admin ? (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Dashboard amministratore</span>
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
) : null}
|
||||
<a
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Segnala un bug</span>
|
||||
<Bug className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
|
||||
<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}
|
||||
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Esci
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
contenuto: <TabOpzioni giocatoreId={g.id} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfiloAllenatorePagina({ g }: { g: GiocatoreSquadra }) {
|
||||
const { tab } = Route.useSearch();
|
||||
// Stagione e Badge non esistono per l'allenatore: un link diretto apre Docs.
|
||||
const tabIniziale = tab === "impostazioni" ? "impostazioni" : "documenti";
|
||||
const nome = nomeCompleto(g);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo={nome} sottotitolo={ruoloVisibile(g)} azione={<LinkHome />} />
|
||||
|
||||
<CardAvatar id={g.id} fallback={inizialiDa(nome)} />
|
||||
|
||||
<BarraSottosezioni
|
||||
key={tabIniziale}
|
||||
defaultId={tabIniziale}
|
||||
riempiLarghezza
|
||||
voci={[
|
||||
{ id: "documenti", label: "Docs", contenuto: <ProfiloAllenatore g={g} /> },
|
||||
{
|
||||
id: "impostazioni",
|
||||
label: "Opzioni",
|
||||
contenuto: <TabOpzioni giocatoreId={g.id} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkHome() {
|
||||
return (
|
||||
<Link to="/" aria-label="Vai alla home" className="premi shrink-0 rounded-2xl">
|
||||
<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Foto profilo: stessa per giocatore e allenatore (bucket `avatar-giocatori`). */
|
||||
function CardAvatar({ id, fallback }: { id: string; fallback: string }) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fotoEsiste = useAvatarEsiste(id);
|
||||
const impostaAvatarEsiste = useImpostaAvatarEsiste();
|
||||
const [bust, setBust] = useState(0);
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
await caricaAvatar(id, file);
|
||||
setBust(Date.now());
|
||||
impostaAvatarEsiste(id, true);
|
||||
toast.success("Immagine profilo aggiornata");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a caricare l'immagine");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Reveal className="-mt-6 px-5">
|
||||
<Card>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Avatar id={id} fallback={fallback} className="h-20 w-20 text-2xl" bust={bust} />
|
||||
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={onFile} />
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
|
||||
>
|
||||
Cambia immagine profilo
|
||||
</button>
|
||||
{fotoEsiste.data ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!confirm("Rimuovere l'immagine profilo?")) return;
|
||||
try {
|
||||
await rimuoviAvatar(id);
|
||||
setBust(Date.now());
|
||||
impostaAvatarEsiste(id, false);
|
||||
toast.success("Immagine rimossa");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a rimuovere l'immagine");
|
||||
}
|
||||
}}
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/15 text-destructive"
|
||||
aria-label="Rimuovi immagine"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tab Opzioni: notifiche, dashboard admin, segnalazioni, uscita. Uguale per tutti. */
|
||||
function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
||||
const admin = useIsAdmin();
|
||||
const [notifiche, setNotifiche] = useState(false);
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
const [supportate, setSupportate] = useState(true);
|
||||
const email = useEmailNotifiche();
|
||||
|
||||
useEffect(() => {
|
||||
setSupportate(pushSupportato());
|
||||
statoNotifiche()
|
||||
.then(setNotifiche)
|
||||
.catch(() => setNotifiche(false));
|
||||
}, []);
|
||||
|
||||
async function cambiaNotifiche() {
|
||||
if (inCorso) return;
|
||||
setInCorso(true);
|
||||
try {
|
||||
if (notifiche) {
|
||||
await disattivaNotifiche();
|
||||
setNotifiche(false);
|
||||
toast.success("Notifiche disattivate");
|
||||
} else {
|
||||
await attivaNotifiche(giocatoreId);
|
||||
setNotifiche(true);
|
||||
toast.success("Notifiche attive");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Notifiche non disponibili");
|
||||
} finally {
|
||||
setInCorso(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function cambiaEmail() {
|
||||
if (email.imposta.isPending) return;
|
||||
const attive = !email.attive;
|
||||
try {
|
||||
await email.imposta.mutateAsync(attive);
|
||||
toast.success(attive ? "Email attive" : "Email disattivate");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Impossibile salvare la preferenza");
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await esci();
|
||||
resetGiocatore();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cambiaNotifiche}
|
||||
disabled={!supportate || inCorso}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">Notifiche</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{supportate
|
||||
? notifiche
|
||||
? "Attive su questo dispositivo"
|
||||
: "Palloni, solleciti presenze e avvisi in app"
|
||||
: "Non supportate su questo dispositivo"}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
|
||||
notifiche
|
||||
? "bg-accent-grad text-accent-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cambiaEmail}
|
||||
disabled={!email.pronta || email.imposta.isPending}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">Email</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{email.attive
|
||||
? "Le notifiche arrivano anche via email"
|
||||
: "Nessuna email, solo notifiche in app"}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
|
||||
email.attive
|
||||
? "bg-accent-grad text-accent-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
{admin ? (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Dashboard amministratore</span>
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
) : null}
|
||||
<a
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Segnala un bug</span>
|
||||
<Bug className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
|
||||
<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}
|
||||
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Esci
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+130
-45
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
|
||||
import { PageHeader, StatTile } from "@/components/crapp/ui-bits";
|
||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { formatData, inizialiDa } from "@/lib/crapp-data";
|
||||
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
||||
import {
|
||||
useRosa,
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
type CriterioClassifica,
|
||||
} from "@/lib/rosa";
|
||||
import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese";
|
||||
import {
|
||||
isAllenatore,
|
||||
nomeCompleto,
|
||||
ruoloVisibile,
|
||||
useGiocatoriSquadra,
|
||||
} from "@/lib/giocatori-squadra";
|
||||
import { useSonoAllenatore } from "@/lib/user-store";
|
||||
import { totaliSquadra, useScoutMatches } from "@/lib/scout-store";
|
||||
import { useCsi } from "@/lib/csi";
|
||||
import { partiteGiocate } from "@/lib/csi-core";
|
||||
@@ -87,6 +94,14 @@ function valore(
|
||||
|
||||
function Squadra() {
|
||||
const rosa = useRosa();
|
||||
// Gli allenatori stanno in Rosa con i giocatori, senza numero né statistiche (DD-034).
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const allenatori = useMemo(() => squadra.filter((g) => g.attivo && isAllenatore(g)), [squadra]);
|
||||
// Chi allena non vede badge, cacche né obiettivi (DD-034).
|
||||
const sonoAllenatore = useSonoAllenatore();
|
||||
const criteriVisibili = sonoAllenatore
|
||||
? criteri.filter((c) => c.id !== "cacchePartita")
|
||||
: criteri;
|
||||
const mese = usePresenzeUltimoMeseTutti();
|
||||
const scoutMatches = useScoutMatches();
|
||||
const { voti: pagelle } = usePagelle();
|
||||
@@ -115,9 +130,52 @@ function Squadra() {
|
||||
const contenutoRosa = useMemo(
|
||||
() => (
|
||||
<div className="space-y-2">
|
||||
{allenatori.map((a) => {
|
||||
const isOpen = aperto === a.id;
|
||||
return (
|
||||
<article key={a.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperto(isOpen ? null : a.id)}
|
||||
className="flex min-h-11 w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<Avatar
|
||||
id={a.id}
|
||||
fallback={inizialiDa(nomeCompleto(a))}
|
||||
className="h-11 w-11 text-lg"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold leading-tight">
|
||||
{nomeCompleto(a)}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<RuoloBadge ruolo={ruoloVisibile(a)} />
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && a.nascita ? (
|
||||
<div className="border-t border-border px-4 pb-4 pt-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{rosa.map((g) => {
|
||||
const stati = badgeGiocatore(g);
|
||||
const sbloccati = [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
|
||||
const sbloccati = sonoAllenatore
|
||||
? []
|
||||
: [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
|
||||
const isOpen = aperto === g.id;
|
||||
return (
|
||||
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
@@ -169,12 +227,14 @@ function Squadra() {
|
||||
|
||||
{isOpen ? (
|
||||
<div className="border-t border-border px-4 pb-4 pt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
|
||||
{g.nascita.slice(0, 4)}
|
||||
</p>
|
||||
</div>
|
||||
{g.nascita ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
|
||||
{g.nascita.slice(0, 4)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{[
|
||||
@@ -183,7 +243,9 @@ function Squadra() {
|
||||
{ l: "Presenze di fila", v: g.streak },
|
||||
{ l: "Media voto", v: g.mediaVoto || "—" },
|
||||
{ l: "MVP", v: g.mvp },
|
||||
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" },
|
||||
...(sonoAllenatore
|
||||
? []
|
||||
: [{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" }]),
|
||||
].map((s) => (
|
||||
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
|
||||
<p className="font-display text-xl leading-none">{s.v}</p>
|
||||
@@ -194,37 +256,45 @@ function Squadra() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
|
||||
</p>
|
||||
{sbloccati.length === 0 ? (
|
||||
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
|
||||
Nessun badge sbloccato per ora.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{sbloccati.map((b) => {
|
||||
const Icon = b.def.icon;
|
||||
const meta = gradoMeta[b.grado!];
|
||||
return (
|
||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
||||
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
|
||||
<Icon className={cn("h-4 w-4", meta.text)} />
|
||||
<p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{b.valore} {b.def.unita}
|
||||
{b.prossimaSoglia
|
||||
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}>
|
||||
{meta.label}
|
||||
</p>
|
||||
</div>
|
||||
</BadgeDrawer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{sonoAllenatore ? null : (
|
||||
<>
|
||||
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
|
||||
</p>
|
||||
{sbloccati.length === 0 ? (
|
||||
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
|
||||
Nessun badge sbloccato per ora.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{sbloccati.map((b) => {
|
||||
const Icon = b.def.icon;
|
||||
const meta = gradoMeta[b.grado!];
|
||||
return (
|
||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
||||
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
|
||||
<Icon className={cn("h-4 w-4", meta.text)} />
|
||||
<p className="mt-1 text-xs font-bold leading-tight">
|
||||
{b.def.nome}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{b.valore} {b.def.unita}
|
||||
{b.prossimaSoglia
|
||||
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
|
||||
: ""}
|
||||
</p>
|
||||
<p
|
||||
className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}
|
||||
>
|
||||
{meta.label}
|
||||
</p>
|
||||
</div>
|
||||
</BadgeDrawer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -233,7 +303,7 @@ function Squadra() {
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
[rosa, aperto, mese],
|
||||
[rosa, aperto, mese, allenatori, sonoAllenatore],
|
||||
);
|
||||
|
||||
const contenutoStats = useMemo(
|
||||
@@ -269,7 +339,7 @@ function Squadra() {
|
||||
<DrawerTitle>Classifica per</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col gap-1 px-4 pb-6">
|
||||
{criteri.map((c) => (
|
||||
{criteriVisibili.map((c) => (
|
||||
<DrawerClose key={c.id} asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -324,7 +394,18 @@ function Squadra() {
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[mediaPresenze, matchGiocati, pagelle, team, filtroAperto, criterio, ordinati, rank, max],
|
||||
[
|
||||
mediaPresenze,
|
||||
matchGiocati,
|
||||
pagelle,
|
||||
team,
|
||||
filtroAperto,
|
||||
criterio,
|
||||
ordinati,
|
||||
rank,
|
||||
max,
|
||||
criteriVisibili,
|
||||
],
|
||||
);
|
||||
|
||||
const contenutoObiettivi = useMemo(
|
||||
@@ -448,8 +529,12 @@ function Squadra() {
|
||||
voci={[
|
||||
{ id: "rosa", label: "Rosa", contenuto: contenutoRosa },
|
||||
{ id: "stats", label: "Stats", contenuto: contenutoStats },
|
||||
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
|
||||
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
|
||||
...(sonoAllenatore
|
||||
? []
|
||||
: [
|
||||
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
|
||||
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
-- M17 — Centro notifiche in-app (badge non lette accanto al profilo)
|
||||
--
|
||||
-- Fino ad oggi le uniche notifiche erano push OS/browser (`push_subscriptions`, vedi
|
||||
-- docs/modules/notifiche.md), senza storico né stato letto/non letto. Questa migration
|
||||
-- aggiunge `notifiche_utente`: una riga per notifica, per giocatore, con un flag `letta`.
|
||||
-- Quattro sorgenti generano righe, mai il client:
|
||||
--
|
||||
-- 1. Messaggio libero dell'admin (`notifica-personalizzata.ts`, invariato lato push).
|
||||
-- 2. Promemoria automatico prima di un evento, 24h e 3h prima (due `cron.schedule`,
|
||||
-- estensione `pg_cron` già abilitata dal 31/07/2026 e mai usata finora). Volutamente
|
||||
-- non c'è una notifica sulla sola creazione dell'evento: conta l'avvicinarsi della
|
||||
-- data, non il momento in cui è stato messo in calendario.
|
||||
-- 3. Turno palloni (`promemoria-palloni.ts`, avviato a mano da un admin dalla pagina
|
||||
-- evento), in parallelo alla push.
|
||||
-- 4. Sollecito presenze (`sollecita-presenze.ts`, stesso innesco manuale), in parallelo
|
||||
-- alla push.
|
||||
--
|
||||
-- I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`
|
||||
-- (src/lib/eventi.ts) ed `evento_permette_voto()` (M13): `convocati` vuoto significa tutta
|
||||
-- la rosa attiva, altrimenti solo gli id in `convocati`.
|
||||
|
||||
CREATE TABLE public.notifiche_utente (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
giocatore_id text NOT NULL REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
|
||||
tipo text NOT NULL CHECK (
|
||||
tipo IN (
|
||||
'admin',
|
||||
'evento_promemoria_24h',
|
||||
'evento_promemoria_3h',
|
||||
'turno_palloni',
|
||||
'sollecita_presenze'
|
||||
)
|
||||
),
|
||||
titolo text NOT NULL,
|
||||
corpo text NOT NULL DEFAULT '',
|
||||
evento_id text REFERENCES public.eventi_app(id) ON DELETE CASCADE,
|
||||
letta boolean NOT NULL DEFAULT false,
|
||||
creato_il timestamptz NOT NULL DEFAULT now(),
|
||||
-- Per tipo='admin' evento_id è NULL: NULL non è mai uguale a NULL, quindi la UNIQUE non
|
||||
-- si applica e ogni messaggio admin resta una riga separata (corretto). Per i tipi legati
|
||||
-- a un evento, un secondo invio (cron che gira di nuovo, admin che preme di nuovo il
|
||||
-- pulsante) aggiorna la stessa riga invece di duplicarla — vedi gli `upsert` sotto.
|
||||
UNIQUE (giocatore_id, evento_id, tipo)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.notifiche_utente IS
|
||||
'Centro notifiche in-app per giocatore: messaggi admin, promemoria evento, turno palloni, sollecito presenze.';
|
||||
|
||||
CREATE INDEX notifiche_utente_giocatore_letta_idx
|
||||
ON public.notifiche_utente (giocatore_id, letta);
|
||||
|
||||
GRANT SELECT, UPDATE, DELETE ON public.notifiche_utente TO authenticated;
|
||||
GRANT ALL ON public.notifiche_utente TO service_role;
|
||||
|
||||
ALTER TABLE public.notifiche_utente ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Un account può essere collegato a più slot giocatore (`giocatori_squadra.auth_user_id`):
|
||||
-- vede le notifiche di tutti i suoi slot, il client filtra per il giocatore selezionato in
|
||||
-- localStorage (stesso pattern di `LinkProfilo`/`useGiocatoreId`).
|
||||
CREATE POLICY "Il giocatore legge le proprie notifiche" ON public.notifiche_utente
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = notifiche_utente.giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Solo per segnarle come lette: nessuna policy INSERT per authenticated, le righe nascono
|
||||
-- solo da funzioni `SECURITY DEFINER` o dalla service role (route admin).
|
||||
CREATE POLICY "Il giocatore segna le proprie notifiche come lette" ON public.notifiche_utente
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = notifiche_utente.giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = notifiche_utente.giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Eliminazione (swipe o pulsante nel pannello): non c'è pulizia automatica delle notifiche
|
||||
-- vecchie, quindi è l'unico modo per un giocatore di toglierle di mezzo per sempre.
|
||||
CREATE POLICY "Il giocatore elimina le proprie notifiche" ON public.notifiche_utente
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = notifiche_utente.giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- --- Destinatari di un evento: stessa convenzione di convocatiEvento()/evento_permette_voto() ---
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.giocatori_destinatari_evento(p_evento_id text)
|
||||
RETURNS SETOF text
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT g.id
|
||||
FROM public.eventi_app e
|
||||
JOIN public.giocatori_squadra g
|
||||
ON g.attivo = true
|
||||
AND (cardinality(e.convocati) = 0 OR g.id = ANY(e.convocati))
|
||||
WHERE e.id = p_evento_id;
|
||||
$$;
|
||||
|
||||
-- --- Promemoria evento imminente (pg_cron) -----------------------------------------------
|
||||
--
|
||||
-- `data`/`ora` sono salvate come ora locale Italia (stessa convenzione di
|
||||
-- `m10_azzera_turni_palloni_allenamenti`), quindi il confronto con `now()` (timestamptz)
|
||||
-- passa esplicitamente da `AT TIME ZONE 'Europe/Rome'`.
|
||||
--
|
||||
-- `eventi_app.ora` è testo libero (`<input type="time">` lato client, ma la form non
|
||||
-- impedisce di svuotarlo, vedi `src/routes/eventi.tsx`): un `::time` diretto su un valore
|
||||
-- non valido manda in errore l'intera istruzione, e con essa **tutti** i promemoria di
|
||||
-- quel giro di cron, non solo quelli dell'evento incriminato. `ora_evento_a_time()` assorbe
|
||||
-- l'errore riga per riga: un evento con l'ora scritta male viene solo escluso.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.ora_evento_a_time(p_ora text)
|
||||
RETURNS time
|
||||
LANGUAGE plpgsql IMMUTABLE AS $$
|
||||
BEGIN
|
||||
RETURN p_ora::time;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.genera_promemoria_eventi(p_tipo text, p_finestra interval)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
|
||||
SELECT d, p_tipo, 'Promemoria: ' || e.titolo,
|
||||
to_char(e.data, 'DD/MM/YYYY') || ' alle ' || e.ora,
|
||||
e.id
|
||||
FROM public.eventi_app e
|
||||
CROSS JOIN LATERAL public.giocatori_destinatari_evento(e.id) AS d
|
||||
WHERE public.ora_evento_a_time(e.ora) IS NOT NULL
|
||||
AND ((e.data + public.ora_evento_a_time(e.ora)) AT TIME ZONE 'Europe/Rome') - now()
|
||||
BETWEEN interval '0' AND p_finestra
|
||||
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT cron.schedule(
|
||||
'promemoria-eventi-24h',
|
||||
'0 * * * *',
|
||||
$$ SELECT public.genera_promemoria_eventi('evento_promemoria_24h', interval '24 hours') $$
|
||||
);
|
||||
|
||||
SELECT cron.schedule(
|
||||
'promemoria-eventi-3h',
|
||||
'*/15 * * * *',
|
||||
$$ SELECT public.genera_promemoria_eventi('evento_promemoria_3h', interval '3 hours') $$
|
||||
);
|
||||
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user