feat(notificaciones): mass email notifications over SES

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.
This commit is contained in:
2026-08-02 02:04:14 -07:00
parent 5e9cb12fba
commit a52e59cbc5
21 changed files with 3128 additions and 4 deletions
+143
View File
@@ -124,6 +124,9 @@ model Customer {
statementDocuments StatementDocument[]
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
emailNotificationLogs EmailNotificationLog[]
accountStatusHistory AccountStatusHistory[]
@@map("customers")
}
@@ -909,6 +912,146 @@ model EmailLog {
@@map("email_log")
}
// ---------------------------------------------------------------------------
// Mass email notifications — the modern replacement for the legacy
// `email_alert_log` + `send_account_status_history` tables on utility_dbo and
// the four PHP scripts under `email.notifications/`. See
// docs/MASS_EMAIL_NOTIFICATIONS.md for the full design.
//
// The two legacy tables are not imported into this schema: the unified
// `customers` model replaces `datosfreak` (no more NUMid-as-string), so the
// rows would no longer carry their meaning. New tables follow the unified
// shape (FK to `customers`, signed Decimal balance, proper enums) and the
// four notification kinds are one `notificationType` enum rather than four
// parallel column families.
// ---------------------------------------------------------------------------
/// Which bulk-notification script produced a row. Mirrors the four PHP
/// jobs in `email.notifications/send*.php`:
/// - OUTSTANDING_PAYMENT → sendOutstandingPaymentAlerts.php
/// - PAYMENT_CONFIRMATION → sendPaymentConfirmation.php (pagosemail)
/// - ACCOUNT_STATUS → sendAccountStatus.php (datosfreak, both
/// red and yellow; the threshold is in the
/// `level` column, 0=yellow / 1=red)
/// - TRUST_PAYMENT_CONFIRMATION → sendConfirmTrustPayment.php (TRUSTHFEE)
enum EmailNotificationType {
OUTSTANDING_PAYMENT
PAYMENT_CONFIRMATION
ACCOUNT_STATUS
TRUST_PAYMENT_CONFIRMATION
}
/// Which "servicio" (line of business) the notification draws its recipients
/// from. CUSTOMERS = the unified customers ledger (replaces `datosfreak`);
/// TRUST = the trust-fee account table (replaces `TRUSTHFEE`). Keeping the
/// two services tagged makes a per-line report trivial.
enum EmailNotificationServicio {
CUSTOMERS
TRUST
}
/// Outcome of a single send attempt. SENT / FAILED are the meaningful ones;
/// SKIPPED_NO_EMAIL records the dry-run path and the legacy's
/// "EMAIL IS NULL" exclusion, SKIPPED_GATE records the Mon/Wed/Fri day gate
/// on the red branch (and the Wed gate on yellow) — so a sweep that ran on
/// the wrong day shows up as skipped rows, not as missing rows.
enum EmailNotificationStatus {
SENT
FAILED
SKIPPED_NO_EMAIL
SKIPPED_GATE
}
/// One row per send attempt. Captures both the deliverable (subject + body
/// snapshot + provider message id) and the diagnostic (URL we would have
/// fetched in the PHP version, SES response, error string). The body snapshot
/// is intentionally kept: the PHP scripts only stored it on the error path;
/// we store it always, so a customer reply quoting an old email can be traced
/// to the exact letter that was sent.
model EmailNotificationLog {
id String @id @default(uuid())
sendDate DateTime @default(now())
notificationType EmailNotificationType
/// 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO"). Only set on
/// ACCOUNT_STATUS rows; null on the other three jobs.
level Int?
/// Which servicio sourced the recipient list. CUSTOMERS for jobs 1/2/3,
/// TRUST for job 4. Tagged here so a per-line audit doesn't need to join.
servicio EmailNotificationServicio
/// FK to the customer that triggered the send. Trust-account notifications
/// resolve the owner through `Property.customerId`, so this stays set on
/// job 4 too. Null only on skipped rows where the lookup itself failed.
customerId String?
customer Customer? @relation(fields: [customerId], references: [id])
customerName String
customerEmail String
/// Subject line of the email we attempted to send.
subject String
/// For PAYMENT_CONFIRMATION: the per-customer URL the PHP code built and
/// fetched (kept verbatim so the legacy format is reproducible). Null on
/// the other three jobs — the body is built inline.
bodyRequestUrl String? @db.Text
/// The HTML body that was sent (or that would have been sent, for SKIPPED
/// rows). Stored verbatim so audit/customer-service can read the exact
/// letter that went out without re-running the render.
bodySnapshot String @db.Text
/// True when `debug` was passed — the recipient was overridden to the
/// admin address and no real customer received the mail. Kept here so a
/// "where did all these emails go" investigation finds the answer in one
/// place instead of "who ran what with what flags" archaeology.
debug Boolean @default(false)
/// SES SendEmail MessageId, when we actually got one back. Null on
/// failures, skipped rows, and dev/mock transport.
providerMessageId String?
/// Free-form provider response (or error). Trimmed to 4k chars before
/// insert so a verbose SES bounce payload can't blow the column.
providerResponse String?
status EmailNotificationStatus
error String? @db.Text
@@index([sendDate])
@@index([notificationType, sendDate])
@@index([customerId, sendDate])
@@map("email_notification_log")
}
/// Mirrors the legacy `utility_dbo.send_account_status_history` table — one
/// row per ACCOUNT_STATUS send, capturing the inputs the PHP version logged
/// for audit ("what balance, what threshold, what category of alert did we
/// fire"). Kept separate from `EmailNotificationLog` so the audit query
/// ("every red alert we ever sent this customer") doesn't have to filter by
/// notificationType; a one-row-per-send history is the whole point of the
/// legacy table.
model AccountStatusHistory {
id String @id @default(uuid())
sendDate DateTime @default(now())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
customerName String
customerEmail String
/// "DEBAJO DEL TIPO" or "EN ROJO" — the legacy literal strings. Kept
/// verbatim (not an enum) because the PHP scripts and downstream reports
/// filter by them, and "preserve legacy semantics" is the stated goal.
tipo String
/// Exchange rate at send time, kept for currency conversions downstream.
/// Null when the customer has no exchange-rate context (no FX movement).
tCambio Decimal? @db.Decimal(10, 4)
/// Customer's balance at send time, in the customer's currency. Negative
/// for red; 0..min for yellow.
balance Decimal @db.Decimal(12, 2)
/// Legacy formula: `0 - TIPO - BALANCE` — the amount the customer needs to
/// deposit to clear the threshold. Preserved verbatim even though it
/// double-subtracts; downstream reports depend on the exact figure.
solicitado Decimal @db.Decimal(12, 2)
/// 0 = yellow, 1 = red. Mirrors the legacy `level` column.
level Int
@@index([sendDate])
@@index([customerId, sendDate])
@@index([level, sendDate])
@@map("account_status_history")
}
// ---------------------------------------------------------------------------
// Admin database operations (Operaciones): backup / restore / re-import / sync.
// Each long-running op is one OpsJob row so the web UI can poll status + tail