Files
jorgecuadros-platform/docs/MASS_EMAIL_NOTIFICATIONS.md
T
rmancinasandClaude Opus 5 6331481f82 docs: record notificaciones as built, flags global, schedules editable
The docs still described the state before the last five commits: the
insurance spec called for a `@Cron` literal and a manual mark-as-sent
mutation, PLAN.md had step 12 as "NOT STARTED", and README's module and
route lists predated seven modules.

- MASS_EMAIL_NOTIFICATIONS.md: new "Send flags", "API surface" and
  "Scheduled runs" sections; "Cron (future)" removed — it exists. The
  flags table says which flags apply where, and why a debug renewal send
  must skip both the RenewalNotice row and `lastSuccessfulAt`.
- INSURANCE_FEATURES_SPEC.md: §1 BUILT note listing the three places the
  build diverged from the spec; §1.1 and §1.4 marked superseded in place
  rather than deleted, so the reasoning stays readable.
- PLAN.md: step 12 renewal emails DONE with the divergences; status
  paragraph rewritten.
- README.md: current module/route lists, plus a "Scheduled jobs" section —
  a reader cloning this repo had no way to know the API sends mail on a
  timer.
- DEPLOY_AND_MIGRATIONS.md: the cadence lives in app_settings and survives
  an image rollback, and the servicios sweep has no multi-replica lock.
- RESUME.md: session record for the whole notificaciones arc.
- RENEWAL_NOTICES.md: pointer that this is the legacy record, not what
  shipped.

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

16 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).
  • apps/api/src/notifications/notification-schedule.service.ts (+ its own module) — the cadence of both automatic sweeps, stored in app_settings and installed into SchedulerRegistry at boot. See "Scheduled runs" below.
  • 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: a shared flags panel and schedule editor above the tabs, then per-tab trigger cards, 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.

Send flags

The three flags are platform-wide, not per-tab. They live in the /notificaciones shell above the tabs (NotificationFlagsCard), and the shell passes them to both halves.

Flag Applies to Effect
debug everything Rewrites every recipient to DEBUG_RECIPIENT (rmancinas@freakma.net), tags the log row debug: true, sends with xTracking: "debug".
ignoreDayRestriction Job 3 only Bypasses the Wed / Mon-Wed-Fri gates.
useEmailLimit Job 3 only The vestigial throttle above.

debug used to exist only on the servicios side, which meant there was no way to test a renewal aviso without mailing a real customer. On the pólizas path it now does three things beyond diverting the mail, all for the same reason — the customer was not notified, so nothing may claim they were:

  1. no RenewalNotice row is written, so the aviso stays in the pending list;
  2. the sweep's lastSuccessfulAt is not advanced, because renewalWindow() uses it to widen the window over missed days — advancing it after a test run would narrow tomorrow's window and drop those candidates for good;
  3. the send response carries debug: true and the address actually used, so the UI says "prueba enviada … el cliente no ha recibido nada" rather than claiming a delivery.

Flags are per-visit UI state and are never persisted. A stored debug would survive a reload and silently swallow real customer mail for as long as nobody noticed. For the same reason the automatic runs below ignore them entirely and always send for real.

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.

Above the tabs, owned by the shell because both halves are subject to them:

  • Flags del envío — the three flags above.
  • Programación de envíos — the cadence of both automatic sweeps (setting:manage to edit; everyone can see when the next run is).

Then per tab:

  • Servicios (notification:send) — an "Ejecutar todos" card, four trigger cards, a transport status header, and the summary-recipients setting. 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.

Both mass actions ("Ejecutar todos" and the pólizas sweep) confirm before firing only when debug is off — that is the case where real customers receive mail, and a confirm on every click trains people to dismiss it.

API surface

Method Route Ability
POST /notifications/outstanding-payments notification:send
POST /notifications/payment-confirmation notification:send
POST /notifications/account-status notification:send
POST /notifications/trust-payment-confirmation notification:send
POST /notifications/run-all notification:send
GET /notifications/log, /notifications/stats authenticated
GET /notifications/settings/admin-emails authenticated
PUT /notifications/settings/admin-emails setting:manage
GET /notifications/settings/schedule authenticated
PUT /notifications/settings/schedule/:kind setting:manage

Every trigger accepts the flags as body or query string — the PHP scripts took both (STDIN vs HTTP-CGI) and parity was cheap. The pólizas endpoints (POST /renewals/sweep, POST /renewals/send) accept debug only; the other two flags are estado-de-cuenta concepts and are not accepted there rather than being silently ignored.

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.

Keys today:

Key Edited on Notes
notification.adminEmails Servicios tab Summary recipients, comma-separated.
notification.schedule.servicios shell JSON cadence of the automatic run-all.
notification.schedule.polizas shell JSON cadence of the renewal sweep.

All three are gated on setting:manage (ADMIN — above notification:send, because redirecting the audit summaries is how someone would stop them being read).

notification.adminEmails is 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.

The two schedule keys have no env rung on the db → env → default ladder: a cadence was never an environment variable (it was a @Cron literal in the source), so the only two sources are the operator's row and the shipped default. A row that fails to parse is logged and treated as absent — a bad JSON blob must not take the scheduler down with it.

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.

Scheduled runs

Both halves run themselves on an operator-editable cadence. Nothing about the schedule is in the source any more:

Kind Handler Default Was
servicios NotificationsService.scheduledRunAll() → all four jobs in order off, 07:00 Mon/Wed/Fri when enabled nothing — the four jobs were click-only
polizas RenewalsService.scheduledSweep() on, 06:00 daily @Cron("0 6 * * *") in renewals.service.ts

The defaults preserve exactly what each half did before: pólizas keeps its 06:00 sweep, servicios stays off. A default that starts mailing 260 customers on its own after a deploy is not a default, it's an incident.

How it works

NotificationScheduleService owns both cadences. The services that own the sweeps register a handler in onModuleInit:

await this.schedule.register("polizas", () => this.scheduledSweep());

The schedule service then compiles the stored value to a cron expression ({hour, minute, weekdays}m h * * dow, empty weekdays = *) and installs a CronJob in SchedulerRegistry under notification-schedule:<kind>, in America/Tijuana. Saving from the UI re-reads the row, removes the old job and installs the new one — no restart, which was the whole point.

Handlers are registered rather than injected because NotificationsModule and RenewalsModule both need this service and neither may import the other. It lives in its own NotificationScheduleModule for the same reason as NotificationLogModule.

cron is a direct dependency of apps/api, not just a transitive one of @nestjs/schedule: pnpm's strict layout does not hoist it, so import { CronJob } from "cron" fails to resolve without it.

What a scheduled run does not do

  • It never uses the UI flags. No debug (so a forgotten test toggle cannot silently stop customer mail), and no ignoreDayRestriction — an automatic run on the operator's own cadence is exactly the case the Mon/Wed/Fri gate was written for, so bypassing it would mail the red list every scheduled day.
  • It does not guard against multi-replica double-fire. The pólizas sweep has always had its own DB lock (scheduled_job_states, taken in RenewalsService.acquireLock); the servicios run-all has no equivalent and relies on the deployment being single-replica, which it is today on galactus. Adding one means the OpsService single-running-job pattern — a DB row, not an in-process flag.

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.