Files
jorgecuadros-platform/docs/MASS_EMAIL_NOTIFICATIONS.md
T
rmancinasandClaude Opus 5 a491ef3eed
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m32s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m28s
feat(notificaciones): edit summary recipients in the UI
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>
2026-08-02 11:58:42 -07:00

10 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  # 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 §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.