Cosa c'era già, e cosa mancava davvero

Il gestionale del cliente aveva già un'integrazione solida con il CRM esterno usato per gestire le conversazioni WhatsApp, SMS ed email con i contatti: lettura delle pipeline di vendita, apertura della chat con bolle e allegati, invio di risposte. Tutta infrastruttura riusabile, già in produzione da tempo. Mancava un pezzo preciso: nessuna funzione interrogava l'intera anagrafica contatti del CRM — solo le opportunità dentro una pipeline specifica — e soprattutto non esisteva alcun modo per far leggere all'assistente AI del gestionale le conversazioni passate.

Prima regola, prima di scrivere una riga di codice nuovo: riusare quello che già funzionava. La funzione che leggeva più stage di pipeline in parallelo, il modale che apriva la chat con la cronologia completa, la funzione che inviava messaggi — tutto collaudato e già in uso. L'unico pezzo davvero mancante era una funzione capace di cercare su tutta l'anagrafica contatti, con ricerca testuale e paginazione, cosa che le funzioni esistenti (pensate per singole pipeline) non facevano.

Una ricerca che deve girare su tutti i contatti, non sui 30 caricati a schermo

Con decine di migliaia di contatti in anagrafica, caricarli tutti lato client per filtrarli non è un'opzione. La soluzione corretta è la ricerca server-side: a ogni carattere digitato (con un debounce di 400ms per non saturare l'API), la funzione backend inoltra la query al motore di ricerca del CRM, che la esegue su tutto l'archivio — nome, telefono, email — non solo sui risultati già mostrati a schermo. Quello che l'utente vede, 30 contatti alla volta con un pulsante "carica altri", è solo la pagina corrente, non il perimetro della ricerca.

Un limite reale da conoscere: la ricerca di un CRM di terze parti è quasi sempre un match "contiene", non fuzzy — non tollera refusi o l'inversione di nome e cognome. Va tenuto presente sia nell'interfaccia utente sia, più avanti in questa storia, in un pezzo di logica AI che si è rotto proprio per questo motivo.

Due bug di interfaccia piccoli, ma fastidiosi

Prima di affrontare la parte difficile, due correzioni minori hanno migliorato parecchio l'usabilità della rubrica. Il primo era un disallineamento tra intestazioni di colonna e righe della tabella: la causa era l'uso di due sistemi di layout diversi, un contenitore flessibile per l'header e una tabella vera per le righe, che non potevano mai combaciare in larghezza. La correzione è stata mettere l'header dentro un vero <thead> nella stessa tabella, con un <colgroup> a fissare le stesse larghezze per entrambi.

Il secondo era un filtro a cascata per etichetta: una nuova funzione elenca tutte le etichette esistenti nell'account (non solo quelle viste nei contatti già caricati a schermo), popola una tendina, e selezionarne una rilancia la stessa ricerca testuale passando il nome dell'etichetta — zero nuovi endpoint per il filtro vero e proprio, visto che il motore di ricerca del CRM copriva già anche le etichette.

La richiesta che cambia la scala del progetto

A quel punto è arrivata la richiesta che ha cambiato la natura del lavoro: il cliente voleva che tutti i contatti e tutte le conversazioni restassero salvati anche nel proprio database, aggiornati nel tempo, interrogabili dall'assistente AI del gestionale — non più solo letti al volo dal CRM quando serviva. Con quasi 48mila contatti e uno storico chat che cresce ogni giorno, questa non è più una funzione in più: è una scelta di architettura.

Perché Firestore e non Realtime Database

La prima decisione, ed è quella su cui vale la pena non sbagliare: Realtime Database ritrasmette l'intero nodo a ogni scrittura, a tutti i client collegati. Con decine di migliaia di contatti e conversazioni che si allungano nel tempo, un nodo del tipo contatti/{id}/messaggi sarebbe diventato un problema di banda già dalla prima ondata di scritture. Firestore, al contrario, supporta query indicizzate, sottocollezioni che crescono senza bisogno di riscrivere il documento padre, e — il motivo decisivo in questo caso — la ricerca vettoriale nativa (vector search), che serve esattamente per una ricerca semantica trasversale su tutte le conversazioni, senza dover aggiungere un database vettoriale separato da pagare e gestire.

