Il problema: il catalogo era cablato nel codice

PanelControl ha una sezione "Utilità" dove i colleghi scaricano moduli, guide e contratti. Il catalogo era un array JavaScript hardcoded in admin.js — nomi, URL di download e tipo di file tutti scritti a mano. Ogni volta che arrivava un nuovo documento bisognava aprire il codice, aggiungere la voce, committare e ricaricare il pannello.

L'obiettivo era rendere il catalogo completamente gestibile dall'interfaccia: aggiungere file, sostituirli, cambiare i nomi, eliminarli — senza toccare il codice. E tutto questo accessibile solo agli utenti autorizzati, non a tutti i colleghi.

L'architettura: RTDB come fonte di verità, Storage per i file

La soluzione si basa su due servizi Firebase distinti con ruoli precisi:

  • Firebase Realtime DB — nodo utility_catalog: contiene la struttura del catalogo (sezioni, nomi file, URL, tipo). È la fonte di verità unica. Leggero e in tempo reale.
  • Firebase Storage — cartella Utility/: contiene i file fisici. Ogni upload genera un URL pubblico che viene salvato nel DB.

L'array hardcoded non è stato eliminato del tutto — è rimasto come fallback (_UTIL_CATALOG_FALLBACK). Se il nodo RTDB non esiste ancora, l'app lo usa per il seeding automatico al primo avvio, scrivendo subito i dati su Firebase. In questo modo il passaggio dal vecchio al nuovo sistema è stato trasparente.

admin.js — initUtilityCatalog()
// Legge il catalogo da RTDB; se mancante, seed dal fallback e salva
async function initUtilityCatalog() {
  const snap = await db.ref('utility_catalog').once('value');

  if (snap.val()) {
    _dynamicCatalog = _fbToArr(snap.val());   // RTDB → struttura locale
  } else {
    // Primo avvio: seeding automatico dal fallback hardcoded
    _dynamicCatalog = JSON.parse(JSON.stringify(_UTIL_CATALOG_FALLBACK));
    await db.ref('utility_catalog').set(_dynamicCatalog);
  }

  window._UTIL_CATALOG = _dynamicCatalog; // aggiorna la ref globale (chatbot AI)

  // Se l'utente è già sulla vista downloads, ri-renderizza subito
  if (document.getElementById('downloads-list')) {
    renderDownloads();
  }
}

Il pulsante ✏️ Modifica: visibile solo agli admin

Non tutti i colleghi devono poter modificare il catalogo — solo un gruppo ristretto. Il check avviene al momento del render: se il nome dell'utente loggato è in una lista allowlist, il pulsante compare sulla stessa riga del titolo di sezione; altrimenti non viene mai inserito nel DOM.

admin.js — render della sezione Utilità
const UTIL_EDITORS = ['[Admin]', '[Editor_A]', '[EDITOR_B]', '[Editor_C]'];
const canEdit = UTIL_EDITORS.includes(currentUser?.displayName);

const sectionHeader = `
  <div style="display:flex;align-items:center;justify-content:space-between">
    <span class="section-pill">Generali</span>
    ${ canEdit ? \`<button class="uem-btn" onclick="openUtilEditModal()">
        ✏️ Modifica
      </button>\` : '' }
  </div>
`;

Il pulsante ha uno stile pill compatto (12px, border-radius 100px) in viola per distinguersi dagli altri controlli dell'interfaccia. Non è un pulsante primario — non deve attirare l'occhio degli utenti normali che non lo vedranno mai.

Il modal: CRUD completo in tre sezioni

Il modal "Gestione File Utilità" rispecchia la struttura del catalogo in tre pannelli (Generali/[PARTNER_A], [BRAND_POS], [PARTNER_B]). Per ogni file mostra un badge col tipo, un campo di testo editabile per il nome, e due pulsanti d'azione:

  • ⇅ Sostituisci: apre un file picker → upload su Utility/ in Storage → URL aggiornato nel catalogo in memoria. Il tipo viene rilevato automaticamente dall'estensione del file. Una barra di progresso compare inline durante il trasferimento.
  • 🗑️ Elimina: rimuove la voce dal catalogo locale (con conferma), poi salva immediatamente su RTDB.

In fondo ad ogni sezione c'è un blocco espandibile + Aggiungi file con un campo descrizione e una drop-zone per il caricamento. All'upload il file viene inviato su Storage e la voce aggiunta al catalogo con salvataggio automatico su RTDB — senza nessun click aggiuntivo.

admin.js — upload su Storage e salvataggio su RTDB
async function uemUploadAndAdd(section, file, description) {
  const ref = storage.ref(`Utility/${file.name}`);
  const task = ref.put(file);

  // Aggiorna la barra di progresso inline
  task.on('state_changed', snap => {
    const pct = (snap.bytesTransferred / snap.totalBytes * 100).toFixed(0);
    progressBar.style.width = pct + '%';
  });

  await task;
  const url = await ref.getDownloadURL();
  const ext = file.name.split('.').pop().toLowerCase();

  // Aggiunge la voce al catalogo in memoria…
  _dynamicCatalog[section].files.push({ name: description, url, type: ext });

  // …e salva immediatamente su RTDB — nessun "Salva tutto" manuale
  await db.ref('utility_catalog').set(_dynamicCatalog);
  renderDownloads();
}

Bug #1: race condition tra render e Firebase

