# Guía de Implementación: Sistema de Notificaciones **Versión:** 1.0.0 **Tiempo estimado:** 4-8 horas **Complejidad:** Alta --- ## Pre-requisitos - [ ] NestJS con TypeORM configurado - [ ] PostgreSQL - [ ] Cuenta SMTP o SendGrid (para email) - [ ] Claves VAPID (para push) --- ## Paso 1: Instalar Dependencias ```bash # Core npm install nodemailer npm install -D @types/nodemailer # Push notifications npm install web-push npm install -D @types/web-push # TypeORM (si no está instalado) npm install typeorm @nestjs/typeorm ``` --- ## Paso 2: Crear DDL ### 2.1 Schema y tabla principal ```sql CREATE SCHEMA IF NOT EXISTS notifications; -- Tabla principal de notificaciones CREATE TABLE notifications.notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, type VARCHAR(50) NOT NULL, title VARCHAR(255) NOT NULL, message TEXT NOT NULL, data JSONB DEFAULT '{}', priority VARCHAR(20) DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent')), channels VARCHAR(20)[] DEFAULT ARRAY['in_app'], status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'read', 'failed')), read_at TIMESTAMPTZ, sent_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ, metadata JSONB DEFAULT '{}' ); CREATE INDEX idx_notifications_user_id ON notifications.notifications(user_id); CREATE INDEX idx_notifications_type ON notifications.notifications(type); CREATE INDEX idx_notifications_status ON notifications.notifications(status); CREATE INDEX idx_notifications_created_at ON notifications.notifications(created_at); ``` ### 2.2 Preferencias ```sql CREATE TABLE notifications.notification_preferences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, notification_type VARCHAR(50) NOT NULL, in_app_enabled BOOLEAN DEFAULT true, email_enabled BOOLEAN DEFAULT true, push_enabled BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, notification_type) ); CREATE INDEX idx_notification_preferences_user ON notifications.notification_preferences(user_id); ``` ### 2.3 Cola de procesamiento ```sql CREATE TABLE notifications.notification_queue ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), notification_id UUID NOT NULL REFERENCES notifications.notifications(id) ON DELETE CASCADE, channel VARCHAR(20) NOT NULL CHECK (channel IN ('in_app', 'email', 'push')), status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')), attempts INTEGER DEFAULT 0, max_attempts INTEGER DEFAULT 3, last_attempt_at TIMESTAMPTZ, next_attempt_at TIMESTAMPTZ DEFAULT NOW(), error_message TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_notification_queue_status ON notifications.notification_queue(status); CREATE INDEX idx_notification_queue_next_attempt ON notifications.notification_queue(next_attempt_at); ``` ### 2.4 Dispositivos para push ```sql CREATE TABLE notifications.user_devices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, device_type VARCHAR(20) NOT NULL CHECK (device_type IN ('web', 'ios', 'android')), subscription JSONB NOT NULL, -- Web Push subscription object browser VARCHAR(100), os VARCHAR(100), is_active BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT NOW(), last_used_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_user_devices_user ON notifications.user_devices(user_id); CREATE INDEX idx_user_devices_active ON notifications.user_devices(is_active); ``` --- ## Paso 3: Crear Entities ### 3.1 Notification Entity ```typescript // src/modules/notifications/entities/notification.entity.ts import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, Index, } from 'typeorm'; @Entity({ schema: 'notifications', name: 'notifications' }) @Index(['userId']) @Index(['type']) @Index(['status']) export class Notification { @PrimaryGeneratedColumn('uuid') id!: string; @Column({ name: 'user_id', type: 'uuid' }) userId!: string; @Column({ type: 'varchar', length: 50 }) type!: string; @Column({ type: 'varchar', length: 255 }) title!: string; @Column({ type: 'text' }) message!: string; @Column({ type: 'jsonb', default: {} }) data?: Record; @Column({ type: 'varchar', length: 20, default: 'normal' }) priority!: string; @Column({ type: 'varchar', array: true, default: ['in_app'] }) channels!: string[]; @Column({ type: 'varchar', length: 20, default: 'pending' }) status!: string; @Column({ name: 'read_at', type: 'timestamp', nullable: true }) readAt?: Date; @Column({ name: 'sent_at', type: 'timestamp', nullable: true }) sentAt?: Date; @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; @Column({ name: 'expires_at', type: 'timestamp', nullable: true }) expiresAt?: Date; @Column({ type: 'jsonb', default: {} }) metadata?: Record; } ``` ### 3.2 NotificationPreference Entity ```typescript // src/modules/notifications/entities/notification-preference.entity.ts import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; @Entity({ schema: 'notifications', name: 'notification_preferences' }) @Index(['userId', 'notificationType'], { unique: true }) export class NotificationPreference { @PrimaryGeneratedColumn('uuid') id!: string; @Column({ name: 'user_id', type: 'uuid' }) userId!: string; @Column({ name: 'notification_type', type: 'varchar', length: 50 }) notificationType!: string; @Column({ name: 'in_app_enabled', type: 'boolean', default: true }) inAppEnabled!: boolean; @Column({ name: 'email_enabled', type: 'boolean', default: true }) emailEnabled!: boolean; @Column({ name: 'push_enabled', type: 'boolean', default: false }) pushEnabled!: boolean; @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt!: Date; } ``` --- ## Paso 4: Crear NotificationService ```typescript // src/modules/notifications/services/notification.service.ts import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Notification } from '../entities/notification.entity'; @Injectable() export class NotificationService { constructor( @InjectRepository(Notification) private readonly notificationRepository: Repository, ) {} async create(data: { userId: string; title: string; message: string; type: string; data?: Record; priority?: string; channels?: string[]; expiresAt?: Date; }): Promise { const notification = this.notificationRepository.create({ userId: data.userId, title: data.title, message: data.message, type: data.type, data: data.data, priority: data.priority || 'normal', channels: data.channels || ['in_app'], status: 'sent', sentAt: new Date(), expiresAt: data.expiresAt, }); return this.notificationRepository.save(notification); } async findAllByUser( userId: string, filters?: { status?: string; type?: string; limit?: number; offset?: number; }, ): Promise<{ data: Notification[]; total: number }> { const query = this.notificationRepository .createQueryBuilder('n') .where('n.user_id = :userId', { userId }); if (filters?.status) { query.andWhere('n.status = :status', { status: filters.status }); } if (filters?.type) { query.andWhere('n.type = :type', { type: filters.type }); } query.orderBy('n.created_at', 'DESC'); query.skip(filters?.offset || 0); query.take(filters?.limit || 50); const [data, total] = await query.getManyAndCount(); return { data, total }; } async markAsRead(notificationId: string, userId: string): Promise { const notification = await this.notificationRepository.findOne({ where: { id: notificationId }, }); if (!notification) { throw new NotFoundException('Notification not found'); } if (notification.userId !== userId) { throw new ForbiddenException('Access denied'); } notification.status = 'read'; notification.readAt = new Date(); await this.notificationRepository.save(notification); } async markAllAsRead(userId: string): Promise { const result = await this.notificationRepository .createQueryBuilder() .update(Notification) .set({ status: 'read', readAt: new Date() }) .where('user_id = :userId', { userId }) .andWhere('status != :status', { status: 'read' }) .execute(); return result.affected || 0; } async getUnreadCount(userId: string): Promise { return this.notificationRepository .createQueryBuilder('n') .where('n.user_id = :userId', { userId }) .andWhere('n.status IN (:...statuses)', { statuses: ['pending', 'sent'] }) .getCount(); } async deleteNotification(notificationId: string, userId: string): Promise { const notification = await this.notificationRepository.findOne({ where: { id: notificationId, userId }, }); if (!notification) { throw new NotFoundException('Notification not found'); } await this.notificationRepository.remove(notification); } } ``` --- ## Paso 5: Crear MailService ```typescript // src/modules/mail/mail.service.ts import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as nodemailer from 'nodemailer'; @Injectable() export class MailService { private transporter: nodemailer.Transporter | null = null; private readonly logger = new Logger(MailService.name); private readonly from: string; constructor(private readonly configService: ConfigService) { this.from = configService.get('SMTP_FROM', 'App '); this.initializeTransporter(); } private initializeTransporter() { const sendgridKey = this.configService.get('SENDGRID_API_KEY'); if (sendgridKey) { this.transporter = nodemailer.createTransport({ host: 'smtp.sendgrid.net', port: 587, auth: { user: 'apikey', pass: sendgridKey }, }); this.logger.log('Email initialized with SendGrid'); return; } const host = this.configService.get('SMTP_HOST'); const user = this.configService.get('SMTP_USER'); const pass = this.configService.get('SMTP_PASS'); if (host && user && pass) { this.transporter = nodemailer.createTransport({ host, port: this.configService.get('SMTP_PORT', 587), secure: this.configService.get('SMTP_SECURE', false), auth: { user, pass }, }); this.logger.log('Email initialized with SMTP'); } else { this.logger.warn('Email not configured - emails will be logged only'); } } async sendEmail( to: string | string[], subject: string, html: string, ): Promise { if (!this.transporter) { this.logger.warn(`[MOCK EMAIL] To: ${to} | Subject: ${subject}`); return false; } try { await this.transporter.sendMail({ from: this.from, to, subject, html, }); this.logger.log(`Email sent to ${to}`); return true; } catch (error) { this.logger.error(`Failed to send email to ${to}`, error); throw error; } } async sendNotificationEmail( to: string, title: string, message: string, actionUrl?: string, ): Promise { const html = `

