Initial commit - Horux Despachos NL
This commit is contained in:
244
docs/plans/2026-04-26-notifications-email.md
Normal file
244
docs/plans/2026-04-26-notifications-email.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# 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".
|
||||
Reference in New Issue
Block a user