Core Notification Service

Servicio omnicanal centralizado de Rotoplas. Recibe una petición de envío, la convierte en una notificación por destinatario y canal, la orquesta hasta el proveedor que corresponda —correo, SMS, push o WhatsApp— y conserva la bitácora completa de lo que pasó con cada una.

NestJS 11 · TypeScript · PostgreSQL 15 Pub/Sub · Cloud Workflows · Functions gen2 Cloud Run × 3 + 6 funciones arquitectura hexagonal · gate en CI env: — proyecto: —

Resumen

Qué resuelve

Antes de este servicio, cada aplicación de Rotoplas que necesitaba avisarle algo a un usuario integraba por su cuenta con SendGrid, con Twilio o con Firebase: sus propias credenciales, su propio manejo de reintentos y ninguna respuesta común a «¿le llegó?». El CNS pone una sola puerta delante de los cuatro canales. Quien integra manda una petición con un identificador de plantilla y una lista de destinatarios; el servicio decide proveedor, renderiza, reintenta, y deja registrado cada paso con un trace_id que sobrevive a los cuatro saltos asíncronos.

Qué hace

  • Acepta envíos por correo, SMS, push y WhatsApp, a uno o a hasta 200 destinatarios por petición.
  • Abstrae al proveedor: cambiarlo es cambiar un secreto, no desplegar.
  • Renderiza plantillas Handlebars versionadas y por idioma.
  • Reintenta con dos políticas distintas y aparca en DLQ lo que no salió.
  • Guarda la bitácora completa por notificación y la expone paginada.
  • Recibe las confirmaciones de entrega de los proveedores y cierra el estado.

Qué no hace

  • No decide a quién avisar. No tiene segmentación ni listas: eso es de quien llama.
  • No redacta. Las plantillas las escribe y las versiona el portal de administración.
  • No es una cola para terceros. Su Pub/Sub es interno; se entra por HTTP.
  • No garantiza la entrega. Garantiza el intento, el registro y el desenlace conocido.
  • No sabe si se leyó un correo. Los eventos de apertura y clic no se traducen a estado.

Identidad del servicio

Repositorio
rtp-cns — GitLab
Ticket
ARS0033 · solicitante Hugo Quintero (Arquitectura TI)
Go-live
14 de noviembre de 2026
Ambiente
—
Proyecto GCP
— · región us-central1
Versión
—
Contrato
OpenAPI 3.1 — cliente · administración
El proyecto de GCP no es de este servicio rtp-transversal-* está compartido. En rtp-transversal-dev conviven 28 servicios de Cloud Run de al menos siete equipos —CNS, CSB, CDH, Xtructure, Wallet y varios sueltos—, de los que solo nueve son del CNS. El prefijo cns-{env}- es lo único que delimita lo nuestro. Importa al tocar cualquier cosa a nivel proyecto: IAM, APIs habilitadas, cuotas.

Las cinco reglas que fija el diseño

  • Todo endpoint nace protegido. Sin decorador, una ruta exige clave de cliente. Abrirla es una línea explícita — ver Seguridad.
  • El dominio no importa nada. Ni NestJS. Todo acceso externo pasa por uno de los 24 puertos, y el gate npm run arch bloquea el build si alguien se salta una capa.
  • La fila se escribe antes de publicar. El mensaje de la cola ya no lleva destinatario ni variables, así que publicar primero dejaría un despacho sin de dónde hidratarse.
  • Un despacho, una ejecución. Cada mensaje reclama su turno en dispatch_claims antes de arrancar el orquestador; sin eso, una reentrega enviaría dos veces.
  • Ningún secreto en la imagen. Ni en variables de entorno del contenedor: todo sale de Secret Manager en ejecución, con caché de 30 s.

Arquitectura

Cinco bloques de izquierda a derecha: quién llama, el borde que lo recibe, el servicio, la orquestación del envío, y los proveedores que entregan. La única flecha que va en sentido contrario —la verde— son las confirmaciones de entrega, que vuelven por la misma puerta pública que todo lo demás.

