- HERENCIA-SIMCO.md actualizado con directivas v3.7 y v3.8 - Actualizaciones de configuracion Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
979 lines
30 KiB
Markdown
979 lines
30 KiB
Markdown
---
|
|
id: "ET-SAAS-007"
|
|
title: "Especificacion Tecnica Notifications v2"
|
|
type: "TechnicalSpec"
|
|
status: "Published"
|
|
priority: "P0"
|
|
module: "notifications"
|
|
version: "2.0.0"
|
|
created_date: "2026-01-08"
|
|
updated_date: "2026-01-10"
|
|
---
|
|
|
|
# ET-SAAS-007: Especificacion Tecnica - Sistema de Notificaciones v2.0
|
|
|
|
## Metadata
|
|
- **Codigo:** ET-SAAS-007
|
|
- **Modulo:** Notifications
|
|
- **Version:** 2.0.0
|
|
- **Estado:** Propuesto
|
|
- **Fecha:** 2026-01-07
|
|
- **Basado en:** gamilit EXT-003, mejores practicas Node.js 2025
|
|
|
|
---
|
|
|
|
## 1. Resumen Ejecutivo
|
|
|
|
### 1.1 Estado Actual (v1.0)
|
|
|
|
El modulo de notificaciones actual en template-saas incluye:
|
|
|
|
| Componente | Estado | Limitaciones |
|
|
|------------|--------|--------------|
|
|
| Email transaccional | Implementado | Envio sincrono, sin cola |
|
|
| In-app notifications | Basico | Sin WebSocket real-time |
|
|
| Push notifications | TODO | No implementado |
|
|
| SMS | Excluido | Fase posterior |
|
|
| Templates | Implementado | 6 templates predefinidos |
|
|
| Preferencias usuario | Basico | Sin granularidad por tipo |
|
|
| Cola de procesamiento | No | Envios sincronos |
|
|
| Reintentos automaticos | No | Sin retry mechanism |
|
|
| Logs de entrega | No | Sin tracking detallado |
|
|
|
|
### 1.2 Propuesta v2.0
|
|
|
|
Sistema de notificaciones enterprise-ready con:
|
|
|
|
- **Multi-canal**: Email, Push (Web Push API), In-App, SMS (futuro)
|
|
- **Arquitectura asincrona**: BullMQ para procesamiento en cola
|
|
- **WebSocket real-time**: Gateway para notificaciones in-app instantaneas
|
|
- **Push nativo**: Web Push API con VAPID (sin Firebase)
|
|
- **Preferencias granulares**: Por tipo de notificacion y canal
|
|
- **Tracking completo**: Logs de entrega con metricas
|
|
- **Reintentos inteligentes**: Exponential backoff con max attempts
|
|
- **Multi-tenant**: RLS + aislamiento por tenant_id
|
|
|
|
---
|
|
|
|
## 2. Analisis Comparativo
|
|
|
|
### 2.1 Referencia: gamilit EXT-003
|
|
|
|
| Caracteristica | gamilit | template-saas actual | Propuesta v2.0 |
|
|
|----------------|---------|---------------------|----------------|
|
|
| Tablas DDL | 6 | 3 | 6 |
|
|
| Push Web (VAPID) | Si | No | Si |
|
|
| Cola BullMQ | Si | Solo webhooks | Si |
|
|
| WebSocket Gateway | Si | No | Si |
|
|
| Devices table | Si | No | Si |
|
|
| Notification queue | Si | No | Si |
|
|
| Notification logs | Si | No | Si |
|
|
| Funciones SQL | 3 | 0 | 3 |
|
|
| Preferencias/tipo | Si | No | Si |
|
|
|
|
### 2.2 Mejores Practicas Node.js 2025
|
|
|
|
Segun investigacion de fuentes como [Novu](https://novu.co/), [NotifMe SDK](https://github.com/notifme/notifme-sdk), y articulos especializados:
|
|
|
|
1. **Arquitectura desacoplada**: Separar logica de negocio de envios
|
|
2. **Event-driven**: Usar event emitters para triggers
|
|
3. **Queue-based**: Nunca enviar en el request principal
|
|
4. **Channel-agnostic**: Definir notificacion base, mapear a canales
|
|
5. **Fallback strategies**: Multi-proveedor con failover
|
|
6. **Rate limiting**: Prevenir spam y sobrecarga
|
|
7. **Batching**: Digest mode para reducir interrupciones
|
|
|
|
---
|
|
|
|
## 3. Arquitectura Propuesta
|
|
|
|
### 3.1 Diagrama de Componentes
|
|
|
|
```
|
|
+------------------+ +-------------------+ +------------------+
|
|
| Event Source |---->| NotificationSvc |---->| BullMQ Queue |
|
|
| (Auth, Billing, | | (orchestration) | | (notifications) |
|
|
| Tenant, etc) | +-------------------+ +------------------+
|
|
+------------------+ | |
|
|
v v
|
|
+-------------------+ +-------------------+
|
|
| Template Service | | Queue Processor |
|
|
| (render vars) | | (worker) |
|
|
+-------------------+ +-------------------+
|
|
|
|
|
+-----------------------------+-----------------------------+
|
|
| | |
|
|
v v v
|
|
+-------------------+ +-------------------+ +-------------------+
|
|
| Email Channel | | Push Channel | | In-App Channel |
|
|
| (SendGrid/SES) | | (Web Push API) | | (WebSocket) |
|
|
+-------------------+ +-------------------+ +-------------------+
|
|
| | |
|
|
v v v
|
|
+-------------------------------------------------------------------+
|
|
| Notification Logs |
|
|
| (tracking & analytics) |
|
|
+-------------------------------------------------------------------+
|
|
```
|
|
|
|
### 3.2 Flujo de Notificacion
|
|
|
|
```
|
|
1. Evento dispara notificacion (ej: usuario creado)
|
|
|
|
|
2. NotificationService.send(userId, templateCode, data)
|
|
|
|
|
3. Cargar template + verificar preferencias usuario
|
|
|
|
|
4. Filtrar canales segun preferencias
|
|
|
|
|
5. Por cada canal habilitado:
|
|
|-- Encolar en BullMQ (job por canal)
|
|
|
|
|
6. Worker procesa job:
|
|
|-- Email: EmailService.send()
|
|
|-- Push: PushService.sendToDevices()
|
|
|-- In-App: WebSocketGateway.emit() + crear registro
|
|
|
|
|
7. Actualizar status en notification_queue
|
|
|
|
|
8. Crear log en notification_logs
|
|
|
|
|
9. Si falla: retry con backoff exponencial
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Modelo de Datos (DDL v2.0)
|
|
|
|
### 4.1 Schema: notifications
|
|
|
|
#### 4.1.1 Enums Adicionales
|
|
|
|
```sql
|
|
-- Ya existentes en 02-enums.sql:
|
|
-- notifications.channel: 'email', 'push', 'in_app', 'sms', 'whatsapp'
|
|
-- notifications.notification_status: 'pending', 'sent', 'delivered', 'failed', 'read'
|
|
-- notifications.priority: 'low', 'normal', 'high', 'urgent'
|
|
|
|
-- Nuevos enums:
|
|
CREATE TYPE notifications.queue_status AS ENUM (
|
|
'queued', -- En cola esperando
|
|
'processing', -- Siendo procesado
|
|
'sent', -- Enviado exitosamente
|
|
'failed', -- Fallo definitivo
|
|
'retrying' -- En espera de retry
|
|
);
|
|
|
|
CREATE TYPE notifications.device_type AS ENUM (
|
|
'web',
|
|
'mobile',
|
|
'desktop'
|
|
);
|
|
```
|
|
|
|
#### 4.1.2 Tablas (6 total)
|
|
|
|
**1. notifications.templates** (existente - sin cambios)
|
|
|
|
**2. notifications.notifications** (existente - sin cambios)
|
|
|
|
**3. notifications.user_preferences** (mejorada)
|
|
|
|
```sql
|
|
-- Agregar campos para preferencias granulares por tipo
|
|
ALTER TABLE notifications.user_preferences
|
|
ADD COLUMN IF NOT EXISTS notification_type_preferences JSONB DEFAULT '{}'::jsonb;
|
|
-- Estructura: { "achievement": {"email": true, "push": true}, "billing": {"email": true, "push": false} }
|
|
|
|
COMMENT ON COLUMN notifications.user_preferences.notification_type_preferences
|
|
IS 'Preferencias granulares por tipo de notificacion y canal';
|
|
```
|
|
|
|
**4. notifications.user_devices** (NUEVA)
|
|
|
|
```sql
|
|
CREATE TABLE notifications.user_devices (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
|
|
|
-- Device info
|
|
device_type notifications.device_type NOT NULL DEFAULT 'web',
|
|
device_token TEXT NOT NULL, -- PushSubscription JSON para Web Push
|
|
device_name VARCHAR(100), -- "Chrome en Windows", "Safari en macOS"
|
|
|
|
-- Browser/OS info
|
|
browser VARCHAR(50),
|
|
browser_version VARCHAR(20),
|
|
os VARCHAR(50),
|
|
os_version VARCHAR(20),
|
|
|
|
-- Status
|
|
is_active BOOLEAN DEFAULT TRUE,
|
|
|
|
-- Timestamps
|
|
last_used_at TIMESTAMPTZ DEFAULT NOW(),
|
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
|
|
|
-- Constraints
|
|
CONSTRAINT unique_user_device_token UNIQUE (user_id, device_token)
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_user_devices_user ON notifications.user_devices(user_id) WHERE is_active = TRUE;
|
|
CREATE INDEX idx_user_devices_tenant ON notifications.user_devices(tenant_id);
|
|
|
|
-- RLS
|
|
ALTER TABLE notifications.user_devices ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY user_devices_tenant_isolation ON notifications.user_devices
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
|
|
|
COMMENT ON TABLE notifications.user_devices IS 'Dispositivos registrados para push notifications';
|
|
```
|
|
|
|
**5. notifications.notification_queue** (NUEVA)
|
|
|
|
```sql
|
|
CREATE TABLE notifications.notification_queue (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
notification_id UUID NOT NULL REFERENCES notifications.notifications(id) ON DELETE CASCADE,
|
|
|
|
-- Channel
|
|
channel notifications.channel NOT NULL,
|
|
|
|
-- Scheduling
|
|
scheduled_for TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
-- Priority (numerico para ordenamiento)
|
|
priority_value INT DEFAULT 0, -- urgent=10, high=5, normal=0, low=-5
|
|
|
|
-- Processing
|
|
attempts INT DEFAULT 0,
|
|
max_attempts INT DEFAULT 3,
|
|
status notifications.queue_status DEFAULT 'queued',
|
|
|
|
-- Timestamps
|
|
last_attempt_at TIMESTAMPTZ,
|
|
next_retry_at TIMESTAMPTZ,
|
|
completed_at TIMESTAMPTZ,
|
|
|
|
-- Error tracking
|
|
error_message TEXT,
|
|
error_count INT DEFAULT 0,
|
|
|
|
-- Audit
|
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
|
);
|
|
|
|
-- Indexes para procesamiento eficiente
|
|
CREATE INDEX idx_queue_pending ON notifications.notification_queue(scheduled_for, priority_value DESC)
|
|
WHERE status IN ('queued', 'retrying');
|
|
CREATE INDEX idx_queue_status ON notifications.notification_queue(status);
|
|
CREATE INDEX idx_queue_notification ON notifications.notification_queue(notification_id);
|
|
CREATE INDEX idx_queue_retry ON notifications.notification_queue(next_retry_at)
|
|
WHERE status = 'retrying' AND next_retry_at IS NOT NULL;
|
|
|
|
COMMENT ON TABLE notifications.notification_queue IS 'Cola de procesamiento asincrono de notificaciones';
|
|
```
|
|
|
|
**6. notifications.notification_logs** (NUEVA)
|
|
|
|
```sql
|
|
CREATE TABLE notifications.notification_logs (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
notification_id UUID NOT NULL REFERENCES notifications.notifications(id) ON DELETE CASCADE,
|
|
queue_id UUID REFERENCES notifications.notification_queue(id),
|
|
|
|
-- Channel info
|
|
channel notifications.channel NOT NULL,
|
|
|
|
-- Delivery status
|
|
status VARCHAR(30) NOT NULL, -- 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'failed'
|
|
|
|
-- Provider info
|
|
provider VARCHAR(50), -- 'sendgrid', 'ses', 'web-push'
|
|
provider_message_id VARCHAR(200),
|
|
provider_response JSONB,
|
|
|
|
-- Timestamps
|
|
delivered_at TIMESTAMPTZ,
|
|
opened_at TIMESTAMPTZ,
|
|
clicked_at TIMESTAMPTZ,
|
|
|
|
-- Error details
|
|
error_code VARCHAR(50),
|
|
error_message TEXT,
|
|
|
|
-- Audit
|
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_logs_notification ON notifications.notification_logs(notification_id);
|
|
CREATE INDEX idx_logs_status ON notifications.notification_logs(status, created_at DESC);
|
|
CREATE INDEX idx_logs_channel ON notifications.notification_logs(channel, created_at DESC);
|
|
|
|
COMMENT ON TABLE notifications.notification_logs IS 'Historial de entregas y eventos de notificaciones';
|
|
```
|
|
|
|
### 4.2 Funciones SQL
|
|
|
|
```sql
|
|
-- ==============================================
|
|
-- FUNCION: Enviar notificacion (encolar)
|
|
-- ==============================================
|
|
CREATE OR REPLACE FUNCTION notifications.send_notification(
|
|
p_user_id UUID,
|
|
p_tenant_id UUID,
|
|
p_template_code VARCHAR,
|
|
p_variables JSONB DEFAULT '{}',
|
|
p_priority VARCHAR DEFAULT 'normal'
|
|
) RETURNS UUID AS $$
|
|
DECLARE
|
|
v_notification_id UUID;
|
|
v_template RECORD;
|
|
v_preferences RECORD;
|
|
v_channel VARCHAR;
|
|
v_priority_value INT;
|
|
v_subject TEXT;
|
|
v_body TEXT;
|
|
BEGIN
|
|
-- Obtener template
|
|
SELECT * INTO v_template
|
|
FROM notifications.templates
|
|
WHERE code = p_template_code AND is_active = TRUE;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'Template % not found', p_template_code;
|
|
END IF;
|
|
|
|
-- Obtener preferencias del usuario
|
|
SELECT * INTO v_preferences
|
|
FROM notifications.user_preferences
|
|
WHERE user_id = p_user_id AND tenant_id = p_tenant_id;
|
|
|
|
-- Calcular priority value
|
|
v_priority_value := CASE p_priority
|
|
WHEN 'urgent' THEN 10
|
|
WHEN 'high' THEN 5
|
|
WHEN 'normal' THEN 0
|
|
WHEN 'low' THEN -5
|
|
ELSE 0
|
|
END;
|
|
|
|
-- Renderizar template (basico - reemplazar {{variables}})
|
|
v_subject := v_template.subject;
|
|
v_body := v_template.body;
|
|
|
|
FOR key, value IN SELECT * FROM jsonb_each_text(p_variables)
|
|
LOOP
|
|
v_subject := REPLACE(v_subject, '{{' || key || '}}', value);
|
|
v_body := REPLACE(v_body, '{{' || key || '}}', value);
|
|
END LOOP;
|
|
|
|
-- Crear notificacion base
|
|
INSERT INTO notifications.notifications (
|
|
tenant_id, user_id, template_id, template_code,
|
|
channel, subject, body, body_html,
|
|
status, priority, metadata
|
|
) VALUES (
|
|
p_tenant_id, p_user_id, v_template.id, p_template_code,
|
|
v_template.channel, v_subject, v_body, v_template.body_html,
|
|
'pending', p_priority::notifications.priority, p_variables
|
|
) RETURNING id INTO v_notification_id;
|
|
|
|
-- Encolar para cada canal habilitado
|
|
-- In-app siempre
|
|
IF v_preferences IS NULL OR v_preferences.in_app_enabled THEN
|
|
INSERT INTO notifications.notification_queue (
|
|
notification_id, channel, priority_value, status
|
|
) VALUES (
|
|
v_notification_id, 'in_app', v_priority_value, 'queued'
|
|
);
|
|
END IF;
|
|
|
|
-- Email si habilitado
|
|
IF v_template.channel = 'email' AND (v_preferences IS NULL OR v_preferences.email_enabled) THEN
|
|
INSERT INTO notifications.notification_queue (
|
|
notification_id, channel, priority_value, status
|
|
) VALUES (
|
|
v_notification_id, 'email', v_priority_value, 'queued'
|
|
);
|
|
END IF;
|
|
|
|
-- Push si habilitado y hay dispositivos
|
|
IF (v_preferences IS NULL OR v_preferences.push_enabled) AND EXISTS (
|
|
SELECT 1 FROM notifications.user_devices
|
|
WHERE user_id = p_user_id AND is_active = TRUE
|
|
) THEN
|
|
INSERT INTO notifications.notification_queue (
|
|
notification_id, channel, priority_value, status
|
|
) VALUES (
|
|
v_notification_id, 'push', v_priority_value, 'queued'
|
|
);
|
|
END IF;
|
|
|
|
RETURN v_notification_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ==============================================
|
|
-- FUNCION: Obtener items pendientes de la cola
|
|
-- ==============================================
|
|
CREATE OR REPLACE FUNCTION notifications.get_pending_queue_items(
|
|
p_limit INT DEFAULT 100
|
|
) RETURNS TABLE (
|
|
id UUID,
|
|
notification_id UUID,
|
|
channel notifications.channel,
|
|
priority_value INT,
|
|
attempts INT,
|
|
user_id UUID,
|
|
tenant_id UUID,
|
|
subject VARCHAR,
|
|
body TEXT,
|
|
body_html TEXT,
|
|
recipient_email VARCHAR,
|
|
device_tokens TEXT[]
|
|
) AS $$
|
|
BEGIN
|
|
RETURN QUERY
|
|
WITH pending AS (
|
|
SELECT q.*
|
|
FROM notifications.notification_queue q
|
|
WHERE q.status IN ('queued', 'retrying')
|
|
AND (q.scheduled_for IS NULL OR q.scheduled_for <= NOW())
|
|
AND (q.next_retry_at IS NULL OR q.next_retry_at <= NOW())
|
|
ORDER BY q.priority_value DESC, q.created_at ASC
|
|
LIMIT p_limit
|
|
FOR UPDATE SKIP LOCKED
|
|
)
|
|
SELECT
|
|
p.id,
|
|
p.notification_id,
|
|
p.channel,
|
|
p.priority_value,
|
|
p.attempts,
|
|
n.user_id,
|
|
n.tenant_id,
|
|
n.subject,
|
|
n.body,
|
|
n.body_html,
|
|
n.recipient_email,
|
|
ARRAY(
|
|
SELECT d.device_token
|
|
FROM notifications.user_devices d
|
|
WHERE d.user_id = n.user_id AND d.is_active = TRUE
|
|
) AS device_tokens
|
|
FROM pending p
|
|
JOIN notifications.notifications n ON n.id = p.notification_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ==============================================
|
|
-- FUNCION: Marcar item de cola como procesado
|
|
-- ==============================================
|
|
CREATE OR REPLACE FUNCTION notifications.complete_queue_item(
|
|
p_queue_id UUID,
|
|
p_status VARCHAR,
|
|
p_error_message TEXT DEFAULT NULL
|
|
) RETURNS VOID AS $$
|
|
DECLARE
|
|
v_attempts INT;
|
|
v_max_attempts INT;
|
|
BEGIN
|
|
-- Obtener intentos actuales
|
|
SELECT attempts, max_attempts INTO v_attempts, v_max_attempts
|
|
FROM notifications.notification_queue
|
|
WHERE id = p_queue_id;
|
|
|
|
IF p_status = 'sent' THEN
|
|
UPDATE notifications.notification_queue
|
|
SET status = 'sent',
|
|
completed_at = NOW(),
|
|
last_attempt_at = NOW(),
|
|
attempts = v_attempts + 1
|
|
WHERE id = p_queue_id;
|
|
ELSIF p_status = 'failed' THEN
|
|
IF v_attempts + 1 >= v_max_attempts THEN
|
|
-- Fallo definitivo
|
|
UPDATE notifications.notification_queue
|
|
SET status = 'failed',
|
|
last_attempt_at = NOW(),
|
|
attempts = v_attempts + 1,
|
|
error_message = p_error_message,
|
|
error_count = error_count + 1
|
|
WHERE id = p_queue_id;
|
|
ELSE
|
|
-- Programar retry con backoff exponencial
|
|
UPDATE notifications.notification_queue
|
|
SET status = 'retrying',
|
|
last_attempt_at = NOW(),
|
|
attempts = v_attempts + 1,
|
|
next_retry_at = NOW() + (POWER(2, v_attempts) * INTERVAL '1 minute'),
|
|
error_message = p_error_message,
|
|
error_count = error_count + 1
|
|
WHERE id = p_queue_id;
|
|
END IF;
|
|
END IF;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Backend - Servicios
|
|
|
|
### 5.1 Estructura de Archivos
|
|
|
|
```
|
|
apps/backend/src/modules/notifications/
|
|
├── notifications.module.ts
|
|
├── controllers/
|
|
│ ├── notifications.controller.ts # CRUD notificaciones
|
|
│ ├── preferences.controller.ts # Preferencias usuario
|
|
│ └── devices.controller.ts # Registro dispositivos
|
|
├── services/
|
|
│ ├── notifications.service.ts # Orquestacion
|
|
│ ├── notification-queue.service.ts # Procesamiento cola
|
|
│ ├── push-notification.service.ts # Web Push API
|
|
│ └── template.service.ts # Renderizado templates
|
|
├── gateways/
|
|
│ └── notifications.gateway.ts # WebSocket real-time
|
|
├── processors/
|
|
│ └── notification.processor.ts # BullMQ worker
|
|
├── dto/
|
|
│ ├── create-notification.dto.ts
|
|
│ ├── send-template.dto.ts
|
|
│ ├── update-preferences.dto.ts
|
|
│ └── register-device.dto.ts
|
|
├── entities/
|
|
│ ├── notification.entity.ts
|
|
│ ├── notification-template.entity.ts
|
|
│ ├── user-preference.entity.ts
|
|
│ ├── user-device.entity.ts
|
|
│ ├── notification-queue.entity.ts
|
|
│ └── notification-log.entity.ts
|
|
└── __tests__/
|
|
├── notifications.service.spec.ts
|
|
├── push-notification.service.spec.ts
|
|
└── notification.processor.spec.ts
|
|
```
|
|
|
|
### 5.2 PushNotificationService
|
|
|
|
```typescript
|
|
// Implementacion usando web-push (sin Firebase)
|
|
import * as webpush from 'web-push';
|
|
|
|
@Injectable()
|
|
export class PushNotificationService implements OnModuleInit {
|
|
private readonly logger = new Logger(PushNotificationService.name);
|
|
|
|
onModuleInit() {
|
|
// Configurar VAPID
|
|
webpush.setVapidDetails(
|
|
process.env.VAPID_SUBJECT || 'mailto:admin@example.com',
|
|
process.env.VAPID_PUBLIC_KEY,
|
|
process.env.VAPID_PRIVATE_KEY,
|
|
);
|
|
}
|
|
|
|
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 },
|
|
});
|
|
|
|
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) {
|
|
// Subscription expirada, marcar como inactiva
|
|
device.is_active = false;
|
|
await this.deviceRepository.save(device);
|
|
this.logger.warn(`Device ${device.id} subscription expired, marked inactive`);
|
|
}
|
|
results.push({ deviceId: device.id, success: false, error: error.message });
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
getVapidPublicKey(): string {
|
|
return process.env.VAPID_PUBLIC_KEY;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.3 NotificationsGateway (WebSocket)
|
|
|
|
```typescript
|
|
@WebSocketGateway({
|
|
namespace: '/notifications',
|
|
cors: { origin: '*' },
|
|
})
|
|
export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|
@WebSocketServer()
|
|
server: Server;
|
|
|
|
private userSockets = new Map<string, Set<string>>(); // userId -> socketIds
|
|
|
|
handleConnection(client: Socket) {
|
|
const userId = this.extractUserId(client);
|
|
if (userId) {
|
|
if (!this.userSockets.has(userId)) {
|
|
this.userSockets.set(userId, new Set());
|
|
}
|
|
this.userSockets.get(userId).add(client.id);
|
|
}
|
|
}
|
|
|
|
handleDisconnect(client: Socket) {
|
|
const userId = this.extractUserId(client);
|
|
if (userId && this.userSockets.has(userId)) {
|
|
this.userSockets.get(userId).delete(client.id);
|
|
}
|
|
}
|
|
|
|
// Llamado por NotificationService cuando se crea notificacion in-app
|
|
async emitToUser(userId: string, notification: Notification) {
|
|
const socketIds = this.userSockets.get(userId);
|
|
if (socketIds) {
|
|
for (const socketId of socketIds) {
|
|
this.server.to(socketId).emit('notification:created', notification);
|
|
}
|
|
}
|
|
}
|
|
|
|
@SubscribeMessage('notification:read')
|
|
async handleMarkAsRead(client: Socket, notificationId: string) {
|
|
// Emitir a otros dispositivos del usuario
|
|
const userId = this.extractUserId(client);
|
|
this.server.to([...this.userSockets.get(userId)]).emit('notification:read', notificationId);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.4 NotificationProcessor (BullMQ)
|
|
|
|
```typescript
|
|
@Processor('notifications')
|
|
export class NotificationProcessor {
|
|
constructor(
|
|
private readonly emailService: EmailService,
|
|
private readonly pushService: PushNotificationService,
|
|
private readonly gateway: NotificationsGateway,
|
|
private readonly queueService: NotificationQueueService,
|
|
) {}
|
|
|
|
@Process('send')
|
|
async handleSend(job: Job<NotificationJobData>) {
|
|
const { queueItemId, channel, notification } = job.data;
|
|
|
|
try {
|
|
switch (channel) {
|
|
case 'email':
|
|
await this.processEmail(notification);
|
|
break;
|
|
case 'push':
|
|
await this.processPush(notification);
|
|
break;
|
|
case 'in_app':
|
|
await this.processInApp(notification);
|
|
break;
|
|
}
|
|
|
|
await this.queueService.completeItem(queueItemId, 'sent');
|
|
} catch (error) {
|
|
await this.queueService.completeItem(queueItemId, 'failed', error.message);
|
|
throw error; // BullMQ maneja retry
|
|
}
|
|
}
|
|
|
|
private async processEmail(notification: NotificationData) {
|
|
await this.emailService.sendEmail({
|
|
to: { email: notification.recipientEmail },
|
|
subject: notification.subject,
|
|
html: notification.bodyHtml || notification.body,
|
|
text: notification.body,
|
|
});
|
|
}
|
|
|
|
private async processPush(notification: NotificationData) {
|
|
await this.pushService.sendToUser(
|
|
notification.userId,
|
|
notification.tenantId,
|
|
{
|
|
title: notification.subject,
|
|
body: notification.body,
|
|
data: notification.metadata,
|
|
},
|
|
);
|
|
}
|
|
|
|
private async processInApp(notification: NotificationData) {
|
|
await this.gateway.emitToUser(notification.userId, notification);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Endpoints API
|
|
|
|
### 6.1 Notificaciones
|
|
|
|
| Metodo | Endpoint | Descripcion | Autorizacion |
|
|
|--------|----------|-------------|--------------|
|
|
| GET | /notifications | Listar mis notificaciones | Usuario |
|
|
| GET | /notifications/unread-count | Contador no leidas | Usuario |
|
|
| PATCH | /notifications/:id/read | Marcar como leida | Usuario |
|
|
| POST | /notifications/read-all | Marcar todas como leidas | Usuario |
|
|
| DELETE | /notifications/:id | Eliminar | Usuario |
|
|
| POST | /notifications/send | Enviar notificacion manual | Admin |
|
|
| POST | /notifications/send-template | Enviar desde template | Admin |
|
|
|
|
### 6.2 Preferencias
|
|
|
|
| Metodo | Endpoint | Descripcion | Autorizacion |
|
|
|--------|----------|-------------|--------------|
|
|
| GET | /notifications/preferences | Obtener preferencias | Usuario |
|
|
| PATCH | /notifications/preferences | Actualizar preferencias | Usuario |
|
|
|
|
### 6.3 Dispositivos (Push)
|
|
|
|
| Metodo | Endpoint | Descripcion | Autorizacion |
|
|
|--------|----------|-------------|--------------|
|
|
| GET | /notifications/devices | Mis dispositivos | Usuario |
|
|
| POST | /notifications/devices | Registrar dispositivo | Usuario |
|
|
| DELETE | /notifications/devices/:id | Eliminar dispositivo | Usuario |
|
|
| GET | /notifications/devices/vapid-key | Clave VAPID publica | Publico |
|
|
|
|
### 6.4 Templates (Admin)
|
|
|
|
| Metodo | Endpoint | Descripcion | Autorizacion |
|
|
|--------|----------|-------------|--------------|
|
|
| GET | /notifications/templates | Listar templates | Admin |
|
|
| GET | /notifications/templates/:code | Obtener template | Admin |
|
|
| POST | /notifications/templates | Crear template | SuperAdmin |
|
|
| PATCH | /notifications/templates/:id | Actualizar template | SuperAdmin |
|
|
|
|
---
|
|
|
|
## 7. Frontend
|
|
|
|
### 7.1 Componentes Requeridos
|
|
|
|
| Componente | Estado | Proposito |
|
|
|------------|--------|-----------|
|
|
| NotificationDrawer | Existente | Panel lateral de notificaciones |
|
|
| NotificationItem | Existente | Item individual |
|
|
| NotificationSettings | Existente | Configurar preferencias |
|
|
| PushPermissionBanner | NUEVO | Solicitar permiso push |
|
|
| DevicesManager | NUEVO | Gestionar dispositivos registrados |
|
|
|
|
### 7.2 Hooks Nuevos
|
|
|
|
```typescript
|
|
// Push notifications
|
|
usePushPermission(): { permission: string; requestPermission: () => Promise<void> }
|
|
useRegisterDevice(): UseMutationResult
|
|
useUnregisterDevice(): UseMutationResult
|
|
useDevices(): UseQueryResult<Device[]>
|
|
useVapidPublicKey(): UseQueryResult<string>
|
|
|
|
// WebSocket
|
|
useNotificationSocket(): {
|
|
isConnected: boolean;
|
|
notifications: Notification[];
|
|
onNewNotification: (callback) => void;
|
|
}
|
|
```
|
|
|
|
### 7.3 Service Worker
|
|
|
|
```javascript
|
|
// public/sw.js - Para recibir push notifications
|
|
self.addEventListener('push', (event) => {
|
|
const data = event.data?.json() || {};
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(data.title || 'Nueva notificacion', {
|
|
body: data.body,
|
|
icon: '/icon-192.png',
|
|
badge: '/badge-72.png',
|
|
data: data.data,
|
|
actions: data.actions,
|
|
})
|
|
);
|
|
});
|
|
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
|
|
if (event.action === 'view' && event.notification.data?.url) {
|
|
event.waitUntil(clients.openWindow(event.notification.data.url));
|
|
}
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Configuracion
|
|
|
|
### 8.1 Variables de Entorno
|
|
|
|
```bash
|
|
# Email (existente)
|
|
EMAIL_PROVIDER=sendgrid # 'sendgrid' | 'ses' | 'smtp'
|
|
SENDGRID_API_KEY=SG.xxx
|
|
EMAIL_FROM=noreply@example.com
|
|
|
|
# Push Notifications (NUEVO)
|
|
VAPID_PUBLIC_KEY=BN4GvZtEZiZuqFJi...
|
|
VAPID_PRIVATE_KEY=aB3cDefGh4IjKlM5...
|
|
VAPID_SUBJECT=mailto:admin@example.com
|
|
|
|
# Queue (Redis - existente para webhooks)
|
|
REDIS_HOST=localhost
|
|
REDIS_PORT=6379
|
|
|
|
# WebSocket
|
|
WS_PORT=3001 # Si separado del HTTP
|
|
```
|
|
|
|
### 8.2 Generar Claves VAPID
|
|
|
|
```bash
|
|
# Instalar web-push globalmente o como dev dependency
|
|
npm install -g web-push
|
|
|
|
# Generar par de claves
|
|
web-push generate-vapid-keys
|
|
|
|
# Output:
|
|
# Public Key: BN4GvZtEZiZuqFJiLNpT...
|
|
# Private Key: aB3cDefGh4IjKlM5nOpQr6StUvWxYz...
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Plan de Implementacion
|
|
|
|
### 9.1 Fase 1: DDL y Entidades (2-3 horas)
|
|
|
|
1. Agregar enums nuevos a 02-enums.sql
|
|
2. Crear tabla user_devices
|
|
3. Crear tabla notification_queue
|
|
4. Crear tabla notification_logs
|
|
5. Crear funciones SQL
|
|
6. Actualizar seeds si necesario
|
|
7. Validar con recreacion de BD
|
|
|
|
### 9.2 Fase 2: Backend Core (4-5 horas)
|
|
|
|
1. Crear entidades TypeORM nuevas
|
|
2. Implementar PushNotificationService
|
|
3. Implementar NotificationQueueService
|
|
4. Implementar NotificationsGateway (WebSocket)
|
|
5. Crear NotificationProcessor (BullMQ)
|
|
6. Actualizar NotificationsService
|
|
7. Agregar endpoints de devices
|
|
|
|
### 9.3 Fase 3: Frontend (3-4 horas)
|
|
|
|
1. Agregar service worker para push
|
|
2. Crear hooks de push
|
|
3. Implementar PushPermissionBanner
|
|
4. Implementar DevicesManager
|
|
5. Conectar WebSocket para real-time
|
|
6. Actualizar NotificationSettings
|
|
|
|
### 9.4 Fase 4: Testing y Documentacion (2-3 horas)
|
|
|
|
1. Tests unitarios servicios nuevos
|
|
2. Tests integracion endpoints
|
|
3. Actualizar documentacion SAAS-007
|
|
4. Actualizar PROJECT-STATUS.md
|
|
5. Actualizar _MAP.md del proyecto
|
|
|
|
---
|
|
|
|
## 10. Dependencias NPM
|
|
|
|
### Backend
|
|
```json
|
|
{
|
|
"web-push": "^3.6.7",
|
|
"@nestjs/websockets": "^10.x",
|
|
"@nestjs/platform-socket.io": "^10.x",
|
|
"socket.io": "^4.7.x"
|
|
}
|
|
```
|
|
|
|
### Frontend
|
|
```json
|
|
{
|
|
"socket.io-client": "^4.7.x"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 11. Compatibilidad
|
|
|
|
### 11.1 Navegadores Web Push
|
|
|
|
| Navegador | Version Minima | Notas |
|
|
|-----------|----------------|-------|
|
|
| Chrome | 50+ | Soporte completo |
|
|
| Firefox | 44+ | Soporte completo |
|
|
| Edge | 17+ | Soporte completo |
|
|
| Safari | 16.4+ | Requiere iOS 16.4+ |
|
|
| Opera | 37+ | Soporte completo |
|
|
|
|
### 11.2 Node.js
|
|
|
|
- Minimo: Node.js 18 LTS
|
|
- Recomendado: Node.js 20 LTS
|
|
|
|
---
|
|
|
|
## 12. Metricas de Exito
|
|
|
|
| Metrica | Objetivo |
|
|
|---------|----------|
|
|
| Tasa de entrega email | > 98% |
|
|
| Tasa de entrega push | > 95% |
|
|
| Latencia in-app | < 100ms |
|
|
| Cobertura tests | > 80% |
|
|
| Tiempo procesamiento cola | < 5 seg promedio |
|
|
|
|
---
|
|
|
|
## 13. Referencias
|
|
|
|
- [Novu - Open-source notifications](https://novu.co/)
|
|
- [NotifMe SDK](https://github.com/notifme/notifme-sdk)
|
|
- [Web Push Protocol](https://datatracker.ietf.org/doc/html/rfc8030)
|
|
- [VAPID for Web Push](https://datatracker.ietf.org/doc/html/rfc8292)
|
|
- [gamilit EXT-003 Implementation](../../../gamilit/docs/03-fase-extensiones/EXT-003-notificaciones/)
|
|
|
|
---
|
|
|
|
**Ultima actualizacion:** 2026-01-07
|
|
**Autor:** Claude Code
|
|
**Revision:** Pendiente aprobacion
|