v0.1 — Admin Ban System + Extended Event Status (DB Spec)
Branch: feat/flow-admin-update + chore/admin-db-spec (merged su develop)
Data: 18/04/2026
Tipo: spec (DB changes deferred, application code already deployed)
Stato: DA IMPLEMENTARE (codice applicativo gia pronto, DB NON ancora modificato)
Contesto
Il branch feat/flow-admin-update ha introdotto nel portale admin:
- Sistema ban completo per utenti (motivazione, durata, storico, rimozione ban)
- Status estesi per eventi (
reported,deletedoltre adraft,active,published) - Audit log per operazioni admin
Il codice frontend + API routes e gia stato scritto e gira, ma NON tocca il DB finche queste modifiche non vengono applicate. Le feature ban e soft-delete degraderanno silenziosamente (best-effort catch) finche le tabelle non esistono.
Questo documento descrive le modifiche DB necessarie, da applicare quando si decidera di attivare le feature.
1. profiles — colonne ban (ADD COLUMN)
Aggiungere al profiles quattro colonne per accesso rapido allo stato ban corrente senza join su user_bans.
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS ban_reason TEXT,
ADD COLUMN IF NOT EXISTS ban_expires_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS banned_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS banned_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_profiles_ban_expires_at
ON public.profiles(ban_expires_at)
WHERE ban_expires_at IS NOT NULL;Semantica
| Colonna | Quando e valorizzata | Quando e NULL |
|---|---|---|
ban_reason | Se status = 'banned' | Altrimenti |
ban_expires_at | Ban a tempo | Ban permanente o niente ban |
banned_at | Momento di applicazione ban | Niente ban |
banned_by | Admin che ha applicato ban | Niente ban o utente eliminato |
Invariante: quando status = 'banned', almeno ban_reason e banned_at devono essere valorizzati.
Rischio: nessuno. ADD COLUMN e operazione non-bloccante, nessuna riga esistente viene toccata.
2. Tabella user_bans — storico ban
Tabella nuova che mantiene lo storico completo: ogni ban applicato e la sua eventuale rimozione.
CREATE TABLE IF NOT EXISTS public.user_bans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
banned_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
reason TEXT NOT NULL,
banned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- NULL = permanente
lifted_at TIMESTAMPTZ, -- NULL = ancora attivo
lifted_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
lifted_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_bans_user_id ON public.user_bans(user_id);
CREATE INDEX IF NOT EXISTS idx_user_bans_active ON public.user_bans(user_id) WHERE lifted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_user_bans_banned_at ON public.user_bans(banned_at DESC);RLS
ALTER TABLE public.user_bans ENABLE ROW LEVEL SECURITY;
-- Solo admin/super_admin possono leggere, inserire, aggiornare
CREATE POLICY "admins_manage_user_bans" ON public.user_bans
FOR ALL USING (
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid()
AND p.role IN ('admin', 'super_admin')
)
);
-- Utenti possono vedere il proprio storico ban (read-only)
CREATE POLICY "users_see_own_bans" ON public.user_bans
FOR SELECT USING (user_id = auth.uid());Flusso applicativo (gia implementato lato API)
Quando un admin applica un ban (POST /api/admin/users/[id]/ban):
- Chiude eventuali ban attivi dello stesso utente:
UPDATE user_bans SET lifted_at=NOW(), lifted_by=admin, lifted_reason='Sovrascritto da nuovo ban' WHERE user_id=X AND lifted_at IS NULL INSERT INTO user_bans (...)nuovo recordUPDATE profiles SET status='banned', ban_*=...per denormalizzazione
Quando un admin rimuove il ban (DELETE /api/admin/users/[id]/ban):
UPDATE user_bans SET lifted_at=NOW(), lifted_by, lifted_reason WHERE user_id=X AND lifted_at IS NULLUPDATE profiles SET status='active', ban_*=NULL
Rischio: basso. Tabella nuova, nessun impatto su dati esistenti.
3. Tabella admin_actions_log — audit trail
Tabella per tracciare ogni azione amministrativa. Utile per rispondere a domande come “chi ha modificato X e quando”, o per analytics interne.
CREATE TABLE IF NOT EXISTS public.admin_actions_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
action_type TEXT NOT NULL, -- 'user_ban' | 'user_unban' | 'user_edit' | 'event_status_change' | 'event_edit' | 'event_soft_delete' | ...
target_type TEXT NOT NULL, -- 'user' | 'event' | 'venue' | 'post'
target_id UUID NOT NULL,
payload JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_admin_actions_admin_id ON public.admin_actions_log(admin_id);
CREATE INDEX IF NOT EXISTS idx_admin_actions_target ON public.admin_actions_log(target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_admin_actions_created_at ON public.admin_actions_log(created_at DESC);RLS
ALTER TABLE public.admin_actions_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY "admins_read_action_log" ON public.admin_actions_log
FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid()
AND p.role IN ('admin', 'super_admin')
)
);
CREATE POLICY "admins_insert_action_log" ON public.admin_actions_log
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid()
AND p.role IN ('admin', 'super_admin')
)
);Esempio payload
// user_ban
{ "reason": "Spam ripetuto in chat", "expires_at": "2026-05-18T00:00:00Z", "ban_id": "uuid" }
// event_status_change
{ "fields": ["status", "updated_at"] }
// user_edit
{ "fields": ["username", "bio", "updated_at"] }Nota applicativa: tutti gli insert nel codice sono best-effort (.then(() => null, () => null)), quindi l’assenza della tabella non blocca le operazioni primarie, si perde solo l’audit.
Rischio: basso. Tabella nuova.
4. events — soft delete
Aggiungere colonne per tracciare soft-delete e un indice su status.
ALTER TABLE public.events
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS deleted_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_events_deleted_at ON public.events(deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_events_status ON public.events(status);Convenzione events.status
Il campo e gia TEXT libero, quindi non serve modificare il check. Convenzione di uso:
| Valore | Significato |
|---|---|
draft | Bozza, visibile solo al creator |
active | Approvato dall’admin, non ancora pubblicato agli utenti |
published | Visibile nell’app agli utenti finali |
reported | Segnalato, in revisione moderazione |
deleted | Soft-deleted, non visibile agli utenti (conservato per storico) |
cancelled | Legacy — trattato come deleted nel frontend |
Invariante
Quando status = 'deleted':
deleted_atdeve essere valorizzatodeleted_bydeve essere valorizzato
Quando status != 'deleted':
deleted_atedeleted_bydevono tornareNULL
Questa logica e gia gestita nel PATCH /api/admin/events/[id].
Query di lettura mobile app
La mobile app dovra filtrare fuori deleted e (opzionalmente) reported dalle query pubbliche:
SELECT * FROM events
WHERE status IN ('published') -- solo published per pubblico
AND deleted_at IS NULLPer il partner che vede i propri eventi:
SELECT * FROM events
WHERE organizer_id = auth.uid()
AND status != 'deleted'Per l’admin vede tutto.
Rischio: basso. Colonne nuove, nessuna modifica a righe esistenti.
Ordine di applicazione consigliato
Tutte le operazioni sono idempotenti e non-bloccanti. Ordine suggerito:
- Prima:
profilesadd columns +eventsadd columns (accesso rapido) - Poi:
user_bans+ RLS + indici - Poi:
admin_actions_log+ RLS + indici - Verifica: creare un ban di test, verificarne lift, controllare l’audit log
Come applicare (quando si decide)
Opzione A — singola migration Supabase (raccomandato):
# Creare il file come
# supabase/migrations/YYYYMMDDHHMMSS_admin_ban_system.sql
# con tutti gli SQL sopra concatenati
npx supabase db pushOpzione B — esecuzione manuale via Studio:
Copiare i blocchi SQL di ogni sezione sopra e incollarli nel SQL editor di Supabase Studio in ordine (1 → 4).
Rollback
Se serve annullare:
DROP TABLE IF EXISTS public.admin_actions_log;
DROP TABLE IF EXISTS public.user_bans;
ALTER TABLE public.profiles
DROP COLUMN IF EXISTS ban_reason,
DROP COLUMN IF EXISTS ban_expires_at,
DROP COLUMN IF EXISTS banned_at,
DROP COLUMN IF EXISTS banned_by;
ALTER TABLE public.events
DROP COLUMN IF EXISTS deleted_at,
DROP COLUMN IF EXISTS deleted_by;
DROP INDEX IF EXISTS idx_events_status;
DROP INDEX IF EXISTS idx_events_deleted_at;Nota: il rollback cancella lo storico ban permanentemente.
Impatto sul codice applicativo gia deployato
Il codice in feat/flow-admin-update (merged su develop) funziona ANCHE senza queste modifiche DB, con il seguente degrado:
| Feature | Con DB | Senza DB |
|---|---|---|
| Modifica campi profilo (username, bio, role, ecc.) | OK | OK |
| Applica ban | Ban salvato + storico + audit | Fallisce — la POST /ban insert su user_bans e su colonne ban_* fallisce |
| Visualizza storico ban | Mostra lista | Lista vuota (query fallisce silently) |
| Soft-delete evento | Status=deleted + deleted_at | Status=deleted OK, deleted_at ignorato |
| Audit log | Tracciato | Silently skipped |
| Analytics globale | OK | OK (la route non usa tabelle nuove) |
Raccomandazione: applicare almeno la sezione 1 (colonne ban su profiles) + sezione 2 (user_bans) prima di usare la feature ban in produzione. Le altre due (audit log, events.deleted_at) sono meno critiche.