Skip to content

Connettore: Zoho CRM (record di un modulo, con i campi dichiarati) ​

Legge i record di un modulo di Zoho CRM dentro il grafo: zohoCrmRecords è un Source che restituisce tutti i record del modulo, con i soli campi che il nodo dichiara, convertiti in colonne tipizzate. Codice in src/connectors/zoho/, subpath @noeva/etl-core/connectors/zoho; decisioni in ADR-0106.

Legge con la Records API v8, oppure, per i moduli oltre i 100.000 record, con la Query API (COQL). Come Jira (ADR-0100) il connettore non ha un trasporto suo: parla con richiestaHttp, e ne eredita timeout, tetto sui byte, abort, validazione Zod e retry di 429 e 5xx. Il token lo rinnova un'identità del modulo OAuth2, con il refresh token di Zoho.

FunzioneTipoCosa fa
zohoCrmRecordsSourceI record di un modulo, con i campi dichiarati, in colonne
loadZohoRegistryregistryLe organizzazioni configurate, per istanza (ADR-0030)
loadZohoConfigconfigUna sola organizzazione, da variabili con prefisso
loadZohoInstanceconfigUna sola organizzazione del registry, senza le altre
istanzaZohoPerIlPianopianoL'organizzazione per il piano, con la sua ricaduta
trasportoZohoconfigLa config del trasporto http per una richiesta, col token
MANIFEST_ZOHOconfigLe variabili di un'organizzazione, per il piano (ADR-0089)
valoreZoho, colonnaZohoutilitàLa conversione di un valore e il nome della sua colonna
schemaRecordZohocontrattoLo schema Zod strict di un record letto con quei campi
nomiColonneZohoutilitàLe colonne di un record, id compreso: quelle di un sink
colonneZoho, tipoColonnaZohoutilitàLe colonne con il campo da cui vengono, e il loro tipo
dettaglioCredenzialeZohodescriptorLa riga credenziale, per un nodo Zoho
maxCampiDi, eCampoUtenteutilitàI campi ammessi da una lettura; se un campo è un utente
LetturaZoho, CampoZoho, …tipiLa lettura (records o coql), un campo, un record
MAX_*, DEFAULT_ZOHO_*, CAMPO_*, SUFFISSO_NOMEcostantiI tetti di Zoho, i default del connettore, i nomi fissi

Configurazione ​

loadZohoRegistry(env?) risolve le organizzazioni: con ZOHO_INSTANCES=vendite ognuna ha le sue ZOHO_<ID>_*, senza lista valgono le variabili base ZOHO_* come istanza default.

VariabileDefaultNote
ZOHO_INSTANCES—elenco delle organizzazioni; senza, l'istanza default dalle variabili base
ZOHO_<ID>_BASE_URL—obbligatoria; la base delle API v8 del data center, https://www.zohoapis.eu/crm/v8
ZOHO_<ID>_OAUTH_INSTANCEdefaultl'identità OAUTH_<ID>_* che rinnova il token
ZOHO_<ID>_TIMEOUT_MS60000tempo massimo di una richiesta, in millisecondi

Qui non c'è nessun segreto. Client id, client secret e refresh token stanno nell'identità OAuth2, con il token endpoint del data center di Zoho:

bash
OAUTH_INSTANCES=zoho_vendite
OAUTH_ZOHO_VENDITE_TOKEN_ENDPOINT=https://accounts.zoho.eu/oauth/v2/token
OAUTH_ZOHO_VENDITE_CLIENT_ID=<client id della Zoho API Console>
OAUTH_ZOHO_VENDITE_CLIENT_SECRET=<nel .env del worker, mai nel repository>
OAUTH_ZOHO_VENDITE_REFRESH_TOKEN=<nel .env del worker, mai nel repository>

Il refresh token vuole gli scope ZohoCRM.modules.READ (i record) e ZohoCRM.settings.fields.READ (il catalogo dei campi), più ZohoCRM.coql.READ se un nodo legge con COQL: tutti e tre di sola lettura. Il data center della base delle API e quello del token endpoint devono essere lo stesso: un token di accounts.zoho.eu vale solo su zohoapis.eu.

BASE_URL vuole https e deve finire con /crm/v8; http è ammesso solo verso la macchina locale, per un server di test. Gli errori nominano le variabili, mai i valori.

Source — zohoCrmRecords ​

ts
const token = fornitoreToken(loadOauthRegistry().get(config.oauthInstance));

zohoCrmRecords({
  config, // loadZohoRegistry().get("vendite")
  token, // un fornitore per run, condiviso fra i nodi
  instanceId: "vendite",
  modulo: "Deals",
  campi: [
    { nome: "Stage", tipo: "picklist" },
    { nome: "Owner", tipo: "ownerlookup" },
    { nome: "Modified_Time", tipo: "datetime" },
  ],
});
OpzioneDefaultNote
config—l'organizzazione
token—() => Promise<string>, da fornitoreToken
modulo—il nome API del modulo (Deals, omniconnectforzohocrm__Chat_Messages)
campi—i campi, con il data_type di Zoho: 48 oltre a Modified_Time con records; con coql 499, nome e cognome di ogni utente compresi, e al più 15 collegamenti a utenti
letturarecordsrecords (Records API, fino a 100.000 record) o coql (Query API, senza tetto)
name, instanceId—nome del nodo e istanza mostrata nel DAG
perPagina200records: record per pagina, fra 1 e 200
maxPerInterrogazione100000records: il tetto di Zoho per interrogazione; più basso solo nei test
perQuery2000coql: record per query, fra 1 e 2000
retry, fetchImpl—retry di ogni richiesta (default 5 tentativi, attese fino a 60 s); fetch finto nei test