Il primo test sembrava funzionare: il file veniva caricato su Storage e l'URL salvato su RTDB. Ma dopo un refresh della pagina la nuova voce non compariva. Il problema era una race condition classica nelle app asincrone.

Il flusso era questo: al caricamento della vista, renderDownloads() veniva chiamato subito — ma in quel momento initUtilityCatalog() stava ancora aspettando la risposta di Firebase. La funzione di render leggeva quindi il fallback hardcoded (privo delle voci nuove) e lo mostrava sullo schermo.

🔍

Regola generale: qualsiasi funzione di render che dipende da dati asincroni non deve essere chiamata prima che quei dati siano disponibili. In questo caso la soluzione era far sì che initUtilityCatalog() stessa chiamasse renderDownloads() al termine, non il chiamante esterno.

Bug #2: Firebase converte gli array in oggetti

Anche dopo aver risolto la race condition, i file aggiunti a volte non comparivano correttamente. Il problema era un comportamento poco intuitivo di Firebase Realtime DB: quando salvi un array JavaScript su RTDB e lo rileggi, Firebase lo restituisce come oggetto con chiavi numeriche.

Il problema — array salvato, oggetto riletto
// Quello che salvi su RTDB:
files: [
  { name: "Contratto", url: "https://..." },
  { name: "Guida",     url: "https://..." }
]

// Quello che RTDB ti restituisce con .val():
files: {
  "0": { name: "Contratto", url: "https://..." },
  "1": { name: "Guida",     url: "https://..." }
}

La soluzione è un helper _fbToArr() che normalizza sempre il risultato: se è già un array lo lascia invariato, se è un oggetto con chiavi numeriche lo riconverte. Va applicato in ogni punto in cui si legge il catalogo da Firebase.

admin.js — _fbToArr()
function _fbToArr(val) {
  if (Array.isArray(val)) return val;
  if (val && typeof val === 'object') {
    const keys = Object.keys(val);
    if (keys.every(k => !isNaN(k))) {
      return keys.sort((a,b) => +a - +b).map(k => val[k]);
    }
  }
  return val ?? [];
}

// Usato in tutti i punti di lettura dal DB:
_dynamicCatalog = _dynamicCatalog.map(section => ({
  ...section,
  files: _fbToArr(section.files)
}));

Bug #3: il Service Worker serviva il vecchio JavaScript

Dopo aver caricato i file aggiornati su Netlify, aprendo il pannello le nuove funzioni non comparivano — finché non si faceva Ctrl+Shift+R. Il motivo: il Service Worker aveva in cache la versione precedente di admin.js e la continuava a servire senza andare in rete.

La soluzione è semplice ma va ricordata: ogni volta che si fa un deploy significativo, si incrementa il numero di versione del CACHE_NAME in pwa-utils.js. Quando il browser rileva un Service Worker con contenuto cambiato, installa il nuovo come "pending" e al successivo caricamento (o alla chiusura di tutte le tab) invalida la vecchia cache e scarica i file freschi.

pwa-utils.js — versioning della cache
// Prima — cache stale servita senza problemi
const CACHE_NAME = 'panelcontrol-v9';

// Dopo — il browser scarica tutto da capo senza Ctrl+Shift+R
const CACHE_NAME = 'panelcontrol-v10';
⚠️

Il Service Worker non aggiorna la cache in modo silenzioso e immediato. L'utente vede ancora la versione vecchia finché non ricarica dopo che il nuovo SW è diventato attivo. Per un aggiornamento senza attriti si può aggiungere un listener su controllerchange che mostra un banner "Aggiornamento disponibile — ricarica" e fa location.reload() al click.

Il flusso auto-save: ogni azione scrive subito su Firebase

La prima versione del modal aveva un pulsante "Salva Tutto" che l'utente doveva cliccare dopo le modifiche. In pratica nessuno lo cliccava: aggiungevano un file, chiudevano il modal, e la modifica scompariva al refresh perché non era mai stata scritta su RTDB.

La soluzione definitiva è stata rendere ogni operazione atomica e auto-salvante:

  • Aggiungi: upload su Storage → push al catalogo locale → db.ref('utility_catalog').set(...)
  • Sostituisci: upload su Storage → aggiorna URL nel catalogo locale → db.ref(...).set(...)
  • Elimina: conferma → rimuovi dal catalogo locale → db.ref(...).set(...)

Il pulsante rimasto in fondo si chiama ora "Salva Nomi" e serve esclusivamente per le modifiche ai campi di testo — l'unica operazione che non ha un trigger naturale di completamento.

Il risultato

Chi ha i permessi vede il pill ✏️ Modifica affianco al titolo di sezione. Un click apre il modal: tre pannelli con tutti i file, modificabili, rimpiazzabili o eliminabili in tempo reale. Un blocco espandibile permette di aggiungere nuovi file con descrizione. Ogni operazione persiste immediatamente su Firebase senza passi intermedi.

Chi non è in lista non vede nulla di tutto questo — nessun pulsante, nessun indizio. Il catalogo rimane comunque aggiornato per tutti al successivo caricamento della vista, perché l'inizializzazione asincrona lo ri-renderizza appena i dati arrivano da RTDB.

I tre bug incontrati — race condition, array-to-object di Firebase e cache del Service Worker — sono tutti pattern ricorrenti nelle PWA con backend real-time. Vale la pena tenerli presenti fin dall'inizio della progettazione.