${title}

${message}

${actionUrl ? `Ver detalles` : ''} `; return this.sendEmail(to, title, html); } } ``` --- ## Paso 6: Crear NotificationsModule ```typescript // src/modules/notifications/notifications.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Notification } from './entities/notification.entity'; import { NotificationPreference } from './entities/notification-preference.entity'; import { NotificationService } from './services/notification.service'; import { NotificationsController } from './controllers/notifications.controller'; import { MailModule } from '../mail/mail.module'; @Module({ imports: [ TypeOrmModule.forFeature([Notification, NotificationPreference]), MailModule, ], controllers: [NotificationsController], providers: [NotificationService], exports: [NotificationService], }) export class NotificationsModule {} ``` --- ## Paso 7: Crear Controller ```typescript // src/modules/notifications/controllers/notifications.controller.ts import { Controller, Get, Post, Delete, Param, Query, UseGuards, Request, } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '@/modules/auth/guards'; import { NotificationService } from '../services/notification.service'; @ApiTags('Notifications') @Controller('notifications') @UseGuards(JwtAuthGuard) @ApiBearerAuth() export class NotificationsController { constructor(private readonly notificationService: NotificationService) {} @Get() async findAll( @Request() req, @Query('status') status?: string, @Query('type') type?: string, @Query('limit') limit?: number, @Query('offset') offset?: number, ) { return this.notificationService.findAllByUser(req.user.id, { status, type, limit: limit || 50, offset: offset || 0, }); } @Get('unread-count') async getUnreadCount(@Request() req) { const count = await this.notificationService.getUnreadCount(req.user.id); return { count }; } @Post(':id/read') async markAsRead(@Param('id') id: string, @Request() req) { await this.notificationService.markAsRead(id, req.user.id); return { success: true }; } @Post('read-all') async markAllAsRead(@Request() req) { const count = await this.notificationService.markAllAsRead(req.user.id); return { success: true, count }; } @Delete(':id') async delete(@Param('id') id: string, @Request() req) { await this.notificationService.deleteNotification(id, req.user.id); return { success: true }; } } ``` --- ## Paso 8: Configurar Variables de Entorno ```env # Email - SMTP SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USER=user SMTP_PASS=password SMTP_SECURE=false SMTP_FROM="App Name " # Email - SendGrid (alternativo) SENDGRID_API_KEY=SG.xxxxx # Push Notifications (generar con: npx web-push generate-vapid-keys) VAPID_PUBLIC_KEY=BN... VAPID_PRIVATE_KEY=... VAPID_SUBJECT=mailto:admin@app.com # Frontend FRONTEND_URL=https://app.example.com ``` --- ## Checklist de Implementación - [ ] Dependencias npm instaladas - [ ] DDL creado (schema + tablas) - [ ] Entities alineadas con DDL - [ ] NotificationService implementado - [ ] MailService implementado - [ ] Controller con endpoints - [ ] NotificationsModule configurado - [ ] Variables de entorno configuradas - [ ] Build pasa sin errores - [ ] Test de envío de email funciona --- ## Opcional: Push Notifications ```typescript // src/modules/notifications/services/push-notification.service.ts import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as webpush from 'web-push'; @Injectable() export class PushNotificationService { private readonly logger = new Logger(PushNotificationService.name); constructor(private readonly configService: ConfigService) { const publicKey = configService.get('VAPID_PUBLIC_KEY'); const privateKey = configService.get('VAPID_PRIVATE_KEY'); const subject = configService.get('VAPID_SUBJECT'); if (publicKey && privateKey && subject) { webpush.setVapidDetails(subject, publicKey, privateKey); this.logger.log('Web Push initialized'); } } async sendPush( subscription: webpush.PushSubscription, payload: { title: string; body: string; url?: string }, ): Promise { try { await webpush.sendNotification( subscription, JSON.stringify(payload), ); return true; } catch (error) { this.logger.error('Push notification failed', error); return false; } } } ``` --- ## Código de Referencia Ver implementación completa en: - `projects/gamilit/apps/backend/src/modules/notifications/` - `projects/gamilit/apps/backend/src/modules/mail/` --- **Versión:** 1.0.0 **Sistema:** SIMCO Catálogo