Il modello dati è un documento per contatto (anagrafica, etichette, ultima sincronizzazione) con una sottocollezione messages per la cronologia — non un array dentro il documento stesso. Un contatto con centinaia di messaggi supererebbe presto il limite di un megabyte per documento, e soprattutto ogni nuovo messaggio scriverebbe un solo documento nuovo, invece di riscrivere ogni volta l'intero storico.

Il backfill a lotti, e il primo ostacolo: "Cannot find package firebase-admin"

Per scrivere e interrogare campi vettoriali su Firestore, la via affidabile è l'SDK ufficiale firebase-admin, non l'API REST grezza. Questo però va in deroga a una convenzione del progetto — zero dipendenze npm nelle Netlify Function esistenti — e la deroga si è fatta sentire al primo deploy: Cannot find package 'firebase-admin' imported from /var/task/..., nonostante il pacchetto fosse dichiarato correttamente in un package.json dedicato.

La causa è un problema di bundling delle function, non di codice: il bundler non stava impacchettando la dipendenza nello zip finale. Primo tentativo, impostare esplicitamente il bundler moderno via node_bundler = "esbuild" nella configurazione — stesso errore. Secondo tentativo, marcare esplicitamente il pacchetto come "esterno" da non impacchettare (external_node_modules), la soluzione standard documentata per casi come questo — di nuovo lo stesso identico errore, segno che il problema non era più il bundling ma l'installazione stessa in fase di build.

⚠️

La soluzione definitiva, quando le vie "pulite" non bastano. Niente più tentativi a metà: scaricare le dipendenze in anticipo e committarle già pronte nel repository (il cosiddetto "vendoring" di node_modules), così Netlify se le ritrova lì senza doverle installare in fase di build.

Il dettaglio pratico interessante: il cliente lavora solo da browser, senza terminale locale. La soluzione è stata usare un ambiente di sviluppo cloud accessibile dal browser (un "codespace"), con un vero terminale integrato: npm install dentro la cartella delle function, poi commit e push dei file generati direttamente dall'interfaccia — zero comandi da installare sul proprio computer. Con node_modules fisicamente presente nel repository, il deploy successivo è passato al primo colpo.

Il cursore che non avanzava (e il costo nascosto di rifare lo stesso lavoro)

Con il bundling risolto, il vero import dei quasi 48mila contatti non può essere una singola chiamata: va per forza a lotti, ripetuti da una function schedulata ogni pochi minuti, con un cursore di avanzamento salvato su Firestore per riprendere esattamente da dove si era interrotta. Il primo giro reale ha mostrato un problema più sottile: il cursore restava sempre null. Il lotto configurato (25 contatti) semplicemente non faceva in tempo a completarsi entro il budget di tempo della function, quindi ogni esecuzione ripartiva sempre dagli stessi contatti iniziali — non solo senza avanzare, ma anche ripagando l'imbottitura di ogni messaggio già embeddato in precedenza, un costo silenzioso e del tutto evitabile.

La correzione è stata doppia: ridurre la dimensione del lotto a una misura che completa davvero entro il budget di tempo disponibile, e — soprattutto — controllare quali messaggi avessero già un embedding salvato da un giro precedente, saltandoli invece di rigenerarli. Con quel fix, il cursore ha finalmente iniziato ad avanzare, e una stima approssimativa sul ritmo osservato ha dato circa un giorno e mezzo per l'intero storico.

Messaggi in tempo reale: un trigger "il cliente ha risposto" che non basta da solo

Per i messaggi nuovi, l'automazione del CRM offriva un trigger tipo "risposta del cliente" — ma con un limite non ovvio finché non ci si arriva: le variabili disponibili nel corpo del webhook esponevano l'identità del contatto, non il testo del messaggio né il suo timestamp reale. E soprattutto, nessun trigger equivalente esisteva per "l'operatore ha risposto": i motori di automazione di questo tipo reagiscono ad azioni del cliente, non del team interno.

