Compare commits
12
Commits
v0.9.1
...
pocketbase
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1744d4087 | ||
|
|
d125bdfb6a | ||
|
|
689468141d | ||
|
|
0f5a27ed5f | ||
|
|
15885f32c7 | ||
|
|
fcff950006 | ||
|
|
99ca3b6495 | ||
|
|
457a0fe23d | ||
|
|
d8fc6ef04f | ||
|
|
e99d853987 | ||
|
|
efca4243b6 | ||
|
|
abd6fafc0c |
@@ -41,3 +41,7 @@ dist-ssr
|
|||||||
.vercel
|
.vercel
|
||||||
# Supabase CLI local state
|
# Supabase CLI local state
|
||||||
supabase/.temp/
|
supabase/.temp/
|
||||||
|
|
||||||
|
# PocketBase locale (dati e log runtime, non lo schema in pb_migrations/pb_hooks)
|
||||||
|
pocketbase/pb_data/*
|
||||||
|
!pocketbase/pb_data/.gitkeep
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Obiettivi di squadra: sezione in Squadra + widget in home
|
|
||||||
|
|
||||||
## Stato attuale
|
|
||||||
|
|
||||||
Al momento c'è un solo "obiettivo di squadra" ed è hardcoded nella home (`src/routes/index.tsx`, riga 136): **"90% di presenze ad agosto"** con una barra finta all'82%. Non esiste uno schema dati né una sezione dedicata, e non è collegato a calendario, presenze o statistiche. Le voci "obiettivi" nelle descrizioni di Squadra e Profilo si riferiscono ai badge individuali, non a obiettivi di squadra.
|
|
||||||
|
|
||||||
## Cosa faccio
|
|
||||||
|
|
||||||
1. **Modello dati locale** in `src/lib/crapp-data.ts`
|
|
||||||
|
|
||||||
- Nuovo tipo `ObiettivoSquadra`: id, titolo, descrizione, target, valore attuale, unità, scadenza (opzionale), icona.
|
|
||||||
- Array `obiettiviSquadra` con gli obiettivi demo della stagione.
|
|
||||||
|
|
||||||
2. **Sezione "Obiettivi di squadra" dentro la scheda Squadra** (`src/routes/squadra.tsx`)
|
|
||||||
|
|
||||||
- Nuova sezione con la lista completa degli obiettivi: barra di progresso, percentuale, stato (in corso / completato).
|
|
||||||
- Ordinati con gli obiettivi in corso in cima e i completati in fondo.
|
|
||||||
- Nessuna nuova rotta e nessuna modifica al bottom nav.
|
|
||||||
|
|
||||||
3. **Widget home dinamico** (`src/routes/index.tsx`)
|
|
||||||
|
|
||||||
- Sostituisce l'obiettivo hardcoded: mostra sempre il primo obiettivo in corso preso dalla lista.
|
|
||||||
- Progresso calcolato dai dati reali dove possibile (es. presenze derivate dagli eventi).
|
|
||||||
|
|
||||||
4. **Obiettivi demo iniziali**
|
|
||||||
|
|
||||||
- 90% di presenze ad agosto (collegato agli eventi di agosto).
|
|
||||||
- 70% di risposte entro 24h nel prossimo mese.
|
|
||||||
- Prima vittoria del campionato (collegato allo storico match).
|
|
||||||
- 5 vittorie in campionato (collegato allo storico match).
|
|
||||||
- 10 vittorie in campionato (collegato allo storico match).
|
|
||||||
- 1 evento di squadra al mese (pizzata, ecc.).
|
|
||||||
|
|
||||||
## Cosa non cambia
|
|
||||||
|
|
||||||
- Resta un prototipo offline: i dati restano in `src/lib/crapp-data.ts`.
|
|
||||||
- I badge individuali restano come sono in `src/lib/badges.ts` e nella rosa di `src/routes/squadra.tsx`.
|
|
||||||
- Bottom nav e rotte invariate.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Rendere CrAPP operativa: migrazione dal prototipo al Cloud
|
|
||||||
|
|
||||||
## Stato attuale
|
|
||||||
|
|
||||||
L'app è un prototipo UI con alcune funzioni già collegate a Lovable Cloud:
|
|
||||||
|
|
||||||
- Già sul Cloud: voti MVP (`mvp_voti`), iscrizioni push (`push_subscriptions`), sessioni scout (`scout_sessioni`), turni palloni (`turni_palloni`).
|
|
||||||
- Ancora in locale come dati demo: rosa giocatori, calendario eventi, storico partite, classifica CSI, statistiche individuali, presenze/assenze, badge.
|
|
||||||
|
|
||||||
## Cosa serve per renderla operativa
|
|
||||||
|
|
||||||
1. **Rosa e profili giocatori sul database**
|
|
||||||
- Creare tabella `profiles` (o `giocatori`) con nome, numero maglia, ruolo, data di nascita, foto profilo.
|
|
||||||
- Collegare ogni riga all'utente autenticato corrispondente.
|
|
||||||
- Rimuovere la rosa statica da `src/lib/crapp-data.ts` e caricarla dal backend.
|
|
||||||
|
|
||||||
2. **Autenticazione reale**
|
|
||||||
- Sostituire la semplice selezione "Chi sei?" in `localStorage` con login email/password o OAuth (Google).
|
|
||||||
- Ogni giocatore accede con le proprie credenziali e vede solo i propri dati modificabili.
|
|
||||||
- Necessaria per garantire che uno scout o un voto MVP provenga davvero da quel giocatore.
|
|
||||||
|
|
||||||
3. **Calendario eventi persistente**
|
|
||||||
- Tabella `eventi` con tipo, titolo, data, ora, luogo, avversario, casa/fuori.
|
|
||||||
- Tabella `presenze` (evento_id, giocatore_id, stato, aggiornato_il).
|
|
||||||
- I compleanni possono restare derivati dalla data di nascita dei giocatori.
|
|
||||||
|
|
||||||
4. **Statistiche e badge dinamici**
|
|
||||||
- Tabella `statistiche` o `azioni_scout` (evento_id, giocatore_id, tipo azione, valore, creato_il).
|
|
||||||
- I badge vengono calcolati in tempo reale dalle statistiche accumulate, senza valori fissi in `crapp-data.ts`.
|
|
||||||
|
|
||||||
5. **Scout live collegato ai dati reali**
|
|
||||||
- Le azioni registrate in `scout.tsx` devono scrivere sulle tabelle eventi/statistiche.
|
|
||||||
- Mantenere il lock di modifica singolo e l'attivazione solo il giorno della partita.
|
|
||||||
|
|
||||||
6. **Classifica CSI**
|
|
||||||
- Tabella `classifica` aggiornata manualmente da un admin o importata dal sito CSI quando disponibile.
|
|
||||||
- Per ora nessuna API CSI ufficiale: si inserisce a mano o si copia/incolla.
|
|
||||||
|
|
||||||
7. **Notifiche push definitive**
|
|
||||||
- Verificare che i cron job inviino correttamente i promemoria palloni.
|
|
||||||
- Aggiungere notifiche per conferma eventi, promemoria presenze, MVP votabile.
|
|
||||||
|
|
||||||
8. **Ruoli e permessi**
|
|
||||||
- Definire chi può creare/modificare eventi (capitano/admin).
|
|
||||||
- Chi può fare scout (designato per partita).
|
|
||||||
- Chi può modificare i turni palloni (tutti, come da tua richiesta).
|
|
||||||
|
|
||||||
## Cosa resta salvato nel Cloud
|
|
||||||
|
|
||||||
Sì: tutto ciò che viene scritto sulle tabelle Lovable Cloud/Supabase resta salvato online e condiviso tra tutti i dispositivi.
|
|
||||||
|
|
||||||
I dati demo in `src/lib/crapp-data.ts` invece no: sono file statici, quindi ogni aggiornamento dell'app li sovrascrive e ogni telefono li vede identici.
|
|
||||||
|
|
||||||
## Decisioni da prendere insieme
|
|
||||||
|
|
||||||
- Vuoi abilitare login email/password per ogni giocatore, o preferisci mantenere la selezione "Chi sei?" senza password per semplicità?
|
|
||||||
- Chi gestirà inserimento eventi e aggiornamento classifica: solo alcuni o tutta la squadra?
|
|
||||||
- Vuoi procedere per fasi (prima rosa + calendario + presenze, poi statistiche) o tutto insieme?
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Ridimensionamento badge nella lista squadra
|
|
||||||
|
|
||||||
## Obiettivo
|
|
||||||
|
|
||||||
Rendere i badge accanto al nome del giocatore nella lista squadra più compatti e meno invasivi, mantenendo lo stile stilizzato (icone Lucide colorate per grado) e lasciando la scheda espansa con una dimensione leggibile.
|
|
||||||
|
|
||||||
## Modifiche previste
|
|
||||||
|
|
||||||
1. **Lista squadra (`src/routes/squadra.tsx`)**
|
|
||||||
- Ridurre le icone badge sbloccati mostrate accanto al nome da `h-4 w-4` (16px) a `h-3 w-3` (12px).
|
|
||||||
- Mantenere il massimo di 3 badge visibili e il contatore `+N` con testo ridotto a `text-[9px]` per armonizzare.
|
|
||||||
- Lasciare invariati avatar, nome, ruolo ed età.
|
|
||||||
|
|
||||||
2. **Scheda giocatore espansa (`src/routes/squadra.tsx`)**
|
|
||||||
- Portare le icone badge dalla dimensione attuale a `h-4 w-4` (16px), leggibili ma non troppo grandi.
|
|
||||||
- Mantenere card, colori per grado (bronzo/argento/oro) e testi descrittivi.
|
|
||||||
|
|
||||||
3. **Verifica**
|
|
||||||
- Controllare la preview su `/squadra` per confermare che i badge in lista siano discreti e la scheda espansa rimanga leggibile.
|
|
||||||
- Eseguire build per assicurarsi che non ci siano errori di tipo o stile.
|
|
||||||
|
|
||||||
## Cosa non cambia
|
|
||||||
|
|
||||||
- Colori dei gradi, soglie badge, logica di sblocco e votazione MVP.
|
|
||||||
- Layout generale della pagina e bottom navigation.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Rimuovere placeholder "Livello 7" dal Profilo
|
|
||||||
|
|
||||||
## Obiettivo
|
|
||||||
|
|
||||||
Eliminare il testo statico "Livello 7" dalla scheda profilo, dato che non è collegato a nessun calcolo reale e l'utente preferisce toglierlo per ora.
|
|
||||||
|
|
||||||
## Modifica
|
|
||||||
|
|
||||||
- `src/routes/profilo.tsx`: rimuovere il paragrafo `<p className="font-display text-2xl leading-none">Livello 7</p>` (riga 116) e, se necessario, riallineare il layout circostante per evitare spazi vuoti strani.
|
|
||||||
|
|
||||||
## Verifica
|
|
||||||
|
|
||||||
- Build senza errori.
|
|
||||||
- Preview della pagina Profilo: nessun riferimento a "Livello" visibile.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Selezione giocatore al primo avvio
|
|
||||||
|
|
||||||
Aggiungere un flusso di onboarding che chiede "Chi sei?" la prima volta che l'app viene aperta, memorizzando la scelta in `localStorage`. Il profilo e la home si aggiorneranno automaticamente in base al giocatore selezionato.
|
|
||||||
|
|
||||||
## Cosa cambia
|
|
||||||
|
|
||||||
1. **Nuovo store `src/lib/user-store.ts`**
|
|
||||||
- Persiste in `localStorage` l'`id` del giocatore scelto.
|
|
||||||
- Espone `useGiocatoreCorrente()` che restituisce il giocatore selezionato o `null`.
|
|
||||||
- Espone `impostaGiocatore(id)` e `resetGiocatore()`.
|
|
||||||
|
|
||||||
2. **Nuova route `/benvenuto`**
|
|
||||||
- Schermata full-screen con logo, titolo "Benvenuto in CrAPP" e lista scrollabile della rosa.
|
|
||||||
- Ogni riga mostra iniziali, nome, ruolo e numero maglia.
|
|
||||||
- Al tap su un giocatore, lo store viene aggiornato e l'utente viene portato a `/`.
|
|
||||||
- Non mostra la bottom navigation.
|
|
||||||
|
|
||||||
3. **Reindirizzamento condizionato in `__root.tsx`**
|
|
||||||
- Se non è ancora stato selezionato un giocatore, qualunque route apre `/benvenuto`.
|
|
||||||
- Dopo la scelta, l'app funziona normalmente.
|
|
||||||
|
|
||||||
4. **Sostituzione di `giocatoreCorrente` con `useGiocatoreCorrente()`**
|
|
||||||
- Aggiornare `src/routes/index.tsx` per salutare il giocatore selezionato e mostrarne le statistiche rapide.
|
|
||||||
- Aggiornare `src/routes/profilo.tsx` per renderlo il profilo personale del giocatore scelto.
|
|
||||||
|
|
||||||
5. **Cambio utente dalle impostazioni**
|
|
||||||
- In `src/routes/profilo.tsx`, aggiungere una voce "Cambia giocatore" che resetta la selezione e porta a `/benvenuto`.
|
|
||||||
|
|
||||||
## Note tecniche
|
|
||||||
|
|
||||||
- `localStorage` viene letto solo lato client, usando `useSyncExternalStore` per evitare mismatch di hydration.
|
|
||||||
- La rosa reale è già presente in `src/lib/crapp-data.ts` (`giocatori`).
|
|
||||||
- La costante esportata `giocatoreCorrente` verrà rimossa; i componenti consumeranno il nuovo hook.
|
|
||||||
- Nessun backend richiesto: resta un prototipo locale.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Turno palloni nel calendario + promemoria
|
|
||||||
|
|
||||||
Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra tutti, con rotazione automatica proposta dall'app e promemoria (in-app e push).
|
|
||||||
|
|
||||||
## Cosa vedrà la squadra
|
|
||||||
|
|
||||||
- **Riga "Palloni" su ogni evento** (allenamento e partita) nella card del calendario e in Home: avatar + nome dell'incaricato, oppure "Da assegnare".
|
|
||||||
- **Chiunque può cambiarlo**: tocco sulla riga, si apre la lista della rosa, si sceglie il nome. La modifica è immediata e visibile a tutti.
|
|
||||||
- **Proposta automatica a rotazione**: l'app suggerisce chi non ha ancora fatto il turno di recente (o è stato meno volte incaricato). Il suggerimento è solo una proposta: resta sempre modificabile.
|
|
||||||
- **Storico turni** nella scheda squadra/giocatore: quante volte ciascuno ha portato i palloni.
|
|
||||||
- **Due promemoria per l'incaricato**:
|
|
||||||
1. il giorno stesso dell'evento in cui li deve **prendere** a fine allenamento/partita;
|
|
||||||
2. il giorno dell'evento successivo, per ricordargli di **riportarli**.
|
|
||||||
- I promemoria arrivano come banner ben visibile in Home e, per chi attiva le notifiche, come notifica push sul telefono.
|
|
||||||
|
|
||||||
## Impostazione tecnica
|
|
||||||
|
|
||||||
**Backend (Lovable Cloud)**
|
|
||||||
|
|
||||||
- Attivazione di Lovable Cloud.
|
|
||||||
- Tabella `eventi` (spostando i dati demo attuali su database) o, in alternativa minima, tabella `turni_palloni` con `evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`. Scelgo la seconda per limitare il refactor: gli eventi restano in `crapp-data.ts` finché non si passa a calendario dinamico.
|
|
||||||
- Tabella `push_subscriptions` (giocatore_id, endpoint, chiavi) per le notifiche.
|
|
||||||
- Grant espliciti + RLS: lettura e scrittura aperte a tutti gli utenti dell'app (nessun login previsto oggi → policy per `anon` limitate a queste tabelle, nessun dato personale sensibile).
|
|
||||||
- Server functions in `src/lib/palloni.functions.ts`: `getTurni`, `setTurno`, `suggerisciTurno`.
|
|
||||||
|
|
||||||
**Frontend**
|
|
||||||
|
|
||||||
- Nuovo componente `TurnoPalloni` usato in `EventoCard` e in Home.
|
|
||||||
- Lettura via TanStack Query (`ensureQueryData` nel loader, `useSuspenseQuery` nel componente), invalidazione dopo la modifica.
|
|
||||||
- Banner promemoria in Home basato su data odierna + evento successivo, mostrato solo al giocatore selezionato in `user-store`.
|
|
||||||
|
|
||||||
**Notifiche push**
|
|
||||||
|
|
||||||
- Service worker dedicato al messaging (separato dalla PWA esistente), chiavi VAPID salvate come secret.
|
|
||||||
- Schermata in Profilo: "Attiva notifiche palloni" con richiesta di permesso.
|
|
||||||
- Invio schedulato tramite un endpoint `src/routes/api/public/promemoria-palloni.ts` protetto da secret, richiamato una volta al giorno da un job pianificato (pg_cron).
|
|
||||||
- Nota: su iPhone le notifiche push funzionano solo se l'app è installata dalla schermata Home.
|
|
||||||
|
|
||||||
## Ordine di lavoro
|
|
||||||
|
|
||||||
1. Attivare Lovable Cloud e creare tabelle + policy.
|
|
||||||
2. Server functions + UI del turno palloni (assegnazione manuale condivisa).
|
|
||||||
3. Rotazione automatica suggerita + storico turni.
|
|
||||||
4. Banner promemoria in-app.
|
|
||||||
5. Notifiche push + job giornaliero.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"template": "tanstack_start_ts_current",
|
|
||||||
"revision": "tanstack_start_ts_current-9e5645c506e5"
|
|
||||||
}
|
|
||||||
@@ -55,6 +55,15 @@ Il progetto richiede le seguenti variabili:
|
|||||||
- `VITE_SUPABASE_URL`
|
- `VITE_SUPABASE_URL`
|
||||||
- `VITE_SUPABASE_PUBLISHABLE_KEY`
|
- `VITE_SUPABASE_PUBLISHABLE_KEY`
|
||||||
|
|
||||||
|
Solo per lo sviluppo locale con PocketBase al posto di Supabase (vedi
|
||||||
|
[docs/PORTABILITA.md](docs/PORTABILITA.md)):
|
||||||
|
|
||||||
|
- `VITE_POCKETBASE_URL` / `POCKETBASE_URL` — `http://127.0.0.1:8090` con
|
||||||
|
`docker-compose.pocketbase.yml`
|
||||||
|
- `POCKETBASE_SUPERUSER_EMAIL` / `POCKETBASE_SUPERUSER_PASSWORD` — credenziali del
|
||||||
|
superuser creato al primo avvio, usate solo dalle route server che oggi usano
|
||||||
|
`supabaseAdmin`
|
||||||
|
|
||||||
## Documentazione
|
## Documentazione
|
||||||
|
|
||||||
Indice in [docs/README.md](docs/README.md). Le regole per gli assistenti AI stanno in
|
Indice in [docs/README.md](docs/README.md). Le regole per gli assistenti AI stanno in
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "crapp",
|
"name": "crapp",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lovable.dev/cloud-auth-js": "^1.1.2",
|
|
||||||
"@supabase/supabase-js": "^2.111.0",
|
"@supabase/supabase-js": "^2.111.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-query": "^5.101.1",
|
"@tanstack/react-query": "^5.101.1",
|
||||||
@@ -16,6 +15,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"motion": "^13.2.0",
|
"motion": "^13.2.0",
|
||||||
|
"pocketbase": "^0.28.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
"@lovable.dev/vite-tanstack-config": "2.8.5",
|
"@tanstack/devtools-vite": "^0.8.3",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
@@ -130,14 +130,6 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="],
|
|
||||||
|
|
||||||
"@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.2.1", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-JdADRwpEJA5t0ggXbd96dND23Wsp69Af9lVSV5m7MnMGxJZlPgqAqz7HUKcfJESfI5M5yFZOl9e0x76JMMDOAg=="],
|
|
||||||
|
|
||||||
"@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.3.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.3.5.tgz", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-LxCj6JIbYRQ6peMm2aVn/bhHLkwZ5eZ4Cn6gmh7LfjaplqZHlA24nhCowgt6ZLfbckc4d8tSy0He7H8EiFEXag=="],
|
|
||||||
|
|
||||||
"@lovable.dev/vite-tanstack-config": ["@lovable.dev/vite-tanstack-config@2.8.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-2.8.5.tgz", { "dependencies": { "@lovable.dev/vite-plugin-dev-server-bridge": "^1.2.1", "@lovable.dev/vite-plugin-hmr-gate": "^1.3.4", "@tanstack/devtools-vite": "^0.8.1", "lightningcss": "^1.30.0" }, "peerDependencies": { "@tailwindcss/vite": ">=4.0.0", "@tanstack/react-start": ">=1.100.0", "@vitejs/plugin-react": ">=4.0.0", "nitro": ">=3.0.260603-beta", "vite": ">=5.0.0 <9.0.0", "vite-tsconfig-paths": ">=6.0.0" }, "optionalPeers": ["nitro"] }, "sha512-qPNxEXjvRsrbJHKgHxuLWpRW/gu2nIM+62qjTUCbCno5nRPMKyJQNsgjh48qjAkS6T0MRIkY/3BENNXeGsi5hw=="],
|
|
||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="],
|
||||||
|
|
||||||
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
||||||
@@ -424,7 +416,7 @@
|
|||||||
|
|
||||||
"canvas-confetti": ["canvas-confetti@1.9.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
|
"canvas-confetti": ["canvas-confetti@1.9.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
|
||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
|
|
||||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
|
|
||||||
@@ -660,6 +652,8 @@
|
|||||||
|
|
||||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||||
|
|
||||||
|
"pocketbase": ["pocketbase@0.28.1", "", {}, "sha512-/3ihkq+rvfcs0MQgrK4sEElOg6gfenHW9S/O+3BSO+V1yT+4B10+9aT22uTQUPqze9ItwNtwWyY5ZmdgVCTdVA=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.24", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw=="],
|
"postcss": ["postcss@8.5.24", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw=="],
|
||||||
|
|
||||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
@@ -800,10 +794,6 @@
|
|||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"@tanstack/devtools-bundler-core/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
|
||||||
|
|
||||||
"@tanstack/devtools-vite/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
|
||||||
|
|
||||||
"@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
"@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||||
|
|
||||||
"@tanstack/router-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
"@tanstack/router-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||||
@@ -820,6 +810,8 @@
|
|||||||
|
|
||||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||||
|
|
||||||
|
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="],
|
"h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="],
|
||||||
|
|
||||||
"h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
"h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
||||||
|
|||||||
+1
-1
@@ -4,4 +4,4 @@ saveTextLockfile = true
|
|||||||
minimumReleaseAge = 86400
|
minimumReleaseAge = 86400
|
||||||
# Each entry bypasses the 24h guard for one package — confirm with the user
|
# Each entry bypasses the 24h guard for one package — confirm with the user
|
||||||
# before adding any.
|
# before adding any.
|
||||||
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config", "@lovable.dev/mcp-js", "@lovable.dev/vite-plugin-dev-server-bridge", "@lovable.dev/vite-plugin-hmr-gate", "@lovable.dev/email-js", "@lovable.dev/webhooks-js"]
|
minimumReleaseAgeExcludes = []
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# PocketBase locale per lo sviluppo (sostituisce "npx supabase start" solo in locale,
|
||||||
|
# vedi docs/PORTABILITA.md e la voce DD relativa). La produzione resta su Supabase.
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# docker compose -f docker-compose.pocketbase.yml up -d # avvia
|
||||||
|
# docker compose -f docker-compose.pocketbase.yml down # ferma
|
||||||
|
# docker compose -f docker-compose.pocketbase.yml down -v # ferma e azzera i dati locali
|
||||||
|
#
|
||||||
|
# Dashboard admin: http://127.0.0.1:8090/_/
|
||||||
|
# API: http://127.0.0.1:8090/api/
|
||||||
|
|
||||||
|
services:
|
||||||
|
pocketbase:
|
||||||
|
image: ghcr.io/muchobien/pocketbase:latest
|
||||||
|
container_name: crapp-pocketbase
|
||||||
|
restart: unless-stopped
|
||||||
|
# --automigrate=0: le modifiche fatte da dashboard (es. abilitare un provider OAuth2,
|
||||||
|
# con relativo client secret) NON devono finire come file di migration versionati in
|
||||||
|
# git. Le migration restano solo quelle scritte a mano in pocketbase/pb_migrations/.
|
||||||
|
command: ["--automigrate=0"]
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
# L'immagine crea il superuser da queste variabili a ogni avvio (idempotente):
|
||||||
|
# sopravvive a "npm run pocketbase:reset" senza passaggi manuali.
|
||||||
|
PB_ADMIN_EMAIL: ${POCKETBASE_SUPERUSER_EMAIL:-}
|
||||||
|
PB_ADMIN_PASSWORD: ${POCKETBASE_SUPERUSER_PASSWORD:-}
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
|
volumes:
|
||||||
|
- ./pocketbase/pb_data:/pb_data
|
||||||
|
- ./pocketbase/pb_migrations:/pb_migrations
|
||||||
|
- ./pocketbase/pb_hooks:/pb_hooks
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8090/api/health"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
@@ -12,6 +12,26 @@ all'indietro.
|
|||||||
|
|
||||||
## [Non rilasciato]
|
## [Non rilasciato]
|
||||||
|
|
||||||
|
## [0.9.2] - 2026-09-10
|
||||||
|
|
||||||
|
### Aggiunto
|
||||||
|
|
||||||
|
- **Dashboard amministratore** — nuova tab "Notifiche" che mostra quanti giocatori hanno
|
||||||
|
le notifiche push attive e chi sono.
|
||||||
|
|
||||||
|
### Modificato
|
||||||
|
|
||||||
|
- **Dashboard amministratore** — le sezioni impilate diventano un menu di tab scorrevole a
|
||||||
|
pillole (come Squadra e Campionato); nell'elenco Profili resta aperta una sola scheda
|
||||||
|
alla volta.
|
||||||
|
- **Profilo** — testi dei campi amministrativi semplificati (label email, rimossa la nota
|
||||||
|
su chi vede quei dati).
|
||||||
|
|
||||||
|
### Rimosso
|
||||||
|
|
||||||
|
- Le dipendenze e il codice legati all'editor Lovable (login social e reporting errori
|
||||||
|
verso l'editor): l'app non ci gira più.
|
||||||
|
|
||||||
## [0.9.1] - 2026-09-10
|
## [0.9.1] - 2026-09-10
|
||||||
|
|
||||||
### Modificato
|
### Modificato
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
|
|||||||
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
|
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
|
||||||
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
|
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
|
||||||
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
|
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
|
||||||
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
|
|
||||||
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
|
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
|
||||||
|
|
||||||
## Regole da rispettare nelle prossime modifiche
|
## Regole da rispettare nelle prossime modifiche
|
||||||
|
|||||||
@@ -81,10 +81,14 @@ 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
|
è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna
|
||||||
variabile d'ambiente.
|
variabile d'ambiente.
|
||||||
|
|
||||||
| Route | Controllo | Chi la chiama |
|
| Route | Controllo | Chi la chiama |
|
||||||
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante riservato agli admin |
|
| `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 |
|
| `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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -165,9 +165,17 @@ Contiene.
|
|||||||
## Dashboard amministratore
|
## Dashboard amministratore
|
||||||
|
|
||||||
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
|
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
|
||||||
Profilo → Opzioni).
|
Profilo → Opzioni), organizzata in tab scorrevoli a pillole (`BarraSottosezioni`, stesso
|
||||||
|
componente di [Squadra](squadra.md) e Campionato): Squadra, Profili, Disattivati (solo se
|
||||||
|
c'è almeno un giocatore disattivato) e Notifiche.
|
||||||
|
|
||||||
Per ogni giocatore vengono mostrati.
|
La tab **Notifiche** mostra quanti giocatori attivi hanno almeno un dispositivo iscritto
|
||||||
|
alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (vedi
|
||||||
|
[Notifiche](notifiche.md)). È solo consultiva: l'attivazione resta un gesto che ogni
|
||||||
|
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
|
||||||
|
di altri.
|
||||||
|
|
||||||
|
Per ogni giocatore, nella tab Profili, vengono mostrati.
|
||||||
|
|
||||||
- Stato del profilo
|
- Stato del profilo
|
||||||
- Certificato medico
|
- Certificato medico
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import reactRefresh from "eslint-plugin-react-refresh";
|
|||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist", ".output", ".vinxi"] },
|
{ ignores: ["dist", ".output", ".vinxi", "pocketbase/pb_data"] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ["**/*.{ts,tsx}"],
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
|||||||
+7
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "crapp",
|
"name": "crapp",
|
||||||
"version": "0.9.1",
|
"version": "0.9.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -14,10 +14,12 @@
|
|||||||
"test": "bun test/run.ts",
|
"test": "bun test/run.ts",
|
||||||
"test:integration": "bun test/run.ts integration",
|
"test:integration": "bun test/run.ts integration",
|
||||||
"test:e2e": "bun test/run.ts e2e",
|
"test:e2e": "bun test/run.ts e2e",
|
||||||
"test:all": "bun test/run.ts all"
|
"test:all": "bun test/run.ts all",
|
||||||
|
"pocketbase:start": "docker compose -f docker-compose.pocketbase.yml up -d",
|
||||||
|
"pocketbase:stop": "docker compose -f docker-compose.pocketbase.yml down",
|
||||||
|
"pocketbase:reset": "bash scripts/pocketbase-reset.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lovable.dev/cloud-auth-js": "^1.1.2",
|
|
||||||
"@supabase/supabase-js": "^2.111.0",
|
"@supabase/supabase-js": "^2.111.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-query": "^5.101.1",
|
"@tanstack/react-query": "^5.101.1",
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"motion": "^13.2.0",
|
"motion": "^13.2.0",
|
||||||
|
"pocketbase": "^0.28.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
"@lovable.dev/vite-tanstack-config": "2.8.5",
|
"@tanstack/devtools-vite": "^0.8.3",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Equivalente del trigger enforce_giocatori_squadra_update (M1/M5/M8, DD-016/DD-018):
|
||||||
|
// un non-admin può aggiornare uno slot di giocatori_squadra solo per reclamarlo (slot
|
||||||
|
// libero, email dell'account uguale a quella registrata sulla riga), senza cambiare nessun
|
||||||
|
// altro campo. La updateRule della collection apre la porta solo quando auth_user_id è
|
||||||
|
// vuoto o l'utente è admin; qui si valida il resto.
|
||||||
|
|
||||||
|
onRecordUpdateRequest((e) => {
|
||||||
|
// Superuser (pbAdmin lato server, dashboard, script di migrazione dati) e admin
|
||||||
|
// applicativi (role="admin" sulla collection users) bypassano il vincolo di claim.
|
||||||
|
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
|
||||||
|
if (isAdmin) {
|
||||||
|
e.next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originale = e.app.findRecordById("giocatori_squadra", e.record.id);
|
||||||
|
|
||||||
|
const campiBloccati = [
|
||||||
|
"nome",
|
||||||
|
"cognome",
|
||||||
|
"numero",
|
||||||
|
"ruolo",
|
||||||
|
"attivo",
|
||||||
|
"email",
|
||||||
|
"numero_tessera",
|
||||||
|
"data_tessera",
|
||||||
|
];
|
||||||
|
for (const campo of campiBloccati) {
|
||||||
|
if (String(e.record.get(campo)) !== String(originale.get(campo))) {
|
||||||
|
throw new BadRequestError("Aggiornamento non autorizzato su giocatori_squadra");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const utenteId = e.auth ? e.auth.id : "";
|
||||||
|
const emailAccount = (e.auth ? e.auth.get("email") || "" : "").toLowerCase();
|
||||||
|
const emailSlot = (originale.get("email") || "").toLowerCase();
|
||||||
|
|
||||||
|
const slotLibero = !originale.get("auth_user_id");
|
||||||
|
const claimSuAccountCorrente = e.record.get("auth_user_id") === utenteId;
|
||||||
|
const emailCombacia = emailSlot !== "" && emailSlot === emailAccount;
|
||||||
|
|
||||||
|
if (!slotLibero || !claimSuAccountCorrente || !emailCombacia) {
|
||||||
|
throw new BadRequestError("Aggiornamento non autorizzato su giocatori_squadra");
|
||||||
|
}
|
||||||
|
|
||||||
|
e.next();
|
||||||
|
}, "giocatori_squadra");
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Il Client ID/Secret di Google OAuth2 configurati da dashboard vivono in pb_data e
|
||||||
|
// andrebbero persi a ogni "npm run pocketbase:reset" (azzera pb_data). Questo hook li
|
||||||
|
// reimposta a ogni avvio da variabili d'ambiente (mai committate, solo in .env), così
|
||||||
|
// sopravvivono al reset senza reinserirli a mano ogni volta dalla dashboard.
|
||||||
|
|
||||||
|
onBootstrap((e) => {
|
||||||
|
e.next();
|
||||||
|
|
||||||
|
const clientId = $os.getenv("GOOGLE_OAUTH_CLIENT_ID");
|
||||||
|
const clientSecret = $os.getenv("GOOGLE_OAUTH_CLIENT_SECRET");
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection = e.app.findCollectionByNameOrId("users");
|
||||||
|
collection.oauth2.enabled = true;
|
||||||
|
collection.oauth2.providers = [
|
||||||
|
{ name: "google", clientId: clientId, clientSecret: clientSecret },
|
||||||
|
];
|
||||||
|
e.app.save(collection);
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Equivalente del trigger risposte_presenze_risposto_il_immutabile (M9): risposto_il è
|
||||||
|
// l'istante della PRIMA risposta, scritto una sola volta e mai più modificabile — serve
|
||||||
|
// per la serie "Conferme 24h" (confronto con eventi_app.created).
|
||||||
|
|
||||||
|
onRecordCreateRequest((e) => {
|
||||||
|
e.record.set("risposto_il", new Date().toISOString());
|
||||||
|
e.next();
|
||||||
|
}, "risposte_presenze");
|
||||||
|
|
||||||
|
onRecordUpdateRequest((e) => {
|
||||||
|
const originale = e.app.findRecordById("risposte_presenze", e.record.id);
|
||||||
|
e.record.set("risposto_il", originale.get("risposto_il"));
|
||||||
|
e.next();
|
||||||
|
}, "risposte_presenze");
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Equivalente di evento_permette_voto() + i CHECK "no autovoto" (M12/M13): applicato a
|
||||||
|
// mvp_voti, pagelle_voti, badge_social_voti su create/update. Gli admin non hanno questi
|
||||||
|
// vincoli (possono correggere un voto anche fuori convocazione/dopo la chiusura pagelle).
|
||||||
|
// convocati vuoto = "tutta la rosa" (stessa convenzione di convocatiEvento() in eventi.ts).
|
||||||
|
//
|
||||||
|
// La validazione è ripetuta in ciascun callback (non estratta in una funzione condivisa a
|
||||||
|
// livello di file): ogni hook di PocketBase viene eseguito nel proprio contesto JS isolato,
|
||||||
|
// che non vede le funzioni dichiarate fuori dal callback stesso (verificato empiricamente:
|
||||||
|
// una funzione condivisa causa "ReferenceError: ... is not defined" a runtime).
|
||||||
|
|
||||||
|
onRecordCreateRequest((e) => {
|
||||||
|
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
|
||||||
|
if (!isAdmin) {
|
||||||
|
const votanteId = e.record.get("votante");
|
||||||
|
const votatoId = e.record.get("votato");
|
||||||
|
if (votanteId === votatoId) {
|
||||||
|
throw new BadRequestError("Non puoi votare te stesso");
|
||||||
|
}
|
||||||
|
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
|
||||||
|
const convocati = evento.get("convocati") || [];
|
||||||
|
if (
|
||||||
|
convocati.length > 0 &&
|
||||||
|
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
|
||||||
|
) {
|
||||||
|
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.next();
|
||||||
|
}, "mvp_voti");
|
||||||
|
|
||||||
|
onRecordUpdateRequest((e) => {
|
||||||
|
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
|
||||||
|
if (!isAdmin) {
|
||||||
|
const votanteId = e.record.get("votante");
|
||||||
|
const votatoId = e.record.get("votato");
|
||||||
|
if (votanteId === votatoId) {
|
||||||
|
throw new BadRequestError("Non puoi votare te stesso");
|
||||||
|
}
|
||||||
|
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
|
||||||
|
const convocati = evento.get("convocati") || [];
|
||||||
|
if (
|
||||||
|
convocati.length > 0 &&
|
||||||
|
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
|
||||||
|
) {
|
||||||
|
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.next();
|
||||||
|
}, "mvp_voti");
|
||||||
|
|
||||||
|
onRecordCreateRequest((e) => {
|
||||||
|
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
|
||||||
|
if (!isAdmin) {
|
||||||
|
const votanteId = e.record.get("votante");
|
||||||
|
const votatoId = e.record.get("votato");
|
||||||
|
if (votanteId === votatoId) {
|
||||||
|
throw new BadRequestError("Non puoi votare te stesso");
|
||||||
|
}
|
||||||
|
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
|
||||||
|
const convocati = evento.get("convocati") || [];
|
||||||
|
if (
|
||||||
|
convocati.length > 0 &&
|
||||||
|
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
|
||||||
|
) {
|
||||||
|
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
|
||||||
|
}
|
||||||
|
if (evento.get("pagelle_chiuse")) {
|
||||||
|
throw new BadRequestError("Le pagelle per questo evento sono chiuse");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.next();
|
||||||
|
}, "pagelle_voti");
|
||||||
|
|
||||||
|
onRecordUpdateRequest((e) => {
|
||||||
|
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
|
||||||
|
if (!isAdmin) {
|
||||||
|
const votanteId = e.record.get("votante");
|
||||||
|
const votatoId = e.record.get("votato");
|
||||||
|
if (votanteId === votatoId) {
|
||||||
|
throw new BadRequestError("Non puoi votare te stesso");
|
||||||
|
}
|
||||||
|
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
|
||||||
|
const convocati = evento.get("convocati") || [];
|
||||||
|
if (
|
||||||
|
convocati.length > 0 &&
|
||||||
|
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
|
||||||
|
) {
|
||||||
|
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
|
||||||
|
}
|
||||||
|
if (evento.get("pagelle_chiuse")) {
|
||||||
|
throw new BadRequestError("Le pagelle per questo evento sono chiuse");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.next();
|
||||||
|
}, "pagelle_voti");
|
||||||
|
|
||||||
|
onRecordCreateRequest((e) => {
|
||||||
|
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
|
||||||
|
if (!isAdmin) {
|
||||||
|
const votanteId = e.record.get("votante");
|
||||||
|
const votatoId = e.record.get("votato");
|
||||||
|
if (votanteId === votatoId) {
|
||||||
|
throw new BadRequestError("Non puoi votare te stesso");
|
||||||
|
}
|
||||||
|
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
|
||||||
|
const convocati = evento.get("convocati") || [];
|
||||||
|
if (
|
||||||
|
convocati.length > 0 &&
|
||||||
|
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
|
||||||
|
) {
|
||||||
|
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.next();
|
||||||
|
}, "badge_social_voti");
|
||||||
|
|
||||||
|
onRecordUpdateRequest((e) => {
|
||||||
|
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
|
||||||
|
if (!isAdmin) {
|
||||||
|
const votanteId = e.record.get("votante");
|
||||||
|
const votatoId = e.record.get("votato");
|
||||||
|
if (votanteId === votatoId) {
|
||||||
|
throw new BadRequestError("Non puoi votare te stesso");
|
||||||
|
}
|
||||||
|
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
|
||||||
|
const convocati = evento.get("convocati") || [];
|
||||||
|
if (
|
||||||
|
convocati.length > 0 &&
|
||||||
|
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
|
||||||
|
) {
|
||||||
|
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.next();
|
||||||
|
}, "badge_social_voti");
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Schema iniziale PocketBase per CrAPP — solo ambiente locale (Docker).
|
||||||
|
//
|
||||||
|
// Non è la riproduzione 1:1 delle 27 migration Supabase in supabase/migrations/: rappresenta
|
||||||
|
// lo STATO FINALE attuale dello schema (fonte: docs/DATABASE.md + lettura diretta delle
|
||||||
|
// migration Supabase più recenti), riscritto nel modello PocketBase (collection + campi +
|
||||||
|
// API rules). Le tabelle Supabase non più usate dal codice (`giocatori`, `eventi`/`presenze`
|
||||||
|
// "nuovo modello" mai adottato, `promemoria_push` deprecata) non vengono portate.
|
||||||
|
//
|
||||||
|
// `user_roles` non diventa una collection: il ruolo admin/user è un campo diretto sulla
|
||||||
|
// collection auth nativa `users` (decisione 3 del piano di migrazione).
|
||||||
|
//
|
||||||
|
// La logica procedurale che in Postgres viveva in trigger/funzioni (claim-slot su
|
||||||
|
// giocatori_squadra, blocco autovoto, convocati/pagelle_chiuse, cascata su cancellazione
|
||||||
|
// evento, risposto_il immutabile) NON sta qui: è in pb_hooks/*.pb.js, perché le API rules di
|
||||||
|
// PocketBase sono espressioni, non codice procedurale.
|
||||||
|
|
||||||
|
migrate(
|
||||||
|
(app) => {
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// users (collection auth nativa): aggiunge il campo ruolo che sostituisce user_roles.
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
const users = app.findCollectionByNameOrId("users");
|
||||||
|
users.fields.add(
|
||||||
|
new Field({
|
||||||
|
name: "role",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
values: ["user", "admin"],
|
||||||
|
maxSelect: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Ogni utente autenticato legge il proprio record; l'admin (verificato lato hook al primo
|
||||||
|
// login, poi qui) può leggere tutti. Il ruolo lo scrive solo chi è già admin.
|
||||||
|
users.listRule = "id = @request.auth.id || @request.auth.role = 'admin'";
|
||||||
|
users.viewRule = "id = @request.auth.id || @request.auth.role = 'admin'";
|
||||||
|
users.updateRule = "id = @request.auth.id";
|
||||||
|
app.save(users);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// giocatori_squadra — anagrafica operativa della squadra (DD-015, DD-016, DD-018)
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
const giocatori = new Collection({
|
||||||
|
name: "giocatori_squadra",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
// Id testuali corti (g1..gN, come in Supabase): il vincolo di default di PocketBase
|
||||||
|
// (minimo 15 caratteri) va rilassato esplicitamente.
|
||||||
|
{ name: "id", type: "text", min: 1, max: 40, pattern: "^g[0-9]+$" },
|
||||||
|
{ name: "nome", type: "text", required: true },
|
||||||
|
{ name: "cognome", type: "text", required: true },
|
||||||
|
{ name: "numero", type: "number", required: true, min: 1 },
|
||||||
|
{ name: "ruolo", type: "text", required: true },
|
||||||
|
{
|
||||||
|
name: "auth_user_id",
|
||||||
|
type: "relation",
|
||||||
|
required: false,
|
||||||
|
collectionId: users.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{ name: "email", type: "email", required: false },
|
||||||
|
{ name: "attivo", type: "bool", required: false },
|
||||||
|
{ name: "numero_tessera", type: "text", required: false },
|
||||||
|
{ name: "data_tessera", type: "date", required: false },
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
"CREATE UNIQUE INDEX idx_giocatori_squadra_auth_user ON giocatori_squadra (auth_user_id) WHERE auth_user_id != ''",
|
||||||
|
"CREATE UNIQUE INDEX idx_giocatori_squadra_email ON giocatori_squadra (email) WHERE email != ''",
|
||||||
|
],
|
||||||
|
// Lettura: rosa attiva a tutti gli autenticati, tutta la rosa agli admin (DD-015/M1).
|
||||||
|
listRule: "@request.auth.id != '' && (attivo = true || @request.auth.role = 'admin')",
|
||||||
|
viewRule: "@request.auth.id != '' && (attivo = true || @request.auth.role = 'admin')",
|
||||||
|
// Creazione slot: solo admin.
|
||||||
|
createRule: "@request.auth.role = 'admin'",
|
||||||
|
// Aggiornamento: admin senza vincoli, oppure claim di uno slot libero — il resto
|
||||||
|
// (che il claim tocchi solo auth_user_id e rispetti l'email) lo valida l'hook
|
||||||
|
// giocatori_squadra_claim.pb.js, la rule qui apre solo la porta.
|
||||||
|
updateRule: "@request.auth.role = 'admin' || (@request.auth.id != '' && auth_user_id = '')",
|
||||||
|
deleteRule: "@request.auth.role = 'admin'",
|
||||||
|
});
|
||||||
|
app.save(giocatori);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// profili_giocatore — dati personali/documento/certificato, 1:1 con giocatori_squadra
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
const profili = new Collection({
|
||||||
|
name: "profili_giocatore",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "giocatore",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{ name: "data_nascita", type: "date", required: false },
|
||||||
|
{ name: "luogo_nascita", type: "text", required: false },
|
||||||
|
{ name: "indirizzo", type: "text", required: false },
|
||||||
|
{ name: "telefono", type: "text", required: false },
|
||||||
|
{ name: "email", type: "email", required: false },
|
||||||
|
{
|
||||||
|
name: "documento_tipo",
|
||||||
|
type: "select",
|
||||||
|
required: false,
|
||||||
|
maxSelect: 1,
|
||||||
|
values: ["Carta d'identità", "Passaporto", "Patente"],
|
||||||
|
},
|
||||||
|
{ name: "documento_numero", type: "text", required: false },
|
||||||
|
{ name: "documento_rilasciato_da", type: "text", required: false },
|
||||||
|
{ name: "documento_emissione", type: "date", required: false },
|
||||||
|
{ name: "documento_scadenza", type: "date", required: false },
|
||||||
|
{
|
||||||
|
name: "documento_fronte",
|
||||||
|
type: "file",
|
||||||
|
required: false,
|
||||||
|
protected: true,
|
||||||
|
maxSelect: 1,
|
||||||
|
maxSize: 10485760,
|
||||||
|
mimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "documento_retro",
|
||||||
|
type: "file",
|
||||||
|
required: false,
|
||||||
|
protected: true,
|
||||||
|
maxSelect: 1,
|
||||||
|
maxSize: 10485760,
|
||||||
|
mimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
|
||||||
|
},
|
||||||
|
{ name: "certificato_scadenza", type: "date", required: false },
|
||||||
|
{
|
||||||
|
name: "certificato",
|
||||||
|
type: "file",
|
||||||
|
required: false,
|
||||||
|
protected: true,
|
||||||
|
maxSelect: 1,
|
||||||
|
maxSize: 10485760,
|
||||||
|
mimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "foto",
|
||||||
|
type: "file",
|
||||||
|
required: false,
|
||||||
|
protected: true,
|
||||||
|
maxSelect: 1,
|
||||||
|
maxSize: 10485760,
|
||||||
|
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
|
||||||
|
},
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
"CREATE UNIQUE INDEX idx_profili_giocatore_giocatore ON profili_giocatore (giocatore)",
|
||||||
|
],
|
||||||
|
// Documenti sanitari/identità: mai pubblici (DD-016 regola 4). Solo il giocatore
|
||||||
|
// proprietario o un admin, sia per i campi sia per i file allegati (le API rules
|
||||||
|
// governano anche il download dei file in PocketBase, non serve un bucket a parte).
|
||||||
|
listRule:
|
||||||
|
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
|
||||||
|
viewRule:
|
||||||
|
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
|
||||||
|
createRule:
|
||||||
|
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
|
||||||
|
updateRule:
|
||||||
|
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
|
||||||
|
deleteRule: "@request.auth.role = 'admin'",
|
||||||
|
});
|
||||||
|
app.save(profili);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// avatar_giocatori — foto profilo pubbliche (M6 avatar-giocatori). Collection separata
|
||||||
|
// da giocatori_squadra: la policy è "chiunque autenticato carica/sostituisce/elimina
|
||||||
|
// qualsiasi avatar" (nessun controllo per-proprietario, DD in docs/DATABASE.md), molto
|
||||||
|
// più permissiva delle regole di giocatori_squadra — mescolarle avrebbe o indebolito
|
||||||
|
// quelle o impedito il caricamento a chi non è admin.
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
const avatar = new Collection({
|
||||||
|
name: "avatar_giocatori",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "giocatore",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "foto",
|
||||||
|
type: "file",
|
||||||
|
required: false,
|
||||||
|
maxSelect: 1,
|
||||||
|
maxSize: 5242880,
|
||||||
|
mimeTypes: ["image/jpeg"],
|
||||||
|
},
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
"CREATE UNIQUE INDEX idx_avatar_giocatori_giocatore ON avatar_giocatori (giocatore)",
|
||||||
|
],
|
||||||
|
// Bucket pubblico in Supabase: leggibile da chiunque, anche senza login.
|
||||||
|
listRule: "",
|
||||||
|
viewRule: "",
|
||||||
|
createRule: "@request.auth.id != ''",
|
||||||
|
updateRule: "@request.auth.id != ''",
|
||||||
|
deleteRule: "@request.auth.id != ''",
|
||||||
|
});
|
||||||
|
app.save(avatar);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// eventi_app — eventi gestionali (partite/allenamenti/eventi squadra)
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
const eventi = new Collection({
|
||||||
|
name: "eventi_app",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
// Id testuali corti (e1..eN, come in Supabase).
|
||||||
|
{ name: "id", type: "text", min: 1, max: 40, pattern: "^e[0-9]+$" },
|
||||||
|
{
|
||||||
|
name: "tipo",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
maxSelect: 1,
|
||||||
|
values: ["partita", "allenamento", "evento"],
|
||||||
|
},
|
||||||
|
{ name: "titolo", type: "text", required: true },
|
||||||
|
{ name: "luogo", type: "text", required: false },
|
||||||
|
{ name: "data", type: "date", required: true },
|
||||||
|
{ name: "ora", type: "text", required: false },
|
||||||
|
{ name: "note", type: "text", required: false },
|
||||||
|
{
|
||||||
|
name: "convocati",
|
||||||
|
type: "relation",
|
||||||
|
required: false,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 999,
|
||||||
|
},
|
||||||
|
{ name: "campionato", type: "bool", required: false },
|
||||||
|
{ name: "casa", type: "bool", required: false },
|
||||||
|
{ name: "pagelle_chiuse", type: "bool", required: false },
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
// Lettura aperta a tutti gli autenticati; scrittura solo admin (rotta /eventi già
|
||||||
|
// riservata — M11/DD-023).
|
||||||
|
listRule: "@request.auth.id != ''",
|
||||||
|
viewRule: "@request.auth.id != ''",
|
||||||
|
createRule: "@request.auth.role = 'admin'",
|
||||||
|
updateRule: "@request.auth.role = 'admin'",
|
||||||
|
deleteRule: "@request.auth.role = 'admin'",
|
||||||
|
});
|
||||||
|
app.save(eventi);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// risposte_presenze — risposte dei giocatori agli eventi
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
const presenze = new Collection({
|
||||||
|
name: "risposte_presenze",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "evento",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: eventi.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "giocatore",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{ name: "stato", type: "text", required: true },
|
||||||
|
// Istante della PRIMA risposta (M9): valorizzato e reso immutabile dall'hook
|
||||||
|
// risposte_presenze_immutabili.pb.js, non scrivibile direttamente dal client.
|
||||||
|
{ name: "risposto_il", type: "date", required: false },
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
"CREATE UNIQUE INDEX idx_risposte_presenze_evento_giocatore ON risposte_presenze (evento, giocatore)",
|
||||||
|
],
|
||||||
|
listRule: "@request.auth.id != ''",
|
||||||
|
viewRule: "@request.auth.id != ''",
|
||||||
|
createRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
|
||||||
|
updateRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
|
||||||
|
deleteRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
|
||||||
|
});
|
||||||
|
app.save(presenze);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// cacche_partita — sondaggio pre-partita (statistiche/badge segreti)
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
const cacche = new Collection({
|
||||||
|
name: "cacche_partita",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "evento",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: eventi.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "giocatore",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{ name: "quantita", type: "number", required: true, min: 0, max: 10 },
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
"CREATE UNIQUE INDEX idx_cacche_partita_evento_giocatore ON cacche_partita (evento, giocatore)",
|
||||||
|
],
|
||||||
|
listRule: "@request.auth.id != ''",
|
||||||
|
viewRule: "@request.auth.id != ''",
|
||||||
|
createRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
|
||||||
|
updateRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
|
||||||
|
deleteRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
|
||||||
|
});
|
||||||
|
app.save(cacche);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// mvp_voti / pagelle_voti / badge_social_voti — votazioni tra compagni
|
||||||
|
//
|
||||||
|
// Ownership (votante = chi scrive) è espressa qui via API rule. Autovoto e vincoli
|
||||||
|
// "convocato all'evento" / "pagelle non chiuse" (M12/M13) NON sono esprimibili in modo
|
||||||
|
// sicuro come semplice espressione di rule su relazioni multiple: li applica l'hook
|
||||||
|
// pb_hooks/voti_convocati.pb.js su create/update, per tutte e tre le collection.
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
const mvpVoti = new Collection({
|
||||||
|
name: "mvp_voti",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "evento",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: eventi.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "votante",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "votato",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{ name: "votato_nome", type: "text", required: true },
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
indexes: ["CREATE UNIQUE INDEX idx_mvp_voti_evento_votante ON mvp_voti (evento, votante)"],
|
||||||
|
listRule: "@request.auth.id != ''",
|
||||||
|
viewRule: "@request.auth.id != ''",
|
||||||
|
createRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
|
||||||
|
updateRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
|
||||||
|
deleteRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
|
||||||
|
});
|
||||||
|
app.save(mvpVoti);
|
||||||
|
|
||||||
|
const pagelleVoti = new Collection({
|
||||||
|
name: "pagelle_voti",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "evento",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: eventi.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "votante",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "votato",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{ name: "voto", type: "number", required: true, min: 1, max: 10 },
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
"CREATE UNIQUE INDEX idx_pagelle_voti_evento_votante_votato ON pagelle_voti (evento, votante, votato)",
|
||||||
|
],
|
||||||
|
listRule: "@request.auth.id != ''",
|
||||||
|
viewRule: "@request.auth.id != ''",
|
||||||
|
createRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
|
||||||
|
updateRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
|
||||||
|
deleteRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
|
||||||
|
});
|
||||||
|
app.save(pagelleVoti);
|
||||||
|
|
||||||
|
const badgeSocialVoti = new Collection({
|
||||||
|
name: "badge_social_voti",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "evento",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: eventi.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{ name: "categoria", type: "text", required: true },
|
||||||
|
{
|
||||||
|
name: "votante",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "votato",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{ name: "votato_nome", type: "text", required: true },
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
"CREATE UNIQUE INDEX idx_badge_social_voti_evento_categoria_votante ON badge_social_voti (evento, categoria, votante)",
|
||||||
|
],
|
||||||
|
listRule: "@request.auth.id != ''",
|
||||||
|
viewRule: "@request.auth.id != ''",
|
||||||
|
createRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
|
||||||
|
updateRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
|
||||||
|
deleteRule:
|
||||||
|
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
|
||||||
|
});
|
||||||
|
app.save(badgeSocialVoti);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// turni_palloni / scout_sessioni / scout_live / scout_partite — nessun gate in UI,
|
||||||
|
// aperte a qualsiasi autenticato (invariato da Supabase, vedi docs/DATABASE.md).
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
const turniPalloni = new Collection({
|
||||||
|
name: "turni_palloni",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "evento",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: eventi.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "giocatore",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{ name: "aggiornato_da", type: "text", required: false },
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
indexes: ["CREATE UNIQUE INDEX idx_turni_palloni_evento ON turni_palloni (evento)"],
|
||||||
|
listRule: "@request.auth.id != ''",
|
||||||
|
viewRule: "@request.auth.id != ''",
|
||||||
|
createRule: "@request.auth.id != ''",
|
||||||
|
updateRule: "@request.auth.id != ''",
|
||||||
|
deleteRule: "@request.auth.id != ''",
|
||||||
|
});
|
||||||
|
app.save(turniPalloni);
|
||||||
|
|
||||||
|
const scoutSessioni = new Collection({
|
||||||
|
name: "scout_sessioni",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "evento",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: eventi.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "giocatore",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{ name: "giocatore_nome", type: "text", required: true },
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
indexes: ["CREATE UNIQUE INDEX idx_scout_sessioni_evento ON scout_sessioni (evento)"],
|
||||||
|
listRule: "@request.auth.id != ''",
|
||||||
|
viewRule: "@request.auth.id != ''",
|
||||||
|
createRule: "@request.auth.id != ''",
|
||||||
|
updateRule: "@request.auth.id != ''",
|
||||||
|
deleteRule: "@request.auth.id != ''",
|
||||||
|
});
|
||||||
|
app.save(scoutSessioni);
|
||||||
|
|
||||||
|
const scoutLive = new Collection({
|
||||||
|
name: "scout_live",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "evento",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: eventi.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{ name: "stato", type: "json", required: false },
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
indexes: ["CREATE UNIQUE INDEX idx_scout_live_evento ON scout_live (evento)"],
|
||||||
|
listRule: "@request.auth.id != ''",
|
||||||
|
viewRule: "@request.auth.id != ''",
|
||||||
|
createRule: "@request.auth.id != ''",
|
||||||
|
updateRule: "@request.auth.id != ''",
|
||||||
|
deleteRule: "@request.auth.id != ''",
|
||||||
|
});
|
||||||
|
app.save(scoutLive);
|
||||||
|
|
||||||
|
const scoutPartite = new Collection({
|
||||||
|
name: "scout_partite",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
// Id libero (come in Supabase, "id text PRIMARY KEY", generato dal client).
|
||||||
|
{ name: "id", type: "text", min: 1, max: 60 },
|
||||||
|
{
|
||||||
|
name: "evento",
|
||||||
|
type: "relation",
|
||||||
|
required: false,
|
||||||
|
collectionId: eventi.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{ name: "data", type: "date", required: true },
|
||||||
|
{ name: "avversario", type: "text", required: true },
|
||||||
|
{ name: "casa", type: "bool", required: false },
|
||||||
|
{ name: "set_nostri", type: "number", required: true, min: 0 },
|
||||||
|
{ name: "set_loro", type: "number", required: true, min: 0 },
|
||||||
|
{ name: "parziali", type: "json", required: false },
|
||||||
|
{ name: "azioni", type: "json", required: false },
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
listRule: "@request.auth.id != ''",
|
||||||
|
viewRule: "@request.auth.id != ''",
|
||||||
|
createRule: "@request.auth.id != ''",
|
||||||
|
updateRule: "@request.auth.id != ''",
|
||||||
|
deleteRule: "@request.auth.id != ''",
|
||||||
|
});
|
||||||
|
app.save(scoutPartite);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// push_subscriptions — dispositivi registrati per le notifiche push
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
const pushSubscriptions = new Collection({
|
||||||
|
name: "push_subscriptions",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "giocatore",
|
||||||
|
type: "relation",
|
||||||
|
required: false,
|
||||||
|
collectionId: giocatori.id,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{ name: "endpoint", type: "text", required: true },
|
||||||
|
{ name: "p256dh", type: "text", required: true },
|
||||||
|
{ name: "auth", type: "text", required: true },
|
||||||
|
{ name: "created", type: "autodate", onCreate: true },
|
||||||
|
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
"CREATE UNIQUE INDEX idx_push_subscriptions_endpoint ON push_subscriptions (endpoint)",
|
||||||
|
],
|
||||||
|
listRule: "@request.auth.id != ''",
|
||||||
|
viewRule: "@request.auth.id != ''",
|
||||||
|
createRule: "@request.auth.id != ''",
|
||||||
|
updateRule: "@request.auth.id != ''",
|
||||||
|
deleteRule: "@request.auth.id != ''",
|
||||||
|
});
|
||||||
|
app.save(pushSubscriptions);
|
||||||
|
},
|
||||||
|
(app) => {
|
||||||
|
const names = [
|
||||||
|
"push_subscriptions",
|
||||||
|
"scout_partite",
|
||||||
|
"scout_live",
|
||||||
|
"scout_sessioni",
|
||||||
|
"turni_palloni",
|
||||||
|
"badge_social_voti",
|
||||||
|
"pagelle_voti",
|
||||||
|
"mvp_voti",
|
||||||
|
"cacche_partita",
|
||||||
|
"risposte_presenze",
|
||||||
|
"eventi_app",
|
||||||
|
"avatar_giocatori",
|
||||||
|
"profili_giocatore",
|
||||||
|
"giocatori_squadra",
|
||||||
|
];
|
||||||
|
for (const name of names) {
|
||||||
|
const collection = app.findCollectionByNameOrId(name);
|
||||||
|
if (collection) app.delete(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = app.findCollectionByNameOrId("users");
|
||||||
|
users.fields.removeByName("role");
|
||||||
|
app.save(users);
|
||||||
|
},
|
||||||
|
);
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Ricrea da zero il PocketBase locale (equivalente di "npx supabase db reset").
|
||||||
|
# Ferma il container, azzera pb_data (le migration in pocketbase/pb_migrations/ vengono
|
||||||
|
# riapplicate all'avvio) e riavvia. Il superuser (da POCKETBASE_SUPERUSER_EMAIL/PASSWORD
|
||||||
|
# in .env) e il provider Google OAuth2 (da GOOGLE_OAUTH_CLIENT_ID/SECRET) si ricreano da
|
||||||
|
# soli all'avvio, senza passaggi manuali.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
docker compose -f docker-compose.pocketbase.yml down
|
||||||
|
# Alcuni file (avatar, thumbnail) li scrive il container con un utente diverso da quello
|
||||||
|
# host: un semplice "rm -rf" da host può fallire per permessi, quindi si pulisce da dentro
|
||||||
|
# un container usa-e-getta.
|
||||||
|
docker run --rm -v "$(pwd)/pocketbase/pb_data:/pb_data" alpine sh -c 'rm -rf /pb_data/* /pb_data/.[!.]* 2>/dev/null || true'
|
||||||
|
mkdir -p pocketbase/pb_data
|
||||||
|
touch pocketbase/pb_data/.gitkeep
|
||||||
|
docker compose -f docker-compose.pocketbase.yml up -d
|
||||||
|
|
||||||
|
echo "PocketBase locale ricreato. Dashboard: http://127.0.0.1:8090/_/"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { urlAvatar } from "@/lib/avatar-store";
|
import { urlAvatarDaRiga, useAvatarMap } from "@/lib/avatar-store";
|
||||||
|
|
||||||
export function Avatar({
|
export function Avatar({
|
||||||
id,
|
id,
|
||||||
@@ -19,8 +19,12 @@ export function Avatar({
|
|||||||
const [errore, setErrore] = useState(false);
|
const [errore, setErrore] = useState(false);
|
||||||
useEffect(() => setErrore(false), [id, bust]);
|
useEffect(() => setErrore(false), [id, bust]);
|
||||||
|
|
||||||
if (!errore) {
|
const { data: mappa } = useAvatarMap();
|
||||||
const src = bust ? `${urlAvatar(id)}?v=${bust}` : urlAvatar(id);
|
const riga = mappa?.[id];
|
||||||
|
|
||||||
|
if (riga && !errore) {
|
||||||
|
const base = urlAvatarDaRiga(riga);
|
||||||
|
const src = bust ? `${base}?v=${bust}` : base;
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
|
|||||||
@@ -34,15 +34,17 @@ function Intestazione({ titolo, completa }: { titolo: string; completa: boolean
|
|||||||
function CampoFile({
|
function CampoFile({
|
||||||
label,
|
label,
|
||||||
path,
|
path,
|
||||||
|
recordId,
|
||||||
sezione,
|
sezione,
|
||||||
giocatoreId,
|
giocatoreId,
|
||||||
onCaricato,
|
onCaricato,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
|
recordId: string | null;
|
||||||
sezione: SezioneFile;
|
sezione: SezioneFile;
|
||||||
giocatoreId: string;
|
giocatoreId: string;
|
||||||
onCaricato: (path: string) => Promise<void>;
|
onCaricato: (risultato: { id: string; filename: string }) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const input = useRef<HTMLInputElement>(null);
|
const input = useRef<HTMLInputElement>(null);
|
||||||
const [inCorso, setInCorso] = useState(false);
|
const [inCorso, setInCorso] = useState(false);
|
||||||
@@ -53,7 +55,7 @@ function CampoFile({
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
setInCorso(true);
|
setInCorso(true);
|
||||||
try {
|
try {
|
||||||
const nuovo = await caricaFile(giocatoreId, sezione, file, path);
|
const nuovo = await caricaFile(giocatoreId, sezione, file);
|
||||||
await onCaricato(nuovo);
|
await onCaricato(nuovo);
|
||||||
toast.success(`${label} caricato`);
|
toast.success(`${label} caricato`);
|
||||||
} catch (errore) {
|
} catch (errore) {
|
||||||
@@ -77,10 +79,10 @@ function CampoFile({
|
|||||||
<span className="truncate">{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-2">
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
{path ? (
|
{path && recordId ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void scaricaFile(path)}
|
onClick={() => void scaricaFile(recordId, path)}
|
||||||
className="premi rounded-xl bg-secondary p-2 text-muted-foreground"
|
className="premi rounded-xl bg-secondary p-2 text-muted-foreground"
|
||||||
aria-label={`Vedi ${label}`}
|
aria-label={`Vedi ${label}`}
|
||||||
>
|
>
|
||||||
@@ -287,8 +289,10 @@ export function ProfiloAmministrativo({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Un file caricato va persistito subito, insieme a quello che si stava scrivendo.
|
// Un file caricato va persistito subito, insieme a quello che si stava scrivendo.
|
||||||
const caricato = (campo: keyof Profilo) => async (path: string) =>
|
const caricato =
|
||||||
scrivi({ ...corrente, [campo]: path });
|
(campo: "documentoFrontePath" | "documentoRetroPath" | "certificatoPath" | "fotoPath") =>
|
||||||
|
async (risultato: { id: string; filename: string }) =>
|
||||||
|
scrivi({ ...corrente, id: risultato.id, [campo]: risultato.filename });
|
||||||
|
|
||||||
const corpo = (
|
const corpo = (
|
||||||
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
|
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
|
||||||
@@ -304,10 +308,6 @@ export function ProfiloAmministrativo({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CampiProfilo
|
<CampiProfilo
|
||||||
corrente={corrente}
|
corrente={corrente}
|
||||||
aggiorna={aggiorna}
|
aggiorna={aggiorna}
|
||||||
@@ -317,6 +317,7 @@ export function ProfiloAmministrativo({
|
|||||||
<CampoFile
|
<CampoFile
|
||||||
label="Foto fronte"
|
label="Foto fronte"
|
||||||
path={corrente.documentoFrontePath}
|
path={corrente.documentoFrontePath}
|
||||||
|
recordId={corrente.id}
|
||||||
sezione="documento-fronte"
|
sezione="documento-fronte"
|
||||||
giocatoreId={giocatoreId}
|
giocatoreId={giocatoreId}
|
||||||
onCaricato={caricato("documentoFrontePath")}
|
onCaricato={caricato("documentoFrontePath")}
|
||||||
@@ -324,6 +325,7 @@ export function ProfiloAmministrativo({
|
|||||||
<CampoFile
|
<CampoFile
|
||||||
label="Foto retro"
|
label="Foto retro"
|
||||||
path={corrente.documentoRetroPath}
|
path={corrente.documentoRetroPath}
|
||||||
|
recordId={corrente.id}
|
||||||
sezione="documento-retro"
|
sezione="documento-retro"
|
||||||
giocatoreId={giocatoreId}
|
giocatoreId={giocatoreId}
|
||||||
onCaricato={caricato("documentoRetroPath")}
|
onCaricato={caricato("documentoRetroPath")}
|
||||||
@@ -334,6 +336,7 @@ export function ProfiloAmministrativo({
|
|||||||
<CampoFile
|
<CampoFile
|
||||||
label="Certificato medico"
|
label="Certificato medico"
|
||||||
path={corrente.certificatoPath}
|
path={corrente.certificatoPath}
|
||||||
|
recordId={corrente.id}
|
||||||
sezione="certificato"
|
sezione="certificato"
|
||||||
giocatoreId={giocatoreId}
|
giocatoreId={giocatoreId}
|
||||||
onCaricato={caricato("certificatoPath")}
|
onCaricato={caricato("certificatoPath")}
|
||||||
@@ -345,6 +348,7 @@ export function ProfiloAmministrativo({
|
|||||||
<CampoFile
|
<CampoFile
|
||||||
label="Foto tessera"
|
label="Foto tessera"
|
||||||
path={corrente.fotoPath}
|
path={corrente.fotoPath}
|
||||||
|
recordId={corrente.id}
|
||||||
sezione="foto"
|
sezione="foto"
|
||||||
giocatoreId={giocatoreId}
|
giocatoreId={giocatoreId}
|
||||||
onCaricato={caricato("fotoPath")}
|
onCaricato={caricato("fotoPath")}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
// This file is auto-generated by Lovable. Do not modify it.
|
|
||||||
|
|
||||||
import { createLovableAuth } from "@lovable.dev/cloud-auth-js";
|
|
||||||
import { supabase } from "../supabase/client";
|
|
||||||
const lovableAuth = createLovableAuth();
|
|
||||||
|
|
||||||
type SignInOptions = {
|
|
||||||
redirect_uri?: string;
|
|
||||||
extraParams?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const lovable = {
|
|
||||||
auth: {
|
|
||||||
signInWithOAuth: async (
|
|
||||||
provider: "google" | "apple" | "microsoft" | "lovable",
|
|
||||||
opts?: SignInOptions,
|
|
||||||
) => {
|
|
||||||
const result = await lovableAuth.signInWithOAuth(provider, {
|
|
||||||
redirect_uri: opts?.redirect_uri ?? window.location.origin,
|
|
||||||
extraParams: {
|
|
||||||
...opts?.extraParams,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.redirected) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await supabase.auth.setSession(result.tokens);
|
|
||||||
} catch (e) {
|
|
||||||
return { error: e instanceof Error ? e : new Error(String(e)) };
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createMiddleware } from "@tanstack/react-start";
|
||||||
|
import { pb } from "./client";
|
||||||
|
|
||||||
|
// Deve essere registrato come `functionMiddleware` globale in `src/start.ts`; altrimenti
|
||||||
|
// il browser non allega il bearer token alle RPC delle server function.
|
||||||
|
export const attachPocketBaseAuth = createMiddleware({ type: "function" }).client(
|
||||||
|
async ({ next }) => {
|
||||||
|
const token = pb.authStore.token;
|
||||||
|
return next({
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Client server-side con privilegi superuser - bypassa le API rules delle collection
|
||||||
|
// (equivalente del service role Supabase, con una differenza: PocketBase non ha una
|
||||||
|
// chiave statica, l'autenticazione è email+password contro la collection _superusers e
|
||||||
|
// va rinnovata quando il token scade).
|
||||||
|
//
|
||||||
|
// SECURITY: usare solo per operazioni server-side fidate, mai esporre al client.
|
||||||
|
// Carica dentro gli handler server: const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
|
||||||
|
// L'import a livello di modulo è sicuro solo in altri moduli *.server.ts - le route e le
|
||||||
|
// server function finiscono nel bundle client.
|
||||||
|
import PocketBase from "pocketbase";
|
||||||
|
|
||||||
|
let _pb: PocketBase | undefined;
|
||||||
|
|
||||||
|
export async function pbAdmin(): Promise<PocketBase> {
|
||||||
|
const POCKETBASE_URL = process.env["POCKETBASE_URL"];
|
||||||
|
const POCKETBASE_SUPERUSER_EMAIL = process.env["POCKETBASE_SUPERUSER_EMAIL"];
|
||||||
|
const POCKETBASE_SUPERUSER_PASSWORD = process.env["POCKETBASE_SUPERUSER_PASSWORD"];
|
||||||
|
|
||||||
|
if (!POCKETBASE_URL || !POCKETBASE_SUPERUSER_EMAIL || !POCKETBASE_SUPERUSER_PASSWORD) {
|
||||||
|
const missing = [
|
||||||
|
...(!POCKETBASE_URL ? ["POCKETBASE_URL"] : []),
|
||||||
|
...(!POCKETBASE_SUPERUSER_EMAIL ? ["POCKETBASE_SUPERUSER_EMAIL"] : []),
|
||||||
|
...(!POCKETBASE_SUPERUSER_PASSWORD ? ["POCKETBASE_SUPERUSER_PASSWORD"] : []),
|
||||||
|
];
|
||||||
|
const message = `Variabili d'ambiente PocketBase mancanti: ${missing.join(", ")}.`;
|
||||||
|
console.error(`[PocketBase] ${message}`);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_pb) _pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
|
||||||
|
if (!_pb.authStore.isValid) {
|
||||||
|
await _pb
|
||||||
|
.collection("_superusers")
|
||||||
|
.authWithPassword(POCKETBASE_SUPERUSER_EMAIL, POCKETBASE_SUPERUSER_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _pb;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import PocketBase from "pocketbase";
|
||||||
|
|
||||||
|
function createPocketBaseClient(): PocketBase {
|
||||||
|
// Use import.meta.env for client-side (Vite build-time replacement)
|
||||||
|
// Fall back to process.env for SSR (server-side rendering)
|
||||||
|
const POCKETBASE_URL = import.meta.env["VITE_POCKETBASE_URL"] || process.env["POCKETBASE_URL"];
|
||||||
|
|
||||||
|
if (!POCKETBASE_URL) {
|
||||||
|
const message = "Manca la variabile d'ambiente POCKETBASE_URL (o VITE_POCKETBASE_URL).";
|
||||||
|
console.error(`[PocketBase] ${message}`);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PocketBase persiste la sessione in localStorage lato browser e in memoria lato SSR
|
||||||
|
// di default: nessuna configurazione aggiuntiva serve (a differenza del client Supabase).
|
||||||
|
return new PocketBase(POCKETBASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _pb: PocketBase | undefined;
|
||||||
|
|
||||||
|
// Import the PocketBase client like this:
|
||||||
|
// import { pb } from "@/integrations/pocketbase/client";
|
||||||
|
export const pb = new Proxy({} as PocketBase, {
|
||||||
|
get(_, prop, receiver) {
|
||||||
|
if (!_pb) _pb = createPocketBaseClient();
|
||||||
|
return Reflect.get(_pb, prop, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* PocketBase restituisce i campi data/ora come "AAAA-MM-GG HH:MM:SS.sssZ" (spazio, non
|
||||||
|
* "T"): `Date.parse` su questo formato non è garantito coerente su tutti i motori JS.
|
||||||
|
* Normalizza a ISO 8601 stretto prima di qualsiasi confronto/calcolo di date.
|
||||||
|
*/
|
||||||
|
export function pbISO(v: string): string {
|
||||||
|
return v.includes("T") ? v : v.replace(" ", "T");
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import PocketBase, { ClientResponseError, type RecordModel } from "pocketbase";
|
||||||
|
import { pb } from "./client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PocketBase non ha un upsert nativo. Per le collection con id significativo (es. `e1`,
|
||||||
|
* `g1`, dove l'id stesso è la chiave di unicità che prima era gestita da
|
||||||
|
* `onConflict: "id"`): aggiorna se il record esiste, altrimenti lo crea con quell'id.
|
||||||
|
*
|
||||||
|
* `client` di default è il client browser (`pb`): passare esplicitamente `pbAdmin()` nei
|
||||||
|
* moduli server-side che oggi usano `supabaseAdmin`.
|
||||||
|
*/
|
||||||
|
export async function upsertById<T = RecordModel>(
|
||||||
|
collection: string,
|
||||||
|
id: string,
|
||||||
|
dati: Record<string, unknown>,
|
||||||
|
client: PocketBase = pb,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await client.collection(collection).update<T>(id, dati);
|
||||||
|
} catch (errore) {
|
||||||
|
if (errore instanceof ClientResponseError && errore.status === 404) {
|
||||||
|
return await client.collection(collection).create<T>({ id, ...dati });
|
||||||
|
}
|
||||||
|
throw errore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per le collection dove l'unicità è su una combinazione di campi (es.
|
||||||
|
* `evento_id,giocatore_id`, prima gestita da un indice UNIQUE + `onConflict`) e l'id del
|
||||||
|
* record è generato da PocketBase, non ha significato applicativo: cerca il record che
|
||||||
|
* combacia col filtro e lo aggiorna, altrimenti ne crea uno nuovo.
|
||||||
|
*/
|
||||||
|
export async function upsertByFilter<T = RecordModel>(
|
||||||
|
collection: string,
|
||||||
|
filtro: string,
|
||||||
|
parametri: Record<string, unknown>,
|
||||||
|
dati: Record<string, unknown>,
|
||||||
|
client: PocketBase = pb,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const esistente = await client
|
||||||
|
.collection(collection)
|
||||||
|
.getFirstListItem<T>(client.filter(filtro, parametri));
|
||||||
|
return await client.collection(collection).update<T>((esistente as RecordModel).id, dati);
|
||||||
|
} catch (errore) {
|
||||||
|
if (errore instanceof ClientResponseError && errore.status === 404) {
|
||||||
|
return await client.collection(collection).create<T>(dati);
|
||||||
|
}
|
||||||
|
throw errore;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,18 +2,19 @@
|
|||||||
* Controllo di accesso per le route in `src/routes/api/public/` che inviano notifiche
|
* Controllo di accesso per le route in `src/routes/api/public/` che inviano notifiche
|
||||||
* a tutta la squadra (DD-024).
|
* a tutta la squadra (DD-024).
|
||||||
*
|
*
|
||||||
* Quelle route usano la service role e saltano la RLS: senza un controllo qui, chiunque
|
* Quelle route usano il superuser PocketBase e saltano le API rules: senza un controllo
|
||||||
* conosca l'URL può far suonare i telefoni di tutti. L'id di un evento non è un segreto —
|
* qui, chiunque conosca l'URL può far suonare i telefoni di tutti. L'id di un evento non
|
||||||
* è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non
|
* è un segreto — è un timestamp in base 36 e compare negli URL che la squadra si scambia —
|
||||||
* può fare da credenziale.
|
* quindi non può fare da credenziale.
|
||||||
*
|
*
|
||||||
* Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli
|
* 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
|
* 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ò
|
* sessione PocketBase e il campo `role` sul record utente. Torna `null` quando la
|
||||||
* proseguire, altrimenti la `Response` di rifiuto già pronta.
|
* richiesta può proseguire, altrimenti la `Response` di rifiuto già pronta.
|
||||||
*/
|
*/
|
||||||
|
import PocketBase from "pocketbase";
|
||||||
|
|
||||||
/** Il token della sessione Supabase, se la richiesta ne porta uno ben formato. */
|
/** Il token della sessione PocketBase, se la richiesta ne porta uno ben formato. */
|
||||||
function tokenDaRichiesta(request: Request): string | null {
|
function tokenDaRichiesta(request: Request): string | null {
|
||||||
const intestazione = request.headers.get("authorization");
|
const intestazione = request.headers.get("authorization");
|
||||||
if (!intestazione?.startsWith("Bearer ")) return null;
|
if (!intestazione?.startsWith("Bearer ")) return null;
|
||||||
@@ -31,19 +32,25 @@ export async function richiediAdmin(request: Request): Promise<Response | null>
|
|||||||
const token = tokenDaRichiesta(request);
|
const token = tokenDaRichiesta(request);
|
||||||
if (!token) return new Response("Autenticazione richiesta", { status: 401 });
|
if (!token) return new Response("Autenticazione richiesta", { status: 401 });
|
||||||
|
|
||||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
const POCKETBASE_URL = process.env["POCKETBASE_URL"];
|
||||||
|
if (!POCKETBASE_URL) {
|
||||||
|
console.error("[PocketBase] Manca la variabile d'ambiente POCKETBASE_URL.");
|
||||||
|
return new Response("Configurazione server non valida", { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
const { data: utente, error } = await supabaseAdmin.auth.getUser(token);
|
// Client "vuoto" con il token dell'utente allegato: authRefresh() lo valida e restituisce
|
||||||
if (error || !utente?.user) return new Response("Sessione non valida", { status: 401 });
|
// il record aggiornato in un'unica chiamata, ruolo incluso (con Supabase servivano due
|
||||||
|
// round trip: getUser() più una query separata su user_roles).
|
||||||
|
const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
pb.authStore.save(token, null);
|
||||||
|
|
||||||
// Stessa fonte di `src/lib/ruoli.ts`: i permessi stanno solo in `user_roles` (DD-011).
|
try {
|
||||||
const { data: ruolo } = await supabaseAdmin
|
const { record } = await pb.collection("users").authRefresh();
|
||||||
.from("user_roles")
|
if (record["role"] !== "admin") {
|
||||||
.select("role")
|
return new Response("Riservato agli amministratori", { status: 403 });
|
||||||
.eq("user_id", utente.user.id)
|
}
|
||||||
.eq("role", "admin")
|
return null;
|
||||||
.maybeSingle();
|
} catch {
|
||||||
|
return new Response("Sessione non valida", { status: 401 });
|
||||||
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-36
@@ -1,49 +1,39 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Session } from "@supabase/supabase-js";
|
import type { AuthRecord } from "pocketbase";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Autenticazione reale con Google (DD-011). Il login ha sostituito la selezione del
|
* Autenticazione reale con Google (DD-011). Il login ha sostituito la selezione del
|
||||||
* giocatore: senza sessione non si entra, e i permessi di amministrazione arrivano solo
|
* giocatore: senza sessione non si entra, e i permessi di amministrazione arrivano solo
|
||||||
* da `user_roles` (vedi `ruoli.ts`).
|
* dal campo `role` sul record utente (vedi `ruoli.ts`).
|
||||||
*/
|
*/
|
||||||
export function useSessione() {
|
export function useSessione() {
|
||||||
const [sessione, setSessione] = useState<Session | null>(null);
|
const [utente, setUtente] = useState<AuthRecord>(null);
|
||||||
const [pronta, setPronta] = useState(false);
|
const [pronta, setPronta] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let attivo = true;
|
// Il client PocketBase esplode alla costruzione se manca la variabile d'ambiente: qui
|
||||||
// Il client Supabase esplode alla costruzione se mancano le variabili d'ambiente:
|
// va assorbito, altrimenti la schermata di accesso non si disegna proprio e resta
|
||||||
// qui va assorbito, altrimenti la schermata di accesso non si disegna proprio e
|
// irraggiungibile anche la selezione del giocatore.
|
||||||
// resta irraggiungibile anche la selezione del giocatore.
|
|
||||||
try {
|
try {
|
||||||
supabase.auth
|
setUtente(pb.authStore.record);
|
||||||
.getSession()
|
|
||||||
.then(({ data }) => {
|
|
||||||
if (!attivo) return;
|
|
||||||
setSessione(data.session);
|
|
||||||
setPronta(true);
|
|
||||||
})
|
|
||||||
.catch(() => attivo && setPronta(true));
|
|
||||||
const { data } = supabase.auth.onAuthStateChange((_evento, nuova) => setSessione(nuova));
|
|
||||||
return () => {
|
|
||||||
attivo = false;
|
|
||||||
data.subscription.unsubscribe();
|
|
||||||
};
|
|
||||||
} catch (errore) {
|
|
||||||
console.error("[auth] Supabase non disponibile", errore);
|
|
||||||
setPronta(true);
|
setPronta(true);
|
||||||
return () => {
|
// A differenza di Supabase, l'authStore di PocketBase è già popolato in modo
|
||||||
attivo = false;
|
// sincrono all'avvio (legge localStorage nel costruttore): non serve una chiamata
|
||||||
};
|
// asincrona equivalente a getSession().
|
||||||
|
return pb.authStore.onChange((_token, record) => setUtente(record));
|
||||||
|
} catch (errore) {
|
||||||
|
console.error("[auth] PocketBase non disponibile", errore);
|
||||||
|
setPronta(true);
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessione,
|
sessione: utente,
|
||||||
pronta,
|
pronta,
|
||||||
utenteId: sessione?.user.id ?? null,
|
utenteId: utente?.id ?? null,
|
||||||
emailUtente: sessione?.user.email ?? null,
|
emailUtente: (utente?.["email"] as string | undefined) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,20 +43,19 @@ export function useSessione() {
|
|||||||
* un token già scaduto.
|
* un token già scaduto.
|
||||||
*/
|
*/
|
||||||
export async function intestazioniAutenticate(): Promise<Record<string, string>> {
|
export async function intestazioniAutenticate(): Promise<Record<string, string>> {
|
||||||
const { data } = await supabase.auth.getSession();
|
const token = pb.authStore.token;
|
||||||
const token = data.session?.access_token;
|
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function accediConGoogle(): Promise<void> {
|
export async function accediConGoogle(): Promise<void> {
|
||||||
const { error } = await supabase.auth.signInWithOAuth({
|
// createData copre il primo accesso: PocketBase crea il record utente al volo se non
|
||||||
|
// esiste ancora per questa identità Google, e il campo `role` è obbligatorio.
|
||||||
|
await pb.collection("users").authWithOAuth2({
|
||||||
provider: "google",
|
provider: "google",
|
||||||
options: { redirectTo: window.location.origin },
|
createData: { role: "user" },
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function esci(): Promise<void> {
|
export async function esci(): Promise<void> {
|
||||||
const { error } = await supabase.auth.signOut();
|
pb.authStore.clear();
|
||||||
if (error) throw error;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-31
@@ -1,41 +1,54 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
|
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||||
|
|
||||||
const BUCKET = "avatar-giocatori";
|
/**
|
||||||
const NOME_FILE = "avatar.jpg";
|
* Foto profilo pubbliche (M6 avatar-giocatori): collection separata da giocatori_squadra,
|
||||||
|
* chiunque autenticato può caricare/sostituire/eliminare l'avatar di chiunque (nessun
|
||||||
|
* controllo per-proprietario, come il vecchio bucket pubblico Supabase).
|
||||||
|
*/
|
||||||
|
type RigaAvatar = { id: string; giocatore: string; foto: string };
|
||||||
|
|
||||||
function percorso(id: string) {
|
export const AVATAR_KEY = ["avatar-giocatori"] as const;
|
||||||
return `${id}/${NOME_FILE}`;
|
|
||||||
|
async function fetchAvatarMap(): Promise<Record<string, RigaAvatar>> {
|
||||||
|
const righe = await pb.collection("avatar_giocatori").getFullList<RigaAvatar>();
|
||||||
|
const mappa: Record<string, RigaAvatar> = {};
|
||||||
|
for (const r of righe) if (r.foto) mappa[r.giocatore] = r;
|
||||||
|
return mappa;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** URL pubblico e stabile: il bucket è pubblico, nessuna richiesta di rete. */
|
/** Una lettura per sessione, condivisa da tutte le istanze di <Avatar>: react-query la
|
||||||
export function urlAvatar(id: string): string {
|
* deduplica anche se il componente compare decine di volte nella stessa pagina (rosa). */
|
||||||
return supabase.storage.from(BUCKET).getPublicUrl(percorso(id)).data.publicUrl;
|
export function useAvatarMap() {
|
||||||
|
return useQuery({ queryKey: AVATAR_KEY, queryFn: fetchAvatarMap, staleTime: 30 * 60_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const chiaveEsiste = (id: string) => ["avatar-esiste", id] as const;
|
/** URL pubblico e stabile: il campo non è protetto, nessuna richiesta di rete aggiuntiva. */
|
||||||
|
export function urlAvatarDaRiga(riga: RigaAvatar): string {
|
||||||
|
return pb.files.getURL({ id: riga.id, collectionName: "avatar_giocatori" }, riga.foto);
|
||||||
|
}
|
||||||
|
|
||||||
/** Solo per il proprio profilo: sapere se mostrare "rimuovi immagine". */
|
/** Solo per il proprio profilo: sapere se mostrare "rimuovi immagine". */
|
||||||
export function useAvatarEsiste(id: string | undefined) {
|
export function useAvatarEsiste(id: string | undefined) {
|
||||||
return useQuery({
|
const query = useAvatarMap();
|
||||||
queryKey: chiaveEsiste(id ?? ""),
|
return { ...query, data: id ? !!query.data?.[id] : false };
|
||||||
enabled: !!id,
|
|
||||||
staleTime: 60_000,
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data, error } = await supabase.storage.from(BUCKET).list(id!, { search: NOME_FILE });
|
|
||||||
if (error) throw error;
|
|
||||||
return (data ?? []).some((f) => f.name === NOME_FILE);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dopo un caricamento o una rimozione lo stato è noto: si scrive in cache invece di
|
* Dopo un caricamento o una rimozione lo stato è noto: si scrive in cache invece di
|
||||||
* rileggere l'elenco del bucket (una richiesta in meno per ogni cambio foto).
|
* rileggere l'elenco (una richiesta in meno per ogni cambio foto).
|
||||||
*/
|
*/
|
||||||
export function useImpostaAvatarEsiste() {
|
export function useImpostaAvatarEsiste() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return (id: string, esiste: boolean) => qc.setQueryData(chiaveEsiste(id), esiste);
|
return (id: string, riga: RigaAvatar | null) => {
|
||||||
|
qc.setQueryData<Record<string, RigaAvatar>>(AVATAR_KEY, (prec) => {
|
||||||
|
const nuova = { ...(prec ?? {}) };
|
||||||
|
if (riga) nuova[id] = riga;
|
||||||
|
else delete nuova[id];
|
||||||
|
return nuova;
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */
|
/** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */
|
||||||
@@ -77,17 +90,21 @@ function fileToBlob(file: File, size = 256): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ridimensiona, comprime e carica la foto profilo: sovrascrive quella precedente. */
|
/** Ridimensiona, comprime e carica la foto profilo: sovrascrive quella precedente. */
|
||||||
export async function caricaAvatar(id: string, file: File) {
|
export async function caricaAvatar(id: string, file: File): Promise<RigaAvatar> {
|
||||||
const blob = await fileToBlob(file);
|
const blob = await fileToBlob(file);
|
||||||
const { error } = await supabase.storage.from(BUCKET).upload(percorso(id), blob, {
|
const foto = new File([blob], "avatar.jpg", { type: "image/jpeg" });
|
||||||
contentType: "image/jpeg",
|
return upsertByFilter<RigaAvatar>(
|
||||||
upsert: true,
|
"avatar_giocatori",
|
||||||
cacheControl: "0",
|
"giocatore = {:giocatore}",
|
||||||
});
|
{ giocatore: id },
|
||||||
if (error) throw error;
|
{ giocatore: id, foto },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rimuoviAvatar(id: string) {
|
export async function rimuoviAvatar(id: string): Promise<void> {
|
||||||
const { error } = await supabase.storage.from(BUCKET).remove([percorso(id)]);
|
const esistente = await pb
|
||||||
if (error) throw error;
|
.collection("avatar_giocatori")
|
||||||
|
.getFirstListItem(pb.filter("giocatore = {:giocatore}", { giocatore: id }))
|
||||||
|
.catch(() => null);
|
||||||
|
if (esistente) await pb.collection("avatar_giocatori").delete(esistente.id);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-10
@@ -1,7 +1,8 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { HandHeart, Handshake, Laugh, Scale, Users } from "lucide-react";
|
import { HandHeart, Handshake, Laugh, Scale, Users } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
|
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||||
|
|
||||||
export type CategoriaSocial = {
|
export type CategoriaSocial = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -58,6 +59,14 @@ export type VotoSocial = {
|
|||||||
votato_nome: string;
|
votato_nome: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RigaSocialPocketBase = {
|
||||||
|
evento: string;
|
||||||
|
categoria: string;
|
||||||
|
votante: string;
|
||||||
|
votato: string;
|
||||||
|
votato_nome: string;
|
||||||
|
};
|
||||||
|
|
||||||
const CHIAVE = ["badge-social-voti"] as const;
|
const CHIAVE = ["badge-social-voti"] as const;
|
||||||
|
|
||||||
/** Tutti i voti social (poche righe): nessun polling, cache lunga. */
|
/** Tutti i voti social (poche righe): nessun polling, cache lunga. */
|
||||||
@@ -66,11 +75,14 @@ export function useVotiSocial() {
|
|||||||
queryKey: CHIAVE,
|
queryKey: CHIAVE,
|
||||||
staleTime: 10 * 60_000,
|
staleTime: 10 * 60_000,
|
||||||
queryFn: async (): Promise<VotoSocial[]> => {
|
queryFn: async (): Promise<VotoSocial[]> => {
|
||||||
const { data, error } = await supabase
|
const righe = await pb.collection("badge_social_voti").getFullList<RigaSocialPocketBase>();
|
||||||
.from("badge_social_voti")
|
return righe.map((r) => ({
|
||||||
.select("match_id, categoria, votante_id, votato_id, votato_nome");
|
match_id: r.evento,
|
||||||
if (error) throw error;
|
categoria: r.categoria,
|
||||||
return (data ?? []) as VotoSocial[];
|
votante_id: r.votante,
|
||||||
|
votato_id: r.votato,
|
||||||
|
votato_nome: r.votato_nome,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -79,10 +91,19 @@ export function useVotaSocial() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (voto: VotoSocial) => {
|
mutationFn: async (voto: VotoSocial) => {
|
||||||
const { error } = await supabase
|
// No autovoto e convocazione li valida l'hook voti_convocati.pb.js.
|
||||||
.from("badge_social_voti")
|
await upsertByFilter(
|
||||||
.upsert(voto, { onConflict: "match_id,categoria,votante_id" });
|
"badge_social_voti",
|
||||||
if (error) throw error;
|
"evento = {:evento} && categoria = {:categoria} && votante = {:votante}",
|
||||||
|
{ evento: voto.match_id, categoria: voto.categoria, votante: voto.votante_id },
|
||||||
|
{
|
||||||
|
evento: voto.match_id,
|
||||||
|
categoria: voto.categoria,
|
||||||
|
votante: voto.votante_id,
|
||||||
|
votato: voto.votato_id,
|
||||||
|
votato_nome: voto.votato_nome,
|
||||||
|
},
|
||||||
|
);
|
||||||
return voto;
|
return voto;
|
||||||
},
|
},
|
||||||
// Aggiornamento locale della cache: zero riletture.
|
// Aggiornamento locale della cache: zero riletture.
|
||||||
|
|||||||
+20
-10
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
|
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||||
import { oggiISO } from "./palloni-core";
|
import { oggiISO } from "./palloni-core";
|
||||||
|
|
||||||
/** Ora di apertura del sondaggio, il giorno stesso della partita. */
|
/** Ora di apertura del sondaggio, il giorno stesso della partita. */
|
||||||
@@ -48,6 +49,12 @@ export type RigaCacche = {
|
|||||||
quantita: number;
|
quantita: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RigaCacchePocketBase = {
|
||||||
|
evento: string;
|
||||||
|
giocatore: string;
|
||||||
|
quantita: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const CACCHE_KEY = ["cacche"] as const;
|
export const CACCHE_KEY = ["cacche"] as const;
|
||||||
|
|
||||||
export function useCacche() {
|
export function useCacche() {
|
||||||
@@ -55,11 +62,12 @@ export function useCacche() {
|
|||||||
queryKey: CACCHE_KEY,
|
queryKey: CACCHE_KEY,
|
||||||
staleTime: 10 * 60_000,
|
staleTime: 10 * 60_000,
|
||||||
queryFn: async (): Promise<RigaCacche[]> => {
|
queryFn: async (): Promise<RigaCacche[]> => {
|
||||||
const { data, error } = await supabase
|
const righe = await pb.collection("cacche_partita").getFullList<RigaCacchePocketBase>();
|
||||||
.from("cacche_partita")
|
return righe.map((r) => ({
|
||||||
.select("evento_id, giocatore_id, quantita");
|
evento_id: r.evento,
|
||||||
if (error) throw error;
|
giocatore_id: r.giocatore,
|
||||||
return (data ?? []) as RigaCacche[];
|
quantita: r.quantita,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { ...query, righe: query.data ?? [] };
|
return { ...query, righe: query.data ?? [] };
|
||||||
@@ -69,10 +77,12 @@ export function useSalvaCacche() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (riga: RigaCacche) => {
|
mutationFn: async (riga: RigaCacche) => {
|
||||||
const { error } = await supabase
|
await upsertByFilter(
|
||||||
.from("cacche_partita")
|
"cacche_partita",
|
||||||
.upsert(riga, { onConflict: "evento_id,giocatore_id" });
|
"evento = {:evento} && giocatore = {:giocatore}",
|
||||||
if (error) throw error;
|
{ evento: riga.evento_id, giocatore: riga.giocatore_id },
|
||||||
|
{ evento: riga.evento_id, giocatore: riga.giocatore_id, quantita: riga.quantita },
|
||||||
|
);
|
||||||
return riga;
|
return riga;
|
||||||
},
|
},
|
||||||
onSuccess: (riga) => {
|
onSuccess: (riga) => {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { daRiga, type Evento, type RigaEvento } from "./eventi";
|
import { daRiga, type Evento, type RigaEvento } from "./eventi";
|
||||||
|
|
||||||
const COLONNE =
|
|
||||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
|
|
||||||
|
|
||||||
/** Lettura eventi lato server (route API): stessa conversione del client. */
|
/** Lettura eventi lato server (route API): stessa conversione del client. */
|
||||||
export async function leggiEventi(): Promise<Evento[]> {
|
export async function leggiEventi(): Promise<Evento[]> {
|
||||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
|
||||||
const { data } = await supabaseAdmin.from("eventi_app").select(COLONNE).order("data");
|
const admin = await pbAdmin();
|
||||||
return ((data ?? []) as RigaEvento[]).map(daRiga);
|
const righe = await admin.collection("eventi_app").getFullList<RigaEvento>({ sort: "data" });
|
||||||
|
return righe.map(daRiga);
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-37
@@ -1,5 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
|
import { pbISO } from "@/integrations/pocketbase/formato";
|
||||||
|
import { upsertById } from "@/integrations/pocketbase/upsert";
|
||||||
import type { Giocatore } from "./crapp-data";
|
import type { Giocatore } from "./crapp-data";
|
||||||
|
|
||||||
export type EventoTipo = "partita" | "allenamento" | "evento" | "compleanno";
|
export type EventoTipo = "partita" | "allenamento" | "evento" | "compleanno";
|
||||||
@@ -25,6 +27,7 @@ export type Evento = {
|
|||||||
creatoIl?: string | undefined;
|
creatoIl?: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Riga così come la restituisce PocketBase. */
|
||||||
export type RigaEvento = {
|
export type RigaEvento = {
|
||||||
id: string;
|
id: string;
|
||||||
tipo: string;
|
tipo: string;
|
||||||
@@ -32,35 +35,37 @@ export type RigaEvento = {
|
|||||||
luogo: string;
|
luogo: string;
|
||||||
data: string;
|
data: string;
|
||||||
ora: string;
|
ora: string;
|
||||||
note: string | null;
|
note: string;
|
||||||
convocati: string[] | null;
|
convocati: string[];
|
||||||
campionato: boolean;
|
campionato: boolean;
|
||||||
casa: boolean | null;
|
casa: boolean;
|
||||||
pagelle_chiuse: boolean;
|
pagelle_chiuse: boolean;
|
||||||
creato_il?: string;
|
created?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** I campi "date" di PocketBase tornano come datetime completo: qui serve solo "YYYY-MM-DD". */
|
||||||
|
function soloData(v: string): string {
|
||||||
|
return v.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
||||||
export function daRiga(r: RigaEvento): Evento {
|
export function daRiga(r: RigaEvento): Evento {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
tipo: (r.tipo as EventoTipo) ?? "evento",
|
tipo: (r.tipo as EventoTipo) || "evento",
|
||||||
titolo: r.titolo,
|
titolo: r.titolo,
|
||||||
luogo: r.luogo ?? "",
|
luogo: r.luogo || "",
|
||||||
data: r.data,
|
data: soloData(r.data),
|
||||||
ora: r.ora ?? "",
|
ora: r.ora || "",
|
||||||
note: r.note ?? "",
|
note: r.note || "",
|
||||||
convocati: r.convocati ?? [],
|
convocati: r.convocati ?? [],
|
||||||
campionato: !!r.campionato,
|
campionato: !!r.campionato,
|
||||||
casa: r.casa ?? true,
|
casa: r.casa,
|
||||||
pagelleChiuse: !!r.pagelle_chiuse,
|
pagelleChiuse: !!r.pagelle_chiuse,
|
||||||
creatoIl: r.creato_il,
|
creatoIl: r.created ? pbISO(r.created) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLONNE =
|
|
||||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
|
|
||||||
|
|
||||||
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
|
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
|
||||||
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
|
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
|
||||||
|
|
||||||
@@ -79,9 +84,8 @@ export function daCategoria(c: CategoriaEvento): Pick<Evento, "tipo" | "campiona
|
|||||||
export const EVENTI_KEY = ["eventi"] as const;
|
export const EVENTI_KEY = ["eventi"] as const;
|
||||||
|
|
||||||
async function fetchEventi(): Promise<Evento[]> {
|
async function fetchEventi(): Promise<Evento[]> {
|
||||||
const { data, error } = await supabase.from("eventi_app").select(COLONNE).order("data");
|
const righe = await pb.collection("eventi_app").getFullList<RigaEvento>({ sort: "data" });
|
||||||
if (error) throw error;
|
return righe.map(daRiga);
|
||||||
return ((data ?? []) as RigaEvento[]).map(daRiga);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Una lettura per sessione: il calendario cambia raramente. */
|
/** Una lettura per sessione: il calendario cambia raramente. */
|
||||||
@@ -119,23 +123,18 @@ export function useSalvaEvento() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (evento: Evento) => {
|
mutationFn: async (evento: Evento) => {
|
||||||
const { error } = await supabase.from("eventi_app").upsert(
|
await upsertById("eventi_app", evento.id, {
|
||||||
{
|
tipo: evento.tipo,
|
||||||
id: evento.id,
|
titolo: evento.titolo,
|
||||||
tipo: evento.tipo,
|
luogo: evento.luogo,
|
||||||
titolo: evento.titolo,
|
data: evento.data,
|
||||||
luogo: evento.luogo,
|
ora: evento.ora,
|
||||||
data: evento.data,
|
note: evento.note,
|
||||||
ora: evento.ora,
|
convocati: evento.convocati,
|
||||||
note: evento.note,
|
campionato: evento.campionato,
|
||||||
convocati: evento.convocati,
|
casa: evento.casa,
|
||||||
campionato: evento.campionato,
|
pagelle_chiuse: evento.pagelleChiuse,
|
||||||
casa: evento.casa,
|
});
|
||||||
pagelle_chiuse: evento.pagelleChiuse,
|
|
||||||
},
|
|
||||||
{ onConflict: "id" },
|
|
||||||
);
|
|
||||||
if (error) throw error;
|
|
||||||
return evento;
|
return evento;
|
||||||
},
|
},
|
||||||
// Aggiornamento locale della cache: nessuna rilettura dal database.
|
// Aggiornamento locale della cache: nessuna rilettura dal database.
|
||||||
@@ -152,8 +151,7 @@ export function useEliminaEvento() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (id: string) => {
|
mutationFn: async (id: string) => {
|
||||||
const { error } = await supabase.from("eventi_app").delete().eq("id", id);
|
await pb.collection("eventi_app").delete(id);
|
||||||
if (error) throw error;
|
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
onSuccess: (id) => {
|
onSuccess: (id) => {
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
||||||
import {
|
import {
|
||||||
COLONNE_SQUADRA,
|
|
||||||
daRigaSquadra,
|
daRigaSquadra,
|
||||||
type GiocatoreSquadra,
|
type GiocatoreSquadra,
|
||||||
type RigaGiocatoreSquadra,
|
type RigaGiocatoreSquadra,
|
||||||
} from "./giocatori-squadra";
|
} from "./giocatori-squadra";
|
||||||
|
|
||||||
/** Lettura squadra lato server (route API): stessa conversione del client. */
|
/** Lettura squadra lato server (route API): usa il superuser per non dipendere dalla
|
||||||
|
* sessione del chiamante, stessa conversione del client. */
|
||||||
export async function leggiGiocatoriSquadra(): Promise<GiocatoreSquadra[]> {
|
export async function leggiGiocatoriSquadra(): Promise<GiocatoreSquadra[]> {
|
||||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
|
||||||
// `types.ts` non include ancora `giocatori_squadra` con le colonne di M8 (vedi client-nuove-tabelle.ts).
|
const admin = await pbAdmin();
|
||||||
const client = supabaseAdmin as unknown as SupabaseClient;
|
const righe = await admin.collection("giocatori_squadra").getFullList<RigaGiocatoreSquadra>({
|
||||||
const { data } = await client
|
sort: "cognome,nome",
|
||||||
.from("giocatori_squadra")
|
});
|
||||||
.select(COLONNE_SQUADRA)
|
return righe.map(daRigaSquadra);
|
||||||
.order("cognome")
|
|
||||||
.order("nome");
|
|
||||||
return ((data ?? []) as RigaGiocatoreSquadra[]).map(daRigaSquadra);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
import { dividiNome, giocatori } from "./crapp-data";
|
import { dividiNome, giocatori } from "./crapp-data";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,17 +20,18 @@ export type GiocatoreSquadra = {
|
|||||||
dataTessera: string | null;
|
dataTessera: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Riga così come la restituisce PocketBase: le relazioni/campi vuoti sono "", mai null. */
|
||||||
export type RigaGiocatoreSquadra = {
|
export type RigaGiocatoreSquadra = {
|
||||||
id: string;
|
id: string;
|
||||||
nome: string;
|
nome: string;
|
||||||
cognome: string;
|
cognome: string;
|
||||||
numero: number;
|
numero: number;
|
||||||
ruolo: string;
|
ruolo: string;
|
||||||
auth_user_id: string | null;
|
auth_user_id: string;
|
||||||
attivo: boolean;
|
attivo: boolean;
|
||||||
email: string | null;
|
email: string;
|
||||||
numero_tessera: string | null;
|
numero_tessera: string;
|
||||||
data_tessera: string | null;
|
data_tessera: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
|
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
|
||||||
@@ -76,10 +77,18 @@ export function slotPerEmail(
|
|||||||
return righe.find((g) => !g.authUserId && g.email?.trim().toLowerCase() === cercata) ?? null;
|
return righe.find((g) => !g.authUserId && g.email?.trim().toLowerCase() === cercata) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const COLONNE_SQUADRA =
|
/** "" -> null: PocketBase non ha un concetto di colonna NULL, i campi vuoti sono stringa vuota. */
|
||||||
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera";
|
function vuotoANull(v: string): string | null {
|
||||||
|
return v ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
/** I campi "date" di PocketBase tornano come datetime completo ("2026-01-01 00:00:00.000Z"):
|
||||||
|
* qui serve solo "YYYY-MM-DD" (input HTML type="date", confronti testuali con altre date). */
|
||||||
|
function soloData(v: string): string | null {
|
||||||
|
return v ? v.slice(0, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conversione riga PocketBase -> modello applicativo (riusabile anche lato server). */
|
||||||
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -87,22 +96,18 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
|||||||
cognome: r.cognome,
|
cognome: r.cognome,
|
||||||
numero: r.numero,
|
numero: r.numero,
|
||||||
ruolo: r.ruolo,
|
ruolo: r.ruolo,
|
||||||
authUserId: r.auth_user_id,
|
authUserId: vuotoANull(r.auth_user_id),
|
||||||
attivo: r.attivo,
|
attivo: r.attivo,
|
||||||
email: r.email,
|
email: vuotoANull(r.email),
|
||||||
numeroTessera: r.numero_tessera,
|
numeroTessera: vuotoANull(r.numero_tessera),
|
||||||
dataTessera: r.data_tessera,
|
dataTessera: soloData(r.data_tessera),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
|
async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
|
||||||
const { data, error } = await supabaseNuoveTabelle
|
const righe = await pb.collection("giocatori_squadra").getFullList<RigaGiocatoreSquadra>({
|
||||||
.from("giocatori_squadra")
|
sort: "cognome,nome",
|
||||||
.select(COLONNE_SQUADRA)
|
});
|
||||||
.order("cognome")
|
|
||||||
.order("nome");
|
|
||||||
if (error) throw error;
|
|
||||||
const righe = (data ?? []) as RigaGiocatoreSquadra[];
|
|
||||||
return righe.map(daRigaSquadra);
|
return righe.map(daRigaSquadra);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,8 +123,8 @@ export function useGiocatoriSquadra() {
|
|||||||
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo" | "email">;
|
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo" | "email">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
|
* Controlli che rispecchiano i vincoli della collection (`numero > 0`, campi obbligatori):
|
||||||
* meglio dirlo qui che far tornare un errore Postgres all'utente.
|
* meglio dirlo qui che far tornare un errore di PocketBase all'utente.
|
||||||
* Restituisce il messaggio da mostrare, oppure `null` se va bene.
|
* Restituisce il messaggio da mostrare, oppure `null` se va bene.
|
||||||
*/
|
*/
|
||||||
export function validaDatiSquadra(dati: DatiSquadra): string | null {
|
export function validaDatiSquadra(dati: DatiSquadra): string | null {
|
||||||
@@ -132,7 +137,7 @@ export function validaDatiSquadra(dati: DatiSquadra): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Il prossimo id libero nel formato `g<N>` richiesto dal vincolo della tabella. */
|
/** Il prossimo id libero nel formato `g<N>` richiesto dal vincolo della collection. */
|
||||||
export function prossimoIdGiocatore(righe: GiocatoreSquadra[]): string {
|
export function prossimoIdGiocatore(righe: GiocatoreSquadra[]): string {
|
||||||
const max = righe.reduce((acc, g) => {
|
const max = righe.reduce((acc, g) => {
|
||||||
const n = Number(g.id.slice(1));
|
const n = Number(g.id.slice(1));
|
||||||
@@ -150,7 +155,7 @@ export function numeroGiaUsato(
|
|||||||
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
|
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
|
/** Modifica dei dati squadra. Solo un admin passa le API rules di M1. */
|
||||||
export function useSalvaDatiSquadra() {
|
export function useSalvaDatiSquadra() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -160,14 +165,10 @@ export function useSalvaDatiSquadra() {
|
|||||||
cognome: input.dati.cognome.trim(),
|
cognome: input.dati.cognome.trim(),
|
||||||
numero: input.dati.numero,
|
numero: input.dati.numero,
|
||||||
ruolo: input.dati.ruolo.trim(),
|
ruolo: input.dati.ruolo.trim(),
|
||||||
email: input.dati.email?.trim() || null,
|
email: input.dati.email?.trim() || "",
|
||||||
};
|
};
|
||||||
const { error } = await supabaseNuoveTabelle
|
await pb.collection("giocatori_squadra").update(input.giocatoreId, dati);
|
||||||
.from("giocatori_squadra")
|
return { giocatoreId: input.giocatoreId, dati: { ...dati, email: dati.email || null } };
|
||||||
.update(dati)
|
|
||||||
.eq("id", input.giocatoreId);
|
|
||||||
if (error) throw error;
|
|
||||||
return { giocatoreId: input.giocatoreId, dati };
|
|
||||||
},
|
},
|
||||||
onSuccess: (input) => {
|
onSuccess: (input) => {
|
||||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||||
@@ -182,7 +183,7 @@ export type DatiTesseramento = Pick<GiocatoreSquadra, "numeroTessera" | "dataTes
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Registra numero e data della tessera CSI (roadmap v1.1). Campo puramente amministrativo:
|
* Registra numero e data della tessera CSI (roadmap v1.1). Campo puramente amministrativo:
|
||||||
* il trigger di M8 lo rende scrivibile solo da un admin, il giocatore non può autodichiararsi
|
* l'hook di M8 lo rende scrivibile solo da un admin, il giocatore non può autodichiararsi
|
||||||
* tesserato.
|
* tesserato.
|
||||||
*/
|
*/
|
||||||
export function useSalvaTesseramento() {
|
export function useSalvaTesseramento() {
|
||||||
@@ -190,17 +191,16 @@ export function useSalvaTesseramento() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { giocatoreId: string; dati: DatiTesseramento }) => {
|
mutationFn: async (input: { giocatoreId: string; dati: DatiTesseramento }) => {
|
||||||
const dati = {
|
const dati = {
|
||||||
numero_tessera: input.dati.numeroTessera?.trim() || null,
|
numero_tessera: input.dati.numeroTessera?.trim() || "",
|
||||||
data_tessera: input.dati.dataTessera || null,
|
data_tessera: input.dati.dataTessera || "",
|
||||||
};
|
};
|
||||||
const { error } = await supabaseNuoveTabelle
|
await pb.collection("giocatori_squadra").update(input.giocatoreId, dati);
|
||||||
.from("giocatori_squadra")
|
|
||||||
.update(dati)
|
|
||||||
.eq("id", input.giocatoreId);
|
|
||||||
if (error) throw error;
|
|
||||||
return {
|
return {
|
||||||
giocatoreId: input.giocatoreId,
|
giocatoreId: input.giocatoreId,
|
||||||
dati: { numeroTessera: dati.numero_tessera, dataTessera: dati.data_tessera },
|
dati: {
|
||||||
|
numeroTessera: vuotoANull(dati.numero_tessera),
|
||||||
|
dataTessera: vuotoANull(dati.data_tessera),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onSuccess: (input) => {
|
onSuccess: (input) => {
|
||||||
@@ -212,7 +212,7 @@ export function useSalvaTesseramento() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le policy di M1.
|
* Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le API rules di M1.
|
||||||
* L'id (`g<N>`) non è generato dal database: va calcolato con `prossimoIdGiocatore`
|
* L'id (`g<N>`) non è generato dal database: va calcolato con `prossimoIdGiocatore`
|
||||||
* prima di chiamare questa mutazione.
|
* prima di chiamare questa mutazione.
|
||||||
*/
|
*/
|
||||||
@@ -226,15 +226,20 @@ export function useAggiungiGiocatore() {
|
|||||||
cognome: input.dati.cognome.trim(),
|
cognome: input.dati.cognome.trim(),
|
||||||
numero: input.dati.numero,
|
numero: input.dati.numero,
|
||||||
ruolo: input.dati.ruolo.trim(),
|
ruolo: input.dati.ruolo.trim(),
|
||||||
email: input.dati.email?.trim() || null,
|
email: input.dati.email?.trim() || "",
|
||||||
|
attivo: true,
|
||||||
|
};
|
||||||
|
await pb.collection("giocatori_squadra").create(riga);
|
||||||
|
return {
|
||||||
|
...riga,
|
||||||
|
email: riga.email || null,
|
||||||
|
authUserId: null,
|
||||||
|
numeroTessera: null,
|
||||||
|
dataTessera: null,
|
||||||
};
|
};
|
||||||
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 };
|
|
||||||
},
|
},
|
||||||
// Aggiornamento locale della cache: nessuna rilettura, stesso ordine della query
|
// Aggiornamento locale della cache: nessuna rilettura, stesso ordine della query
|
||||||
// (`.order("cognome").order("nome")`).
|
// (sort "cognome,nome").
|
||||||
onSuccess: (nuovo) => {
|
onSuccess: (nuovo) => {
|
||||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||||
[...(prec ?? []), nuovo].sort(
|
[...(prec ?? []), nuovo].sort(
|
||||||
@@ -253,11 +258,7 @@ export function useImpostaAttivo() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { giocatoreId: string; attivo: boolean }) => {
|
mutationFn: async (input: { giocatoreId: string; attivo: boolean }) => {
|
||||||
const { error } = await supabaseNuoveTabelle
|
await pb.collection("giocatori_squadra").update(input.giocatoreId, { attivo: input.attivo });
|
||||||
.from("giocatori_squadra")
|
|
||||||
.update({ attivo: input.attivo })
|
|
||||||
.eq("id", input.giocatoreId);
|
|
||||||
if (error) throw error;
|
|
||||||
return input;
|
return input;
|
||||||
},
|
},
|
||||||
onSuccess: (input) => {
|
onSuccess: (input) => {
|
||||||
@@ -276,11 +277,7 @@ export function useScollegaAccount() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (giocatoreId: string) => {
|
mutationFn: async (giocatoreId: string) => {
|
||||||
const { error } = await supabaseNuoveTabelle
|
await pb.collection("giocatori_squadra").update(giocatoreId, { auth_user_id: "" });
|
||||||
.from("giocatori_squadra")
|
|
||||||
.update({ auth_user_id: null })
|
|
||||||
.eq("id", giocatoreId);
|
|
||||||
if (error) throw error;
|
|
||||||
return giocatoreId;
|
return giocatoreId;
|
||||||
},
|
},
|
||||||
onSuccess: (giocatoreId) => {
|
onSuccess: (giocatoreId) => {
|
||||||
@@ -292,20 +289,17 @@ export function useScollegaAccount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collega l'account al giocatore scelto. Il trigger di M1 accetta l'operazione solo se
|
* Collega l'account al giocatore scelto. L'hook giocatori_squadra_claim.pb.js accetta
|
||||||
* lo slot è libero e se nessun altro campo cambia (DD-016 regola 2): il vincolo vive nel
|
* l'operazione solo se lo slot è libero, l'email combacia e nessun altro campo cambia
|
||||||
* database, non qui.
|
* (DD-016 regola 2): il vincolo vive nel database, non qui.
|
||||||
*/
|
*/
|
||||||
export function useCollegaGiocatore() {
|
export function useCollegaGiocatore() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { giocatoreId: string; utenteId: string }) => {
|
mutationFn: async (input: { giocatoreId: string; utenteId: string }) => {
|
||||||
const { error } = await supabaseNuoveTabelle
|
await pb
|
||||||
.from("giocatori_squadra")
|
.collection("giocatori_squadra")
|
||||||
.update({ auth_user_id: input.utenteId })
|
.update(input.giocatoreId, { auth_user_id: input.utenteId });
|
||||||
.eq("id", input.giocatoreId)
|
|
||||||
.is("auth_user_id", null);
|
|
||||||
if (error) throw error;
|
|
||||||
return input;
|
return input;
|
||||||
},
|
},
|
||||||
onSuccess: (input) => {
|
onSuccess: (input) => {
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
type LovableErrorOptions = {
|
|
||||||
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
|
|
||||||
handled?: boolean;
|
|
||||||
severity?: "error" | "warning" | "info";
|
|
||||||
};
|
|
||||||
|
|
||||||
type LovableEvents = {
|
|
||||||
captureException?: (
|
|
||||||
error: unknown,
|
|
||||||
context?: Record<string, unknown>,
|
|
||||||
options?: LovableErrorOptions,
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__lovableEvents?: LovableEvents;
|
|
||||||
__lovableReportRuntimeError?: (payload: {
|
|
||||||
message: string;
|
|
||||||
stack?: string;
|
|
||||||
filename?: string;
|
|
||||||
}) => void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
window.__lovableEvents?.captureException?.(
|
|
||||||
error,
|
|
||||||
{
|
|
||||||
source: "react_error_boundary",
|
|
||||||
route: window.location.pathname,
|
|
||||||
...context,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mechanism: "react_error_boundary",
|
|
||||||
handled: false,
|
|
||||||
severity: "error",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
|
|
||||||
// editor's telemetry never sees them. Forward to lovable.js's reporting hook,
|
|
||||||
// which is present only inside the editor preview.
|
|
||||||
// Loaders and server fns commonly throw a raw Response; String(it) is the
|
|
||||||
// opaque "[object Response]", so pull out the status and URL instead.
|
|
||||||
const message =
|
|
||||||
error instanceof Response
|
|
||||||
? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: String(error);
|
|
||||||
const stack = error instanceof Error ? error.stack : undefined;
|
|
||||||
window.__lovableReportRuntimeError?.({
|
|
||||||
message,
|
|
||||||
...(stack !== undefined && { stack }),
|
|
||||||
filename: window.location.pathname,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+28
-10
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
|
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||||
|
|
||||||
export type VotoMvp = {
|
export type VotoMvp = {
|
||||||
match_id: string;
|
match_id: string;
|
||||||
@@ -8,6 +9,13 @@ export type VotoMvp = {
|
|||||||
votato_nome: string;
|
votato_nome: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RigaMvpPocketBase = {
|
||||||
|
evento: string;
|
||||||
|
votante: string;
|
||||||
|
votato: string;
|
||||||
|
votato_nome: string;
|
||||||
|
};
|
||||||
|
|
||||||
const CHIAVE = ["mvp-voti"] as const;
|
const CHIAVE = ["mvp-voti"] as const;
|
||||||
|
|
||||||
/** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
|
/** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
|
||||||
@@ -27,11 +35,13 @@ export function useVotiMvp() {
|
|||||||
queryKey: CHIAVE,
|
queryKey: CHIAVE,
|
||||||
staleTime: 10 * 60_000,
|
staleTime: 10 * 60_000,
|
||||||
queryFn: async (): Promise<VotoMvp[]> => {
|
queryFn: async (): Promise<VotoMvp[]> => {
|
||||||
const { data, error } = await supabase
|
const righe = await pb.collection("mvp_voti").getFullList<RigaMvpPocketBase>();
|
||||||
.from("mvp_voti")
|
return righe.map((r) => ({
|
||||||
.select("match_id, votante_id, votato_id, votato_nome");
|
match_id: r.evento,
|
||||||
if (error) throw error;
|
votante_id: r.votante,
|
||||||
return (data ?? []) as VotoMvp[];
|
votato_id: r.votato,
|
||||||
|
votato_nome: r.votato_nome,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -40,10 +50,18 @@ export function useVotaMvp() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (voto: VotoMvp) => {
|
mutationFn: async (voto: VotoMvp) => {
|
||||||
const { error } = await supabase
|
// No autovoto e convocazione li valida l'hook voti_convocati.pb.js.
|
||||||
.from("mvp_voti")
|
await upsertByFilter(
|
||||||
.upsert(voto, { onConflict: "match_id,votante_id" });
|
"mvp_voti",
|
||||||
if (error) throw error;
|
"evento = {:evento} && votante = {:votante}",
|
||||||
|
{ evento: voto.match_id, votante: voto.votante_id },
|
||||||
|
{
|
||||||
|
evento: voto.match_id,
|
||||||
|
votante: voto.votante_id,
|
||||||
|
votato: voto.votato_id,
|
||||||
|
votato_nome: voto.votato_nome,
|
||||||
|
},
|
||||||
|
);
|
||||||
return voto;
|
return voto;
|
||||||
},
|
},
|
||||||
// Aggiorna la cache localmente: nessuna rilettura dal database.
|
// Aggiorna la cache localmente: nessuna rilettura dal database.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { intestazioniAutenticate } from "./auth";
|
||||||
|
|
||||||
|
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
|
||||||
|
|
||||||
|
async function fetchNotificheAttive(): Promise<Set<string>> {
|
||||||
|
const res = await fetch("/api/public/notifiche-attive", {
|
||||||
|
headers: await intestazioniAutenticate(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Impossibile leggere le notifiche attive");
|
||||||
|
const { giocatoreIds } = (await res.json()) as { giocatoreIds: string[] };
|
||||||
|
return new Set(giocatoreIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insieme degli id giocatore con almeno un dispositivo iscritto alle notifiche push. */
|
||||||
|
export function useNotificheAttive() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: NOTIFICHE_ATTIVE_KEY,
|
||||||
|
queryFn: fetchNotificheAttive,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Id giocatore distinti tra le righe di `push_subscriptions` (una riga per dispositivo). */
|
||||||
|
export function idsConNotificheAttive(righe: Array<{ giocatore_id: string }>): string[] {
|
||||||
|
return [...new Set(righe.map((r) => r.giocatore_id))];
|
||||||
|
}
|
||||||
+28
-10
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
|
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||||
import { VOTI_MINIMI_PAGELLA } from "./badges";
|
import { VOTI_MINIMI_PAGELLA } from "./badges";
|
||||||
|
|
||||||
/** Voto anonimo da 1 a 10 dato a un compagno per una partita. */
|
/** Voto anonimo da 1 a 10 dato a un compagno per una partita. */
|
||||||
@@ -10,6 +11,13 @@ export type VotoPagella = {
|
|||||||
voto: number;
|
voto: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RigaPagellaPocketBase = {
|
||||||
|
evento: string;
|
||||||
|
votante: string;
|
||||||
|
votato: string;
|
||||||
|
voto: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const PAGELLE_KEY = ["pagelle"] as const;
|
export const PAGELLE_KEY = ["pagelle"] as const;
|
||||||
|
|
||||||
/** Poche righe per stagione: una lettura per sessione basta. */
|
/** Poche righe per stagione: una lettura per sessione basta. */
|
||||||
@@ -18,11 +26,13 @@ export function usePagelle() {
|
|||||||
queryKey: PAGELLE_KEY,
|
queryKey: PAGELLE_KEY,
|
||||||
staleTime: 10 * 60_000,
|
staleTime: 10 * 60_000,
|
||||||
queryFn: async (): Promise<VotoPagella[]> => {
|
queryFn: async (): Promise<VotoPagella[]> => {
|
||||||
const { data, error } = await supabase
|
const righe = await pb.collection("pagelle_voti").getFullList<RigaPagellaPocketBase>();
|
||||||
.from("pagelle_voti")
|
return righe.map((r) => ({
|
||||||
.select("match_id, votante_id, votato_id, voto");
|
match_id: r.evento,
|
||||||
if (error) throw error;
|
votante_id: r.votante,
|
||||||
return (data ?? []) as VotoPagella[];
|
votato_id: r.votato,
|
||||||
|
voto: r.voto,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { ...query, voti: query.data ?? [] };
|
return { ...query, voti: query.data ?? [] };
|
||||||
@@ -32,10 +42,18 @@ export function useVotaPagella() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (voto: VotoPagella) => {
|
mutationFn: async (voto: VotoPagella) => {
|
||||||
const { error } = await supabase
|
// Convocazione e "pagelle non chiuse" li valida l'hook voti_convocati.pb.js.
|
||||||
.from("pagelle_voti")
|
await upsertByFilter(
|
||||||
.upsert(voto, { onConflict: "match_id,votante_id,votato_id" });
|
"pagelle_voti",
|
||||||
if (error) throw error;
|
"evento = {:evento} && votante = {:votante} && votato = {:votato}",
|
||||||
|
{ evento: voto.match_id, votante: voto.votante_id, votato: voto.votato_id },
|
||||||
|
{
|
||||||
|
evento: voto.match_id,
|
||||||
|
votante: voto.votante_id,
|
||||||
|
votato: voto.votato_id,
|
||||||
|
voto: voto.voto,
|
||||||
|
},
|
||||||
|
);
|
||||||
return voto;
|
return voto;
|
||||||
},
|
},
|
||||||
// Cache aggiornata localmente: nessuna rilettura.
|
// Cache aggiornata localmente: nessuna rilettura.
|
||||||
|
|||||||
+14
-16
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
|
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||||
import { completaTurni } from "./palloni-core";
|
import { completaTurni } from "./palloni-core";
|
||||||
import { useEventi } from "./eventi";
|
import { useEventi } from "./eventi";
|
||||||
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||||
@@ -7,18 +8,15 @@ import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
|||||||
export const TURNI_KEY = ["turni-palloni"] as const;
|
export const TURNI_KEY = ["turni-palloni"] as const;
|
||||||
|
|
||||||
type RigaTurno = {
|
type RigaTurno = {
|
||||||
evento_id: string;
|
evento: string;
|
||||||
giocatore_id: string;
|
giocatore: string;
|
||||||
aggiornato_da: string | null;
|
aggiornato_da: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchTurni(): Promise<Record<string, string>> {
|
async function fetchTurni(): Promise<Record<string, string>> {
|
||||||
const { data, error } = await supabase
|
const righe = await pb.collection("turni_palloni").getFullList<RigaTurno>();
|
||||||
.from("turni_palloni")
|
|
||||||
.select("evento_id, giocatore_id, aggiornato_da");
|
|
||||||
if (error) throw error;
|
|
||||||
const mappa: Record<string, string> = {};
|
const mappa: Record<string, string> = {};
|
||||||
for (const riga of (data ?? []) as RigaTurno[]) mappa[riga.evento_id] = riga.giocatore_id;
|
for (const riga of righe) mappa[riga.evento] = riga.giocatore;
|
||||||
return mappa;
|
return mappa;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,16 +35,16 @@ export function useAssegnaTurno() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { eventoId: string; giocatoreId: string; da: string | null }) => {
|
mutationFn: async (input: { eventoId: string; giocatoreId: string; da: string | null }) => {
|
||||||
const { error } = await supabase.from("turni_palloni").upsert(
|
await upsertByFilter(
|
||||||
|
"turni_palloni",
|
||||||
|
"evento = {:evento}",
|
||||||
|
{ evento: input.eventoId },
|
||||||
{
|
{
|
||||||
evento_id: input.eventoId,
|
evento: input.eventoId,
|
||||||
giocatore_id: input.giocatoreId,
|
giocatore: input.giocatoreId,
|
||||||
aggiornato_da: input.da,
|
aggiornato_da: input.da ?? "",
|
||||||
aggiornato_il: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
{ onConflict: "evento_id" },
|
|
||||||
);
|
);
|
||||||
if (error) throw error;
|
|
||||||
return input;
|
return input;
|
||||||
},
|
},
|
||||||
// Scrittura unica + aggiornamento cache locale, senza rilettura.
|
// Scrittura unica + aggiornamento cache locale, senza rilettura.
|
||||||
|
|||||||
+32
-24
@@ -1,5 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
|
import { pbISO } from "@/integrations/pocketbase/formato";
|
||||||
|
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||||
import type { Stato } from "./crapp-data";
|
import type { Stato } from "./crapp-data";
|
||||||
import type { Evento } from "./eventi";
|
import type { Evento } from "./eventi";
|
||||||
import { aggiornaSerie } from "./serie";
|
import { aggiornaSerie } from "./serie";
|
||||||
@@ -13,6 +15,14 @@ export type MappaPresenze = Record<string, Record<string, Stato>>;
|
|||||||
/** eventoId -> giocatoreId -> istante della prima risposta (ISO). */
|
/** eventoId -> giocatoreId -> istante della prima risposta (ISO). */
|
||||||
export type MappaTempiRisposta = Record<string, Record<string, string>>;
|
export type MappaTempiRisposta = Record<string, Record<string, string>>;
|
||||||
|
|
||||||
|
type RigaPresenza = {
|
||||||
|
id: string;
|
||||||
|
evento: string;
|
||||||
|
giocatore: string;
|
||||||
|
stato: string;
|
||||||
|
risposto_il: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** Allenamenti e partite CrAPP già passati, che contano per le statistiche di presenza. */
|
/** Allenamenti e partite CrAPP già passati, che contano per le statistiche di presenza. */
|
||||||
function eventiContanoPresenze(
|
function eventiContanoPresenze(
|
||||||
eventi: Evento[],
|
eventi: Evento[],
|
||||||
@@ -158,15 +168,12 @@ function serieSu(
|
|||||||
type LetturaPresenze = { presenze: MappaPresenze; tempi: MappaTempiRisposta };
|
type LetturaPresenze = { presenze: MappaPresenze; tempi: MappaTempiRisposta };
|
||||||
|
|
||||||
async function fetchPresenze(): Promise<LetturaPresenze> {
|
async function fetchPresenze(): Promise<LetturaPresenze> {
|
||||||
const { data, error } = await supabase
|
const righe = await pb.collection("risposte_presenze").getFullList<RigaPresenza>();
|
||||||
.from("risposte_presenze")
|
|
||||||
.select("evento_id, giocatore_id, stato, risposto_il");
|
|
||||||
if (error) throw error;
|
|
||||||
const presenze: MappaPresenze = {};
|
const presenze: MappaPresenze = {};
|
||||||
const tempi: MappaTempiRisposta = {};
|
const tempi: MappaTempiRisposta = {};
|
||||||
for (const riga of data ?? []) {
|
for (const riga of righe) {
|
||||||
(presenze[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato;
|
(presenze[riga.evento] ??= {})[riga.giocatore] = riga.stato as Stato;
|
||||||
(tempi[riga.evento_id] ??= {})[riga.giocatore_id] = riga.risposto_il;
|
(tempi[riga.evento] ??= {})[riga.giocatore] = pbISO(riga.risposto_il);
|
||||||
}
|
}
|
||||||
return { presenze, tempi };
|
return { presenze, tempi };
|
||||||
}
|
}
|
||||||
@@ -188,7 +195,7 @@ export function usePresenzeEvento(eventoId: string) {
|
|||||||
* Due dettagli non sono cosmetici e non vanno persi (vedi `docs/modules/serie-presenze.md`):
|
* Due dettagli non sono cosmetici e non vanno persi (vedi `docs/modules/serie-presenze.md`):
|
||||||
*
|
*
|
||||||
* - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il`
|
* - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il`
|
||||||
* non viene inviato sull'upsert e un trigger lo congela: è la prima risposta, non l'ultima,
|
* non viene inviato sulla scrittura e un hook lo congela: è la prima risposta, non l'ultima,
|
||||||
* e un ripensamento non deve far ripartire il cronometro della serie "Conferme 24h";
|
* e un ripensamento non deve far ripartire il cronometro della serie "Conferme 24h";
|
||||||
* - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore
|
* - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore
|
||||||
* risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta.
|
* risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta.
|
||||||
@@ -219,23 +226,24 @@ export function useSalvaPresenza() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { eventoId: string; giocatoreId: string; stato: Stato | null }) => {
|
mutationFn: async (input: { eventoId: string; giocatoreId: string; stato: Stato | null }) => {
|
||||||
if (input.stato === null) {
|
if (input.stato === null) {
|
||||||
const { error } = await supabase
|
const esistente = await pb
|
||||||
.from("risposte_presenze")
|
.collection("risposte_presenze")
|
||||||
.delete()
|
.getFirstListItem(
|
||||||
.eq("evento_id", input.eventoId)
|
pb.filter("evento = {:evento} && giocatore = {:giocatore}", {
|
||||||
.eq("giocatore_id", input.giocatoreId);
|
evento: input.eventoId,
|
||||||
if (error) throw error;
|
giocatore: input.giocatoreId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
if (esistente) await pb.collection("risposte_presenze").delete(esistente.id);
|
||||||
} else {
|
} else {
|
||||||
const { error } = await supabase.from("risposte_presenze").upsert(
|
// risposto_il NON va inviato: lo valorizza/congela l'hook risposte_presenze_immutabili.pb.js.
|
||||||
{
|
await upsertByFilter(
|
||||||
evento_id: input.eventoId,
|
"risposte_presenze",
|
||||||
giocatore_id: input.giocatoreId,
|
"evento = {:evento} && giocatore = {:giocatore}",
|
||||||
stato: input.stato,
|
{ evento: input.eventoId, giocatore: input.giocatoreId },
|
||||||
aggiornato_il: new Date().toISOString(),
|
{ evento: input.eventoId, giocatore: input.giocatoreId, stato: input.stato },
|
||||||
},
|
|
||||||
{ onConflict: "evento_id,giocatore_id" },
|
|
||||||
);
|
);
|
||||||
if (error) throw error;
|
|
||||||
}
|
}
|
||||||
return input;
|
return input;
|
||||||
},
|
},
|
||||||
|
|||||||
+7
-72
@@ -2,10 +2,14 @@ import { rigaCsv } from "./scout-export";
|
|||||||
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
|
* Profilo amministrativo di un giocatore (DD-016). I file veri sono campi file su
|
||||||
* `profili-giocatore`: qui viaggiano solo i path.
|
* PocketBase (`profili_giocatore`, protetti — mai URL pubblici): qui viaggia il nome del
|
||||||
|
* file caricato (usato anche come semplice marcatore "presente/assente"). `id` è l'id del
|
||||||
|
* record PocketBase (non quello del giocatore): serve a costruire l'URL dei file, `null`
|
||||||
|
* finché il profilo non è mai stato salvato.
|
||||||
*/
|
*/
|
||||||
export type Profilo = {
|
export type Profilo = {
|
||||||
|
id: string | null;
|
||||||
giocatoreId: string;
|
giocatoreId: string;
|
||||||
dataNascita: string | null;
|
dataNascita: string | null;
|
||||||
luogoNascita: string | null;
|
luogoNascita: string | null;
|
||||||
@@ -24,30 +28,9 @@ export type Profilo = {
|
|||||||
fotoPath: string | null;
|
fotoPath: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RigaProfilo = {
|
|
||||||
giocatore_id: string;
|
|
||||||
data_nascita: string | null;
|
|
||||||
luogo_nascita: string | null;
|
|
||||||
indirizzo: string | null;
|
|
||||||
telefono: string | null;
|
|
||||||
email: string | null;
|
|
||||||
documento_tipo: string | null;
|
|
||||||
documento_numero: string | null;
|
|
||||||
documento_rilasciato_da: string | null;
|
|
||||||
documento_emissione: string | null;
|
|
||||||
documento_scadenza: string | null;
|
|
||||||
documento_fronte_path: string | null;
|
|
||||||
documento_retro_path: string | null;
|
|
||||||
certificato_scadenza: string | null;
|
|
||||||
certificato_path: string | null;
|
|
||||||
foto_path: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const COLONNE_PROFILO =
|
|
||||||
"giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email, documento_tipo, documento_numero, documento_rilasciato_da, documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path, certificato_scadenza, certificato_path, foto_path";
|
|
||||||
|
|
||||||
export function profiloVuoto(giocatoreId: string): Profilo {
|
export function profiloVuoto(giocatoreId: string): Profilo {
|
||||||
return {
|
return {
|
||||||
|
id: null,
|
||||||
giocatoreId,
|
giocatoreId,
|
||||||
dataNascita: null,
|
dataNascita: null,
|
||||||
luogoNascita: null,
|
luogoNascita: null,
|
||||||
@@ -67,54 +50,6 @@ export function profiloVuoto(giocatoreId: string): Profilo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function daRigaProfilo(r: RigaProfilo): Profilo {
|
|
||||||
return {
|
|
||||||
giocatoreId: r.giocatore_id,
|
|
||||||
dataNascita: r.data_nascita,
|
|
||||||
luogoNascita: r.luogo_nascita,
|
|
||||||
indirizzo: r.indirizzo,
|
|
||||||
telefono: r.telefono,
|
|
||||||
email: r.email,
|
|
||||||
documentoTipo: r.documento_tipo,
|
|
||||||
documentoNumero: r.documento_numero,
|
|
||||||
documentoRilasciatoDa: r.documento_rilasciato_da,
|
|
||||||
documentoEmissione: r.documento_emissione,
|
|
||||||
documentoScadenza: r.documento_scadenza,
|
|
||||||
documentoFrontePath: r.documento_fronte_path,
|
|
||||||
documentoRetroPath: r.documento_retro_path,
|
|
||||||
certificatoScadenza: r.certificato_scadenza,
|
|
||||||
certificatoPath: r.certificato_path,
|
|
||||||
fotoPath: r.foto_path,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** I campi vuoti tornano al database come NULL, non come stringa vuota. */
|
|
||||||
function oNull(valore: string | null): string | null {
|
|
||||||
const pulito = valore?.trim();
|
|
||||||
return pulito ? pulito : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function aRigaProfilo(p: Profilo): RigaProfilo {
|
|
||||||
return {
|
|
||||||
giocatore_id: p.giocatoreId,
|
|
||||||
data_nascita: oNull(p.dataNascita),
|
|
||||||
luogo_nascita: oNull(p.luogoNascita),
|
|
||||||
indirizzo: oNull(p.indirizzo),
|
|
||||||
telefono: oNull(p.telefono),
|
|
||||||
email: oNull(p.email),
|
|
||||||
documento_tipo: oNull(p.documentoTipo),
|
|
||||||
documento_numero: oNull(p.documentoNumero),
|
|
||||||
documento_rilasciato_da: oNull(p.documentoRilasciatoDa),
|
|
||||||
documento_emissione: oNull(p.documentoEmissione),
|
|
||||||
documento_scadenza: oNull(p.documentoScadenza),
|
|
||||||
documento_fronte_path: oNull(p.documentoFrontePath),
|
|
||||||
documento_retro_path: oNull(p.documentoRetroPath),
|
|
||||||
certificato_scadenza: oNull(p.certificatoScadenza),
|
|
||||||
certificato_path: oNull(p.certificatoPath),
|
|
||||||
foto_path: oNull(p.fotoPath),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pesi delle sezioni del profilo (docs/modules/profilo-giocatore.md). */
|
/** Pesi delle sezioni del profilo (docs/modules/profilo-giocatore.md). */
|
||||||
export const PESI = { dati: 30, documento: 30, certificato: 30, foto: 10 } as const;
|
export const PESI = { dati: 30, documento: 30, certificato: 30, foto: 10 } as const;
|
||||||
|
|
||||||
|
|||||||
+115
-58
@@ -1,29 +1,71 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
|
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||||
import {
|
import type { Profilo } from "./profili-core";
|
||||||
aRigaProfilo,
|
|
||||||
COLONNE_PROFILO,
|
|
||||||
daRigaProfilo,
|
|
||||||
type Profilo,
|
|
||||||
type RigaProfilo,
|
|
||||||
} from "./profili-core";
|
|
||||||
|
|
||||||
export const PROFILI_KEY = ["profili-giocatore"] as const;
|
export const PROFILI_KEY = ["profili-giocatore"] as const;
|
||||||
export const BUCKET = "profili-giocatore";
|
|
||||||
|
type RigaProfiloPocketBase = {
|
||||||
|
id: string;
|
||||||
|
giocatore: string;
|
||||||
|
data_nascita: string;
|
||||||
|
luogo_nascita: string;
|
||||||
|
indirizzo: string;
|
||||||
|
telefono: string;
|
||||||
|
email: string;
|
||||||
|
documento_tipo: string;
|
||||||
|
documento_numero: string;
|
||||||
|
documento_rilasciato_da: string;
|
||||||
|
documento_emissione: string;
|
||||||
|
documento_scadenza: string;
|
||||||
|
documento_fronte: string;
|
||||||
|
documento_retro: string;
|
||||||
|
certificato_scadenza: string;
|
||||||
|
certificato: string;
|
||||||
|
foto: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "" -> null: PocketBase non ha un concetto di colonna NULL, i campi vuoti sono stringa vuota. */
|
||||||
|
function vuotoANull(v: string): string | null {
|
||||||
|
return v ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** I campi "date" di PocketBase tornano come datetime completo: qui serve solo "YYYY-MM-DD". */
|
||||||
|
function soloData(v: string): string | null {
|
||||||
|
return v ? v.slice(0, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daRiga(r: RigaProfiloPocketBase): Profilo {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
giocatoreId: r.giocatore,
|
||||||
|
dataNascita: soloData(r.data_nascita),
|
||||||
|
luogoNascita: vuotoANull(r.luogo_nascita),
|
||||||
|
indirizzo: vuotoANull(r.indirizzo),
|
||||||
|
telefono: vuotoANull(r.telefono),
|
||||||
|
email: vuotoANull(r.email),
|
||||||
|
documentoTipo: vuotoANull(r.documento_tipo),
|
||||||
|
documentoNumero: vuotoANull(r.documento_numero),
|
||||||
|
documentoRilasciatoDa: vuotoANull(r.documento_rilasciato_da),
|
||||||
|
documentoEmissione: soloData(r.documento_emissione),
|
||||||
|
documentoScadenza: soloData(r.documento_scadenza),
|
||||||
|
documentoFrontePath: vuotoANull(r.documento_fronte),
|
||||||
|
documentoRetroPath: vuotoANull(r.documento_retro),
|
||||||
|
certificatoScadenza: soloData(r.certificato_scadenza),
|
||||||
|
certificatoPath: vuotoANull(r.certificato),
|
||||||
|
fotoPath: vuotoANull(r.foto),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchProfili(): Promise<Record<string, Profilo>> {
|
async function fetchProfili(): Promise<Record<string, Profilo>> {
|
||||||
const { data, error } = await supabaseNuoveTabelle
|
const righe = await pb.collection("profili_giocatore").getFullList<RigaProfiloPocketBase>();
|
||||||
.from("profili_giocatore")
|
|
||||||
.select(COLONNE_PROFILO);
|
|
||||||
if (error) throw error;
|
|
||||||
const mappa: Record<string, Profilo> = {};
|
const mappa: Record<string, Profilo> = {};
|
||||||
for (const r of (data ?? []) as RigaProfilo[]) mappa[r.giocatore_id] = daRigaProfilo(r);
|
for (const r of righe) mappa[r.giocatore] = daRiga(r);
|
||||||
return mappa;
|
return mappa;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profili visibili all'utente corrente: le policy RLS decidono quanti sono — il proprio
|
* Profili visibili all'utente corrente: le API rule decidono quanti sono — il proprio
|
||||||
* per un giocatore, tutti per un admin. Una lettura per sessione.
|
* per un giocatore, tutti per un admin. Una lettura per sessione.
|
||||||
*/
|
*/
|
||||||
export function useProfili() {
|
export function useProfili() {
|
||||||
@@ -32,33 +74,55 @@ export function useProfili() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* I documenti stanno in un bucket privato e non hanno URL permanenti (DD-016 regola 4):
|
* I documenti sono un campo file protetto e non hanno URL permanenti (DD-016 regola 4):
|
||||||
* ogni download passa da una signed URL che scade in un minuto.
|
* ogni download passa da un token che scade a breve, l'equivalente PocketBase della
|
||||||
|
* signed URL Supabase.
|
||||||
*/
|
*/
|
||||||
export async function urlFirmato(path: string): Promise<string> {
|
export async function urlFirmato(recordId: string, filename: string): Promise<string> {
|
||||||
const { data, error } = await supabase.storage.from(BUCKET).createSignedUrl(path, 60);
|
const token = await pb.files.getToken();
|
||||||
if (error) throw error;
|
return pb.files.getURL({ id: recordId, collectionName: "profili_giocatore" }, filename, {
|
||||||
return data.signedUrl;
|
token,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scaricaFile(path: string): Promise<void> {
|
export async function scaricaFile(recordId: string, filename: string): Promise<void> {
|
||||||
const url = await urlFirmato(path);
|
const url = await urlFirmato(recordId, filename);
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Salva il profilo del giocatore. Le policy RLS lasciano scrivere solo la propria riga:
|
* Salva i campi testuali del profilo. Le API rule lasciano scrivere solo la propria riga:
|
||||||
* il vincolo vive nel database, qui non serve ricontrollarlo.
|
* il vincolo vive nel database, qui non serve ricontrollarlo.
|
||||||
|
*
|
||||||
|
* I campi file NON passano da qui: PocketBase si aspetta un file in quei campi, non una
|
||||||
|
* stringa, quindi mandarli in questo upsert testuale fallirebbe la validazione. Li scrive
|
||||||
|
* `caricaFile` con una richiesta multipart dedicata — ometterli qui li lascia semplicemente
|
||||||
|
* invariati, esattamente il comportamento voluto.
|
||||||
*/
|
*/
|
||||||
export function useSalvaProfilo() {
|
export function useSalvaProfilo() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (profilo: Profilo) => {
|
mutationFn: async (profilo: Profilo) => {
|
||||||
const { error } = await supabaseNuoveTabelle
|
const riga = await upsertByFilter<RigaProfiloPocketBase>(
|
||||||
.from("profili_giocatore")
|
"profili_giocatore",
|
||||||
.upsert(aRigaProfilo(profilo), { onConflict: "giocatore_id" });
|
"giocatore = {:giocatore}",
|
||||||
if (error) throw error;
|
{ giocatore: profilo.giocatoreId },
|
||||||
return profilo;
|
{
|
||||||
|
giocatore: profilo.giocatoreId,
|
||||||
|
data_nascita: profilo.dataNascita?.trim() || "",
|
||||||
|
luogo_nascita: profilo.luogoNascita?.trim() || "",
|
||||||
|
indirizzo: profilo.indirizzo?.trim() || "",
|
||||||
|
telefono: profilo.telefono?.trim() || "",
|
||||||
|
email: profilo.email?.trim() || "",
|
||||||
|
documento_tipo: profilo.documentoTipo?.trim() || "",
|
||||||
|
documento_numero: profilo.documentoNumero?.trim() || "",
|
||||||
|
documento_rilasciato_da: profilo.documentoRilasciatoDa?.trim() || "",
|
||||||
|
documento_emissione: profilo.documentoEmissione || "",
|
||||||
|
documento_scadenza: profilo.documentoScadenza || "",
|
||||||
|
certificato_scadenza: profilo.certificatoScadenza || "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return daRiga(riga);
|
||||||
},
|
},
|
||||||
// Scrittura unica e cache aggiornata a mano, senza rilettura.
|
// Scrittura unica e cache aggiornata a mano, senza rilettura.
|
||||||
onSuccess: (profilo) => {
|
onSuccess: (profilo) => {
|
||||||
@@ -72,46 +136,39 @@ export function useSalvaProfilo() {
|
|||||||
|
|
||||||
export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato" | "foto";
|
export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato" | "foto";
|
||||||
|
|
||||||
|
const CAMPO_SEZIONE: Record<SezioneFile, keyof RigaProfiloPocketBase> = {
|
||||||
|
"documento-fronte": "documento_fronte",
|
||||||
|
"documento-retro": "documento_retro",
|
||||||
|
certificato: "certificato",
|
||||||
|
foto: "foto",
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_BYTE = 8 * 1024 * 1024;
|
const MAX_BYTE = 8 * 1024 * 1024;
|
||||||
const TIPI_AMMESSI = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
const TIPI_AMMESSI = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
||||||
|
|
||||||
function estensione(nome: string): string {
|
|
||||||
const punto = nome.lastIndexOf(".");
|
|
||||||
const est = punto > 0 ? nome.slice(punto + 1).toLowerCase() : "";
|
|
||||||
return /^[a-z0-9]{1,5}$/.test(est) ? est : "bin";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Carica un file nella cartella del giocatore e restituisce il path da salvare sul profilo.
|
* Carica un file nel campo giusto del profilo del giocatore (crea il profilo se non
|
||||||
* Il controllo su tipo e dimensione sta qui perché è il confine con un file scelto
|
* esiste ancora) e restituisce id del record e nome del file, da salvare nella bozza
|
||||||
* dall'utente; le policy dello Storage impediscono comunque di scrivere fuori dalla
|
* locale. Il controllo su tipo e dimensione sta qui perché è il confine con un file scelto
|
||||||
* propria cartella.
|
* dall'utente; le API rule della collection impediscono comunque di scrivere sul profilo
|
||||||
|
* di qualcun altro.
|
||||||
*/
|
*/
|
||||||
export async function caricaFile(
|
export async function caricaFile(
|
||||||
giocatoreId: string,
|
giocatoreId: string,
|
||||||
sezione: SezioneFile,
|
sezione: SezioneFile,
|
||||||
file: File,
|
file: File,
|
||||||
pathPrecedente?: string | null,
|
): Promise<{ id: string; filename: string }> {
|
||||||
): Promise<string> {
|
|
||||||
if (!TIPI_AMMESSI.includes(file.type)) {
|
if (!TIPI_AMMESSI.includes(file.type)) {
|
||||||
throw new Error("Formato non ammesso: usa JPG, PNG, WEBP o PDF.");
|
throw new Error("Formato non ammesso: usa JPG, PNG, WEBP o PDF.");
|
||||||
}
|
}
|
||||||
if (file.size > MAX_BYTE) throw new Error("File troppo grande: massimo 8 MB.");
|
if (file.size > MAX_BYTE) throw new Error("File troppo grande: massimo 8 MB.");
|
||||||
|
|
||||||
const path = `${giocatoreId}/${sezione}.${estensione(file.name)}`;
|
const campo = CAMPO_SEZIONE[sezione];
|
||||||
const { error } = await supabase.storage
|
const riga = await upsertByFilter<RigaProfiloPocketBase>(
|
||||||
.from(BUCKET)
|
"profili_giocatore",
|
||||||
.upload(path, file, { upsert: true, contentType: file.type });
|
"giocatore = {:giocatore}",
|
||||||
if (error) throw error;
|
{ giocatore: giocatoreId },
|
||||||
|
{ giocatore: giocatoreId, [campo]: file },
|
||||||
// Cambiando estensione il vecchio file resterebbe orfano nel bucket.
|
);
|
||||||
if (pathPrecedente && pathPrecedente !== path) {
|
return { id: riga.id, filename: riga[campo] };
|
||||||
await supabase.storage.from(BUCKET).remove([pathPrecedente]);
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rimuoviFile(path: string): Promise<void> {
|
|
||||||
const { error } = await supabase.storage.from(BUCKET).remove([path]);
|
|
||||||
if (error) throw error;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-28
@@ -1,34 +1,15 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
|
||||||
import { useSessione } from "./auth";
|
import { useSessione } from "./auth";
|
||||||
|
|
||||||
export const RUOLI_KEY = ["ruolo-admin"] as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011).
|
* Permessi di amministrazione: unica fonte è il campo `role` sul record utente
|
||||||
* Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare.
|
* PocketBase (DD-011). Nessuna lista di nomi, altrimenti basterebbe scegliere il nome
|
||||||
|
* giusto per amministrare.
|
||||||
|
*
|
||||||
|
* A differenza di Supabase (dove il ruolo stava nella tabella separata `user_roles` e
|
||||||
|
* richiedeva una query dedicata), qui il ruolo arriva già con la sessione: nessuna
|
||||||
|
* richiesta di rete o cache aggiuntiva serve.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */
|
|
||||||
async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> {
|
|
||||||
if (!utenteId) return null;
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("user_roles")
|
|
||||||
.select("role")
|
|
||||||
.eq("user_id", utenteId)
|
|
||||||
.eq("role", "admin")
|
|
||||||
.maybeSingle();
|
|
||||||
if (error) throw error;
|
|
||||||
return !!data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useIsAdmin(): boolean {
|
export function useIsAdmin(): boolean {
|
||||||
const { utenteId } = useSessione();
|
const { sessione } = useSessione();
|
||||||
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
|
return sessione?.["role"] === "admin";
|
||||||
const query = useQuery({
|
|
||||||
queryKey: [...RUOLI_KEY, utenteId],
|
|
||||||
queryFn: () => fetchRuoloAdmin(utenteId),
|
|
||||||
staleTime: 30 * 60_000,
|
|
||||||
});
|
|
||||||
return query.data === true;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-29
@@ -1,6 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { ClientResponseError } from "pocketbase";
|
||||||
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
|
import { pbISO } from "@/integrations/pocketbase/formato";
|
||||||
|
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||||
import { useEventi, type Evento } from "./eventi";
|
import { useEventi, type Evento } from "./eventi";
|
||||||
|
|
||||||
/** Minuti dopo i quali una sessione scout inattiva viene considerata libera. */
|
/** Minuti dopo i quali una sessione scout inattiva viene considerata libera. */
|
||||||
@@ -29,6 +32,23 @@ export type SessioneScout = {
|
|||||||
aggiornato_il: string;
|
aggiornato_il: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RigaSessionePocketBase = {
|
||||||
|
id: string;
|
||||||
|
evento: string;
|
||||||
|
giocatore: string;
|
||||||
|
giocatore_nome: string;
|
||||||
|
updated: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function daRigaSessione(r: RigaSessionePocketBase): SessioneScout {
|
||||||
|
return {
|
||||||
|
evento_id: r.evento,
|
||||||
|
giocatore_id: r.giocatore,
|
||||||
|
giocatore_nome: r.giocatore_nome,
|
||||||
|
aggiornato_il: pbISO(r.updated),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function sessioneScaduta(s: SessioneScout | null): boolean {
|
export function sessioneScaduta(s: SessioneScout | null): boolean {
|
||||||
if (!s) return true;
|
if (!s) return true;
|
||||||
const aggiornato = new Date(s.aggiornato_il).getTime();
|
const aggiornato = new Date(s.aggiornato_il).getTime();
|
||||||
@@ -41,13 +61,17 @@ export const SESSIONE_KEY = (eventoId: string) => ["scout-sessione", eventoId] a
|
|||||||
|
|
||||||
/** Sessione condivisa: chi la tiene aperta lo vede chiunque, su qualsiasi dispositivo. */
|
/** Sessione condivisa: chi la tiene aperta lo vede chiunque, su qualsiasi dispositivo. */
|
||||||
async function leggiSessione(eventoId: string): Promise<SessioneScout | null> {
|
async function leggiSessione(eventoId: string): Promise<SessioneScout | null> {
|
||||||
const { data, error } = await supabase
|
try {
|
||||||
.from("scout_sessioni")
|
const riga = await pb
|
||||||
.select("evento_id, giocatore_id, giocatore_nome, aggiornato_il")
|
.collection("scout_sessioni")
|
||||||
.eq("evento_id", eventoId)
|
.getFirstListItem<RigaSessionePocketBase>(
|
||||||
.maybeSingle();
|
pb.filter("evento = {:evento}", { evento: eventoId }),
|
||||||
if (error) throw error;
|
);
|
||||||
return data;
|
return daRigaSessione(riga);
|
||||||
|
} catch (errore) {
|
||||||
|
if (errore instanceof ClientResponseError && errore.status === 404) return null;
|
||||||
|
throw errore;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSessioneScout(eventoId: string | null) {
|
export function useSessioneScout(eventoId: string | null) {
|
||||||
@@ -70,16 +94,12 @@ export function useApriSessioneScout() {
|
|||||||
if (attuale && !sessioneScaduta(attuale) && attuale.giocatore_id !== input.giocatoreId) {
|
if (attuale && !sessioneScaduta(attuale) && attuale.giocatore_id !== input.giocatoreId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { error } = await supabase.from("scout_sessioni").upsert(
|
await upsertByFilter(
|
||||||
{
|
"scout_sessioni",
|
||||||
evento_id: input.eventoId,
|
"evento = {:evento}",
|
||||||
giocatore_id: input.giocatoreId,
|
{ evento: input.eventoId },
|
||||||
giocatore_nome: input.nome,
|
{ evento: input.eventoId, giocatore: input.giocatoreId, giocatore_nome: input.nome },
|
||||||
aggiornato_il: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ onConflict: "evento_id" },
|
|
||||||
);
|
);
|
||||||
if (error) throw error;
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
onSuccess: (_ok, input) => {
|
onSuccess: (_ok, input) => {
|
||||||
@@ -92,13 +112,17 @@ export function useChiudiSessioneScout() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { eventoId: string; giocatoreId: string }) => {
|
mutationFn: async (input: { eventoId: string; giocatoreId: string }) => {
|
||||||
const attuale = await leggiSessione(input.eventoId);
|
const attuale = await pb
|
||||||
if (attuale && attuale.giocatore_id === input.giocatoreId) {
|
.collection("scout_sessioni")
|
||||||
const { error } = await supabase
|
.getFirstListItem<RigaSessionePocketBase>(
|
||||||
.from("scout_sessioni")
|
pb.filter("evento = {:evento}", { evento: input.eventoId }),
|
||||||
.delete()
|
)
|
||||||
.eq("evento_id", input.eventoId);
|
.catch((errore) => {
|
||||||
if (error) throw error;
|
if (errore instanceof ClientResponseError && errore.status === 404) return null;
|
||||||
|
throw errore;
|
||||||
|
});
|
||||||
|
if (attuale && attuale.giocatore === input.giocatoreId) {
|
||||||
|
await pb.collection("scout_sessioni").delete(attuale.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (_d, input) => {
|
onSuccess: (_d, input) => {
|
||||||
@@ -116,11 +140,21 @@ export function useHeartbeatScout(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!attivo || !eventoId || !giocatoreId) return;
|
if (!attivo || !eventoId || !giocatoreId) return;
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
void supabase
|
// Solo aggiornamento, mai creazione: se la sessione è stata chiusa o presa da un
|
||||||
.from("scout_sessioni")
|
// altro giocatore nel frattempo, il filtro non trova nulla e il battito è un no-op
|
||||||
.update({ aggiornato_il: new Date().toISOString() })
|
// (stesso comportamento dell'update Supabase originale). Riscrivere `giocatore` con
|
||||||
.eq("evento_id", eventoId)
|
// lo stesso valore basta a PocketBase per aggiornare `updated`, che qui fa le veci
|
||||||
.eq("giocatore_id", giocatoreId);
|
// di `aggiornato_il`.
|
||||||
|
void pb
|
||||||
|
.collection("scout_sessioni")
|
||||||
|
.getFirstListItem<RigaSessionePocketBase>(
|
||||||
|
pb.filter("evento = {:evento} && giocatore = {:giocatore}", {
|
||||||
|
evento: eventoId,
|
||||||
|
giocatore: giocatoreId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((riga) => pb.collection("scout_sessioni").update(riga.id, { giocatore: giocatoreId }))
|
||||||
|
.catch(() => {});
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [attivo, eventoId, giocatoreId]);
|
}, [attivo, eventoId, giocatoreId]);
|
||||||
|
|||||||
+28
-18
@@ -1,5 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { ClientResponseError } from "pocketbase";
|
||||||
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
|
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
|
||||||
import type { Azione } from "./scout-store";
|
import type { Azione } from "./scout-store";
|
||||||
|
|
||||||
/** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */
|
/** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */
|
||||||
@@ -19,6 +21,8 @@ export const statoIniziale = (avversario: string, casa: boolean): StatoScout =>
|
|||||||
|
|
||||||
export const SCOUT_STATO_KEY = (eventoId: string) => ["scout-stato", eventoId] as const;
|
export const SCOUT_STATO_KEY = (eventoId: string) => ["scout-stato", eventoId] as const;
|
||||||
|
|
||||||
|
type RigaScoutLive = { evento: string; stato: unknown };
|
||||||
|
|
||||||
export function useStatoScout(eventoId: string | null) {
|
export function useStatoScout(eventoId: string | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: SCOUT_STATO_KEY(eventoId ?? "-"),
|
queryKey: SCOUT_STATO_KEY(eventoId ?? "-"),
|
||||||
@@ -26,13 +30,14 @@ export function useStatoScout(eventoId: string | null) {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
queryFn: async (): Promise<StatoScout | null> => {
|
queryFn: async (): Promise<StatoScout | null> => {
|
||||||
if (!eventoId) return null;
|
if (!eventoId) return null;
|
||||||
const { data, error } = await supabase
|
const riga = await pb
|
||||||
.from("scout_live")
|
.collection("scout_live")
|
||||||
.select("stato")
|
.getFirstListItem<RigaScoutLive>(pb.filter("evento = {:evento}", { evento: eventoId }))
|
||||||
.eq("evento_id", eventoId)
|
.catch((errore) => {
|
||||||
.maybeSingle();
|
if (errore instanceof ClientResponseError && errore.status === 404) return null;
|
||||||
if (error) throw error;
|
throw errore;
|
||||||
const stato = data?.stato as StatoScout | undefined;
|
});
|
||||||
|
const stato = riga?.stato as StatoScout | undefined;
|
||||||
if (!stato || !Array.isArray(stato.azioni)) return null;
|
if (!stato || !Array.isArray(stato.azioni)) return null;
|
||||||
return stato;
|
return stato;
|
||||||
},
|
},
|
||||||
@@ -43,15 +48,12 @@ export function useSalvaStatoScout() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { eventoId: string; stato: StatoScout }) => {
|
mutationFn: async (input: { eventoId: string; stato: StatoScout }) => {
|
||||||
const { error } = await supabase.from("scout_live").upsert(
|
await upsertByFilter(
|
||||||
{
|
"scout_live",
|
||||||
evento_id: input.eventoId,
|
"evento = {:evento}",
|
||||||
stato: JSON.parse(JSON.stringify(input.stato)),
|
{ evento: input.eventoId },
|
||||||
aggiornato_il: new Date().toISOString(),
|
{ evento: input.eventoId, stato: JSON.parse(JSON.stringify(input.stato)) },
|
||||||
},
|
|
||||||
{ onConflict: "evento_id" },
|
|
||||||
);
|
);
|
||||||
if (error) throw error;
|
|
||||||
return input;
|
return input;
|
||||||
},
|
},
|
||||||
onSuccess: (input) => {
|
onSuccess: (input) => {
|
||||||
@@ -64,8 +66,16 @@ export function useCancellaStatoScout() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (eventoId: string) => {
|
mutationFn: async (eventoId: string) => {
|
||||||
const { error } = await supabase.from("scout_live").delete().eq("evento_id", eventoId);
|
const riga = await pb
|
||||||
if (error) throw error;
|
.collection("scout_live")
|
||||||
|
.getFirstListItem<RigaScoutLive & { id: string }>(
|
||||||
|
pb.filter("evento = {:evento}", { evento: eventoId }),
|
||||||
|
)
|
||||||
|
.catch((errore) => {
|
||||||
|
if (errore instanceof ClientResponseError && errore.status === 404) return null;
|
||||||
|
throw errore;
|
||||||
|
});
|
||||||
|
if (riga) await pb.collection("scout_live").delete(riga.id);
|
||||||
return eventoId;
|
return eventoId;
|
||||||
},
|
},
|
||||||
onSuccess: (eventoId) => {
|
onSuccess: (eventoId) => {
|
||||||
|
|||||||
+10
-14
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { pb } from "@/integrations/pocketbase/client";
|
||||||
import { giocatori, type Giocatore } from "./crapp-data";
|
import { giocatori, type Giocatore } from "./crapp-data";
|
||||||
|
|
||||||
export type AzioneTipo = "attacco" | "ace" | "muro" | "errore" | "punto_avv" | "errore_avv";
|
export type AzioneTipo = "attacco" | "ace" | "muro" | "errore" | "punto_avv" | "errore_avv";
|
||||||
@@ -85,7 +85,7 @@ type RigaScoutPartita = {
|
|||||||
function daRiga(r: RigaScoutPartita): ScoutMatch {
|
function daRiga(r: RigaScoutPartita): ScoutMatch {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
data: r.data,
|
data: r.data.slice(0, 10),
|
||||||
avversario: r.avversario,
|
avversario: r.avversario,
|
||||||
casa: r.casa,
|
casa: r.casa,
|
||||||
setNostri: r.set_nostri,
|
setNostri: r.set_nostri,
|
||||||
@@ -98,12 +98,10 @@ function daRiga(r: RigaScoutPartita): ScoutMatch {
|
|||||||
export const SCOUT_MATCHES_KEY = ["scout-partite"] as const;
|
export const SCOUT_MATCHES_KEY = ["scout-partite"] as const;
|
||||||
|
|
||||||
async function fetchScoutMatches(): Promise<ScoutMatch[]> {
|
async function fetchScoutMatches(): Promise<ScoutMatch[]> {
|
||||||
const { data, error } = await supabase
|
const righe = await pb.collection("scout_partite").getFullList<RigaScoutPartita>({
|
||||||
.from("scout_partite")
|
sort: "-created",
|
||||||
.select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni")
|
});
|
||||||
.order("creato_il", { ascending: false });
|
return righe.map(daRiga);
|
||||||
if (error) throw error;
|
|
||||||
return (data ?? []).map(daRiga);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Partite scoutate condivise con tutta la squadra: chi scoutizza le vede da qualsiasi
|
/** Partite scoutate condivise con tutta la squadra: chi scoutizza le vede da qualsiasi
|
||||||
@@ -121,9 +119,9 @@ export function useSalvaScoutMatch() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { eventoId: string | null; match: ScoutMatch }) => {
|
mutationFn: async (input: { eventoId: string | null; match: ScoutMatch }) => {
|
||||||
const { error } = await supabase.from("scout_partite").insert({
|
await pb.collection("scout_partite").create({
|
||||||
id: input.match.id,
|
id: input.match.id,
|
||||||
evento_id: input.eventoId,
|
evento: input.eventoId ?? "",
|
||||||
data: input.match.data,
|
data: input.match.data,
|
||||||
avversario: input.match.avversario,
|
avversario: input.match.avversario,
|
||||||
casa: input.match.casa,
|
casa: input.match.casa,
|
||||||
@@ -132,10 +130,9 @@ export function useSalvaScoutMatch() {
|
|||||||
parziali: JSON.parse(JSON.stringify(input.match.parziali)),
|
parziali: JSON.parse(JSON.stringify(input.match.parziali)),
|
||||||
azioni: JSON.parse(JSON.stringify(input.match.azioni)),
|
azioni: JSON.parse(JSON.stringify(input.match.azioni)),
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
|
||||||
return input.match;
|
return input.match;
|
||||||
},
|
},
|
||||||
// La query ordina per `creato_il` decrescente: la partita appena salvata è la più recente.
|
// La query ordina per creazione decrescente: la partita appena salvata è la più recente.
|
||||||
onSuccess: (match) =>
|
onSuccess: (match) =>
|
||||||
queryClient.setQueryData<ScoutMatch[]>(SCOUT_MATCHES_KEY, (prec) => [
|
queryClient.setQueryData<ScoutMatch[]>(SCOUT_MATCHES_KEY, (prec) => [
|
||||||
match,
|
match,
|
||||||
@@ -148,8 +145,7 @@ export function useEliminaScoutMatch() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (id: string) => {
|
mutationFn: async (id: string) => {
|
||||||
const { error } = await supabase.from("scout_partite").delete().eq("id", id);
|
await pb.collection("scout_partite").delete(id);
|
||||||
if (error) throw error;
|
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
onSuccess: (id) =>
|
onSuccess: (id) =>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
|
|||||||
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
||||||
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
||||||
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
||||||
|
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
|
||||||
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
||||||
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
||||||
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
|
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
|
||||||
@@ -99,6 +100,12 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
|
|||||||
path: '/api/public/csi',
|
path: '/api/public/csi',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiPublicNotificheAttiveRoute =
|
||||||
|
ApiPublicNotificheAttiveRouteImport.update({
|
||||||
|
id: '/api/public/notifiche-attive',
|
||||||
|
path: '/api/public/notifiche-attive',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiPublicPromemoriaPalloniRoute =
|
const ApiPublicPromemoriaPalloniRoute =
|
||||||
ApiPublicPromemoriaPalloniRouteImport.update({
|
ApiPublicPromemoriaPalloniRouteImport.update({
|
||||||
id: '/api/public/promemoria-palloni',
|
id: '/api/public/promemoria-palloni',
|
||||||
@@ -142,6 +149,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||||
@@ -163,6 +171,7 @@ export interface FileRoutesByTo {
|
|||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||||
@@ -185,6 +194,7 @@ export interface FileRoutesById {
|
|||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||||
@@ -208,6 +218,7 @@ export interface FileRouteTypes {
|
|||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
| '/api/public/push-subscribe'
|
| '/api/public/push-subscribe'
|
||||||
@@ -229,6 +240,7 @@ export interface FileRouteTypes {
|
|||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
| '/api/public/push-subscribe'
|
| '/api/public/push-subscribe'
|
||||||
@@ -250,6 +262,7 @@ export interface FileRouteTypes {
|
|||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
| '/api/public/push-subscribe'
|
| '/api/public/push-subscribe'
|
||||||
@@ -272,6 +285,7 @@ export interface RootRouteChildren {
|
|||||||
PartitaIdRoute: typeof PartitaIdRoute
|
PartitaIdRoute: typeof PartitaIdRoute
|
||||||
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
||||||
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
||||||
|
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
|
||||||
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
||||||
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
||||||
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
|
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
|
||||||
@@ -379,6 +393,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/public/notifiche-attive': {
|
||||||
|
id: '/api/public/notifiche-attive'
|
||||||
|
path: '/api/public/notifiche-attive'
|
||||||
|
fullPath: '/api/public/notifiche-attive'
|
||||||
|
preLoaderRoute: typeof ApiPublicNotificheAttiveRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/public/promemoria-palloni': {
|
'/api/public/promemoria-palloni': {
|
||||||
id: '/api/public/promemoria-palloni'
|
id: '/api/public/promemoria-palloni'
|
||||||
path: '/api/public/promemoria-palloni'
|
path: '/api/public/promemoria-palloni'
|
||||||
@@ -432,6 +453,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
PartitaIdRoute: PartitaIdRoute,
|
PartitaIdRoute: PartitaIdRoute,
|
||||||
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
||||||
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
||||||
|
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
|
||||||
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
||||||
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
||||||
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
|
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import appCss from "../styles.css?url";
|
import appCss from "../styles.css?url";
|
||||||
import { reportLovableError } from "../lib/lovable-error-reporting";
|
|
||||||
import { BottomNav } from "../components/crapp/BottomNav";
|
import { BottomNav } from "../components/crapp/BottomNav";
|
||||||
import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge";
|
import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
@@ -47,9 +46,6 @@ function NotFoundComponent() {
|
|||||||
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
useEffect(() => {
|
|
||||||
reportLovableError(error, { boundary: "tanstack_root_error_component" });
|
|
||||||
}, [error]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh items-center justify-center bg-background px-4">
|
<div className="flex min-h-dvh items-center justify-center bg-background px-4">
|
||||||
|
|||||||
+109
-65
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
Bell,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -16,14 +17,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { Campo, classiInput, PageHeader, Select, StatTile } from "@/components/crapp/ui-bits";
|
||||||
Campo,
|
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||||
classiInput,
|
|
||||||
PageHeader,
|
|
||||||
Section,
|
|
||||||
Select,
|
|
||||||
StatTile,
|
|
||||||
} from "@/components/crapp/ui-bits";
|
|
||||||
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||||
import {
|
import {
|
||||||
nomeCompleto,
|
nomeCompleto,
|
||||||
@@ -54,6 +49,7 @@ import {
|
|||||||
import { oggiISO } from "@/lib/palloni-core";
|
import { oggiISO } from "@/lib/palloni-core";
|
||||||
import { scaricaCsv } from "@/lib/scout-export";
|
import { scaricaCsv } from "@/lib/scout-export";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
|
import { useNotificheAttive } from "@/lib/notifiche-admin";
|
||||||
import { Reveal } from "@/components/motion/Reveal";
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
|
||||||
export const Route = createFileRoute("/admin")({
|
export const Route = createFileRoute("/admin")({
|
||||||
@@ -232,7 +228,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
</Select>
|
</Select>
|
||||||
</Campo>
|
</Campo>
|
||||||
</div>
|
</div>
|
||||||
<Campo label="Email (collegamento automatico al login, DD-018)">
|
<Campo label="Email">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={squadraCorrente.email ?? ""}
|
value={squadraCorrente.email ?? ""}
|
||||||
@@ -342,19 +338,21 @@ function Documento({
|
|||||||
label,
|
label,
|
||||||
stato,
|
stato,
|
||||||
path,
|
path,
|
||||||
|
recordId,
|
||||||
}: {
|
}: {
|
||||||
icona: React.ReactNode;
|
icona: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
stato: StatoScadenza | "presente" | "assente";
|
stato: StatoScadenza | "presente" | "assente";
|
||||||
path: string | null;
|
path: string | null;
|
||||||
|
recordId: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [inCorso, setInCorso] = useState(false);
|
const [inCorso, setInCorso] = useState(false);
|
||||||
|
|
||||||
async function scarica() {
|
async function scarica() {
|
||||||
if (!path || inCorso) return;
|
if (!path || !recordId || inCorso) return;
|
||||||
setInCorso(true);
|
setInCorso(true);
|
||||||
try {
|
try {
|
||||||
await scaricaFile(path);
|
await scaricaFile(recordId, path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "Download non riuscito");
|
toast.error(error instanceof Error ? error.message : "Download non riuscito");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -366,7 +364,7 @@ function Documento({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={scarica}
|
onClick={scarica}
|
||||||
disabled={!path || inCorso}
|
disabled={!path || !recordId || inCorso}
|
||||||
className={cn(
|
className={cn(
|
||||||
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60",
|
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60",
|
||||||
statoClasse[stato],
|
statoClasse[stato],
|
||||||
@@ -384,13 +382,16 @@ function SchedaGiocatore({
|
|||||||
profilo,
|
profilo,
|
||||||
oggi,
|
oggi,
|
||||||
indice,
|
indice,
|
||||||
|
aperta,
|
||||||
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
g: GiocatoreSquadra;
|
g: GiocatoreSquadra;
|
||||||
profilo: Profilo | undefined;
|
profilo: Profilo | undefined;
|
||||||
oggi: string;
|
oggi: string;
|
||||||
indice: number;
|
indice: number;
|
||||||
|
aperta: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [aperta, setAperta] = useState(false);
|
|
||||||
const nome = nomeCompleto(g);
|
const nome = nomeCompleto(g);
|
||||||
const { ruolo, numero } = g;
|
const { ruolo, numero } = g;
|
||||||
const perc = completamento(profilo);
|
const perc = completamento(profilo);
|
||||||
@@ -401,11 +402,7 @@ function SchedaGiocatore({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
|
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
|
||||||
<button
|
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 text-left">
|
||||||
type="button"
|
|
||||||
onClick={() => setAperta((v) => !v)}
|
|
||||||
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">
|
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums">
|
||||||
{numero}
|
{numero}
|
||||||
</div>
|
</div>
|
||||||
@@ -436,24 +433,28 @@ function SchedaGiocatore({
|
|||||||
label="Doc fronte"
|
label="Doc fronte"
|
||||||
stato={fronte}
|
stato={fronte}
|
||||||
path={profilo?.documentoFrontePath ?? null}
|
path={profilo?.documentoFrontePath ?? null}
|
||||||
|
recordId={profilo?.id ?? null}
|
||||||
/>
|
/>
|
||||||
<Documento
|
<Documento
|
||||||
icona={<IdCard className="h-3.5 w-3.5" />}
|
icona={<IdCard className="h-3.5 w-3.5" />}
|
||||||
label="Doc retro"
|
label="Doc retro"
|
||||||
stato={retro}
|
stato={retro}
|
||||||
path={profilo?.documentoRetroPath ?? null}
|
path={profilo?.documentoRetroPath ?? null}
|
||||||
|
recordId={profilo?.id ?? null}
|
||||||
/>
|
/>
|
||||||
<Documento
|
<Documento
|
||||||
icona={<FileText className="h-3.5 w-3.5" />}
|
icona={<FileText className="h-3.5 w-3.5" />}
|
||||||
label="Certificato"
|
label="Certificato"
|
||||||
stato={certificato}
|
stato={certificato}
|
||||||
path={profilo?.certificatoPath ?? null}
|
path={profilo?.certificatoPath ?? null}
|
||||||
|
recordId={profilo?.id ?? null}
|
||||||
/>
|
/>
|
||||||
<Documento
|
<Documento
|
||||||
icona={<Image className="h-3.5 w-3.5" />}
|
icona={<Image className="h-3.5 w-3.5" />}
|
||||||
label="Foto"
|
label="Foto"
|
||||||
stato={sezioni.foto ? "presente" : "assente"}
|
stato={sezioni.foto ? "presente" : "assente"}
|
||||||
path={profilo?.fotoPath ?? null}
|
path={profilo?.fotoPath ?? null}
|
||||||
|
recordId={profilo?.id ?? null}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -552,7 +553,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
|||||||
</Select>
|
</Select>
|
||||||
</Campo>
|
</Campo>
|
||||||
</div>
|
</div>
|
||||||
<Campo label="Email (collegamento automatico al login, opzionale)">
|
<Campo label="Email">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={dati.email ?? ""}
|
value={dati.email ?? ""}
|
||||||
@@ -623,6 +624,8 @@ function Dashboard() {
|
|||||||
const admin = useIsAdmin();
|
const admin = useIsAdmin();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
const { profili, isPending } = useProfili();
|
const { profili, isPending } = useProfili();
|
||||||
|
const { data: notificheAttive } = useNotificheAttive();
|
||||||
|
const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
|
||||||
const oggi = oggiISO();
|
const oggi = oggiISO();
|
||||||
|
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
@@ -649,56 +652,97 @@ function Dashboard() {
|
|||||||
).length;
|
).length;
|
||||||
const tesserati = attivi.filter((g) => g.numeroTessera).length;
|
const tesserati = attivi.filter((g) => g.numeroTessera).length;
|
||||||
|
|
||||||
|
const contenutoSquadra = (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<StatTile valore={attivi.length} label="Giocatori" />
|
||||||
|
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
|
||||||
|
<StatTile
|
||||||
|
valore={`${certificatiOk}/${attivi.length}`}
|
||||||
|
label="Certificati validi"
|
||||||
|
hint="non scaduti"
|
||||||
|
/>
|
||||||
|
<StatTile valore={`${tesserati}/${attivi.length}`} label="Tesserati" hint="CSI" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))}
|
||||||
|
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
||||||
|
</button>
|
||||||
|
<AggiungiGiocatore righe={squadra} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const contenutoProfili = isPending ? (
|
||||||
|
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||||
|
Caricamento…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{attivi.map((g, i) => (
|
||||||
|
<SchedaGiocatore
|
||||||
|
key={g.id}
|
||||||
|
g={g}
|
||||||
|
profilo={profili[g.id]}
|
||||||
|
oggi={oggi}
|
||||||
|
indice={i}
|
||||||
|
aperta={schedaAperta === g.id}
|
||||||
|
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const contenutoDisattivati = (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disattivi.map((g) => (
|
||||||
|
<GiocatoreDisattivato key={g.id} g={g} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const contenutoNotifiche = notificheAttive ? (
|
||||||
|
<>
|
||||||
|
<StatTile
|
||||||
|
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
|
||||||
|
label="Notifiche attive"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{attivi
|
||||||
|
.filter((g) => notificheAttive.has(g.id))
|
||||||
|
.map((g) => (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||||
|
Caricamento…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" />
|
<PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" />
|
||||||
|
|
||||||
<Section titolo="Squadra">
|
<BarraSottosezioni
|
||||||
<div className="grid grid-cols-2 gap-2">
|
defaultId="squadra"
|
||||||
<StatTile valore={attivi.length} label="Giocatori" />
|
variante="sottolineatura"
|
||||||
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
|
riempiLarghezza
|
||||||
<StatTile
|
voci={[
|
||||||
valore={`${certificatiOk}/${attivi.length}`}
|
{ id: "squadra", label: "Squadra", contenuto: contenutoSquadra },
|
||||||
label="Certificati validi"
|
{ id: "profili", label: "Profili", contenuto: contenutoProfili },
|
||||||
hint="non scaduti"
|
...(disattivi.length > 0
|
||||||
/>
|
? [{ id: "disattivati", label: "Disattivati", contenuto: contenutoDisattivati }]
|
||||||
<StatTile valore={`${tesserati}/${attivi.length}`} label="Tesserati" hint="CSI" />
|
: []),
|
||||||
</div>
|
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
|
||||||
<button
|
]}
|
||||||
type="button"
|
/>
|
||||||
onClick={() =>
|
|
||||||
scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))
|
|
||||||
}
|
|
||||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
|
||||||
</button>
|
|
||||||
<AggiungiGiocatore righe={squadra} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section titolo="Profili" indice={1}>
|
|
||||||
{isPending ? (
|
|
||||||
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
|
||||||
Caricamento…
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{attivi.map((g, i) => (
|
|
||||||
<SchedaGiocatore key={g.id} g={g} profilo={profili[g.id]} oggi={oggi} indice={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{disattivi.length > 0 ? (
|
|
||||||
<Section titolo="Giocatori disattivati" indice={2}>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{disattivi.map((g) => (
|
|
||||||
<GiocatoreDisattivato key={g.id} g={g} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { inviaPush } from "@/lib/webpush.server";
|
|||||||
|
|
||||||
const schema = z.object({ eventoId: z.string().min(1).max(50) });
|
const schema = z.object({ eventoId: z.string().min(1).max(50) });
|
||||||
|
|
||||||
|
type RigaIscrizione = { id: string; endpoint: string; p256dh: string; auth: string };
|
||||||
|
|
||||||
/** Avviso "sondaggio pre-partita aperto": lo fa partire un admin dalla pagina partita. */
|
/** Avviso "sondaggio pre-partita aperto": lo fa partire un admin dalla pagina partita. */
|
||||||
export const Route = createFileRoute("/api/public/apri-sondaggio")({
|
export const Route = createFileRoute("/api/public/apri-sondaggio")({
|
||||||
server: {
|
server: {
|
||||||
@@ -21,23 +23,21 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
|
|||||||
const partita = eventi.find((e) => e.id === parsed.data.eventoId);
|
const partita = eventi.find((e) => e.id === parsed.data.eventoId);
|
||||||
if (!partita) return new Response("Evento non trovato", { status: 404 });
|
if (!partita) return new Response("Evento non trovato", { status: 404 });
|
||||||
|
|
||||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
|
||||||
const { data: iscrizioni } = await supabaseAdmin
|
const admin = await pbAdmin();
|
||||||
.from("push_subscriptions")
|
const iscrizioni = await admin
|
||||||
.select("endpoint, p256dh, auth");
|
.collection("push_subscriptions")
|
||||||
|
.getFullList<RigaIscrizione>();
|
||||||
|
|
||||||
const titolo = "💩 Sondaggio pre-partita aperto";
|
const titolo = "💩 Sondaggio pre-partita aperto";
|
||||||
const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`;
|
const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`;
|
||||||
|
|
||||||
let inviate = 0;
|
let inviate = 0;
|
||||||
for (const iscrizione of iscrizioni ?? []) {
|
for (const iscrizione of iscrizioni) {
|
||||||
try {
|
try {
|
||||||
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||||
if (stato === 404 || stato === 410) {
|
if (stato === 404 || stato === 410) {
|
||||||
await supabaseAdmin
|
await admin.collection("push_subscriptions").delete(iscrizione.id);
|
||||||
.from("push_subscriptions")
|
|
||||||
.delete()
|
|
||||||
.eq("endpoint", iscrizione.endpoint);
|
|
||||||
} else if (stato >= 200 && stato < 300) {
|
} else if (stato >= 200 && stato < 300) {
|
||||||
inviate += 1;
|
inviate += 1;
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,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,29 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||||
|
import { idsConNotificheAttive } from "@/lib/notifiche-attive.server";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/public/notifiche-attive")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: async ({ request }) => {
|
||||||
|
const negato = await richiediAdmin(request);
|
||||||
|
if (negato) return negato;
|
||||||
|
|
||||||
|
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
|
||||||
|
const admin = await pbAdmin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const righe = await admin
|
||||||
|
.collection("push_subscriptions")
|
||||||
|
.getFullList<{ giocatore: string }>();
|
||||||
|
return Response.json({
|
||||||
|
giocatoreIds: idsConNotificheAttive(righe.map((r) => ({ giocatore_id: r.giocatore }))),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("notifiche-attive", error);
|
||||||
|
return new Response("Errore lettura", { status: 500 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,6 +9,15 @@ import { leggiEventi } from "@/lib/eventi.server";
|
|||||||
|
|
||||||
const schema = z.object({ eventoId: z.string().min(1).max(50) });
|
const schema = z.object({ eventoId: z.string().min(1).max(50) });
|
||||||
|
|
||||||
|
type RigaTurno = { evento: string; giocatore: string };
|
||||||
|
type RigaIscrizione = {
|
||||||
|
id: string;
|
||||||
|
endpoint: string;
|
||||||
|
giocatore: string;
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Promemoria del turno palloni per un evento: lo fa partire un admin dalla pagina
|
* Promemoria del turno palloni per un evento: lo fa partire un admin dalla pagina
|
||||||
* dell'evento (DD-025). Il testo viaggia cifrato dentro la push.
|
* dell'evento (DD-025). Il testo viaggia cifrato dentro la push.
|
||||||
@@ -27,13 +36,12 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
|||||||
const evento = eventi.find((e) => e.id === parsed.data.eventoId);
|
const evento = eventi.find((e) => e.id === parsed.data.eventoId);
|
||||||
if (!evento) return new Response("Evento non trovato", { status: 404 });
|
if (!evento) return new Response("Evento non trovato", { status: 404 });
|
||||||
|
|
||||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
|
||||||
|
const admin = await pbAdmin();
|
||||||
|
|
||||||
const { data: righe } = await supabaseAdmin
|
const righeTurni = await admin.collection("turni_palloni").getFullList<RigaTurno>();
|
||||||
.from("turni_palloni")
|
|
||||||
.select("evento_id, giocatore_id");
|
|
||||||
const salvati: Record<string, string> = {};
|
const salvati: Record<string, string> = {};
|
||||||
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
|
for (const riga of righeTurni) salvati[riga.evento] = riga.giocatore;
|
||||||
|
|
||||||
const squadra = await leggiGiocatoriSquadra();
|
const squadra = await leggiGiocatoriSquadra();
|
||||||
const rosa = squadra
|
const rosa = squadra
|
||||||
@@ -44,25 +52,21 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
|||||||
const avvisi = avvisiPalloniEvento(turni, eventi, evento.id);
|
const avvisi = avvisiPalloniEvento(turni, eventi, evento.id);
|
||||||
if (avvisi.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
|
if (avvisi.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
|
||||||
|
|
||||||
const { data: iscrizioni } = await supabaseAdmin
|
const idsFiltro = avvisi.map((a) => a.giocatoreId);
|
||||||
.from("push_subscriptions")
|
const filtro = idsFiltro.map((_, i) => `giocatore = {:g${i}}`).join(" || ");
|
||||||
.select("endpoint, giocatore_id, p256dh, auth")
|
const parametri = Object.fromEntries(idsFiltro.map((id, i) => [`g${i}`, id]));
|
||||||
.in(
|
const iscrizioni = await admin
|
||||||
"giocatore_id",
|
.collection("push_subscriptions")
|
||||||
avvisi.map((a) => a.giocatoreId),
|
.getFullList<RigaIscrizione>({ filter: admin.filter(filtro, parametri) });
|
||||||
);
|
|
||||||
|
|
||||||
let inviate = 0;
|
let inviate = 0;
|
||||||
for (const iscrizione of iscrizioni ?? []) {
|
for (const iscrizione of iscrizioni) {
|
||||||
const avviso = avvisi.find((a) => a.giocatoreId === iscrizione.giocatore_id);
|
const avviso = avvisi.find((a) => a.giocatoreId === iscrizione.giocatore);
|
||||||
if (!avviso) continue;
|
if (!avviso) continue;
|
||||||
try {
|
try {
|
||||||
const { stato } = await inviaPush(iscrizione, avviso.titolo, avviso.testo);
|
const { stato } = await inviaPush(iscrizione, avviso.titolo, avviso.testo);
|
||||||
if (stato === 404 || stato === 410) {
|
if (stato === 404 || stato === 410) {
|
||||||
await supabaseAdmin
|
await admin.collection("push_subscriptions").delete(iscrizione.id);
|
||||||
.from("push_subscriptions")
|
|
||||||
.delete()
|
|
||||||
.eq("endpoint", iscrizione.endpoint);
|
|
||||||
} else if (stato >= 200 && stato < 300) {
|
} else if (stato >= 200 && stato < 300) {
|
||||||
inviate += 1;
|
inviate += 1;
|
||||||
}
|
}
|
||||||
@@ -71,7 +75,7 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ inviate, destinatari: (iscrizioni ?? []).length });
|
return Response.json({ inviate, destinatari: iscrizioni.length });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,17 +17,24 @@ export const Route = createFileRoute("/api/public/push-subscribe")({
|
|||||||
const parsed = schemaIscrizione.safeParse(await request.json());
|
const parsed = schemaIscrizione.safeParse(await request.json());
|
||||||
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
|
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
|
||||||
|
|
||||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
|
||||||
const { error } = await supabaseAdmin.from("push_subscriptions").upsert(
|
const { upsertByFilter } = await import("@/integrations/pocketbase/upsert");
|
||||||
{
|
const admin = await pbAdmin();
|
||||||
endpoint: parsed.data.endpoint,
|
|
||||||
giocatore_id: parsed.data.giocatoreId,
|
try {
|
||||||
p256dh: parsed.data.p256dh,
|
await upsertByFilter(
|
||||||
auth: parsed.data.auth,
|
"push_subscriptions",
|
||||||
},
|
"endpoint = {:endpoint}",
|
||||||
{ onConflict: "endpoint" },
|
{ endpoint: parsed.data.endpoint },
|
||||||
);
|
{
|
||||||
if (error) {
|
endpoint: parsed.data.endpoint,
|
||||||
|
giocatore: parsed.data.giocatoreId,
|
||||||
|
p256dh: parsed.data.p256dh,
|
||||||
|
auth: parsed.data.auth,
|
||||||
|
},
|
||||||
|
admin,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
console.error("push-subscribe", error);
|
console.error("push-subscribe", error);
|
||||||
return new Response("Errore salvataggio", { status: 500 });
|
return new Response("Errore salvataggio", { status: 500 });
|
||||||
}
|
}
|
||||||
@@ -37,11 +44,15 @@ export const Route = createFileRoute("/api/public/push-subscribe")({
|
|||||||
const parsed = schemaCancellazione.safeParse(await request.json());
|
const parsed = schemaCancellazione.safeParse(await request.json());
|
||||||
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
|
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
|
||||||
|
|
||||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
|
||||||
await supabaseAdmin
|
const admin = await pbAdmin();
|
||||||
.from("push_subscriptions")
|
const esistente = await admin
|
||||||
.delete()
|
.collection("push_subscriptions")
|
||||||
.eq("endpoint", parsed.data.endpoint);
|
.getFirstListItem(
|
||||||
|
admin.filter("endpoint = {:endpoint}", { endpoint: parsed.data.endpoint }),
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
if (esistente) await admin.collection("push_subscriptions").delete(esistente.id);
|
||||||
return Response.json({ ok: true });
|
return Response.json({ ok: true });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const schema = z.object({
|
|||||||
da: z.string().min(1).max(60).optional(),
|
da: z.string().min(1).max(60).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type RigaPresenza = { giocatore: string; stato: string };
|
||||||
|
type RigaIscrizione = { id: string; endpoint: string; p256dh: string; auth: string };
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
@@ -26,22 +29,26 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
|||||||
const evento = eventi.find((e) => e.id === parsed.data.eventoId);
|
const evento = eventi.find((e) => e.id === parsed.data.eventoId);
|
||||||
if (!evento) return new Response("Evento non trovato", { status: 404 });
|
if (!evento) return new Response("Evento non trovato", { status: 404 });
|
||||||
|
|
||||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
|
||||||
|
const admin = await pbAdmin();
|
||||||
|
|
||||||
const { data: righe } = await supabaseAdmin
|
const righe = await admin.collection("risposte_presenze").getFullList<RigaPresenza>({
|
||||||
.from("risposte_presenze")
|
filter: admin.filter("evento = {:evento}", { evento: evento.id }),
|
||||||
.select("giocatore_id, stato")
|
});
|
||||||
.eq("evento_id", evento.id);
|
|
||||||
|
|
||||||
const squadra = await leggiGiocatoriSquadra();
|
const squadra = await leggiGiocatoriSquadra();
|
||||||
const destinatari = destinatariSollecito(squadra, righe ?? []);
|
const destinatari = destinatariSollecito(
|
||||||
|
squadra,
|
||||||
|
righe.map((r) => ({ giocatore_id: r.giocatore, stato: r.stato })),
|
||||||
|
);
|
||||||
|
|
||||||
if (destinatari.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
|
if (destinatari.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
|
||||||
|
|
||||||
const { data: iscrizioni } = await supabaseAdmin
|
const filtro = destinatari.map((_, i) => `giocatore = {:g${i}}`).join(" || ");
|
||||||
.from("push_subscriptions")
|
const parametri = Object.fromEntries(destinatari.map((id, i) => [`g${i}`, id]));
|
||||||
.select("endpoint, p256dh, auth")
|
const iscrizioni = await admin
|
||||||
.in("giocatore_id", destinatari);
|
.collection("push_subscriptions")
|
||||||
|
.getFullList<RigaIscrizione>({ filter: admin.filter(filtro, parametri) });
|
||||||
|
|
||||||
const titolo = "Manca la tua risposta";
|
const titolo = "Manca la tua risposta";
|
||||||
const testo = `${evento.titolo} · ${formatData(evento.data)} ore ${evento.ora}. ${
|
const testo = `${evento.titolo} · ${formatData(evento.data)} ore ${evento.ora}. ${
|
||||||
@@ -49,14 +56,11 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
|||||||
} una conferma: presente, assente o in ritardo?`;
|
} una conferma: presente, assente o in ritardo?`;
|
||||||
|
|
||||||
let inviate = 0;
|
let inviate = 0;
|
||||||
for (const iscrizione of iscrizioni ?? []) {
|
for (const iscrizione of iscrizioni) {
|
||||||
try {
|
try {
|
||||||
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||||
if (stato === 404 || stato === 410) {
|
if (stato === 404 || stato === 410) {
|
||||||
await supabaseAdmin
|
await admin.collection("push_subscriptions").delete(iscrizione.id);
|
||||||
.from("push_subscriptions")
|
|
||||||
.delete()
|
|
||||||
.eq("endpoint", iscrizione.endpoint);
|
|
||||||
} else if (stato >= 200 && stato < 300) {
|
} else if (stato >= 200 && stato < 300) {
|
||||||
inviate += 1;
|
inviate += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,9 +119,9 @@ function Profilo() {
|
|||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
if (!file || !g) return;
|
if (!file || !g) return;
|
||||||
try {
|
try {
|
||||||
await caricaAvatar(g.id, file);
|
const riga = await caricaAvatar(g.id, file);
|
||||||
setBust(Date.now());
|
setBust(Date.now());
|
||||||
impostaAvatarEsiste(g.id, true);
|
impostaAvatarEsiste(g.id, riga);
|
||||||
toast.success("Immagine profilo aggiornata");
|
toast.success("Immagine profilo aggiornata");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Non sono riuscito a caricare l'immagine");
|
toast.error("Non sono riuscito a caricare l'immagine");
|
||||||
@@ -169,7 +169,7 @@ function Profilo() {
|
|||||||
try {
|
try {
|
||||||
await rimuoviAvatar(g.id);
|
await rimuoviAvatar(g.id);
|
||||||
setBust(Date.now());
|
setBust(Date.now());
|
||||||
impostaAvatarEsiste(g.id, false);
|
impostaAvatarEsiste(g.id, null);
|
||||||
toast.success("Immagine rimossa");
|
toast.success("Immagine rimossa");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Non sono riuscito a rimuovere l'immagine");
|
toast.error("Non sono riuscito a rimuovere l'immagine");
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { createStart, createCsrfMiddleware, createMiddleware } from "@tanstack/react-start";
|
import { createStart, createCsrfMiddleware, createMiddleware } from "@tanstack/react-start";
|
||||||
|
|
||||||
import { renderErrorPage } from "./lib/error-page";
|
import { renderErrorPage } from "./lib/error-page";
|
||||||
import { attachSupabaseAuth } from "@/integrations/supabase/auth-attacher";
|
import { attachPocketBaseAuth } from "@/integrations/pocketbase/auth-attacher";
|
||||||
|
|
||||||
const errorMiddleware = createMiddleware().server(async ({ next }) => {
|
const errorMiddleware = createMiddleware().server(async ({ next }) => {
|
||||||
try {
|
try {
|
||||||
@@ -26,6 +26,6 @@ const csrfMiddleware = createCsrfMiddleware({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const startInstance = createStart(() => ({
|
export const startInstance = createStart(() => ({
|
||||||
functionMiddleware: [attachSupabaseAuth],
|
functionMiddleware: [attachPocketBaseAuth],
|
||||||
requestMiddleware: [errorMiddleware, csrfMiddleware],
|
requestMiddleware: [errorMiddleware, csrfMiddleware],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Chi può leggere l'elenco dei giocatori con notifiche push attive:
|
||||||
|
* `bun test/integration/notifiche-attive-route.test.ts`.
|
||||||
|
*
|
||||||
|
* `/api/public/notifiche-attive` legge `push_subscriptions` con la service role e
|
||||||
|
* salta la RLS (come le route che mandano notifiche, DD-024): il permesso deve stare
|
||||||
|
* nella route. Serve un database vero per provare token di un giocatore normale e di
|
||||||
|
* un amministratore, quindi solo stack locale.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { statoLocale } from "../helpers/locale";
|
||||||
|
import { avviaServer, json } from "../helpers/server";
|
||||||
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
|
const locale = statoLocale();
|
||||||
|
|
||||||
|
if (!locale) {
|
||||||
|
salta("notifiche attive: permessi route", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("notifiche-attive-route");
|
||||||
|
} else {
|
||||||
|
const { url: SUPABASE, anon: ANON, servizio: SERVIZIO } = locale;
|
||||||
|
|
||||||
|
// Il server di sviluppo eredita queste: le route leggono i nomi senza prefisso.
|
||||||
|
process.env["SUPABASE_URL"] = SUPABASE;
|
||||||
|
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
|
||||||
|
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
|
||||||
|
|
||||||
|
const PASSWORD = "prova-notifiche-123";
|
||||||
|
const idUtenti: string[] = [];
|
||||||
|
|
||||||
|
const authAdmin = { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` };
|
||||||
|
|
||||||
|
async function creaUtente(email: string): Promise<string> {
|
||||||
|
const res = await fetch(`${SUPABASE}/auth/v1/admin/users`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authAdmin, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
|
||||||
|
});
|
||||||
|
const corpo = (await res.json()) as { id?: string };
|
||||||
|
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
|
||||||
|
idUtenti.push(corpo.id);
|
||||||
|
return corpo.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accedi(email: string): Promise<string> {
|
||||||
|
const res = await fetch(`${SUPABASE}/auth/v1/token?grant_type=password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { apikey: ANON, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password: PASSWORD }),
|
||||||
|
});
|
||||||
|
const corpo = (await res.json()) as { access_token?: string };
|
||||||
|
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
|
||||||
|
return corpo.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailGiocatore = `test-notifiche-giocatore-${Date.now()}@example.test`;
|
||||||
|
const emailAdmin = `test-notifiche-admin-${Date.now()}@example.test`;
|
||||||
|
await creaUtente(emailGiocatore);
|
||||||
|
const idAdmin = await creaUtente(emailAdmin);
|
||||||
|
await fetch(`${SUPABASE}/rest/v1/user_roles`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authAdmin, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenGiocatore = await accedi(emailGiocatore);
|
||||||
|
const tokenAdmin = await accedi(emailAdmin);
|
||||||
|
|
||||||
|
const server = await avviaServer();
|
||||||
|
console.log(`notifiche-attive-route su ${server.baseUrl} (database ${SUPABASE})`);
|
||||||
|
|
||||||
|
const PERCORSO = "/api/public/notifiche-attive";
|
||||||
|
const chiama = (intestazioni: Record<string, string> = {}) =>
|
||||||
|
fetch(`${server.baseUrl}${PERCORSO}`, { headers: intestazioni });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova("senza token la route non risponde", async () => {
|
||||||
|
assert.equal((await chiama()).status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un giocatore autenticato non è admin", async () => {
|
||||||
|
const res = await chiama({ authorization: `Bearer ${tokenGiocatore}` });
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un amministratore riceve l'elenco degli id", async () => {
|
||||||
|
const res = await chiama({ authorization: `Bearer ${tokenAdmin}` });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const corpo = (await json(res)) as { giocatoreIds: unknown };
|
||||||
|
assert.ok(Array.isArray(corpo.giocatoreIds), "giocatoreIds è un array");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
server.stop();
|
||||||
|
for (const id of idUtenti) {
|
||||||
|
await fetch(`${SUPABASE}/rest/v1/user_roles?user_id=eq.${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: authAdmin,
|
||||||
|
});
|
||||||
|
await fetch(`${SUPABASE}/auth/v1/admin/users/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: authAdmin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
riepilogo("notifiche-attive-route");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,13 @@
|
|||||||
* M2 non è ancora applicata a quel database: sono stati dell'ambiente, non difetti.
|
* M2 non è ancora applicata a quel database: sono stati dell'ambiente, non difetti.
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { COLONNE_PROFILO } from "@/lib/profili-core";
|
|
||||||
import { envDaFile } from "../helpers/server";
|
import { envDaFile } from "../helpers/server";
|
||||||
|
|
||||||
|
// Elenco storico delle colonne Supabase di profili_giocatore (M2/DD-016). Non più importato
|
||||||
|
// da src/lib/profili-core.ts da quando il livello dati è passato a PocketBase (Fase 4 della
|
||||||
|
// migrazione): questo test valida ancora lo schema Supabase, quindi lo tiene qui in locale.
|
||||||
|
const COLONNE_PROFILO =
|
||||||
|
"giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email, documento_tipo, documento_numero, documento_rilasciato_da, documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path, certificato_scadenza, certificato_path, foto_path";
|
||||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
const env = { ...envDaFile(), ...process.env };
|
const env = { ...envDaFile(), ...process.env };
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Check dell'avatar giocatore: `bun test/unit/avatar-store.test.ts`.
|
* Check dell'avatar giocatore: `bun test/unit/avatar-store.test.ts`.
|
||||||
* `urlAvatar` è puro (il bucket è pubblico, nessuna richiesta di rete): qui
|
* `urlAvatarDaRiga` è puro (il campo non è protetto, nessuna richiesta di rete): qui
|
||||||
* verifichiamo solo la forma dell'URL, non serve un database.
|
* verifichiamo solo la forma dell'URL a partire da una riga finta, non serve un database.
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { urlAvatar } from "@/lib/avatar-store";
|
import { urlAvatarDaRiga } from "@/lib/avatar-store";
|
||||||
|
|
||||||
const url = urlAvatar("g1");
|
const riga = { id: "rec1", giocatore: "g1", foto: "avatar_abc123.jpg" };
|
||||||
|
|
||||||
assert.ok(url.includes("avatar-giocatori"), "punta al bucket degli avatar");
|
const url = urlAvatarDaRiga(riga);
|
||||||
assert.ok(url.includes("g1/avatar.jpg"), "il percorso è <id>/avatar.jpg");
|
|
||||||
assert.equal(urlAvatar("g1"), url, "deterministico: nessuna chiamata di rete coinvolta");
|
assert.ok(url.includes("avatar_giocatori"), "punta alla collection degli avatar");
|
||||||
assert.notEqual(urlAvatar("g2"), url, "id diversi -> percorsi diversi");
|
assert.ok(url.includes("rec1"), "contiene l'id del record");
|
||||||
|
assert.ok(url.includes("avatar_abc123.jpg"), "contiene il nome del file");
|
||||||
|
assert.equal(
|
||||||
|
urlAvatarDaRiga(riga),
|
||||||
|
url,
|
||||||
|
"deterministico a parità di riga: nessuna chiamata di rete",
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
urlAvatarDaRiga({ ...riga, foto: "altro.jpg" }),
|
||||||
|
url,
|
||||||
|
"file diverso -> URL diverso",
|
||||||
|
);
|
||||||
|
|
||||||
console.log("avatar-store: ok");
|
console.log("avatar-store: ok");
|
||||||
|
|||||||
+10
-14
@@ -23,10 +23,10 @@ const riga: RigaEvento = {
|
|||||||
campionato: true,
|
campionato: true,
|
||||||
casa: true,
|
casa: true,
|
||||||
pagelle_chiuse: false,
|
pagelle_chiuse: false,
|
||||||
creato_il: "2026-08-20T09:00:00Z",
|
created: "2026-08-20 09:00:00.000Z",
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- daRiga: i NULL del database diventano valori sicuri ---------------------
|
// --- daRiga: normalizza data/creato_il, PocketBase non ha NULL (solo "", [], false) -----
|
||||||
assert.deepEqual(daRiga(riga), {
|
assert.deepEqual(daRiga(riga), {
|
||||||
id: "e1",
|
id: "e1",
|
||||||
tipo: "partita",
|
tipo: "partita",
|
||||||
@@ -39,20 +39,20 @@ assert.deepEqual(daRiga(riga), {
|
|||||||
campionato: true,
|
campionato: true,
|
||||||
casa: true,
|
casa: true,
|
||||||
pagelleChiuse: false,
|
pagelleChiuse: false,
|
||||||
creatoIl: "2026-08-20T09:00:00Z",
|
creatoIl: "2026-08-20T09:00:00.000Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
const vuota = daRiga({
|
const vuota = daRiga({
|
||||||
...riga,
|
...riga,
|
||||||
note: null,
|
note: "",
|
||||||
convocati: null,
|
convocati: [],
|
||||||
casa: null,
|
casa: false,
|
||||||
campionato: false,
|
campionato: false,
|
||||||
pagelle_chiuse: false,
|
pagelle_chiuse: false,
|
||||||
});
|
});
|
||||||
assert.equal(vuota.note, "", "note NULL → stringa vuota");
|
assert.equal(vuota.note, "", "note vuota resta vuota");
|
||||||
assert.deepEqual(vuota.convocati, [], "convocati NULL → tutta la rosa (array vuoto)");
|
assert.deepEqual(vuota.convocati, [], "convocati vuoti → tutta la rosa (array vuoto)");
|
||||||
assert.equal(vuota.casa, true, "casa NULL → si gioca in casa");
|
assert.equal(vuota.casa, false, "casa è quello che c'è in database, nessun default implicito");
|
||||||
assert.equal(vuota.pagelleChiuse, false);
|
assert.equal(vuota.pagelleChiuse, false);
|
||||||
|
|
||||||
// --- categoriaEvento: l'amichevole è una partita fuori campionato ------------
|
// --- categoriaEvento: l'amichevole è una partita fuori campionato ------------
|
||||||
@@ -100,11 +100,7 @@ assert.deepEqual(
|
|||||||
["g2"],
|
["g2"],
|
||||||
"con i convocati indicati si filtra",
|
"con i convocati indicati si filtra",
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(convocatiEvento(daRiga({ ...riga, convocati: [] }), rosa), rosa, "vuoto = tutti");
|
||||||
convocatiEvento(daRiga({ ...riga, convocati: null }), rosa),
|
|
||||||
rosa,
|
|
||||||
"vuoto = tutti",
|
|
||||||
);
|
|
||||||
assert.deepEqual(convocatiEvento(null, rosa), rosa, "senza evento restano tutti");
|
assert.deepEqual(convocatiEvento(null, rosa), rosa, "senza evento restano tutti");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
convocatiEvento(daRiga({ ...riga, convocati: ["ignoto"] }), rosa),
|
convocatiEvento(daRiga({ ...riga, convocati: ["ignoto"] }), rosa),
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* Check della segnalazione errori verso l'editor Lovable: `bun test/unit/lovable-error-reporting.test.ts`.
|
|
||||||
* Fuori dal browser (nessun `window`) deve essere un no-op silenzioso; qui simuliamo
|
|
||||||
* anche un `window` minimale per verificare cosa viene inoltrato al hook di reporting.
|
|
||||||
*/
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { reportLovableError } from "@/lib/lovable-error-reporting";
|
|
||||||
|
|
||||||
// --- fuori dal browser: nessun window, nessun crash ---------------------------
|
|
||||||
assert.equal(typeof window, "undefined", "il test gira in ambiente server, senza DOM");
|
|
||||||
assert.doesNotThrow(() => reportLovableError(new Error("boom")));
|
|
||||||
|
|
||||||
// --- con un window simulato: inoltra al hook dell'editor ----------------------
|
|
||||||
type Riportato = { message: string; stack?: string; filename?: string };
|
|
||||||
let riportato: Riportato | undefined;
|
|
||||||
const finto = {
|
|
||||||
location: { pathname: "/rosa" },
|
|
||||||
__lovableReportRuntimeError: (payload: Riportato) => {
|
|
||||||
riportato = payload;
|
|
||||||
},
|
|
||||||
} as unknown as Window & typeof globalThis;
|
|
||||||
|
|
||||||
(globalThis as { window?: unknown }).window = finto;
|
|
||||||
try {
|
|
||||||
reportLovableError(new Error("qualcosa è andato storto"));
|
|
||||||
assert.ok(riportato, "il payload è stato inoltrato");
|
|
||||||
assert.equal(riportato!.message, "qualcosa è andato storto");
|
|
||||||
assert.equal(riportato!.filename, "/rosa");
|
|
||||||
assert.ok(riportato!.stack, "include lo stack per un Error");
|
|
||||||
|
|
||||||
// Una Response non ha un messaggio leggibile: si usa status + url.
|
|
||||||
riportato = undefined;
|
|
||||||
reportLovableError(new Response(null, { status: 404 }));
|
|
||||||
assert.equal(riportato!.message, "Response 404");
|
|
||||||
assert.equal(riportato!.stack, undefined, "una Response non ha stack");
|
|
||||||
|
|
||||||
// Un valore qualunque diventa la sua stringa.
|
|
||||||
riportato = undefined;
|
|
||||||
reportLovableError("motivo generico");
|
|
||||||
assert.equal(riportato!.message, "motivo generico");
|
|
||||||
} finally {
|
|
||||||
delete (globalThis as { window?: unknown }).window;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(typeof window, "undefined", "il window simulato non è rimasto in giro");
|
|
||||||
|
|
||||||
console.log("lovable-error-reporting: ok");
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Check della deduplica per la sezione Notifiche in admin:
|
||||||
|
* `bun test/unit/notifiche-attive.test.ts`.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { idsConNotificheAttive } from "@/lib/notifiche-attive.server";
|
||||||
|
|
||||||
|
assert.deepEqual(idsConNotificheAttive([]), [], "nessuna riga -> nessun id");
|
||||||
|
|
||||||
|
assert.deepEqual(idsConNotificheAttive([{ giocatore_id: "g1" }]), ["g1"], "una riga -> un id");
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
idsConNotificheAttive([{ giocatore_id: "g1" }, { giocatore_id: "g1" }, { giocatore_id: "g2" }]),
|
||||||
|
["g1", "g2"],
|
||||||
|
"più dispositivi dello stesso giocatore contano una volta sola",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
idsConNotificheAttive([{ giocatore_id: "g2" }, { giocatore_id: "g1" }]),
|
||||||
|
["g2", "g1"],
|
||||||
|
"ordine di prima comparsa, non alfabetico",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("notifiche-attive: ok");
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
/** Check dei profili giocatore: `bun test/unit/profili-core.test.ts`. */
|
/** Check dei profili giocatore: `bun test/unit/profili-core.test.ts`. */
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {
|
import {
|
||||||
aRigaProfilo,
|
|
||||||
completamento,
|
completamento,
|
||||||
csvTesseramento,
|
csvTesseramento,
|
||||||
daRigaProfilo,
|
|
||||||
sezioniComplete,
|
sezioniComplete,
|
||||||
statoScadenza,
|
statoScadenza,
|
||||||
type Profilo,
|
type Profilo,
|
||||||
@@ -19,6 +17,7 @@ import {
|
|||||||
import { dividiNome } from "@/lib/crapp-data";
|
import { dividiNome } from "@/lib/crapp-data";
|
||||||
|
|
||||||
const vuoto: Profilo = {
|
const vuoto: Profilo = {
|
||||||
|
id: null,
|
||||||
giocatoreId: "g1",
|
giocatoreId: "g1",
|
||||||
dataNascita: null,
|
dataNascita: null,
|
||||||
luogoNascita: null,
|
luogoNascita: null,
|
||||||
@@ -77,17 +76,6 @@ assert.equal(
|
|||||||
);
|
);
|
||||||
assert.equal(sezioniComplete({ ...completo, documentoFrontePath: null }).documento, false);
|
assert.equal(sezioniComplete({ ...completo, documentoFrontePath: null }).documento, false);
|
||||||
|
|
||||||
// --- aRigaProfilo ------------------------------------------------------------
|
|
||||||
const riga = aRigaProfilo({ ...completo, luogoNascita: " ", telefono: " 333 " });
|
|
||||||
assert.equal(riga.luogo_nascita, null, "i campi solo-spazi tornano NULL, non stringa vuota");
|
|
||||||
assert.equal(riga.telefono, "333", "il resto viene ripulito ai bordi");
|
|
||||||
assert.equal(riga.documento_fronte_path, "g1/documento-fronte.jpg");
|
|
||||||
assert.deepEqual(
|
|
||||||
daRigaProfilo(aRigaProfilo(completo)),
|
|
||||||
completo,
|
|
||||||
"modello -> riga -> modello non perde niente",
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- statoScadenza -----------------------------------------------------------
|
// --- statoScadenza -----------------------------------------------------------
|
||||||
const oggi = "2026-08-30";
|
const oggi = "2026-08-30";
|
||||||
assert.equal(statoScadenza(null, null, oggi), "mancante");
|
assert.equal(statoScadenza(null, null, oggi), "mancante");
|
||||||
|
|||||||
+91
-13
@@ -1,15 +1,93 @@
|
|||||||
// @lovable.dev/vite-tanstack-config already includes the following — do NOT add them manually
|
import { defineConfig, loadEnv, mergeConfig } from "vite";
|
||||||
// or the app will break with duplicate plugins:
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
// - TanStack devtools (dev-only, first), tanstackStart, viteReact, tailwindcss, tsConfigPaths,
|
import tsConfigPaths from "vite-tsconfig-paths";
|
||||||
// nitro (build-only using cloudflare as a default target), VITE_* env injection, @ path alias,
|
import viteReact from "@vitejs/plugin-react";
|
||||||
// React/TanStack dedupe, error logger plugins, and sandbox detection (port/host/strictPort).
|
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||||
// You can pass additional config via defineConfig({ vite: { ... }, etc... }) if needed.
|
import { devtools } from "@tanstack/devtools-vite";
|
||||||
import { defineConfig } from "@lovable.dev/vite-tanstack-config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(async ({ command, mode }) => {
|
||||||
tanstackStart: {
|
const isDevBuild = command === "build" && mode === "development";
|
||||||
// Redirect TanStack Start's bundled server entry to src/server.ts (our SSR error wrapper).
|
|
||||||
// nitro/vite builds from this
|
const loadedEnv = loadEnv(mode, process.cwd(), "VITE_");
|
||||||
server: { entry: "server" },
|
const envDefine: Record<string, string> = {};
|
||||||
},
|
for (const [key, value] of Object.entries(loadedEnv)) {
|
||||||
|
envDefine[`import.meta.env.${key}`] = JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = [];
|
||||||
|
if (mode === "development") {
|
||||||
|
plugins.push(
|
||||||
|
devtools({
|
||||||
|
logging: false,
|
||||||
|
eventBusConfig: { enabled: false },
|
||||||
|
enhancedLogs: { enabled: false },
|
||||||
|
consolePiping: { enabled: false },
|
||||||
|
removeDevtoolsOnBuild: false,
|
||||||
|
injectSource: { enabled: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
plugins.push(tailwindcss());
|
||||||
|
plugins.push(tsConfigPaths({ projects: ["./tsconfig.json"] }));
|
||||||
|
plugins.push(
|
||||||
|
tanstackStart({
|
||||||
|
importProtection: {
|
||||||
|
behavior: "error",
|
||||||
|
client: { files: ["**/server/**"], specifiers: ["server-only"] },
|
||||||
|
},
|
||||||
|
// Redirect TanStack Start's bundled server entry to src/server.ts (our SSR error wrapper).
|
||||||
|
// nitro/vite builds from this
|
||||||
|
server: { entry: "server" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (command === "build") {
|
||||||
|
const { nitro } = await import("nitro/vite");
|
||||||
|
plugins.push(nitro({ defaultPreset: "cloudflare-module" }));
|
||||||
|
}
|
||||||
|
plugins.push(viteReact());
|
||||||
|
|
||||||
|
let config = {
|
||||||
|
define: envDefine,
|
||||||
|
...(isDevBuild
|
||||||
|
? {
|
||||||
|
environments: {
|
||||||
|
client: { define: { "process.env.NODE_ENV": JSON.stringify("development") } },
|
||||||
|
},
|
||||||
|
esbuild: { keepNames: true },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
css: { transformer: "lightningcss" as const },
|
||||||
|
resolve: {
|
||||||
|
alias: { "@": `${process.cwd()}/src` },
|
||||||
|
dedupe: [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react/jsx-runtime",
|
||||||
|
"react/jsx-dev-runtime",
|
||||||
|
"@tanstack/react-query",
|
||||||
|
"@tanstack/query-core",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-dom/client",
|
||||||
|
"react/jsx-runtime",
|
||||||
|
"react/jsx-dev-runtime",
|
||||||
|
],
|
||||||
|
ignoreOutdatedRequests: true,
|
||||||
|
},
|
||||||
|
plugins,
|
||||||
|
};
|
||||||
|
|
||||||
|
config = mergeConfig(config, {
|
||||||
|
server: {
|
||||||
|
host: "::",
|
||||||
|
port: 8080,
|
||||||
|
watch: { awaitWriteFinish: { stabilityThreshold: 1000, pollInterval: 100 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user