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.
The problem: the catalog was baked into the code
PanelControl has a "Utility" section where team members download forms, guides and contracts. The catalog was a hardcoded JavaScript array in admin.js — names, download URLs and file types all written by hand. Every time a new document arrived, someone had to open the code, add the entry, commit and reload the panel.
The goal was to make the catalog fully manageable from the UI: add files, replace them, rename them, delete them — without touching code. And all of this accessible only to authorized users, not the entire team.
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.
The architecture: RTDB as source of truth, Storage for files
The solution relies on two distinct Firebase services with precise roles:
- Firebase Realtime DB — node
utility_catalog: holds the catalog structure (sections, file names, URLs, type). It's the single source of truth. Lightweight and real-time. - Firebase Storage — folder
Utility/: holds the physical files. Each upload generates a public URL that gets saved in the DB.
The hardcoded array wasn't completely removed — it survived as a fallback (_UTIL_CATALOG_FALLBACK). If the RTDB node doesn't exist yet, the app uses it for automatic seeding on first launch, immediately writing the data to Firebase. This made the migration from old to new completely transparent.
// 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.
The ✏️ Edit button: visible only to admins
Not every team member should be able to edit the catalog — only a small group. The check happens at render time: if the logged-in user's name is in an allowlist, the button appears on the same row as the section title; otherwise it's never injected into the DOM.
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.
The button has a compact pill style (12px, border-radius 100px) in purple to stand out from other UI controls. It's not a primary button — it doesn't need to catch the eye of regular users who will never see it.
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.
The modal: full CRUD in three sections
The "Utility File Management" modal mirrors the catalog structure in three panels (General/[PARTNER_A], [BRAND_POS], [PARTNER_B]). For each file it shows a type badge, an editable text field for the name, and two action buttons:
- ⇅ Replace: opens a file picker → uploads to
Utility/in Storage → updates the URL in the in-memory catalog. The type is auto-detected from the file extension. An inline progress bar appears during the transfer. - 🗑️ Delete: removes the entry from the local catalog (with confirmation), then immediately saves to RTDB.
At the bottom of each section there's an expandable + Add file block with a description field and a drop-zone for uploading. On upload the file is sent to Storage and the entry added to the catalog with auto-save to RTDB — no extra click needed.
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.
Bug #1: race condition between render and Firebase
The first test seemed to work: the file was uploaded to Storage and the URL saved in RTDB. But after a page refresh the new entry didn't appear. The problem was a classic race condition in async apps.
The flow was: when the view loaded, renderDownloads() was called immediately — but at that point initUtilityCatalog() was still waiting for Firebase's response. The render function therefore read the hardcoded fallback (without the new entries) and displayed it on screen.
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.
General rule: any render function that depends on async data should not be called before that data is available. The fix here was to have initUtilityCatalog() itself call renderDownloads() on completion, not the external caller.
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.
Bug #2: Firebase converts arrays to objects
Even after fixing the race condition, added files sometimes didn't appear correctly. The problem was a non-obvious behavior of Firebase Realtime DB: when you save a JavaScript array to RTDB and read it back, Firebase returns it as an object with numeric keys.
// 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.
The fix is a _fbToArr() helper that always normalizes the result: if it's already an array it leaves it unchanged, if it's an object with numeric keys it converts it back. It must be applied at every point where the catalog is read from Firebase.
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.
Bug #3: the Service Worker was serving old JavaScript
After uploading the updated files to Netlify, opening the panel showed none of the new functions — until hitting Ctrl+Shift+R. The reason: the Service Worker had the previous version of admin.js cached and kept serving it without hitting the network.
The fix is simple but needs to be remembered: on every significant deploy, increment the version number in CACHE_NAME in pwa-utils.js. When the browser detects a Service Worker with changed content, it installs the new one as "pending" and on next load (or when all tabs are closed) it invalidates the old cache and downloads fresh files.
// 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.
The Service Worker doesn't silently update the cache immediately. Users still see the old version until they reload after the new SW becomes active. For a frictionless update, you can add a controllerchange listener that shows a "Update available — reload" banner and calls location.reload() on 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.
The auto-save flow: every action writes to Firebase immediately
The first version of the modal had a "Save All" button the user had to click after making changes. In practice nobody clicked it: they'd add a file, close the modal, and the change would vanish on refresh because it was never written to RTDB.
The definitive fix was to make every operation atomic and auto-saving:
- ✅ Add: upload to Storage → push to local catalog →
db.ref('utility_catalog').set(...) - ✅ Replace: upload to Storage → update URL in local catalog →
db.ref(...).set(...) - ✅ Delete: confirm → remove from local catalog →
db.ref(...).set(...)
The remaining button at the bottom is now called "Save Names" and is exclusively for text field edits — the only operation without a natural completion trigger.
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.
The result
Users with permissions see the ✏️ Edit pill next to the section title. One click opens the modal: three panels with all files, editable, replaceable or deletable in real time. An expandable block lets you add new files with a description. Every operation persists immediately to Firebase with no intermediate steps.
Users not in the allowlist see none of this — no button, no hint. The catalog remains up to date for everyone on the next view load, because the async initialization re-renders it as soon as the data arrives from RTDB.
The three bugs encountered — race condition, Firebase array-to-object, and Service Worker cache — are all recurring patterns in PWAs with real-time backends. Worth keeping in mind from the start of any similar project.