Aggiunge infrastruttura PocketBase locale (Docker, schema, hook)

Introduce PocketBase come sostituto di Supabase solo per l'ambiente di
sviluppo locale (docs/PORTABILITA.md): docker-compose.pocketbase.yml,
schema iniziale in pocketbase/pb_migrations/ (collection equivalenti alle
tabelle Supabase, con le stesse API rule dove esprimibili) e la logica
procedurale che in Postgres viveva in trigger/funzioni riscritta come
hook JS in pocketbase/pb_hooks/ (claim slot giocatore, blocco autovoto,
convocati/pagelle chiuse, immutabilità di risposto_il).

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:57:55 +02:00
co-authored by Claude Sonnet 5
parent fcff950006
commit 15885f32c7
10 changed files with 953 additions and 2 deletions
+4
View File
@@ -41,3 +41,7 @@ dist-ssr
.vercel
# Supabase CLI local state
supabase/.temp/
# PocketBase locale (dati e log runtime, non lo schema in pb_migrations/pb_hooks)
pocketbase/pb_data/*
!pocketbase/pb_data/.gitkeep
+38
View File
@@ -0,0 +1,38 @@
# PocketBase locale per lo sviluppo (sostituisce "npx supabase start" solo in locale,
# vedi docs/PORTABILITA.md e la voce DD relativa). La produzione resta su Supabase.
#
# Uso:
# docker compose -f docker-compose.pocketbase.yml up -d # avvia
# docker compose -f docker-compose.pocketbase.yml down # ferma
# docker compose -f docker-compose.pocketbase.yml down -v # ferma e azzera i dati locali
#
# Dashboard admin: http://127.0.0.1:8090/_/
# API: http://127.0.0.1:8090/api/
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: crapp-pocketbase
restart: unless-stopped
# --automigrate=0: le modifiche fatte da dashboard (es. abilitare un provider OAuth2,
# con relativo client secret) NON devono finire come file di migration versionati in
# git. Le migration restano solo quelle scritte a mano in pocketbase/pb_migrations/.
command: ["--automigrate=0"]
env_file:
- .env
environment:
# L'immagine crea il superuser da queste variabili a ogni avvio (idempotente):
# sopravvive a "npm run pocketbase:reset" senza passaggi manuali.
PB_ADMIN_EMAIL: ${POCKETBASE_SUPERUSER_EMAIL:-}
PB_ADMIN_PASSWORD: ${POCKETBASE_SUPERUSER_PASSWORD:-}
ports:
- "8090:8090"
volumes:
- ./pocketbase/pb_data:/pb_data
- ./pocketbase/pb_migrations:/pb_migrations
- ./pocketbase/pb_hooks:/pb_hooks
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8090/api/health"]
interval: 5s
timeout: 3s
retries: 5
+1 -1
View File
@@ -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}"],
View File
@@ -0,0 +1,49 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Equivalente del trigger enforce_giocatori_squadra_update (M1/M5/M8, DD-016/DD-018):
// un non-admin può aggiornare uno slot di giocatori_squadra solo per reclamarlo (slot
// libero, email dell'account uguale a quella registrata sulla riga), senza cambiare nessun
// altro campo. La updateRule della collection apre la porta solo quando auth_user_id è
// vuoto o l'utente è admin; qui si valida il resto.
onRecordUpdateRequest((e) => {
// Superuser (pbAdmin lato server, dashboard, script di migrazione dati) e admin
// applicativi (role="admin" sulla collection users) bypassano il vincolo di claim.
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (isAdmin) {
e.next();
return;
}
const originale = e.app.findRecordById("giocatori_squadra", e.record.id);
const campiBloccati = [
"nome",
"cognome",
"numero",
"ruolo",
"attivo",
"email",
"numero_tessera",
"data_tessera",
];
for (const campo of campiBloccati) {
if (String(e.record.get(campo)) !== String(originale.get(campo))) {
throw new BadRequestError("Aggiornamento non autorizzato su giocatori_squadra");
}
}
const utenteId = e.auth ? e.auth.id : "";
const emailAccount = (e.auth ? e.auth.get("email") || "" : "").toLowerCase();
const emailSlot = (originale.get("email") || "").toLowerCase();
const slotLibero = !originale.get("auth_user_id");
const claimSuAccountCorrente = e.record.get("auth_user_id") === utenteId;
const emailCombacia = emailSlot !== "" && emailSlot === emailAccount;
if (!slotLibero || !claimSuAccountCorrente || !emailCombacia) {
throw new BadRequestError("Aggiornamento non autorizzato su giocatori_squadra");
}
e.next();
}, "giocatori_squadra");
+23
View File
@@ -0,0 +1,23 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Il Client ID/Secret di Google OAuth2 configurati da dashboard vivono in pb_data e
// andrebbero persi a ogni "npm run pocketbase:reset" (azzera pb_data). Questo hook li
// reimposta a ogni avvio da variabili d'ambiente (mai committate, solo in .env), così
// sopravvivono al reset senza reinserirli a mano ogni volta dalla dashboard.
onBootstrap((e) => {
e.next();
const clientId = $os.getenv("GOOGLE_OAUTH_CLIENT_ID");
const clientSecret = $os.getenv("GOOGLE_OAUTH_CLIENT_SECRET");
if (!clientId || !clientSecret) {
return;
}
const collection = e.app.findCollectionByNameOrId("users");
collection.oauth2.enabled = true;
collection.oauth2.providers = [
{ name: "google", clientId: clientId, clientSecret: clientSecret },
];
e.app.save(collection);
});
@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Equivalente del trigger risposte_presenze_risposto_il_immutabile (M9): risposto_il è
// l'istante della PRIMA risposta, scritto una sola volta e mai più modificabile — serve
// per la serie "Conferme 24h" (confronto con eventi_app.created).
onRecordCreateRequest((e) => {
e.record.set("risposto_il", new Date().toISOString());
e.next();
}, "risposte_presenze");
onRecordUpdateRequest((e) => {
const originale = e.app.findRecordById("risposte_presenze", e.record.id);
e.record.set("risposto_il", originale.get("risposto_il"));
e.next();
}, "risposte_presenze");
+137
View File
@@ -0,0 +1,137 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Equivalente di evento_permette_voto() + i CHECK "no autovoto" (M12/M13): applicato a
// mvp_voti, pagelle_voti, badge_social_voti su create/update. Gli admin non hanno questi
// vincoli (possono correggere un voto anche fuori convocazione/dopo la chiusura pagelle).
// convocati vuoto = "tutta la rosa" (stessa convenzione di convocatiEvento() in eventi.ts).
//
// La validazione è ripetuta in ciascun callback (non estratta in una funzione condivisa a
// livello di file): ogni hook di PocketBase viene eseguito nel proprio contesto JS isolato,
// che non vede le funzioni dichiarate fuori dal callback stesso (verificato empiricamente:
// una funzione condivisa causa "ReferenceError: ... is not defined" a runtime).
onRecordCreateRequest((e) => {
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (!isAdmin) {
const votanteId = e.record.get("votante");
const votatoId = e.record.get("votato");
if (votanteId === votatoId) {
throw new BadRequestError("Non puoi votare te stesso");
}
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
const convocati = evento.get("convocati") || [];
if (
convocati.length > 0 &&
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
) {
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
}
}
e.next();
}, "mvp_voti");
onRecordUpdateRequest((e) => {
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (!isAdmin) {
const votanteId = e.record.get("votante");
const votatoId = e.record.get("votato");
if (votanteId === votatoId) {
throw new BadRequestError("Non puoi votare te stesso");
}
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
const convocati = evento.get("convocati") || [];
if (
convocati.length > 0 &&
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
) {
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
}
}
e.next();
}, "mvp_voti");
onRecordCreateRequest((e) => {
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (!isAdmin) {
const votanteId = e.record.get("votante");
const votatoId = e.record.get("votato");
if (votanteId === votatoId) {
throw new BadRequestError("Non puoi votare te stesso");
}
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
const convocati = evento.get("convocati") || [];
if (
convocati.length > 0 &&
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
) {
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
}
if (evento.get("pagelle_chiuse")) {
throw new BadRequestError("Le pagelle per questo evento sono chiuse");
}
}
e.next();
}, "pagelle_voti");
onRecordUpdateRequest((e) => {
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (!isAdmin) {
const votanteId = e.record.get("votante");
const votatoId = e.record.get("votato");
if (votanteId === votatoId) {
throw new BadRequestError("Non puoi votare te stesso");
}
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
const convocati = evento.get("convocati") || [];
if (
convocati.length > 0 &&
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
) {
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
}
if (evento.get("pagelle_chiuse")) {
throw new BadRequestError("Le pagelle per questo evento sono chiuse");
}
}
e.next();
}, "pagelle_voti");
onRecordCreateRequest((e) => {
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (!isAdmin) {
const votanteId = e.record.get("votante");
const votatoId = e.record.get("votato");
if (votanteId === votatoId) {
throw new BadRequestError("Non puoi votare te stesso");
}
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
const convocati = evento.get("convocati") || [];
if (
convocati.length > 0 &&
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
) {
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
}
}
e.next();
}, "badge_social_voti");
onRecordUpdateRequest((e) => {
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (!isAdmin) {
const votanteId = e.record.get("votante");
const votatoId = e.record.get("votato");
if (votanteId === votatoId) {
throw new BadRequestError("Non puoi votare te stesso");
}
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
const convocati = evento.get("convocati") || [];
if (
convocati.length > 0 &&
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
) {
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
}
}
e.next();
}, "badge_social_voti");
@@ -0,0 +1,665 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Schema iniziale PocketBase per CrAPP — solo ambiente locale (Docker).
//
// Non è la riproduzione 1:1 delle 27 migration Supabase in supabase/migrations/: rappresenta
// lo STATO FINALE attuale dello schema (fonte: docs/DATABASE.md + lettura diretta delle
// migration Supabase più recenti), riscritto nel modello PocketBase (collection + campi +
// API rules). Le tabelle Supabase non più usate dal codice (`giocatori`, `eventi`/`presenze`
// "nuovo modello" mai adottato, `promemoria_push` deprecata) non vengono portate.
//
// `user_roles` non diventa una collection: il ruolo admin/user è un campo diretto sulla
// collection auth nativa `users` (decisione 3 del piano di migrazione).
//
// La logica procedurale che in Postgres viveva in trigger/funzioni (claim-slot su
// giocatori_squadra, blocco autovoto, convocati/pagelle_chiuse, cascata su cancellazione
// evento, risposto_il immutabile) NON sta qui: è in pb_hooks/*.pb.js, perché le API rules di
// PocketBase sono espressioni, non codice procedurale.
migrate(
(app) => {
// ---------------------------------------------------------------------------------------
// users (collection auth nativa): aggiunge il campo ruolo che sostituisce user_roles.
// ---------------------------------------------------------------------------------------
const users = app.findCollectionByNameOrId("users");
users.fields.add(
new Field({
name: "role",
type: "select",
required: true,
values: ["user", "admin"],
maxSelect: 1,
}),
);
// Ogni utente autenticato legge il proprio record; l'admin (verificato lato hook al primo
// login, poi qui) può leggere tutti. Il ruolo lo scrive solo chi è già admin.
users.listRule = "id = @request.auth.id || @request.auth.role = 'admin'";
users.viewRule = "id = @request.auth.id || @request.auth.role = 'admin'";
users.updateRule = "id = @request.auth.id";
app.save(users);
// ---------------------------------------------------------------------------------------
// giocatori_squadra — anagrafica operativa della squadra (DD-015, DD-016, DD-018)
// ---------------------------------------------------------------------------------------
const giocatori = new Collection({
name: "giocatori_squadra",
type: "base",
fields: [
// Id testuali corti (g1..gN, come in Supabase): il vincolo di default di PocketBase
// (minimo 15 caratteri) va rilassato esplicitamente.
{ name: "id", type: "text", min: 1, max: 40, pattern: "^g[0-9]+$" },
{ name: "nome", type: "text", required: true },
{ name: "cognome", type: "text", required: true },
{ name: "numero", type: "number", required: true, min: 1 },
{ name: "ruolo", type: "text", required: true },
{
name: "auth_user_id",
type: "relation",
required: false,
collectionId: users.id,
maxSelect: 1,
},
{ name: "email", type: "email", required: false },
{ name: "attivo", type: "bool", required: false },
{ name: "numero_tessera", type: "text", required: false },
{ name: "data_tessera", type: "date", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_giocatori_squadra_auth_user ON giocatori_squadra (auth_user_id) WHERE auth_user_id != ''",
"CREATE UNIQUE INDEX idx_giocatori_squadra_email ON giocatori_squadra (email) WHERE email != ''",
],
// Lettura: rosa attiva a tutti gli autenticati, tutta la rosa agli admin (DD-015/M1).
listRule: "@request.auth.id != '' && (attivo = true || @request.auth.role = 'admin')",
viewRule: "@request.auth.id != '' && (attivo = true || @request.auth.role = 'admin')",
// Creazione slot: solo admin.
createRule: "@request.auth.role = 'admin'",
// Aggiornamento: admin senza vincoli, oppure claim di uno slot libero — il resto
// (che il claim tocchi solo auth_user_id e rispetti l'email) lo valida l'hook
// giocatori_squadra_claim.pb.js, la rule qui apre solo la porta.
updateRule: "@request.auth.role = 'admin' || (@request.auth.id != '' && auth_user_id = '')",
deleteRule: "@request.auth.role = 'admin'",
});
app.save(giocatori);
// ---------------------------------------------------------------------------------------
// profili_giocatore — dati personali/documento/certificato, 1:1 con giocatori_squadra
// ---------------------------------------------------------------------------------------
const profili = new Collection({
name: "profili_giocatore",
type: "base",
fields: [
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "data_nascita", type: "date", required: false },
{ name: "luogo_nascita", type: "text", required: false },
{ name: "indirizzo", type: "text", required: false },
{ name: "telefono", type: "text", required: false },
{ name: "email", type: "email", required: false },
{
name: "documento_tipo",
type: "select",
required: false,
maxSelect: 1,
values: ["Carta d'identità", "Passaporto", "Patente"],
},
{ name: "documento_numero", type: "text", required: false },
{ name: "documento_rilasciato_da", type: "text", required: false },
{ name: "documento_emissione", type: "date", required: false },
{ name: "documento_scadenza", type: "date", required: false },
{
name: "documento_fronte",
type: "file",
required: false,
protected: true,
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
},
{
name: "documento_retro",
type: "file",
required: false,
protected: true,
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
},
{ name: "certificato_scadenza", type: "date", required: false },
{
name: "certificato",
type: "file",
required: false,
protected: true,
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
},
{
name: "foto",
type: "file",
required: false,
protected: true,
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
},
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_profili_giocatore_giocatore ON profili_giocatore (giocatore)",
],
// Documenti sanitari/identità: mai pubblici (DD-016 regola 4). Solo il giocatore
// proprietario o un admin, sia per i campi sia per i file allegati (le API rules
// governano anche il download dei file in PocketBase, non serve un bucket a parte).
listRule:
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
viewRule:
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
createRule:
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
updateRule:
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
deleteRule: "@request.auth.role = 'admin'",
});
app.save(profili);
// ---------------------------------------------------------------------------------------
// avatar_giocatori — foto profilo pubbliche (M6 avatar-giocatori). Collection separata
// da giocatori_squadra: la policy è "chiunque autenticato carica/sostituisce/elimina
// qualsiasi avatar" (nessun controllo per-proprietario, DD in docs/DATABASE.md), molto
// più permissiva delle regole di giocatori_squadra — mescolarle avrebbe o indebolito
// quelle o impedito il caricamento a chi non è admin.
// ---------------------------------------------------------------------------------------
const avatar = new Collection({
name: "avatar_giocatori",
type: "base",
fields: [
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "foto",
type: "file",
required: false,
maxSelect: 1,
maxSize: 5242880,
mimeTypes: ["image/jpeg"],
},
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_avatar_giocatori_giocatore ON avatar_giocatori (giocatore)",
],
// Bucket pubblico in Supabase: leggibile da chiunque, anche senza login.
listRule: "",
viewRule: "",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(avatar);
// ---------------------------------------------------------------------------------------
// eventi_app — eventi gestionali (partite/allenamenti/eventi squadra)
// ---------------------------------------------------------------------------------------
const eventi = new Collection({
name: "eventi_app",
type: "base",
fields: [
// Id testuali corti (e1..eN, come in Supabase).
{ name: "id", type: "text", min: 1, max: 40, pattern: "^e[0-9]+$" },
{
name: "tipo",
type: "select",
required: true,
maxSelect: 1,
values: ["partita", "allenamento", "evento"],
},
{ name: "titolo", type: "text", required: true },
{ name: "luogo", type: "text", required: false },
{ name: "data", type: "date", required: true },
{ name: "ora", type: "text", required: false },
{ name: "note", type: "text", required: false },
{
name: "convocati",
type: "relation",
required: false,
collectionId: giocatori.id,
maxSelect: 999,
},
{ name: "campionato", type: "bool", required: false },
{ name: "casa", type: "bool", required: false },
{ name: "pagelle_chiuse", type: "bool", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
// Lettura aperta a tutti gli autenticati; scrittura solo admin (rotta /eventi già
// riservata — M11/DD-023).
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.role = 'admin'",
updateRule: "@request.auth.role = 'admin'",
deleteRule: "@request.auth.role = 'admin'",
});
app.save(eventi);
// ---------------------------------------------------------------------------------------
// risposte_presenze — risposte dei giocatori agli eventi
// ---------------------------------------------------------------------------------------
const presenze = new Collection({
name: "risposte_presenze",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "stato", type: "text", required: true },
// Istante della PRIMA risposta (M9): valorizzato e reso immutabile dall'hook
// risposte_presenze_immutabili.pb.js, non scrivibile direttamente dal client.
{ name: "risposto_il", type: "date", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_risposte_presenze_evento_giocatore ON risposte_presenze (evento, giocatore)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
});
app.save(presenze);
// ---------------------------------------------------------------------------------------
// cacche_partita — sondaggio pre-partita (statistiche/badge segreti)
// ---------------------------------------------------------------------------------------
const cacche = new Collection({
name: "cacche_partita",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "quantita", type: "number", required: true, min: 0, max: 10 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_cacche_partita_evento_giocatore ON cacche_partita (evento, giocatore)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
});
app.save(cacche);
// ---------------------------------------------------------------------------------------
// mvp_voti / pagelle_voti / badge_social_voti — votazioni tra compagni
//
// Ownership (votante = chi scrive) è espressa qui via API rule. Autovoto e vincoli
// "convocato all'evento" / "pagelle non chiuse" (M12/M13) NON sono esprimibili in modo
// sicuro come semplice espressione di rule su relazioni multiple: li applica l'hook
// pb_hooks/voti_convocati.pb.js su create/update, per tutte e tre le collection.
// ---------------------------------------------------------------------------------------
const mvpVoti = new Collection({
name: "mvp_voti",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "votante",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{
name: "votato",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "votato_nome", type: "text", required: true },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_mvp_voti_evento_votante ON mvp_voti (evento, votante)"],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
});
app.save(mvpVoti);
const pagelleVoti = new Collection({
name: "pagelle_voti",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "votante",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{
name: "votato",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "voto", type: "number", required: true, min: 1, max: 10 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_pagelle_voti_evento_votante_votato ON pagelle_voti (evento, votante, votato)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
});
app.save(pagelleVoti);
const badgeSocialVoti = new Collection({
name: "badge_social_voti",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "categoria", type: "text", required: true },
{
name: "votante",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{
name: "votato",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "votato_nome", type: "text", required: true },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_badge_social_voti_evento_categoria_votante ON badge_social_voti (evento, categoria, votante)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
});
app.save(badgeSocialVoti);
// ---------------------------------------------------------------------------------------
// turni_palloni / scout_sessioni / scout_live / scout_partite — nessun gate in UI,
// aperte a qualsiasi autenticato (invariato da Supabase, vedi docs/DATABASE.md).
// ---------------------------------------------------------------------------------------
const turniPalloni = new Collection({
name: "turni_palloni",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "aggiornato_da", type: "text", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_turni_palloni_evento ON turni_palloni (evento)"],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(turniPalloni);
const scoutSessioni = new Collection({
name: "scout_sessioni",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "giocatore_nome", type: "text", required: true },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_scout_sessioni_evento ON scout_sessioni (evento)"],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(scoutSessioni);
const scoutLive = new Collection({
name: "scout_live",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "stato", type: "json", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_scout_live_evento ON scout_live (evento)"],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(scoutLive);
const scoutPartite = new Collection({
name: "scout_partite",
type: "base",
fields: [
// Id libero (come in Supabase, "id text PRIMARY KEY", generato dal client).
{ name: "id", type: "text", min: 1, max: 60 },
{
name: "evento",
type: "relation",
required: false,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "data", type: "date", required: true },
{ name: "avversario", type: "text", required: true },
{ name: "casa", type: "bool", required: false },
{ name: "set_nostri", type: "number", required: true, min: 0 },
{ name: "set_loro", type: "number", required: true, min: 0 },
{ name: "parziali", type: "json", required: false },
{ name: "azioni", type: "json", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(scoutPartite);
// ---------------------------------------------------------------------------------------
// push_subscriptions — dispositivi registrati per le notifiche push
// ---------------------------------------------------------------------------------------
const pushSubscriptions = new Collection({
name: "push_subscriptions",
type: "base",
fields: [
{
name: "giocatore",
type: "relation",
required: false,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "endpoint", type: "text", required: true },
{ name: "p256dh", type: "text", required: true },
{ name: "auth", type: "text", required: true },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_push_subscriptions_endpoint ON push_subscriptions (endpoint)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(pushSubscriptions);
},
(app) => {
const names = [
"push_subscriptions",
"scout_partite",
"scout_live",
"scout_sessioni",
"turni_palloni",
"badge_social_voti",
"pagelle_voti",
"mvp_voti",
"cacche_partita",
"risposte_presenze",
"eventi_app",
"avatar_giocatori",
"profili_giocatore",
"giocatori_squadra",
];
for (const name of names) {
const collection = app.findCollectionByNameOrId(name);
if (collection) app.delete(collection);
}
const users = app.findCollectionByNameOrId("users");
users.fields.removeByName("role");
app.save(users);
},
);
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Ricrea da zero il PocketBase locale (equivalente di "npx supabase db reset").
# Ferma il container, azzera pb_data (le migration in pocketbase/pb_migrations/ vengono
# riapplicate all'avvio) e riavvia. Il superuser (da POCKETBASE_SUPERUSER_EMAIL/PASSWORD
# in .env) e il provider Google OAuth2 (da GOOGLE_OAUTH_CLIENT_ID/SECRET) si ricreano da
# soli all'avvio, senza passaggi manuali.
set -euo pipefail
cd "$(dirname "$0")/.."
docker compose -f docker-compose.pocketbase.yml down
# Alcuni file (avatar, thumbnail) li scrive il container con un utente diverso da quello
# host: un semplice "rm -rf" da host può fallire per permessi, quindi si pulisce da dentro
# un container usa-e-getta.
docker run --rm -v "$(pwd)/pocketbase/pb_data:/pb_data" alpine sh -c 'rm -rf /pb_data/* /pb_data/.[!.]* 2>/dev/null || true'
mkdir -p pocketbase/pb_data
touch pocketbase/pb_data/.gitkeep
docker compose -f docker-compose.pocketbase.yml up -d
echo "PocketBase locale ricreato. Dashboard: http://127.0.0.1:8090/_/"