Skip to content

Notifications

Sistema centralizado de notificaciones in-app para todos los portales de NappAI Fluency. Características clave:

  • 79 tipos definidos en notification-type.config.ts, cada uno con categoría, icono, severidad y canales predeterminados.
  • Deduplicación automática en ventana de 24 horas: mismo userId + type + metadata no genera duplicados.
  • Multi-canal: in_app (siempre), email (vía Resend), push (para futuras apps móviles).
  • Preferencias por usuario: el usuario puede silenciar tipos disableable: true.
  • Severidad critical: bypasea siempre las preferencias del usuario.
  • Push en tiempo real vía WebSocket (gateway en notifications.gateway.ts).
  • Notificaciones a rol: sendToRole() envía a todos los usuarios de un rol (capped a 50).

La infraestructura vive en apps/api/src/modules/notifications/. NotificationsModule exporta NotificationService para que cualquier módulo pueda enviar notificaciones.


flowchart TD
A[Domain Service / Job] -->|send / sendToRole| B[NotificationService]
B --> C{Deduplicación 24h}
C -->|Duplicado| D[Retorna existing]
C -->|Nuevo| E[INSERT Notification]
E --> F[EventEmitter → notification.created]
E --> G{channels.includes email?}
F --> H[NotificationsGateway → WebSocket push]
G -->|Sí| I{severity critical?}
I -->|No| J{user.notificationPrefs.emailEnabled?}
J -->|true| K[Resend → email]
I -->|Sí| K
J -->|false| L[Skip email]

Archivo: apps/api/src/modules/notifications/notification.service.ts

Método principal. Crea una notificación, aplica deduplicación y dispara los canales configurados (WebSocket, email).

async send(params: CreateNotificationParams): Promise<NotificationRecord>

Parámetros de CreateNotificationParams:

ParámetroTipoRequeridoDescripción
userIdstringID del usuario destinatario
typeNotificationTypeTipo de la notificación — ver notification-type.config.ts
titlestringTítulo corto (se muestra en la campana)
bodystringTexto descriptivo de la notificación
actionUrlstringRuta relativa o URL absoluta del CTA
actionLabelstringNoTexto del botón CTA (default: “Ver en NappAI Fluency”)
metadataRecord<string, unknown>NoDatos extra; forma parte de la clave de deduplicación

Envía una notificación a todos los usuarios de un rol (máximo 50, por seguridad).

async sendToRole(
role: string,
params: Omit<CreateNotificationParams, 'userId'>,
): Promise<void>

Otros métodos disponibles (para el propio controller de notificaciones):

MétodoDescripción
findForUser(userId, query)Lista paginada para un usuario, filtrable por category e isRead
getUnreadCount(userId)Número de notificaciones no leídas
markAsRead(id, userId)Marca una notificación como leída (verifica ownership)
markAllRead(userId)Marca todas como leídas
markCategoryRead(userId, category)Marca toda una categoría como leída
deleteNotification(id, userId)Elimina (solo info/success — no critical/warning)
getPreferences(userId)Devuelve preferencias de notificación del usuario
updatePreferences(userId, prefs)Actualiza (merge) preferencias del usuario
getStats()Estadísticas del día (solo SUPER_ADMIN)

mi-feature.module.ts
import { NotificationsModule } from '../notifications/notification.module'
@Module({
imports: [NotificationsModule],
})
export class MiFeatureModule {}

2. Inyectar y llamar — siempre fire-and-forget

Section titled “2. Inyectar y llamar — siempre fire-and-forget”
@Injectable()
export class MiFeatureService {
constructor(private readonly notificationService: NotificationService) {}
async onAlgoImportante(userId: string, quiz: Quiz) {
// Fire-and-forget — NUNCA await sin .catch() — no debe bloquear el flujo principal
this.notificationService.send({
userId,
type: 'QUIZ_RETRY_AVAILABLE',
title: '¡Puedes reintentar el quiz!',
body: `Ya puedes volver a intentar "${quiz.title}".`,
actionUrl: `/learn/quiz/${quiz.id}`,
actionLabel: 'Ir al quiz',
metadata: { quizId: quiz.id }, // ← incluir id para deduplicación correcta
}).catch(() => {})
}
}

