services/api/src/modules/notifications/notification.service.js

Resumen

Este archivo implementa la entrega de entradas del outbox de notificaciones por correo. Su responsabilidad principal es tomar un outboxEntry, construir el mensaje con buildNotificationEmail, resolver el destinatario real desde User, enviarlo mediante sendNotificationEmail y actualizar el estado persistente de la entrada. El flujo está diseñado para registrar fallos sin romper la operación de dominio que originó la notificación.

El módulo forma parte del subsistema de notificaciones y trabaja junto con notification.templates.js, notification.client.js y notification.repository.js. En el repositorio, la persistencia del outbox se mantiene separada de la entrega; ese comportamiento ya aparece reflejado en el repositorio y en el emisor de eventos de notificación .

[!NOTE] El envío solo acepta el canal primary_email y requiere recipientUserId. Si esa condición no se cumple, la entrega falla con UNSUPPORTED_RECIPIENT_CHANNEL.

Cómo funciona

flowchart TD
    A[deliverOutboxEntry] --> B[buildNotificationEmail]
    B -->|sin mensaje| C[Return skipped]
    B -->|mensaje| D[markOutboxEntrySending]
    D --> E[resolveRecipient]
    E --> F[User findById]
    F --> G[sendNotificationEmail]
    G -->|error| H[markOutboxEntryFailed]
    H --> I[safeLog]
    G -->|ok| J[markOutboxEntrySent]
    J --> K[Return delivered]
    H --> L[Return errorCode]

El flujo empieza con la construcción del correo usando eventType y payload. Si la plantilla no devuelve un mensaje, la función sale con skipped: true. Si sí existe el mensaje, marca la entrada como sending, busca el email del usuario, envía el correo con una clave de idempotencia basada en el _id del outbox y, al final, marca la entrada como sent.

Si ocurre cualquier error, el módulo normaliza el código, intenta marcar la entrada como failed y registra el problema con un log estructurado. Si la actualización de estado también falla, el log cambia a nivel warn con el código OUTBOX_STATUS_UPDATE_FAILED.

Componentes clave

{
    "title": "deliverOutboxEntry",
    "content": "[badge:public] Orquesta la entrega completa de una entrada del outbox. Construye el correo, resuelve el destinatario, llama al proveedor y persiste el resultado final.",
    "type": "function",
    "filePath": "services/api/src/modules/notifications/notification.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "resolveRecipient",
    "content": "Valida el canal soportado y busca el email del usuario destinatario con `User.findById(...).select('email').lean()`. Devuelve solo la direcci\u00f3n de correo.",
    "type": "function",
    "filePath": "services/api/src/modules/notifications/notification.service.js",
    "badges": []
}
{
    "title": "safeErrorCode",
    "content": "Normaliza los errores de entrega. Solo acepta c\u00f3digos con el patr\u00f3n `A-Z0-9_` y longitud v\u00e1lida. Si no, devuelve `EMAIL_DELIVERY_FAILED`.",
    "type": "function",
    "filePath": "services/api/src/modules/notifications/notification.service.js",
    "badges": []
}
{
    "title": "safeLog",
    "content": "Escribe logs estructurados del subsistema de notificaciones. Usa `console.error` para errores y `console.warn` para advertencias.",
    "type": "function",
    "filePath": "services/api/src/modules/notifications/notification.service.js",
    "badges": []
}

Datos y campos

[
    {
        "name": "outboxEntry",
        "type": "object",
        "required": true,
        "description": "Entrada persistida del outbox que se intenta entregar.",
        "children": [
            {
                "name": "_id",
                "type": "ObjectId|string",
                "required": true,
                "description": "Identificador de la entrada. Se usa como `outboxId` y como `idempotencyKey` del proveedor."
            },
            {
                "name": "eventType",
                "type": "string",
                "required": true,
                "description": "Tipo de evento de notificación. Se pasa a `buildNotificationEmail`."
            },
            {
                "name": "recipientChannel",
                "type": "string",
                "required": true,
                "description": "Canal del destinatario. Este servicio solo acepta `primary_email`."
            },
            {
                "name": "recipientUserId",
                "type": "string|null",
                "description": "Identificador del usuario destinatario. Se usa para resolver el email."
            },
            {
                "name": "payload",
                "type": "object",
                "description": "Datos del evento que alimentan la plantilla del correo."
            }
        ]
    }
]
[
    {
        "name": "deliverOutboxEntry result",
        "type": "object",
        "required": true,
        "description": "Resultado devuelto por `deliverOutboxEntry`.",
        "children": [
            {
                "name": "delivered",
                "type": "boolean",
                "required": true,
                "description": "Indica si el envío terminó correctamente."
            },
            {
                "name": "skipped",
                "type": "boolean",
                "description": "Aparece cuando `buildNotificationEmail` no devuelve un mensaje."
            },
            {
                "name": "errorCode",
                "type": "string",
                "description": "Código de error normalizado cuando la entrega falla."
            },
            {
                "name": "providerMessageId",
                "type": "string|null",
                "description": "Identificador devuelto por el proveedor de email cuando el envío fue aceptado."
            }
        ]
    }
]

Comportamientos

  • safeErrorCode(error) — Devuelve un código seguro para persistencia y logging, o EMAIL_DELIVERY_FAILED.
  • safeLog(level, code, outboxEntry) — Registra un evento de notificación con code, eventType y outboxId.
  • resolveRecipient(outboxEntry) — Valida el canal, busca el usuario y devuelve user.email.
  • deliverOutboxEntry(outboxEntry) — Ejecuta todo el ciclo de entrega y devuelve el resultado final.

[!IMPORTANT] deliverOutboxEntry usa el _id del outbox como idempotencyKey al enviar el correo. Ese valor sale del propio registro persistido.

Riesgos y casos límite

[!WARNING] Si recipientChannel no es primary_email, la función lanza un error con código UNSUPPORTED_RECIPIENT_CHANNEL.

[!WARNING] Si recipientUserId existe pero el usuario no tiene email, la función lanza NOTIFICATION_RECIPIENT_NOT_FOUND.

[!CAUTION] Si buildNotificationEmail devuelve un valor falsy, la entrega no continúa. La función retorna { delivered: false, skipped: true }.

[!NOTE] Si fallan las actualizaciones de estado del outbox, el error no interrumpe el retorno principal. El módulo solo deja un log con OUTBOX_STATUS_UPDATE_FAILED.

[!IMPORTANT] Los códigos de error persistidos pasan por safeErrorCode. Eso evita que códigos arbitrarios o malformados lleguen al outbox.

Relacionado

  • services/api/src/modules/notifications/notification.client.js envía el correo real mediante Resend y valida la configuración del proveedor. También expone overrides de prueba .
  • services/api/src/modules/notifications/notification.templates.js construye el correo a partir de eventType y payload, y resuelve la URL del logo de email .
  • services/api/src/modules/notifications/notification.repository.js persiste los cambios de estado del outbox: sending, sent y failed .
  • services/api/src/modules/notifications/notification.events.js registra eventos de dominio en el outbox y luego intenta la entrega sincrónica .
  • services/api/src/models/User.js aporta la búsqueda del destinatario por recipientUserId, ya que este servicio lee el email desde ese modelo.