--- id: "PLAN-SAAS-007" title: "Plan Implementacion Notifications v2" type: "ImplementationPlan" status: "Completed" priority: "P1" module: "notifications" version: "1.0.0" created_date: "2026-01-08" updated_date: "2026-01-10" --- # Plan de Implementacion - Sistema de Notificaciones v2.0 ## Metadata - **Modulo:** SAAS-007 Notifications - **Version:** 2.0.0 - **Fecha:** 2026-01-07 - **Referencia:** ET-SAAS-007-notifications-v2.md - **Estado:** Pendiente Aprobacion --- ## Resumen de Cambios ### Estado Actual vs Propuesto | Aspecto | Actual | Propuesto | |---------|--------|-----------| | Tablas DDL | 3 | 6 (+3 nuevas) | | Servicios Backend | 1 | 4 (+3 nuevos) | | Push Notifications | No | Si (Web Push API) | | WebSocket Real-time | No | Si | | Cola Asincrona | No | Si (BullMQ) | | Logs de Entrega | No | Si | ### Nuevos Componentes 1. **DDL**: user_devices, notification_queue, notification_logs 2. **Backend**: PushNotificationService, NotificationQueueService, NotificationsGateway 3. **Frontend**: Service Worker, PushPermissionBanner, DevicesManager 4. **Dependencias**: web-push, socket.io, @nestjs/websockets --- ## Fase 1: Base de Datos ### Tareas | ID | Tarea | Archivo | Estimacion | |----|-------|---------|------------| | DB-001 | Crear DDL extendido | `ddl/schemas/notifications/tables/02-extended-notifications.sql` | COMPLETADO | | DB-002 | Agregar enums nuevos | `ddl/02-enums.sql` (queue_status, device_type) | Ya incluido | | DB-003 | Actualizar create-database.sh | `scripts/create-database.sh` | 15 min | | DB-004 | Validar recreacion BD | `./scripts/drop-and-recreate-database.sh` | 30 min | | DB-005 | Crear seeds de prueba | `seeds/dev/notifications.sql` | 30 min | ### Validacion ```bash # Recrear BD cd apps/database ./scripts/drop-and-recreate-database.sh # Verificar tablas psql -d template_saas -c "\dt notifications.*" # Verificar funciones psql -d template_saas -c "\df notifications.*" ``` **Criterio de Exito**: Las 6 tablas y 6+ funciones existen sin errores. --- ## Fase 2: Backend - Entidades y DTOs ### Tareas | ID | Tarea | Archivo | Estimacion | |----|-------|---------|------------| | BE-001 | Crear entidad UserDevice | `entities/user-device.entity.ts` | 20 min | | BE-002 | Crear entidad NotificationQueue | `entities/notification-queue.entity.ts` | 20 min | | BE-003 | Crear entidad NotificationLog | `entities/notification-log.entity.ts` | 20 min | | BE-004 | Crear DTO RegisterDevice | `dto/register-device.dto.ts` | 15 min | | BE-005 | Actualizar entities/index.ts | `entities/index.ts` | 5 min | | BE-006 | Actualizar module imports | `notifications.module.ts` | 10 min | ### Archivos a Crear ```typescript // entities/user-device.entity.ts @Entity({ schema: 'notifications', name: 'user_devices' }) export class UserDevice { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'tenant_id' }) tenant_id: string; @Column({ name: 'user_id' }) user_id: string; @Column({ name: 'device_type' }) device_type: 'web' | 'mobile' | 'desktop'; @Column({ name: 'device_token' }) device_token: string; @Column({ name: 'device_name', nullable: true }) device_name?: string; @Column({ nullable: true }) browser?: string; @Column({ nullable: true }) os?: string; @Column({ name: 'is_active', default: true }) is_active: boolean; @Column({ name: 'last_used_at', type: 'timestamptz' }) last_used_at: Date; @Column({ name: 'created_at', type: 'timestamptz' }) created_at: Date; } ``` --- ## Fase 3: Backend - Servicios ### Tareas | ID | Tarea | Archivo | Estimacion | |----|-------|---------|------------| | SV-001 | Crear PushNotificationService | `services/push-notification.service.ts` | 1 hora | | SV-002 | Crear NotificationQueueService | `services/notification-queue.service.ts` | 1 hora | | SV-003 | Crear DevicesService | `services/devices.service.ts` | 45 min | | SV-004 | Actualizar NotificationsService | `services/notifications.service.ts` | 1 hora | | SV-005 | Crear NotificationProcessor | `processors/notification.processor.ts` | 1 hora | ### PushNotificationService ```typescript // services/push-notification.service.ts import * as webpush from 'web-push'; @Injectable() export class PushNotificationService implements OnModuleInit { private readonly logger = new Logger(PushNotificationService.name); constructor( @InjectRepository(UserDevice) private readonly deviceRepository: Repository, ) {} onModuleInit() { const vapidPublicKey = this.configService.get('VAPID_PUBLIC_KEY'); const vapidPrivateKey = this.configService.get('VAPID_PRIVATE_KEY'); const vapidSubject = this.configService.get('VAPID_SUBJECT', 'mailto:admin@example.com'); if (vapidPublicKey && vapidPrivateKey) { webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey); this.logger.log('Web Push configured with VAPID keys'); } else { this.logger.warn('VAPID keys not configured, push notifications disabled'); } } async sendToUser(userId: string, tenantId: string, payload: PushPayload): Promise { const devices = await this.deviceRepository.find({ where: { user_id: userId, tenant_id: tenantId, is_active: true }, }); if (devices.length === 0) { return []; } const results: SendResult[] = []; for (const device of devices) { try { const subscription = JSON.parse(device.device_token); await webpush.sendNotification(subscription, JSON.stringify(payload)); results.push({ deviceId: device.id, success: true }); // Actualizar last_used_at device.last_used_at = new Date(); await this.deviceRepository.save(device); } catch (error) { if (error.statusCode === 410 || error.statusCode === 404) { // Subscription expirada device.is_active = false; await this.deviceRepository.save(device); this.logger.warn(`Device ${device.id} subscription expired`); } results.push({ deviceId: device.id, success: false, error: error.message }); } } return results; } getVapidPublicKey(): string | null { return this.configService.get('VAPID_PUBLIC_KEY') || null; } } ``` --- ## Fase 4: Backend - WebSocket Gateway ### Tareas | ID | Tarea | Archivo | Estimacion | |----|-------|---------|------------| | WS-001 | Crear NotificationsGateway | `gateways/notifications.gateway.ts` | 1 hora | | WS-002 | Configurar WebSocket module | `notifications.module.ts` | 15 min | | WS-003 | Integrar con NotificationsService | `services/notifications.service.ts` | 30 min | ### NotificationsGateway ```typescript // gateways/notifications.gateway.ts @WebSocketGateway({ namespace: '/notifications', cors: { origin: process.env.FRONTEND_URL || '*', credentials: true, }, }) export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; private readonly logger = new Logger(NotificationsGateway.name); private userSockets = new Map>(); handleConnection(client: Socket) { try { const userId = this.extractUserId(client); const tenantId = this.extractTenantId(client); if (userId) { const key = `${tenantId}:${userId}`; if (!this.userSockets.has(key)) { this.userSockets.set(key, new Set()); } this.userSockets.get(key).add(client.id); this.logger.debug(`User ${userId} connected: ${client.id}`); } } catch (error) { this.logger.error(`Connection error: ${error.message}`); client.disconnect(); } } handleDisconnect(client: Socket) { const userId = this.extractUserId(client); const tenantId = this.extractTenantId(client); if (userId) { const key = `${tenantId}:${userId}`; this.userSockets.get(key)?.delete(client.id); this.logger.debug(`User ${userId} disconnected: ${client.id}`); } } async emitToUser(tenantId: string, userId: string, event: string, data: any) { const key = `${tenantId}:${userId}`; const socketIds = this.userSockets.get(key); if (socketIds && socketIds.size > 0) { for (const socketId of socketIds) { this.server.to(socketId).emit(event, data); } this.logger.debug(`Emitted ${event} to user ${userId}`); } } @SubscribeMessage('notification:read') handleMarkAsRead(client: Socket, payload: { notificationId: string }) { const userId = this.extractUserId(client); const tenantId = this.extractTenantId(client); const key = `${tenantId}:${userId}`; // Broadcast to other sockets of same user const socketIds = this.userSockets.get(key); if (socketIds) { for (const socketId of socketIds) { if (socketId !== client.id) { this.server.to(socketId).emit('notification:read', payload); } } } } private extractUserId(client: Socket): string | null { return client.handshake.auth?.userId || client.handshake.query?.userId as string || null; } private extractTenantId(client: Socket): string | null { return client.handshake.auth?.tenantId || client.handshake.query?.tenantId as string || null; } } ``` --- ## Fase 5: Backend - Controllers ### Tareas | ID | Tarea | Archivo | Estimacion | |----|-------|---------|------------| | CT-001 | Crear DevicesController | `controllers/devices.controller.ts` | 45 min | | CT-002 | Actualizar NotificationsController | `controllers/notifications.controller.ts` | 30 min | | CT-003 | Agregar endpoint VAPID key | `controllers/devices.controller.ts` | 15 min | ### DevicesController ```typescript // controllers/devices.controller.ts @Controller('notifications/devices') @UseGuards(JwtAuthGuard, TenantGuard) @ApiTags('Notification Devices') export class DevicesController { constructor(private readonly devicesService: DevicesService) {} @Get() @ApiOperation({ summary: 'List my registered devices' }) async getDevices(@CurrentUser() user: User, @CurrentTenant() tenantId: string) { return this.devicesService.findByUser(user.id, tenantId); } @Post() @ApiOperation({ summary: 'Register device for push notifications' }) async registerDevice( @CurrentUser() user: User, @CurrentTenant() tenantId: string, @Body() dto: RegisterDeviceDto, ) { return this.devicesService.register(user.id, tenantId, dto); } @Delete(':id') @ApiOperation({ summary: 'Unregister device' }) async unregisterDevice( @CurrentUser() user: User, @CurrentTenant() tenantId: string, @Param('id') deviceId: string, ) { return this.devicesService.unregister(deviceId, user.id, tenantId); } @Get('vapid-key') @Public() // No requiere auth @ApiOperation({ summary: 'Get VAPID public key for push subscription' }) async getVapidKey() { return { vapidPublicKey: this.devicesService.getVapidPublicKey() }; } } ``` --- ## Fase 6: Frontend ### Tareas | ID | Tarea | Archivo | Estimacion | |----|-------|---------|------------| | FE-001 | Crear Service Worker | `public/sw.js` | 30 min | | FE-002 | Crear hook usePushNotifications | `hooks/usePushNotifications.ts` | 45 min | | FE-003 | Crear hook useNotificationSocket | `hooks/useNotificationSocket.ts` | 45 min | | FE-004 | Crear PushPermissionBanner | `components/notifications/PushPermissionBanner.tsx` | 30 min | | FE-005 | Crear DevicesManager | `components/notifications/DevicesManager.tsx` | 45 min | | FE-006 | Actualizar NotificationSettings | `pages/settings/NotificationSettings.tsx` | 30 min | | FE-007 | Actualizar notificationsAPI | `services/api/notifications.ts` | 30 min | ### Service Worker ```javascript // public/sw.js self.addEventListener('push', (event) => { const data = event.data?.json() || {}; const options = { body: data.body || 'Nueva notificacion', icon: '/icon-192.png', badge: '/badge-72.png', vibrate: [100, 50, 100], data: { url: data.url || '/', notificationId: data.notificationId, }, actions: [ { action: 'view', title: 'Ver' }, { action: 'dismiss', title: 'Descartar' }, ], }; event.waitUntil( self.registration.showNotification(data.title || 'Notificacion', options) ); }); self.addEventListener('notificationclick', (event) => { event.notification.close(); if (event.action === 'view' || !event.action) { const url = event.notification.data?.url || '/'; event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { // Si hay una ventana abierta, enfocarla for (const client of clientList) { if (client.url.includes(self.location.origin) && 'focus' in client) { return client.focus().then((c) => c.navigate(url)); } } // Si no, abrir nueva ventana return clients.openWindow(url); }) ); } }); ``` ### Hook usePushNotifications ```typescript // hooks/usePushNotifications.ts export function usePushNotifications() { const [permission, setPermission] = useState('default'); const [isSupported, setIsSupported] = useState(false); const [isSubscribed, setIsSubscribed] = useState(false); const registerDevice = useMutation({ mutationFn: (dto: RegisterDeviceDto) => notificationsAPI.registerDevice(dto), }); useEffect(() => { setIsSupported('serviceWorker' in navigator && 'PushManager' in window); setPermission(Notification.permission); // Verificar subscription existente if ('serviceWorker' in navigator) { navigator.serviceWorker.ready.then((registration) => { registration.pushManager.getSubscription().then((sub) => { setIsSubscribed(!!sub); }); }); } }, []); const requestPermission = useCallback(async () => { if (!isSupported) return; const result = await Notification.requestPermission(); setPermission(result); if (result === 'granted') { await subscribe(); } }, [isSupported]); const subscribe = useCallback(async () => { try { // Obtener VAPID key const { vapidPublicKey } = await notificationsAPI.getVapidKey(); // Registrar service worker const registration = await navigator.serviceWorker.ready; // Crear subscription const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }); // Enviar al backend await registerDevice.mutateAsync({ deviceToken: JSON.stringify(subscription), deviceType: 'web', deviceName: getDeviceName(), }); setIsSubscribed(true); } catch (error) { console.error('Failed to subscribe:', error); } }, [registerDevice]); const unsubscribe = useCallback(async () => { try { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); if (subscription) { await subscription.unsubscribe(); setIsSubscribed(false); } } catch (error) { console.error('Failed to unsubscribe:', error); } }, []); return { isSupported, permission, isSubscribed, requestPermission, subscribe, unsubscribe, }; } ``` --- ## Fase 7: Testing ### Tareas | ID | Tarea | Archivo | Estimacion | |----|-------|---------|------------| | TS-001 | Tests PushNotificationService | `__tests__/push-notification.service.spec.ts` | 1 hora | | TS-002 | Tests NotificationQueueService | `__tests__/notification-queue.service.spec.ts` | 1 hora | | TS-003 | Tests DevicesController | `__tests__/devices.controller.spec.ts` | 45 min | | TS-004 | Tests NotificationsGateway | `__tests__/notifications.gateway.spec.ts` | 45 min | | TS-005 | Tests E2E push flow | `__tests__/notifications.e2e-spec.ts` | 1 hora | ### Cobertura Objetivo - Servicios: > 80% - Controllers: > 75% - Gateway: > 70% --- ## Fase 8: Documentacion ### Tareas | ID | Tarea | Archivo | Estimacion | |----|-------|---------|------------| | DC-001 | Actualizar SAAS-007-notifications.md | `docs/01-modulos/SAAS-007-notifications.md` | 30 min | | DC-002 | Actualizar PROJECT-STATUS.md | `orchestration/PROJECT-STATUS.md` | 15 min | | DC-003 | Actualizar _MAP.md | `apps/database/_MAP.md` | 15 min | | DC-004 | Crear CHANGELOG | `docs/CHANGELOG.md` | 15 min | --- ## Dependencias NPM a Instalar ### Backend ```bash cd apps/backend npm install web-push @nestjs/websockets @nestjs/platform-socket.io socket.io npm install -D @types/web-push ``` ### Frontend ```bash cd apps/frontend npm install socket.io-client ``` --- ## Variables de Entorno Agregar a `.env.example`: ```bash # Push Notifications (Web Push API) VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_SUBJECT=mailto:admin@example.com # WebSocket WS_CORS_ORIGIN=http://localhost:3000 ``` Generar claves VAPID: ```bash npx web-push generate-vapid-keys ``` --- ## Cronograma Estimado | Fase | Descripcion | Horas | Acumulado | |------|-------------|-------|-----------| | 1 | Base de Datos | 1.5 | 1.5 | | 2 | Entidades y DTOs | 1.5 | 3.0 | | 3 | Servicios Backend | 4.0 | 7.0 | | 4 | WebSocket Gateway | 1.5 | 8.5 | | 5 | Controllers | 1.5 | 10.0 | | 6 | Frontend | 4.5 | 14.5 | | 7 | Testing | 4.5 | 19.0 | | 8 | Documentacion | 1.5 | 20.5 | **Total estimado: 20-22 horas de desarrollo** --- ## Criterios de Aceptacion ### Funcionales - [ ] Push notifications funcionan en Chrome, Firefox, Edge - [ ] WebSocket conecta y recibe notificaciones in-app en tiempo real - [ ] Cola procesa notificaciones asincronamente - [ ] Reintentos automaticos funcionan con backoff exponencial - [ ] Dispositivos se registran y desregistran correctamente - [ ] Preferencias de usuario se respetan por canal ### No Funcionales - [ ] Latencia WebSocket < 100ms - [ ] Cola procesa > 100 notificaciones/segundo - [ ] Push delivery rate > 95% - [ ] Tests coverage > 80% --- ## Riesgos y Mitigaciones | Riesgo | Probabilidad | Impacto | Mitigacion | |--------|--------------|---------|------------| | VAPID keys mal configuradas | Media | Alto | Script de validacion | | WebSocket disconnections | Media | Medio | Reconnect automatico | | Push subscription expira | Alta | Bajo | Invalidacion automatica | | Redis no disponible | Baja | Alto | Fallback sincrono | --- ## Aprobaciones | Rol | Nombre | Fecha | Firma | |-----|--------|-------|-------| | Tech Lead | | | | | Product Owner | | | | | DevOps | | | | --- **Ultima actualizacion:** 2026-01-07 **Autor:** Claude Code