Replaces the four legacy PHP scripts under email.notifications/send*.php with a single NestJS module. Four jobs (outstanding payments, payment confirmations, account-status alerts with day-of-week gates, trust payment confirmations) share one MailService modelled on StorageService: env-driven SES client, null fallback in dev with console logging, refuses to send in production when unconfigured. Schema adds email_notification_log (every attempt, sent/failed/skipped) and account_status_history (one row per threshold hit, Job 3). Enums encode the legacy wire shape so external log scrapers keep parsing notificationType keys verbatim. Web adds /notificaciones with four trigger cards, a flags panel, and a paginated log browser. New notification:send ability gates all four endpoints at MANAGER, matching the renewal:send trust tier.
7.0 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 theStorageServiceenv-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 + aPOST /notifications/{slug}HTTP endpoint gated on the newnotification:sendability (MANAGER).packages/database/prisma/migrations/20260801200000_mass_email_notifications/migration.sql— two new tables (email_notification_log,account_status_history) with enums and FKs tocustomers.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_GATEuntil 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).
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.
UI
/notificaciones (gated on notification:send) — four trigger cards,
a debug/ignoreDayRestriction/useEmailLimit flags panel, a transport
status header, and a paginated log table. STAFF users see the 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
bodySnapshotand rendering HTML in the browser, deferred until a customer-service need surfaces. - Bounce / complaint webhooks.
providerMessageIdis 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.
Customerhas no language preference. Add one when the need is concrete (same open question asINSURANCE_FEATURES_SPEC.md§1.6). - Importing the legacy
utility_dbo.email_alert_logrows. They reference the oldNUMid(a stringified double) which no longer maps to a unified customer; an import would be destructive.