template-saas/docs/02-especificaciones/PLAN-IMPLEMENTACION-NOTIFICATIONS-V2.md
rckrdmrd 50a821a415
Some checks failed
CI / Backend CI (push) Has been cancelled
CI / Frontend CI (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / CI Summary (push) Has been cancelled
[SIMCO-V38] feat: Actualizar a SIMCO v3.8.0
- HERENCIA-SIMCO.md actualizado con directivas v3.7 y v3.8
- Actualizaciones de configuracion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:53:08 -06:00

658 lines
18 KiB
Markdown

---
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<UserDevice>,
) {}
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<SendResult[]> {
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<string, Set<string>>();
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<NotificationPermission>('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