Los mensajes que llegan por el formulario de contacto se guardan en Neon y los leo en /admin/, un panel privado: solo entran los correos de ADMIN_EMAILS, con un código de un solo uso. Hasta hace poco era una lista con un estado por contacto. Servía para leer, no para hacer seguimiento.
Ahora el panel lleva el seguimiento de cada contacto (DEC-031): estados, historial, notas, filtros, exportación y respuestas en el idioma del contacto. Te cuento qué cuidé en cada parte.
Seis estados, con los identificadores de siempre
El recorrido de un contacto es Nuevo, Contactado, Propuesta, Ganado, Perdido y Descartado (spam o no encaja). En la base y en la API son identificadores en inglés:
export const LEAD_STATUSES = ['new', 'contacted', 'proposal', 'won', 'lost', 'spam'] as const
Cinco ya existían; solo agregué proposal. Renombrarlos para que dijeran lo mismo que el panel habría roto la versión publicada mientras corre la migración, porque esa versión seguiría escribiendo los nombres viejos. Agregar un valor no rompe nada.
Por eso la migración 0007 es solo aditiva: un valor nuevo del enum (ADD VALUE 'proposal' BEFORE 'won'), dos tablas y un índice. La versión anterior funciona contra la base migrada, así que primero migro y después publico.
En pantalla, cada estado lleva color, ícono y texto, para que el color nunca sea la única señal.
El historial se escribe en la misma sentencia que el cambio
Quería saber quién movió un contacto, de qué estado a cuál y cuándo. El registro de accesos se escribe aparte y puede fallar sin dejar rastro. Por eso el historial tiene su propia tabla, lead_status_changes.
El reto es la atomicidad. La API habla con Neon por HTTP (te conté por qué) y ese driver no tiene transacciones interactivas. La salida fue no necesitarlas: el cambio y su historial van en una sola sentencia con CTE. La primera lee la fila con for update, la segunda cambia el estado y devuelve el anterior, y la tercera inserta el historial con lo que devolvió la segunda:
const recorded = db.$with('recorded').as(
db.insert(leadStatusChanges).select(
db
.select({
id: sql`gen_random_uuid()`.as('id'),
leadId: updated.id,
at: sql`${at.toISOString()}::timestamptz`.as('at'),
actor: sql`${actor}::text`.as('actor'),
fromStatus: updated.previous,
toStatus: updated.status,
})
.from(updated)
.where(ne(updated.previous, updated.status)),
),
)
El where final hace que volver a elegir el mismo estado no escriba historial, y el candado de fila deja exacto el estado anterior si dos cambios llegan a la vez. Una prueba agrega a la tabla del historial un check que ninguna fila cumple y exige que el estado del contacto quede como estaba: si falla una parte, no se guarda nada.
Notas que no salen del panel
Cada contacto tiene notas internas en lead_notes, con autor y fecha, de hasta 1000 caracteres. El límite está en el contrato (el maxlength del campo) y también en un check de la base.
Las notas no van en la lista de contactos, ni en el CSV, ni en el aviso por correo, ni en el registro de accesos. Ahí queda que agregué una nota y su id, pero no lo que dice. Una prueba guarda una nota y exige que su texto no aparezca en la respuesta de la lista.
Guardarla también es una sola sentencia, un insert … select que toma el id del propio contacto: si no existe, no hay nota y la API responde 404.
Filtros en la URL y una búsqueda sin tildes
Filtro por estado, servicio, idioma, rango de fechas y texto, y ordeno por los más nuevos o los más viejos. Los filtros viven en la URL (/admin/?status=won&from=2026-11-01), así que una vista se guarda como marcador y el botón Atrás deshace el último filtro. Si un marcador trae un valor que el panel ya no conoce, se descarta ese filtro y se conservan los demás. Las fechas son días completos en la zona horaria del navegador, que viaja con cada petición.
La búsqueda mira nombre, correo, teléfono y mensaje, sin tildes ni mayúsculas. Descarté unaccent y pg_trgm: no vienen iguales en PGlite, donde corren las pruebas, ni en el Postgres local. En su lugar, la misma tabla de letras en SQL y en JavaScript:
const FOLD_FROM = 'ÁÀÄÂÃÉÈËÊÍÌÏÎÓÒÖÔÕÚÙÜÛÑÇáàäâãéèëêíìïîóòöôõúùüûñç'
const FOLD_TO = 'AAAAAEEEEIIIIOOOOOUUUUNCaaaaaeeeeiiiiooooouuuunc'
Así «gomez» encuentra «Gómez».
La paginación es por cursor (fecha e id), no con OFFSET: el costo de una página no crece con el número de la página. Cada página trae el total de la vista filtrada, contado en paralelo. Arriba, un resumen muestra los contactos por estado y los de los últimos 7 y 30 días, en una sola consulta con count(*) filter (where …).
Un CSV para Excel que no ejecuta fórmulas
Exportar la vista filtrada tiene tres trampas:
- Tildes: sin BOM, Excel lee «Pérez» como «Pérez». El archivo va en UTF-8 con BOM.
- Separador: Excel separa las columnas con el separador de listas del sistema, que en es-CO es
;porque la coma es el decimal. Google Sheets y LibreOffice lo detectan solos. - Inyección de fórmulas: una hoja de cálculo ejecuta como fórmula una celda que empieza con
=,+,-o@, y cualquiera puede escribir eso en un formulario público.
export function neutralizeFormula(value: string): string {
return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value
}
El apóstrofo la deja como texto. De paso, el teléfono sale como +57…, la regla lo vuelve '+57… y Excel no lo muestra como 5,73E+11.
Una exportación tiene como máximo 5000 filas; con más, la API pide acotar los filtros. El archivo se arma en el servidor y no en el navegador, porque la exportación debe quedar registrada. Y como en él salen los datos personales de todos los contactos de la vista, falla cerrada: si no se puede anotar en el registro de accesos, no hay archivo.
} catch (err) {
log.error('export audit failed', err)
c.header('Retry-After', '60')
Ese registro guarda quién exportó, cuántas filas y con qué filtros, pero no el texto de la búsqueda, que puede ser el nombre o el correo de alguien.
Responder en el idioma del contacto
Cada contacto guarda el idioma de la página desde la que escribió. La ficha tiene «Responder por correo» y, si dejó teléfono, «Responder por WhatsApp»: enlaces con el mensaje ya escrito en ese idioma, que puedo cambiar antes de enviarlo.
Los textos viven en admin-replies.json y en su par de en/, y se validan al compilar. Entre llaves solo caben {firstName}, {name} y {service}; una llave desconocida o sin cerrar rompe el build. Una prueba falla si llevan cifras o monedas, porque la primera respuesta no debe prometer precios ni fechas que el sitio no dice.
Y pesa menos
El panel con sesión se carga aparte del login, así que la primera carga de /admin/ bajó de 23,8 a 19,1 KB gzip.
Para revisarlo tú
- La decisión y lo que descarté:
docs/DECISIONES.md(DEC-031). - Estados, filtros, historial y resumen:
repository.ts; las notas, enactivity.ts. - El CSV y sus pruebas:
csv.tsycsv.test.ts. - Las rutas del panel:
admin-leads.ts, con sus pruebas enadmin-crm.test.ts. - El contrato:
packages/contracts/src/crm.ts. - La migración y cómo publicarla:
0007_lead_crm.sqlydocs/DESPLIEGUE.md. - Cómo editar las respuestas:
docs/CONTENIDO.md, sección «Panel: respuestas a los contactos».
El panel es privado, pero en cómo está hecho este sitio ves dónde encaja con el resto. Y si quieres algo así para tu negocio, escríbeme.