# 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`. ### 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(''); ``` 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".