Files
jorgecuadros-platform/docs/MASS_EMAIL_NOTIFICATIONS.md
T
rmancinasandClaude Opus 5 33833c3af9
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m30s
Build and Push Images / Build jorgecuadros-api (push) Failing after 3h13m42s
feat(notificaciones): one send log across servicios and pólizas
Renewal avisos left behind only a `RenewalNotice` row, whose sole job is
gating: a row with `sentAt` drops the policy off the pending list. It
cannot represent a failed send or a customer with no address, so the
Pólizas tab had no "Registro de envíos" to show and a sent notice simply
vanished from the list.

Renewals now write `email_notification_log` — the same table the four
bulk jobs write — as `RENEWAL_NOTICE` / `POLICIES`, with rows for
failures and no-email skips too. `RenewalNotice` keeps its gating role
unchanged; the two are complementary, not redundant.

- extend `EmailNotificationType` (+RENEWAL_NOTICE) and
  `EmailNotificationServicio` (+POLICIES); `level` now carries the aviso
  generation on renewal rows, so every reader must branch on the type
  first (`notificationLevelLabel()` is the one place that lives)
- backfill emailed notices (`channel = 'EMAIL'`) into the log; MAIL-channel
  rows are legacy printed letters and are deliberately left out
- extract `NotificationLogService`/`NotificationLogModule` as the single
  writer, so a feature that sends mail records it without pulling the
  bulk-job pipelines into its module
- `GET /notifications/log` and `/stats` take a comma-separated `servicio`
  list; each tab reads its own slice. This also fixes the "Omitidos"
  view, which mapped to no filter at all and showed every row
- share one `NotificationLogPanel` between both tabs
- pass SES_* / NOTIFICATION_ADMIN_EMAILS through the galactus compose,
  which was missing them entirely — mail is runtime config, not a CI
  secret, and the prod image sets NODE_ENV=production so a blank config
  fails loudly instead of falling back to stdout

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 03:01:03 -07:00

8.6 KiB

Mass Email Notifications

Modern replacement for the four PHP scripts under email.notifications/send*.php that fired bulk emails off the legacy utility_dbo.email_alert_log table. Lives in this codebase from massive-email-notification onward; the PHP scripts stay operational until the office flips over.

Why

The legacy scripts did three things this app needed to keep doing: send outstanding-payment reminders, send payment-confirmation letters, and fire account-status alerts (red and yellow). They also sent a fourth trust-payment confirmation tied to TRUSTHFEE. Each was a separate CGI script the office hit manually or via cron, talking to utility_dbo over the same mysqli connection as the rest of the portal.

The unified schema (see PLAN.md and docs/INSURANCE_FEATURES_SPEC.md) folded datosfreak and TRUSTHFEE into customers + transactions + trust_accounts, so the scripts' SQL no longer maps to anything. Rather than maintain parallel sync code to keep utility_dbo populated, this feature ports the four jobs onto the unified data and writes its own log.

What ships

  • apps/api/src/mail/ — outbound mail transport. Amazon SES (matches the StorageService env-driven optional-client pattern). Dev falls back to stdout logging so a fresh checkout can exercise the jobs without SES credentials.
  • apps/api/src/notifications/ — the four jobs (outstanding, payment-confirm, account-status, trust-confirm), each a public service method + a POST /notifications/{slug} HTTP endpoint gated on the new notification:send ability (MANAGER).
  • packages/database/prisma/migrations/20260801200000_mass_email_notifications/migration.sql — two new tables (email_notification_log, account_status_history) with enums and FKs to customers.
  • apps/web/src/app/notificaciones/ — admin page with 4 trigger cards, a flags panel, a transport-status header, and a paginated log browser.

Job semantics

Preserved from the PHP originals (see ~/Documents/Claude-Memory/email-notifications-spec.md):

Job Recipients Subject Response key
1. Outstanding payments Customers with ≥1 outstanding Transaction (amount<0) "Jorge Cuadros - Outstanding Payments" result:"success", notificationType:"sendPaymentConfirmation"
2. Payment confirmation Customers with a credit in last 24h "Jorge Cuadros - Payment Confirmation" request:"success", notificationType:"sendPaymentConfirmation"
3. Account status All customers with a balance; yellow/red thresholds "Jorge Cuadros - Account Status Alert" request:"success", notificationType:"sendAccountStatus"
4. Trust confirmation Customers with TrustAccount + recent TRUST-domain credit "Jorge Cuadros - Trust Payment Confirmation" request:"success", notificationType:"sendTrustPaymentConfirmation"

Wire shapes match the PHP originals byte-for-byte so anything downstream that scrapes notificationType:"sendPaymentConfirmation" keeps working. Job 1 reports result (not request) and notificationType literally sendPaymentConfirmation — these are the legacy quirks, preserved.