La soluzione più solida non è stata inseguire un trigger che probabilmente non esiste, ma cambiare uso di quello disponibile: il webhook non porta più il testo del messaggio, porta solo l'identità del contatto, usata come segnale "questo contatto è appena stato attivo, risincronizzalo adesso". La function richiamata rilegge l'intera conversazione fresca dalla stessa API già usata dal backfill — che include sia i messaggi in entrata sia quelli in uscita nello stesso fetch. Un solo trigger disponibile, copertura completa su entrambe le direzioni. Un secondo webhook più leggero, agganciato ai trigger di creazione/modifica contatto, copre invece i cambi di etichetta o anagrafica che avvengono senza un nuovo messaggio.

Dati sporchi nel flusso: eventi di pipeline scambiati per messaggi

Un effetto collaterale non previsto: tra i messaggi importati comparivano anche eventi di sistema del tipo "opportunità eliminata", generati dal CRM quando un'opportunità cambia stato — non testo scritto da nessuno dei due lati della conversazione. La correzione, minima ma necessaria in tre punti diversi (il webhook in tempo reale, la funzione di sincronizzazione condivisa, e il job di backfill già in produzione), è stata filtrare esplicitamente questi eventi in base al loro tipo prima di salvarli o generarne l'embedding.

Un promemoria pratico su questo tipo di modifica: un job di importazione già live e in esecuzione va corretto con patch chirurgiche, non riscritto per pulizia del codice a metà percorso — il rischio di introdurre un nuovo bug proprio mentre sta processando decine di migliaia di record supera di gran lunga il beneficio estetico.

Cercare per telefono, cercare per nome — e un'ambiguità silenziosa

Il primo test reale della ricerca AI ha mostrato subito il limite della ricerca semantica: cercare un numero di telefono con il motore vettoriale non funziona, perché il numero non compare nel significato del testo dei messaggi. Serviva un lookup diretto per identità, non per contenuto — due strumenti diversi per due domande diverse.

Il primo tentativo di lookup per telefono ha prodotto un bug più insidioso: un numero locale di dieci cifre che iniziava, per pura coincidenza, con lo stesso prefisso internazionale italiano, veniva scambiato per un numero già completo — e la ricerca si fermava al primo risultato trovato, restituendo il contatto sbagliato senza alcun avviso. La correzione corretta non è stata "indovinare meglio" ma verificare tutte le varianti plausibili in parallelo (con e senza prefisso) e, se emergono contatti diversi, dichiarare esplicitamente l'ambiguità invece di sceglierne uno a caso. Lo stesso principio è stato poi esteso a un lookup diretto per nome esatto e per etichetta, entrambi con fallback automatico e silenzioso sulla ricerca semantica quando il match diretto non trova nulla.

Il fuso orario sbagliato nella risposta dell'AI

Un dettaglio facile da sottovalutare: i timestamp erano salvati correttamente in UTC, come è giusto che sia, ma venivano passati così com'erano al modello linguistico, lasciando che fosse lui a convertirli mentalmente nel fuso orario locale nella risposta finale. Il risultato era un orario sbagliato di due ore, in modo silenzioso e senza nessun errore da intercettare — un messaggio delle 13:22 locali veniva presentato come delle 11:22.

La correzione affidabile non è stata scrivere un'istruzione più insistente nel prompt, ma togliere del tutto quel calcolo al modello: convertire l'orario lato server nel fuso orario corretto (gestendo automaticamente anche il cambio ora legale/solare) prima di iniettarlo nel contesto, e dichiarare esplicitamente che l'orario ricevuto è già locale, così il modello non ci riprova per conto suo.

Quanto deve essere lunga una risposta dell'AI

Un ultimo problema, più banale ma frequente: alcune risposte con liste lunghe di messaggi venivano tagliate a metà, con l'ultimo elemento — spesso il più recente e il più cercato — mancante. La causa era semplicemente un budget di token di risposta troppo basso per il modello linguistico. La correzione ha tre livelli, non uno solo: alzare il budget massimo di token della risposta, ordinare le liste dal messaggio più recente al più vecchio (così un eventuale taglio futuro perde i dati meno rilevanti, non quelli appena scritti), e istruire esplicitamente il modello a restare compatto nella formattazione, per non sprecare margine di risposta in decorazioni inutili.

Cosa mi porto a casa