Tres servicios, una imagen cns-{env}-api, cns-{env}-admin y cns-{env}-webhooks corren el mismo código: se construyen del mismo Dockerfile y cargan los mismos módulos. Lo único que los distingue es la variable SERVICE_ROLE —que ajusta logging y CORS—, el dimensionamiento, y quién puede invocarlos. En particular, /v1/admin/* desde internet lo atiende api, no admin: el url_map no tiene regla para admin, y ese servicio solo acepta invocaciones de la cuenta de servicio de Cloud Run.

Componentes y URLs

Todo vive en el proyecto —, región us-central1, con el prefijo cns-{env}-. Las URLs públicas son las del balanceador; los *.run.app devuelven 404 del frontend de Google porque la política de la organización obliga a internal-and-cloud-load-balancing.

Cloud Run

ServicioQué atiendeQuién puede invocarloRuta pública
cns-{env}-apiTodo el tráfico de cliente y de administración, más el consumidor de Pub/Sub y esta documentaciónallUsers (entra por el balanceador)/ · /v1/* · /health
cns-{env}-webhooksLos callbacks de SendGrid, Twilio y MetaallUsers/webhooks/*
cns-{env}-adminLa misma superficie de administración, en un despliegue apartesolo cns-{env}-run-saninguna — no está en el url_map
cns-{env}-migrateJob de migraciones de TypeORM, lo corre el pipelineCloud Build—

Cloud Functions gen2 — todas HTTP, ninguna con event_trigger

FunciónEntry pointQuién la llamaQué hace
cns-{env}-template-rendererrenderTemplateel orquestadorLee .hbs y .subject.hbs de GCS, compila Handlebars, cachea en memoria
cns-{env}-adapter-emailsendEmailel orquestadorElige entre SendGrid, Gmail API y SMTP según el secreto del canal
cns-{env}-adapter-smssendSmsel orquestadorTwilio; clasifica los códigos permanentes y enmascara el teléfono
cns-{env}-adapter-pushsendPushel orquestadorFCM; ramifica entre message.token y message.topic
cns-{env}-adapter-whatsappsendWhatsappel orquestadorMeta Graph API v21.0, plantillas aprobadas
cns-{env}-dlq-reprocessorreprocessDlquna persona, a demandaSaca mensajes de una DLQ y los republica en el topic de ingesta
Las seis funciones repiten código a propósito Cada una es un proyecto npm independiente y no puede importar de src/. Así que el contexto de traza W3C con AsyncLocalStorage, el enmascarado de PII, el circuit-breaker.js y el secret-cache.js están duplicados seis veces. Lo que impide que diverjan no es la disciplina: es functions/replicas-identicas.test.js, que compara las copias y falla si una se aparta.

Resto de la plataforma

RecursoNombrePara qué
Cloud Workflowscns-{env}-orchestratorLa saga de envío: render → adaptador → estado → DLQ
Cloud SQLPostgreSQL 1515 tablas. dev db-f1-micro, qa db-g1-small, prd db-n1-standard-1 con HA y PITR
Cloud Storagecns-{env}-templatesEl contenido .hbs de las plantillas
Cloud Storagecns-{env}-mediaImágenes de notificación de push, con caducidad
Secret Managercns-{env}-{nombre}Credenciales de proveedor, clave interna, tokens de webhook
Balanceadorcns-{env}-url-map · cns-{env}-lb-ip · cns-{env}-ssl-certLa única entrada pública
Cloud Armorcns-{env}-armorWAF y límite por IP, aplicado a todos los backends

Dominios

AmbienteProyecto GCPDominioEstado
devrtp-transversal-devcns-dev.rotoplas.comcertificado activo
qartp-transversal-qascns-qas.rotoplas.comcertificado activo
prdrtp-transversal-prd—sin balanceador

El registro DNS de *.rotoplas.com no lo gestiona este repositorio: Terraform crea la IP estática y el certificado, y el certificado se queda en PROVISIONING hasta que otro equipo da de alta el registro A. Para dev y qa eso ya está hecho. Ojo con qa: el proyecto termina en qas, no en qa, y no se puede interpolar.

Puertas de entrada

Cinco formas de que algo entre al servicio, cada una con su propia autenticación. Las cinco acaban conduciendo un caso de uso; el consumidor de Pub/Sub no es una excepción, y por eso vive en interfaces/ junto a los controladores.

  1. HTTP · apps cliente POST /v1/notifications y las consultas de estado. Autenticación por API key hasheada en el header X-API-Key; el app_id lo resuelve el guard y lo escribe en la petición — nunca se lee del cuerpo. 100 req/min por clave.
  2. HTTP · portal de administración /v1/admin/*, 41 operaciones: alta de aplicaciones y de claves, plantillas y sus versiones, configuración de canales y credenciales, topics de push, webhooks de salida y desvíos. Autenticación por clave interna de plataforma, un registro distinto del de clientes. 60 req/min, y 10 para el envío desde el portal.
  3. HTTP · callbacks de proveedor /webhooks/{sendgrid|twilio|whatsapp}, sin el prefijo /v1: no es una API que publiquemos, es lo que se configura en el panel de cada proveedor. Autenticación por firma sobre el cuerpo crudo, nunca por clave. Sin límite de tasa: los proveedores mandan ráfagas.
  4. Pub/Sub · consumidor de despacho La suscripción notifications.requested.v1.dispatcher.sub, con flowControl de 10 mensajes. Es la puerta que convierte una notificación encolada en una ejecución del orquestador. Autenticación por IAM de la suscripción.
  5. HTTP · descarga de imágenes GET /v1/media-assets/{appId}/{objeto}, anónima: la abre el teléfono del usuario final al desplegar la notificación, sin credenciales. Va por su propio backend con CDN. Responde 404 tanto si el objeto no existe como si la ruta no tiene la forma que este servicio emite, y en ese segundo caso no registra nada, para no amplificar los barridos.

Canales y proveedores

Cuatro canales, y por cada canal uno o varios proveedores posibles. El proveedor activo no está en el código ni en una variable del contenedor: viaja en el secreto cns-{env}-{canal}-provider, que las funciones adaptadoras leen con caché de 30 s. Conmutar de proveedor aplica en medio minuto y sin desplegar nada.

CanalProveedoresPor omisión¿Confirma entrega?Catálogo del proveedor
sendgrid · gmail · smtpsendgridsí con SendGrid · no con Gmailno
smstwiliotwiliosíno
pushfcmfcmnono
whatsapp_cloudwhatsapp_cloudsísí
Con Gmail, «entregado» no existe La API de Gmail no emite eventos de entrega. Una notificación enviada por ese proveedor termina en SENT_TO_PROVIDER y nunca llega a DELIVERED, por mucho que el correo haya llegado. Cualquier tablero que mida porcentaje de entrega marcará 0 % mientras Gmail sea el proveedor activo. No es un fallo: es lo que el proveedor sabe decir. Además tiene un tope de 2 000 mensajes al día con bloqueo de 24 horas al superarlo, y hay alerta al 75 %.

Destinatarios por canal

Cada canal nombra a su destinatario de forma distinta, y el DTO lo refleja: el objeto recipient lleva una clave por canal. Push es el único con dos formas posibles —un token de dispositivo o un topic— y son excluyentes: mandar las dos es un 400.

CanalCampoFormaTipo de destino
recipient.email.todirección de correodirect
smsrecipient.sms.toteléfono E.164direct
pushrecipient.push.totoken de registro FCMtoken
pushrecipient.push.topictopic FCM previamente registrado para esa apptopic
recipient.whatsapp.toteléfono E.164direct

Los topics de push se validan contra app_topics antes de aceptar el envío: una aplicación no puede publicar en el topic de otra. Y se validan sobre el destino original, antes de aplicar el desvío de ambiente, porque en dev el desvío sustituye el topic por un token de pruebas y validar después dejaría pasar cualquier cosa.

Desvío por ambiente

En dev y qa, cada aplicación puede declarar a qué dirección se redirige realmente cada canal, para que probar no signifique escribirle a un cliente. La notificación se guarda con el destino desviado, y queda un evento REDIRECTED en la bitácora con las dos direcciones ya enmascaradas. En producción no se consulta siquiera la tabla: la función que resuelve el ambiente de desvío devuelve null y el desvío no existe.

Credenciales

Entran por PUT /v1/admin/channels/{canal}/credentials y salen a Secret Manager. No se devuelven nunca: la consulta del canal expone un hasValue, no el valor. Desde agosto de 2026 las funciones adaptadoras tampoco las llevan montadas como variables de entorno — las leen de Secret Manager en ejecución. Hay dos endpoints para comprobar sin enviar y para enviar de prueba: POST …/verify valida la credencial contra el proveedor sin mandar nada, y POST …/test manda un mensaje real.

Plantillas y renderizado

Una plantilla tiene dos mitades que viven en sitios distintos a propósito: el catálogo —qué plantillas hay, qué versiones, cuál es la de por omisión— está en PostgreSQL, y el contenido —el .hbs de verdad— está en Cloud Storage. El catálogo se consulta en el camino caliente del envío; el contenido solo lo lee la función de renderizado.

Cómo se direcciona el contenido

gs://cns-{env}-templates/templates/{templateId}/{versionId}/{locale}/{channel}.hbs
gs://cns-{env}-templates/templates/{templateId}/{versionId}/{locale}/{channel}.subject.hbs   // solo email

Cuatro ejes: qué plantilla, qué versión, qué idioma y qué canal. El mismo templateId sirve para los cuatro canales y cada uno tiene su archivo, porque el cuerpo de un SMS no es el de un correo.

Cómo se resuelve la versión

  1. El cliente manda templateVersionSe usa tal cual y no se consulta el catálogo. El catálogo no es la fuente de verdad de lo que se puede renderizar: si el objeto existe en el bucket, se renderiza.
  2. El cliente no la mandaSe busca la versión vigente de esa plantilla; si no hay, la versión por omisión. Si tampoco, la petición falla con TEMPLATE_VERSION_UNRESOLVED antes de escribir nada.

Caché e invalidación

El renderizador compila y guarda en memoria con una clave determinista por los cuatro ejes. Cuando alguien reescribe el contenido de una versión desde el portal, el topic notifications.template.invalidated.v1 avisa a la función para que tire su copia. Es caché de proceso: un despliegue la vacía sola.

Toda escritura de contenido queda registrada template_content_audit guarda quién escribió qué plantilla, en qué versión, idioma y canal, y cuándo. Se consulta con GET /v1/admin/templates/{templateId}/audit. La versión marcada por omisión no se puede borrar.

Flujo de una notificación

Cuatro fases, y solo la primera es síncrona. Cuando el cliente recibe su 202, lo único que está garantizado es que la notificación quedó escrita y encolada: todo lo demás —orquestar, renderizar, entregar, confirmar— pasa después y puede tardar de segundos a horas.

Fase A · Ingesta — las cuatro fases del caso de uso

El caso de uso central parte el trabajo en cuatro tramos explícitos, y el orden entre el tercero y el cuarto es una decisión de diseño, no una casualidad.

  1. ① Validar Canales habilitados (un canal apagado es un 409), topics de push propios de esa aplicación, y versión de plantilla resoluble. Corre también en dryRun, para que la simulación conteste exactamente lo mismo que el envío real.
  2. ② Resolver, sin escribir nada Doble bucle por destinatario y luego por canal —ése es el orden en que salen los identificadores en la respuesta—. Aquí se aplica el desvío de ambiente, se exige que cada canal pedido tenga dirección, y se detectan duplicados: el error dice qué par canal + dirección repite y en qué índice.
  3. ③ Escribir Un INSERT en notifications con estado QUEUED y otro en notification_events con los RECEIVED (y los REDIRECTED, si hubo desvío). Si la petición era dryRun, se devuelve aquí y no se publica nada.
  4. ④ Publicar En paralelo, no una a una: el cliente de Pub/Sub agrupa en lotes cuando se le encolan juntas. Si fallan todas, la petición falla. Si fallan algunas, cada una pasa a FAILED con categoría TRANSIENT_FAILURE y sale reportada en el resumen del lote — la misma regla que usa firebase-admin para los suyos.
Escribir va antes de publicar, y no hay transacción El mensaje de la cola ya no lleva destinatario ni variables de plantilla: los rehidrata el consumidor desde la fila. Por eso publicar primero dejaría un despacho sin de dónde leer. Lo que no hay entre el INSERT y el publish es una transacción, y no se pretende que la haya: eso es el patrón outbox, y es trabajo aparte — ver Pendientes.

Fase B · Despacho

El consumidor de Pub/Sub descarta por su cuenta lo que no tiene arreglo: un mensaje que no es JSON, uno que no valida contra el DTO, y uno con una versión de esquema que no soporta. Los tres se acusan (ack) en lugar de devolverse — reintentar un malformado da exactamente lo mismo. Y el cuerpo del mensaje inválido no se registra, porque lleva destinatario.

Después, DispatchNotificationUseCase reclama el mensaje, rehidrata, y arranca el orquestador. Lo delicado es el manejo del fallo: la reclamación solo se libera cuando consta que no se creó ejecución —un 4xx, o un 503 que dice explícitamente que no se aceptó—. Un timeout, un fallo de red o un 502 no dicen nada, así que la reclamación se conserva; liberarla ahí arriesgaría una segunda ejecución.

Y los dos duplicados se tratan al revés

SituaciónQué se hacePor qué
La reclamación ya estaba completadaconfirmar (ack)El workflow ya corrió. Repetirlo enviaría dos veces.
La reclamación está en cursodevolver (nack)Acusarlo sacaría de la suscripción el único mensaje capaz de reintentar si aquel intento murió a medias. Si persiste, acaba en DLQ: visible y reprocesable.

Fase C · Orquestación

El workflow lee la clave interna de Secret Manager en ejecución, no desde sus variables de despliegue: ahí quedaría escrita en el estado de Terraform. El PATCH de estado necesita dos credenciales a la vez, OIDC para que Cloud Run lo deje entrar y X-API-Key para que el guard interno lo acepte; con una sola no pasa.

Fase D · Confirmación

El webhook casa el evento del proveedor contra la columna external_msg_id que escribió la fase C. Sin ese identificador en la fila, ningún webhook se aplica — fue exactamente el defecto que dejaba las notificaciones clavadas en SENT_TO_PROVIDER. Es idempotente por diseño: reaplicar un desenlace terminal no reescribe nada, y de un estado terminal no se sale.

Modelo de datos

15 entidades en PostgreSQL 15, 17 migraciones. synchronize está en false y no puede dejar de estarlo: el esquema solo cambia por migración explícita.

El núcleo

TablaClaveQué guarda
notificationsnotification_idUna fila por destinatario y canal, no por petición. Lleva request_id (el lote), trace_id, app_id, destinatario y su recipient_kind, plantilla, versión, idioma, template_data, image_url (solo en push), prioridad, estado, retry_count, external_msg_id, proveedor, categoría de error y dry_run.
notification_eventsevent_idLa bitácora. Tipo de evento, actor (api, workflow, webhook, system), metadatos y momento. Índice compuesto por notificación y fecha, y borrado en cascada.
dispatch_claimspubsub_msg_idEl registro de idempotencia del despacho. Que la clave primaria sea el identificador del mensaje de Pub/Sub es el mecanismo: dos entregas del mismo mensaje no pueden reclamar dos veces.
applicationsapp_idLas aplicaciones cliente. Cuelgan de ella las claves, las notificaciones, los webhooks de salida, los desvíos, los topics y las apps de Firebase.

Credenciales, plantillas y configuración

TablaPara qué
api_keysClaves de aplicación cliente. Se guarda el hash, nunca la clave; con estado, revocación y caducidad.
admin_api_keysClaves de plataforma. Registro distinto del anterior: confundirlos fue lo que dejó el PATCH de estado al alcance de cualquier cliente.
templates · template_versionsEl catálogo. Qué plantillas hay, qué canales soporta cada una, qué versiones y cuál es la de por omisión. El contenido no está aquí: está en GCS.
template_content_auditQuién escribió qué contenido, cuándo y sobre qué versión, idioma y canal.
channel_configsProveedor activo y si el canal está habilitado. La clave primaria es el propio canal. Las credenciales no están aquí, están en Secret Manager.
channel_config_auditBitácora de cambios de canal: cambio de proveedor, habilitar, deshabilitar, rotar credencial.
app_topicsQué topics de push tiene registrados cada aplicación. Es lo que impide que una publique en el topic de otra.
push_client_appsQué aplicaciones de Firebase dio de alta el CNS y para quién. Sin borrado, a propósito.
redirect_configsLos desvíos por ambiente y canal. Inactivos en producción.
webhook_configsA qué URL del cliente se le avisa (webhooks de salida).

Estados de una notificación

  • QUEUEDEscrita y publicada en la cola. Es el estado con el que nace.
  • PROCESSINGEl orquestador la tomó y va a renderizar.
  • SENT_TO_PROVIDEREl adaptador la entregó al proveedor y devolvió un identificador externo. Con Gmail y con push, este es el estado final.
  • DELIVEREDEl proveedor confirmó la entrega por webhook.
  • FAILEDFallo transitorio. Hoy lo escribe la fase de publicación cuando Pub/Sub rechaza el mensaje.
  • FAILED_PERMANENTLYNo se va a reintentar más: o el proveedor dijo que el destino no existe, o se agotaron los reintentos del workflow. Es el estado que dispara la DLQ del canal.
  • REDIRECTEDReservado para el desvío de ambiente; en el camino normal el desvío se registra como evento y la fila sigue su curso.

Mensajería — Pub/Sub

Ocho topics y siete suscripciones. La convención de nombres es {dominio}.{entidad}.{evento}.v{N}, sin prefijo de ambiente —dev, qa y prd son proyectos de GCP distintos, así que el nombre ya está aislado— y con la versión dentro del nombre.

El catálogo completo

TopicQuién publicaQuién consumeNota
notifications.requested.v1la API · el reprocesador…dispatcher.subEl único del camino principal
notifications.requested.v1.dlqPub/Sub…dlq.reader.subDead-letter real de la suscripción del despachador
…v1.email.dlqel orquestador…reader.subAparcaderos por canal. Los llena el paso publish_to_dlq por REST con OAuth2 — desde que el despacho pasó de cola a HTTP, ya no hay una suscripción detrás que pueda tener política de dead-letter
…v1.sms.dlqel orquestador…reader.sub
…v1.push.dlqel orquestador…reader.sub
…v1.whatsapp.dlqel orquestador…reader.sub
notifications.template.invalidated.v1la superficie de administración…renderer.subTira la caché del renderizador. Retención de 1 día, no 7
notifications.template.stored.v1Cloud Storage—Cambios en el bucket de plantillas

El mensaje

JSON en camelCase, con sobre de evento estándar y el dominio dentro. La versión de esquema es 1.0 y el consumidor la comprueba: un mensaje con una versión que no soporta se acusa y se descarta, no se devuelve a la cola.

{
  // sobre
  "schemaVersion": "1.0",
  "eventId":       "9f1c2e5a-3b7d-4c81-9a0e-2f6b8d4c1e07",
  "eventType":     "notification.queued",
  "occurredAt":    "2026-09-17T18:04:05.000Z",
  "aggregateId":   "3c8f…",        // mismo valor que notificationId

  // dominio
  "notificationId": "3c8f…",
  "requestId":      "71ab…",        // el lote
  "traceId":        "5d0e…",
  "traceparent":    "00-5d0e…-a1b2c3d4e5f60718-01",
  "appId":          "c2d4…",
  "channel":        "email",
  "templateId":     "orden-confirmada",
  "templateVersion": "v3",
  "locale":         "es-MX",
  "priority":       "STANDARD",
  "dryRun":         false
}

// Atributos del mensaje (no el cuerpo): el traceId va aquí porque el
// estándar de mensajería lo exige en atributos, y porque ganan al cuerpo.
notificationId · traceId · traceparent · tracestate? · channel · priority
El mensaje ya no lleva destinatario ni variables Los campos recipient y data siguen declarados como opcionales en el tipo, pero no se escriben. Sacarlos del cuerpo era el objetivo: el destinatario es un dato personal y no tiene por qué vivir siete días en una cola. Borrarlos del tipo subiría la versión mayor, y con siete días de retención todavía puede haber mensajes vivos del formato anterior — por eso el consumidor sabe leer los dos.

Y una desviación consciente del estándar

La norma de mensajería del área pide un topic por evento de dominio. notifications.requested.v1 es genérico y lleva el channel dentro del mensaje. Es deliberado: no es un topic de eventos de dominio sino de solicitudes de trabajo, y partirlo en cuatro daría cuatro suscripciones, cuatro despachadores y cuatro sitios donde se puede olvidar una.

Reintentos, idempotencia y DLQ

Hay dos capas de reintento, con parámetros distintos, y confundirlas lleva a conclusiones equivocadas al leer un incidente. Una reintenta el despacho —volver a intentar arrancar el orquestador—; la otra reintenta el envío —volver a llamar al proveedor—. Una notificación puede consumir las dos.

Idempotencia del despacho

Cada mensaje reclama su turno en dispatch_claims, cuya clave primaria es el identificador del mensaje de Pub/Sub. Con eso, dos entregas del mismo mensaje no pueden arrancar dos ejecuciones. El orden de las dos escrituras finales también es deliberado: primero se completa la reclamación y después se cambia el estado. Al revés, un fallo del cambio de estado dejaría la reclamación sin completar, la reentrega la vería «en curso», acabaría en DLQ, y al reprocesarla se lanzaría una segunda ejecución.

Qué reintenta el proveedor y qué no

El adaptador clasifica el fallo del proveedor en dos categorías, y el workflow decide con esa etiqueta:

CategoríaEjemplosQué hace el workflow
PERMANENT_FAILURENúmero inexistente, token de FCM revocado, dirección malformada, plantilla de WhatsApp no aprobadaNi un reintento: directo a FAILED_PERMANENTLY y a la DLQ del canal
TRANSIENT_FAILURE429 del proveedor, 5xx, timeout, corte de redReintenta con backoff hasta agotar los intentos
desconocidaUna respuesta que el adaptador no supo clasificarSe trata como transitoria. Reintentar de más es más barato que no reintentar un fallo recuperable

Reprocesar una DLQ

Lo hace dlq-reprocessor, que es HTTP y no tiene scheduler: alguien la invoca a propósito. Saca mensajes con un pull síncrono, los republica en el topic de ingesta y los acusa. Tiene modo de simulación que muestra lo que haría con el contenido enmascarado. El guion de operación es scripts/dlq-reprocess.sh.

Reprocesar re-despacha, no reenvía Lo que vuelve al topic de ingesta es la solicitud, así que pasa otra vez por el consumidor — y ahí se encuentra con una reclamación nueva, porque el identificador del mensaje republicado es otro. Es decir: reprocesar sí puede volver a enviar. Antes de drenar una DLQ conviene mirar en qué estado quedaron esas notificaciones.

Endpoints

Todos responden JSON. Lo que sigue son los del camino de integración; la referencia completa con los esquemas generados desde los DTO está en Swagger UI —y las 41 operaciones de administración, en su propia superficie—.

Un campo que el DTO no declara es un 400, no un campo ignorado El ValidationPipe global va con forbidNonWhitelisted. Si un llamador manda una clave de más —aunque sea inofensiva, aunque sea un comment— la petición entera se rechaza. Es deliberado: un campo que se ignora en silencio es un campo que alguien cree que funciona.
POST/v1/notificationsapps cliente · X-API-Key

Acepta un envío y lo encola. Responde 202, no 200: que se acepte no significa que se entregara. El appId sale de la clave, nunca del cuerpo.

Cuerpo — SendNotificationDto
{
  "channels": ["email", "push"],       // se intentan todos, para cada destinatario
  "templateId": "orden-confirmada",
  "templateVersion": "v3",             // opcional: si falta se resuelve la vigente
  "locale": "es-MX",
  "data": { "nombre": "Ana", "folio": "ORD-000123" },

  // `recipient` y `recipients` son EXCLUYENTES. La lista admite hasta 200.
  "recipients": [
    {
      "email": { "to": "ana.lopez@example.com" },
      "push":  { "to": "fJ8k…token-de-registro-fcm" }
    }
  ],

  "push": { "image": "https://cns-dev.rotoplas.com/v1/media-assets/…/….jpg" },
  "priority": "STANDARD",                // STANDARD | CRITICAL — cambia los reintentos
  "dryRun": false                       // true valida y resuelve, pero no envía nada
}
Respuesta 202 — SendNotificationResponseDto
{
  "requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",   // GUARDA ESTE
  "notificationIds": [                                  // una por destinatario × canal
    "b1a6f2d4-0c3e-4a91-8f7d-5e2c1b0a9d83",
    "c2b7e3f5-1d4f-4b02-9e8a-6f3d2c1b0a94"
  ],
  "dryRun": false
}
Lo que rechaza antes de escribir nada
  • recipient y recipients a la vez, o ninguno de los dos.
  • push.to y push.topic a la vez: exactamente uno.
  • push.image sin que push esté entre los canales.
  • Un topic de push que no está registrado para esa aplicación → PUSH_TOPIC_NOT_REGISTERED.
  • Un canal deshabilitado → 409 CHANNEL_DISABLED.
  • El mismo par canal + dirección dos veces → DUPLICATE_RECIPIENT, que dice qué índice repite a cuál.
Códigos
202400401 409413429500
GET/v1/notifications?requestId=…apps cliente · X-API-Key

El desenlace de un lote entero: conteo por estado y la lista paginada de las que fallaron. requestId es obligatorio y hoy es el único filtro.

{
  "requestId": "f47ac10b-…",
  "total": 200,
  "porEstado": { "SENT_TO_PROVIDER": 198, "FAILED_PERMANENTLY": 2 },  // un estado en 0 no aparece
  "failures": [ /* … */ ],
  "meta": { "limit": 50, "nextCursor": null }             // pagina SOLO failures
}
200400401404429500