Day gates (Job 3 only)

  • Yellow ("DEBAJO DEL TIPO"): Wed only (or ignoreDayRestriction).
  • Red ("EN ROJO"): Mon/Wed/Fri only (or ignoreDayRestriction).
  • A customer who is red on Tuesday is logged as SKIPPED_GATE until Wed, when both checks can fire on the same row.

Threshold logic (Job 3)

The PHP used datosfreak.TIPO (50/100/200/300/500) and a hardcoded threshold table. The new schema encodes this as Customer.minimumBalance:

  • Yellow: 0 ≤ balance < minimumBalance
  • Red: balance < 0

Per-currency balance uses BillingService.balances() semantics (signed SUM(transactions.amount), voided + outstanding excluded), so a yellow/red alert always lines up with what the receivables worklist shows staff. The customer-servicing letter reports in USD because the legacy letter was always USD; the union of balanceUsd and balanceMxn is reported per-customer, never collapsed (see BillingService.balances()).

Rate limit (Job 3 only)

useEmailLimit=true enables a vestigial throttle: pause the sweep 1h after 100 sends. Off by default; SES does not need it.

Tables

email_notification_log

One row per send attempt (sent, failed, skipped). Carries the rendered body verbatim so a customer reply quoting an old email can be traced to the exact letter sent. SES MessageId stored for bounce/complaint correlation.

Indexes: (sendDate), (notificationType, sendDate), (customerId, sendDate).

This table is not job-specific. Insurance renewal avisos (RenewalsService, see RENEWAL_NOTICES.md) write here too, as notificationType = RENEWAL_NOTICE / servicio = POLICIES — one send history for the whole platform rather than one per feature. NotificationLogService is the only writer; anything that sends mail goes through it.

level is therefore per-type and cannot be read without its notificationType: 0/1 (yellow/red) on ACCOUNT_STATUS, the aviso generation 1/2/3 on RENEWAL_NOTICE, null elsewhere. On the web side notificationLevelLabel() is the only place that branch lives.

Renewals keep their own renewal_notices row as well. The two are not redundant: renewal_notices is gating state (one row per policy+generation, "already notified" — it drives the pending list), while this log is history (every attempt, including the failures and no-email skips a gating row cannot represent).

account_status_history

Mirrors the legacy utility_dbo.send_account_status_history table: (customerId, customerName, customerEmail, tipo, tCambio, balance, solicitado, level). tipo is the literal "DEBAJO DEL TIPO" or "EN ROJO" string the PHP used. solicitado keeps the legacy formula (0 - TIPO - BALANCE) even though it double-subtracts; downstream reports depend on the exact figure.

Indexes: (sendDate), (customerId, sendDate), (level, sendDate).

Environment

SES_REGION=us-east-1
SES_ACCESS_KEY=...
SES_SECRET_KEY=...
SES_FROM=mail@jorgecuadros.com
SES_FROM_NAME=Information Server
SES_CONFIGURATION_SET=...      # optional
NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net

Without SES_* the API still boots and MailService falls back to stdout in dev (NODE_ENV !== "production"). In production every send throws ServiceUnavailableException and the row is recorded as FAILED.

These are runtime config, set in the deployed stack's env file on the host (deploy/jorgecuadros-app.env.example documents the full set) — not Gitea Actions secrets. The build never sends mail; only the running container does, and the production image sets NODE_ENV=production, so a blank SES config fails loudly rather than falling back to stdout.

UI

/notificaciones, two tabs over the one log:

  • Servicios (notification:send) — four trigger cards, a debug/ignoreDayRestriction/useEmailLimit flags panel, a transport status header. Reads the CUSTOMERS + TRUST slice.
  • Pólizas (renewal:send) — pending avisos and the manual sweep. Reads the POLICIES slice.

Both render the same NotificationLogPanel ("Registro de envíos"), which filters by servicio and by view (todos / enviados / fallidos / omitidos). STAFF users see the Servicios log read-only.

Cron (future)

The four service methods (runOutstandingPayments, runPaymentConfirmation, runAccountStatus, runTrustConfirmation) are the entry points. A future @nestjs/schedule cron would call them on the legacy cadence (Job 3 on Mon/Wed/Fri, Job 2 daily, Jobs 1 + 4 ad-hoc). Pattern matches OpsService's single-running-job guard: one email_notification_sweep OpsJob per run, with its log streamed to OpsJob.log.

What is intentionally NOT in scope

  • Per-recipient preview / HTML view in the UI. The log table shows what was sent; previewing one requires fetching bodySnapshot and rendering HTML in the browser, deferred until a customer-service need surfaces.
  • Bounce / complaint webhooks. providerMessageId is captured so a future SNS topic can write back; the integration itself is a separate piece of work.
  • Spanish / English body toggle. Legacy letters are English; the legacy customer base is bilingual. Customer has no language preference. Add one when the need is concrete (same open question as INSURANCE_FEATURES_SPEC.md §1.6).
  • Importing the legacy utility_dbo.email_alert_log rows. They reference the old NUMid (a stringified double) which no longer maps to a unified customer; an import would be destructive.