Skip to content

Plan del sync service ​

Objetivo ​

Mantener la sub-cuenta de GoHighLevel Trebol Insurance (SW4v78LtTjzKHd3cnJ48) y la base imcrmdev sincronizadas en near-real-time. La primera pasada hace el catch-up completo; las siguientes traen solo deltas.

Principios ​

  1. No tocar el dev actual. El target Mongo es una nueva instancia Docker en puerto 27019. El dev actual (27018) queda intacto.
  2. Idempotencia. Toda inserción es una upsert por (company_id, external_id). El servicio puede re-correr (y se espera que lo haga) sin duplicar.
  3. Resumibilidad dentro de una pasada full. El estado del cursor por recurso se serializa en migrations/state/<recurso>.json. Una pasada interrumpida retoma desde donde quedó al re-ejecutarse.
  4. Estado por recurso. Cada recurso lleva un last_sync_at independiente. Si messages tarda y contacts ya terminó, sus timestamps quedan separados — cuando vuelve a correr messages solo procesa lo nuevo desde su propio last_sync_at.
  5. Trazabilidad total. Cada documento sincronizado conserva el ID original de GHL en external_id y el payload original en _ghl_raw. Si más adelante hay que re-procesar un campo, no se necesita volver a llamar a GHL.
  6. Tolerancia a fallas. Backoff exponencial + retries en errores 429/5xx. Errores de validación se loguean y el doc se mete en migrations/logs/dlq-<recurso>.jsonl para revisión manual.
  7. Catálogos siempre frescos. Pipelines, tags, custom field definitions, custom values y forms se re-descargan completos en cada pasada (son chicos: docenas de documentos cada uno).

Fases (válidas para full y para cada iteración del loop) ​

Fase 0 — Bootstrap del tenant (solo full o primera vez) ​

Antes de mover datos hay que crear la fila de empresa y al menos un usuario admin para que company_id y created_by tengan valor:

  1. Llamar GET /locations/{id} en GHL → crear documento en companies con companyName=Trebol Insurance, timezone, currency, phone, website, etc.
  2. Llamar GET /users?locationId=… → crear users (uno por usuario GHL). El primero del listado se usa como system_user_id para los created_by cuando no haya match.

Bootstrap es idempotente: re-correrlo no rompe nada. La diferencia con incremental es que el bootstrap se ejecuta como comando aparte (--resource bootstrap) y no entra al ciclo de --resource all por seguridad.

Fase 1 — Catálogos (siempre full refresh, ~2 min) ​

Estos son lookups. Se re-leen completos en cada pasada porque son chicos.

GHL endpointMongo collection destino
GET /locations/{id}/customFields(en memoria, ver TODO)
GET /locations/{id}/customValuescustom_values (NUEVA)
GET /locations/{id}/tagstags (NUEVA)
GET /opportunities/pipelinessales_pipelines (NUEVA) + sales_pipeline_stages (NUEVA)
GET /forms/?locationId=…forms (NUEVA)
GET /businesses/?locationId=…contacts_new con service_type=corporate (~6 docs en Trebol)

Las colecciones nuevas son necesarias porque el destino no tenía equivalente directo (su service_pipelines es para tickets de soporte, no para ventas).

Nota custom fields: Se siguen leyendo las 227 definiciones de GHL al arrancar la pasada (necesario para resolver IDs a keys legibles dentro de los contactos), pero no se escribe la colección custom_field_definitions por ahora — ver TODO.

Businesses → contacts corporate: convención del CRM destino. Por eso se ejecuta en Fase 1 (antes de Fase 2 Contactos), así el business_name_map queda cacheado y los contactos persona con businessId pueden denormalizar el nombre en custom_fields.parent_business_name durante su upsert. Solo 0.01% de los contactos de Trebol tienen businessId set (~6 casos), pero el vínculo se preserva.

Fase 2 — Contactos ​

POST /contacts/search con paginación cursor searchAfter. En modo incremental, el body lleva un filtro dateUpdated > last_sync_at y se ordena ascendente para que el cursor avance limpio.

