- HERENCIA-SIMCO.md actualizado con directivas v3.7 y v3.8 - Actualizaciones de configuracion Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
658 lines
18 KiB
Markdown
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
|