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}/messagesmessages (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}/taskstasks 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.