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

18 KiB

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

  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

# 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

  • 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