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¶
- 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.
- 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:
- Dashboard (
http://192.168.0.201:4000) → Workflows → crear con paso del canal deseado, usando{{payload.xxx}}en las plantillas. - ⚠️ Novu genera el identifier con sufijo aleatorio (ej:
oms-in-app-11bs4j37). Copiar el identifier real y pasarlo por env var (patrón deNOVU_IN_APP_WORKFLOW_IDennovu.config.ts). - 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
}
urlrelativa 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).