3. Notificación a todos los usuarios de un rol

Section titled “3. Notificación a todos los usuarios de un rol”
// Alertar a todos los SUPER_ADMIN de un evento de plataforma
await this.notificationService.sendToRole('SUPER_ADMIN', {
type: 'PLATFORM_HEALTH_DEGRADED',
title: 'Degradación de la plataforma detectada',
body: `Métrica "http_error_rate": valor 0.12 supera el umbral de 0.05.`,
actionUrl: '/admin/platform/health',
actionLabel: 'Ver estado del sistema',
metadata: { alertType: 'http_error_rate', currentValue: 0.12 },
})

La ventana es de 24 horas. Antes de crear una notificación, el servicio busca una existente con el mismo userId + type + metadata. Si existe, la devuelve sin crear duplicado.

const DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000 // 24 horas

Implicación práctica: si el mismo evento puede ocurrir varias veces en un día (por ejemplo, el mismo quiz reintentado varias veces), incluir en metadata el identificador que hace única cada instancia:

// Incorrecto — todas las notificaciones de quiz del día se deduplicarán en una sola
metadata: { quizId: quiz.id }
// Correcto — cada intento genera su propia notificación
metadata: { quizId: quiz.id, attemptNumber: attempt.number }

Severidad¿Respeta emailEnabled?Ejemplos
infoProgreso, recordatorios, recomendaciones
successCertificados, badges, hitos
warningRenovación próxima, racha en riesgo
criticalNo — siempre se envíaAlertas de seguridad, pagos fallidos, trials expirados

Los tipos con disableable: false en la config siempre se crean en BD, independientemente de preferencias.


Hay 79 tipos configurados en notification-type.config.ts, organizados por categoría:

STREAK_RISK, STREAK_MILESTONE, STREAK_LOST, LESSON_COMPLETED, MODULE_COMPLETED, QUIZ_EVALUATED, TUTOR_RESPONSE, ADAPTIVE_RECOMMENDATION, WELCOME_BACK, CORP_NUDGE, PATH_ENROLLED, FLUENCY_TEST_RESULT, INACTIVITY_NUDGE, COURSE_DEADLINE_REMINDER, QUIZ_RETRY_AVAILABLE

BADGE_EARNED, CERTIFICATE_EARNED, STREAK_MILESTONE

NEW_CONTENT, CONTENT_UPDATED, NEW_CONTENT_RELEVANT, GUIDELINE_UPDATED

PATH_REVIEW_SUBMITTED, PATH_REVIEW_APPROVED, PATH_REVIEW_REJECTED, PATH_REVIEW_FEEDBACK, NEW_ENROLLMENT, NEW_REVIEW, MILESTONE_ENROLLMENTS, EARNINGS_PAYOUT, BADGE_EARNED_CREATOR, CREATOR_APPLICATION_APPROVED, CREATOR_APPLICATION_REJECTED, PATH_UNPUBLISHED, EARNINGS_MILESTONE, CREATOR_WEEKLY_STATS, GUIDELINE_VIOLATION_WARNING, CASE_STUDY_EVALUATED

MEMBER_CHURN_RISK, MEMBER_CERTIFICATE, MEMBER_ENROLLED, MEMBER_COMPLETED_PATH, SEATS_THRESHOLD, WEEKLY_DIGEST, MONTHLY_REPORT, SEAT_AUTO_RECLAIMED, CORP_PATH_ASSIGNED, MEMBER_INACTIVE_LONG, MEMBER_JOINED, BULK_IMPORT_COMPLETED, TEAM_MILESTONE

PLAN_RENEWAL, PLAN_EXPIRED, INVOICE_GENERATED, AI_PROVIDER_DOWN, AI_HIGH_ERROR_RATE, AI_COST_THRESHOLD, AI_LATENCY_DEGRADED, QDRANT_UNAVAILABLE, CREDENTIAL_EXPIRING, NEW_INSTRUCTOR_APPLICATION, PATH_PENDING_REVIEW, USER_MILESTONE, TRIAL_EXPIRING, TRIAL_EXPIRED, SSO_CONFIG_ERROR, INVOICE_READY, NEW_ORG_PROVISIONED, ORG_TRIAL_EXPIRING, ORG_CHURNED, PAYMENT_FAILED, SECURITY_ALERT, PATH_REVIEW_OVERDUE, ABUSE_REPORT, PLATFORM_HEALTH_DEGRADED, CREATOR_CONTENT_SPIKE

