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.
What already existed, and what was really missing
The client's management panel already had a solid integration with the external CRM used to handle WhatsApp, SMS, and email conversations with contacts: reading sales pipelines, opening chat with bubbles and attachments, sending replies. All reusable infrastructure, already in production for a while. One specific piece was missing: no function queried the CRM's full contact directory — only opportunities inside a specific pipeline — and, more importantly, there was no way at all for the panel's AI assistant to read past conversations.
First rule, before writing a single new line of code: reuse what already worked. The function that read multiple pipeline stages in parallel, the modal that opened chat with full history, the function that sent messages — all proven and already in use. The one genuinely missing piece was a function able to search the entire contact directory, with text search and pagination, which the existing functions (built for single pipelines) didn't do.
A search that has to run across all contacts, not just the 30 loaded on screen
With tens of thousands of contacts in the directory, loading them all client-side to filter isn't an option. The correct approach is server-side search: on every keystroke (debounced by 400ms to avoid hammering the API), the backend function forwards the query to the CRM's own search engine, which runs it against the entire archive — name, phone, email — not just the results already shown on screen. What the user sees, 30 contacts at a time with a "load more" button, is only the current page, not the search scope.
A real limitation worth knowing: a third-party CRM's search is almost always a "contains" match, not fuzzy — it doesn't tolerate typos or a swapped first/last name. Worth keeping in mind both in the UI and, later in this story, in a piece of AI logic that broke for exactly this reason.
Two small but annoying interface bugs
Before tackling the hard part, two minor fixes noticeably improved the contact directory's usability. The first was a misalignment between column headers and table rows: caused by using two different layout systems, a flex container for the header and a real table for the rows, which could never match in width. The fix was putting the header inside a real <thead> in the same table, with a <colgroup> fixing the same widths for both.
The second was a cascading tag filter: a new function lists every tag that exists in the account (not just the ones seen among the contacts already loaded on screen), populates a dropdown, and selecting one reruns the same text search passing the tag name — zero new endpoints for the actual filtering, since the CRM's search engine already covered tags too.
The request that changes the scale of the project
At that point came the request that changed the nature of the work: the client wanted every contact and every conversation to also persist in their own database, kept up to date over time, queryable by the panel's AI assistant — no longer just fetched on the fly from the CRM when needed. With nearly 48,000 contacts and a chat history growing daily, this stopped being one more feature and became an architecture decision.
Why Firestore and not Realtime Database
The first decision, and the one worth getting right: Realtime Database retransmits the entire node on every write, to every connected client. With tens of thousands of contacts and conversations that keep growing, a node like contacts/{id}/messages would have become a bandwidth problem from the very first wave of writes. Firestore, by contrast, supports indexed queries, subcollections that grow without rewriting the parent document, and — the decisive reason here — native vector search, exactly what's needed for semantic search across all conversations at once, without adding a separate vector database to pay for and manage.
The data model is one document per contact (profile, tags, last sync) with a messages subcollection for history — not an array inside the document itself. A contact with hundreds of messages would soon hit the one-megabyte-per-document limit, and more importantly every new message writes a single new document instead of rewriting the entire history each time.
The batched backfill, and the first obstacle: "Cannot find package firebase-admin"
Writing and querying vector fields on Firestore reliably means using the official firebase-admin SDK, not the raw REST API. That runs against a project convention — zero npm dependencies in the existing Netlify Functions — and the exception made itself felt on the very first deploy: Cannot find package 'firebase-admin' imported from /var/task/..., despite the package being correctly declared in a dedicated package.json.
The cause is a function-bundling problem, not a code problem: the bundler simply wasn't packaging the dependency into the final zip. First attempt, explicitly setting the modern bundler via node_bundler = "esbuild" in the config — same error. Second attempt, explicitly marking the package as "external" so it wouldn't be bundled (external_node_modules), the documented standard fix for exactly this case — again the identical error, a sign the problem was no longer bundling but the install step itself during the build.
The definitive fix, when the "clean" routes aren't enough. No more half-measures: download the dependencies ahead of time and commit them already installed into the repository (so-called "vendoring" of node_modules), so the platform finds them already there instead of having to install them during the build.
The interesting practical detail: the client works entirely from a browser, with no local terminal. The solution was a browser-accessible cloud dev environment (a "codespace") with a real built-in terminal: npm install inside the functions folder, then commit and push the generated files straight from the interface — zero commands to install on the person's own computer. With node_modules physically present in the repository, the next deploy went through on the first try.
The cursor that never advanced (and the hidden cost of redoing the same work)
With bundling solved, actually importing nearly 48,000 contacts can't be a single call: it has to run in batches, repeated by a function scheduled every few minutes, with a progress cursor saved on Firestore to resume exactly where it left off. The first real run revealed a subtler problem: the cursor stayed null forever. The configured batch (25 contacts) simply couldn't finish within the function's time budget, so every execution restarted from the same initial contacts — not only failing to advance, but also re-paying for embedding every message that had already been embedded before, a silent and entirely avoidable cost.
The fix had two parts: shrink the batch size to something that genuinely completes within the available time budget, and — more importantly — check which messages already had an embedding saved from a previous run, skipping them instead of regenerating them. With that fix, the cursor finally started advancing, and a rough estimate based on the observed pace put the full historical import at about a day and a half.
Real-time messages: a "customer replied" trigger that isn't enough on its own
For new messages, the CRM's automation offered a "customer replied"-style trigger — but with a non-obvious limitation: the variables available in the webhook body exposed the contact's identity, not the message text or its real timestamp. And, more importantly, no equivalent trigger existed for "the operator replied": automation engines of this kind react to customer actions, not to internal team actions.
The sturdier fix wasn't chasing a trigger that probably doesn't exist, but changing how the available one gets used: the webhook no longer carries the message text, only the contact's identity, used as a signal meaning "this contact was just active, resync it now." The function it calls re-reads the entire fresh conversation from the same API the backfill already uses — which includes both inbound and outbound messages in the same fetch. One available trigger, full coverage on both directions. A second, lighter webhook, attached to contact-created/updated triggers, covers tag or profile changes that happen without a new message.
Dirty data in the pipeline: system events mistaken for messages
An unplanned side effect: imported messages started including system events like "opportunity deleted", generated by the CRM whenever an opportunity changes stage — not text written by either side of the conversation. The fix, minimal but needed in three separate places (the real-time webhook, the shared sync function, and the backfill job already live in production), was to explicitly filter these events by type before saving them or generating an embedding for them.
A practical reminder about this kind of change: an import job that's already live and running should be fixed with surgical patches, not rewritten for code cleanliness midway through — the risk of introducing a new bug while it's processing tens of thousands of records far outweighs the cosmetic benefit.
Searching by phone, searching by name — and a silent ambiguity
The first real test of AI search immediately exposed the limit of semantic search: searching for a phone number with the vector engine doesn't work, because the number doesn't appear in the meaning of the message text. What was needed was a direct identity lookup, not a content search — two different tools for two different questions.
The first attempt at a phone lookup produced a sneakier bug: a ten-digit local number that happened, by pure coincidence, to start with the same digits as the Italian country code was mistaken for an already-complete international number — and the search stopped at the first match found, returning the wrong contact with no warning at all. The correct fix wasn't "guess better" but checking every plausible variant in parallel (with and without the country code) and, if different contacts turn up, explicitly flagging the ambiguity instead of picking one at random. The same principle was then extended to a direct lookup by exact name and by tag, both with an automatic, silent fallback to semantic search whenever the direct match finds nothing.
The wrong time zone in the AI's answer
An easy detail to underestimate: timestamps were correctly stored in UTC, as they should be, but were passed to the language model as-is, leaving it to mentally convert them to local time in its final answer. The result was silently off by two hours, with no error to catch — a message from 1:22 PM local time was presented as 11:22 AM.
The reliable fix wasn't a more insistent prompt instruction, but removing that calculation from the model entirely: converting the timestamp server-side to the correct time zone (automatically handling daylight saving as well) before injecting it into the context, and explicitly stating that the time received is already local, so the model doesn't attempt the conversion again on its own.
How long an AI answer should be
One last problem, more mundane but frequent: some answers with long lists of messages were getting cut off halfway, with the last item — often the most recent and the most relevant one — missing. The cause was simply a response token budget too low for the language model. The fix has three layers, not just one: raise the maximum response token budget, order lists from most recent message to oldest (so any future truncation drops the least relevant data, not the freshest), and explicitly instruct the model to stay compact in its formatting, so it doesn't waste response headroom on unnecessary decoration.
What I take away from this
The broader lesson is that reusing existing infrastructure saves real time, but changing scale (from a few on-the-fly reads to tens of thousands of persistent, queryable records) is always an architecture decision, not just a code one — worth stopping to decide explicitly before writing the first line. Making an exception to an established convention like "zero npm dependencies" needs a plan B ready in advance: if standard bundling doesn't work on the first try, manually vendoring dependencies is a slow but reliable path, and it's better to know that before deploy day, not during it. Identity search and semantic search are two different tools, not one tool with two modes: using the wrong one produces a result that looks plausible but is silently incorrect, the hardest kind of bug to spot. And finally, the most annoying bugs in this work were never the "big" decisions — Firestore or RTDB, vector or text search — but the small, silent details: a time budget too tight, a time zone left unconverted, a token limit set too low. None of these throws a visible error: they just produce a wrong answer that looks right.
If you're interested in the broader reasoning behind database choices in this same management panel, I also wrote about migrating RTDB rules after an authentication issue. And if you want to see how the AI assistant that learned to read these chats is structured in the first place, the starting article is the one on the Gemini API chatbot inside the management panel.
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.
Frequently asked questions
Why use Firestore instead of Realtime Database to store a CRM's conversations?
Because Realtime Database retransmits the entire node on every write, to every connected client: with tens of thousands of contacts and growing conversations that becomes a bandwidth problem. Firestore instead supports indexed queries, subcollections that grow without rewriting the parent document, and the native vector search needed for semantic search.
What is vector search and what is it useful for in a case like this?
It's a search by meaning rather than by exact words: each message is turned into a numeric vector (embedding) by an AI model, and a query of the same kind finds the semantically closest messages. It's useful for questions like "who asked for a refund this month", where the exact wording of the question never appears in the original messages.
Why can a Netlify Function with a dependency like firebase-admin fail in production even if it works locally?
Because the function bundler sometimes fails to correctly package heavy dependencies with native bindings or dynamic require() calls, even when explicitly setting the modern bundler or excluding the package from the bundle. In the most stubborn cases the only reliable fix is vendoring node_modules — committing it already installed into the repository instead of letting it install during the build.
Why can a phone number without its country code lead to the wrong contact?
Because a search that stops at the first match found, without checking every plausible variant of the number (with and without the country code), can return a different contact than the one being searched for by pure numeric coincidence. The correct fix is checking every variant in parallel and explicitly flagging an ambiguity instead of picking one at random.
Is it possible to capture both inbound and outbound CRM messages in real time?
It depends on the CRM: many automation engines offer a "customer replied" trigger but no native trigger for "the operator replied". In that case, it's better to use the available trigger only as a "this contact is active, resync it" signal, and re-read the whole conversation from an API that returns both directions in a single pass.
Why can timestamps stored in UTC appear wrong in an AI-generated answer?
Because if the model is left to mentally convert a UTC timestamp to local time, the calculation can be silently wrong. The reliable fix is converting the time server-side before passing it into the prompt, and explicitly stating to the model that the time is already local, so it doesn't attempt the conversion again on its own.