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:

  1. Sistema ban completo per utenti (motivazione, durata, storico, rimozione ban)
  2. Status estesi per eventi (reported, deleted oltre a draft, active, published)
  3. 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

ColonnaQuando e valorizzataQuando e NULL
ban_reasonSe status = 'banned'Altrimenti
ban_expires_atBan a tempoBan permanente o niente ban
banned_atMomento di applicazione banNiente ban
banned_byAdmin che ha applicato banNiente 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):

  1. 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
  2. INSERT INTO user_bans (...) nuovo record
  3. UPDATE profiles SET status='banned', ban_*=... per denormalizzazione

Quando un admin rimuove il ban (DELETE /api/admin/users/[id]/ban):

  1. UPDATE user_bans SET lifted_at=NOW(), lifted_by, lifted_reason WHERE user_id=X AND lifted_at IS NULL
  2. UPDATE 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:

ValoreSignificato
draftBozza, visibile solo al creator
activeApprovato dall’admin, non ancora pubblicato agli utenti
publishedVisibile nell’app agli utenti finali
reportedSegnalato, in revisione moderazione
deletedSoft-deleted, non visibile agli utenti (conservato per storico)
cancelledLegacy — trattato come deleted nel frontend

Invariante

Quando status = 'deleted':

  • deleted_at deve essere valorizzato
  • deleted_by deve essere valorizzato

Quando status != 'deleted':

  • deleted_at e deleted_by devono tornare NULL

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 NULL

Per 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:

  1. Prima: profiles add columns + events add columns (accesso rapido)
  2. Poi: user_bans + RLS + indici
  3. Poi: admin_actions_log + RLS + indici
  4. 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 push

Opzione 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:

FeatureCon DBSenza DB
Modifica campi profilo (username, bio, role, ecc.)OKOK
Applica banBan salvato + storico + auditFallisce — la POST /ban insert su user_bans e su colonne ban_* fallisce
Visualizza storico banMostra listaLista vuota (query fallisce silently)
Soft-delete eventoStatus=deleted + deleted_atStatus=deleted OK, deleted_at ignorato
Audit logTracciatoSilently skipped
Analytics globaleOKOK (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.