Files
HoruxDespachosNuevo/docs/plans/2026-04-26-notifications-email.md

245 lines
9.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Notificaciones email automáticas — alertas y recordatorios (2026-04-26)
Cron diario 8:30 AM (America/Mexico_City) que envía dos tipos de email a
los responsables del despacho:
1. **Alertas fiscales nuevas**: una vez por alerta detectada (no se repite).
2. **Recordatorios próximos a vencer**: en 3 ventanas (3 días, 1 día, mismo día).
Cierra el pendiente histórico "notificaciones email automáticas de
alertas/recordatorios" que estaba en CLAUDE.md "Problemas conocidos".
---
## 1. Modelo de notificación elegido (Option B)
Después de evaluar 3 opciones (digest diario / por evento / híbrido) el
owner eligió **Option B — por evento**: notificar cuando algo se activa
por primera vez. Para alertas significa una sola email por (alerta_id,
contribuyente_id) en toda la vida; para recordatorios significa hasta tres
emails (uno por ventana) por recordatorio.
### Por qué no digest diario
El owner prefiere relevancia temporal sobre consolidación. Una alerta
nueva debe gatillar email; no esperar al "siguiente lunes" como hace
`weekly-update.job.ts`.
### Por qué no real-time (vs daily 8:30 AM)
Real-time requiere hooks en `generarAlertasAutomaticas` que se ejecuta on
each `/alertas` page load. Costoso. El cron diario captura el mismo set
de alertas con UX equivalente (el usuario no esperaba inmediatez sub-hora
para una alerta fiscal). Los recordatorios siempre se evalúan en función
de `fecha_limite ± días`, así que un cron al día es suficiente.
---
## 2. Esquema de datos (BD tenant)
### Migración 039 — `alertas_notificadas`
```sql
CREATE TABLE alertas_notificadas (
id BIGSERIAL PRIMARY KEY,
alerta_id TEXT NOT NULL,
contribuyente_id UUID,
notified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resuelta_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uniq_alertas_notif
ON alertas_notificadas (alerta_id, COALESCE(contribuyente_id::text, ''));
```
- `alerta_id` es el `id` que retorna `generarAlertasAutomaticas` (e.g.,
`'lista-negra-propia'`, `'discrepancia-regimen'`).
- `contribuyente_id` puede ser NULL si la alerta es tenant-level. El
UNIQUE compuesto con `COALESCE(... ::text, '')` permite la combinación
porque NULL no participa en UNIQUE de columna sola.
- `resuelta_at` se setea cuando una alerta previamente notificada deja
de aparecer en la corrida actual del cron. Solo informativo — no
genera email.
### Migración 040 — columnas en `recordatorios`
```sql
ALTER TABLE recordatorios
ADD COLUMN email_3d_at TIMESTAMPTZ,
ADD COLUMN email_1d_at TIMESTAMPTZ,
ADD COLUMN email_0d_at TIMESTAMPTZ;
```
Cada columna se setea cuando el cron envía el email para esa ventana.
Si el usuario edita `fecha_limite` después de un envío, las columnas
previas siguen marcadas — no se re-notifica para ventanas ya enviadas.
Decisión MVP simple y predecible.
---
## 3. Flujo del cron
`apps/api/src/jobs/notifications.job.ts` corre `30 8 * * *` America/Mexico_City:
```
runNotifications()
├─ FOR each tenant active:
│ ├─ runNotificationsForTenant(tenantId)
│ │ ├─ Promise.all:
│ │ │ ├─ processNewAlertas(pool, tenantId, ctx)
│ │ │ └─ processProximosRecordatorios(pool, tenantId, ctx)
│ │ └─ try/catch por proceso (un error no bloquea el otro)
│ └─ try/catch por tenant
└─ Logea resumen final
```
### `processNewAlertas`
Para cada contribuyente activo:
1. `generarAlertasAutomaticas(pool, tenantId, contribuyenteId)` → lista
de alertas activas (no persistidas).
2. Por cada alerta: `INSERT INTO alertas_notificadas ... ON CONFLICT DO
NOTHING RETURNING id`. Si retorna fila → era nueva, agregar a batch.
3. Marcar `resuelta_at = NOW()` para alertas previamente notificadas
que NO están activas hoy (`alerta_id <> ALL($activos)`).
4. Si hay alertas nuevas → resolver destinatarios y enviar email
batched (1 email con todas las alertas nuevas del contribuyente).
### `processProximosRecordatorios`
Para cada ventana `['3d', '1d', '0d']`:
1. Buscar recordatorios donde `completado = false`,
`fecha_limite = CURRENT_DATE + diasVentana`, y `email_Xd_at IS NULL`.
2. Por cada recordatorio: resolver destinatarios, enviar email,
`UPDATE email_Xd_at = NOW()`.
---
## 4. Resolución de destinatarios
### Alertas (contribuyente-específicas)
Conjunto = unión de:
- `entidades_gestionadas.supervisor_user_id` del contribuyente.
- `carteras.auxiliar_user_id` de carteras donde aparece el contribuyente
(vía `cartera_entidades`).
- `cliente_accesos.user_id` para ese contribuyente.
El owner del tenant queda incluido **solo si es supervisor de ese
contribuyente** (no se agrega por ser owner). Dedupe natural vía `Set<userId>`.
### Recordatorios (tenant-level, no atados a contribuyente)
- **Privado**: solo el `creado_por`.
- **Público**:
- Clientes con cualquier acceso (`cliente_accesos.user_id`).
- Auxiliares de cualquier cartera (`carteras.auxiliar_user_id`).
- **Si no hay auxiliares en absoluto**: agregar supervisores
(`entidades_gestionadas.supervisor_user_id` `carteras.supervisor_user_id`).
- **Si owner es supervisor y no hay auxiliares**: owner queda incluido
vía la lista de supervisores (intersección).
### Dedupe por usuario, no por email
Si el mismo user aparece como supervisor + auxiliar + cliente, el `Set`
sobre userId garantiza un solo email. El email se resuelve después con
`prisma.user.findMany({ where: { id: { in: userIds }, active: true } })`.
---
## 5. Templates email
### `alertas-nuevas.ts`
Header con conteo total + breakdown por nivel (alta/media/baja con
badges de color SAT-style). Lista de items con borde izquierdo del color
del nivel. Botón "Ver alertas en el sistema" → `${FRONTEND_URL}/alertas`.
Footer aclaratorio: "Estas alertas ya fueron registradas — solo te
avisaremos cuando aparezcan nuevas, no se repetirá esta notificación si
la misma alerta sigue activa."
### `recordatorio-proximo.ts`
Subject incluye prefijo según ventana: `🗓 / ⚠️ / ⏰` + label "en 3 días"
/ "mañana" / "HOY". Body resalta `fecha_limite` con color del nivel de
urgencia (azul/amarillo/rojo). Link → `${FRONTEND_URL}/calendario`.
---
## 6. Archivos creados/modificados
```
apps/api/src/migrations/tenant/039_alertas_notificadas.sql [+]
apps/api/src/migrations/tenant/040_recordatorios_email_notif.sql [+]
apps/api/src/services/email/templates/alertas-nuevas.ts [+]
apps/api/src/services/email/templates/recordatorio-proximo.ts [+]
apps/api/src/services/email/email.service.ts [~] +sendAlertasNuevas, +sendRecordatorioProximo
apps/api/src/services/notifications.service.ts [+]
apps/api/src/jobs/notifications.job.ts [+]
apps/api/src/index.ts [~] +startNotificationsJob (prod-only)
```
Migraciones aplicadas a 3 tenants existentes:
`horux_despacho_mo3nhzvl_1xheu`, `horux_despacho_mo3ni6u8_b9vgg`
(Patito), `horux_despacho_mo7je8bz_vdopr` (Zorro).
---
## 7. Operación
### Activación
- **Producción**: el cron arranca automáticamente en `index.ts` cuando
`NODE_ENV === 'production'`. SMTP debe estar configurado en `.env`
(`SMTP_HOST/PORT/USER/PASS/FROM`).
- **Dev**: cron OMITIDO. Disparo manual:
```ts
import { runNotificationsForTenant } from './jobs/notifications.job.js';
await runNotificationsForTenant('<tenantId>');
```
Para probar sin SMTP real, los emails se loguean a consola (transport
detecta SMTP_USER vacío y entra en modo "log only").
### Disparo manual desde admin
`runNotifications()` y `runNotificationsForTenant(tenantId)` están
exportados — se pueden cablear a un endpoint admin futuro tipo
`POST /admin/notifications/run` para forzar un envío.
### Trazabilidad
- Tabla `alertas_notificadas` queda como log permanente (con
`notified_at` + `resuelta_at`). Útil para auditar "¿se envió email
cuando apareció esta alerta?".
- Recordatorios: las 3 columnas `email_Xd_at` documentan cuáles ventanas
se enviaron.
---
## 8. Pendientes / mejoras posibles
- **Notificación de resolución**: hoy `resuelta_at` se setea en silencio.
Si el owner quiere "buena noticia: la alerta de lista negra desapareció"
agregar template `alerta-resuelta.ts` y disparar email cuando
`rowCount > 0` en el UPDATE de resolved.
- **Re-notificación tras resolución**: hoy MVP "una sola vez en la vida".
Si una alerta se resuelve y vuelve a activarse, no re-notifica. Cambio
pequeño: `DELETE alertas_notificadas WHERE resuelta_at IS NOT NULL AND
resuelta_at < NOW() - INTERVAL '30 days'` antes del INSERT permitiría
re-notificación tras 30 días.
- **Preferencias por usuario**: hoy el destinatario no puede opt-out de
notificaciones específicas. Tabla `user_notification_preferences` con
flags por categoría sería útil cuando aparezca el primer "demasiados
emails" de un cliente.
- **Endpoint admin de disparo manual**: cablear `runNotifications()` a
`POST /admin/notifications/run` para QA/debug.
- **Histórico de emails enviados**: audit-log entry por cada email
enviado (cuántos a quién) para soporte cuando un usuario diga "no me
llegó nada".