5 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5 c1744d4087 Sposta avatar e documenti profilo su PocketBase (file field)
Sostituisce i due bucket Supabase Storage con campi file PocketBase:

- avatar-giocatori: nuova collection avatar_giocatori separata da
  giocatori_squadra, perché la policy originale ("chiunque autenticato
  carica/sostituisce/elimina qualsiasi avatar, nessun controllo
  proprietario") è più permissiva delle regole di giocatori_squadra —
  mescolarle avrebbe indebolito le une o bloccato l'altra. File pubblico,
  come il bucket originale;
- profili-giocatore: campi file su profili_giocatore stesso, con
  protected: true (vedi sotto).

Trovato e corretto un problema di sicurezza reale: PocketBase rende i
file pubblici di default (si affida solo alla casualità del nome file),
a meno di impostare esplicitamente protected: true sul campo — i
documenti d'identità e i certificati medici sarebbero stati raggiungibili
senza autenticazione, in violazione di DD-016 regola 4. Verificato che
ora servono un token valido (404 senza, 200 con).

La scrittura dei campi testuali del profilo e il caricamento dei file
sono due operazioni separate (PocketBase rifiuta una stringa dove si
aspetta un file): verificato che caricare un documento non cancella i
dati testuali già salvati.

Rimossi da profili-core.ts RigaProfilo/daRigaProfilo/aRigaProfilo
(shape Postgres non più usata da nessun modulo di produzione) e
rimuoviFile (mai chiamato da nessun componente). Avatar.tsx non usa più
un URL deterministico per giocatore: PocketBase genera nomi file
casuali, quindi legge la mappa avatar tramite una query React Query
condivisa tra tutte le istanze del componente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:58:54 +02:00
davideandClaude Sonnet 5 d125bdfb6a Sposta le route di notifiche push su PocketBase
Le route server in src/routes/api/public/ (promemoria-palloni,
sollecita-presenze, apri-sondaggio, push-subscribe, notifiche-attive)
usano pbAdmin al posto di supabaseAdmin per leggere/scrivere
push_subscriptions e i dati necessari a comporre gli avvisi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:58:40 +02:00
davideandClaude Sonnet 5 689468141d Sposta roster, eventi, presenze, voti, scout e palloni su PocketBase
Riscrive i moduli dati che parlavano con Supabase per usare l'SDK
PocketBase, mantenendo invariate le firme degli hook React Query così i
componenti a valle non cambiano: giocatori-squadra, eventi, presenze,
cacche, pagelle, mvp-voti, badge-social, scout (store/live/stato),
palloni.

Aggiunge due helper condivisi in src/integrations/pocketbase/:
- upsert.ts: PocketBase non ha upsert nativo, questi replicano il
  pattern onConflict di Supabase (per id significativo o per filtro su
  combinazione di campi), verificati contro un'istanza reale così da non
  duplicare record sulla stessa scrittura ripetuta;
- formato.ts: normalizza le date PocketBase ("AAAA-MM-GG HH:MM:SS.sssZ",
  spazio non "T") a ISO stretto, altrimenti Date.parse e i confronti
  testuali con altre date si comportano in modo incoerente.

Corregge anche due problemi trovati testando contro un'istanza reale:
- alcune API rule confrontavano un campo relazione con @request.auth.id
  senza richiedere l'autenticazione, lasciando passare richieste anonime
  quando entrambi i lati erano vuoti;
- gli hook non riconoscevano il superuser PocketBase (solo il ruolo
  admin applicativo), bloccando le operazioni fatte con pbAdmin.

Aggiunta la colonna "casa" mancante su eventi_app e i campi di sistema
created/updated (non generati automaticamente da PocketBase per le
collection create via migration, a differenza di quanto assunto
inizialmente).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:58:27 +02:00
davideandClaude Sonnet 5 0f5a27ed5f Sposta l'autenticazione da Supabase a PocketBase
Login Google via PocketBase invece che Supabase Auth: nuovi client
in src/integrations/pocketbase/ (browser, server con superuser, attacher
del bearer token per le server function). Il ruolo admin/user vive ora
come campo diretto sul record utente PocketBase invece che nella tabella
separata user_roles, quindi ruoli.ts e la verifica in auth-route.server.ts
non hanno più bisogno di una query aggiuntiva.

requireSupabaseAuth/auth-middleware.ts non viene riportato: non era
importato da nessun file, era codice morto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:58:09 +02:00
davideandClaude Sonnet 5 15885f32c7 Aggiunge infrastruttura PocketBase locale (Docker, schema, hook)
Introduce PocketBase come sostituto di Supabase solo per l'ambiente di
sviluppo locale (docs/PORTABILITA.md): docker-compose.pocketbase.yml,
schema iniziale in pocketbase/pb_migrations/ (collection equivalenti alle
tabelle Supabase, con le stesse API rule dove esprimibili) e la logica
procedurale che in Postgres viveva in trigger/funzioni riscritta come
hook JS in pocketbase/pb_hooks/ (claim slot giocatore, blocco autovoto,
convocati/pagelle chiuse, immutabilità di risposto_il).

Il superuser e il provider Google OAuth2 si ricreano da soli a ogni
avvio da variabili d'ambiente (script scripts/pocketbase-reset.sh),
così un reset non richiede passaggi manuali da dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:57:55 +02:00
62 changed files with 1970 additions and 1211 deletions
+4
View File
@@ -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
+9
View File
@@ -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
+3
View File
@@ -15,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",
@@ -651,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=="],
+38
View File
@@ -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
+5 -13
View File
@@ -1,11 +1,10 @@
# Modulo — Calendario ed Eventi # Modulo — Calendario ed Eventi
**Stato:** implementato **Stato:** implementato
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts` **File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/routes/calendario.tsx`
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti), (vista mensile, tutti), `src/routes/eventi.tsx` (creazione/modifica, solo admin),
`src/routes/eventi.tsx` (creazione/modifica, solo admin), `src/components/crapp/EventoCard.tsx` `src/components/crapp/EventoCard.tsx` (card condivisa)
(card condivisa) **Test:** `test/unit/eventi.test.ts`
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
--- ---
@@ -27,14 +26,7 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato` rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata (`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). Sopra alla lista in interfaccia e la coppia `{ tipo, campionato }` salvata nel database).
cronologica c'è una griglia mensile (stessa logica di `/calendario`, tramite le funzioni
condivise di `src/lib/calendario.ts`): ogni giorno è cliccabile, anche senza eventi, e apre
un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento in
questo giorno" che apre il form con la data già precompilata. Creare, modificare ed
eliminare passano solo da lì: la lista cronologica sotto il calendario è un elenco senza
azioni dirette, cliccare una riga apre lo stesso drawer del giorno corrispondente (anche se
è in un mese diverso da quello mostrato sulla griglia) invece di duplicare matita/cestino.
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate; calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
+1 -11
View File
@@ -83,22 +83,12 @@ variabile d'ambiente.
| Route | Controllo | Chi la chiama | | Route | Controllo | Chi la chiama |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin | | `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 `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" un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo. della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate:
l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non
l'ha ancora attivata.
`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId`
lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a
quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto
dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione
che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard
admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore.
--- ---
+1 -1
View File
@@ -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}"],
+5 -1
View File
@@ -14,7 +14,10 @@
"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": {
"@supabase/supabase-js": "^2.111.0", "@supabase/supabase-js": "^2.111.0",
@@ -27,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",
View File
@@ -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");
+23
View File
@@ -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");
+137
View File
@@ -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);
},
);
+19
View File
@@ -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/_/"
+7 -3
View File
@@ -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}
+14 -6
View File
@@ -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">
@@ -313,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")}
@@ -320,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")}
@@ -330,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")}
@@ -341,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")}
@@ -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;
}
+28
View File
@@ -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);
},
});
+8
View File
@@ -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");
}
+52
View File
@@ -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;
}
}
+26 -19
View File
@@ -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")
.maybeSingle();
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
return null; return null;
} catch {
return new Response("Sessione non valida", { status: 401 });
}
} }
+23 -34
View File
@@ -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); setPronta(true);
}) // A differenza di Supabase, l'authStore di PocketBase è già popolato in modo
.catch(() => attivo && setPronta(true)); // sincrono all'avvio (legge localStorage nel costruttore): non serve una chiamata
const { data } = supabase.auth.onAuthStateChange((_evento, nuova) => setSessione(nuova)); // asincrona equivalente a getSession().
return () => { return pb.authStore.onChange((_token, record) => setUtente(record));
attivo = false;
data.subscription.unsubscribe();
};
} catch (errore) { } catch (errore) {
console.error("[auth] Supabase non disponibile", errore); console.error("[auth] PocketBase non disponibile", errore);
setPronta(true); setPronta(true);
return () => { return undefined;
attivo = false;
};
} }
}, []); }, []);
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
View File
@@ -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
View File
@@ -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
View File
@@ -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) => {
-63
View File
@@ -1,63 +0,0 @@
import { useState } from "react";
/** Etichette condivise tra `/calendario` (sola lettura) ed `/eventi` (griglia di gestione). */
export const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
export const mesiIT = [
"Gennaio",
"Febbraio",
"Marzo",
"Aprile",
"Maggio",
"Giugno",
"Luglio",
"Agosto",
"Settembre",
"Ottobre",
"Novembre",
"Dicembre",
] as const;
export function pad2(n: number) {
return String(n).padStart(2, "0");
}
/** Numero di giorni nel mese (0-indicizzato) e offset del primo giorno rispetto a lunedì. */
export function giorniDelMese(anno: number, mese: number) {
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
const offsetLunedi = (primoGiorno + 6) % 7;
return { giorni, offsetLunedi };
}
/** Stato di navigazione mese per mese, condiviso dalle griglie calendario. */
export function useMeseNav(initial?: { anno: number; mese: number }) {
const oggi = new Date();
const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear());
const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth());
// Serve a far entrare e uscire la griglia dallo stesso lato del gesto:
// se un mese esce a sinistra, il precedente deve rientrare da sinistra.
const [direzione, setDirezione] = useState(0);
const precedente = () => {
setDirezione(-1);
if (mese === 0) {
setMese(11);
setAnno((a) => a - 1);
} else {
setMese((m) => m - 1);
}
};
const successivo = () => {
setDirezione(1);
if (mese === 11) {
setMese(0);
setAnno((a) => a + 1);
} else {
setMese((m) => m + 1);
}
};
return { anno, mese, direzione, precedente, successivo };
}
+4 -6
View File
@@ -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);
} }
+26 -28
View File
@@ -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. */
@@ -104,7 +108,7 @@ export function eventoVuoto(): Evento {
id: nuovoIdEvento(), id: nuovoIdEvento(),
tipo: "allenamento", tipo: "allenamento",
titolo: "", titolo: "",
luogo: "", luogo: "Palestra Comunale",
data: new Date().toISOString().slice(0, 10), data: new Date().toISOString().slice(0, 10),
ora: "20:30", ora: "20:30",
note: "", note: "",
@@ -119,9 +123,7 @@ 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, {
{
id: evento.id,
tipo: evento.tipo, tipo: evento.tipo,
titolo: evento.titolo, titolo: evento.titolo,
luogo: evento.luogo, luogo: evento.luogo,
@@ -132,10 +134,7 @@ export function useSalvaEvento() {
campionato: evento.campionato, campionato: evento.campionato,
casa: evento.casa, casa: evento.casa,
pagelle_chiuse: evento.pagelleChiuse, 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) => {
+8 -12
View File
@@ -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);
} }
+59 -65
View File
@@ -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) => {
+28 -10
View File
@@ -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.
+1 -16
View File
@@ -1,4 +1,4 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { intestazioniAutenticate } from "./auth"; import { intestazioniAutenticate } from "./auth";
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const; const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
@@ -20,18 +20,3 @@ export function useNotificheAttive() {
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
} }
/** Invia un messaggio push libero a un giocatore, o a tutti se `giocatoreId` è omesso. */
export function useInviaNotifica() {
return useMutation({
mutationFn: async ({ messaggio, giocatoreId }: { messaggio: string; giocatoreId?: string }) => {
const res = await fetch("/api/public/notifica-personalizzata", {
method: "POST",
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ messaggio, giocatoreId }),
});
if (!res.ok) throw new Error("Impossibile inviare la notifica");
return (await res.json()) as { inviate: number; destinatari: number };
},
});
}
+28 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) =>
-22
View File
@@ -23,7 +23,6 @@ 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 ApiPublicNotificaPersonalizzataRouteImport } from './routes/api/public/notifica-personalizzata'
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive' 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'
@@ -101,12 +100,6 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
path: '/api/public/csi', path: '/api/public/csi',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiPublicNotificaPersonalizzataRoute =
ApiPublicNotificaPersonalizzataRouteImport.update({
id: '/api/public/notifica-personalizzata',
path: '/api/public/notifica-personalizzata',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicNotificheAttiveRoute = const ApiPublicNotificheAttiveRoute =
ApiPublicNotificheAttiveRouteImport.update({ ApiPublicNotificheAttiveRouteImport.update({
id: '/api/public/notifiche-attive', id: '/api/public/notifiche-attive',
@@ -156,7 +149,6 @@ 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/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute '/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
@@ -179,7 +171,6 @@ 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/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute '/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
@@ -203,7 +194,6 @@ 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/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute '/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
@@ -228,7 +218,6 @@ export interface FileRouteTypes {
| '/partita/$id' | '/partita/$id'
| '/api/public/apri-sondaggio' | '/api/public/apri-sondaggio'
| '/api/public/csi' | '/api/public/csi'
| '/api/public/notifica-personalizzata'
| '/api/public/notifiche-attive' | '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni' | '/api/public/promemoria-palloni'
| '/api/public/push-config' | '/api/public/push-config'
@@ -251,7 +240,6 @@ export interface FileRouteTypes {
| '/partita/$id' | '/partita/$id'
| '/api/public/apri-sondaggio' | '/api/public/apri-sondaggio'
| '/api/public/csi' | '/api/public/csi'
| '/api/public/notifica-personalizzata'
| '/api/public/notifiche-attive' | '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni' | '/api/public/promemoria-palloni'
| '/api/public/push-config' | '/api/public/push-config'
@@ -274,7 +262,6 @@ export interface FileRouteTypes {
| '/partita/$id' | '/partita/$id'
| '/api/public/apri-sondaggio' | '/api/public/apri-sondaggio'
| '/api/public/csi' | '/api/public/csi'
| '/api/public/notifica-personalizzata'
| '/api/public/notifiche-attive' | '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni' | '/api/public/promemoria-palloni'
| '/api/public/push-config' | '/api/public/push-config'
@@ -298,7 +285,6 @@ export interface RootRouteChildren {
PartitaIdRoute: typeof PartitaIdRoute PartitaIdRoute: typeof PartitaIdRoute
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
ApiPublicCsiRoute: typeof ApiPublicCsiRoute ApiPublicCsiRoute: typeof ApiPublicCsiRoute
ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
@@ -407,13 +393,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiPublicCsiRouteImport preLoaderRoute: typeof ApiPublicCsiRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/public/notifica-personalizzata': {
id: '/api/public/notifica-personalizzata'
path: '/api/public/notifica-personalizzata'
fullPath: '/api/public/notifica-personalizzata'
preLoaderRoute: typeof ApiPublicNotificaPersonalizzataRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/notifiche-attive': { '/api/public/notifiche-attive': {
id: '/api/public/notifiche-attive' id: '/api/public/notifiche-attive'
path: '/api/public/notifiche-attive' path: '/api/public/notifiche-attive'
@@ -474,7 +453,6 @@ const rootRouteChildren: RootRouteChildren = {
PartitaIdRoute: PartitaIdRoute, PartitaIdRoute: PartitaIdRoute,
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute, ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
ApiPublicCsiRoute: ApiPublicCsiRoute, ApiPublicCsiRoute: ApiPublicCsiRoute,
ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute,
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute, ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute, ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute, ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
+16 -126
View File
@@ -3,7 +3,6 @@ import { createFileRoute } from "@tanstack/react-router";
import { import {
BadgeCheck, BadgeCheck,
Bell, Bell,
BellOff,
ChevronDown, ChevronDown,
Download, Download,
FileText, FileText,
@@ -11,7 +10,6 @@ import {
Image, Image,
Loader2, Loader2,
Lock, Lock,
Send,
Unlink, Unlink,
UserCheck, UserCheck,
UserPlus, UserPlus,
@@ -51,17 +49,8 @@ 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 { useInviaNotifica, useNotificheAttive } from "@/lib/notifiche-admin"; import { useNotificheAttive } from "@/lib/notifiche-admin";
import { Reveal } from "@/components/motion/Reveal"; import { Reveal } from "@/components/motion/Reveal";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
export const Route = createFileRoute("/admin")({ export const Route = createFileRoute("/admin")({
head: () => ({ head: () => ({
@@ -349,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 {
@@ -373,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],
@@ -442,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(
@@ -632,32 +627,6 @@ function Dashboard() {
const { data: notificheAttive } = useNotificheAttive(); const { data: notificheAttive } = useNotificheAttive();
const [schedaAperta, setSchedaAperta] = useState<string | null>(null); const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
const oggi = oggiISO(); const oggi = oggiISO();
const inviaNotifica = useInviaNotifica();
const [destinatarioMessaggio, setDestinatarioMessaggio] = useState<{
id?: string;
nome: string;
} | null>(null);
const [messaggio, setMessaggio] = useState("");
async function inviaMessaggio() {
if (!destinatarioMessaggio || !messaggio.trim()) return;
try {
const dati = await inviaNotifica.mutateAsync(
destinatarioMessaggio.id
? { messaggio: messaggio.trim(), giocatoreId: destinatarioMessaggio.id }
: { messaggio: messaggio.trim() },
);
toast.success(
dati.inviate > 0
? `Notifica inviata a ${dati.inviate} dispositivi`
: "Nessun dispositivo con notifiche attive per l'invio",
);
setDestinatarioMessaggio(null);
setMessaggio("");
} catch {
toast.error("Non sono riuscito a inviare la notifica");
}
}
if (!admin) { if (!admin) {
return ( return (
@@ -740,42 +709,15 @@ function Dashboard() {
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`} valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
label="Notifiche attive" label="Notifiche attive"
/> />
<button
type="button"
onClick={() => setDestinatarioMessaggio({ nome: "tutta la squadra" })}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<Send className="h-4 w-4" /> Invia messaggio a tutti
</button>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{attivi.map((g) => { {attivi
const attiva = notificheAttive.has(g.id); .filter((g) => notificheAttive.has(g.id))
return ( .map((g) => (
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"> <div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
{attiva ? ( <Bell className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Bell className="h-3.5 w-3.5 shrink-0 text-accent" /> <p className="truncate text-sm font-semibold leading-tight">{nomeCompleto(g)}</p>
) : (
<BellOff className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<p
className={cn(
"min-w-0 flex-1 truncate text-sm font-semibold leading-tight",
!attiva && "text-muted-foreground",
)}
>
{nomeCompleto(g)}
</p>
<button
type="button"
onClick={() => setDestinatarioMessaggio({ id: g.id, nome: nomeCompleto(g) })}
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
aria-label={`Invia messaggio a ${nomeCompleto(g)}`}
>
<Send className="h-3.5 w-3.5" />
</button>
</div> </div>
); ))}
})}
</div> </div>
</> </>
) : ( ) : (
@@ -801,58 +743,6 @@ function Dashboard() {
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche }, { id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
]} ]}
/> />
<Drawer
open={!!destinatarioMessaggio}
onOpenChange={(aperto) => {
if (!aperto) {
setDestinatarioMessaggio(null);
setMessaggio("");
}
}}
>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Invia messaggio</DrawerTitle>
<DrawerDescription>
{destinatarioMessaggio ? `Destinatario: ${destinatarioMessaggio.nome}.` : ""}
</DrawerDescription>
</DrawerHeader>
<div className="px-4">
<textarea
value={messaggio}
maxLength={300}
rows={3}
onChange={(e) => setMessaggio(e.target.value)}
placeholder="Scrivi il messaggio da inviare come notifica push…"
className={cn(classiInput, "h-auto")}
/>
</div>
<DrawerFooter>
<button
type="button"
onClick={inviaMessaggio}
disabled={inviaNotifica.isPending || !messaggio.trim()}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{inviaNotifica.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}{" "}
Invia
</button>
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
>
Annulla
</button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</> </>
); );
} }
+10 -10
View File
@@ -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 });
}, },
}, },
}, },
@@ -1,53 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { inviaPush } from "@/lib/webpush.server";
const schema = z.object({
messaggio: z.string().min(1).max(300),
giocatoreId: z.string().min(1).max(50).optional(),
});
export const Route = createFileRoute("/api/public/notifica-personalizzata")({
server: {
handlers: {
POST: async ({ request }) => {
const negato = await richiediAdmin(request);
if (negato) return negato;
const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
let query = supabaseAdmin.from("push_subscriptions").select("endpoint, p256dh, auth");
if (parsed.data.giocatoreId) {
query = query.eq("giocatore_id", parsed.data.giocatoreId);
}
const { data: iscrizioni } = await query;
const titolo = "Messaggio dallo staff";
const testo = parsed.data.messaggio.trim();
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
try {
const { stato } = await inviaPush(iscrizione, titolo, testo);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
.delete()
.eq("endpoint", iscrizione.endpoint);
} else if (stato >= 200 && stato < 300) {
inviate += 1;
}
} catch (error) {
console.error("notifica-personalizzata", error);
}
}
return Response.json({ inviate, destinatari: iscrizioni?.length ?? 0 });
},
},
},
});
+11 -7
View File
@@ -9,16 +9,20 @@ export const Route = createFileRoute("/api/public/notifiche-attive")({
const negato = await richiediAdmin(request); const negato = await richiediAdmin(request);
if (negato) return negato; if (negato) return negato;
const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
const { data, error } = await supabaseAdmin const admin = await pbAdmin();
.from("push_subscriptions")
.select("giocatore_id"); try {
if (error) { 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); console.error("notifiche-attive", error);
return new Response("Errore lettura", { status: 500 }); return new Response("Errore lettura", { status: 500 });
} }
return Response.json({ giocatoreIds: idsConNotificheAttive(data ?? []) });
}, },
}, },
}, },
+23 -19
View File
@@ -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 });
}, },
}, },
}, },
+21 -10
View File
@@ -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();
try {
await upsertByFilter(
"push_subscriptions",
"endpoint = {:endpoint}",
{ endpoint: parsed.data.endpoint },
{ {
endpoint: parsed.data.endpoint, endpoint: parsed.data.endpoint,
giocatore_id: parsed.data.giocatoreId, giocatore: parsed.data.giocatoreId,
p256dh: parsed.data.p256dh, p256dh: parsed.data.p256dh,
auth: parsed.data.auth, auth: parsed.data.auth,
}, },
{ onConflict: "endpoint" }, admin,
); );
if (error) { } 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 });
}, },
}, },
+19 -15
View File
@@ -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;
} }
+59 -1
View File
@@ -11,7 +11,6 @@ import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi"; import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
import { useAnagraficaRosa } from "@/lib/rosa"; import { useAnagraficaRosa } from "@/lib/rosa";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
import { import {
Drawer, Drawer,
DrawerClose, DrawerClose,
@@ -38,6 +37,8 @@ export const Route = createFileRoute("/calendario")({
component: Calendario, component: Calendario,
}); });
const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
/** Colore per tipo di evento, usato per dividere le celle con più tipi. */ /** Colore per tipo di evento, usato per dividere le celle con più tipi. */
const coloreTipo: Record<Evento["tipo"], string> = { const coloreTipo: Record<Evento["tipo"], string> = {
partita: "var(--accent)", partita: "var(--accent)",
@@ -46,6 +47,63 @@ const coloreTipo: Record<Evento["tipo"], string> = {
compleanno: "var(--success)", compleanno: "var(--success)",
}; };
const mesiIT = [
"Gennaio",
"Febbraio",
"Marzo",
"Aprile",
"Maggio",
"Giugno",
"Luglio",
"Agosto",
"Settembre",
"Ottobre",
"Novembre",
"Dicembre",
] as const;
function useMeseNav(initial?: { anno: number; mese: number }) {
const oggi = new Date();
const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear());
const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth());
// Serve a far entrare e uscire la griglia dallo stesso lato del gesto:
// se un mese esce a sinistra, il precedente deve rientrare da sinistra.
const [direzione, setDirezione] = useState(0);
const precedente = () => {
setDirezione(-1);
if (mese === 0) {
setMese(11);
setAnno((a) => a - 1);
} else {
setMese((m) => m - 1);
}
};
const successivo = () => {
setDirezione(1);
if (mese === 11) {
setMese(0);
setAnno((a) => a + 1);
} else {
setMese((m) => m + 1);
}
};
return { anno, mese, direzione, precedente, successivo };
}
function giorniDelMese(anno: number, mese: number) {
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
const offsetLunedi = (primoGiorno + 6) % 7;
return { giorni, offsetLunedi };
}
function pad2(n: number) {
return String(n).padStart(2, "0");
}
function Calendario() { function Calendario() {
// SSR-safe: la data di oggi arriva solo dopo il mount. // SSR-safe: la data di oggi arriva solo dopo il mount.
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null); const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
+19 -208
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react"; import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Campo, Card, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits"; import { Campo, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits";
import { import {
Drawer, Drawer,
DrawerClose, DrawerClose,
@@ -14,7 +14,6 @@ import {
DrawerTitle, DrawerTitle,
} from "@/components/ui/drawer"; } from "@/components/ui/drawer";
import { formatData } from "@/lib/crapp-data"; import { formatData } from "@/lib/crapp-data";
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { import {
categoriaEvento, categoriaEvento,
@@ -67,19 +66,8 @@ function GestioneEventi() {
const elimina = useEliminaEvento(); const elimina = useEliminaEvento();
const [bozza, setBozza] = useState<Evento | null>(null); const [bozza, setBozza] = useState<Evento | null>(null);
const [daEliminare, setDaEliminare] = useState<Evento | null>(null); const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
const [confermaModifica, setConfermaModifica] = useState(false);
const rosa = squadra.filter((g) => g.attivo); const rosa = squadra.filter((g) => g.attivo);
// SSR-safe: la data di oggi arriva solo dopo il mount.
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
useEffect(() => {
const d = new Date();
setOggi({ anno: d.getFullYear(), mese: d.getMonth(), giorno: d.getDate() });
}, []);
const [giornoSelezionato, setGiornoSelezionato] = useState<string | null>(null);
const { anno, mese, precedente, successivo } = useMeseNav();
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
if (!io || !admin) { if (!io || !admin) {
return ( return (
<> <>
@@ -97,31 +85,18 @@ function GestioneEventi() {
setBozza((b) => (b ? { ...b, ...patch } : b)); setBozza((b) => (b ? { ...b, ...patch } : b));
} }
const modificaEsistente = bozza ? eventi.some((e) => e.id === bozza.id) : false; async function conferma() {
function chiediConferma() {
if (!bozza) return; if (!bozza) return;
if (!bozza.titolo.trim()) { if (!bozza.titolo.trim()) {
toast.error("Serve un titolo per l'evento"); toast.error("Serve un titolo per l'evento");
return; return;
} }
if (modificaEsistente) {
setConfermaModifica(true);
return;
}
conferma();
}
async function conferma() {
if (!bozza) return;
try { try {
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() }); await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
toast.success("Evento salvato"); toast.success("Evento salvato");
setBozza(null); setBozza(null);
} catch { } catch {
toast.error("Non sono riuscito a salvare l'evento"); toast.error("Non sono riuscito a salvare l'evento");
} finally {
setConfermaModifica(false);
} }
} }
@@ -137,106 +112,20 @@ function GestioneEventi() {
} }
} }
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
const eventiPerGiorno = new Map<number, Evento[]>();
for (const e of eventi) {
if (!e.data.startsWith(mesePrefix)) continue;
const g = Number(e.data.slice(8, 10));
const lista = eventiPerGiorno.get(g) ?? [];
lista.push(e);
eventiPerGiorno.set(g, lista);
}
function apriGiorno(giorno: number) {
setGiornoSelezionato(`${mesePrefix}-${pad2(giorno)}`);
}
function nuovoNelGiorno() {
if (!giornoSelezionato) return;
setBozza({ ...eventoVuoto(), data: giornoSelezionato });
setGiornoSelezionato(null);
}
function modificaDalGiorno(e: Evento) {
setBozza(e);
setGiornoSelezionato(null);
}
function eliminaDalGiorno(e: Evento) {
setDaEliminare(e);
setGiornoSelezionato(null);
}
// Non usa `eventiPerGiorno` (limitato al mese in vista sul calendario): il giorno
// selezionato può arrivare anche dalla lista sotto, per un mese diverso da quello mostrato.
const eventiGiornoSelezionato = giornoSelezionato
? eventi.filter((e) => e.data === giornoSelezionato)
: [];
return ( return (
<> <>
<PageHeader titolo="Gestione eventi" sottotitolo={`${eventi.length} eventi in calendario`} /> <PageHeader titolo="Gestione eventi" sottotitolo={`${eventi.length} eventi in calendario`} />
<Section titolo="Calendario"> <div className="px-5 pt-4">
<Card>
<div className="mb-3 flex items-center justify-between">
<button <button
type="button" type="button"
onClick={precedente} onClick={() => setBozza(eventoVuoto())}
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95" className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
aria-label="Mese precedente"
> >
<ChevronLeft className="h-5 w-5" /> <CalendarPlus className="h-4 w-4" /> Nuovo evento
</button>
<span className="font-display-sm text-xl uppercase">
{mesiIT[mese]} {anno}
</span>
<button
type="button"
onClick={successivo}
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
aria-label="Mese successivo"
>
<ChevronRight className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
{giorniIT.map((g, i) => (
<span key={i}>{g}</span>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1">
{Array.from({ length: offsetLunedi }).map((_, i) => (
<span key={`v${i}`} />
))}
{Array.from({ length: giorni }).map((_, i) => {
const giorno = i + 1;
const eventiGiorno = eventiPerGiorno.get(giorno) ?? [];
const haEventi = eventiGiorno.length > 0;
const isOggi =
!!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
return (
<button
key={giorno}
type="button"
onClick={() => apriGiorno(giorno)}
className={cn(
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold transition-transform active:scale-90",
haEventi ? "bg-accent text-accent-foreground" : "bg-secondary text-foreground",
isOggi && "ring-2 ring-foreground ring-offset-1 ring-offset-card",
)}
aria-label={`${giorno} ${mesiIT[mese]}${haEventi ? `: ${eventiGiorno.length} ${eventiGiorno.length === 1 ? "evento" : "eventi"}` : ", nessun evento"}`}
aria-current={isOggi ? "date" : undefined}
>
{giorno}
</button>
);
})}
</div>
</Card>
</Section>
{bozza ? ( {bozza ? (
<Section <Section
titolo={eventi.some((e) => e.id === bozza.id) ? "Modifica evento" : "Nuovo evento"} titolo={eventi.some((e) => e.id === bozza.id) ? "Modifica evento" : "Nuovo evento"}
@@ -265,6 +154,7 @@ function GestioneEventi() {
value={bozza.titolo} value={bozza.titolo}
maxLength={80} maxLength={80}
onChange={(e) => aggiorna({ titolo: e.target.value })} onChange={(e) => aggiorna({ titolo: e.target.value })}
placeholder="Es. CRAP Volley vs Aurora Nera"
className={classiInput} className={classiInput}
/> />
</Campo> </Campo>
@@ -382,7 +272,7 @@ function GestioneEventi() {
</button> </button>
<button <button
type="button" type="button"
onClick={chiediConferma} onClick={conferma}
disabled={salva.isPending} disabled={salva.isPending}
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
> >
@@ -415,11 +305,9 @@ function GestioneEventi() {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{eventi.map((e) => ( {eventi.map((e) => (
<button <div
key={e.id} key={e.id}
type="button" className="flex items-center gap-2 rounded-3xl bg-card p-3 shadow-card"
onClick={() => setGiornoSelezionato(e.data)}
className="flex w-full items-center gap-2 rounded-3xl bg-card p-3 text-left shadow-card active:scale-[0.99]"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p> <p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
@@ -427,104 +315,27 @@ function GestioneEventi() {
{formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"} {formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"}
</p> </p>
</div> </div>
</button>
))}
</div>
)}
</Section>
<Drawer
open={!!giornoSelezionato}
onOpenChange={(aperto) => !aperto && setGiornoSelezionato(null)}
>
<DrawerContent className="rounded-t-[24px] border-border bg-background px-4 pb-6 pt-2">
<DrawerHeader className="relative px-0 pb-2 text-left">
<DrawerTitle className="font-display-lg text-2xl uppercase">
{giornoSelezionato ? formatData(giornoSelezionato) : "Giorno"}
</DrawerTitle>
<DrawerClose className="absolute right-0 top-0 grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground transition-transform active:scale-90">
<X className="h-4 w-4" />
<span className="sr-only">Chiudi</span>
</DrawerClose>
</DrawerHeader>
<div className="max-h-[50vh] space-y-2 overflow-y-auto py-2">
{eventiGiornoSelezionato.length > 0 ? (
eventiGiornoSelezionato.map((e) => (
<div
key={e.id}
className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
<p className="text-xs text-muted-foreground">
{e.ora} · {e.luogo || "luogo da definire"}
</p>
</div>
<button <button
type="button" type="button"
onClick={() => modificaDalGiorno(e)} onClick={() => setBozza(e)}
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95" className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
aria-label={`Modifica ${e.titolo}`} aria-label={`Modifica ${e.titolo}`}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => eliminaDalGiorno(e)} onClick={() => setDaEliminare(e)}
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95" className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
aria-label={`Elimina ${e.titolo}`} aria-label={`Elimina ${e.titolo}`}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
</div> </div>
)) ))}
) : (
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
Nessun evento in programma
</p>
)}
</div> </div>
<button )}
type="button" </Section>
onClick={nuovoNelGiorno}
className="premi mt-1 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
</button>
</DrawerContent>
</Drawer>
<Drawer
open={confermaModifica}
onOpenChange={(aperto) => !aperto && setConfermaModifica(false)}
>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Salvare le modifiche?</DrawerTitle>
<DrawerDescription>
{bozza ? `Aggiorni l'evento "${bozza.titolo.trim() || "senza titolo"}".` : ""}
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<button
type="button"
onClick={conferma}
disabled={salva.isPending}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Conferma
</button>
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
>
Annulla
</button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Drawer open={!!daEliminare} onOpenChange={(aperto) => !aperto && setDaEliminare(null)}> <Drawer open={!!daEliminare} onOpenChange={(aperto) => !aperto && setDaEliminare(null)}>
<DrawerContent> <DrawerContent>
+3 -3
View File
@@ -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
View File
@@ -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],
})); }));
-7
View File
@@ -230,13 +230,6 @@ try {
} }
}); });
await prova("l'invio di un messaggio personalizzato chiede le credenziali", async () => {
assert.equal(
(await postJson("/api/public/notifica-personalizzata", { messaggio: "prova" })).status,
401,
);
});
await prova("un token malformato non passa", async () => { await prova("un token malformato non passa", async () => {
const res = await fetch(url("/api/public/sollecita-presenze"), { const res = await fetch(url("/api/public/sollecita-presenze"), {
method: "POST", method: "POST",
-25
View File
@@ -107,31 +107,6 @@ if (!locale) {
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`); assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
} }
}); });
/** Messaggio libero: stesso controllo d'accesso, corpo diverso. */
const chiamaMessaggio = (intestazioni: Record<string, string> = {}) =>
fetch(`${server.baseUrl}/api/public/notifica-personalizzata`, {
method: "POST",
headers: { "content-type": "application/json", ...intestazioni },
body: JSON.stringify({ messaggio: "prova" }),
});
await prova("senza token non si manda un messaggio personalizzato", async () => {
assert.equal((await chiamaMessaggio()).status, 401);
});
await prova("un giocatore autenticato non manda messaggi personalizzati", async () => {
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenGiocatore}` });
assert.equal(res.status, 403);
});
await prova("un amministratore manda il messaggio (nessun dispositivo iscritto)", async () => {
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAdmin}` });
assert.equal(res.status, 200);
const corpo = (await res.json()) as { inviate: number; destinatari: number };
assert.equal(corpo.destinatari, 0);
assert.equal(corpo.inviate, 0);
});
} finally { } finally {
server.stop(); server.stop();
for (const id of idUtenti) { for (const id of idUtenti) {
+6 -1
View File
@@ -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 };
+19 -8
View File
@@ -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");
-24
View File
@@ -1,24 +0,0 @@
/** Check delle funzioni pure della griglia mensile: `bun test/unit/calendario.test.ts`. */
import assert from "node:assert/strict";
import { giorniDelMese, pad2 } from "@/lib/calendario";
// --- pad2 --------------------------------------------------------------
assert.equal(pad2(1), "01");
assert.equal(pad2(9), "09");
assert.equal(pad2(10), "10");
assert.equal(pad2(31), "31");
// --- giorniDelMese -------------------------------------------------------
// Febbraio 2026 (non bisestile): 28 giorni, inizia di domenica -> offset 6.
assert.deepEqual(giorniDelMese(2026, 1), { giorni: 28, offsetLunedi: 6 });
// Febbraio 2028 (bisestile): 29 giorni.
assert.deepEqual(giorniDelMese(2028, 1), { giorni: 29, offsetLunedi: 1 });
// Settembre 2026: 30 giorni, il 1° settembre 2026 è martedì -> offset 1.
assert.deepEqual(giorniDelMese(2026, 8), { giorni: 30, offsetLunedi: 1 });
// Gennaio 2027: il 1° gennaio è venerdì -> offset 4.
assert.deepEqual(giorniDelMese(2027, 0), { giorni: 31, offsetLunedi: 4 });
console.log("calendario: ok");
+10 -14
View File
@@ -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 -13
View File
@@ -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");