Saltar a contenido

Guía de desarrollo — Enviar notificaciones con Novu

Cómo agregar una nueva notificación al OMS, paso a paso. Para el contexto general (qué es Novu, arquitectura, setup del ambiente) ver README.md.

Mapa mental en 30 segundos

Algo pasa en el negocio (se crea un pedido, se aprueba un cobro...)
    │
    ▼
El servicio de dominio emite un evento de aplicación        eventEmitter.emit('order.created', {...})
    │
    ▼
Un listener en src/notifications/ lo escucha                @OnEvent('order.created')
    │
    ▼
Llama a NovuService.notifyUser(destinatario, contenido)     título + cuerpo + url
    │
    ▼
Novu guarda la notificación y la empuja al Inbox            campana del header, persistente

Regla de oro: el servicio de dominio NO llama a Novu directamente. Emite un evento con EventEmitter2 y un listener en src/notifications/ decide a quién y cómo notificar. Así el flujo de negocio nunca depende de (ni se rompe por) las notificaciones.

Receta 1 — Notificar algo que ya tiene evento

Si el evento ya se emite (ver inventario en roadmap-features.md), solo hay que crear el listener. Plantilla real: backend/src/notifications/order-notifications.listener.ts.

// backend/src/notifications/mi-feature-notifications.listener.ts
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { NovuService } from '../services/novu/novu.service';

@Injectable()
export class MiFeatureNotificationsListener {
    private readonly logger = new Logger(MiFeatureNotificationsListener.name);

    constructor(private readonly novuService: NovuService) { }

    @OnEvent('mi_feature.algo_paso')
    async handleAlgoPaso(event: MiEvento) {
        if (event.salesPersonCode == null) return; // sin destinatario, no hay notificación

        await this.novuService.notifyUser(
            { subscriberId: event.salesPersonCode },   // SIEMPRE salesPersonCode
            {
                title: 'Título corto',                  // lo que se ve en negrita en la campana
                body: `Texto con datos: ${event.docNum}...`,
                url: `/ruta/del/frontend/${event.docEntry}`, // adónde navega el click (opcional)
            },
            { event: 'mi_evento', docEntry: event.docEntry }, // payload extra (opcional)
        );
    }
}

Registrarlo en backend/src/notifications/notifications.module.ts (array providers). Nada más: NovuModule ya está importado ahí.

Receta 2 — Notificar algo que todavía no emite evento

  1. En el servicio de dominio, después de que la operación se confirme en SAP, emitir el evento enriquecido con todo lo que la notificación va a necesitar (el listener no debe volver a consultar SAP):
// dentro del service, tras la respuesta exitosa de SAP
this.eventEmitter.emit('factura.creada', {
    docEntry: created.DocEntry,
    docNum: created.DocNum,
    cardName: created.CardName,
    salesPersonCode: created.SalesPersonCode, // el destinatario
});

EventEmitter2 ya está global (app.module.ts → EventEmitterModule.forRoot()); solo inyectar private readonly eventEmitter: EventEmitter2.

  1. Crear el listener (Receta 1).

Ejemplo completo de referencia: el evento order.created en orders.service.ts (emisión enriquecida) + order-notifications.listener.ts (consumo).

Receta 3 — ¿A quién le llega? (destinatarios)

Caso Cómo Estado
A un usuario puntual notifyUser({ subscriberId: salesPersonCode }, ...) ✅ Disponible
Al vendedor dueño de un documento Usar el SalesPersonCode del documento SAP, no el del creador (ver order-notifications.listener.ts) ✅ Disponible
A todos los de un rol (ej: Autorizadores) Topics de Novu: el backend suscribe subscribers a un topic y dispara al topic 🔜 No implementado — ver roadmap

El subscriberId es siempre el salesPersonCode (como string). Es la misma identidad de las salas user-{salesPersonCode} del gateway de Socket.io. No usar userId, employeeId ni el id de OIDC. Novu crea/actualiza el subscriber automáticamente en cada trigger — no hay que "registrar" usuarios.

Receta 4 — ¿Necesito un workflow nuevo?

Casi nunca. El workflow genérico oms-in-app (env: NOVU_IN_APP_WORKFLOW_ID) renderiza cualquier title/body/url, así que todas las notificaciones de Inbox pasan por ahí y se distinguen por contenido.

