9.0 KiB
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:
- Alertas fiscales nuevas: una vez por alerta detectada (no se repite).
- 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
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_ides elidque retornagenerarAlertasAutomaticas(e.g.,'lista-negra-propia','discrepancia-regimen').contribuyente_idpuede ser NULL si la alerta es tenant-level. El UNIQUE compuesto conCOALESCE(... ::text, '')permite la combinación porque NULL no participa en UNIQUE de columna sola.resuelta_atse 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
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:
generarAlertasAutomaticas(pool, tenantId, contribuyenteId)→ lista de alertas activas (no persistidas).- Por cada alerta:
INSERT INTO alertas_notificadas ... ON CONFLICT DO NOTHING RETURNING id. Si retorna fila → era nueva, agregar a batch. - Marcar
resuelta_at = NOW()para alertas previamente notificadas que NO están activas hoy (alerta_id <> ALL($activos)). - 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']:
- Buscar recordatorios donde
completado = false,fecha_limite = CURRENT_DATE + diasVentana, yemail_Xd_at IS NULL. - 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_iddel contribuyente.carteras.auxiliar_user_idde carteras donde aparece el contribuyente (víacartera_entidades).cliente_accesos.user_idpara 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).
- Clientes con cualquier acceso (
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.tscuandoNODE_ENV === 'production'. SMTP debe estar configurado en.env(SMTP_HOST/PORT/USER/PASS/FROM). -
Dev: cron OMITIDO. Disparo manual:
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_notificadasqueda como log permanente (connotified_at+resuelta_at). Útil para auditar "¿se envió email cuando apareció esta alerta?". - Recordatorios: las 3 columnas
email_Xd_atdocumentan cuáles ventanas se enviaron.
8. Pendientes / mejoras posibles
- Notificación de resolución: hoy
resuelta_atse setea en silencio. Si el owner quiere "buena noticia: la alerta de lista negra desapareció" agregar templatealerta-resuelta.tsy disparar email cuandorowCount > 0en 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_preferencescon flags por categoría sería útil cuando aparezca el primer "demasiados emails" de un cliente. - Endpoint admin de disparo manual: cablear
runNotifications()aPOST /admin/notifications/runpara 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".