El 404 significa las dos cosas a la vez: que no existe, o que no es de esta aplicación. Se responde lo mismo a propósito, para no confirmarle a nadie la existencia de un lote ajeno.

GET/v1/notifications/{id}/eventsapps cliente · X-API-Key

La bitácora completa de una notificación, del evento más antiguo al más reciente, paginada por cursor opaco. Es donde se ve qué pasó de verdad.

Tipos de evento y quién los escribe
eventTypeactorCuándo
RECEIVEDapiLa petición se aceptó y la fila se escribió
REDIRECTEDapiHubo desvío de ambiente. Las dos direcciones van enmascaradas
QUEUEDapiPublicada en Pub/Sub; los metadatos traen el id del mensaje y el topic
PROCESSINGworkflowEl orquestador la tomó
SENT_TO_PROVIDERworkflowEl proveedor la aceptó y devolvió identificador externo
RETRYworkflowReintento del envío tras un fallo transitorio
DELIVEREDwebhookEl proveedor confirmó la entrega
FAILED · FAILED_PERMANENTLYapi · workflow · webhookSegún quién detectó el fallo

occurredAt es el momento del hecho, no el de su registro: un webhook puede llegar minutos después de lo que describe.

POST/v1/media-assetsapps cliente · multipart/form-data

Sube la imagen de una notificación de push y devuelve la URL que se manda después en push.image. Campo file. 20 subidas por minuto.

