diff --git a/.gitignore b/.gitignore
index 7df29fe..c0f92a7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,4 +40,8 @@ dist-ssr
# Optional
.vercel
# Supabase CLI local state
-supabase/.temp/
\ No newline at end of file
+supabase/.temp/
+
+# PocketBase locale (dati e log runtime, non lo schema in pb_migrations/pb_hooks)
+pocketbase/pb_data/*
+!pocketbase/pb_data/.gitkeep
\ No newline at end of file
diff --git a/docker-compose.pocketbase.yml b/docker-compose.pocketbase.yml
new file mode 100644
index 0000000..29e7a8a
--- /dev/null
+++ b/docker-compose.pocketbase.yml
@@ -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
diff --git a/eslint.config.js b/eslint.config.js
index be4075b..e8d6d32 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -6,7 +6,7 @@ import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
- { ignores: ["dist", ".output", ".vinxi"] },
+ { ignores: ["dist", ".output", ".vinxi", "pocketbase/pb_data"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
diff --git a/pocketbase/pb_data/.gitkeep b/pocketbase/pb_data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/pocketbase/pb_hooks/giocatori_squadra_claim.pb.js b/pocketbase/pb_hooks/giocatori_squadra_claim.pb.js
new file mode 100644
index 0000000..5e6e774
--- /dev/null
+++ b/pocketbase/pb_hooks/giocatori_squadra_claim.pb.js
@@ -0,0 +1,49 @@
+///
+//
+// 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");
diff --git a/pocketbase/pb_hooks/oauth2_google.pb.js b/pocketbase/pb_hooks/oauth2_google.pb.js
new file mode 100644
index 0000000..d453d5a
--- /dev/null
+++ b/pocketbase/pb_hooks/oauth2_google.pb.js
@@ -0,0 +1,23 @@
+///
+//
+// 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);
+});
diff --git a/pocketbase/pb_hooks/risposte_presenze_immutabili.pb.js b/pocketbase/pb_hooks/risposte_presenze_immutabili.pb.js
new file mode 100644
index 0000000..923ad03
--- /dev/null
+++ b/pocketbase/pb_hooks/risposte_presenze_immutabili.pb.js
@@ -0,0 +1,16 @@
+///
+//
+// 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");
diff --git a/pocketbase/pb_hooks/voti_convocati.pb.js b/pocketbase/pb_hooks/voti_convocati.pb.js
new file mode 100644
index 0000000..a49326c
--- /dev/null
+++ b/pocketbase/pb_hooks/voti_convocati.pb.js
@@ -0,0 +1,137 @@
+///
+//
+// 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");
diff --git a/pocketbase/pb_migrations/1757500000_schema_iniziale.js b/pocketbase/pb_migrations/1757500000_schema_iniziale.js
new file mode 100644
index 0000000..d0e26b6
--- /dev/null
+++ b/pocketbase/pb_migrations/1757500000_schema_iniziale.js
@@ -0,0 +1,665 @@
+///
+//
+// 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);
+ },
+);
diff --git a/scripts/pocketbase-reset.sh b/scripts/pocketbase-reset.sh
new file mode 100755
index 0000000..d80333c
--- /dev/null
+++ b/scripts/pocketbase-reset.sh
@@ -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/_/"