La lezione più generale è che riusare l'infrastruttura esistente fa risparmiare tempo reale, ma cambiare scala (da poche letture al volo a decine di migliaia di record persistenti e interrogabili) è sempre una decisione di architettura, non solo di codice — vale la pena fermarsi a deciderla esplicitamente prima di scrivere la prima riga. Una deroga a una convenzione consolidata come "zero dipendenze npm" va presa con un piano B già pronto: se il bundling standard non funziona al primo colpo, il vendoring manuale delle dipendenze è una via lenta ma affidabile, ed è bene saperlo prima del giorno del deploy, non durante. Ricerca per identità e ricerca per significato sono due strumenti diversi, non uno strumento con due modalità: usare quello sbagliato produce un risultato che sembra plausibile ma è silenziosamente scorretto, il tipo di errore più difficile da individuare. E infine, i bug più fastidiosi in questo lavoro non sono mai stati le decisioni "grandi" — Firestore o RTDB, vettoriale o testuale — ma i dettagli piccoli e silenziosi: un budget di tempo troppo stretto, un fuso orario non convertito, un limite di token troppo basso. Nessuno di questi produce un errore visibile: producono solo una risposta sbagliata che sembra giusta.

Se ti interessa il ragionamento più ampio dietro le scelte di database in questo stesso gestionale, ne ho scritto anche a proposito della migrazione delle regole RTDB dopo un problema di autenticazione. E se vuoi vedere com'è strutturato l'assistente AI che qui ha imparato a leggere le chat, l'articolo di partenza è quello sul chatbot con Gemini API nel gestionale.

Domande frequenti

Perché usare Firestore invece di Realtime Database per salvare le conversazioni di un CRM?

Perché Realtime Database ritrasmette l'intero nodo a ogni scrittura a tutti i client collegati: con decine di migliaia di contatti e conversazioni che crescono nel tempo diventa un problema di banda. Firestore, invece, supporta query indicizzate, sottocollezioni che crescono senza riscrivere il documento padre, e la ricerca vettoriale nativa necessaria per la ricerca semantica.

Cos'è la ricerca vettoriale (vector search) e a cosa serve in un caso come questo?

È una ricerca per significato invece che per parole esatte: ogni messaggio viene trasformato in un vettore numerico (embedding) tramite un modello AI, e una query dello stesso tipo trova i messaggi semanticamente più vicini. Serve per domande come "chi ha chiesto un rimborso questo mese", dove il testo esatto della domanda non compare mai nei messaggi originali.

Perché una Netlify Function con una dipendenza come firebase-admin può fallire in produzione anche se in locale funziona?

Perché il bundler delle function a volte non riesce a impacchettare correttamente pacchetti pesanti con dipendenze native o require() dinamici, anche impostando esplicitamente il bundler moderno o escludendo il pacchetto dal bundle. Nei casi più ostinati l'unica soluzione affidabile è vendorizzare node_modules, cioè committarlo già pronto nel repository invece di farlo installare in fase di build.

Perché un numero di telefono senza prefisso internazionale può portare al contatto sbagliato?

Perché una ricerca che si ferma al primo risultato trovato, senza controllare tutte le varianti plausibili del numero (con e senza prefisso), può restituire un contatto diverso da quello cercato per pura coincidenza numerica. La soluzione corretta è verificare tutte le varianti in parallelo e segnalare esplicitamente un'ambiguità invece di scegliere a caso.

È possibile intercettare in tempo reale sia i messaggi in entrata sia quelli in uscita da un CRM?

Dipende dal CRM: molti motori di automazione offrono un trigger per "risposta del cliente" ma nessun trigger nativo per "l'operatore ha risposto". In quel caso conviene usare il trigger disponibile solo come segnale "questo contatto è attivo, risincronizzalo", e rileggere l'intera conversazione da un'API che restituisce entrambe le direzioni in un solo passaggio.

Perché gli orari salvati in UTC possono apparire sbagliati in una risposta generata da un'AI?

Perché se si lascia che sia il modello a convertire mentalmente un timestamp UTC nel fuso orario locale, il calcolo può essere sbagliato in modo silenzioso. La soluzione affidabile è convertire l'orario lato server prima di passarlo al prompt, e dichiarare esplicitamente al modello che l'orario è già locale, così non ci riprova da solo.