/// // // 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); }, );