Files
HoruxDespachos/docs/plans/2026-04-26-notifications-email.md
2026-04-27 22:09:36 -06:00

9.0 KiB
Raw Permalink Blame History

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

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

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:

    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".