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/_/"