Por cada contacto:

  1. Upsert en contacts_new con mapping (ver mapping).
  2. Resolver customFields[] → mapear cada id del custom field al fieldKey y meterlo en custom_fields del destino.
  3. Las tags[] se guardan tal cual (array de strings).
  4. dndSettings se preserva en custom_fields.dnd_settings.

Fase 3 — Oportunidades ​

GET /opportunities/search con paginación cursor. En incremental usa startAfter = since_epoch_ms con date=updatedAt para filtrar.

Mapping clave: pipelineId → buscar el pipeline en sales_pipelines ya migrado y guardar nombre legible. pipelineStageId igual con stages.

Fase 4 — Conversaciones y mensajes ​

  1. GET /conversations/search paginado → colección conversations (NUEVA).
    En incremental, la API ya devuelve sorted por lastMessageDate desc; hacemos early-stop cuando vemos uno con lastMessageDate <= last_sync_at.
  2. Por cada conversación con cambios: GET /conversations/{id}/messages → messages (NUEVA).
    Con upsert idempotente, las páginas existentes se sobrescriben (no se duplican) y solo los mensajes nuevos generan inserts reales.

En modo loop, la fase de mensajes es la más cara — pero a steady-state (después de la carga inicial) solo procesa conversaciones recientes.

Fase 5 — Notas y tasks (por contacto) ​

Solo se itera sobre contactos cuyo updated_at > last_sync_at en nuestra Mongo (asumimos que GHL sube dateUpdated del contacto cuando se le agrega/cambia una nota o task).

Por cada contacto seleccionado:

  • GET /contacts/{id}/notes → array embebido contacts_new.notes[].
  • GET /contacts/{id}/tasks → tasks collection.

Appointments (GET /contacts/{id}/appointments) está en TODO — pendiente decidir alcance.

Fase 6 — Verificación ​

Ver verificación: conteos por recurso, sampling de campos críticos, búsqueda de huérfanos.

Riesgos y mitigaciones ​

RiesgoMitigación
Duplicados por email (índice único)Skip emails vacíos; si dos contactos comparten email, se mete el segundo con email null y se loguea.
Rate limit GHL (429)Backoff exponencial, retry hasta 5 veces, throttle a 8 req/s configurable.
Campos custom sin equivalenteSe preservan tal cual en custom_fields con su nombre legible. Cero pérdida.
Pipeline GHL ≠ pipeline imcrmSe crean colecciones nuevas (sales_pipelines, sales_pipeline_stages); no se intenta forzar el modelo de tickets.
Mensajes con adjuntosURLs se preservan; no se descargan archivos.
Token rotado durante el servicioSi el PIT cambia, el servicio falla limpio y se puede continuar con el nuevo token. El cursor está preservado.
Cambios en GHL durante una pasada fullEl cursor se basa en dateAdded/dateUpdated. Cualquier cambio nuevo lo recoge la siguiente pasada incremental.

Cronograma estimado ​

Carga inicial (--mode full --resource all) ​

FaseDuración estimada
0 — Bootstrap< 1 min
1 — Catálogos~2 min
2 — Contactos~1 hora
3 — Oportunidades~1.5 horas
4 — Conversaciones + mensajes~12 horas
5 — Notas/tasks por contacto~2 horas (concurrencia 5)
6 — Verificación~10 min

Total con todo secuencial: ~17 horas. La parte utilizable (contactos+oportunidades+catálogos) está lista en ~3 horas; los mensajes terminan al día siguiente.

Steady-state (--mode loop --interval 600) ​

A los 10 min de la última pasada, una iteración de incremental procesa típicamente:

  • 0–50 contactos cambiados
  • 0–100 oportunidades cambiadas
  • 0–30 conversaciones con mensajes nuevos
  • pocos mensajes, notas, tasks

Por iteración: 30–90 segundos. Sobre 10 min de intervalo, el servicio está dormido el 90% del tiempo.