Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6defc1a49 | ||
|
|
0a2112a9f3 | ||
|
|
a51cdb43e4 | ||
|
|
49d4d9de31 | ||
|
|
9cc4c7e47a | ||
|
|
46bcb21cdb | ||
|
|
15a23dfaf4 | ||
|
|
2ec72fd08f | ||
|
|
15a010855e | ||
|
|
dda4aff746 | ||
|
|
e1e4ca0df6 | ||
|
|
ae5d790011 | ||
|
|
33ed2398ab | ||
|
|
c6437a605c | ||
|
|
431530af2a | ||
|
|
61d4dacb3a | ||
|
|
cdd433cb15 | ||
|
|
3f2ca30775 | ||
|
|
4d036f532d | ||
|
|
0314560885 | ||
|
|
0286961e92 | ||
|
|
6372ef9c8d |
No files matched your search
+9
-1
@@ -40,4 +40,12 @@ 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
|
||||
@@ -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.
|
||||
|
||||
|
||||
+31
-9
@@ -1,6 +1,6 @@
|
||||
# Project State
|
||||
|
||||
Ultimo aggiornamento: 09/09/2026
|
||||
Ultimo aggiornamento: 27/09/2026
|
||||
|
||||
## Stato generale
|
||||
|
||||
@@ -10,9 +10,10 @@ 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
|
||||
con il ruolo allenatore, è pronta ma non ancora pubblicata (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 +24,8 @@ 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)
|
||||
- 32 migration in `supabase/migrations/`, fino a `m21_ruolo_allenatore` (24/09/2026), tutte
|
||||
applicate in produzione (verificato con `npx supabase migration list` il 24/09/2026)
|
||||
- Sviluppo locale verificato con il nuovo Supabase
|
||||
|
||||
---
|
||||
@@ -42,6 +43,27 @@ 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 `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 +178,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) è implementato e versione
|
||||
1.1.0 (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,111 @@
|
||||
# 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. **(Solo se userai `--encrypt`) Verifica che gpg sia installato**:
|
||||
```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/ con tutto dentro
|
||||
./backups/backup.sh --encrypt # come sopra, poi comprime e cifra in un unico
|
||||
# backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
|
||||
# passphrase) e cancella la cartella in chiaro
|
||||
```
|
||||
|
||||
Ogni esecuzione crea una cartella nuova 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) |
|
||||
|
||||
## 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).
|
||||
|
||||
Per un archivio cifrato: `gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz`.
|
||||
|
||||
## 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 sempre `--encrypt` o cifra tu
|
||||
l'archivio a mano;
|
||||
- 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
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Backup completo di disaster recovery: schema + dati DB, utenti auth, file storage.
|
||||
# Uso:
|
||||
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/ con dentro tutto
|
||||
# ./backups/backup.sh --encrypt come sopra, poi comprime e cifra con gpg (chiede una passphrase)
|
||||
#
|
||||
# Richiede: essere già collegati al progetto Supabase (supabase login + link, già fatto
|
||||
# in questo repo) e avere .env con VITE_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY.
|
||||
|
||||
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)"
|
||||
node "$SCRIPT_DIR/download-storage.mjs" "$RUN_DIR/storage"
|
||||
|
||||
echo "==> Backup completato: $RUN_DIR"
|
||||
|
||||
if [[ "${1:-}" == "--encrypt" ]]; then
|
||||
ARCHIVE="$RUN_DIR.tar.gz.gpg"
|
||||
echo "==> Comprimo e cifro in $ARCHIVE (verrà chiesta una passphrase)"
|
||||
tar -C "$SCRIPT_DIR" -czf - "$STAMP" | gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE"
|
||||
rm -rf "$RUN_DIR"
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+60
-3
@@ -6,12 +6,69 @@ 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.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
|
||||
|
||||
+33
-30
@@ -7,31 +7,33 @@ 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 |
|
||||
| `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 +46,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 +63,20 @@ 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`. Quattro sorgenti, tutte in parallelo alla push esistente: 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`), questi ultimi due 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. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); 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. |
|
||||
|
||||
## Funzioni speciali
|
||||
|
||||
|
||||
+384
-29
@@ -16,35 +16,41 @@ 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 |
|
||||
|
||||
**In valutazione**
|
||||
|
||||
@@ -1155,3 +1161,352 @@ 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 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.
|
||||
@@ -27,6 +27,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.
|
||||
|
||||
+10
-10
@@ -6,16 +6,16 @@ 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 |
|
||||
| [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).
|
||||
|
||||
+9
-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,11 @@ 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)
|
||||
|
||||
## Prossimo
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# 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.
|
||||
|
||||
**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
|
||||
|
||||
L'allenatore riceve i promemoria automatici degli eventi (24h e 3h prima, centro notifiche
|
||||
in-app di M17) come i giocatori, anche se non è convocato: `giocatori_destinatari_evento()`
|
||||
lo include sempre. Riceve anche i messaggi dell'admin, e può attivare le push dal proprio
|
||||
Profilo. Non riceve il sollecito presenze, il turno palloni né il sondaggio cacche, che
|
||||
riguardano chi gioca.
|
||||
|
||||
## 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 })`), i destinatari dei promemoria evento e 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` |
|
||||
| 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ì |
|
||||
| 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ì |
|
||||
|
||||
## 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; promemoria evento aperti.
|
||||
- **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
|
||||
|
||||
- Riceve i promemoria degli eventi: sì.
|
||||
- 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.
|
||||
@@ -1,10 +1,11 @@
|
||||
# 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)
|
||||
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
|
||||
|
||||
---
|
||||
|
||||
@@ -19,14 +20,25 @@ 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;
|
||||
|
||||
@@ -43,6 +43,15 @@ un `project_id`), nessun codice le chiama direttamente.
|
||||
`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 —
|
||||
@@ -155,7 +164,8 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
|
||||
↓
|
||||
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
|
||||
sotto; tab "Storico partite": ogni squadra col proprio logo,
|
||||
chevron di dettaglio sulle gare cliccabili)
|
||||
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
|
||||
@@ -239,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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
---
|
||||
|
||||
@@ -16,6 +18,11 @@ indipendenti:
|
||||
- **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)).
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
@@ -81,14 +88,24 @@ 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`, `notifiche-attive` | `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 |
|
||||
| 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 |
|
||||
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
@@ -102,6 +119,51 @@ ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
Quattro sorgenti scrivono in `notifiche_utente`, mai il client:
|
||||
|
||||
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 da un vincolo
|
||||
`UNIQUE (giocatore_id, evento_id, tipo)` così un cron che gira più volte non manda
|
||||
doppioni. 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.
|
||||
|
||||
Turno palloni e sollecito presenze 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`.
|
||||
|
||||
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.
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
|
||||
|
||||
@@ -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
|
||||
@@ -175,6 +309,9 @@ alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (
|
||||
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
|
||||
@@ -244,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
|
||||
@@ -258,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
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "0.9.2",
|
||||
"version": "1.1.0",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { dataOggi } from "@/lib/scout-live";
|
||||
|
||||
const tipoMeta = {
|
||||
@@ -69,12 +69,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",
|
||||
|
||||
@@ -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.
|
||||
@@ -380,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 (
|
||||
@@ -409,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;
|
||||
|
||||
@@ -8,9 +8,9 @@ import { Barra } from "@/components/motion/Barra";
|
||||
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||
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"];
|
||||
@@ -20,8 +20,10 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
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();
|
||||
@@ -37,7 +39,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 };
|
||||
@@ -115,7 +117,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{admin ? (
|
||||
{puoSollecitare ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sollecita}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,66 @@ 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 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 +134,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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { intestazioniAutenticate } from "./auth";
|
||||
|
||||
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
|
||||
@@ -20,3 +20,18 @@ export function useNotificheAttive() {
|
||||
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,145 @@
|
||||
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. Quattro 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"
|
||||
| "sollecita_presenze";
|
||||
|
||||
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 };
|
||||
}
|
||||
+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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
+5
-3
@@ -2,6 +2,7 @@ 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 { inRosa, type TipoMembro } from "./giocatori-squadra";
|
||||
import { aggiornaSerie } from "./serie";
|
||||
import { dataOggi } from "./scout-live";
|
||||
|
||||
@@ -71,17 +72,18 @@ export function totaliEventiGiocatore(
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi va sollecitato per un evento: i giocatori attivi che non hanno ancora risposto, più
|
||||
* Chi va sollecitato per un evento: i giocatori attivi (non gli allenatori) 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.
|
||||
*/
|
||||
export function destinatariSollecito(
|
||||
squadra: Array<{ id: string; attivo: boolean }>,
|
||||
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
|
||||
risposte: Array<{ giocatore_id: string; stato: 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) => {
|
||||
const stato = stati.get(g.id);
|
||||
return stato === undefined || stato === "forse";
|
||||
|
||||
+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();
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 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'
|
||||
@@ -100,6 +101,12 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
|
||||
path: '/api/public/csi',
|
||||
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',
|
||||
@@ -149,6 +156,7 @@ export interface FileRoutesByFullPath {
|
||||
'/partita/$id': typeof PartitaIdRoute
|
||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
@@ -171,6 +179,7 @@ export interface FileRoutesByTo {
|
||||
'/partita/$id': typeof PartitaIdRoute
|
||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
@@ -194,6 +203,7 @@ export interface FileRoutesById {
|
||||
'/partita/$id': typeof PartitaIdRoute
|
||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
@@ -218,6 +228,7 @@ export interface FileRouteTypes {
|
||||
| '/partita/$id'
|
||||
| '/api/public/apri-sondaggio'
|
||||
| '/api/public/csi'
|
||||
| '/api/public/notifica-personalizzata'
|
||||
| '/api/public/notifiche-attive'
|
||||
| '/api/public/promemoria-palloni'
|
||||
| '/api/public/push-config'
|
||||
@@ -240,6 +251,7 @@ export interface FileRouteTypes {
|
||||
| '/partita/$id'
|
||||
| '/api/public/apri-sondaggio'
|
||||
| '/api/public/csi'
|
||||
| '/api/public/notifica-personalizzata'
|
||||
| '/api/public/notifiche-attive'
|
||||
| '/api/public/promemoria-palloni'
|
||||
| '/api/public/push-config'
|
||||
@@ -262,6 +274,7 @@ export interface FileRouteTypes {
|
||||
| '/partita/$id'
|
||||
| '/api/public/apri-sondaggio'
|
||||
| '/api/public/csi'
|
||||
| '/api/public/notifica-personalizzata'
|
||||
| '/api/public/notifiche-attive'
|
||||
| '/api/public/promemoria-palloni'
|
||||
| '/api/public/push-config'
|
||||
@@ -285,6 +298,7 @@ export interface RootRouteChildren {
|
||||
PartitaIdRoute: typeof PartitaIdRoute
|
||||
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
||||
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
||||
ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute
|
||||
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
|
||||
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
||||
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
||||
@@ -393,6 +407,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
||||
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'
|
||||
@@ -453,6 +474,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
PartitaIdRoute: PartitaIdRoute,
|
||||
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
||||
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
||||
ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute,
|
||||
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
|
||||
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
||||
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
||||
|
||||
+335
-136
@@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
BellOff,
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileText,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
Image,
|
||||
Loader2,
|
||||
Lock,
|
||||
Send,
|
||||
Unlink,
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
@@ -19,12 +21,15 @@ 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 { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { CampiAllenatore, CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import {
|
||||
inRosa,
|
||||
isAllenatore,
|
||||
nomeCompleto,
|
||||
numeroGiaUsato,
|
||||
prossimoIdGiocatore,
|
||||
RUOLI,
|
||||
ruoloVisibile,
|
||||
useAggiungiGiocatore,
|
||||
useGiocatoriSquadra,
|
||||
useImpostaAttivo,
|
||||
@@ -39,6 +44,7 @@ import {
|
||||
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
|
||||
import {
|
||||
completamento,
|
||||
completamentoAllenatore,
|
||||
csvTesseramento,
|
||||
profiloVuoto,
|
||||
sezioniComplete,
|
||||
@@ -49,8 +55,17 @@ import {
|
||||
import { oggiISO } from "@/lib/palloni-core";
|
||||
import { scaricaCsv } from "@/lib/scout-export";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useNotificheAttive } from "@/lib/notifiche-admin";
|
||||
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: () => ({
|
||||
@@ -115,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() {
|
||||
@@ -124,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;
|
||||
}
|
||||
@@ -205,28 +223,34 @@ 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">
|
||||
<input
|
||||
@@ -246,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}
|
||||
@@ -391,8 +432,9 @@ function SchedaGiocatore({
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
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);
|
||||
@@ -402,12 +444,12 @@ function SchedaGiocatore({
|
||||
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
|
||||
<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
|
||||
@@ -425,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);
|
||||
@@ -481,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) {
|
||||
@@ -502,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
|
||||
@@ -527,25 +596,32 @@ 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">
|
||||
<input
|
||||
@@ -599,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
|
||||
@@ -621,6 +697,32 @@ function Dashboard() {
|
||||
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 (
|
||||
@@ -636,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(
|
||||
@@ -686,6 +792,20 @@ function Dashboard() {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -700,18 +820,45 @@ function Dashboard() {
|
||||
const contenutoNotifiche = notificheAttive ? (
|
||||
<>
|
||||
<StatTile
|
||||
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
|
||||
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">
|
||||
{attivi
|
||||
.filter((g) => notificheAttive.has(g.id))
|
||||
.map((g) => (
|
||||
{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">
|
||||
<Bell className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<p className="truncate text-sm font-semibold leading-tight">{nomeCompleto(g)}</p>
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
@@ -737,6 +884,58 @@ function Dashboard() {
|
||||
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
open={!!destinatarioMessaggio}
|
||||
onOpenChange={(aperto) => {
|
||||
if (!aperto) {
|
||||
setDestinatarioMessaggio(null);
|
||||
setMessaggio("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { z } from "zod";
|
||||
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
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 +24,20 @@ 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");
|
||||
// Il sondaggio cacche non riguarda l'allenatore, che non le vede (DD-034).
|
||||
const allenatori = new Set(
|
||||
(await leggiGiocatoriSquadra()).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.`;
|
||||
|
||||
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 +53,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,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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,6 +1,7 @@
|
||||
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 { richiediGestoreEventi } from "@/lib/auth-route.server";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
|
||||
@@ -16,7 +17,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());
|
||||
@@ -48,6 +49,23 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
parsed.data.da ? `${parsed.data.da} chiede` : "Serve"
|
||||
} una conferma: presente, assente o in ritardo?`;
|
||||
|
||||
// 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,
|
||||
corpo: 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 ?? []) {
|
||||
try {
|
||||
|
||||
@@ -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"
|
||||
|
||||
+133
-118
@@ -7,7 +7,13 @@ 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";
|
||||
@@ -26,9 +32,7 @@ function LogoPartita({
|
||||
avversario: string;
|
||||
}) {
|
||||
if (nostro) {
|
||||
return (
|
||||
<TeamLogo src={LOGO_NOI} className="h-8 w-8 rounded-lg shadow-none" />
|
||||
);
|
||||
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" />;
|
||||
@@ -129,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(
|
||||
@@ -202,124 +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 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 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 (
|
||||
<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
|
||||
key={i}
|
||||
className={cn(
|
||||
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
|
||||
setVintoDaNoi
|
||||
? "bg-secondary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
"shrink-0 rounded-xl px-2.5 py-1 font-display text-xl",
|
||||
vinta
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-destructive text-destructive-foreground",
|
||||
)}
|
||||
>
|
||||
{puntiCasa}-{puntiOspite}
|
||||
{setCasa}-{setOspite}
|
||||
</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"
|
||||
>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{cliccabile ? (
|
||||
<ChevronRight
|
||||
className="h-5 w-5 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{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],
|
||||
@@ -329,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>
|
||||
|
||||
+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">
|
||||
|
||||
+232
-181
@@ -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,7 +25,13 @@ 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 { Reveal } from "@/components/motion/Reveal";
|
||||
@@ -60,130 +67,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 +142,223 @@ 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);
|
||||
|
||||
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 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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
+114
-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,36 @@ function Squadra() {
|
||||
const contenutoRosa = useMemo(
|
||||
() => (
|
||||
<div className="space-y-2">
|
||||
{allenatori.map((a) => (
|
||||
<article
|
||||
key={a.id}
|
||||
className="flex min-h-11 items-center gap-3 rounded-3xl bg-card p-3 shadow-card"
|
||||
>
|
||||
<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)} />
|
||||
{a.nascita ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</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 +211,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 +227,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 +240,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 +287,7 @@ function Squadra() {
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
[rosa, aperto, mese],
|
||||
[rosa, aperto, mese, allenatori, sonoAllenatore],
|
||||
);
|
||||
|
||||
const contenutoStats = useMemo(
|
||||
@@ -269,7 +323,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 +378,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 +513,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') $$
|
||||
);
|
||||
@@ -0,0 +1,124 @@
|
||||
-- M18 — Data di nascita pubblica a tutta la squadra (DD-031)
|
||||
--
|
||||
-- In "Squadra" la data di nascita mostrava "Invalid Date" per chi l'aveva inserita da solo
|
||||
-- dal proprio Profilo: quel valore finisce in `profili_giocatore.data_nascita`, tabella con
|
||||
-- RLS "solo il proprio profilo o admin" (M2), mai letta da `useRosa()`/`useAnagraficaRosa()`
|
||||
-- (src/lib/rosa.ts). Quelle funzioni leggevano solo la mappa statica `nascitaPerId` del seed
|
||||
-- storico dei 17 giocatori originali (src/lib/crapp-data.ts) — pubblica perché bundlata nel
|
||||
-- client, ma senza voce per chi si è aggiunto dopo.
|
||||
--
|
||||
-- Questa migration aggiunge la colonna pubblica prevista fin da DD-015 ("giocatori_squadra
|
||||
-- non ha ancora questa colonna, DD-015 follow-up") e la tiene sincronizzata con
|
||||
-- `profili_giocatore.data_nascita`: un solo input nel form Profilo, due destinazioni — quella
|
||||
-- amministrativa esistente (RLS invariata) e questo specchio pubblico, leggibile da tutta la
|
||||
-- rosa attiva con la stessa policy di sempre.
|
||||
|
||||
ALTER TABLE public.giocatori_squadra ADD COLUMN nascita date;
|
||||
|
||||
-- Backfill una tantum dei 17 giocatori storici (stessi valori di `rosaCSI` in
|
||||
-- src/lib/crapp-data.ts, stesso ordine g1..g17 del seed di M1). Il trigger
|
||||
-- `enforce_giocatori_squadra_update` (attivo dalla migration M1) rifiuterebbe questi UPDATE:
|
||||
-- gira in un contesto senza `auth.uid()`, quindi non passa né il ramo admin né quello di
|
||||
-- reclamazione. Va disabilitato solo per il backfill e riattivato subito dopo.
|
||||
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
|
||||
|
||||
UPDATE public.giocatori_squadra SET nascita = '1997-08-30' WHERE id = 'g1';
|
||||
UPDATE public.giocatori_squadra SET nascita = '1996-12-07' WHERE id = 'g2';
|
||||
UPDATE public.giocatori_squadra SET nascita = '2000-04-28' WHERE id = 'g3';
|
||||
UPDATE public.giocatori_squadra SET nascita = '1995-05-01' WHERE id = 'g4';
|
||||
UPDATE public.giocatori_squadra SET nascita = '1995-08-13' WHERE id = 'g5';
|
||||
UPDATE public.giocatori_squadra SET nascita = '1996-02-01' WHERE id = 'g6';
|
||||
UPDATE public.giocatori_squadra SET nascita = '1997-07-04' WHERE id = 'g7';
|
||||
UPDATE public.giocatori_squadra SET nascita = '1994-05-28' WHERE id = 'g8';
|
||||
UPDATE public.giocatori_squadra SET nascita = '2003-04-04' WHERE id = 'g9';
|
||||
UPDATE public.giocatori_squadra SET nascita = '1998-11-30' WHERE id = 'g10';
|
||||
UPDATE public.giocatori_squadra SET nascita = '2000-02-02' WHERE id = 'g11';
|
||||
UPDATE public.giocatori_squadra SET nascita = '1999-10-03' WHERE id = 'g12';
|
||||
UPDATE public.giocatori_squadra SET nascita = '2000-09-16' WHERE id = 'g13';
|
||||
UPDATE public.giocatori_squadra SET nascita = '1996-12-13' WHERE id = 'g14';
|
||||
UPDATE public.giocatori_squadra SET nascita = '1993-03-24' WHERE id = 'g15';
|
||||
UPDATE public.giocatori_squadra SET nascita = '2001-04-18' WHERE id = 'g16';
|
||||
UPDATE public.giocatori_squadra SET nascita = '1994-05-20' WHERE id = 'g17';
|
||||
|
||||
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
|
||||
|
||||
-- Il trigger di sync (sotto) è SECURITY DEFINER e bypassa la RLS in scrittura su
|
||||
-- giocatori_squadra (di proprietà dell'owner della tabella), ma `enforce_giocatori_squadra_update`
|
||||
-- resta un trigger BEFORE UPDATE che scatta comunque: senza questo ramo, l'update annidato
|
||||
-- fallirebbe con l'eccezione già in vigore per chi non è admin.
|
||||
--
|
||||
-- Non apre una scrittura diretta dal client: la policy di update per un non-admin resta
|
||||
-- "Users can claim unassigned roster slot" (USING auth_user_id IS NULL) — una riga già
|
||||
-- collegata non la supera nemmeno, quindi l'unica strada che raggiunge questo ramo è la
|
||||
-- funzione di sync qui sotto, che gira con privilegi propri e ignora la RLS.
|
||||
CREATE OR REPLACE FUNCTION public.enforce_giocatori_squadra_update()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF public.has_role(auth.uid(), 'admin'::public.app_role) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF OLD.auth_user_id IS NULL
|
||||
AND NEW.auth_user_id = auth.uid()
|
||||
AND OLD.email IS NOT NULL
|
||||
AND lower(OLD.email) = lower(coalesce(auth.jwt() ->> 'email', ''))
|
||||
AND NEW.id IS NOT DISTINCT FROM OLD.id
|
||||
AND NEW.nome IS NOT DISTINCT FROM OLD.nome
|
||||
AND NEW.cognome IS NOT DISTINCT FROM OLD.cognome
|
||||
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
|
||||
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
|
||||
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
|
||||
AND NEW.email IS NOT DISTINCT FROM OLD.email
|
||||
AND NEW.numero_tessera IS NOT DISTINCT FROM OLD.numero_tessera
|
||||
AND NEW.data_tessera IS NOT DISTINCT FROM OLD.data_tessera
|
||||
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Slot già collegato al chiamante: può cambiare solo `nascita` (M18). È la via percorsa
|
||||
-- dal trigger di sync qui sotto, mai da un client (vedi commento sopra).
|
||||
IF OLD.auth_user_id IS NOT NULL
|
||||
AND OLD.auth_user_id = auth.uid()
|
||||
AND NEW.auth_user_id IS NOT DISTINCT FROM OLD.auth_user_id
|
||||
AND NEW.id IS NOT DISTINCT FROM OLD.id
|
||||
AND NEW.nome IS NOT DISTINCT FROM OLD.nome
|
||||
AND NEW.cognome IS NOT DISTINCT FROM OLD.cognome
|
||||
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
|
||||
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
|
||||
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
|
||||
AND NEW.email IS NOT DISTINCT FROM OLD.email
|
||||
AND NEW.numero_tessera IS NOT DISTINCT FROM OLD.numero_tessera
|
||||
AND NEW.data_tessera IS NOT DISTINCT FROM OLD.data_tessera
|
||||
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'Aggiornamento non autorizzato su giocatori_squadra';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.sincronizza_nascita_pubblica()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
UPDATE public.giocatori_squadra SET nascita = NULL WHERE id = OLD.giocatore_id;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
UPDATE public.giocatori_squadra SET nascita = NEW.data_nascita WHERE id = NEW.giocatore_id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER sincronizza_nascita_pubblica
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.profili_giocatore
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.sincronizza_nascita_pubblica();
|
||||
@@ -0,0 +1,22 @@
|
||||
-- M19 — Backfill della nascita pubblica per i profili già esistenti (fix di M18)
|
||||
--
|
||||
-- M18 ha aggiunto `giocatori_squadra.nascita` e un trigger che la sincronizza da
|
||||
-- `profili_giocatore.data_nascita`, ma quel trigger scatta solo sulle scritture *future*:
|
||||
-- chi aveva già compilato il proprio profilo (data_nascita) prima che M18 fosse applicata è
|
||||
-- rimasto con `nascita` vuota, mentre M18 aveva backfillato a mano solo i 17 giocatori del
|
||||
-- seed storico. Effetto visibile: in Squadra la data di nascita compariva solo per quei 17,
|
||||
-- non per chi l'aveva inserita dal Profilo prima di oggi.
|
||||
--
|
||||
-- Stesso motivo di M18 per disabilitare temporaneamente `enforce_giocatori_squadra_update`:
|
||||
-- gira in un contesto senza `auth.uid()`, quindi non passerebbe nessun ramo del trigger.
|
||||
|
||||
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
|
||||
|
||||
-- Ha la precedenza sui valori hardcoded di M18 per chi, tra i 17 storici, ha anche corretto
|
||||
-- la propria data dal Profilo nel frattempo: `profili_giocatore` è la fonte più aggiornata.
|
||||
UPDATE public.giocatori_squadra gs
|
||||
SET nascita = pg.data_nascita
|
||||
FROM public.profili_giocatore pg
|
||||
WHERE pg.giocatore_id = gs.id;
|
||||
|
||||
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- M20 — Nuovo valore `allenatore` per `app_role` (DD-034).
|
||||
--
|
||||
-- Sta da solo perché Postgres non permette di usare un valore di enum nella stessa
|
||||
-- transazione che lo aggiunge: le policy e le funzioni che lo citano stanno in M21.
|
||||
|
||||
ALTER TYPE public.app_role ADD VALUE IF NOT EXISTS 'allenatore';
|
||||
@@ -0,0 +1,206 @@
|
||||
-- M21 — Ruolo allenatore (DD-034, docs/modules/allenatore.md).
|
||||
--
|
||||
-- L'allenatore è una riga di giocatori_squadra con `tipo = 'allenatore'`: si collega per
|
||||
-- email come i giocatori (DD-018) e riusa profilo, avatar e compleanno. Non gioca, quindi
|
||||
-- non ha numero di maglia né ruolo in campo, non risponde alle presenze, non dichiara
|
||||
-- cacche, non vota e non viene votato. Gestisce gli eventi come un admin.
|
||||
--
|
||||
-- Il permesso vive in user_roles (DD-011) come valore `allenatore` di app_role (M20). Non
|
||||
-- lo scrive nessuno a mano: lo tiene allineato un trigger su giocatori_squadra, quindi è
|
||||
-- l'admin a concederlo registrando lo slot, e l'allenatore non può auto-promuoversi perché
|
||||
-- `tipo` lo cambia solo un admin.
|
||||
|
||||
-- --- Colonna tipo --------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE public.giocatori_squadra
|
||||
ADD COLUMN tipo text NOT NULL DEFAULT 'giocatore'
|
||||
CONSTRAINT giocatori_squadra_tipo_valido CHECK (tipo IN ('giocatore', 'allenatore'));
|
||||
|
||||
COMMENT ON COLUMN public.giocatori_squadra.tipo IS
|
||||
'giocatore (in rosa, convocabile, votabile) oppure allenatore (gestisce gli eventi, fuori '
|
||||
'dalla rosa di gioco). Scrivibile solo da un admin (DD-034).';
|
||||
|
||||
-- L'allenatore non ha numero di maglia: resta obbligatorio solo per i giocatori.
|
||||
ALTER TABLE public.giocatori_squadra ALTER COLUMN numero DROP NOT NULL;
|
||||
ALTER TABLE public.giocatori_squadra
|
||||
ADD CONSTRAINT giocatori_squadra_numero_giocatori
|
||||
CHECK (tipo = 'allenatore' OR numero IS NOT NULL);
|
||||
|
||||
-- --- Trigger sugli slot: tipo solo admin, nome e cognome anche all'allenatore ---------------
|
||||
--
|
||||
-- Stessi due rami di M18 (reclamo dello slot libero, sync della nascita sul proprio slot),
|
||||
-- con `tipo` tra i campi bloccati. Il secondo ramo lascia cambiare nome e cognome solo se
|
||||
-- lo slot è di un allenatore: per un giocatore restano dati squadra gestiti dall'admin.
|
||||
CREATE OR REPLACE FUNCTION public.enforce_giocatori_squadra_update()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF public.has_role(auth.uid(), 'admin'::public.app_role) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF OLD.auth_user_id IS NULL
|
||||
AND NEW.auth_user_id = auth.uid()
|
||||
AND OLD.email IS NOT NULL
|
||||
AND lower(OLD.email) = lower(coalesce(auth.jwt() ->> 'email', ''))
|
||||
AND NEW.id IS NOT DISTINCT FROM OLD.id
|
||||
AND NEW.nome IS NOT DISTINCT FROM OLD.nome
|
||||
AND NEW.cognome IS NOT DISTINCT FROM OLD.cognome
|
||||
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
|
||||
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
|
||||
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
|
||||
AND NEW.email IS NOT DISTINCT FROM OLD.email
|
||||
AND NEW.numero_tessera IS NOT DISTINCT FROM OLD.numero_tessera
|
||||
AND NEW.data_tessera IS NOT DISTINCT FROM OLD.data_tessera
|
||||
AND NEW.tipo IS NOT DISTINCT FROM OLD.tipo
|
||||
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Slot già collegato al chiamante: cambia `nascita` (sync di M18) e, se è un allenatore,
|
||||
-- nome e cognome.
|
||||
IF OLD.auth_user_id IS NOT NULL
|
||||
AND OLD.auth_user_id = auth.uid()
|
||||
AND NEW.auth_user_id IS NOT DISTINCT FROM OLD.auth_user_id
|
||||
AND NEW.id IS NOT DISTINCT FROM OLD.id
|
||||
AND (OLD.tipo = 'allenatore' OR NEW.nome IS NOT DISTINCT FROM OLD.nome)
|
||||
AND (OLD.tipo = 'allenatore' OR NEW.cognome IS NOT DISTINCT FROM OLD.cognome)
|
||||
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
|
||||
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
|
||||
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
|
||||
AND NEW.email IS NOT DISTINCT FROM OLD.email
|
||||
AND NEW.numero_tessera IS NOT DISTINCT FROM OLD.numero_tessera
|
||||
AND NEW.data_tessera IS NOT DISTINCT FROM OLD.data_tessera
|
||||
AND NEW.tipo IS NOT DISTINCT FROM OLD.tipo
|
||||
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'Aggiornamento non autorizzato su giocatori_squadra';
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Fino a qui un giocatore non poteva aggiornare una riga già collegata (solo il reclamo di
|
||||
-- uno slot libero, M1): l'allenatore ora può farlo sulla propria, per nome e cognome. Il
|
||||
-- trigger sopra decide quali campi.
|
||||
CREATE POLICY "L'allenatore aggiorna il proprio slot" ON public.giocatori_squadra
|
||||
FOR UPDATE TO authenticated
|
||||
USING (auth_user_id = auth.uid() AND tipo = 'allenatore')
|
||||
WITH CHECK (auth_user_id = auth.uid() AND tipo = 'allenatore');
|
||||
|
||||
-- --- Ruolo in user_roles allineato allo slot ---------------------------------------------
|
||||
--
|
||||
-- Un account ha il ruolo `allenatore` finché è collegato a uno slot attivo di tipo
|
||||
-- allenatore. Scollegare, disattivare o cambiare tipo lo toglie.
|
||||
CREATE OR REPLACE FUNCTION public.sincronizza_ruolo_allenatore()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE' AND OLD.auth_user_id IS NOT NULL
|
||||
AND (NEW.auth_user_id IS DISTINCT FROM OLD.auth_user_id
|
||||
OR NEW.tipo <> 'allenatore' OR NOT NEW.attivo) THEN
|
||||
DELETE FROM public.user_roles
|
||||
WHERE user_id = OLD.auth_user_id AND role = 'allenatore'::public.app_role;
|
||||
END IF;
|
||||
|
||||
IF NEW.auth_user_id IS NOT NULL AND NEW.tipo = 'allenatore' AND NEW.attivo THEN
|
||||
INSERT INTO public.user_roles (user_id, role)
|
||||
VALUES (NEW.auth_user_id, 'allenatore'::public.app_role)
|
||||
ON CONFLICT (user_id, role) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER sincronizza_ruolo_allenatore
|
||||
AFTER INSERT OR UPDATE OF auth_user_id, tipo, attivo ON public.giocatori_squadra
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.sincronizza_ruolo_allenatore();
|
||||
|
||||
-- --- Eventi: admin o allenatore -----------------------------------------------------------
|
||||
DROP POLICY "Gli admin gestiscono gli eventi" ON public.eventi_app;
|
||||
|
||||
CREATE POLICY "Admin e allenatori gestiscono gli eventi" ON public.eventi_app
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
public.has_role(auth.uid(), 'admin'::public.app_role)
|
||||
OR public.has_role(auth.uid(), 'allenatore'::public.app_role)
|
||||
)
|
||||
WITH CHECK (
|
||||
public.has_role(auth.uid(), 'admin'::public.app_role)
|
||||
OR public.has_role(auth.uid(), 'allenatore'::public.app_role)
|
||||
);
|
||||
|
||||
-- --- Presenze e cacche: solo i giocatori rispondono per sé --------------------------------
|
||||
DROP POLICY "Ognuno gestisce la propria presenza" ON public.risposte_presenze;
|
||||
|
||||
CREATE POLICY "Ognuno gestisce la propria presenza" ON public.risposte_presenze
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() AND g.tipo = 'giocatore'
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() AND g.tipo = 'giocatore'
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY "Ognuno dichiara le proprie cacche" ON public.cacche_partita;
|
||||
|
||||
CREATE POLICY "Ognuno dichiara le proprie cacche" ON public.cacche_partita
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() AND g.tipo = 'giocatore'
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() AND g.tipo = 'giocatore'
|
||||
)
|
||||
);
|
||||
|
||||
-- --- Voti: l'allenatore non vota e non viene votato ---------------------------------------
|
||||
--
|
||||
-- Stessa firma di M13, quindi le policy di pagelle, MVP e badge social la usano già.
|
||||
CREATE OR REPLACE FUNCTION public.evento_permette_voto(
|
||||
p_match_id text, p_votante_id text, p_votato_id text, p_richiede_pagelle_aperte boolean
|
||||
) RETURNS boolean
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.eventi_app e
|
||||
WHERE e.id = p_match_id
|
||||
AND (cardinality(e.convocati) = 0 OR p_votante_id = ANY(e.convocati))
|
||||
AND (cardinality(e.convocati) = 0 OR p_votato_id = ANY(e.convocati))
|
||||
AND (NOT p_richiede_pagelle_aperte OR NOT e.pagelle_chiuse)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id IN (p_votante_id, p_votato_id) AND g.tipo = 'allenatore'
|
||||
);
|
||||
$$;
|
||||
|
||||
-- --- Promemoria evento: anche agli allenatori, convocati o no -----------------------------
|
||||
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 (g.tipo = 'allenatore' OR cardinality(e.convocati) = 0 OR g.id = ANY(e.convocati))
|
||||
WHERE e.id = p_evento_id;
|
||||
$$;
|
||||
@@ -5,6 +5,12 @@
|
||||
-- I path dei file puntano a oggetti che nel bucket non esistono: i pulsanti di download
|
||||
-- falliscono finché non carichi qualcosa dall'app o dallo Studio (http://127.0.0.1:54323).
|
||||
|
||||
-- Il trigger di sync M18 (`sincronizza_nascita_pubblica`) aggiorna `giocatori_squadra.nascita`
|
||||
-- appena questo INSERT tocca `data_nascita`, ma qui gira senza un JWT reale: né il ramo admin
|
||||
-- né quello del proprietario dello slot del trigger di sicurezza passerebbero. Va disabilitato
|
||||
-- solo per questo seed, mai un problema in produzione dove il seed non gira.
|
||||
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
|
||||
|
||||
INSERT INTO public.profili_giocatore
|
||||
(giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email,
|
||||
documento_tipo, documento_numero, documento_rilasciato_da,
|
||||
@@ -29,6 +35,8 @@ VALUES
|
||||
'2027-09-15', 'g2/certificato.pdf', NULL)
|
||||
ON CONFLICT (giocatore_id) DO NOTHING;
|
||||
|
||||
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
|
||||
|
||||
-- Il primo amministratore non si può seminare qui: `user_roles.user_id` punta a un utente
|
||||
-- di `auth.users`, che su un database appena creato non esiste ancora. Dopo il primo login
|
||||
-- (in locale come in produzione) basta una riga:
|
||||
|
||||
+8
-7
@@ -13,7 +13,8 @@ bun test/unit/badges.test.ts # un singolo file
|
||||
```
|
||||
|
||||
Il runner (`test/run.ts`) esegue ogni file in un processo separato, così un test
|
||||
non può inquinare gli altri.
|
||||
non può inquinare gli altri. bun esegue il TypeScript senza controllarne i tipi: un
|
||||
test può risultare verde anche con errori di tipo, che trova solo `npm run typecheck`.
|
||||
|
||||
Per le push, `unit/push-sw.test.ts` esegue il service worker reale in contesti isolati,
|
||||
senza pagina né rete: verifica la notifica al risveglio e le promesse di `waitUntil`.
|
||||
@@ -23,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
|
||||
|
||||
## Struttura
|
||||
|
||||
| Cartella | Cosa verifica | Serve rete? |
|
||||
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route) | No |
|
||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) e la funzione di bonifica delle righe orfane (`bonifica-evento`, M16) sul database locale | Sì |
|
||||
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||
| Cartella | Cosa verifica | Serve rete? |
|
||||
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup) | No |
|
||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
|
||||
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||
|
||||
## Database locale in Docker
|
||||
|
||||
|
||||
@@ -230,6 +230,13 @@ try {
|
||||
}
|
||||
});
|
||||
|
||||
await prova("l'invio di un messaggio personalizzato chiede le credenziali", async () => {
|
||||
assert.equal(
|
||||
(await postJson("/api/public/notifica-personalizzata", { messaggio: "prova" })).status,
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
await prova("un token malformato non passa", async () => {
|
||||
const res = await fetch(url("/api/public/sollecita-presenze"), {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Backfill di M19 (`m19_backfill_nascita_da_profili_esistenti`):
|
||||
* `bun test/integration/backfill-nascita-esistenti.test.ts`.
|
||||
*
|
||||
* M18 ha aggiunto `giocatori_squadra.nascita`, sincronizzata da un trigger su
|
||||
* `profili_giocatore` — ma solo per le scritture *future*. In produzione questo ha lasciato
|
||||
* scoperti i profili già compilati **prima** di M18: la nascita restava vuota finché
|
||||
* `m19_backfill_nascita_da_profili_esistenti` non ha rieseguito una tantum lo stesso
|
||||
* allineamento (vedi DD-031, aggiornamento del 15/09/2026).
|
||||
*
|
||||
* A differenza della bonifica eventi orfani (M15→M16, `bonifica_dati_evento_orfani()`),
|
||||
* questo backfill non è stato reso una funzione richiamabile: qui si verifica lo stesso
|
||||
* pattern SQL (`UPDATE giocatori_squadra ... FROM profili_giocatore`, con
|
||||
* `enforce_giocatori_squadra_update` temporaneamente disattivato) eseguendolo via `psql`
|
||||
* dentro il container Postgres locale — PostgREST non espone un update multi-tabella come
|
||||
* RPC, quindi qui serve accesso diretto al database, non solo alla REST.
|
||||
*
|
||||
* Simula un profilo "storico" (inserito con il trigger di sync disattivato, come se
|
||||
* esistesse da prima di M18) e verifica che il backfill lo recuperi. Gira solo sullo stack
|
||||
* locale con Docker attivo: usa lo slot `g9` della rosa seed, ripristinato alla fine.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
if (!locale) {
|
||||
salta("backfill nascita profili esistenti", "stack locale non attivo (npx supabase start)");
|
||||
riepilogo("backfill-nascita-esistenti");
|
||||
} else {
|
||||
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
|
||||
|
||||
const projectId = /project_id\s*=\s*"([^"]+)"/.exec(
|
||||
readFileSync(new URL("../../supabase/config.toml", import.meta.url), "utf8"),
|
||||
)?.[1];
|
||||
const container = projectId ? `supabase_db_${projectId}` : null;
|
||||
const dockerDisponibile =
|
||||
!!container && spawnSync("docker", ["inspect", container], { stdio: "ignore" }).status === 0;
|
||||
|
||||
if (!dockerDisponibile) {
|
||||
salta("backfill nascita profili esistenti", "container Postgres locale non trovato");
|
||||
riepilogo("backfill-nascita-esistenti");
|
||||
} else {
|
||||
console.log(`backfill nascita profili esistenti su ${URL_BASE} (container ${container})`);
|
||||
|
||||
function eseguiSql(sql: string): string {
|
||||
const esito = spawnSync(
|
||||
"docker",
|
||||
["exec", "-i", container!, "psql", "-U", "postgres", "-d", "postgres"],
|
||||
{
|
||||
input: sql,
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
if (esito.status !== 0) throw new Error(`psql fallito: ${esito.stderr || esito.stdout}`);
|
||||
return esito.stdout;
|
||||
}
|
||||
|
||||
const rest = (percorso: string, token: string, init?: RequestInit) =>
|
||||
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||
...init,
|
||||
headers: {
|
||||
apikey: token === SERVIZIO ? SERVIZIO : ANON,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
async function leggiNascita(): Promise<string | null> {
|
||||
const res = await rest("giocatori_squadra?id=eq.g9&select=nascita", SERVIZIO);
|
||||
const righe = (await res.json()) as Array<{ nascita: string | null }>;
|
||||
return righe[0]?.nascita ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
await prova(
|
||||
"il backfill recupera la nascita di un profilo compilato prima di M18",
|
||||
async () => {
|
||||
// g9 è uno dei 17 storici: il backfill hardcoded di M18 le ha già dato una nascita
|
||||
// pubblica. Per simulare lo stato "prima di M19" — un profilo compilato ma non
|
||||
// ancora sincronizzato — va prima azzerata, poi inserito il profilo con il trigger
|
||||
// di sync disattivato (come se esistesse da prima che M18 lo introducesse).
|
||||
eseguiSql(`
|
||||
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
|
||||
UPDATE public.giocatori_squadra SET nascita = NULL WHERE id = 'g9';
|
||||
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
|
||||
|
||||
ALTER TABLE public.profili_giocatore DISABLE TRIGGER sincronizza_nascita_pubblica;
|
||||
INSERT INTO public.profili_giocatore (giocatore_id, data_nascita)
|
||||
VALUES ('g9', '1988-07-21');
|
||||
ALTER TABLE public.profili_giocatore ENABLE TRIGGER sincronizza_nascita_pubblica;
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
await leggiNascita(),
|
||||
null,
|
||||
"prima del backfill la nascita pubblica è ancora vuota",
|
||||
);
|
||||
|
||||
// Stesso corpo esatto di m19_backfill_nascita_da_profili_esistenti.sql.
|
||||
eseguiSql(`
|
||||
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
|
||||
UPDATE public.giocatori_squadra gs
|
||||
SET nascita = pg.data_nascita
|
||||
FROM public.profili_giocatore pg
|
||||
WHERE pg.giocatore_id = gs.id;
|
||||
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
await leggiNascita(),
|
||||
"1988-07-21",
|
||||
"dopo il backfill la nascita del profilo preesistente è recuperata",
|
||||
);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
// g9 (Camilla Esposito) è uno dei 17 storici: il backfill di M18 le aveva già dato
|
||||
// '2003-04-04' (src/lib/crapp-data.ts) prima che questo test la sovrascrivesse — va
|
||||
// ripristinato quel valore, non svuotato, per non alterare lo stato del seed.
|
||||
eseguiSql(`
|
||||
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
|
||||
DELETE FROM public.profili_giocatore WHERE giocatore_id = 'g9';
|
||||
UPDATE public.giocatori_squadra SET nascita = '2003-04-04' WHERE id = 'g9';
|
||||
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
|
||||
`);
|
||||
riepilogo("backfill-nascita-esistenti");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Data di nascita pubblica a tutta la squadra (M18): `bun test/integration/nascita-pubblica.test.ts`.
|
||||
*
|
||||
* Prima di questa migration `giocatori_squadra` non aveva una colonna `nascita`: la Squadra
|
||||
* mostrava "Invalid Date" per chi l'aveva inserita da solo dal Profilo, perché quel valore
|
||||
* finiva solo in `profili_giocatore.data_nascita`, tabella con RLS "solo il proprio profilo
|
||||
* o admin" — mai letta dall'anagrafica pubblica. Qui si verifica che il trigger di sync
|
||||
* (`sincronizza_nascita_pubblica`) propaghi il valore a `giocatori_squadra.nascita`, visibile
|
||||
* a **qualsiasi** giocatore (non solo l'interessato o un admin), che lo azzeri quando il
|
||||
* profilo viene cancellato, e che il client non possa scriverlo direttamente.
|
||||
*
|
||||
* Gira solo sullo stack locale (`npx supabase start`): usa lo slot `g6` della rosa seed,
|
||||
* ripristinato alla fine.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
if (!locale) {
|
||||
salta("nascita pubblica", "stack locale non attivo (npx supabase start)");
|
||||
riepilogo("nascita-pubblica");
|
||||
} else {
|
||||
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
|
||||
console.log(`nascita pubblica su ${URL_BASE}`);
|
||||
|
||||
const GIOCATORE = "g6";
|
||||
const PASSWORD = "prova-nascita-pubblica-123";
|
||||
const idUtenti: string[] = [];
|
||||
let tokenAdmin = "";
|
||||
|
||||
const rest = (percorso: string, token: string, init?: RequestInit) =>
|
||||
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||
...init,
|
||||
headers: {
|
||||
apikey: token === SERVIZIO ? SERVIZIO : ANON,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
async function creaUtente(email: string): Promise<string> {
|
||||
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: SERVIZIO,
|
||||
Authorization: `Bearer ${SERVIZIO}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
|
||||
});
|
||||
const corpo = (await res.json()) as { id?: string };
|
||||
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
|
||||
return corpo.id;
|
||||
}
|
||||
|
||||
async function accedi(email: string): Promise<string> {
|
||||
const res = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
|
||||
method: "POST",
|
||||
headers: { apikey: ANON, "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password: PASSWORD }),
|
||||
});
|
||||
const corpo = (await res.json()) as { access_token?: string };
|
||||
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
|
||||
return corpo.access_token;
|
||||
}
|
||||
|
||||
const eliminaUtente = (id: string) =>
|
||||
fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
|
||||
});
|
||||
|
||||
async function leggiNascita(token: string): Promise<string | null> {
|
||||
const res = await rest(`giocatori_squadra?id=eq.${GIOCATORE}&select=nascita`, token);
|
||||
const righe = (await res.json()) as Array<{ nascita: string | null }>;
|
||||
return righe[0]?.nascita ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
const emailAdmin = `test-nascita-admin-${Date.now()}@example.test`;
|
||||
const emailGiocatore = `test-nascita-giocatore-${Date.now()}@example.test`;
|
||||
const emailAltro = `test-nascita-altro-${Date.now()}@example.test`;
|
||||
const idAdmin = await creaUtente(emailAdmin);
|
||||
const idGiocatore = await creaUtente(emailGiocatore);
|
||||
const idAltro = await creaUtente(emailAltro);
|
||||
idUtenti.push(idAdmin, idGiocatore, idAltro);
|
||||
|
||||
await rest("user_roles", SERVIZIO, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
|
||||
});
|
||||
|
||||
tokenAdmin = await accedi(emailAdmin);
|
||||
const tokenGiocatore = await accedi(emailGiocatore);
|
||||
const tokenAltro = await accedi(emailAltro);
|
||||
|
||||
const collega = (utente: string | null) =>
|
||||
rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ auth_user_id: utente }),
|
||||
});
|
||||
await collega(idGiocatore);
|
||||
|
||||
await prova(
|
||||
"il giocatore salva la propria nascita e diventa visibile a un altro giocatore",
|
||||
async () => {
|
||||
const res = await rest("profili_giocatore", tokenGiocatore, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ giocatore_id: GIOCATORE, data_nascita: "2002-06-15" }),
|
||||
});
|
||||
assert.ok(res.ok, `salvataggio profilo: ${res.status} ${await res.text()}`);
|
||||
|
||||
assert.equal(await leggiNascita(tokenAltro), "2002-06-15", "visibile a un altro giocatore");
|
||||
assert.equal(await leggiNascita(tokenAdmin), "2002-06-15", "visibile all'admin");
|
||||
},
|
||||
);
|
||||
|
||||
await prova("il client non può scrivere direttamente la nascita pubblica", async () => {
|
||||
await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenGiocatore, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ nascita: "1900-01-01" }),
|
||||
});
|
||||
assert.equal(
|
||||
await leggiNascita(tokenAdmin),
|
||||
"2002-06-15",
|
||||
"il valore resta quello scritto dalla sync, non quello del tentativo diretto",
|
||||
);
|
||||
});
|
||||
|
||||
await prova("cancellare il profilo azzera la nascita pubblica", async () => {
|
||||
const res = await rest(`profili_giocatore?giocatore_id=eq.${GIOCATORE}`, tokenAdmin, {
|
||||
method: "DELETE",
|
||||
});
|
||||
assert.ok(res.ok, `cancellazione profilo: ${res.status} ${await res.text()}`);
|
||||
assert.equal(await leggiNascita(tokenAdmin), null, "torna NULL");
|
||||
});
|
||||
|
||||
await prova("un admin può impostare la nascita per conto del giocatore", async () => {
|
||||
const res = await rest("profili_giocatore", tokenAdmin, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ giocatore_id: GIOCATORE, data_nascita: "1999-01-01" }),
|
||||
});
|
||||
assert.ok(res.ok, `salvataggio profilo da admin: ${res.status} ${await res.text()}`);
|
||||
assert.equal(await leggiNascita(tokenGiocatore), "1999-01-01");
|
||||
});
|
||||
} finally {
|
||||
// Con la service role questa DELETE fallirebbe: fa scattare il trigger di sync, che
|
||||
// tenta un UPDATE su giocatori_squadra ancora collegato, e `enforce_giocatori_squadra_update`
|
||||
// rifiuta la service role quanto un utente qualsiasi (auth.uid() NULL, nessun ramo passa).
|
||||
// Serve il JWT vero dell'admin, come per la cancellazione dentro il test.
|
||||
if (tokenAdmin) {
|
||||
await rest(`profili_giocatore?giocatore_id=eq.${GIOCATORE}`, tokenAdmin, {
|
||||
method: "DELETE",
|
||||
});
|
||||
} else {
|
||||
await rest(`profili_giocatore?giocatore_id=eq.${GIOCATORE}`, SERVIZIO, { method: "DELETE" });
|
||||
}
|
||||
if (tokenAdmin) {
|
||||
await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ auth_user_id: null }),
|
||||
});
|
||||
}
|
||||
for (const id of idUtenti) {
|
||||
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||
await eliminaUtente(id);
|
||||
}
|
||||
riepilogo("nascita-pubblica");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Centro notifiche in-app (M17): `bun test/integration/notifiche-utente.test.ts`.
|
||||
*
|
||||
* Copre le quattro sorgenti che scrivono in `notifiche_utente` mai dal client (le due
|
||||
* funzioni `pg_cron` dei promemoria evento, la route `notifica-personalizzata`, la route
|
||||
* `promemoria-palloni`, la route `sollecita-presenze`), più la RLS che decide chi può
|
||||
* leggerle e segnarle come lette. `notifiche-utente.test.ts` (unit) copre la sola
|
||||
* conversione riga->modello e il badge; qui serve un database vero per funzioni, trigger
|
||||
* e policy scritte su `auth.uid()`.
|
||||
*
|
||||
* Gira solo sullo stack locale (`npx supabase start`): usa id con il prefisso
|
||||
* `test-notifiche-utente` per gli eventi e lo slot `g4` della rosa seed per la RLS,
|
||||
* ripristinati alla fine.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { avviaServer } from "../helpers/server";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
if (!locale) {
|
||||
salta("notifiche in-app", "stack locale non attivo (npx supabase start)");
|
||||
riepilogo("notifiche-utente");
|
||||
} else {
|
||||
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
|
||||
console.log(`notifiche in-app su ${URL_BASE}`);
|
||||
|
||||
// Il server di sviluppo (per le sezioni con le route) eredita queste: le route leggono
|
||||
// i nomi senza prefisso e senza questo override punterebbero al progetto cloud di `.env`.
|
||||
process.env["SUPABASE_URL"] = URL_BASE;
|
||||
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
|
||||
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
|
||||
|
||||
const PREFISSO = "test-notifiche-utente";
|
||||
const EVENTO_24H = `${PREFISSO}-24h`;
|
||||
const EVENTO_3H = `${PREFISSO}-3h`;
|
||||
const EVENTO_ORA_VALIDA = `${PREFISSO}-ora-valida`;
|
||||
const EVENTO_ORA_ROTTA = `${PREFISSO}-ora-rotta`;
|
||||
const EVENTO_PALLONI = `${PREFISSO}-palloni`;
|
||||
const EVENTO_PRESENZE = `${PREFISSO}-presenze`;
|
||||
const GIOCATORE_RLS = "g4";
|
||||
const MESSAGGIO_UNICO = `${PREFISSO}-messaggio-${Date.now()}`;
|
||||
|
||||
/**
|
||||
* `data`/`ora` sono ora locale Italia (stessa convenzione del resto dell'app, vedi
|
||||
* `m10_azzera_turni_palloni_allenamenti` e la funzione `genera_promemoria_eventi`):
|
||||
* calcolarle da `Date` in UTC senza passare da `Europe/Rome` sfaserebbe il confronto
|
||||
* con `now()` lato database fino a due ore, abbastanza da far scattare il promemoria
|
||||
* sbagliato vicino ai bordi delle finestre di 3 e 24 ore.
|
||||
*/
|
||||
function dataOraRoma(quando: Date): { data: string; ora: string } {
|
||||
const data = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(quando);
|
||||
const ora = new Intl.DateTimeFormat("it-IT", {
|
||||
timeZone: "Europe/Rome",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(quando);
|
||||
return { data, ora };
|
||||
}
|
||||
|
||||
const rest = (percorso: string, token: string, init?: RequestInit) =>
|
||||
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||
...init,
|
||||
headers: {
|
||||
apikey: token === SERVIZIO ? SERVIZIO : ANON,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
async function inserisci(tabella: string, riga: Record<string, unknown>) {
|
||||
const res = await rest(tabella, SERVIZIO, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=minimal" },
|
||||
body: JSON.stringify(riga),
|
||||
});
|
||||
if (!res.ok) throw new Error(`insert su ${tabella}: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function leggi<T>(percorso: string): Promise<T[]> {
|
||||
const res = await rest(percorso, SERVIZIO);
|
||||
if (!res.ok) throw new Error(`select su ${percorso}: ${res.status} ${await res.text()}`);
|
||||
return (await res.json()) as T[];
|
||||
}
|
||||
|
||||
async function contaAttivi(): Promise<number> {
|
||||
const righe = await leggi<{ id: string }>("giocatori_squadra?attivo=eq.true&select=id");
|
||||
return righe.length;
|
||||
}
|
||||
|
||||
async function chiamaRpc(nome: string, corpo: Record<string, unknown>) {
|
||||
const res = await fetch(`${URL_BASE}/rest/v1/rpc/${nome}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: SERVIZIO,
|
||||
Authorization: `Bearer ${SERVIZIO}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(corpo),
|
||||
});
|
||||
if (!res.ok) throw new Error(`rpc ${nome}: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function creaUtente(email: string, password: string): Promise<string> {
|
||||
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: SERVIZIO,
|
||||
Authorization: `Bearer ${SERVIZIO}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email, password, email_confirm: true }),
|
||||
});
|
||||
const corpo = (await res.json()) as { id?: string };
|
||||
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
|
||||
return corpo.id;
|
||||
}
|
||||
|
||||
async function accedi(email: string, password: string): Promise<string> {
|
||||
const res = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
|
||||
method: "POST",
|
||||
headers: { apikey: ANON, "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const corpo = (await res.json()) as { access_token?: string };
|
||||
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
|
||||
return corpo.access_token;
|
||||
}
|
||||
|
||||
const eliminaUtente = (id: string) =>
|
||||
fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
|
||||
});
|
||||
|
||||
const PASSWORD = "prova-notifiche-utente-123";
|
||||
const idUtenti: string[] = [];
|
||||
let tokenAdmin = "";
|
||||
let server: { baseUrl: string; stop: () => void } | null = null;
|
||||
|
||||
try {
|
||||
// --- preparazione: un amministratore, un giocatore collegato a g4 -----------------
|
||||
const emailAdmin = `test-notifiche-utente-admin-${Date.now()}@example.test`;
|
||||
const emailGiocatore = `test-notifiche-utente-giocatore-${Date.now()}@example.test`;
|
||||
const idAdmin = await creaUtente(emailAdmin, PASSWORD);
|
||||
const idGiocatore = await creaUtente(emailGiocatore, PASSWORD);
|
||||
idUtenti.push(idAdmin, idGiocatore);
|
||||
|
||||
await rest("user_roles", SERVIZIO, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
|
||||
});
|
||||
|
||||
tokenAdmin = await accedi(emailAdmin, PASSWORD);
|
||||
const tokenGiocatore = await accedi(emailGiocatore, PASSWORD);
|
||||
|
||||
// Il trigger `enforce_giocatori_squadra_update` rifiuta la service key (auth.uid() NULL):
|
||||
// serve il JWT dell'admin, come in `permessi.test.ts`.
|
||||
const collega = (utente: string | null) =>
|
||||
rest(`giocatori_squadra?id=eq.${GIOCATORE_RLS}`, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ auth_user_id: utente }),
|
||||
});
|
||||
await collega(idGiocatore);
|
||||
|
||||
// --- 1. Promemoria: finestre 24h/3h e deduplica -----------------------------------
|
||||
await prova("il promemoria 24h scatta per un evento a 20 ore, non il 3h", async () => {
|
||||
const { data, ora } = dataOraRoma(new Date(Date.now() + 20 * 60 * 60 * 1000));
|
||||
await inserisci("eventi_app", {
|
||||
id: EVENTO_24H,
|
||||
tipo: "allenamento",
|
||||
titolo: "Allenamento imminente",
|
||||
data,
|
||||
ora,
|
||||
convocati: [GIOCATORE_RLS],
|
||||
});
|
||||
|
||||
await chiamaRpc("genera_promemoria_eventi", {
|
||||
p_tipo: "evento_promemoria_24h",
|
||||
p_finestra: "24 hours",
|
||||
});
|
||||
await chiamaRpc("genera_promemoria_eventi", {
|
||||
p_tipo: "evento_promemoria_3h",
|
||||
p_finestra: "3 hours",
|
||||
});
|
||||
|
||||
const promemoria24h = await leggi(
|
||||
`notifiche_utente?evento_id=eq.${EVENTO_24H}&tipo=eq.evento_promemoria_24h&select=id`,
|
||||
);
|
||||
const promemoria3h = await leggi(
|
||||
`notifiche_utente?evento_id=eq.${EVENTO_24H}&tipo=eq.evento_promemoria_3h&select=id`,
|
||||
);
|
||||
assert.equal(promemoria24h.length, 1, "il promemoria 24h è stato generato");
|
||||
assert.equal(
|
||||
promemoria3h.length,
|
||||
0,
|
||||
"il promemoria 3h non scatta ancora a 20 ore di distanza",
|
||||
);
|
||||
});
|
||||
|
||||
await prova(
|
||||
"il promemoria 3h scatta per un evento a 2 ore, ed entrambi non duplicano",
|
||||
async () => {
|
||||
const { data, ora } = dataOraRoma(new Date(Date.now() + 2 * 60 * 60 * 1000));
|
||||
await inserisci("eventi_app", {
|
||||
id: EVENTO_3H,
|
||||
tipo: "allenamento",
|
||||
titolo: "Allenamento imminentissimo",
|
||||
data,
|
||||
ora,
|
||||
convocati: [GIOCATORE_RLS],
|
||||
});
|
||||
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await chiamaRpc("genera_promemoria_eventi", {
|
||||
p_tipo: "evento_promemoria_24h",
|
||||
p_finestra: "24 hours",
|
||||
});
|
||||
await chiamaRpc("genera_promemoria_eventi", {
|
||||
p_tipo: "evento_promemoria_3h",
|
||||
p_finestra: "3 hours",
|
||||
});
|
||||
}
|
||||
|
||||
const promemoria24h = await leggi(
|
||||
`notifiche_utente?evento_id=eq.${EVENTO_3H}&tipo=eq.evento_promemoria_24h&select=id`,
|
||||
);
|
||||
const promemoria3h = await leggi(
|
||||
`notifiche_utente?evento_id=eq.${EVENTO_3H}&tipo=eq.evento_promemoria_3h&select=id`,
|
||||
);
|
||||
assert.equal(promemoria24h.length, 1, "un solo promemoria 24h anche a due esecuzioni");
|
||||
assert.equal(promemoria3h.length, 1, "un solo promemoria 3h anche a due esecuzioni");
|
||||
},
|
||||
);
|
||||
|
||||
// `ora` è testo libero (input nativo `type="time"`, ma la form non impedisce di
|
||||
// svuotarlo): un evento con l'ora scritta male non deve rompere la generazione dei
|
||||
// promemoria per tutti gli altri (era il caso prima che `ora_evento_a_time()`
|
||||
// assorbisse l'errore riga per riga invece di far fallire l'intera INSERT...SELECT).
|
||||
await prova(
|
||||
"un evento con l'ora scritta male non blocca i promemoria degli altri eventi",
|
||||
async () => {
|
||||
const { data, ora } = dataOraRoma(new Date(Date.now() + 2 * 60 * 60 * 1000));
|
||||
await inserisci("eventi_app", {
|
||||
id: EVENTO_ORA_VALIDA,
|
||||
tipo: "allenamento",
|
||||
titolo: "Allenamento imminentissimo",
|
||||
data,
|
||||
ora,
|
||||
convocati: [GIOCATORE_RLS],
|
||||
});
|
||||
await inserisci("eventi_app", {
|
||||
id: EVENTO_ORA_ROTTA,
|
||||
tipo: "allenamento",
|
||||
titolo: "Evento con ora vuota",
|
||||
data,
|
||||
ora: "",
|
||||
convocati: [GIOCATORE_RLS],
|
||||
});
|
||||
|
||||
await chiamaRpc("genera_promemoria_eventi", {
|
||||
p_tipo: "evento_promemoria_3h",
|
||||
p_finestra: "3 hours",
|
||||
});
|
||||
|
||||
const promemoria3h = await leggi(
|
||||
`notifiche_utente?evento_id=eq.${EVENTO_ORA_VALIDA}&tipo=eq.evento_promemoria_3h&select=id`,
|
||||
);
|
||||
assert.equal(
|
||||
promemoria3h.length,
|
||||
1,
|
||||
"l'evento con l'ora valida riceve comunque il suo promemoria",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// --- 2. RLS: ognuno legge e segna come lette solo le proprie ----------------------
|
||||
await prova("il giocatore legge solo le proprie notifiche", async () => {
|
||||
const mie = await rest(
|
||||
`notifiche_utente?giocatore_id=eq.${GIOCATORE_RLS}&select=id`,
|
||||
tokenGiocatore,
|
||||
);
|
||||
assert.equal(mie.status, 200);
|
||||
const righeMie = (await mie.json()) as unknown[];
|
||||
assert.ok(righeMie.length > 0, "g4 ha almeno una notifica dai test precedenti");
|
||||
|
||||
const altrui = await rest("notifiche_utente?giocatore_id=eq.g1&select=id", tokenGiocatore);
|
||||
assert.equal(altrui.status, 200);
|
||||
assert.deepEqual(
|
||||
await altrui.json(),
|
||||
[],
|
||||
"le notifiche di un altro giocatore restano invisibili",
|
||||
);
|
||||
});
|
||||
|
||||
await prova("il giocatore segna come lette solo le proprie notifiche", async () => {
|
||||
const res = await rest(
|
||||
`notifiche_utente?giocatore_id=eq.${GIOCATORE_RLS}&letta=eq.false`,
|
||||
tokenGiocatore,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ letta: true }),
|
||||
},
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const aggiornate = (await res.json()) as Array<{ letta: boolean }>;
|
||||
assert.ok(aggiornate.length > 0);
|
||||
assert.ok(aggiornate.every((r) => r.letta === true));
|
||||
|
||||
const restaFalse = await leggi(
|
||||
`notifiche_utente?giocatore_id=eq.${GIOCATORE_RLS}&letta=eq.false&select=id`,
|
||||
);
|
||||
assert.equal(restaFalse.length, 0, "nessuna non letta è rimasta indietro");
|
||||
});
|
||||
|
||||
await prova("il client non può inserire notifiche per sé", async () => {
|
||||
const res = await rest("notifiche_utente", tokenGiocatore, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ giocatore_id: GIOCATORE_RLS, tipo: "admin", titolo: "finta" }),
|
||||
});
|
||||
assert.ok(!res.ok, `l'insert dal client deve fallire, invece ha risposto ${res.status}`);
|
||||
});
|
||||
|
||||
// --- 3. Route notifica-personalizzata: scrive anche lo storico in-app -------------
|
||||
server = await avviaServer();
|
||||
console.log(`route notifica-personalizzata su ${server.baseUrl}`);
|
||||
|
||||
await prova("un messaggio a un giocatore specifico crea solo la sua notifica", async () => {
|
||||
const res = await fetch(`${server!.baseUrl}/api/public/notifica-personalizzata`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
|
||||
body: JSON.stringify({ messaggio: MESSAGGIO_UNICO, giocatoreId: GIOCATORE_RLS }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const righe = await leggi<{ giocatore_id: string; tipo: string }>(
|
||||
`notifiche_utente?corpo=eq.${encodeURIComponent(MESSAGGIO_UNICO)}&select=giocatore_id,tipo`,
|
||||
);
|
||||
assert.deepEqual(righe, [{ giocatore_id: GIOCATORE_RLS, tipo: "admin" }]);
|
||||
|
||||
await rest(`notifiche_utente?corpo=eq.${encodeURIComponent(MESSAGGIO_UNICO)}`, SERVIZIO, {
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
|
||||
await prova("un messaggio senza destinatario notifica tutta la rosa attiva", async () => {
|
||||
const attesi = await contaAttivi();
|
||||
const res = await fetch(`${server!.baseUrl}/api/public/notifica-personalizzata`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
|
||||
body: JSON.stringify({ messaggio: MESSAGGIO_UNICO }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const righe = await leggi(
|
||||
`notifiche_utente?corpo=eq.${encodeURIComponent(MESSAGGIO_UNICO)}&tipo=eq.admin&select=giocatore_id`,
|
||||
);
|
||||
assert.equal(righe.length, attesi, "una notifica admin per ogni giocatore attivo");
|
||||
});
|
||||
|
||||
await prova("il giocatore elimina solo le proprie notifiche", async () => {
|
||||
const [miaNotifica] = await leggi<{ id: string }>(
|
||||
`notifiche_utente?giocatore_id=eq.${GIOCATORE_RLS}&select=id&limit=1`,
|
||||
);
|
||||
const [notificaAltrui] = await leggi<{ id: string }>(
|
||||
"notifiche_utente?giocatore_id=eq.g1&select=id&limit=1",
|
||||
);
|
||||
assert.ok(miaNotifica, "g4 ha almeno una notifica da eliminare");
|
||||
assert.ok(notificaAltrui, "g1 ha almeno una notifica altrui da provare a eliminare");
|
||||
|
||||
// Lo swipe/la × chiamano una DELETE per id: qui la RLS deve bloccare quella
|
||||
// sull'id di un altro giocatore (0 righe toccate) e permettere la propria.
|
||||
await rest(`notifiche_utente?id=eq.${notificaAltrui.id}`, tokenGiocatore, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const altruiRestaAncora = await leggi(
|
||||
`notifiche_utente?id=eq.${notificaAltrui.id}&select=id`,
|
||||
);
|
||||
assert.equal(altruiRestaAncora.length, 1, "la notifica di un altro giocatore non si tocca");
|
||||
|
||||
await rest(`notifiche_utente?id=eq.${miaNotifica.id}`, tokenGiocatore, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const miaResta = await leggi(`notifiche_utente?id=eq.${miaNotifica.id}&select=id`);
|
||||
assert.equal(miaResta.length, 0, "la propria notifica è stata eliminata");
|
||||
});
|
||||
|
||||
// --- 4. Route promemoria-palloni: scrive anche lo storico in-app ------------------
|
||||
await prova("il promemoria del turno palloni crea la notifica per l'incaricato", async () => {
|
||||
await inserisci("eventi_app", {
|
||||
id: EVENTO_PALLONI,
|
||||
tipo: "partita",
|
||||
titolo: "Partita con palloni assegnati",
|
||||
data: "2026-09-25",
|
||||
ora: "18:00",
|
||||
convocati: [],
|
||||
});
|
||||
await inserisci("turni_palloni", { evento_id: EVENTO_PALLONI, giocatore_id: "g1" });
|
||||
|
||||
const res = await fetch(`${server!.baseUrl}/api/public/promemoria-palloni`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
|
||||
body: JSON.stringify({ eventoId: EVENTO_PALLONI }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const righe = await leggi<{ giocatore_id: string; letta: boolean }>(
|
||||
`notifiche_utente?evento_id=eq.${EVENTO_PALLONI}&tipo=eq.turno_palloni&giocatore_id=eq.g1&select=giocatore_id,letta`,
|
||||
);
|
||||
assert.equal(righe.length, 1, "una sola notifica per l'incaricato");
|
||||
assert.equal(righe[0]?.letta, false);
|
||||
|
||||
// Ripremere il pulsante aggiorna la stessa riga (upsert) invece di duplicarla o
|
||||
// fallire per il vincolo UNIQUE.
|
||||
const res2 = await fetch(`${server!.baseUrl}/api/public/promemoria-palloni`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
|
||||
body: JSON.stringify({ eventoId: EVENTO_PALLONI }),
|
||||
});
|
||||
assert.equal(res2.status, 200);
|
||||
const dopo = await leggi(
|
||||
`notifiche_utente?evento_id=eq.${EVENTO_PALLONI}&tipo=eq.turno_palloni&giocatore_id=eq.g1&select=id`,
|
||||
);
|
||||
assert.equal(dopo.length, 1, "un secondo invio non duplica la notifica");
|
||||
});
|
||||
|
||||
// --- 5. Route sollecita-presenze: scrive anche lo storico in-app ------------------
|
||||
await prova("il sollecito presenze notifica chi non ha ancora risposto", async () => {
|
||||
const attesi = await contaAttivi();
|
||||
await inserisci("eventi_app", {
|
||||
id: EVENTO_PRESENZE,
|
||||
tipo: "allenamento",
|
||||
titolo: "Allenamento da confermare",
|
||||
data: "2026-09-26",
|
||||
ora: "20:30",
|
||||
convocati: [],
|
||||
});
|
||||
|
||||
const res = await fetch(`${server!.baseUrl}/api/public/sollecita-presenze`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
|
||||
body: JSON.stringify({ eventoId: EVENTO_PRESENZE }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const righe = await leggi(
|
||||
`notifiche_utente?evento_id=eq.${EVENTO_PRESENZE}&tipo=eq.sollecita_presenze&select=giocatore_id`,
|
||||
);
|
||||
assert.equal(righe.length, attesi, "nessuno ha ancora risposto: tutta la rosa attiva");
|
||||
});
|
||||
} finally {
|
||||
server?.stop();
|
||||
await rest(`notifiche_utente?corpo=eq.${encodeURIComponent(MESSAGGIO_UNICO)}`, SERVIZIO, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await rest(`turni_palloni?evento_id=eq.${EVENTO_PALLONI}`, SERVIZIO, { method: "DELETE" });
|
||||
for (const id of [
|
||||
EVENTO_24H,
|
||||
EVENTO_3H,
|
||||
EVENTO_ORA_VALIDA,
|
||||
EVENTO_ORA_ROTTA,
|
||||
EVENTO_PALLONI,
|
||||
EVENTO_PRESENZE,
|
||||
]) {
|
||||
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||
}
|
||||
if (tokenAdmin) {
|
||||
await rest(`giocatori_squadra?id=eq.${GIOCATORE_RLS}`, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ auth_user_id: null }),
|
||||
});
|
||||
}
|
||||
for (const id of idUtenti) {
|
||||
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||
await eliminaUtente(id);
|
||||
}
|
||||
riepilogo("notifiche-utente");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Permessi del ruolo allenatore (DD-034, migration M21): `bun test/integration/permessi-allenatore.test.ts`.
|
||||
*
|
||||
* Stesso impianto di `permessi.test.ts`: utenti veri, database interrogato *come loro*,
|
||||
* solo contro lo stack locale. L'allenatore è uno slot di `giocatori_squadra` con
|
||||
* `tipo = 'allenatore'`, creato dall'admin e reclamato per email al primo accesso.
|
||||
*
|
||||
* Stato toccato e ripristinato alla fine: gli utenti creati, lo slot allenatore (cancellato),
|
||||
* lo slot g3 reclamato dal giocatore di prova e le righe con il prefisso `test-allenatore`.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
if (!locale) {
|
||||
salta("permessi allenatore", "stack locale non attivo (npx supabase start)");
|
||||
riepilogo("permessi-allenatore");
|
||||
} else {
|
||||
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
|
||||
console.log(`permessi allenatore su ${URL_BASE}`);
|
||||
|
||||
const rest = (percorso: string, token: string, init?: RequestInit) =>
|
||||
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||
...init,
|
||||
headers: {
|
||||
apikey: token === SERVIZIO ? SERVIZIO : ANON,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
const righeToccate = async (res: Response): Promise<number> => {
|
||||
if (!res.ok) return 0;
|
||||
const corpo = (await res.json()) as unknown[];
|
||||
return Array.isArray(corpo) ? corpo.length : 0;
|
||||
};
|
||||
|
||||
async function creaUtente(email: string, password: string): Promise<string> {
|
||||
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: SERVIZIO,
|
||||
Authorization: `Bearer ${SERVIZIO}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email, password, email_confirm: true }),
|
||||
});
|
||||
const corpo = (await res.json()) as { id?: string };
|
||||
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
|
||||
return corpo.id;
|
||||
}
|
||||
|
||||
async function accedi(email: string, password: string): Promise<string> {
|
||||
const res = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
|
||||
method: "POST",
|
||||
headers: { apikey: ANON, "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const corpo = (await res.json()) as { access_token?: string };
|
||||
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
|
||||
return corpo.access_token;
|
||||
}
|
||||
|
||||
const eliminaUtente = (id: string) =>
|
||||
fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
|
||||
});
|
||||
|
||||
const ruoliDi = async (idUtente: string): Promise<string[]> => {
|
||||
const res = await rest(`user_roles?user_id=eq.${idUtente}&select=role`, SERVIZIO);
|
||||
return ((await res.json()) as Array<{ role: string }>).map((r) => r.role);
|
||||
};
|
||||
|
||||
const PASSWORD = "prova-allenatore-123";
|
||||
const PREFISSO = "test-allenatore";
|
||||
const SLOT = "g9001";
|
||||
const GIOCATORE = "g3";
|
||||
const EVENTO = `${PREFISSO}-evento`;
|
||||
const idUtenti: string[] = [];
|
||||
let tokenAdmin = "";
|
||||
|
||||
try {
|
||||
const oggi = Date.now();
|
||||
const emailAllenatore = `test-allenatore-${oggi}@example.test`;
|
||||
const emailAdmin = `test-admin-all-${oggi}@example.test`;
|
||||
const emailGiocatore = `test-giocatore-all-${oggi}@example.test`;
|
||||
const idAllenatore = await creaUtente(emailAllenatore, PASSWORD);
|
||||
const idAdmin = await creaUtente(emailAdmin, PASSWORD);
|
||||
const idGiocatore = await creaUtente(emailGiocatore, PASSWORD);
|
||||
idUtenti.push(idAllenatore, idAdmin, idGiocatore);
|
||||
|
||||
await rest("user_roles", SERVIZIO, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
|
||||
});
|
||||
tokenAdmin = await accedi(emailAdmin, PASSWORD);
|
||||
const tokenAllenatore = await accedi(emailAllenatore, PASSWORD);
|
||||
const tokenGiocatore = await accedi(emailGiocatore, PASSWORD);
|
||||
|
||||
const collegaGiocatore = await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ auth_user_id: idGiocatore }),
|
||||
});
|
||||
assert.equal(await righeToccate(collegaGiocatore), 1, `${GIOCATORE} collegato al test`);
|
||||
|
||||
await prova("l'admin registra un allenatore senza numero né ruolo", async () => {
|
||||
const res = await rest("giocatori_squadra", tokenAdmin, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
id: SLOT,
|
||||
nome: "Carla",
|
||||
cognome: "Neri",
|
||||
numero: null,
|
||||
ruolo: "",
|
||||
email: emailAllenatore,
|
||||
tipo: "allenatore",
|
||||
}),
|
||||
});
|
||||
assert.equal(await righeToccate(res), 1, `slot allenatore creato (${res.status})`);
|
||||
});
|
||||
|
||||
await prova("un giocatore senza numero resta rifiutato", async () => {
|
||||
const res = await rest("giocatori_squadra", tokenAdmin, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: "g9002", nome: "X", cognome: "Y", numero: null, ruolo: "" }),
|
||||
});
|
||||
assert.ok(!res.ok, `il numero è obbligatorio per chi gioca (${res.status})`);
|
||||
});
|
||||
|
||||
await prova("il collegamento per email dà il ruolo allenatore", async () => {
|
||||
assert.deepEqual(await ruoliDi(idAllenatore), [], "prima del collegamento nessun ruolo");
|
||||
const res = await rest(
|
||||
`giocatori_squadra?id=eq.${SLOT}&auth_user_id=is.null`,
|
||||
tokenAllenatore,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ auth_user_id: idAllenatore }),
|
||||
},
|
||||
);
|
||||
assert.equal(await righeToccate(res), 1, `slot reclamato (${res.status})`);
|
||||
assert.deepEqual(await ruoliDi(idAllenatore), ["allenatore"]);
|
||||
});
|
||||
|
||||
await prova("l'allenatore crea, modifica ed elimina gli eventi", async () => {
|
||||
const crea = await rest("eventi_app", tokenAllenatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
id: EVENTO,
|
||||
tipo: "partita",
|
||||
titolo: "Partita di prova",
|
||||
data: "2026-01-01",
|
||||
ora: "20:00",
|
||||
luogo: "Palestra",
|
||||
convocati: [GIOCATORE],
|
||||
}),
|
||||
});
|
||||
assert.equal(await righeToccate(crea), 1, `evento creato (${crea.status})`);
|
||||
|
||||
const modifica = await rest(`eventi_app?id=eq.${EVENTO}`, tokenAllenatore, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ luogo: "Altra palestra" }),
|
||||
});
|
||||
assert.equal(await righeToccate(modifica), 1, "evento modificato");
|
||||
|
||||
const altro = await rest("eventi_app", tokenAllenatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
id: `${PREFISSO}-da-cancellare`,
|
||||
tipo: "allenamento",
|
||||
titolo: "Da cancellare",
|
||||
data: "2026-01-02",
|
||||
ora: "20:00",
|
||||
luogo: "",
|
||||
}),
|
||||
});
|
||||
assert.equal(await righeToccate(altro), 1);
|
||||
const cancella = await rest(`eventi_app?id=eq.${PREFISSO}-da-cancellare`, tokenAllenatore, {
|
||||
method: "DELETE",
|
||||
headers: { Prefer: "return=representation" },
|
||||
});
|
||||
assert.equal(await righeToccate(cancella), 1, "evento eliminato");
|
||||
});
|
||||
|
||||
await prova("il giocatore continua a non gestire gli eventi", async () => {
|
||||
const res = await rest(`eventi_app?id=eq.${EVENTO}`, tokenGiocatore, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ luogo: "Abusivo" }),
|
||||
});
|
||||
assert.equal(await righeToccate(res), 0);
|
||||
});
|
||||
|
||||
await prova("l'allenatore cambia il proprio nome e cognome", async () => {
|
||||
const res = await rest(`giocatori_squadra?id=eq.${SLOT}`, tokenAllenatore, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ nome: "Carlotta", cognome: "Neri Bianchi" }),
|
||||
});
|
||||
assert.equal(await righeToccate(res), 1, `nome aggiornato (${res.status})`);
|
||||
});
|
||||
|
||||
await prova("l'allenatore non cambia il proprio tipo né gli altri campi admin", async () => {
|
||||
for (const campi of [{ tipo: "giocatore" }, { numero: 5 }, { email: "altra@example.test" }]) {
|
||||
const res = await rest(`giocatori_squadra?id=eq.${SLOT}`, tokenAllenatore, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify(campi),
|
||||
});
|
||||
assert.equal(await righeToccate(res), 0, `rifiutato: ${JSON.stringify(campi)}`);
|
||||
}
|
||||
assert.deepEqual(await ruoliDi(idAllenatore), ["allenatore"], "il ruolo resta");
|
||||
});
|
||||
|
||||
await prova("l'allenatore non scrive lo slot di un giocatore", async () => {
|
||||
const res = await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAllenatore, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ nome: "Abusivo" }),
|
||||
});
|
||||
assert.equal(await righeToccate(res), 0);
|
||||
});
|
||||
|
||||
await prova("il giocatore non cambia il proprio nome", async () => {
|
||||
const res = await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenGiocatore, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ nome: "Abusivo" }),
|
||||
});
|
||||
assert.equal(await righeToccate(res), 0, "per chi gioca nome e cognome restano all'admin");
|
||||
});
|
||||
|
||||
await prova("l'allenatore non risponde alle presenze né dichiara cacche", async () => {
|
||||
const presenza = await rest("risposte_presenze", tokenAllenatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: SLOT, stato: "presente" }),
|
||||
});
|
||||
assert.ok(!presenza.ok, `presenza rifiutata (${presenza.status})`);
|
||||
|
||||
const cacche = await rest("cacche_partita", tokenAllenatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: SLOT, quantita: 1 }),
|
||||
});
|
||||
assert.ok(!cacche.ok, `cacche rifiutate (${cacche.status})`);
|
||||
});
|
||||
|
||||
await prova("l'allenatore non vota e non viene votato", async () => {
|
||||
// Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque.
|
||||
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ convocati: [] }),
|
||||
});
|
||||
const vota = await rest("mvp_voti", tokenAllenatore, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO,
|
||||
votante_id: SLOT,
|
||||
votato_id: GIOCATORE,
|
||||
votato_nome: "Tre",
|
||||
}),
|
||||
});
|
||||
assert.ok(!vota.ok, `voto dell'allenatore rifiutato (${vota.status})`);
|
||||
|
||||
const votato = await rest("mvp_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO,
|
||||
votante_id: GIOCATORE,
|
||||
votato_id: SLOT,
|
||||
votato_nome: "Allenatore",
|
||||
}),
|
||||
});
|
||||
assert.ok(!votato.ok, `l'allenatore non si vota (${votato.status})`);
|
||||
});
|
||||
|
||||
await prova("i promemoria evento arrivano anche all'allenatore non convocato", async () => {
|
||||
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ convocati: [GIOCATORE] }),
|
||||
});
|
||||
const res = await rest("rpc/giocatori_destinatari_evento", SERVIZIO, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ p_evento_id: EVENTO }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const destinatari = ((await res.json()) as Array<string | Record<string, string>>).map((d) =>
|
||||
typeof d === "string" ? d : Object.values(d)[0],
|
||||
);
|
||||
assert.deepEqual([...destinatari].sort(), [GIOCATORE, SLOT].sort());
|
||||
});
|
||||
|
||||
await prova("scollegare l'account toglie il ruolo", async () => {
|
||||
const res = await rest(`giocatori_squadra?id=eq.${SLOT}`, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ auth_user_id: null }),
|
||||
});
|
||||
assert.equal(await righeToccate(res), 1);
|
||||
assert.deepEqual(await ruoliDi(idAllenatore), []);
|
||||
const crea = await rest("eventi_app", tokenAllenatore, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
id: `${PREFISSO}-dopo`,
|
||||
tipo: "allenamento",
|
||||
titolo: "Non deve esistere",
|
||||
data: "2026-01-03",
|
||||
ora: "20:00",
|
||||
luogo: "",
|
||||
}),
|
||||
});
|
||||
assert.ok(!crea.ok, `senza ruolo niente eventi (${crea.status})`);
|
||||
});
|
||||
} finally {
|
||||
await rest(`mvp_voti?match_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
|
||||
await rest(`eventi_app?id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
|
||||
await rest(`giocatori_squadra?id=in.(${SLOT},g9002)`, SERVIZIO, { method: "DELETE" });
|
||||
if (tokenAdmin) {
|
||||
await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ auth_user_id: null }),
|
||||
});
|
||||
}
|
||||
for (const id of idUtenti) {
|
||||
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
||||
await eliminaUtente(id);
|
||||
}
|
||||
riepilogo("permessi-allenatore");
|
||||
}
|
||||
}
|
||||
@@ -66,8 +66,19 @@ if (!locale) {
|
||||
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
|
||||
});
|
||||
|
||||
// Il ruolo allenatore lo scrive il trigger di M21 al collegamento dello slot; qui basta
|
||||
// la riga in `user_roles`, che è ciò che la route legge (DD-034).
|
||||
const emailAllenatore = `test-route-allenatore-${Date.now()}@example.test`;
|
||||
const idAllenatore = await creaUtente(emailAllenatore);
|
||||
await fetch(`${SUPABASE}/rest/v1/user_roles`, {
|
||||
method: "POST",
|
||||
headers: { ...authAdmin, "content-type": "application/json" },
|
||||
body: JSON.stringify({ user_id: idAllenatore, role: "allenatore" }),
|
||||
});
|
||||
|
||||
const tokenGiocatore = await accedi(emailGiocatore);
|
||||
const tokenAdmin = await accedi(emailAdmin);
|
||||
const tokenAllenatore = await accedi(emailAllenatore);
|
||||
|
||||
const server = await avviaServer();
|
||||
console.log(`permessi route su ${server.baseUrl} (database ${SUPABASE})`);
|
||||
@@ -107,8 +118,58 @@ if (!locale) {
|
||||
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
|
||||
}
|
||||
});
|
||||
|
||||
await prova(
|
||||
"l'allenatore sollecita le presenze ma non manda palloni né sondaggio",
|
||||
async () => {
|
||||
const sollecito = await chiama("/api/public/sollecita-presenze", {
|
||||
authorization: `Bearer ${tokenAllenatore}`,
|
||||
});
|
||||
assert.equal(sollecito.status, 404, "sollecito: superato l'accesso, evento inesistente");
|
||||
for (const percorso of ["/api/public/apri-sondaggio", "/api/public/promemoria-palloni"]) {
|
||||
const res = await chiama(percorso, { authorization: `Bearer ${tokenAllenatore}` });
|
||||
assert.equal(res.status, 403, `${percorso}: restano all'admin`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** Messaggio libero: stesso controllo d'accesso, corpo diverso. */
|
||||
const chiamaMessaggio = (intestazioni: Record<string, string> = {}) =>
|
||||
fetch(`${server.baseUrl}/api/public/notifica-personalizzata`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...intestazioni },
|
||||
body: JSON.stringify({ messaggio: "prova" }),
|
||||
});
|
||||
|
||||
await prova("senza token non si manda un messaggio personalizzato", async () => {
|
||||
assert.equal((await chiamaMessaggio()).status, 401);
|
||||
});
|
||||
|
||||
await prova("un giocatore autenticato non manda messaggi personalizzati", async () => {
|
||||
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenGiocatore}` });
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
await prova("l'allenatore non manda messaggi personalizzati", async () => {
|
||||
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAllenatore}` });
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
await prova("un amministratore manda il messaggio (nessun dispositivo iscritto)", async () => {
|
||||
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAdmin}` });
|
||||
assert.equal(res.status, 200);
|
||||
const corpo = (await res.json()) as { inviate: number; destinatari: number };
|
||||
assert.equal(corpo.destinatari, 0);
|
||||
assert.equal(corpo.inviate, 0);
|
||||
});
|
||||
} finally {
|
||||
server.stop();
|
||||
// La route scrive anche lo storico in-app (M17): il messaggio "prova" a tutta la
|
||||
// rosa lascia righe in `notifiche_utente` che altrimenti restano per sempre.
|
||||
await fetch(`${SUPABASE}/rest/v1/notifiche_utente?corpo=eq.prova&tipo=eq.admin`, {
|
||||
method: "DELETE",
|
||||
headers: authAdmin,
|
||||
});
|
||||
for (const id of idUtenti) {
|
||||
await fetch(`${SUPABASE}/rest/v1/user_roles?user_id=eq.${id}`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -27,6 +27,8 @@ import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
|
||||
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
import type { Stato } from "@/lib/crapp-data";
|
||||
import type { MappaPresenze } from "@/lib/presenze";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
@@ -97,16 +99,16 @@ if (!locale) {
|
||||
}
|
||||
|
||||
/** Rilegge esattamente come `fetchPresenze()`. */
|
||||
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
|
||||
async function leggiPresenze(): Promise<MappaPresenze> {
|
||||
const res = await rest(
|
||||
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
|
||||
);
|
||||
const righe = (await res.json()) as Array<{
|
||||
evento_id: string;
|
||||
giocatore_id: string;
|
||||
stato: string;
|
||||
stato: Stato;
|
||||
}>;
|
||||
const mappa: Record<string, Record<string, string>> = {};
|
||||
const mappa: MappaPresenze = {};
|
||||
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
|
||||
return mappa;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
|
||||
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
import type { Stato } from "@/lib/crapp-data";
|
||||
import type { MappaPresenze } from "@/lib/presenze";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
@@ -87,16 +89,16 @@ if (!locale) {
|
||||
return righe.map(daRiga);
|
||||
}
|
||||
|
||||
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
|
||||
async function leggiPresenze(): Promise<MappaPresenze> {
|
||||
const res = await rest(
|
||||
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
|
||||
);
|
||||
const righe = (await res.json()) as Array<{
|
||||
evento_id: string;
|
||||
giocatore_id: string;
|
||||
stato: string;
|
||||
stato: Stato;
|
||||
}>;
|
||||
const mappa: Record<string, Record<string, string>> = {};
|
||||
const mappa: MappaPresenze = {};
|
||||
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
|
||||
return mappa;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
|
||||
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
import type { Stato } from "@/lib/crapp-data";
|
||||
import type { MappaPresenze } from "@/lib/presenze";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
@@ -90,7 +92,7 @@ if (!locale) {
|
||||
}
|
||||
|
||||
async function leggiPresenzeETempi(): Promise<{
|
||||
presenze: Record<string, Record<string, string>>;
|
||||
presenze: MappaPresenze;
|
||||
tempi: Record<string, Record<string, string>>;
|
||||
}> {
|
||||
const res = await rest(
|
||||
@@ -99,10 +101,10 @@ if (!locale) {
|
||||
const righe = (await res.json()) as Array<{
|
||||
evento_id: string;
|
||||
giocatore_id: string;
|
||||
stato: string;
|
||||
stato: Stato;
|
||||
risposto_il: string;
|
||||
}>;
|
||||
const presenze: Record<string, Record<string, string>> = {};
|
||||
const presenze: MappaPresenze = {};
|
||||
const tempi: Record<string, Record<string, string>> = {};
|
||||
for (const r of righe) {
|
||||
(presenze[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
|
||||
|
||||
@@ -17,6 +17,8 @@ import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
|
||||
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
import type { Stato } from "@/lib/crapp-data";
|
||||
import type { MappaPresenze } from "@/lib/presenze";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
@@ -81,16 +83,16 @@ if (!locale) {
|
||||
return righe.map(daRiga);
|
||||
}
|
||||
|
||||
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
|
||||
async function leggiPresenze(): Promise<MappaPresenze> {
|
||||
const res = await rest(
|
||||
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
|
||||
);
|
||||
const righe = (await res.json()) as Array<{
|
||||
evento_id: string;
|
||||
giocatore_id: string;
|
||||
stato: string;
|
||||
stato: Stato;
|
||||
}>;
|
||||
const mappa: Record<string, Record<string, string>> = {};
|
||||
const mappa: MappaPresenze = {};
|
||||
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
|
||||
return mappa;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,14 @@ if (!URL_BASE || !CHIAVE_SERVIZIO || !CHIAVE_PUBBLICA) {
|
||||
// Attenzione al falso verde: su un UPDATE che non tocca nessuna riga PostgREST
|
||||
// risponde comunque 2xx. Quello che conta è che il dato non cambi.
|
||||
await prova("un anonimo non si collega a uno slot della rosa", async () => {
|
||||
const leggiSlot = async () => {
|
||||
const r = await rest("giocatori_squadra?id=eq.g1&select=auth_user_id", CHIAVE_SERVIZIO);
|
||||
const righe = (await r.json()) as Array<{ auth_user_id: string | null }>;
|
||||
return righe[0]?.auth_user_id ?? null;
|
||||
};
|
||||
// Sul database di produzione g1 può essere già collegato a un giocatore vero:
|
||||
// conta che il tentativo anonimo non cambi il valore, non che lo slot sia libero.
|
||||
const prima = await leggiSlot();
|
||||
const res = await rest("giocatori_squadra?id=eq.g1", CHIAVE_PUBBLICA, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
@@ -68,9 +76,7 @@ if (!URL_BASE || !CHIAVE_SERVIZIO || !CHIAVE_PUBBLICA) {
|
||||
const aggiornate = (await res.json()) as unknown[];
|
||||
assert.equal(aggiornate.length, 0, "nessuna riga aggiornata senza sessione");
|
||||
}
|
||||
const dopo = await rest("giocatori_squadra?id=eq.g1&select=auth_user_id", CHIAVE_SERVIZIO);
|
||||
const righe = (await dopo.json()) as Array<{ auth_user_id: string | null }>;
|
||||
assert.equal(righe[0]?.auth_user_id ?? null, null, "lo slot g1 è rimasto libero");
|
||||
assert.equal(await leggiSlot(), prima, "lo slot g1 non è stato modificato");
|
||||
});
|
||||
|
||||
// --- M8: tracciamento tesseramento CSI --------------------------------------
|
||||
|
||||
@@ -26,6 +26,8 @@ import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
|
||||
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
import type { Stato } from "@/lib/crapp-data";
|
||||
import type { MappaPresenze } from "@/lib/presenze";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
@@ -93,16 +95,16 @@ if (!locale) {
|
||||
return righe.map(daRiga);
|
||||
}
|
||||
|
||||
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
|
||||
async function leggiPresenze(): Promise<MappaPresenze> {
|
||||
const res = await rest(
|
||||
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
|
||||
);
|
||||
const righe = (await res.json()) as Array<{
|
||||
evento_id: string;
|
||||
giocatore_id: string;
|
||||
stato: string;
|
||||
stato: Stato;
|
||||
}>;
|
||||
const mappa: Record<string, Record<string, string>> = {};
|
||||
const mappa: MappaPresenze = {};
|
||||
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
|
||||
return mappa;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** Logica pura del backup storage: `bun test/unit/backup-storage.test.ts`. Solo dati finti. */
|
||||
import assert from "node:assert/strict";
|
||||
import { elencaFile, leggiEnv, type Voce } from "../../backups/storage-core.ts";
|
||||
import { prova, riepilogo } from "../helpers/prova";
|
||||
|
||||
// --- leggiEnv -----------------------------------------------------------------
|
||||
await prova("toglie virgolette doppie e singole", () => {
|
||||
const env = leggiEnv(`A="uno"\nB='due'\nC=tre`);
|
||||
assert.deepEqual(env, { A: "uno", B: "due", C: "tre" });
|
||||
});
|
||||
|
||||
await prova("lascia le virgolette che non fanno coppia", () => {
|
||||
const env = leggiEnv(`A="aperta\nB=chiusa"\nC="mista'`);
|
||||
assert.deepEqual(env, { A: '"aperta', B: 'chiusa"', C: `"mista'` });
|
||||
});
|
||||
|
||||
await prova("conserva gli = dentro il valore", () => {
|
||||
assert.equal(leggiEnv(`CHIAVE="abc=="`)["CHIAVE"], "abc==");
|
||||
});
|
||||
|
||||
await prova("ignora righe vuote, commenti e spazi ai bordi", () => {
|
||||
const env = leggiEnv(`# commento\n\n A=1 \r\nnon una variabile\n`);
|
||||
assert.deepEqual(env, { A: "1" });
|
||||
});
|
||||
|
||||
await prova("valore vuoto resta stringa vuota", () => {
|
||||
assert.deepEqual(leggiEnv(`A=\nB=""`), { A: "", B: "" });
|
||||
});
|
||||
|
||||
// --- elencaFile ---------------------------------------------------------------
|
||||
const cartella = (name: string): Voce => ({ name, id: null });
|
||||
const file = (name: string): Voce => ({ name, id: `id-${name}` });
|
||||
|
||||
function bucketFinto(albero: Record<string, Voce[]>) {
|
||||
const chiesti: string[] = [];
|
||||
const lista = async (prefisso: string) => {
|
||||
chiesti.push(prefisso);
|
||||
return albero[prefisso] ?? [];
|
||||
};
|
||||
return { lista, chiesti };
|
||||
}
|
||||
|
||||
await prova("scende nelle sottocartelle e restituisce percorsi completi", async () => {
|
||||
const { lista } = bucketFinto({
|
||||
"": [cartella("g1"), file("radice.jpg")],
|
||||
g1: [file("avatar.jpg"), cartella("doc")],
|
||||
"g1/doc": [file("fronte.png")],
|
||||
});
|
||||
assert.deepEqual(await elencaFile(lista), ["g1/avatar.jpg", "g1/doc/fronte.png", "radice.jpg"]);
|
||||
});
|
||||
|
||||
await prova("interroga ogni cartella una volta sola", async () => {
|
||||
const { lista, chiesti } = bucketFinto({ "": [cartella("a"), cartella("b")], a: [file("x")] });
|
||||
await elencaFile(lista);
|
||||
assert.deepEqual(chiesti, ["", "a", "b"]);
|
||||
});
|
||||
|
||||
await prova("bucket vuoto: nessun file", async () => {
|
||||
assert.deepEqual(await elencaFile(bucketFinto({}).lista), []);
|
||||
});
|
||||
|
||||
await prova("un errore di elenco interrompe il backup invece di saltare file", async () => {
|
||||
const lista = async (prefisso: string) => {
|
||||
if (prefisso === "g1") throw new Error("rete giù");
|
||||
return [cartella("g1")];
|
||||
};
|
||||
await assert.rejects(elencaFile(lista), /rete giù/);
|
||||
});
|
||||
|
||||
riepilogo("backup-storage");
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Check delle funzioni pure della griglia mensile: `bun test/unit/calendario.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import { giorniDelMese, pad2 } from "@/lib/calendario";
|
||||
|
||||
// --- pad2 --------------------------------------------------------------
|
||||
assert.equal(pad2(1), "01");
|
||||
assert.equal(pad2(9), "09");
|
||||
assert.equal(pad2(10), "10");
|
||||
assert.equal(pad2(31), "31");
|
||||
|
||||
// --- giorniDelMese -------------------------------------------------------
|
||||
// Febbraio 2026 (non bisestile): 28 giorni, inizia di domenica -> offset 6.
|
||||
assert.deepEqual(giorniDelMese(2026, 1), { giorni: 28, offsetLunedi: 6 });
|
||||
|
||||
// Febbraio 2028 (bisestile): 29 giorni.
|
||||
assert.deepEqual(giorniDelMese(2028, 1), { giorni: 29, offsetLunedi: 1 });
|
||||
|
||||
// Settembre 2026: 30 giorni, il 1° settembre 2026 è martedì -> offset 1.
|
||||
assert.deepEqual(giorniDelMese(2026, 8), { giorni: 30, offsetLunedi: 1 });
|
||||
|
||||
// Gennaio 2027: il 1° gennaio è venerdì -> offset 4.
|
||||
assert.deepEqual(giorniDelMese(2027, 0), { giorni: 31, offsetLunedi: 4 });
|
||||
|
||||
console.log("calendario: ok");
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
parseClassifica,
|
||||
parseFormazioni,
|
||||
parseInfoPartita,
|
||||
raggruppaPerStagione,
|
||||
stagioneDaPartite,
|
||||
stagioneDi,
|
||||
parsePrecedenti,
|
||||
partiteDaEventi,
|
||||
partiteFormatoSospetto,
|
||||
@@ -368,4 +371,34 @@ if (process.env["CSI_LIVE"]) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- stagioneDi / stagioneDaPartite: l'anno cambia ad agosto ------------------
|
||||
assert.equal(stagioneDi("2025-11-12"), "2025/26");
|
||||
assert.equal(stagioneDi("2026-05-20"), "2025/26");
|
||||
assert.equal(stagioneDi("2026-07-31"), "2025/26");
|
||||
assert.equal(stagioneDi("2026-08-01"), "2026/27");
|
||||
assert.equal(stagioneDi("2099-10-01"), "2099/00");
|
||||
assert.equal(stagioneDaPartite([]), null);
|
||||
assert.equal(
|
||||
stagioneDaPartite([{ data: "2026-03-04" }, { data: "2025-11-12" }, { data: "" }]),
|
||||
"2025/26",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
raggruppaPerStagione([
|
||||
{ id: "a", data: "2026-04-10" },
|
||||
{ id: "b", data: "2025-10-01" },
|
||||
{ id: "c", data: "2025-05-02" },
|
||||
]),
|
||||
[
|
||||
{
|
||||
stagione: "2025/26",
|
||||
match: [
|
||||
{ id: "a", data: "2026-04-10" },
|
||||
{ id: "b", data: "2025-10-01" },
|
||||
],
|
||||
},
|
||||
{ stagione: "2024/25", match: [{ id: "c", data: "2025-05-02" }] },
|
||||
],
|
||||
);
|
||||
|
||||
console.log("csi-core: ok");
|
||||
@@ -1,10 +1,14 @@
|
||||
/** Check dell'anagrafica squadra: `bun test/unit/giocatori-squadra.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
daRigaSquadra,
|
||||
inRosa,
|
||||
isAllenatore,
|
||||
nomeCompleto,
|
||||
numeroGiaUsato,
|
||||
prossimoIdGiocatore,
|
||||
rosaFallback,
|
||||
ruoloVisibile,
|
||||
slotDi,
|
||||
slotPerEmail,
|
||||
validaDatiSquadra,
|
||||
@@ -23,6 +27,8 @@ const riga = (parziale: Partial<GiocatoreSquadra> = {}): GiocatoreSquadra => ({
|
||||
email: null,
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
nascita: null,
|
||||
tipo: "giocatore",
|
||||
...parziale,
|
||||
});
|
||||
|
||||
@@ -47,6 +53,11 @@ assert.deepEqual(
|
||||
giocatori.map((g) => g.id).sort(),
|
||||
"gli id combaciano con la rosa reale",
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(fallback.map((g) => [g.id, g.nascita])),
|
||||
Object.fromEntries(giocatori.map((g) => [g.id, g.nascita])),
|
||||
"la nascita del fallback combacia con quella del seed (M18)",
|
||||
);
|
||||
|
||||
// --- nomeCompleto ----------------------------------------------------------------
|
||||
assert.equal(nomeCompleto(riga()), "Mario Rossi");
|
||||
@@ -86,7 +97,14 @@ assert.equal(
|
||||
assert.equal(slotPerEmail(rosaEmail, ""), null, "stringa vuota trattata come nessuna email");
|
||||
|
||||
// --- validaDatiSquadra ----------------------------------------------------------------
|
||||
const datiOk = { nome: "Mario", cognome: "Rossi", numero: 7, ruolo: "schiacciatore", email: null };
|
||||
const datiOk = {
|
||||
nome: "Mario",
|
||||
cognome: "Rossi",
|
||||
numero: 7,
|
||||
ruolo: "schiacciatore",
|
||||
email: null,
|
||||
tipo: "giocatore" as const,
|
||||
};
|
||||
assert.equal(validaDatiSquadra(datiOk), null, "dati validi: nessun errore");
|
||||
assert.match(validaDatiSquadra({ ...datiOk, nome: "" })!, /nome/i);
|
||||
assert.match(
|
||||
@@ -149,5 +167,56 @@ assert.equal(
|
||||
"conta solo il 7 attivo di g1, non serve escludere g3",
|
||||
);
|
||||
assert.equal(numeroGiaUsato(rosaNumeri, "g4", 11), false, "un numero libero non risulta usato");
|
||||
assert.equal(
|
||||
numeroGiaUsato([riga({ id: "g5", numero: 0, tipo: "allenatore" })], "g4", 0),
|
||||
false,
|
||||
"l'allenatore non occupa numeri di maglia",
|
||||
);
|
||||
|
||||
// --- allenatore (DD-034) ---------------------------------------------------------
|
||||
const allenatore = riga({ id: "g20", numero: 0, ruolo: "", tipo: "allenatore" });
|
||||
assert.equal(isAllenatore(allenatore), true);
|
||||
assert.equal(isAllenatore(riga()), false);
|
||||
assert.equal(isAllenatore(null), false, "senza slot non si è allenatori");
|
||||
assert.equal(inRosa(riga()), true, "giocatore attivo: in rosa");
|
||||
assert.equal(inRosa(riga({ attivo: false })), false, "giocatore disattivato: fuori");
|
||||
assert.equal(inRosa(allenatore), false, "l'allenatore non è nella rosa di gioco");
|
||||
assert.equal(ruoloVisibile(allenatore), "Allenatore", "dove c'è il ruolo in campo");
|
||||
assert.equal(ruoloVisibile(riga({ ruolo: "Libero" })), "Libero");
|
||||
assert.equal(
|
||||
validaDatiSquadra({ ...datiOk, tipo: "allenatore", numero: 0, ruolo: "" }),
|
||||
null,
|
||||
"l'allenatore non ha numero né ruolo",
|
||||
);
|
||||
assert.match(
|
||||
validaDatiSquadra({ ...datiOk, tipo: "allenatore", nome: " " })!,
|
||||
/nome/i,
|
||||
"il nome resta obbligatorio anche per l'allenatore",
|
||||
);
|
||||
assert.match(
|
||||
validaDatiSquadra({ ...datiOk, tipo: "allenatore", email: "senza-chiocciola" })!,
|
||||
/email/i,
|
||||
);
|
||||
const rigaDb = {
|
||||
id: "g20",
|
||||
nome: "Carla",
|
||||
cognome: "Neri",
|
||||
numero: null,
|
||||
ruolo: "",
|
||||
auth_user_id: null,
|
||||
attivo: true,
|
||||
email: "carla@gmail.com",
|
||||
numero_tessera: null,
|
||||
data_tessera: null,
|
||||
nascita: null,
|
||||
tipo: "allenatore" as const,
|
||||
};
|
||||
assert.equal(daRigaSquadra(rigaDb).numero, 0, "numero NULL a database diventa 0");
|
||||
assert.equal(daRigaSquadra(rigaDb).tipo, "allenatore");
|
||||
assert.equal(daRigaSquadra({ ...rigaDb, numero: 7, tipo: "giocatore" }).numero, 7);
|
||||
assert.ok(
|
||||
rosaFallback().every((g) => g.tipo === "giocatore"),
|
||||
"il fallback contiene solo giocatori",
|
||||
);
|
||||
|
||||
console.log("giocatori-squadra: ok");
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Check delle notifiche in-app (M17): `bun test/unit/notifiche-utente.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
contatoreBadge,
|
||||
daRiga,
|
||||
pallinoNotifiche,
|
||||
type RigaNotifica,
|
||||
} from "@/lib/notifiche-utente";
|
||||
|
||||
// --- daRiga: conversione database -> modello applicativo ---------------------
|
||||
const riga: RigaNotifica = {
|
||||
id: "n1",
|
||||
tipo: "evento_promemoria_24h",
|
||||
titolo: "Promemoria: Allenamento",
|
||||
corpo: "10/09/2026 alle 20:30",
|
||||
evento_id: "e1",
|
||||
letta: false,
|
||||
creato_il: "2026-09-10T09:00:00Z",
|
||||
};
|
||||
|
||||
assert.deepEqual(daRiga(riga), {
|
||||
id: "n1",
|
||||
tipo: "evento_promemoria_24h",
|
||||
titolo: "Promemoria: Allenamento",
|
||||
corpo: "10/09/2026 alle 20:30",
|
||||
eventoId: "e1",
|
||||
letta: false,
|
||||
creataIl: "2026-09-10T09:00:00Z",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
daRiga({ ...riga, evento_id: null }).eventoId,
|
||||
null,
|
||||
"un messaggio admin non ha evento",
|
||||
);
|
||||
|
||||
// --- contatoreBadge: il numero esatto fino a 9, poi "9+" ---------------------
|
||||
assert.equal(contatoreBadge(0), "0");
|
||||
assert.equal(contatoreBadge(1), "1");
|
||||
assert.equal(contatoreBadge(9), "9");
|
||||
assert.equal(contatoreBadge(10), "9+");
|
||||
assert.equal(contatoreBadge(42), "9+");
|
||||
|
||||
// --- pallinoNotifiche: rosso con le non lette, neutro col totale, assente se vuoto ---
|
||||
assert.equal(pallinoNotifiche(0, 0), null);
|
||||
assert.deepEqual(pallinoNotifiche(5, 2), { testo: "2", daLeggere: true });
|
||||
assert.deepEqual(pallinoNotifiche(30, 12), { testo: "9+", daLeggere: true });
|
||||
// Tutte lette: il pallino resta per poterle ancora aprire ed eliminare.
|
||||
assert.deepEqual(pallinoNotifiche(3, 0), { testo: "3", daLeggere: false });
|
||||
|
||||
console.log("notifiche-utente: ok");
|
||||
@@ -120,11 +120,12 @@ assert.equal(totaliEventiGiocatore("g1", conRistretti, OGGI), 5, "chi non è con
|
||||
|
||||
// --- destinatari del sollecito: chi non ha risposto, più i "forse" ------------
|
||||
const squadra = [
|
||||
{ id: "g1", attivo: true },
|
||||
{ id: "g2", attivo: true },
|
||||
{ id: "g3", attivo: true },
|
||||
{ id: "g4", attivo: true },
|
||||
{ id: "g5", attivo: false }, // uscito dalla squadra: non lo si disturba più
|
||||
{ id: "g1", attivo: true, tipo: "giocatore" as const },
|
||||
{ id: "g2", attivo: true, tipo: "giocatore" as const },
|
||||
{ id: "g3", attivo: true, tipo: "giocatore" as const },
|
||||
{ id: "g4", attivo: true, tipo: "giocatore" as const },
|
||||
{ id: "g5", attivo: false, tipo: "giocatore" as const }, // uscito: non lo si disturba più
|
||||
{ id: "g6", attivo: true, tipo: "allenatore" as const }, // non risponde: mai sollecitato (DD-034)
|
||||
];
|
||||
|
||||
const risposte = [
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
aRigaProfilo,
|
||||
avvisiCertificati,
|
||||
completamento,
|
||||
completamentoAllenatore,
|
||||
csvTesseramento,
|
||||
daRigaProfilo,
|
||||
formatDataBreve,
|
||||
giorniAllaScadenza,
|
||||
sezioniComplete,
|
||||
statoScadenza,
|
||||
testoScadenza,
|
||||
type Profilo,
|
||||
} from "@/lib/profili-core";
|
||||
import {
|
||||
@@ -114,6 +119,8 @@ const squadra: GiocatoreSquadra[] = [
|
||||
email: null,
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
nascita: null,
|
||||
tipo: "giocatore",
|
||||
},
|
||||
{
|
||||
id: "g2",
|
||||
@@ -126,6 +133,8 @@ const squadra: GiocatoreSquadra[] = [
|
||||
email: null,
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
nascita: null,
|
||||
tipo: "giocatore",
|
||||
},
|
||||
];
|
||||
const csv = csvTesseramento(squadra, { g1: completo });
|
||||
@@ -153,7 +162,14 @@ assert.equal(slotDi(squadra, "u2")?.id, "g2");
|
||||
assert.equal(slotDi(squadra, "sconosciuto"), null);
|
||||
|
||||
// --- dati squadra modificabili dall'admin (DD-017) ---------------------------
|
||||
const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda", email: null };
|
||||
const datiOk = {
|
||||
nome: "Ivan",
|
||||
cognome: "Cacciari",
|
||||
numero: 23,
|
||||
ruolo: "Banda",
|
||||
email: null,
|
||||
tipo: "giocatore" as const,
|
||||
};
|
||||
assert.equal(validaDatiSquadra(datiOk), null);
|
||||
assert.match(validaDatiSquadra({ ...datiOk, nome: " " }) ?? "", /nome/i);
|
||||
assert.match(validaDatiSquadra({ ...datiOk, cognome: "" }) ?? "", /cognome/i);
|
||||
@@ -182,4 +198,139 @@ assert.ok(
|
||||
"il fallback non può collegare account",
|
||||
);
|
||||
|
||||
// --- completamento dell'allenatore (DD-034) --------------------------------------
|
||||
assert.equal(completamentoAllenatore(null), 0, "senza profilo: 0%");
|
||||
assert.equal(
|
||||
completamentoAllenatore({ ...vuoto, dataNascita: "1980-01-01", telefono: "333" }),
|
||||
50,
|
||||
"due campi su quattro",
|
||||
);
|
||||
assert.equal(
|
||||
completamentoAllenatore({
|
||||
...vuoto,
|
||||
dataNascita: "1980-01-01",
|
||||
luogoNascita: "Bologna",
|
||||
telefono: "333",
|
||||
email: "a@b.it",
|
||||
}),
|
||||
100,
|
||||
"bastano i quattro dati personali: niente indirizzo, documento, certificato, foto",
|
||||
);
|
||||
assert.equal(completamentoAllenatore({ ...vuoto, telefono: " " }), 0, "spazi soli non contano");
|
||||
|
||||
// --- avviso certificati (DD-035) ------------------------------------------------
|
||||
assert.equal(giorniAllaScadenza("2026-09-27", "2026-09-20"), 7);
|
||||
assert.equal(giorniAllaScadenza("2026-09-19", "2026-09-20"), -1, "scaduto ieri");
|
||||
assert.equal(giorniAllaScadenza("2026-09-20", "2026-09-20"), 0, "scade oggi");
|
||||
assert.equal(giorniAllaScadenza("2026-10-01", "2026-09-30"), 1, "cambio di mese");
|
||||
assert.equal(giorniAllaScadenza("2027-01-01", "2026-12-31"), 1, "cambio di anno");
|
||||
|
||||
assert.equal(formatDataBreve("2026-09-12"), "12/09/2026");
|
||||
|
||||
assert.equal(testoScadenza(0), "scade oggi");
|
||||
assert.equal(testoScadenza(1), "scade domani");
|
||||
assert.equal(testoScadenza(5), "scade tra 5 giorni");
|
||||
|
||||
const rosaCertificati: GiocatoreSquadra[] = [
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari", attivo: true, tipo: "giocatore" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni", attivo: true, tipo: "giocatore" },
|
||||
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi", attivo: true, tipo: "giocatore" },
|
||||
{ ...squadra[0]!, id: "g4", nome: "Sara", cognome: "Neri", attivo: false, tipo: "giocatore" },
|
||||
{ ...squadra[0]!, id: "g5", nome: "Luca", cognome: "Bianchi", attivo: true, tipo: "allenatore" },
|
||||
];
|
||||
const oggiTest = "2026-09-20";
|
||||
const profiliCertificati: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" }, // in scadenza, 5 giorni
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-10", certificatoPath: "p2" }, // scaduto da 10 giorni
|
||||
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-10-31", certificatoPath: "p3" }, // valido, fuori soglia
|
||||
g4: { ...vuoto, giocatoreId: "g4", certificatoScadenza: "2026-09-21", certificatoPath: "p4" }, // disattivato: escluso
|
||||
g5: { ...vuoto, giocatoreId: "g5", certificatoScadenza: "2026-09-21", certificatoPath: "p5" }, // allenatore: escluso
|
||||
};
|
||||
const avvisi = avvisiCertificati(rosaCertificati, profiliCertificati, oggiTest);
|
||||
assert.deepEqual(
|
||||
avvisi.scaduti.map((a) => a.giocatoreId),
|
||||
["g2"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
avvisi.inScadenza.map((a) => a.giocatoreId),
|
||||
["g1"],
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
avvisiCertificati(
|
||||
[{ ...squadra[0]!, id: "g6" }],
|
||||
{ g6: { ...vuoto, giocatoreId: "g6", certificatoScadenza: null, certificatoPath: null } },
|
||||
oggiTest,
|
||||
).inScadenza.length,
|
||||
0,
|
||||
"certificato mancante: nessun avviso",
|
||||
);
|
||||
|
||||
const dueInScadenza: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-27", certificatoPath: "p1" }, // 7 giorni, più lontano
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-21", certificatoPath: "p2" }, // 1 giorno, più vicino
|
||||
};
|
||||
const ordinati = avvisiCertificati(
|
||||
[
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
|
||||
],
|
||||
dueInScadenza,
|
||||
oggiTest,
|
||||
).inScadenza.map((a) => a.giocatoreId);
|
||||
assert.deepEqual(ordinati, ["g2", "g1"], "la scadenza più vicina viene prima");
|
||||
|
||||
const pariGiorni: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" },
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-25", certificatoPath: "p2" },
|
||||
};
|
||||
const alfabetico = avvisiCertificati(
|
||||
[
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" },
|
||||
],
|
||||
pariGiorni,
|
||||
oggiTest,
|
||||
).inScadenza.map((a) => a.giocatoreId);
|
||||
assert.deepEqual(alfabetico, ["g2", "g1"], "a parità di data, ordine alfabetico per cognome");
|
||||
|
||||
// soglia esatta (DD-035): 8 giorni fuori, 7 e 0 dentro
|
||||
const rosaSoglia: GiocatoreSquadra[] = [
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
|
||||
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
|
||||
];
|
||||
const profiliSoglia: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-28", certificatoPath: "p1" }, // 8 giorni: fuori
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-27", certificatoPath: "p2" }, // 7 giorni: dentro
|
||||
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-20", certificatoPath: "p3" }, // 0 giorni: dentro
|
||||
};
|
||||
const soglia = avvisiCertificati(rosaSoglia, profiliSoglia, oggiTest);
|
||||
assert.deepEqual(
|
||||
soglia.inScadenza.map((a) => a.giocatoreId),
|
||||
["g3", "g2"],
|
||||
"8 giorni resta valido, 7 e 0 generano avviso",
|
||||
);
|
||||
assert.equal(soglia.scaduti.length, 0);
|
||||
|
||||
// ordinamento tra scaduti: il più vecchio prima, poi alfabetico a parità di data
|
||||
const rosaScaduti: GiocatoreSquadra[] = [
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" },
|
||||
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
|
||||
];
|
||||
const profiliScaduti: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-01", certificatoPath: "p1" }, // scaduto da 19 giorni
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-01", certificatoPath: "p2" }, // pari data di g1
|
||||
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-19", certificatoPath: "p3" }, // scaduto da 1 giorno
|
||||
};
|
||||
const scadutiOrdinati = avvisiCertificati(rosaScaduti, profiliScaduti, oggiTest).scaduti.map(
|
||||
(a) => a.giocatoreId,
|
||||
);
|
||||
assert.deepEqual(
|
||||
scadutiOrdinati,
|
||||
["g2", "g1", "g3"],
|
||||
"il più vecchio prima, poi alfabetico a parità di data (Alfa prima di Zeta)",
|
||||
);
|
||||
|
||||
console.log("profili-core: ok");
|
||||
@@ -4,11 +4,16 @@ import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
const codice = readFileSync(new URL("../../public/push-sw.js", import.meta.url), "utf8");
|
||||
type Evento = { data?: { json: () => unknown } | null; waitUntil: (p: Promise<unknown>) => void };
|
||||
type Evento = {
|
||||
data?: { json: () => unknown } | null | undefined;
|
||||
waitUntil: (p: Promise<unknown>) => void;
|
||||
};
|
||||
// `renotify` è supportato da Chromium ma manca dai tipi DOM di TypeScript.
|
||||
type OpzioniNotifica = NotificationOptions & { renotify?: boolean };
|
||||
|
||||
function avviaWorker() {
|
||||
const listener = new Map<string, (event: Evento) => void>();
|
||||
const notifiche: Array<{ titolo: string; opzioni: NotificationOptions }> = [];
|
||||
const notifiche: Array<{ titolo: string; opzioni: OpzioniNotifica }> = [];
|
||||
let completa: (() => void) | undefined;
|
||||
const mostrata = new Promise<void>((resolve) => (completa = resolve));
|
||||
const installata = Promise.resolve();
|
||||
@@ -23,7 +28,7 @@ function avviaWorker() {
|
||||
matchAll: () => assert.fail("la push deve funzionare senza consultare finestre aperte"),
|
||||
},
|
||||
registration: {
|
||||
showNotification(titolo: string, opzioni: NotificationOptions) {
|
||||
showNotification(titolo: string, opzioni: OpzioniNotifica) {
|
||||
notifiche.push({ titolo, opzioni });
|
||||
return mostrata;
|
||||
},
|
||||
|
||||
@@ -4,7 +4,8 @@ import { SCOUT_STATO_KEY, statoIniziale } from "@/lib/scout-stato";
|
||||
|
||||
// --- statoIniziale: parte vuoto, con avversario e campo memorizzati -----------
|
||||
const stato = statoIniziale("Avversari", true);
|
||||
assert.deepEqual(stato, { azioni: [], setChiusi: [], avversario: "Avversari", casa: true });
|
||||
// Confronta una copia: deepEqual restringerebbe `stato` al letterale, con `azioni: never[]`.
|
||||
assert.deepEqual({ ...stato }, { azioni: [], setChiusi: [], avversario: "Avversari", casa: true });
|
||||
|
||||
const trasferta = statoIniziale("Altra Squadra", false);
|
||||
assert.equal(trasferta.casa, false);
|
||||
|
||||
+2
-3
@@ -1,4 +1,4 @@
|
||||
import { defineConfig, loadEnv, mergeConfig } from "vite";
|
||||
import { defineConfig, loadEnv, mergeConfig, type UserConfig } from "vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import tsConfigPaths from "vite-tsconfig-paths";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
@@ -46,14 +46,13 @@ export default defineConfig(async ({ command, mode }) => {
|
||||
}
|
||||
plugins.push(viteReact());
|
||||
|
||||
let config = {
|
||||
let config: UserConfig = {
|
||||
define: envDefine,
|
||||
...(isDevBuild
|
||||
? {
|
||||
environments: {
|
||||
client: { define: { "process.env.NODE_ENV": JSON.stringify("development") } },
|
||||
},
|
||||
esbuild: { keepNames: true },
|
||||
}
|
||||
: {}),
|
||||
css: { transformer: "lightningcss" as const },
|
||||
|
||||
Reference in new issue
Block a user