Files
CRAPP/pocketbase/pb_migrations/1757500000_schema_iniziale.js
T

666 lines
26 KiB
JavaScript
Raw Normal View History

/// <reference path="../pb_data/types.d.ts" />
//
// Schema iniziale PocketBase per CrAPP — solo ambiente locale (Docker).
//
// Non è la riproduzione 1:1 delle 27 migration Supabase in supabase/migrations/: rappresenta
// lo STATO FINALE attuale dello schema (fonte: docs/DATABASE.md + lettura diretta delle
// migration Supabase più recenti), riscritto nel modello PocketBase (collection + campi +
// API rules). Le tabelle Supabase non più usate dal codice (`giocatori`, `eventi`/`presenze`
// "nuovo modello" mai adottato, `promemoria_push` deprecata) non vengono portate.
//
// `user_roles` non diventa una collection: il ruolo admin/user è un campo diretto sulla
// collection auth nativa `users` (decisione 3 del piano di migrazione).
//
// La logica procedurale che in Postgres viveva in trigger/funzioni (claim-slot su
// giocatori_squadra, blocco autovoto, convocati/pagelle_chiuse, cascata su cancellazione
// evento, risposto_il immutabile) NON sta qui: è in pb_hooks/*.pb.js, perché le API rules di
// PocketBase sono espressioni, non codice procedurale.
migrate(
(app) => {
// ---------------------------------------------------------------------------------------
// users (collection auth nativa): aggiunge il campo ruolo che sostituisce user_roles.
// ---------------------------------------------------------------------------------------
const users = app.findCollectionByNameOrId("users");
users.fields.add(
new Field({
name: "role",
type: "select",
required: true,
values: ["user", "admin"],
maxSelect: 1,
}),
);
// Ogni utente autenticato legge il proprio record; l'admin (verificato lato hook al primo
// login, poi qui) può leggere tutti. Il ruolo lo scrive solo chi è già admin.
users.listRule = "id = @request.auth.id || @request.auth.role = 'admin'";
users.viewRule = "id = @request.auth.id || @request.auth.role = 'admin'";
users.updateRule = "id = @request.auth.id";
app.save(users);
// ---------------------------------------------------------------------------------------
// giocatori_squadra — anagrafica operativa della squadra (DD-015, DD-016, DD-018)
// ---------------------------------------------------------------------------------------
const giocatori = new Collection({
name: "giocatori_squadra",
type: "base",
fields: [
// Id testuali corti (g1..gN, come in Supabase): il vincolo di default di PocketBase
// (minimo 15 caratteri) va rilassato esplicitamente.
{ name: "id", type: "text", min: 1, max: 40, pattern: "^g[0-9]+$" },
{ name: "nome", type: "text", required: true },
{ name: "cognome", type: "text", required: true },
{ name: "numero", type: "number", required: true, min: 1 },
{ name: "ruolo", type: "text", required: true },
{
name: "auth_user_id",
type: "relation",
required: false,
collectionId: users.id,
maxSelect: 1,
},
{ name: "email", type: "email", required: false },
{ name: "attivo", type: "bool", required: false },
{ name: "numero_tessera", type: "text", required: false },
{ name: "data_tessera", type: "date", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_giocatori_squadra_auth_user ON giocatori_squadra (auth_user_id) WHERE auth_user_id != ''",
"CREATE UNIQUE INDEX idx_giocatori_squadra_email ON giocatori_squadra (email) WHERE email != ''",
],
// Lettura: rosa attiva a tutti gli autenticati, tutta la rosa agli admin (DD-015/M1).
listRule: "@request.auth.id != '' && (attivo = true || @request.auth.role = 'admin')",
viewRule: "@request.auth.id != '' && (attivo = true || @request.auth.role = 'admin')",
// Creazione slot: solo admin.
createRule: "@request.auth.role = 'admin'",
// Aggiornamento: admin senza vincoli, oppure claim di uno slot libero — il resto
// (che il claim tocchi solo auth_user_id e rispetti l'email) lo valida l'hook
// giocatori_squadra_claim.pb.js, la rule qui apre solo la porta.
updateRule: "@request.auth.role = 'admin' || (@request.auth.id != '' && auth_user_id = '')",
deleteRule: "@request.auth.role = 'admin'",
});
app.save(giocatori);
// ---------------------------------------------------------------------------------------
// profili_giocatore — dati personali/documento/certificato, 1:1 con giocatori_squadra
// ---------------------------------------------------------------------------------------
const profili = new Collection({
name: "profili_giocatore",
type: "base",
fields: [
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "data_nascita", type: "date", required: false },
{ name: "luogo_nascita", type: "text", required: false },
{ name: "indirizzo", type: "text", required: false },
{ name: "telefono", type: "text", required: false },
{ name: "email", type: "email", required: false },
{
name: "documento_tipo",
type: "select",
required: false,
maxSelect: 1,
values: ["Carta d'identità", "Passaporto", "Patente"],
},
{ name: "documento_numero", type: "text", required: false },
{ name: "documento_rilasciato_da", type: "text", required: false },
{ name: "documento_emissione", type: "date", required: false },
{ name: "documento_scadenza", type: "date", required: false },
{
name: "documento_fronte",
type: "file",
required: false,
protected: true,
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
},
{
name: "documento_retro",
type: "file",
required: false,
protected: true,
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
},
{ name: "certificato_scadenza", type: "date", required: false },
{
name: "certificato",
type: "file",
required: false,
protected: true,
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
},
{
name: "foto",
type: "file",
required: false,
protected: true,
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
},
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_profili_giocatore_giocatore ON profili_giocatore (giocatore)",
],
// Documenti sanitari/identità: mai pubblici (DD-016 regola 4). Solo il giocatore
// proprietario o un admin, sia per i campi sia per i file allegati (le API rules
// governano anche il download dei file in PocketBase, non serve un bucket a parte).
listRule:
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
viewRule:
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
createRule:
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
updateRule:
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
deleteRule: "@request.auth.role = 'admin'",
});
app.save(profili);
// ---------------------------------------------------------------------------------------
// avatar_giocatori — foto profilo pubbliche (M6 avatar-giocatori). Collection separata
// da giocatori_squadra: la policy è "chiunque autenticato carica/sostituisce/elimina
// qualsiasi avatar" (nessun controllo per-proprietario, DD in docs/DATABASE.md), molto
// più permissiva delle regole di giocatori_squadra — mescolarle avrebbe o indebolito
// quelle o impedito il caricamento a chi non è admin.
// ---------------------------------------------------------------------------------------
const avatar = new Collection({
name: "avatar_giocatori",
type: "base",
fields: [
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "foto",
type: "file",
required: false,
maxSelect: 1,
maxSize: 5242880,
mimeTypes: ["image/jpeg"],
},
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_avatar_giocatori_giocatore ON avatar_giocatori (giocatore)",
],
// Bucket pubblico in Supabase: leggibile da chiunque, anche senza login.
listRule: "",
viewRule: "",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(avatar);
// ---------------------------------------------------------------------------------------
// eventi_app — eventi gestionali (partite/allenamenti/eventi squadra)
// ---------------------------------------------------------------------------------------
const eventi = new Collection({
name: "eventi_app",
type: "base",
fields: [
// Id testuali corti (e1..eN, come in Supabase).
{ name: "id", type: "text", min: 1, max: 40, pattern: "^e[0-9]+$" },
{
name: "tipo",
type: "select",
required: true,
maxSelect: 1,
values: ["partita", "allenamento", "evento"],
},
{ name: "titolo", type: "text", required: true },
{ name: "luogo", type: "text", required: false },
{ name: "data", type: "date", required: true },
{ name: "ora", type: "text", required: false },
{ name: "note", type: "text", required: false },
{
name: "convocati",
type: "relation",
required: false,
collectionId: giocatori.id,
maxSelect: 999,
},
{ name: "campionato", type: "bool", required: false },
{ name: "casa", type: "bool", required: false },
{ name: "pagelle_chiuse", type: "bool", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
// Lettura aperta a tutti gli autenticati; scrittura solo admin (rotta /eventi già
// riservata — M11/DD-023).
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.role = 'admin'",
updateRule: "@request.auth.role = 'admin'",
deleteRule: "@request.auth.role = 'admin'",
});
app.save(eventi);
// ---------------------------------------------------------------------------------------
// risposte_presenze — risposte dei giocatori agli eventi
// ---------------------------------------------------------------------------------------
const presenze = new Collection({
name: "risposte_presenze",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "stato", type: "text", required: true },
// Istante della PRIMA risposta (M9): valorizzato e reso immutabile dall'hook
// risposte_presenze_immutabili.pb.js, non scrivibile direttamente dal client.
{ name: "risposto_il", type: "date", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_risposte_presenze_evento_giocatore ON risposte_presenze (evento, giocatore)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
});
app.save(presenze);
// ---------------------------------------------------------------------------------------
// cacche_partita — sondaggio pre-partita (statistiche/badge segreti)
// ---------------------------------------------------------------------------------------
const cacche = new Collection({
name: "cacche_partita",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "quantita", type: "number", required: true, min: 0, max: 10 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_cacche_partita_evento_giocatore ON cacche_partita (evento, giocatore)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
});
app.save(cacche);
// ---------------------------------------------------------------------------------------
// mvp_voti / pagelle_voti / badge_social_voti — votazioni tra compagni
//
// Ownership (votante = chi scrive) è espressa qui via API rule. Autovoto e vincoli
// "convocato all'evento" / "pagelle non chiuse" (M12/M13) NON sono esprimibili in modo
// sicuro come semplice espressione di rule su relazioni multiple: li applica l'hook
// pb_hooks/voti_convocati.pb.js su create/update, per tutte e tre le collection.
// ---------------------------------------------------------------------------------------
const mvpVoti = new Collection({
name: "mvp_voti",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "votante",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{
name: "votato",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "votato_nome", type: "text", required: true },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_mvp_voti_evento_votante ON mvp_voti (evento, votante)"],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
});
app.save(mvpVoti);
const pagelleVoti = new Collection({
name: "pagelle_voti",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "votante",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{
name: "votato",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "voto", type: "number", required: true, min: 1, max: 10 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_pagelle_voti_evento_votante_votato ON pagelle_voti (evento, votante, votato)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
});
app.save(pagelleVoti);
const badgeSocialVoti = new Collection({
name: "badge_social_voti",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "categoria", type: "text", required: true },
{
name: "votante",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{
name: "votato",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "votato_nome", type: "text", required: true },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_badge_social_voti_evento_categoria_votante ON badge_social_voti (evento, categoria, votante)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
});
app.save(badgeSocialVoti);
// ---------------------------------------------------------------------------------------
// turni_palloni / scout_sessioni / scout_live / scout_partite — nessun gate in UI,
// aperte a qualsiasi autenticato (invariato da Supabase, vedi docs/DATABASE.md).
// ---------------------------------------------------------------------------------------
const turniPalloni = new Collection({
name: "turni_palloni",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "aggiornato_da", type: "text", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_turni_palloni_evento ON turni_palloni (evento)"],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(turniPalloni);
const scoutSessioni = new Collection({
name: "scout_sessioni",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "giocatore_nome", type: "text", required: true },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_scout_sessioni_evento ON scout_sessioni (evento)"],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(scoutSessioni);
const scoutLive = new Collection({
name: "scout_live",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "stato", type: "json", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_scout_live_evento ON scout_live (evento)"],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(scoutLive);
const scoutPartite = new Collection({
name: "scout_partite",
type: "base",
fields: [
// Id libero (come in Supabase, "id text PRIMARY KEY", generato dal client).
{ name: "id", type: "text", min: 1, max: 60 },
{
name: "evento",
type: "relation",
required: false,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "data", type: "date", required: true },
{ name: "avversario", type: "text", required: true },
{ name: "casa", type: "bool", required: false },
{ name: "set_nostri", type: "number", required: true, min: 0 },
{ name: "set_loro", type: "number", required: true, min: 0 },
{ name: "parziali", type: "json", required: false },
{ name: "azioni", type: "json", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(scoutPartite);
// ---------------------------------------------------------------------------------------
// push_subscriptions — dispositivi registrati per le notifiche push
// ---------------------------------------------------------------------------------------
const pushSubscriptions = new Collection({
name: "push_subscriptions",
type: "base",
fields: [
{
name: "giocatore",
type: "relation",
required: false,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "endpoint", type: "text", required: true },
{ name: "p256dh", type: "text", required: true },
{ name: "auth", type: "text", required: true },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_push_subscriptions_endpoint ON push_subscriptions (endpoint)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(pushSubscriptions);
},
(app) => {
const names = [
"push_subscriptions",
"scout_partite",
"scout_live",
"scout_sessioni",
"turni_palloni",
"badge_social_voti",
"pagelle_voti",
"mvp_voti",
"cacche_partita",
"risposte_presenze",
"eventi_app",
"avatar_giocatori",
"profili_giocatore",
"giocatori_squadra",
];
for (const name of names) {
const collection = app.findCollectionByNameOrId(name);
if (collection) app.delete(collection);
}
const users = app.findCollectionByNameOrId("users");
users.fields.removeByName("role");
app.save(users);
},
);