El API ya tiene un sistema de notificaciones completo: el modelo Notification,
veintinueve tipos en NotificationTypeEnum y un atajo notifyAdmins()
llamado desde dieciocho sitios distintos. Funciona. El problema es dónde termina: en una
fila de Postgres que solo existe si alguien entra al panel y mira la campanita.
Una cotización que vence a las seis de la tarde, un pago rechazado tres veces seguidas o un cliente pidiendo hablar con una persona no pueden depender de que alguien recuerde revisar. El equipo ya vive en un chat. Ahí es donde tiene que llegar el aviso, y ahí mismo debería poder resolverse.
No reemplaza las notificaciones del panel ni los correos de Postmark ni las plantillas de WhatsApp para clientes. Es un canal más, y es solo para el equipo interno. Tampoco replica los veintinueve tipos: la mayoría no merece salir. Solo sale lo que tiene una regla escrita a propósito.
La regla que sostiene todo lo demás: las dependencias apuntan hacia adentro. El código de negocio no conoce el conector, publica un evento. El núcleo no conoce Slack ni Lark, habla con un puerto. Solo los adaptadores, en la orilla, conocen una plataforma concreta.
El puerto. Seis métodos estables — es todo lo que una plataforma nueva tiene que cumplir.
La capa 4 es el camino de vuelta: cuando alguien aprieta un botón, cada adaptador verifica la firma de su plataforma, normaliza la interacción al mismo sobre y se la entrega al despachador. De ahí en adelante el núcleo no distingue de dónde vino.
Esto es lo que ve el equipo. El mismo evento del catálogo, el mismo Card IR por detrás, y dos resultados que se sienten nativos en su casa: en Slack un attachment con barra de color, en Lark una tarjeta con cabecera sólida. Nadie escribe el mensaje dos veces.
Slack acepta el hex exacto de marca en la barra del attachment; Lark no — su cabecera
solo admite colores con nombre de su propia paleta. Por eso la severidad viaja por el núcleo como
palabra (info, accion, critico, exito) y
cada adaptador la baja a lo que su plataforma sabe pintar.
El texto de un botón de Slack admite 75 caracteres pero trunca visualmente cerca de 30. Lark no tiene ese corte. El Card IR valida a 30 caracteres: la plataforma más restrictiva manda sobre el contrato, no al revés.
Una notificación llega a un canal que tiene otras diez cosas encima, se discute, alguien aprieta un botón y al día siguiente alguien más quiere el resumen sin revolver el historial. Eso es lo que hay que diseñar, no el rectángulo aislado. Este es el cliente completo, a escala.
Cuando la acción viene de una tarjeta con discusión abierta, el conector responde en el hilo,
no en el canal: el canal queda limpio y el contexto no se pierde. Es una opción de la regla de ruteo,
responderEnHilo: true, no un comportamiento fijo.
Un conector serio no es solo mensajes al canal. «Pedir cambios» necesita que alguien escriba
por qué; la pestaña Inicio convierte la app en un panel personal dentro de Slack; y
/mogos permite consultar sin publicarle nada a nadie.
Trampa real: el trigger_id del clic caduca a los 3 segundos. Si el despachador
consulta la base de datos antes de abrir el modal, el modal no abre. La autorización se resuelve
con identidad ya cacheada; el chequeo profundo va en el view_submission.
Se republica sola cada vez que cambian tus pendientes. Es lo que hace que la app se sienta un producto y no un webhook.
Lark no tiene canales con almohadilla: tiene grupos con avatar, la lista lateral muestra el último mensaje y las tarjetas van dentro del flujo del chat. Y trae algo que Slack no tiene: un toast de confirmación instantánea, y campos de formulario dentro de la propia tarjeta.
El toast oscuro es la confirmación que Lark devuelve en el mismo cuerpo del callback, antes de que la tarjeta termine de actualizarse. Slack no tiene equivalente: allá la señal de que el clic entró es que los botones se apagan.
Los campos se declaran una vez
La acción define sus campos en el catálogo: motivo (selección), detalle (texto) y vence (fecha). El adaptador de Slack los monta en un modal; el de Lark los inserta en la tarjeta. Nadie escribe el formulario dos veces.
El formulario en tarjeta de Lark es visible para todo el grupo mientras se llena. Para
acciones con datos sensibles — motivos de rechazo, montos internos — la acción se marca
privado: true y el adaptador de Lark abre un chat con el bot en vez de escribir en
el grupo. Es una regla del núcleo, no un criterio del adaptador.
Un evento no es una cadena de texto suelta. Es una entrada del catálogo con su payload validado por Zod, su severidad y la lista blanca de acciones que puede ofrecer. Si un servicio intenta publicar algo que no está aquí, no compila.
notifier/catalog/service-quotes.events.tsexport const SERVICE_QUOTE_EVENTS = defineEvents({ 'service_quote.pending_approval': { severidad: 'accion', titulo: 'Cotización esperando aprobación', payload: z.object({ quoteId: z.string().uuid(), code: z.string(), // SQ-2481 client: z.string(), amountUsd: z.number().positive(), marginPct: z.number(), expiresAt: z.date(), builtBy: z.string(), }), // lista blanca: solo estas acciones pueden llegar a ser un botón acciones: ['quote.approve', 'quote.request_changes', 'quote.reject'], }, })
Publicar es una línea dentro del servicio que ya existe. El tipo del payload se infiere del catálogo:
// service-quotes.service.ts · dentro de la transacción que ya corre await this.notifier.publish('service_quote.pending_approval', { quoteId: quote.id, code: quote.code, client: client.name, amountUsd: quote.total, marginPct: quote.margin, expiresAt: quote.expiresAt, builtBy: user.fullName, })
publish() no manda nada. Escribe una fila en el outbox dentro de la misma
transacción de Prisma. Si la cotización se revierte, la notificación nunca existió. Con webhooks
hechos a mano siempre termina pasando lo contrario: el aviso sale y la operación se cae después.
Qué evento sale, a qué destino, bajo qué condición y con qué mención. Todo en TypeScript, revisable en un PR y con historia en git. Ningún evento sale por defecto: si no tiene regla, se queda en el panel y ya.
notifier/routing/routes.tsexport const ROUTES: Route[] = [ // operación diaria · informativo, sin ruido { on: 'freight.arrived_port', to: [canal('operaciones')] }, // solo las cotizaciones que de verdad requieren a un manager { on: 'service_quote.pending_approval', to: [canal('cotizaciones')], cuando: (p) => p.amountUsd >= 5_000 || p.marginPct < 20, mencion: 'aqui', agrupar: (p) => `quote:${p.quoteId}`, // una sola tarjeta por cotización responderEnHilo: true, }, // el mismo evento, dos destinos, y silencio por cliente { on: 'payment.verification_failed', to: [canal('pagos'), canal('guardia')], cuando: (p) => p.attempts >= 3, mencion: 'canal', silencio: { ventanaMin: 60, por: (p) => p.clientId }, }, ]
El nombre lógico en código, el identificador afuera
Las rutas nombran canal('pagos'). El identificador real de cada canal —y sobre todo el token del bot— se resuelve en arranque contra el proveedor de secretos y se cachea en memoria. Cambiar de canal no es un despliegue.
Slack y Lark no comparten destino
El mismo nombre lógico resuelve a slack/pagos y lark/pagos. Una ruta puede publicar en las dos a la vez durante una migración, y apagarse en una sin tocar la otra.
Aquí el sistema es agnóstico o no lo es. El renderer convierte un evento en bloques semánticos — ni Block Kit, ni tarjeta de Lark. Seis tipos de bloque cubrieron las cuatro tarjetas de este documento, y cubrirían las siguientes veinte.
notifier/card/card.types.tsexport type Card = { severidad: 'info' | 'accion' | 'critico' | 'exito' titulo: string mencion?: 'aqui' | 'canal' | { usuarios: string[] } bloques: Bloque[] } export type Bloque = | { t: 'texto'; md: string } | { t: 'campos'; items: { k: string; v: string }[] } | { t: 'linea' } | { t: 'pie'; items: string[] } | { t: 'enlace'; texto: string; url: string } | { t: 'acciones'; items: Accion[] } export type Accion = | { tipo: 'enlace'; texto: string; url: string } | { tipo: 'ejecuta' clave: AccionKey // del registro de acciones texto: string // ≤ 30 · Slack trunca ahí estilo?: 'primario' | 'peligro' confirmar?: string campos?: CampoFormulario[] } // modal en Slack, en tarjeta en Lark
| Bloque neutro | Slack | Lark |
|---|---|---|
| severidad | color hex exacto en el attachment | plantilla con nombre: turquoise · orange · red · green |
| titulo | bloque header | cabecera de la tarjeta |
| texto | section con mrkdwn | elemento markdown |
| campos | section fields a dos columnas | column_set de dos columnas |
| linea | divider | hr |
| pie | context | note |
| acciones | actions · hasta 25 elementos por bloque | módulo action |
Esto es todo lo que hay que implementar para enchufar Teams, Discord o lo que aparezca. Un archivo, sin tocar el núcleo.
notifier/port/messaging-connector.interface.tsexport interface MessagingConnector { readonly plataforma: Plataforma // ── ida ────────────────────────────────────────────────── resolverDestino(ref: DestinoRef): Promise<string> enviar(destino: string, card: Card): Promise<EnvioRef> actualizar(ref: EnvioRef, card: Card): Promise<void> // ── vuelta ─────────────────────────────────────────────── verificarFirma(req: PeticionCruda): boolean parsearAccion(req: PeticionCruda): SobreDeAccion acuse(sobre: SobreDeAccion, card?: Card): RespuestaHttp // ── capacidades opcionales · el núcleo pregunta antes ──── enviarEfimero?(destino: string, usuario: string, card: Card): Promise<void> abrirFormulario?(sobre: SobreDeAccion, campos: CampoFormulario[]): Promise<void> publicarPanel?(usuario: string, card: Card): Promise<void> }
SobreDeAccion es el mismo objeto venga de donde venga: { claveAccion, payload, usuarioPlataforma, envioRef, plataforma }. A partir de ese punto el núcleo no distingue Slack de Lark.
Nunca a ciegas. El núcleo pregunta if (conector.enviarEfimero) y, si no existe, cae a
la alternativa declarada en el catálogo — en Lark, un mensaje directo del bot. Así una plataforma
pobre degrada en vez de romperse. Es el mismo patrón que ya usa el módulo
messaging del repo con sendListMessage en Instagram.
Slack y Lark coinciden en algo que define la arquitectura: hay 3 segundos para responder o el usuario ve un error en su pantalla. Aprobar una cotización toca base de datos, dispara correos y quizá un WhatsApp — eso no cabe. Por eso el despachador es ack-first: acusa recibo de inmediato y hace el trabajo después.
Slack: HMAC-SHA256 sobre v0:timestamp:body con el signing secret. Lark: verificación de token y descifrado AES. Si falla, 401 y no se lee nada más del cuerpo.
El payload crudo se convierte en SobreDeAccion. De aquí en adelante nadie sabe de qué plataforma vino.
El identificador de Slack o Lark se traduce a un usuario de Mogos vía ConnectorIdentity. Si no está vinculado, se responde con el aviso de vinculación y se guarda la acción pendiente.
Se comprueba el rol del usuario de Mogos, con datos cacheados. Estar en el canal no es permiso para aprobar nada.
Responde 200 con la tarjeta en «procesando» y los botones ya retirados. En Lark, además, el toast. Todo lo anterior cabe holgadamente en menos de 300 ms.
Corre el handler real contra el servicio de negocio y llama actualizar() con la tarjeta final: verde si salió, roja con el motivo si falló.
Retirar los botones en el acuse, antes de ejecutar, es lo que resuelve la carrera de dos managers aprobando la misma cotización al mismo tiempo: el segundo clic llega a una tarjeta que ya no tiene botones. Sin eso, un conector interactivo es una fuente de datos corruptos.
Alguien aprieta «Aprobar» y el conector no sabe quién es dentro de Mogos. Esto le pasa a todo el equipo el primer día, así que hay que diseñarlo, no improvisarlo.
Detalle pequeño, dignidad grande: nadie más ve que no estabas vinculado.
Se guarda y se ejecuta al volver
El intento se guarda como acción pendiente con su clave de idempotencia. El botón lleva a un enlace de un solo uso que caduca en 10 minutos; al autenticarse en Mogos se escribe la fila de ConnectorIdentity y se ejecuta la acción original. El usuario aprieta un botón una sola vez, aunque el sistema le haya tenido que pedir permiso en el medio.
Identidad y permiso son cosas distintas
Vincularse solo dice quién eres. Si tu usuario de Mogos es OPERADOR, los botones de aprobar te van a seguir diciendo que no. La membresía del canal y el rol nunca se mezclan.
Un botón en un chat es una superficie de ejecución expuesta a internet. Se trata como tal.
| Riesgo | Cómo se cierra |
|---|---|
| Petición falsificada | Verificación de firma por adaptador antes de leer nada del cuerpo. Slack: HMAC-SHA256 con el signing secret. Lark: token y AES. Sin firma válida no se parsea siquiera. |
| Repetición | Se rechaza cualquier petición con marca de tiempo de más de 5 minutos y se lleva registro de los identificadores de interacción ya vistos. |
| Escalada de privilegios | Tabla ConnectorIdentity. Sin vínculo no hay acción, y el rol que manda es el de Mogos. Pertenecer al canal no autoriza nada. |
| Fuga de datos | El renderer nunca vuelca el payload completo: solo los campos que el evento declara. Montos y nombres de cliente sí; documentos, cédulas y tokens nunca. |
| Doble ejecución | Botones retirados en el acuse más clave de idempotencia por par (envío, acción). El segundo clic no encuentra nada que apretar. |
| Trazabilidad | Cada acción ejecutada desde el chat queda en la bitácora con el usuario real, la plataforma y la marca de tiempo. Auditable igual que si se hubiera hecho en el panel. |
El repo no tiene BullMQ, ni Redis, ni bus de eventos — son llamadas directas a servicios,
Prisma y @nestjs/schedule. Esta propuesta no los introduce: una tabla y el cron que ya
está instalado alcanzan de sobra para el volumen de un equipo interno.
model ConnectorDelivery { id String @id @default(uuid()) @db.Uuid eventKey String dedupeKey String @unique plataforma ConnectorPlatformEnum destino String card Json estado DeliveryStatusEnum @default(PENDIENTE) intentos Int @default(0) proximoIntento DateTime? refExterna String? // ts de Slack · message_id de Lark ultimoError String? creadoEn DateTime @default(now()) enviadoEn DateTime? @@index([estado, proximoIntento]) @@map("connector_deliveries") }
dedupeKey único
Se compone de evento : entidad : plataforma : destino. Un reintento del cron jamás duplica la tarjeta, y dos productores que publiquen lo mismo colapsan en una sola fila.
Backoff exponencial
Un cron cada 30 segundos toma lo pendiente. Reintentos a 1, 4, 16 y 60 minutos. Al quinto fallo pasa a FALLIDO y avisa en el canal de guardia — el conector reporta sus propias caídas.
refExterna guardada
Es lo que permite reescribir una tarjeta días después: cambió el estado del flete y la tarjeta original se actualiza sola, sin publicar una nueva.
El umbral, dicho de una vez
Si el volumen pasara de unos pocos miles de entregas al día o hiciera falta paralelismo real entre instancias, tocaría mover el outbox a una cola. Hoy no es el caso, y meterlo antes sería infraestructura sin problema que resolver.
La prueba de que la abstracción sirve. Esto es todo lo que costaría meter Teams mañana:
Ni en el catálogo de eventos, ni en las reglas de ruteo, ni en el renderer, ni en el outbox, ni en el despachador de acciones, ni en un solo servicio de negocio. Si meter una plataforma obliga a tocar el núcleo, el diseño falló — y se arregla ahí, no en el adaptador.
Esta tabla es el contrato del puerto: lo que está en las dos es obligatorio; lo que está en una sola es capacidad opcional.
| Capacidad | Slack | Lark | En el puerto |
|---|---|---|---|
| Tarjeta con bloques | Block Kit | Interactive card | obligatorio |
| Botones de acción | actions · hasta 25 | módulo action | obligatorio |
| Actualizar en sitio | chat.update | respuesta del callback | obligatorio |
| Hilos | nativos | respuestas en hilo | obligatorio |
| Color exacto de marca | hex en el attachment | solo paleta con nombre | severidad como palabra |
| Mensaje efímero | chat.postEphemeral | se resuelve con chat privado | opcional |
| Formulario | modal · views.open | campos en la tarjeta | declarativo |
| Panel personal | pestaña Inicio | sin equivalente directo | opcional |
| Confirmación previa | objeto confirm | segunda tarjeta | opcional |
| Aviso instantáneo | se apagan los botones | toast en el callback | opcional |
| Ventana de respuesta | 3 segundos | 3 segundos | ack-first |
Cada fase deja algo funcionando en producción. Ninguna depende de que la siguiente exista, así que el proyecto se puede detener en cualquier corte sin dejar nada a medias.
Núcleo y Slack, solo salida
- Catálogo, ruteo y Card IR
- Outbox y cron de reintentos
- Adaptador de Slack: enviar y actualizar
- Tres eventos reales de punta a punta
Lark
- Adaptador de Lark contra el mismo puerto
- Sin tocar el núcleo — es la prueba de fuego
- Se corrige lo que la abstracción no previó
Acciones interactivas
- Endpoints entrantes y verificación de firma
- ConnectorIdentity y vinculación de cuentas
- Despachador ack-first y registro de acciones
- Aprobar y rechazar desde el chat
Cobertura
- Se migran los eventos que valgan la pena
- Modal, pestaña Inicio y comandos
- Resúmenes diarios y silenciamientos
Tres cosas quedaron sin respuesta explícita durante el diseño. Están resueltas con el criterio más conservador para no bloquear la escritura del plan, pero cualquiera de las tres se puede cambiar sin rehacer nada. Si una de estas no te cuadra, dilo antes de empezar la fase 1.
| Decisión | Se asumió | Por qué, y qué costaría cambiarla |
|---|---|---|
| Dónde vive el núcleo | apps/api/src/notifier | Sigue el patrón del repo, usa Prisma directo y arranca sin preparar nada. Hoy el API es el único que necesita publicar. Si más adelante agent, delivery o warehouse tuvieran que publicar sin pasar por el API, el catálogo y el renderer se mueven a packages/ sin tocar adaptadores. |
| Primeros eventos | cotización esperando aprobación · pago rechazado · cliente pidiendo agente | Los tres tienen dueño claro y cuestan dinero si nadie los ve a tiempo, que es exactamente lo que justifica sacarlos del panel. Cambiar la lista es editar el catálogo y las rutas: horas, no días. |
| Dónde viven los secretos | variables de entorno, con el resolvedor abstraído | El repo hoy no usa Supabase Vault en ningún lado; los secretos están en Render. Meter Vault ahora sería infraestructura nueva en la fase 1. Como todo pasa por un resolvedor de destinos, migrar a Vault después toca un solo archivo. |
Un núcleo que no sabe de Slack, y una orilla que sí.
Todo lo caro de este sistema —el catálogo, el ruteo, el render, el outbox, la identidad, la autorización— se escribe una sola vez y no vuelve a tocarse. Lo que cambia por plataforma es un archivo de seis métodos. Esa es la única razón por la que meter Lark después de Slack, o Teams después de Lark, cuesta un día y no un trimestre.
Bajo y aditivo. Nada de lo que existe cambia de comportamiento. Si el conector se cae, el panel sigue igual que hoy.
Ninguna nueva. Una tabla, un cron y dos endpoints. Sin Redis, sin colas, sin servicios extra.
Fase 1. Tres eventos llegando a Slack, con reintentos y sin duplicados.
Confirmar las tres decisiones de la sección 17 antes de arrancar.