Appearance
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
- No tocar el dev actual. El target Mongo es una nueva instancia Docker en puerto
27019. El dev actual (27018) queda intacto. - 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. - Resumibilidad dentro de una pasada
full. El estado del cursor por recurso se serializa enmigrations/state/<recurso>.json. Una pasada interrumpida retoma desde donde quedó al re-ejecutarse. - Estado por recurso. Cada recurso lleva un
last_sync_atindependiente. Simessagestarda ycontactsya terminó, sus timestamps quedan separados — cuando vuelve a corrermessagessolo procesa lo nuevo desde su propiolast_sync_at. - Trazabilidad total. Cada documento sincronizado conserva el ID original de GHL en
external_idy el payload original en_ghl_raw. Si más adelante hay que re-procesar un campo, no se necesita volver a llamar a GHL. - 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>.jsonlpara revisión manual. - 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:
- Llamar
GET /locations/{id}en GHL → crear documento encompaniesconcompanyName=Trebol Insurance,timezone,currency,phone,website, etc. - Llamar
GET /users?locationId=…→ crearusers(uno por usuario GHL). El primero del listado se usa comosystem_user_idpara loscreated_bycuando 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 allpor seguridad.
Fase 1 — Catálogos (siempre full refresh, ~2 min)
Estos son lookups. Se re-leen completos en cada pasada porque son chicos.
| GHL endpoint | Mongo collection destino |
|---|---|
GET /locations/{id}/customFields | (en memoria, ver TODO) |
GET /locations/{id}/customValues | custom_values (NUEVA) |
GET /locations/{id}/tags | tags (NUEVA) |
GET /opportunities/pipelines | sales_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_pipelineses 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_definitionspor 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_mapqueda cacheado y los contactos persona conbusinessIdpueden denormalizar el nombre encustom_fields.parent_business_namedurante su upsert. Solo 0.01% de los contactos de Trebol tienenbusinessIdset (~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:
- Upsert en
contacts_newcon mapping (ver mapping). - Resolver
customFields[]→ mapear cadaiddel custom field alfieldKeyy meterlo encustom_fieldsdel destino. - Las
tags[]se guardan tal cual (array de strings). dndSettingsse preserva encustom_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
GET /conversations/searchpaginado → colecciónconversations(NUEVA).
En incremental, la API ya devuelve sorted porlastMessageDate desc; hacemos early-stop cuando vemos uno conlastMessageDate <= last_sync_at.- 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 embebidocontacts_new.notes[].GET /contacts/{id}/tasks→taskscollection.
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
| Riesgo | Mitigació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 equivalente | Se preservan tal cual en custom_fields con su nombre legible. Cero pérdida. |
| Pipeline GHL ≠ pipeline imcrm | Se crean colecciones nuevas (sales_pipelines, sales_pipeline_stages); no se intenta forzar el modelo de tickets. |
| Mensajes con adjuntos | URLs se preservan; no se descargan archivos. |
| Token rotado durante el servicio | Si 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 full | El cursor se basa en dateAdded/dateUpdated. Cualquier cambio nuevo lo recoge la siguiente pasada incremental. |
Cronograma estimado
Carga inicial (--mode full --resource all)
| Fase | Duració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.