Il problema: un'azienda operativa su fogli Excel e WhatsApp

Il cliente per cui ho costruito PanelControl è un distributore ufficiale di terminali di pagamento myPOS. Ogni giorno gestisce decine di ordini, centinaia di lead commerciali, attivazioni contrattuali, spedizioni, turni del personale, ferie, buste paga e un flusso costante di lead in arrivo da un CRM esterno. Prima di PanelControl, tutto questo viveva sparso tra fogli Excel, chat WhatsApp, email e telefonate — nessuna vista unica, nessuno storico affidabile, nessun modo di sapere in tempo reale cosa stesse facendo un collega nell'ufficio accanto.

Ho progettato e costruito da zero un gestionale web-based che centralizza tutta l'operatività — commerciale, onboarding, assistenza, spedizioni, HR, amministrazione — in un'unica Progressive Web App multi-ruolo, installabile su desktop e su mobile, usata quotidianamente anche su tablet e telefono dal team. Come per gli altri progetti su roversia.it, nessun framework: solo JavaScript caricato direttamente dal browser, per zero attrito nel deploy e nel debug diretto in produzione.

Stack tecnologico

Nessuna riga in comune col resto del sito, ma la stessa filosofia: zero bundler sul frontend, dipendenze scelte una per una.

LivelloTecnologia
FrontendHTML/CSS/JS vanilla, nessun bundler
Dati realtimeFirebase Realtime Database
Query/analyticsFirebase Firestore
AutenticazioneFirebase Auth con Custom Token, coniato lato server
File storageFirebase Storage
Notifiche pushFirebase Cloud Messaging (v1, service account OAuth2)
Backend serverlessNetlify Functions, ESM, zero dipendenze npm
Hosting/CI-CDNetlify, deploy automatico da GitHub
GraficiChart.js
Export datiSheetJS (Excel), jsPDF (PDF)
IA integrataGoogle Gemini API
CRM esternoDelera / GoHighLevel, via webhook
TelefoniaVoxloud, Telephony Events API
EmailGmail API, OAuth2

Un rendering engine scritto a mano, senza React né Vue

L'intera UI è renderizzata da un motore fatto in casa: uno state object globale contiene tutti i dati applicativi — ordini, attivazioni, lead, operatori, turni, ferie, permessi. Ogni modifica allo stato invoca render(), con un debounce di 80ms per evitare re-render a cascata durante scritture multiple ravvicinate, più una renderNow() per i casi che richiedono aggiornamento sincrono immediato, come il cambio mese. Il router interno è uno switch sulla vista corrente, che seleziona quale funzione render*() chiamare — ciascuna definita nel proprio file, caricato come modulo separato.

La parte a cui tengo di più è la guardia di interazione utente: prima di ogni re-render, il motore controlla se il focus è su un campo di input, e in quel caso posticipa il render di qualche secondo, per non distruggere il DOM sotto le dita di chi sta scrivendo — con eccezioni esplicite per select di cambio mese e checkbox, che devono restare reattivi comunque. Senza un framework che gestisce il diffing per me, questa è la regola che ho dovuto scrivere a mano perché un aggiornamento realtime da Firebase non cancellasse mai un modulo mentre un operatore lo sta compilando.

💡

Non tutti i moduli sono caricati al primo avvio. I file più pesanti sono caricati on-demand solo quando l'utente naviga verso quella sezione, con un loader che aspetta e ritenta se il modulo non è ancora arrivato in rete — utile su reti mobili lente, dato che il gestionale viene usato anche fuori sede su tablet e telefono.

Da on('value') a listener granulari: il vero collo di bottiglia

Ogni nodo di dati critico è sincronizzato in tempo reale tra tutti i client connessi tramite i listener nativi di Firebase. L'ottimizzazione più significativa del progetto è stata la migrazione dai listener on('value') ai listener granulari child_added / child_changed / child_removed:

  • Prima: ogni singola scrittura su un nodo — es. una nuova attivazione — causava il ri-scaricamento dell'intero storico da parte di ogni client connesso.
  • Dopo: solo il delta, il record nuovo o modificato, viene trasmesso a ciascun client.
  • Risultato misurato: risparmio stimato di 40-60 MB al giorno di banda Firebase, con impatto diretto sui costi del piano a consumo.

Lo stesso pattern, applicato al log attività, ha ridotto il caricamento iniziale da 200 a 50 record con once('value'), seguito da un listener child_added per i soli eventi nuovi — circa il 75% di banda risparmiata sul caricamento iniziale di quella sezione. È il tipo di ottimizzazione che non si vede nell'interfaccia ma si vede eccome nella bolletta.

Autenticazione: niente più credenziali Firebase condivise

Il sistema di login è stato riprogettato da zero per eliminare le credenziali Firebase condivise lato client. Una Netlify Function verifica username e password con hashing PBKDF2-SHA256, applica rate limiting server-side contro il brute force, e restituisce un Firebase Custom Token coniato lato server. Il client scambia il token con una sessione Firebase Auth autenticata, con permessi granulari mappati via custom claim, verificati nelle regole di sicurezza del database.

Sopra il ruolo di base, ogni operatore può avere permessi specifici abilitati singolarmente da un pannello Admin dedicato. Il pattern architetturale scelto per ogni controllo di accesso è sempre lo stesso:

pseudocodice
accesso = lista_legacy_hardcoded.includes(operatore)
       OR hasAbilitazione(operatore, feature)

Mai la sola verifica dinamica. Questo per non rompere l'accesso degli operatori storici non ancora migrati esplicitamente al nuovo sistema a permessi granulari, garantendo continuità durante la transizione. È un principio che ho applicato in generale su tutte le migrazioni di permessi del progetto: ogni nuova regola si unisce in OR alla vecchia, mai la sostituisce di netto.