Crear un workflow nuevo solo cuando cambie el comportamiento, no el texto: otro canal (email, push), digest/agrupación, o preferencias de usuario por tipo de notificación. En ese caso:

  1. Dashboard (http://192.168.0.201:4000) → Workflows → crear con paso del canal deseado, usando {{payload.xxx}} en las plantillas.
  2. ⚠️ Novu genera el identifier con sufijo aleatorio (ej: oms-in-app-11bs4j37). Copiar el identifier real y pasarlo por env var (patrón de NOVU_IN_APP_WORKFLOW_ID en novu.config.ts).
  3. Disparar con novuService.trigger(workflowId, recipient, payload) (el método genérico).

Convenciones de payload

{
  "title": "Pedido creado",              // corto, sin datos variables si es posible
  "body": "Pedido #123 creado para \"Cliente X\" por GS 1.500.000.",
  "url": "/orders/456",                  // ruta RELATIVA del frontend (el Inbox usa router.push)
  "event": "order_created",              // slug del evento, para filtros/análisis futuros
  "docEntry": 456, "docNum": 123         // referencias SAP para trazabilidad
}
  • url relativa siempre (/orders/456), nunca absoluta — funciona en cualquier ambiente.
  • Montos: formatear en el listener (toLocaleString('es-PY')), el template no formatea.
  • Texto en español, tuteo neutro, igual que los toasts existentes.

Cómo probar

# 1. Endpoint de prueba (requiere JWT — botón Authorize en /api-docs)
curl -X POST http://192.168.0.220:7000/api/notifications/novu-test \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title": "Test", "body": "Hola"}'

# 2. Ver el feed de un subscriber directo en Novu (sin JWT del OMS)
curl -H "Authorization: ApiKey $NOVU_SECRET_KEY" \
  "http://192.168.0.201:3100/v1/subscribers/101/notifications/feed?limit=5"

# 3. Disparar un trigger a mano (bypass del backend, para aislar problemas)
curl -X POST http://192.168.0.201:3100/v1/events/trigger \
  -H "Authorization: ApiKey $NOVU_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{"name": "oms-in-app-11bs4j37", "to": {"subscriberId": "101"}, "payload": {"title": "T", "body": "B"}}'

Con eso se aísla cualquier problema: si (3) funciona pero (1) no → el problema es del backend; si (3) llega al feed pero no a la campana → el problema es del frontend.

Troubleshooting (errores que ya nos pasaron)

Síntoma Causa Fix
Trigger processed pero no aparece notificación El workflowId no existe (Novu descarta en silencio) Verificar identifier real en Dashboard → Workflows; recordar el sufijo aleatorio
Campana siempre vacía, sin errores NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER no corresponde al environment del servidor (quedó de una instalación anterior) Comparar con Dashboard → API Keys, o GET /v1/environments
Campana vacía tras cambiar .env Backend/frontend leen .env solo al arrancar Reiniciar el proceso (el watch de nest NO recarga .env)
El Inbox apunta a api.novu.co Faltan NEXT_PUBLIC_NOVU_BACKEND_URL / SOCKET_URL Setearlas (y propagarlas en Dockerfile + build-and-push.sh)
500 "Origen no permitido por CORS" al probar por Swagger El origen del Swagger no está en CORS_ALLOWED_ORIGINS Agregarlo al .env del backend
Notificación llega a otro usuario / a nadie subscriberId distinto de salesPersonCode Revisar Receta 3
NovuService dice "deshabilitado" al arrancar Faltan NOVU_API_URL / NOVU_SECRET_KEY Completar .env; el backend arranca igual a propósito

Variables de entorno (referencia)

Dónde Variable Valor en pruebas
backend/.env NOVU_API_URL http://192.168.0.201:3100
backend/.env NOVU_SECRET_KEY Dashboard → API Keys
backend/.env NOVU_IN_APP_WORKFLOW_ID oms-in-app-11bs4j37
frontend/.env NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER VOHKCgrgMUn2 (Dashboard → API Keys)
frontend/.env NEXT_PUBLIC_NOVU_BACKEND_URL http://192.168.0.201:3100
frontend/.env NEXT_PUBLIC_NOVU_SOCKET_URL http://192.168.0.201:3002

Al llevar a staging/producción: los valores cambian por ambiente y las NEXT_PUBLIC_* deben estar en el .env.build-* + Dockerfile + build-and-push.sh (ya están declaradas en los tres).