NOTIFICATION_ADMIN_EMAILS made "add Beto to the summaries" a redeploy — the wrong unit of work for a list that changes when office staff change. Adds `app_settings`, a key/value table for the configuration staff must be able to change without a deploy, and `SettingsService`, which resolves every key db -> env -> default and reports which of the three a value came from. That ladder is what makes the move safe: a deployment behaves exactly as before until somebody saves in the UI, and the screen can say "this is still coming from the deployment" rather than implying somebody chose it. - new ability `setting:manage` (ADMIN) — deliberately above `notification:send`, since redirecting the audit summaries is how someone would quietly stop them being read - GET/PUT /notifications/settings/admin-emails; read is open to any logged-in user so the UI can display the list, write is gated - resolved per job, not cached at boot, or we would reintroduce exactly the restart-to-apply behaviour being removed - a saved empty list means "nobody" and does NOT fall through to the env, or clearing the field would keep mailing the people just removed Credentials stay in env — see the model doc for where the line is drawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 lines
10 KiB
Markdown
215 lines
10 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 # fallback only
|
|
```
|
|
|
|
`NOTIFICATION_ADMIN_EMAILS` is no longer the source of truth. The summary
|
|
recipients are edited in the UI and stored in `app_settings`; the env var
|
|
is the fallback for a deployment where nobody has saved them yet. See
|
|
"Operator settings" below.
|
|
|
|
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 — read at container boot, never baked into the
|
|
image. For the Portainer deployments they are set as **Gitea repo secrets**
|
|
and injected into the stack env by the `env_data` block of
|
|
`.gitea/workflows/deploy-galactus.yml` (and `deploy.yml`), exactly like
|
|
`DATABASE_URL` and `SESSION_SECRET`. Unlike most secrets there they carry no
|
|
`_GALACTUS` suffix: one SES identity serves every deployment.
|
|
|
|
They are optional to *deploy* — the preflight only warns — but the
|
|
production image sets `NODE_ENV=production`, which disables the stdout dev
|
|
fallback, so a blank SES config makes every send fail loudly rather than
|
|
quietly going nowhere.
|
|
|
|
## 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.
|
|
|
|
## Operator settings
|
|
|
|
`app_settings` holds the configuration staff change without a redeploy.
|
|
`SettingsService` resolves every key **db → env → default**, and reports
|
|
which of the three a value came from so the UI can say so. Adding a key
|
|
means adding a typed accessor there, not a generic getter.
|
|
|
|
Currently one key: `notification.adminEmails` (summary recipients), edited
|
|
on the Servicios tab, gated on `setting:manage` (ADMIN — above
|
|
`notification:send`, because redirecting the audit summaries is how someone
|
|
would stop them being read). Read on every job rather than cached, so an
|
|
edit takes effect on the next sweep with no restart. An empty saved list
|
|
means "nobody" and deliberately does **not** fall through to the env.
|
|
|
|
Credentials do not belong here. SES keys, `DATABASE_URL` and S3 config stay
|
|
in the environment: they are deployment identity, they must exist before
|
|
the app can reach its own database, and a table only widens who can read
|
|
them.
|
|
|
|
## 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.
|