Moduli che hanno posto i problemi più interessanti

Chat interna: il pattern "portal" per il position:fixed

La messaggistica tra operatori supporta foto, messaggi vocali, documenti e GIF, con una UI a bolla flottante. Il problema classico: position:fixed smette di funzionare correttamente quando un antenato ha una transform applicata via CSS, cosa comune quando si annidano modali e pannelli. L'ho risolto rendendo la chat un sibling diretto del contenitore principale, invece che un discendente — struttura del DOM, non trucco CSS.

Posta: polling invece di push, schema denormalizzato

Il modulo email interroga la Gmail API con un polling periodico invece che push, e usa uno schema dati denormalizzato su due nodi separati per bilanciare velocità di lista e di dettaglio. Su questo modulo i listener Firebase sono sempre granulari, mai on('value'): i nodi contengono corpi email potenzialmente pesanti, e un on('value') scaricherebbe l'intera mailbox a ogni minima modifica.

Telefonia: architettura ibrida per volumi alti

Lo storico chiamate integra l'API di Voxloud con un'architettura a due velocità: il mese corrente resta su Realtime Database, con listener limitato e paginazione "carica altri"; i mesi passati vengono spostati su Firestore, con paginazione a blocchi. Le ricerche instradano sempre su Firestore, per non saturare il database realtime con query pesanti su migliaia di record.

Riconciliazione con un CRM esterno a rate limit stretti

Una funzione schedulata confronta ogni lead con l'opportunità corrispondente nel CRM esterno, una chiamata alla volta invece che con dump massivi della pipeline, per restare dentro i limiti dell'API. Strategia a due velocità: lead recenti ricontrollati ogni 24 ore, lead più vecchi esclusi permanentemente dal ricontrollo per non consumare quota su dati ormai stabilizzati.

Backend: funzioni serverless idempotenti e a prova di errore

Le funzioni backend sono scritte in ESM puro, deliberatamente senza dipendenze npm, per restare leggere e senza supply chain da mantenere. Alcuni pattern condivisi tra tutte le integrazioni con il CRM esterno:

  • Un secret condiviso valida le richieste webhook in ingresso.
  • Deduplicazione tramite una chiave di business — il numero ordine — invece degli ID tecnici generati dal CRM.
  • Ogni webhook logga sia su Realtime Database sia su Firestore, per avere sia visibilità realtime sia query storiche.
  • Alert critici inviati via Telegram con throttle per categoria, per evitare flood di notifiche in caso di errori a raffica.
  • I webhook rispondono sempre HTTP 200, anche in caso di errore gestito internamente — scelta deliberata per evitare che il CRM esterno ritenti la stessa richiesta all'infinito.

Un vincolo operativo che ho dovuto imparare a gestire: le variabili d'ambiente di Netlify hanno un limite di 4KB per funzione. Credenziali grandi come chiavi private non vengono quindi salvate come variabile d'ambiente, ma tenute direttamente nel file della funzione, con una nota esplicita di rotazione manuale — evita di superare il limite e rompere il deploy, al costo di una procedura di rotazione meno automatica.

Cosa mi porto a casa da questo progetto

PanelControl è il progetto che mi ha insegnato più di ogni altro che "senza framework" non significa "senza disciplina": significa scrivere a mano le regole che un framework darebbe per scontate — un debounce sensato, una guardia sull'interazione utente, un pattern di permessi non distruttivo — e poi rispettarle con costanza mentre il codice cresce da un file a sessantacinque. Lo stesso approccio di PWA installabile e zero dipendenze l'ho usato anche per i giochi di questo sito: cambia la natura del problema, ma la filosofia di fondo resta la stessa.

Se ti interessa la parte di autenticazione, ho raccontato più in dettaglio sia la migrazione a Custom Token per operatore sia il token HMAC cross-app usato per far comunicare in sicurezza PanelControl con altri gestionali dello stesso cliente.

Domande frequenti

Conviene costruire un gestionale aziendale senza framework come React o Vue?

Per un singolo sviluppatore che conosce a fondo la propria base di codice, vanilla JavaScript con un piccolo motore di rendering fatto in casa elimina build step e dipendenze da aggiornare. Il costo si paga in disciplina: senza un framework che impone pattern, tocca mantenerli a mano nel tempo.

Come si riduce il consumo di banda di Firebase Realtime Database su nodi che crescono molto?

Passando da listener on('value') a listener granulari child_added/child_changed/child_removed, che trasmettono solo il delta modificato invece di riscaricare l'intero nodo a ogni scrittura. Il risparmio può arrivare al 70-75% sul caricamento iniziale.

Perché usare un Custom Token Firebase invece delle credenziali dirette lato client?

Con un Custom Token una funzione serverless verifica le credenziali con hashing e rate limiting, poi conia un token firmato lato server. Nessuna credenziale condivisa resta visibile nel client, e i permessi diventano custom claim verificabili nelle regole di sicurezza.

Come si gestiscono i permessi granulari senza rompere l'accesso degli utenti esistenti?

Con un OR non distruttivo: accesso concesso se l'utente è nella vecchia lista hardcoded oppure ha l'abilitazione granulare esplicita. Si introduce un sistema più fine senza dover migrare tutti nello stesso momento.

Perché le push notification su iOS richiedono l'SDK Firebase e non l'evento push nativo?

Il raw push event del Service Worker non risveglia in modo affidabile una PWA completamente chiusa su iOS. L'SDK Firebase Messaging con onBackgroundMessage gestisce correttamente decrittazione del payload e integrazione con APNs.