I campi sono la regola di privacy ​

Il source chiede a Zoho solo i campi dichiarati, più id (e, con records, Modified_Time; con coql anche nome e cognome degli utenti, vedi sotto), ed emette solo le loro colonne: un campo che Zoho manda senza che lo si chieda non esce. Un campo nuovo in Zoho non entra finché qualcuno non lo dichiara. Per la stessa ragione lo schema di schemaRecordZoho è strict: un record con una colonna in più non passa il contratto.

A ogni run il source legge prima il catalogo dei campi del modulo (GET /settings/fields): un campo dichiarato che Zoho non conosce più è un ConfigError che lo nomina, prima della prima pagina. Un tipo cambiato si segnala nel log (zoho-source.tipo-cambiato).

Colonne e valori ​

Il nome della colonna è il nome API in minuscolo, con i caratteri non di parola in _ (Account_Name → account_name). La conversione è quella dello script che leggeva il CRM del primo cliente prima di questo connettore:

Tipo ZohoColonna
lookupil solo id del record collegato, mai il nome
userlookup, ownerlookupl'id, e il nome dell'utente Zoho in <colonna>_nome
multiselectpicklist e listele voci unite da "; "
datetimel'ora locale come la dà Zoho, senza fuso: AAAA-MM-GGTHH:MM:SS
dateAAAA-MM-GG
booleanbooleano
integerintero
double, currency, decimal, percentnumero
il resto (testo, picklist, formule)testo

Un valore vuoto è null. Un valore che non si converte nel tipo del suo campo è un ExtractError che nomina modulo, campo e id del record, mai il valore.

Le due letture ​

Entrambe leggono il modulo in ordine di id, in sequenza: un blocco si chiede solo quando chi legge ha consumato il precedente, e in memoria restano gli id visti, non i record. Un modulo vuoto (Zoho risponde 204) non dà record.

L'ordine è per id perché un id non cambia mai. La prima versione ordinava per Modified_Time, e nella prima prova su un CRM reale perdeva fra lo 0,1% e il 13% dei record per modulo: un record modificato durante la lettura passa in fondo all'ordinamento, compare due volte e fa scivolare il confine fra due pagine, così un altro record salta.

  • records (default): la Records API, GET /{modulo}?sort_by=id&sort_order=asc, pagine da 200 con page_token. Non va oltre 100.000 record per interrogazione: arrivato al tetto, il source si ferma con un ExtractError che indica la lettura coql, invece di lasciare un modulo troncato. more_records senza next_page_token, o un token già visto, è un ExtractError.
  • coql: la Query API, POST /coql con select 'id', 'Campo', … from Modulo where id > '<ultimo>' order by id asc limit 2000. Ogni query riparte dall'ultimo id del blocco prima, come raccomanda Zoho per andare oltre i 100.000 record: nessun tetto, e dieci volte meno chiamate della Records API. Fino a 499 campi per query, quindi un modulo largo non si divide in passate. I nomi dei campi e del modulo sono nomi API già validati, e l'id che entra nella query è di sole cifre: un id che non lo è, o un blocco i cui id non crescono, è un ExtractError. COQL restituisce un utente come { id, name }, ma in name mette il solo cognome (e per alcuni utenti niente), mentre la Records API dà nome e cognome. Per ogni collegamento a un utente la query chiede quindi anche '<Campo>.first_name' e '<Campo>.last_name', con una join sul modulo degli utenti, e la colonna _nome è «Nome Cognome», come con la Records API (full_name COQL non lo accetta). Senza nome né cognome resta il name di COQL; se manca anche quello la colonna resta vuota e il log lo segnala (zoho-source.nome-utente-assente). Nome e cognome contano fra i 499 campi della query, e i collegamenti a utenti sono al più 15, le join che COQL ammette. COQL non ammette i campi multi-select lookup, upload di file e immagini, e al più cinque campi formula dinamici.

Token ed errori ​

Il token si chiede al fornitore a ogni richiesta, con l'header Authorization: Zoho-oauthtoken <token>: una lettura che dura più della vita del token si rinnova da sé. Zoho risponde 200 anche a un refresh rifiutato, con { "error": … } nel corpo: il fornitore lo tratta come un rifiuto e lo dice con il codice di Zoho, come un ConfigError.

Gli errori di lettura sono ExtractError con metodo, percorso e status, più un suggerimento per 400, 401, 403 e 404. Mai il token, mai il corpo della risposta.

Nel DAG ​

Il descriptor ha kind: "zoho-source", l'istanza, poi api (la base delle API, con la sua variabile), modulo, lettura, campi, e in fondo la credenziale: quale identità OAuth2 rinnova il token e, nel piano, se le sue variabili ci sono tutte o quali mancano. Mai un segreto.

Test ​

src/connectors/zoho/*.test.ts: configurazione e registry, descriptor senza segreti, conversione e nomi di colonna, paginazione per id con page_token e il tetto che indica COQL, lettura COQL a chiavi crescenti di id (query, blocchi, id non crescenti o non numerici), campo sconosciuto prima della prima pagina, privacy (nessun campo non dichiarato esce), token chiesto a ogni richiesta, retry di un 429. Nessuna chiamata a Zoho vero: fetchImpl finto e dati sintetici.

Noeva è un marchio registrato di 4D S.R.L.