El tipo real se deduce de los primeros bytes, no del Content-Type que declaró el cliente. La URL apunta al dominio del servicio y no al bucket: desde que el bucket dejó de ser público, los bytes salen por el mismo borde que el resto de la API. Úsala tal cual llega; no se construye a mano. Caduca a los 30 días.

201400415429503
PATCH/v1/notifications/{id}/statusinterno · lo llama Cloud Workflows

Actualiza el estado de una notificación. No es para clientes: exige clave de plataforma. Responde 204.

Por qué no basta con la clave Este endpoint necesita dos credenciales a la vez cuando lo llama el orquestador: OIDC para que Cloud Run acepte la invocación, y X-API-Key interna para que el guard la deje pasar. Con una sola no entra. Antes estaba bajo el guard de cliente y buscaba solo por notification_id, así que cualquier aplicación podía mover el estado de notificaciones ajenas — y el evento quedaba firmado como si lo hubiera hecho el orquestador.
POST/webhooks/{sendgrid|twilio|whatsapp}proveedores · firma

Sin prefijo /v1 y sin límite de tasa. Cada uno verifica la firma de su proveedor sobre el cuerpo crudo. Hay además un GET /webhooks/whatsapp que responde el reto de alta de Meta comparando hub.verify_token.

Responden 200 incluso cuando el evento no se traduce a nada: open, click y processed no dicen nada de la entrega y se ignoran a propósito. Lo que no encuentra notificación tampoco es un error para el proveedor — se registra y se acepta, porque devolverle un 4xx a SendGrid solo consigue que reintente.

