IA & Backend · Parcial (chatbot + portal en diseño)
Plataforma B2B + agente IA para salud ocupacional
Sistema multi-tenant con datos sensibles: aislamiento por RLS en el motor, no en la app.
- Next.js 16
- Supabase/Postgres RLS
- WhatsApp Cloud API
- Cloudflare Workers
- Wompi (SAQ A)
Encargo full-stack para una empresa de servicios de salud ocupacional en Colombia. Dos componentes, un mismo criterio: aislamiento de datos a nivel de motor, no de aplicación. El valor no es el stack — es dónde puse las fronteras de confianza.
Stack: Next.js 16 · Supabase/Postgres RLS · WhatsApp Cloud API · Cloudflare Workers · Wompi (SAQ A) · Estado: chatbot sobre motor existente (parcial) + portal web en diseño detallado (no construido) · Rol: arquitectura + desarrollo (solo)
El problema
La empresa opera dos verticales bajo una marca: una IPS de salud ocupacional (exámenes, laboratorio, telemedicina) y un centro de reconocimiento de conductores. Ambas mueven datos sensibles — formularios médicos, datos de empresas afiliadas (NIT, contactos, historial de solicitudes) — bajo la Ley 1581 (Habeas Data). La operación digital eran formularios en papel, llamadas y archivos sueltos, sin canal autenticado para las empresas afiliadas ni forma de que el ERP existente (un sistema de terceros) leyera nada de lo que entraba.
Dos necesidades, mismo dueño del problema: capturar y atender por WhatsApp sin fugar PII a terceros, y darle a las empresas afiliadas un portal donde cada una vea solo lo suyo, garantizado por algo más fuerte que un WHERE company_id = ? en el código.
Qué diseñé/construí
Lo presento como un solo engagement porque comparte la columna vertebral: Postgres con RLS por tenant_id/company_id, validación en cada frontera, y compliance Habeas Data tratado como requisito de arquitectura, no como una página legal al final.
1. Agente de IA multimodal por WhatsApp (sobre motor existente). El motor conversacional es la misma plataforma de otro proyecto (un agente en Cloudflare Workers + Durable Objects + Gemini); aquí lo relevante es el diseño del sistema para un dominio regulado y dos verticales:
- Agenda de citas Supabase-native, no Google Calendar (ver decisiones).
- Handoff a humano con el patrón nativo de inbox
pending/open: el bot solo actúa sobre conversacionespendingy se apaga solo cuando un humano toma la conversación. Sin race entre bot y agente. - Multi-tenant suave: un workflow sirve N clientes; cada tenant es una fila de config (prompt, servicios, horarios, KB) + sus filas con RLS. Cliente nuevo = config, no rebuild.
- Observabilidad de negocio sobre el edge: el Worker emite eventos JSON estructurados (
booking_created,handoff_humano,llm_callcon latencia/tokens/costo) a Grafana Cloud vía OTLP, y los KPIs salen con LogQL sobre Loki — porque Cloudflare aún no expone métricas OTLP estables, así que la decisión fue logs estructurados en vez de fingir un backend de métricas.
2. Portal web B2B (Next.js 16) — diseño detallado, no construido. Sitio público (institucional + servicios + blog + formularios) sobre el que se monta la capa que importa:
- Portal de empresas afiliadas con acceso solo por invitación (sin self-signup): las cuentas las crea personal interno; el invitado recibe un token de un solo uso (hash bcrypt, expira 72h) y queda obligado a enrolar 2FA TOTP en el primer acceso.
- RLS por
company_iden todas las tablas: cada empresa ve solo sus envíos. - Panel admin para staff no técnico (gestión de empresas, inbox de formularios, contenido vía CMS headless).
- API REST
/api/v1/*para que el ERP externo lea y empuje datos server-to-server. - Pasarela Wompi en alcance SAQ A y cumplimiento Ley 1581 incorporado desde el esquema (consent log con timestamp/IP, endpoint de derechos ARCO, retención documentada).
Decisiones de arquitectura interesantes
RLS como frontera real, no como decoración. El aislamiento entre tenants vive en políticas Row-Level Security de Postgres, evaluadas por el motor. La consecuencia que me importa: aunque la aplicación tenga un bug, la base rechaza la query que cruza tenants. La service_role key nunca llega al navegador. La tabla de audit log es append-only — ningún rol puede hacer UPDATE/DELETE sobre ella. Esto es lo que separa “multi-tenant” de “multi-tenant que de verdad aísla”, y es la razón principal por la que descarté WordPress + plugins: ahí un plugin desactualizado lee toda la base, no hay aislamiento por fila.
Agenda Supabase-native en vez de Google Calendar — decisión forzada por Habeas Data. Lo natural para un bot de citas es colgar google_calendar como tool. Lo descarté: meter nombre + motivo de consulta en Calendar es exportar PII de salud a un tercero (infra fuera del país) sin RLS ni control de consentimiento. La agenda vive en Postgres con RLS; la disponibilidad se genera en runtime (trocea las reglas semanales del profesional en slots, resta bloqueos y citas confirmadas, descarta pasado en America/Bogota) en vez de leer una lista estática, con índice único anti doble-booking y re-chequeo del slot antes de confirmar para cerrar la carrera entre oferta y reserva. El motivo de consulta (notes) es la columna más sensible: nunca sale en una notificación de email/WhatsApp — el staff lo ve entrando al panel autenticado. Google Calendar queda como espejo read-only opcional, sin PII (solo “Cita — servicio — nombre”). La fuente de verdad siempre es Postgres.
Una sola definición de “qué es válido” en las tres fronteras. El mismo esquema zod (CitaSchema) valida: los argumentos que el LLM manda al agendar, el form del panel, y el body de la API. safeParse antes de tocar la base: si el bot alucina una fecha basura o un servicio inexistente, re-pregunta, no inserta. Si cambia una regla (el teléfono pasa a obligatorio), cambia en un archivo. Frontera de confianza tratada igual venga de un humano o de un modelo.
API de ERP: defensa en capas, con el esquema correcto para cada caso. La integración server-to-server con el ERP externo va con Bearer Token (hasheado bcrypt en DB) sobre TLS 1.3 + IP allowlist obligatorio + rate limiting por token + audit log de cada request (token, endpoint, IP, timestamp, status). El orden importa: la IP fuera del rango se rechaza con 403 antes de evaluar credenciales. Elegí Bearer en vez de firma HMAC por request a propósito: el origen es controlable (rango de IPs declarado), el dato es operativo B2B, y HMAC por request mete fricción real con un equipo de terceros sin subir el nivel de seguridad de forma proporcional — es el mismo patrón de Stripe/Shopify para este caso. HMAC se reserva para el escenario donde sí aplica: webhooks entrantes del ERP, donde el origen no es predecible y hay que firmar y validar cada evento (replay protection con timestamp + nonce). Distinto problema, distinta herramienta.
Pagos en alcance SAQ A: el mejor código de pagos es el que no escribo. Widget Wompi client-side; los datos de tarjeta nunca tocan el servidor. La fuente de verdad transaccional (estados, conciliación, recibos) vive en el dashboard de Wompi, no en nuestra DB. Esto baja el alcance PCI a cero y elimina la superficie de ataque de webhooks/conciliación. Es una limitación consciente (no hay historial de pagos dentro del sitio en esta fase), no un olvido — el backend de pagos es un upgrade posterior, no parte del núcleo.
Habeas Data desde el esquema. Consentimiento granular por formulario, consent log (timestamp, IP, user-agent, versión de política), endpoint de supresión, retención con TTL. No es una página de “política de datos” pegada al final: es columnas y políticas en la base.
Arquitectura
Qué demuestra
- Sé dónde poner la frontera de confianza: aislamiento en el motor (RLS, audit append-only) en vez de confiar en que el código de la app no tenga bugs.
- Tomo decisiones de compliance que cambian la arquitectura (descartar Google Calendar por PII de salud) en vez de tratarlas como checklist.
- Trato a un LLM como una fuente de entrada no confiable más: misma validación de frontera (zod +
safeParse) que para un humano. - Elijo el esquema de seguridad proporcional al caso y sé por qué — Bearer para server-to-server controlable, HMAC para webhooks entrantes; no aplico el más pesado por reflejo.
- Reduzco superficie de ataque sacando responsabilidad del sistema (pagos SAQ A) en vez de reimplementarla.
- Diseño multi-tenant que escala por configuración, no por rebuild.
Estado y siguiente paso
Honesto sobre el estado, porque el criterio se juzga mejor sin inflar:
- Agente de IA: el motor (Worker + Durable Objects + Gemini) es reusado de otro proyecto y corre; lo específico de este encargo (agenda Supabase-native con generación de slots, tools zod, notificaciones con regla de privacidad, observabilidad Grafana) está especificado a nivel de implementación y parcialmente montado. Día 1 del cliente arrancó sobre el bot nativo de su CRM; este diseño es la graduación cuando el agendamiento multi-profesional lo justifique.
- Portal web B2B: diseño detallado y propuesta técnica, no construido. El plan de 8 semanas separa el sitio público (MVP), la capa autenticada + RLS + API del ERP, y los pagos. Todas las decisiones de seguridad de arriba están especificadas; falta ejecutarlas.
Siguiente paso: construir la capa autenticada del portal (invitación + 2FA + RLS) y cerrar el contrato de la API con el equipo del ERP antes de tocar pagos. El hardening operativo (cola resiliente con dead-letter para la sincronización al ERP, rate limiting distribuido, monitoring de errores) está mapeado como fase posterior, a activar cuando el volumen real lo pida — no antes.