Serie aurora · producto
Un flotante vestido con la marca donde el cliente escribe una sugerencia como quien manda un mensaje. En el fondo se crea la sugerencia y llega a un canal de Slack de crescō con el detalle y un botón a su WhatsApp. Y del lado del cliente, lo que de verdad importa: una línea que dice «Vista por el equipo».
Mogos ya tiene sugerencias: una ruta, un formulario con calificación y comentario, una tabla, un módulo de API y hasta una pantalla de admin para leerlas. Lo que no tiene es el circuito. Tres hallazgos leídos en el código antes de dibujar nada.
page.tsx:42 monta <SuggestionForm /> sin props, y el API lee userId del body — que el formulario nunca manda.
Resultado: toda sugerencia web se guarda anónima, aun con sesión abierta. El admin las lista como «Anónimo».
Cero dependencia @slack/*, cero SLACK_* en los .env, cero webhooks en todo el repo.
SuggestionsService.create() es un prisma.create pelado: no avisa por ningún canal. El módulo ni siquiera importa notificaciones.
SuggestionStatusEnum es PENDING · REVIEWED · IMPLEMENTED · REJECTED: un estado interno de gestión.
No hay seenAt, ni quién la vio, ni cuándo, ni dónde guardar una respuesta. «Vista» hay que inventarla.
/suggestions se queda donde está; esta es la puerta rápida, no su reemplazo.
56 px, superficie noche, el monograma en blanco con su punto rojo fijo. Vive en la esquina inferior derecha de toda la app del cliente y no compite con nada: el FAB de crear vive en el sidebar en escritorio y dentro de la barra de pestañas en móvil.
aurora-home, sin cambios — y con la holgura inferior que le hace falta.
nuevo
ReposoQuieto. Sin halo, sin pulso, sin insignia. No pide nada.
Hay novedadTe respondieron, o algo que pediste ya está listo. Halo celeste de 3,2 s y la cifra: el único movimiento que existe.
Al pasar el ratónLa etiqueta dice para qué sirve. En móvil no existe: ahí el ícono se aprende tocándolo.
380 px que nacen desde el botón: el círculo se estira en tarjeta desde su esquina inferior derecha, radio 50 % → 20 px, en 640 ms con --ease-out. Sin rebote. --ease-spring queda reservado para una sola cosa en toda la pieza: el tick de la estrella.
La corona es de noche, no la banda de día del home. El flotante es una capa encima de la página, no un bloque de ella — y la noche es la misma voz que le abrió la puerta al cliente en /login. Dos glows a la deriva: celeste arriba (22 s), lima abajo (31 s), amplitudes chicas porque es una corona de 380 px, no una escena de auth.
Estás viendo la versión nueva de Mogos. Si algo te chirría, o se te ocurre algo mejor, escríbelo acá: lo lee el equipo, no un buzón.
Lo lee el equipo de Mogos. Te avisamos acá mismo.
El anuncio. Los chips no clasifican ni mandan: solo escriben las primeras palabras en el campo. Una caja vacía es lo más difícil de enfrentar; cuatro palabras ya puestas lo resuelven sin cobrar peaje.
Ya está con el equipo. Cuando la vean, te lo digo acá.
Lo lee el equipo de Mogos. Te avisamos acá mismo.
Escribes primero, calificas después. Y solo la primera vez, nunca más. El «Saltar» es honesto: se puede saltar de verdad. El costo aceptado es que casi nadie califique — el texto vale más que el promedio.
Escríbelo como se te ocurra. Lo lee el equipo.
Ya puedes pagar en cuotas los envíos sobre 500 $. Lo activamos esta mañana: está en Pagos, al lado del monto.
Mogos · hace 2 h
Lo lee el equipo de Mogos. Te avisamos acá mismo.
El viaje de vuelta. Lo que se pidió hace dos semanas ya está hecho, y esa línea es la única con color propio en todo el flotante. Debajo, lo de ayer espera su turno con una promesa escrita, no con silencio. Tú hablas en burbuja; Mogos responde sobre el lienzo, sin caja.
| Estado | Se ve así | Qué lo dispara |
|---|---|---|
| Enviando | Enviando… | Optimista, en el instante en que sueltas el botón |
| Enviada | ✓ Enviada · 14:32 | El 201 del API. No espera a Slack. |
| Vista | 👁Vista por el equipo | Alguien reaccionó con 👀en Slack → seenAt |
| Anotada | 📝Anotada para hacerla | Reacción ✅→ status = REVIEWED |
| Lista | ✔ Lista · ya está en la app | Reacción 🚀→ status = IMPLEMENTED |
--ease-spring en 240 ms — el segundo y último uso del rebote en la pieza, junto al de la estrella. En esta casa el resorte significa algo bueno acaba de pasar, y no significa nada más.
aurora-familia dejó el FAB de crear dentro de la barra de pestañas. El de sugerencias no puede pelear con él.
nuevo
Columna distinta, plano distinto. El de crear vive dentro de la barra, centrado y con el degradado de marca. El de sugerencias baja a 48 px y se apoya sobre la barra, por la derecha. Nunca se tocan, y el pulgar sabe cuál es cuál sin pensarlo.
Estás viendo la versión nueva de Mogos. Si algo te chirría, o se te ocurre algo mejor, escríbelo acá: lo lee el equipo, no un buzón.
Ya puedes pagar en cuotas los envíos sobre 500 $. Lo activamos esta mañana: está en Pagos, al lado del monto.
Mogos · hace 2 h
A 390 es pantalla completa, no una hoja a medias. Una hoja al 70 % deja media app asomando detrás y el teclado se come lo que queda. El pie con el aviso desaparece: en móvil el espacio es del texto.
Una Slack app propia, no un webhook entrante. Un webhook solo sabe escribir; acá hace falta leer reacciones y mensajes de hilo, que es exactamente de donde sale el «visto» y la respuesta. El mensaje trae todo lo que se necesita para actuar sin abrir otra pantalla.
La lista de envíos no me deja filtrar por fecha de llegada, y con siete cajas abiertas se me pierde cuál llega primero.
/account/shipments👀vista · ✅anotada · 🚀lista — el cliente ve las tres. Responde en el hilo y también le llega.
Aparece únicamente cuando phoneVerified es cierto. Si no lo es, el botón no está y una línea de contexto dice por qué. Un botón que lleva a un número sin verificar es peor que ninguno.
El enlace es wa.me/<dígitos>: el teléfono se guarda en E.164 con + y el repo ya sabe quitarlo — la regla vive escrita en apps/delivery/lib/whatsapp-intent.ts.
El verde de WhatsApp es de otra marca. Slack ya pone el marco del botón; meterle un color ajeno a la paleta rompe el sistema por una convención que nadie pidió. El glifo basta para reconocerlo.
«Escribió desde» es el campo que más rinde: convierte un «no encuentro nada» en un bug reproducible sin tener que preguntar.
url, you'll still receive an interaction payload and will need to send an acknowledgement response». Los dos botones de arriba disparan una interacción que la app tiene que contestar con un 200 en menos de tres segundos — y si no hay una Interactivity Request URL configurada, el enlace abre pero el usuario ve un error. No existe forma documentada de tener un botón de enlace puro. | Reacción | Qué mueve | ¿Lo ve el cliente? |
|---|---|---|
| 👀 Ojos | seenAt = now | Sí — «Vista por el equipo» |
| ✅ Anotada | status = REVIEWED | Sí — «Anotada para hacerla» |
| 🚀 Cohete | status = IMPLEMENTED | Sí — «Lista · ya está en la app» + insignia |
| 🗑️ Papelera | status = REJECTED | No — la única que no sale del canal |
| 💬 Hilo | Crea SuggestionReply | Sí — mensaje de Mogos + insignia |
| Si quitan la reacción: quitar 👀 no deshace nada — no se puede des-ver algo. Quitar ✅ o 🚀 sí revierte el estado: si alguien marcó «Lista» por error, el cliente está creyendo que se le entregó algo que no existe, y ese error es peor que el otro. | ||
Seis pasos. El único que el cliente espera es el segundo: el resto pasa mientras él sigue con lo suyo.
El texto aparece en el hilo al instante como burbuja con «Enviando…». La interfaz no espera a nadie.
POST /suggestions → 201La fila se crea y el API responde de una. El userId sale del token, nunca del body — es el arreglo del que cuelga todo lo demás. Se guardan también route (desde dónde escribió) y el rating si ya lo dio. La línea de estado pasa a «Enviada · 14:32».
El servicio emite suggestion.created al INTEGRATION_HUB y ahí se acaba su responsabilidad — no nombra Slack. El hub busca quién atiende ese evento, llama a su render() puro y publica con chat.postMessage, ya fuera del camino de la respuesta. El ts que devuelve Slack se guarda como externalId en IntegrationDelivery: el ancla para casar después una reacción o un hilo con esta fila exacta.
Events API pega en la puerta única POST /integrations/slack/events. El guardia verifica con la firma declarada en el descriptor, el parse() puro traduce el evento y se busca por (integrationKey, externalId). 👀escribe seenAt; ✅y 🚀mueven status y sellan statusChangedAt. Gestos que el equipo ya hace por instinto, convertidos en las señales que el cliente esperaba.
El evento trae thread_ts, que es el mismo externalId. Se guarda una fila en SuggestionReply con el texto y quién lo escribió. Si mañana quien escribe es un agente en vez de una persona, no cambia una sola pantalla: el hilo es el mismo.
GET /suggestions/mine al abrir, y ya. Sin tiempo real: nadie deja esto abierto esperando, y una sugerencia no es un chat en vivo. Si hay respuesta sin leer, el botón se lo dijo antes de abrirlo.
Slack es la primera, no la última. El mismo circuito lo van a querer después ventas, soporte y finanzas, y el destino no siempre será Slack. Así que la pregunta no es «cómo mando esto a Slack» sino cómo se manda cualquier cosa a cualquier parte sin volver a tocar el dominio.
MessagingConnector, su Card IR neutral, su patrón outbox con reintentos y sus cuatro capas para que el negocio nunca conozca Slack. No lo encontré porque inventarié el código y aquél es un diseño sin implementar.suggestion.created debería entrar como un evento más de su catálogo. Lo que esta pieza sí aporta de forma independiente son los hallazgos verificados contra la documentación de Slack, que valen para cualquiera de las dos arquitecturas.
SuggestionsService inyecta un SlackService y lo llama al crear la fila.
Funciona el lunes. El problema es que ahora el módulo de sugerencias sabe que Slack existe: el segundo destino ya no cabe sin editarlo, y el quinto obliga a editar los cinco dominios que notifican.
SuggestionsService inyecta INTEGRATION_HUB y emite suggestion.created. No nombra Slack. No sabe que existe.
Del otro lado, un registro de descriptores decide quién atiende ese evento. Sumar Discord es un archivo y un bloque de env — cero líneas tocadas en suggestions/.
No es un patrón inventado para esta pieza: es el que el repo ya usa para los bancos. finance/banking/connectors/ lleva cinco bancos con la misma disciplina — el conector declara sus capacidades como datos, y el motor compartido lee esas declaraciones y nunca ramifica por banco. Es la única propiedad que hace barata la integración número siete.
render y parse son puras: se prueban sin red, sin base de datos y sin Nest. El motor las llama, hace la petición y aplica los efectos. Todo el I/O vive en un solo sitio.
| Webhook | Esquema | Cadena que se firma | Ventana |
|---|---|---|---|
| WhatsApp existe | hmac-sha256 · sha256= | body | — |
| Instagram existe | hmac-sha256 · sha256= | body | — |
| TikTok existe | hmac-sha256 · t=…,s=… | {ts}.{body} | 300 s |
| Slack nueva | hmac-sha256 · v0= | v0:{ts}:{body} | 300 s |
common/. Diffear el de Instagram contra el de WhatsApp con los nombres normalizados deja una sola línea de log distinta. Cada integración nueva vuelve a copiar. Con la firma declarada como dato, la quinta ya no copia — y las cuatro viejas tienen camino de migración. Pero no en el mismo PR: tocar el pipeline de mensajería mientras se estrena Slack es cambiar dos cosas a la vez. Va en fase aparte, con sus pruebas.
@nestjs/event-emitter, cqrs y bullmq no están instalados, y @OnEvent da cero resultados en todo el repo. Meter un bus para un solo consumidor es deuda neta.
El patrón de la casa para esto ya existe: el sumidero inyectable de BANK_ALERT_SINK. Con su regla, que acá es igual de obligatoria: una integración que falla no puede tumbar la operación. emit() nunca propaga — registra el fallo y devuelve.
slackTsLa sección anterior de esta misma propuesta proponía un campo slackTs en la tabla Suggestion. Eso es el nombre de un proveedor dentro de una tabla de dominio — justo la fuga que todo esto existe para impedir.
Se va a IntegrationDelivery, genérico: integrationKey, event, entityId, externalId, status, attempts. Suggestion se queda solo con lo suyo — y los reintentos por fin tienen dónde vivir.
integrations, no connectors. La palabra ya está tomada: finance/banking/connectors/ exporta registerConnector() y BankConnectorDescriptor. La colisión es de palabra y no de ruta — y por eso mismo es peor, porque dos registerConnector distintos en el mismo apps/api son una trampa para quien llegue en seis meses. Se replica el patrón, se cambia el sustantivo, y en la cabecera del módulo se cita el SDK bancario como el arte previo.docs/superpowers/specs/2026-08-08-integraciones-design.md, como ADR-API-001.
Once cambios, ninguno grande. SuggestionStatusEnum no cambia ni un valor, y ahí está lo bonito: REVIEWED e IMPLEMENTED ya existían desde el primer día, esperando que alguien se los contara al cliente. Lo que faltaba no era vocabulario nuevo — era el otro eje (seenAt) y las fechas para saber qué es novedad.
| Cambio | Dónde | Por qué |
|---|---|---|
userId del token | suggestions.controller.ts | Hoy toda sugerencia web es anónima. Sin esto no hay historial ni WhatsApp. |
rating opcional | DTO · Prisma · Zod | Se califica después de escribir, o nunca. |
| Largo del comentario | create-suggestion.dto.ts | El 10/500 solo vive en el front; el API acepta cualquier cosa. |
seenAt | Suggestion | «Vista» es otro eje que status: acuse de recibo, no gestión. |
statusChangedAt | Suggestion | Sin esto no se sabe si «Lista» es novedad o ya la vio. |
suggestionsOpenedAt | User | Contra qué compara la insignia. En el usuario, no en localStorage: la novedad tiene que cruzar de teléfono a computadora. |
route | Suggestion | Desde qué pantalla escribió. |
IntegrationDelivery | Tabla nueva | El ancla genérica: integrationKey, event, entityId, externalId (el ts de Slack), status, attempts. Ningún nombre de proveedor entra en Suggestion. |
SuggestionReply | Tabla nueva | suggestionId, body, slackUserName, createdAt. |
GET /suggestions/mine | suggestions.controller.ts | El historial del propio cliente. |
POST /integrations/:key/events | integrations/ | La puerta única de entrada. Una ruta, un guardia, N proveedores. |
/suggestions sobreviveConviven a propósito. El flotante es la puerta rápida desde cualquier pantalla; el formulario es la puerta deliberada, con calificación de entrada, y sigue siendo el destino de los cinco puntos de entrada que ya lo apuntan.
Deuda anotada: dos interfaces sobre el mismo backend. Si el flotante se come el tráfico, el formulario se retira en una fase posterior — decisión de datos, no de diseño.
El corte es una constante, no la primera visita de cada quien: el anuncio habla de la plataforma, no de la persona, y quien entre por primera vez en la semana 3 también lo merece.
Después de esa fecha el saludo pasa al permanente («¿Qué mejoramos?») sin que nadie tenga que acordarse de desactivar nada.
Cada fase deja algo funcionando. Ninguna rompe lo que ya existe. Y la primera no es del flotante: es del andamio que lo va a sostener a él y a los que vengan.
reaction_added cuando alguien reacciona a un mensaje publicado por el propio bot? La evidencia indirecta dice que sí —la doc explica qué forma tiene la carga cuando el autor no es una persona, lo cual solo tiene sentido si esos eventos existen— pero empíricamente funciona no es una fuente. Se prueba a mano en un workspace de juguete antes de escribir una línea. Si sale que no, el «visto» no puede venir de una reacción y se rediseña con un botón, que sí está documentado de punta a punta.
integrations/ con el descriptor, el registro, el hub con su implementación de solo-registrar, los secretos por credentialRef y la tabla IntegrationDelivery. Más el arreglo del userId y los campos nuevos de Suggestion.
Queda funcionando: nada visible todavía. Es la fase que no se ve y sin la cual las otras tres se construyen torcidas.
La Slack app, slack.integration.ts con su render(), el mensaje con sus dos botones, y el emit() en SuggestionsService.
Queda funcionando: el formulario que ya existe empieza a caer en Slack con nombre, ruta y WhatsApp. Todavía sin flotante — y ya vale la pena.
El botón, el panel, los chips, la calificación diferida, el historial, GET /mine. Y la puerta de entrada: controlador, guardia genérico, parse() de Slack, la insignia.
Queda funcionando: el encargo completo — el cliente se entera de que lo que pidió ya está hecho sin tener que ir a buscarlo.
Migrar WhatsApp, Instagram, TikTok y Supabase al guardia genérico: −389 líneas de guardias copiados.
Por qué aparte: tocar el pipeline de mensajería mientras se estrena Slack es cambiar dos cosas a la vez. Esta fase no bloquea a ninguna de las otras y puede esperar meses sin costo.
Un buzón recibe y calla.
Esto contesta.