Y los de salud

RutaQué compruebaQuién la usa
GET /healthSolo que el proceso responde. No toca la base a propósito: hacerla profunda convertiría una caída de PostgreSQL en un reinicio del contenedorEl liveness probe de Cloud Run y el uptime check
GET /health/readyConsulta la base. Es la que dice si el servicio puede atender tráficoEl readiness probe. Sí pasa por el límite de tasa, deliberadamente

Contratos

Cuatro contratos, y cada uno tiene su propio mecanismo para no derivar del código.

1 · HTTP — OpenAPI 3.1

Dos superficies separadas, porque tienen públicos distintos: cliente (/api-docs, 4 grupos) y administración (/api-docs/admin, 8 grupos y 41 operaciones).

Los dos documentos están commiteados en openapi/ y hay un gate que los regenera y compara: si alguien cambia un DTO y no vuelve a generar, el build falla. Este repositorio no usa el plugin CLI de Swagger — todo lo que el contrato dice está escrito a mano en decoradores.

2 · El mensaje de la cola

Definido como objeto de valor en domain/, no en infraestructura: lo que se publica es vocabulario del negocio. Versión 1.0, y el consumidor comprueba la versión antes de actuar.

Ver Mensajería para la forma completa.

3 · El argumento del workflow

No es el mensaje. Es el mensaje menos los campos que ya no viajan, más recipient, recipientKind, data e imageUrl — todos obligatorios, porque el YAML los lee y una clave ausente mata la ejecución con KeyError.

Lo fija una prueba de tipos: si el tipo gana un campo obligatorio, el spec deja de compilar hasta que el YAML lo lea.

4 · El cuerpo del adaptador

Uno solo para los cuatro canales. El workflow manda siempre la misma forma —notificationId, traceId, recipient, recipientKind, imageUrl, subject, body, contentType, title, data— y cada adaptador usa lo que le sirve.

La respuesta también es común: { success, externalMsgId, provider } o { success: false, errorCategory }.

Trazabilidad — la misma en los cuatro saltos

Toda operación acepta y devuelve traceparent y tracestate (W3C), más X-Trace-Id como alias de compatibilidad con menos precedencia. La precedencia al resolver el contexto es: traceparent > x-cloud-trace-context (la que pone el balanceador) > X-Trace-Id; si no llega ninguna, se genera.

En el mensaje de Pub/Sub la traza viaja en los atributos, no solo en el cuerpo, y los atributos ganan. El consumidor abre un span nuevo, así que el despacho cuelga de la petición que lo originó. Dentro del workflow, en cambio, todas las llamadas comparten el mismo span: Cloud Workflows no sabe generar los ocho bytes aleatorios de un span nuevo, así que en Cloud Trace salen como hermanas.

Compatibilidad

  • La versión va en la URI (/v1), salvo /health y /webhooks. /health porque seis sitios de Terraform lo apuntan sin versión y versionarlo dejaría al servicio sin comprobación de vida. /webhooks es una desviación consciente pendiente de decisión: versionarla obliga a tocar los paneles de tres proveedores, la URL sobre la que Twilio firma, y la regla del balanceador.
  • El code del error es la parte estable. Un código publicado no se renombra: se agrega uno nuevo y se deja de emitir el viejo. El message está escrito para una persona y puede cambiar de redacción sin aviso.

Errores

El sobre, idéntico en éxito y en error

{
  "data": null,                        // siempre null en un error
  "error": {
    "code": "DUPLICATE_RECIPIENT",     // SCREAMING_SNAKE, estable, 55 valores
    "message": "El destinatario del canal email se repite en el indice 4"
  },
  "meta": {
    "timestamp": "2026-09-17T16:42:11.083Z",
    "path": "/v1/notifications",
    "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"   // cítalo al reportar
  }
}

El traceId es el dato que hay que citar al abrir una incidencia: con él se encuentra el detalle interno que la respuesta no lleva. Viaja también en la cabecera traceparent.

En un 5xx el mensaje nunca lleva detalle interno Ni el error del proveedor, ni la consulta, ni la traza de pila. Eso queda en el log, indexado por trace_id. Lo que el cliente recibe es un código y una frase genérica — por diseño.

Qué significa cada familia

  • 400El cuerpo no pasó la validación, o pasó la validación pero no el negocio: VALIDATION_ERROR, CHANNEL_ADDRESS_MISSING, DUPLICATE_RECIPIENT, TEMPLATE_VERSION_UNRESOLVED, PAYLOAD_TOO_DEEP, UNSUPPORTED_IMAGE_TYPE. Incluye el campo de más que el DTO no declara.
  • 401MISSING_API_KEY, INVALID_API_KEY, INVALID_WEBHOOK_SIGNATURE. Una clave revocada y una inexistente dan lo mismo.
  • 403ACCESS_DENIED. Se usa poco: lo habitual es que un recurso ajeno responda 404.
  • 404NOTIFICATION_NOT_FOUND, REQUEST_NOT_FOUND, TEMPLATE_NOT_FOUND, APPLICATION_NOT_FOUND… «No existe» y «no es tuyo» son la misma respuesta, a propósito.
  • 409Conflicto de estado: CHANNEL_DISABLED, TEMPLATE_ALREADY_EXISTS, API_KEY_ALREADY_REVOKED, LAST_ADMIN_KEY (no se revoca la última clave activa), DEFAULT_VERSION_NOT_DELETABLE.
  • 413PAYLOAD_TOO_LARGE. El límite es 100 kB y lo aplica el parser antes de intentar leer el JSON.
  • 415UNSUPPORTED_IMAGE_TYPE al subir un archivo que no es una imagen admitida.
  • 429RATE_LIMIT_EXCEEDED. El cubo es por clave y por ruta — ver Seguridad.
  • 500INTERNAL_ERROR, SERVICE_MISCONFIGURED, WORKFLOW_REJECTED, CHANNEL_PROVIDER_SWITCH_FAILED.
  • 503SERVICE_UNAVAILABLE, MEDIA_STORAGE_UNAVAILABLE, PROVIDER_NOT_AVAILABLE. Reintentable con respaldo.

