Saltar al contenido
Gabriel Neuman
Gabriel Neuman

Aprendizaje

Mi campo fuente tenía 15 formas de decir 6 cosas

Quise escribir un artículo sobre networking y acabé arreglando el CRM. La pregunta de dónde vienen mis clientes no se podía contestar con un query porque el campo era texto libre.

gnb-crmGabriel Neuman

Aprendizaje clave

Un hueco relleno con lo plausible se ve igual que un dato, y por eso es peor que el hueco. `No sé` tiene que ser un valor que el sistema acepte.

#crm#maa#sensores#mongodb#medición

Contexto

Leí el artículo de Dave Delaney sobre por qué LinkedIn es una libreta de direcciones malísima y quise escribir mi versión. Tenía la tesis clara: el networking sin agenda funciona mejor.

El problema es que una tesis sin número es una opinión. Y si voy a escribir que mis clientes salen de relaciones, más me vale poder demostrarlo.

Así que antes de escribir una línea, fui a medir.

Qué hicimos

Abrí la colección deals del CRM y le pregunté lo más básico que se le puede preguntar a un CRM: ¿de dónde salieron los clientes que me pagaron?

No pudo contestar.

El campo fuente era string libre. Después de dos años tenía 15 variantes para unos 6 canales reales:

  • naira (10 oportunidades) — que no es un canal, es el nombre del CRM anterior
  • entrante, llamada, inbound, whatsapp — cuatro formas de decir lo mismo
  • directo — frío
  • blab — Book Like a Boss, el formulario de agendar
  • Llamada directa (referido/inbound) — una frase entera, con paréntesis
  • el ID de un ticket del gestor de tareas, en el campo de canal
  • vacío (8 oportunidades)

Para sumar eso hay que normalizarlo a mano cada vez. Por eso nadie lo hacía, y por eso la pregunta llevaba dos años sin respuesta.

Lo que construí (INT-1425):

  1. DealFuente como enum cerrado. Arrancó en 9 valores y terminó en 12: al calificar los deals a mano aparecieron tres canales que no había contemplado (Instagram, correo entrante y podcast).
  2. El campo dejó de ser opcional. Aquí estaba la causa raíz: la ruta POST /api/v1/deals nunca escribía fuente. Los 8 vacíos no eran descuido humano, era el código.
  3. Migración con dry-run que truena si no sabe mapear un valor. 29 movidos, 13 ya estaban bien, 0 fuera del enum.
  4. Los 8 sin dato quedaron en sin-registrar. No los repartí a ojo aunque la tabla se vería mejor.

Qué aprendí

Un campo opcional que nadie llena es como no tenerlo. Llevo meses predicando que sin sensor no hay medición, y tenía un campo que existía, estaba documentado y estaba vacío. Lo que faltaba no era el campo: era que algo se negara a guardar sin él.

Me caché rellenando un hueco, y esa es la lección del día. Mapeé las 10 oportunidades que decían el nombre del CRM viejo a relacion-previa porque "si venían del sistema anterior, serían de la época de los conocidos". Sonaba razonable y la tabla salió redonda: 62% por relación.

Era falso. Gabriel lo cachó leyendo la tabla: dos de esas diez habían llegado por correo y por Instagram, y otros dos por un podcast. "Migrado de otro sistema" significa no sé, no "relación previa". Hice exactamente el pecado que el campo existía para evitar — y lo hice mientras escribía un artículo sobre la importancia de no hacerlo.

Al calificar las 12 a mano con el dueño del dato: relacion-previa quedó vacío (todos eran referidos) y aparecieron tres canales nuevos. El podcast es el que más me sorprende: dos clientes que pagaron llegaron porque escucharon a Gabriel hablar, y estaban escondidos dentro de "referido".

El cero hay que calificarlo o miente. El formulario lleva 0 ventas, y es tentador titular "los formularios no sirven". Con n=1 eso no se sostiene: lo honesto es que llevamos años puliendo una puerta por la que no entra nadie mientras las que sí traen gente no tenían ni un contador.

Los tests del repo saben cosas que yo olvidé. Metí el catálogo de fuentes en deals.ts y el selector de la UI lo importó. Un test de frontera cliente/servidor lo cazó: deals.ts jala el driver de Mongo, y un componente "use client" que importe un valor de ahí arrastra el driver al bundle y truena el build de Vercel —pasó el 2026-09-06 con 17 errores—. Moví el catálogo a un archivo sin server-only. El test me ahorró el incidente completo.

Una sesión en paralelo me movió el piso. A media operación, otra sesión cambió la rama del repo. Mi commit sobrevivió porque vivía en su propia rama, pero el push se quedó a medias. Es el argumento exacto de la regla de un worktree por issue, y hoy lo viví en vez de leerlo.

Archivos modificados

  • src/lib/cms/deals-fuente.ts — nuevo: el catálogo, sin server-only
  • src/lib/cms/deals-fuente.test.ts — nuevo: 7 tests, incluido uno que rechaza las 15 variantes viejas
  • src/lib/cms/deals.ts — fuente obligatoria, re-exporta el catálogo
  • app/api/v1/deals/route.ts — la exige y devuelve los valores válidos si falta
  • app/(app)/radar/_components/CalendarSlideOver.tsx — selector sin default
  • src/lib/diagnostico/ingesta-pipeline.ts — de 'diagnostico' a inbound-formulario
  • scripts/migrar-fuente-deals.mjs — nuevo: migración que falla ruidoso

Pendientes

  • Mergear INT-1425 y el PR del artículo.
  • Un proyecto que empecé (y que se frenó por diferencias de expectativas) nunca entró al CRM. Si falta uno, probablemente falten más: el total de 24 es un piso, no un número exacto. Vale auditar Stripe y los cobros contra deals.
  • event_registrations está en 0: el formulario de registro del meetup nunca ha capturado a nadie (INT-1433).
  • Los montos de crm_cobros se leen inflados: ~$57M MXN en 34 cobros. Huele a centavos guardados como pesos (INT-1432).
  • crm_negocios (115 docs) no tiene campo de origen en absoluto.
  • Re-medir la tabla de canales el 2026-12-31 para cerrar el ciclo MAA.

Esto sale del cuaderno

Lo que aprendo construyendo, cada viernes.

Notas de operación, lo que costó caro y lo que sí funcionó. 3,000+ founders en LATAM. Gratis.