| id |
title |
type |
status |
priority |
module |
version |
created_date |
updated_date |
| PLAN-SAAS-007 |
Plan Implementacion Notifications v2 |
ImplementationPlan |
Completed |
P1 |
notifications |
1.0.0 |
2026-01-08 |
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
- DDL: user_devices, notification_queue, notification_logs
- Backend: PushNotificationService, NotificationQueueService, NotificationsGateway
- Frontend: Service Worker, PushPermissionBanner, DevicesManager
- 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
# 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
// 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
// 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
// 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
// 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
// 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
// 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
cd apps/backend
npm install web-push @nestjs/websockets @nestjs/platform-socket.io socket.io
npm install -D @types/web-push
Frontend
cd apps/frontend
npm install socket.io-client
Variables de Entorno
Agregar a .env.example:
# 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:
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
No Funcionales
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