AI_TASK_COMPLETED, AI_TASK_FAILED, AI_TASK_SYSTEM


Paso 1 — Definir en notification-type.config.ts

Section titled “Paso 1 — Definir en notification-type.config.ts”
apps/api/src/modules/notifications/notification-type.config.ts
NUEVO_TIPO: {
category: 'LEARNING', // LEARNING | ACHIEVEMENT | CONTENT | CREATOR | TEAM | SYSTEM | AI_TASK
icon: '🎯',
iconBg: 'var(--green-bg)',
severity: 'info', // info | success | warning | critical
channels: ['in_app', 'email'], // subconjunto de: in_app, email, push
disableable: true, // false = siempre se envía
},

Paso 2 — Añadir al enum en schema.prisma y al union type en shared-types

Section titled “Paso 2 — Añadir al enum en schema.prisma y al union type en shared-types”
// apps/api/prisma/schema.prisma — enum NotificationType
enum NotificationType {
// ...tipos existentes...
NUEVO_TIPO
}

Después del cambio en el schema, aplicar con:

Terminal window
cd apps/api && pnpm prisma db push
packages/shared-types/src/notification.types.ts
export type NotificationType =
| 'TIPO_EXISTENTE_1'
| 'TIPO_EXISTENTE_2'
| 'NUEVO_TIPO' // ← añadir aquí

Eso es todo. El servicio ya maneja deduplicación, canales, WebSocket y email de forma transparente.


Notificaciones programadas vs. event-triggered

Section titled “Notificaciones programadas vs. event-triggered”
CriterioUsar cron jobUsar send() directo
Consulta muchos usuarios a la vezSi
Responde a una acción del usuarioSi
Depende de tiempo transcurridoSi
Inmediata en el mismo requestSi

Los cron jobs de notificaciones van en apps/api/src/modules/notifications/jobs/ y deben seguir el patrón de JobExecutionTrackerService. Ver Scheduled Jobs.


GET /api/notifications JwtAuthGuard Lista paginada del usuario actual (filtros: category, isRead)
GET /api/notifications/unread-count JwtAuthGuard Número de notificaciones no leídas
PATCH /api/notifications/:id/read JwtAuthGuard Marca una notificación como leída
POST /api/notifications/mark-all-read JwtAuthGuard Marca todas como leídas
POST /api/notifications/mark-category-read JwtAuthGuard Marca toda una categoría como leída
DELETE /api/notifications/:id JwtAuthGuard Elimina (solo info/success — no critical/warning)
GET /api/notifications/preferences JwtAuthGuard Preferencias de notificación del usuario
PATCH /api/notifications/preferences JwtAuthGuard Actualiza preferencias (merge)
GET /api/notifications/stats SUPER_ADMIN Estadísticas del día por categoría y canal

CampoTipoDescripción
idString (cuid)PK
userIdStringFK al usuario destinatario
typeNotificationTypeTipo de la notificación
categoryNotificationCategoryCategoría derivada del config
titleStringTítulo de la notificación
bodyStringCuerpo descriptivo
actionUrlStringURL del CTA
actionLabelString?Texto del botón CTA
iconStringEmoji del icono (del config)
iconBgStringCSS var del fondo del icono
severityStringinfo | success | warning | critical
isReadBooleanSi ha sido leída (default false)
readAtDateTime?Timestamp de lectura
channelsString[]Canales usados (in_app, email, push)
metadataJson?Datos extra del evento (parte de la clave de dedup)
emailSentAtDateTime?Timestamp de entrega de email
pushSentAtDateTime?Timestamp de entrega push
createdAtDateTimeTimestamp de creación

Índices: [userId, isRead, createdAt] y [userId, category, createdAt] para queries del panel de notificaciones.