Los errores que solo aparecen en la superficie de administración

Alta de apps de Firebase y configuración de canales tienen su propio vocabulario: PROVIDER_NOT_CONFIGURED, CHANNEL_CREDENTIAL_REQUIRED, INVALID_CREDENTIAL_FORMAT, CHANNEL_OPERATION_NOT_SUPPORTED, INVALID_TEST_FIELD, TOPIC_ALREADY_EXISTS, PUSH_APP_QUOTA_EXCEEDED, INVALID_PUSH_PROFILE, y los tres del registro de Firebase: FIREBASE_APP_REGISTRY_DENIED, …_UNAVAILABLE y …_INDETERMINATE.

FIREBASE_APP_REGISTRY_INDETERMINATE no es «falló» Es «no sabemos si se creó». La API de gestión de Firebase es asíncrona y hay respuestas que no permiten concluir nada. Reintentar a ciegas puede duplicar el alta, y por eso existe un código propio en lugar de meterlo en el saco de los 5xx.

Errores que no son respuestas HTTP

Lo que falla después del 202 no puede devolverse: se escribe. Un fallo del envío aparece como evento FAILED o FAILED_PERMANENTLY en la bitácora, con error_category y error_message en la fila, y —si fue permanente— con un mensaje aparcado en la DLQ del canal. La forma de verlo es GET /v1/notifications/{id}/events, no el código de la petición original.

Seguridad e identidad

Un solo guard que despacha, y no cuatro apilados

Hay un único guard global de autenticación. Lee un metadato de la ruta y llama a uno de cuatro caminos:

