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.

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.

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();
  }
}

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.

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>
`;

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.

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.

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 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.

🔍

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 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.

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://..." }
}

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.

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

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';
⚠️

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.

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.

When this architecture makes sense (and when it doesn't)

When to use it

  • Content catalog that changes over time (products, documents, media) managed by a small team with no development skills, through a simple admin panel.
  • Content volume — from tens to a few thousand items — where RTDB as source of truth stays performant without needing complex queries.
  • Binary files separated from metadata: Storage for the asset, RTDB for title/description/order, avoiding bloating the database with blobs.
  • Live updates desired: with RTDB listeners, every admin change propagates instantly to all connected clients with no refresh.

When not to use it

  • Very large catalogs (tens of thousands of items with complex filters/search): Firestore with composite queries, or a dedicated search engine like Algolia, is better.
  • Complex data relationships between catalog items: RTDB is a JSON tree, not a relational database — joins and multi-field queries get awkward past a certain complexity.
  • Versioning or audit trail needed for changes: RTDB overwrites state with no native history — a separate application log would be needed.

Alternatives

For more structured catalogs with advanced filters, Firestore is the natural alternative within the same Firebase ecosystem, offering composite queries and pagination without downloading the entire JSON tree like RTDB. For e-commerce with more complex order logic, see also the approach used in the custom e-commerce catalog on Firebase.

Common mistakes

  • Ignoring the race condition between initial render and the first Firebase event: if the DOM is built before the first snapshot arrives, you risk duplicate or missing elements.
  • Assuming arrays stay arrays: RTDB automatically converts arrays with "gaps" (non-sequential indices) into objects — this needs explicit handling in read code.
  • Not bumping the Service Worker cache after a deploy that touches the admin panel's JavaScript: the browser keeps serving the old cached version.

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.

Frequently asked questions

Why does an array saved to Firebase Realtime Database sometimes come back as an object?

RTDB automatically converts a saved JavaScript array into an object with numeric keys when read back. The fix is a helper like _fbToArr() that always normalizes the result — leaving it unchanged if it's already an array, converting it back if it's an object with numeric keys — applied at every point where the data is read.

Why did catalog changes sometimes disappear after a deploy, until hitting Ctrl+Shift+R?

The Service Worker had the previous version of admin.js cached and kept serving it without hitting the network. The fix is incrementing the CACHE_NAME version number on every significant deploy, so the browser downloads the updated files without needing a manual hard reload.

Why does every catalog operation (add, replace, delete) save to Firebase immediately instead of waiting for a "Save" button?

The first version had a "Save All" button nobody actually clicked: changes vanished on refresh because they were never written. Making every operation atomic and self-saving — upload, local catalog update, and RTDB write in the same step — removed any risk of losing unsaved changes.