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

185 lines
8.6 KiB
Markdown

# 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`](../PLAN.md) and
[`docs/INSURANCE_FEATURES_SPEC.md`](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`](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`](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.