ModoDecoradorQué compruebaDónde se usa
clienteninguno — es el valor por omisiónAPI key de aplicación, hasheada con SHA-256Notificaciones, media
admin@RequireAdmin()Clave de plataforma, un registro distinto/v1/admin/* y el PATCH de estado
webhook@WebhookAuth()Firma del proveedor sobre el cuerpo crudo/webhooks/*
publico@Public()Nada/health, descarga de media, esta documentación
Por qué despachar y no apilar NestJS suma los guards de método a los de clase en vez de reemplazarlos. Declarar el de cliente en la clase y el interno en el método deja el endpoint interno inalcanzable: el de cliente corre primero y rechaza la clave interna. Con un solo guard que elige, eso no puede pasar. Y el switch cierra con un caso exhaustivo que deniega en lugar de abrir.

Por omisión, protegido. Un controlador nuevo sin decorador exige clave de cliente; abrirlo es una línea explícita que se ve en la revisión. Antes era al revés —la protección se declaraba ruta por ruta— y por eso un endpoint podía nacer abierto sin que nadie lo notara. Hay una prueba que fija esa omisión.

Cómo se resuelve un app_id

  1. El guard lee X-API-KeySi falta, 401 sin más.
  2. Se hashea la clave completa con SHA-256En la base solo vive el hash. El secreto se enseña una vez, al emitirlo, y no se puede volver a consultar.
  3. Se busca en una foto en memoria del registro de clavesSíncrono, y tiene que seguir siéndolo: una consulta a la base en el camino caliente de cada petición es un coste que no se paga.
  4. El guard escribe el app_id en la peticiónLos controladores lo leen de ahí y nunca del cuerpo. Es lo que hace imposible enviar en nombre de otra aplicación.

La foto se refresca desde tres sitios: al arrancar, con un planificador periódico, y en cada mutación de la administración. Si el refresco falla, la foto anterior no se toca: un parpadeo de la base no deja al servicio sin poder autenticar a nadie. Pero hay un tope de antigüedad: pasada esa ventana sin poder releer, se rechaza toda clave.

Firmas de webhook — una por proveedor

ProveedorAlgoritmoSobre qué firmaAnti-replay
SendGridECDSA sobre la Verification Keycuerpo + timestampSí, ventana de 300 s
TwilioHMAC-SHA1la URL completa, no solo el cuerpoNo — no firma sobre timestamp
Meta (WhatsApp)HMAC-SHA256el cuerpo crudoNo
Meta — altacomparación de tokenhub.verify_token de la queryn/a

Twilio firma sobre la URL exacta, así que WEBHOOK_BASE_URL forma parte de la verificación: cambiarla rompe las firmas hasta que se actualice el panel. Y como ni Twilio ni Meta firman sobre un timestamp propio, no hay ventana anti-replay posible; la mitigación es que un estado terminal no se puede reabrir, así que reenviar un evento viejo no cambia nada.

Todos los secretos de webhook son listas, no valores: eso permite rotar con dos válidos a la vez.

Falla cerrado, y no siempre fue así Antes la verificación se saltaba cuando NODE_ENV !== 'production'. Pero dev y qa corren con NODE_ENV=dev|qa y están expuestos por balanceador: una credencial ausente degradaba a «webhook sin autenticar» en silencio, en un endpoint público. Hoy solo degrada si alguien pone explícitamente WEBHOOKS_ALLOW_UNSIGNED=true.

Rate limiting — dos capas, y una de ellas no está aquí

ÁmbitoLímite
Cloud Armor, por IP, en el borde500 / min
Por omisión en la aplicación200 / min
/v1/notifications100 / min
/v1/admin/*60 / min
POST /v1/admin/apps/{appId}/notifications10 / min
POST /v1/media-assets20 / min
Webhooks · descarga de media · /health · PATCH /statussin límite

El cubo de la aplicación se calcula por handler y por instancia, así que el techo real es el límite × endpoints × instancias máximas: sirve para frenar un cliente desbocado, no como control de capacidad. El tracker hashea la clave completa; agrupaba por los últimos ocho caracteres y bastaba conocerlos para dejar a otro inquilino en 429 con claves basura. Lo que sigue sin proteger es la fuerza bruta —cada clave inventada hashea distinto y estrena cubo—, y eso es trabajo del borde.

El resto del borde

  • Cabeceras de seguridad con helmet(): CSP, HSTS, nosniff, X-Frame-Options.
  • CORS nunca abierto: o hay lista explícita de orígenes, o queda cerrado del todo. Nunca *.
  • Límites de cuerpo antes de parsear: 100 kB de tamaño y 10 niveles de anidamiento. La profundidad se mide con los bytes en memoria y todavía sin parsear, porque JSON.parse es recursivo y con anidamiento suficiente revienta la pila antes de que nadie pueda contar nada.
  • TLS 1.2 con perfil RESTRICTED. TLS 1.3 todavía no se puede.
  • Ingress interno-LB obligatorio por política de la organización: las URLs *.run.app devuelven 404.

Datos personales

  • El destinatario se enmascara antes de persistirlo en la bitácora, y el enmascarado distingue entre una dirección y un token de push —tratarlos igual daba una respuesta silenciosamente equivocada—.
  • El cuerpo de un mensaje de Pub/Sub inválido no se registra: lleva destinatario y variables de plantilla.
  • La descarga anónima de media no registra una línea por descarga: eso lo lleva el log de acceso del balanceador.
  • Las credenciales de canal entran y no salen: la consulta expone si hay valor, no el valor.

Infraestructura y CI/CD

Terraform

Nueve módulos —networking, compute, database, pubsub, functions, storage, secrets, load-balancer, monitoring— y cuatro ambientes: bootstrap, dev, qa, prd. La versión del gate es Terraform 1.9, que no tiene por qué ser la instalada localmente.

El registro DNS no está aquí: Terraform crea la IP estática y el certificado gestionado, y el certificado se queda en PROVISIONING hasta que otro equipo da de alta el registro A.

El despliegue es automático, y el trigger es regional

Cada push a dev despliega Dispara el trigger rtp-cns en us-central1, que corre el pipeline entero — incluido terraform apply. Y ojo al buscarlo: gcloud builds triggers list y gcloud builds list a secas consultan la región global y no lo ven. Hay que pasar --region=us-central1. Más de una persona ha concluido que «no hay CI» por esto.

El pipeline, en orden

  1. Auditoría de seguridadVulnerabilidades de dependencias a nivel high, con lista de excepciones explícita.
  2. BootstrapBucket de estado, habilitación de APIs y roles de la cuenta de servicio.
  3. PruebasCobertura con umbrales, más pruebas de contrato con Testcontainers que contrastan las respuestas reales contra los YAML.
  4. Lint — cuatro gates encadenadosESLint · el gate que vigila que ningún argumento del propio pipeline pase de 10 000 caracteres (si lo pasa, el pipeline ni ejecuta su primer paso) · el gate de IAM · formato · contrato OpenAPI · arquitectura.
  5. Formato de Terraformfmt -check -recursive.
  6. Construcción y escaneoTres imágenes desde un único Dockerfile, y Trivy bloqueando en CRITICAL.
  7. FuncionesSe empaquetan, se suben y se prueban las seis.
  8. Terraforminit → validate → imports idempotentes → plan a archivo → guarda de producción → apply del plan guardado. Nunca -auto-approve sobre un plan que no se generó antes.
  9. Migraciones y plantillasLas migraciones corren en un Job de Cloud Run; las plantillas .hbs se sincronizan al bucket.
  10. Smoke testComprueba que las tres revisiones de Cloud Run llegan a Ready. No hace ninguna petición HTTP — conviene saberlo antes de confiar en que «pasó el smoke».

El gate de arquitectura

npm run arch no es una convención de revisión: bloquea el build. Lo que impone:

  • domain/ no importa de ninguna otra capa. Ni de NestJS. Es lista blanca, no negra.
  • application/ no ve infrastructure/ ni interfaces/ — solo puertos.
  • infrastructure/ no ve interfaces/.
  • Los SDK externos —TypeORM, pg, @google-cloud/*— solo dentro de adaptadores.
  • Nada fuera de infrastructure/ y de las raíces de composición puede importar un adaptador.
  • Sin ciclos.

Y los nombres son parte de la regla: un adaptador de salida termina en Repository, Gateway o Adapter. Service no es válido.

validate no valida lo que uno cree terraform validate no evalúa las validaciones de variables ni las restricciones de la API de GCP. Pasar fmt y validate no dice nada sobre si el apply va a sobrevivir: ha habido applies que murieron con un 400 después de pasar los dos. Para las validaciones de variables hace falta plan.

Observabilidad y runbook

Los logs

JSON de primer nivel con severity, para que Cloud Logging los ingiera como estructurados y no como texto. Cada línea lleva trace_id, y las del camino de notificación llevan además app_id y notification_id.

El middleware de traza se registra una sola vez. Estuvo registrado dos veces —en el arranque y en el módulo— y las dos registraciones corrían: una misma petición producía dos líneas de log y, cuando el cliente no mandaba traza, dos trace_id distintos, de los cuales solo uno coincidía con el que se devolvía en la cabecera.

Eventos de log que vale la pena buscar

EventoQué significa
pubsub_published · pubsub_publish_failedLa notificación entró (o no) a la cola
pubsub_message_invalidLlegó un mensaje que no valida. El cuerpo no se registra: lleva PII
pubsub_message_unsupported_schemaVersión de esquema que este consumidor no sabe leer
workflow_triggered · workflow_trigger_failedEl despacho arrancó (o no) una ejecución
workflow_execution_createdCloud Workflows aceptó la ejecución
webhook_status_updatedUn webhook movió el estado. Trae el estado anterior y el nuevo, ambos reales
webhook_notification_not_foundLlegó un evento que no casa con ninguna fila — normalmente falta external_msg_id
webhook_duplicate_ignored · estado_terminalReaplicación idempotente; no es un error
secret_fallback_env · secret_read_failedTienen política de alerta propia: significan que algo está leyendo una credencial de donde no debe, o no puede leerla
docs_asset_fallbackEsta página se está sirviendo desde src/ y no desde el compilado

Diagnóstico — por dónde empezar

  1. «Mandé una notificación y no llegó» Pedir el requestId. GET /v1/notifications?requestId=… da el conteo por estado del lote entero. Si están en SENT_TO_PROVIDER y el canal es correo por Gmail o es push, ese es el final normal: esos proveedores no confirman entrega.
  2. «Se quedó en QUEUED» No salió de la cola. Mirar la suscripción del despachador y su DLQ. Si hay mensajes acumulados ahí, el despacho está fallando: buscar workflow_trigger_failed.
  3. «Se quedó en PROCESSING» El workflow la tomó y murió entre el render y el adaptador. Buscar la ejecución en Cloud Workflows por el notificationId.
  4. «Se quedó en SENT_TO_PROVIDER y el proveedor sí confirma» Comprobar que la fila tiene external_msg_id: sin él, ningún webhook se aplica. Y comprobar que el webhook llega — buscar webhook_notification_not_found.
  5. «Está en FAILED_PERMANENTLY» Hay un mensaje en la DLQ del canal con errorCategory y retryCount en los atributos. La bitácora de la notificación dice el resto.

Drenar una DLQ

Se invoca dlq-reprocessor con el guion de operación. Primero en modo simulación, que enseña lo que haría con el contenido enmascarado. Luego de verdad.

Mirar antes de drenar Reprocesar republica la solicitud, así que vuelve a pasar por el consumidor — y allí estrena reclamación, porque el identificador del mensaje republicado es otro. Es decir: reprocesar sí puede volver a enviar. Si lo que falló fue el proveedor, eso es justo lo que se quiere; si lo que falló fue el destinatario, no.

Rotar una credencial de proveedor

PUT /v1/admin/channels/{canal}/credentials publica una versión nueva del secreto. Las funciones adaptadoras cachean 30 s, así que el cambio aplica en medio minuto sin desplegar nada. Para los secretos de webhook, que son listas, se puede tener dos valores válidos a la vez y rotar sin ventana de caída. Comprobar con POST …/verify, que valida contra el proveedor sin enviar, y solo después con POST …/test, que sí manda un mensaje real.

Promoción DEV → QA → PRD

Las ramas son dev → qa → main, y no se salta ninguna etapa. Cada ambiente es un proyecto de GCP distinto, con su base, sus secretos, sus topics y su balanceador.

AmbienteRamaProyectoBaseInstancias mín.Balanceador
devdevrtp-transversal-devdb-f1-micro0sí
qaqartp-transversal-qasdb-g1-small1sí
prdmainrtp-transversal-prddb-n1-standard-1 · HA · PITR1no

Lo que no se hereda y hay que dar de alta en cada ambiente

  • Las credenciales de cada canal. No se replican: cada proyecto tiene su propio Secret Manager.
  • El cliente OAuth y el buzón de Gmail, si ese es el proveedor de correo. Hoy solo dev lo tiene.
  • El proyecto de Firebase para push. Vive en un proyecto propio y no en el compartido — por eso el canal push apunta a un proyecto que no se llama como ninguno de los tres.
  • El registro DNS del dominio, que lo da de alta otro equipo.
  • Los paneles de los proveedores: la URL de webhook cambia por ambiente, y Twilio firma sobre ella.
prd no tiene balanceador, y el plan falla a propósito La variable de dominios de producción no la asigna nadie, y hay una validación que hace fallar el plan en lugar de dejar que se despliegue un ambiente sin borde. Es una barrera puesta a conciencia, no un olvido: quitarla exige decidir antes el dominio y el DNS.
Volver a dev después de promover Quedarse en la rama de qa hizo que un ticket se saltara dev sin que nada avisara. Después de promover, se vuelve.

Desarrollo local

# Arranque
npm ci
npm run env:init          # genera el .env local
docker compose up -d      # PostgreSQL + emulador de Pub/Sub
npm run migration:run
npm run seed
npm run start:dev         # http://localhost:3000 — y esta página en /

# Los gates, los mismos que corren en CI
npm run lint              # eslint + gate del pipeline + gate de IAM
npm run format:check
npm run arch              # capas hexagonales — BLOQUEANTE
npm run openapi:check     # el contrato commiteado vs. el regenerado
npm run test:cov
npm run test:contract     # respuestas reales contra openapi/ (Testcontainers)

# Al cambiar un DTO de interfaces/
npm run openapi:generate  # y commitear los dos YAML
Con el emulador, el orquestador nunca se ejercita La puerta de enlace de Cloud Workflows se salta la llamada cuando detecta el emulador. Es decir: en local se puede ver la notificación escribirse, publicarse y consumirse, pero el render y el envío no ocurren. Una prueba local en verde no dice nada sobre la mitad asíncrona del sistema; para eso hay que desplegar a dev.
docker compose aborta sin .env, y es a propósito Antes arrancaba con valores por omisión, y uno de ellos era una credencial de administración escrita en el repositorio.

Convenciones

QuéCómo
Ramasdev → qa → main, sin saltarse etapas
CommitsConventional Commits, con el ticket al final
Archivoskebab-case
ClasesPascalCase; adaptadores terminan en Repository, Gateway o Adapter
Base de datossnake_case, y siempre por migración explícita
HTTP salienteTodo con timeout explícito
CoberturaUmbrales por sentencias, ramas, funciones y líneas; el gate los aplica

Dónde poner cada cosa

  • Un endpoint nuevo → controlador en interfaces/ + DTO en interfaces/dto/, y regenerar el contrato.
  • Una regla de negocio → caso de uso en application/, que solo habla con puertos.
  • Un servicio externo nuevo → puerto en domain/ports/ + adaptador en infrastructure/ + binding en el módulo raíz.
  • Una excepción de negocio → error de dominio con su code. Nunca una excepción HTTP desde domain/: el filtro global la traduce.

Pendientes y brechas

Lo que hoy no está, dicho con claridad. Una documentación que solo cuenta lo que funciona es una documentación en la que no se puede confiar cuando algo falla.

  • bloqueanteprd no tiene balanceador. Sin dominio asignado, y el plan falla a propósito. Hasta resolverlo, producción no se puede desplegar.
  • abiertoSendGrid responde 401 en dev. La credencial es un marcador de posición y el remitente configurado es una dirección inválida. Por eso dev entrega hoy por Gmail y no por el proveedor por omisión.
  • brechaNo hay patrón outbox. Entre escribir la fila y publicar en Pub/Sub no hay transacción; si el proceso muere justo en medio, queda una notificación en QUEUED que nadie va a despachar. Hoy se detecta mirando el resumen del lote, no automáticamente.
  • brechaEl rate limiting debería estar en el borde. El de la aplicación es por instancia, así que el techo real escala con el número de instancias. La regla por IP de Cloud Armor cubre la parte que importa, pero el control por cliente sigue dentro.
  • desviación/webhooks no está versionado. Consciente y pendiente de decisión: versionarlo obliga a coordinar tres paneles de proveedor, la URL sobre la que Twilio firma, y la regla del balanceador — que si deja de casar, manda el tráfico al backend equivocado y responde 404 en silencio, solo en producción.
  • desviaciónUn solo topic genérico de ingesta en vez de uno por evento de dominio. Ver Mensajería.
  • por diseñoEl reprocesador de DLQ no tiene scheduler. Se invoca a mano. Drenar una cola de fallos automáticamente es una decisión que alguien debe tomar cada vez.
  • por diseñoEl smoke test no hace ninguna petición HTTP. Solo comprueba que las revisiones de Cloud Run llegan a Ready.
  • aclaraciónEl SSO corporativo no está en este servicio. La superficie de administración se autentica con clave interna hasheada. El SSO es un requisito del portal que la consume, no de aquí.
  • deudaEl README.md tiene dos afirmaciones desfasadas sobre las rutas de administración y sobre los proveedores de correo. Si contradice a esta página, gana esta página; y si contradice al código, gana el código.

Referencias

Decisiones de arquitectura (ADR)

ADRDecisiónEstado
001Retirar el endpoint POST /webhooks/fcmaceptada
002Estructura hexagonal objetivo y criterio de «listo» por móduloaceptada
003Mecanismo de mensajería y patrón de saga — el despacho pasa de cola a HTTPaceptada
004Autenticación por omisión en el bordeaceptada
005Rate limiting en dos capasaceptada
006Contrato del canal pushaceptada
007Convención de nombres de Pub/Subaceptada
008TLS en el borde: perfil restringido, y por qué TLS 1.3 todavía noaceptada
009API key entre servicios, no OAuth2/OIDCaceptada
010Gmail API como proveedor alternativo del canal de correoaceptada
011Firebase sobre el proyecto compartidorechazada
012Firebase en un proyecto propio para dev y qa — sustituye a la 011aceptada
013Carga de imágenes de notificación: un bucket con caducidadaceptada
014Las imágenes salen por el borde propio, y el bucket deja de ser públicoaceptada
015El alta de apps cliente en Firebase la hace el CNS por API, en ejecuciónaceptada
016El portal envía por una ruta de administración propia, no con claves de aplicaciónaceptada

Estándares del área — la fuente de verdad normativa

Seis documentos y 53 cláusulas en docs/estandares/, propiedad de Arquitectura TI. Al citar una cláusula se lee de ahí, nunca de memoria.

CódigoDocumentoPeso en este servicio
ARQArquitectura de microserviciosAlto — define la estructura hexagonal y el nombrado de adaptadores
MSGMensajería y eventosEl más pesado. Sobre de evento, idempotencia, DLQ, traza en atributos
SINAPIs síncronasAlto — versionado, paginación por cursor, autenticación en el borde
TRPTransporte y comunicaciónMedio — TLS, timeouts
FMTFormatos y serializaciónMedio — camelCase, ISO 8601 en UTC, límites de cuerpo
IAIntegración con IA y LLMNo aplica: este servicio no usa modelos

Documentos del repositorio

TemaDónde
Estado, contexto de negocio y decisiones vivasSOUL.md · NEXT_STEPS.md
Instalación y despliegueINSTALL.md · PIPELINE.md
Guía para agentes y convencionesCLAUDE.md
Conformidad con los estándares y su backlogdocs/conformidad-estandares-rtp-cns.md · docs/BACKLOG-rtp-cns.md
Integración del canal pushdocs/onboarding-apps-push-firebase.md · docs/brechas-canal-push.md · docs/diagramas/canal-push-firebase.html
Seguridaddocs/respuesta-escaneo-scc-2026-08.md
Traspaso al portal de administracióndocs/traspaso-ae310-rtp-admin.md
Contratos generadosopenapi/openapi.client.yaml · openapi/openapi.admin.yaml
Esta páginasrc/interfaces/docs/public/index.html

Servicios vecinos

ServicioRelaciónDocumentación
rtp-csb — Core Service BusLa capa de integración event-driven del área. Comparte proyecto de GCP y patronescsb-dev.rotoplas.com/api/docs
rtp-admin — ZeusEl portal que consume la superficie de administración de este servicioen su propio repositorio