feat(notificaciones): one send log across servicios and pólizas
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m30s
Build and Push Images / Build jorgecuadros-api (push) Failing after 3h13m42s

Renewal avisos left behind only a `RenewalNotice` row, whose sole job is
gating: a row with `sentAt` drops the policy off the pending list. It
cannot represent a failed send or a customer with no address, so the
Pólizas tab had no "Registro de envíos" to show and a sent notice simply
vanished from the list.

Renewals now write `email_notification_log` — the same table the four
bulk jobs write — as `RENEWAL_NOTICE` / `POLICIES`, with rows for
failures and no-email skips too. `RenewalNotice` keeps its gating role
unchanged; the two are complementary, not redundant.

- extend `EmailNotificationType` (+RENEWAL_NOTICE) and
  `EmailNotificationServicio` (+POLICIES); `level` now carries the aviso
  generation on renewal rows, so every reader must branch on the type
  first (`notificationLevelLabel()` is the one place that lives)
- backfill emailed notices (`channel = 'EMAIL'`) into the log; MAIL-channel
  rows are legacy printed letters and are deliberately left out
- extract `NotificationLogService`/`NotificationLogModule` as the single
  writer, so a feature that sends mail records it without pulling the
  bulk-job pipelines into its module
- `GET /notifications/log` and `/stats` take a comma-separated `servicio`
  list; each tab reads its own slice. This also fixes the "Omitidos"
  view, which mapped to no filter at all and showed every row
- share one `NotificationLogPanel` between both tabs
- pass SES_* / NOTIFICATION_ADMIN_EMAILS through the galactus compose,
  which was missing them entirely — mail is runtime config, not a CI
  secret, and the prod image sets NODE_ENV=production so a blank config
  fails loudly instead of falling back to stdout

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 03:01:03 -07:00
co-authored by Claude Opus 5
parent c0cc0d2ac2
commit 33833c3af9
20 changed files with 813 additions and 194 deletions
@@ -0,0 +1,67 @@
-- Fold insurance renewal avisos into the one notification log.
--
-- Until now `renewal_notices` was the only record a renewal send left
-- behind, and its sole job is gating: a row with `sentAt` removes the
-- policy from the pending list. It cannot represent a failed send, a
-- customer with no email, or a second attempt — so the Pólizas tab of
-- /notificaciones had no "Registro de envíos" to show.
--
-- Extending the two enums lets `RenewalsService` write the same
-- `email_notification_log` rows the four bulk jobs write. `renewal_notices`
-- keeps its gating role unchanged.
-- AlterEnum: EmailNotificationType += RENEWAL_NOTICE
ALTER TABLE `email_notification_log`
MODIFY `notificationType` ENUM('OUTSTANDING_PAYMENT', 'PAYMENT_CONFIRMATION', 'ACCOUNT_STATUS', 'TRUST_PAYMENT_CONFIRMATION', 'RENEWAL_NOTICE') NOT NULL;
-- AlterEnum: EmailNotificationServicio += POLICIES
ALTER TABLE `email_notification_log`
MODIFY `servicio` ENUM('CUSTOMERS', 'TRUST', 'POLICIES') NOT NULL;
-- Backfill: every renewal notice this platform actually emailed.
--
-- Scope is deliberately `channel = 'EMAIL' AND sentAt IS NOT NULL`. MAIL-
-- channel rows are printed letters carried over from the legacy book — they
-- were never emails, and inventing log rows for them would misreport the
-- send history. Emailed rows carry a real recipient (resolved through the
-- policy's customer) and a real timestamp; the only fields we cannot
-- recover are the rendered body and the exact subject, so `bodySnapshot`
-- stays empty and the subject is reconstructed from the same two templates
-- `renderRenewalEmail()` uses (generation 3 = expired wording).
--
-- Rows whose customer has no email are skipped: `customerEmail` is NOT NULL
-- and a blank recipient would be a lie. Their `renewal_notices` row still
-- gates the pending list exactly as before.
INSERT INTO `email_notification_log` (
`id`, `sendDate`, `notificationType`, `level`, `servicio`,
`customerId`, `customerName`, `customerEmail`, `subject`,
`bodyRequestUrl`, `bodySnapshot`, `debug`,
`providerMessageId`, `providerResponse`, `status`, `error`
)
SELECT
UUID(),
rn.`sentAt`,
'RENEWAL_NOTICE',
rn.`generation`,
'POLICIES',
c.`id`,
c.`name`,
c.`email`,
CASE WHEN rn.`generation` = 3
THEN CONCAT('Póliza vencida: ', p.`policyNumber`)
ELSE CONCAT('Aviso de renovación: póliza ', p.`policyNumber`)
END,
NULL,
'',
FALSE,
rn.`providerMessageId`,
'backfill:20260802120000',
'SENT',
NULL
FROM `renewal_notices` rn
JOIN `policies` p ON p.`id` = rn.`policyId`
JOIN `customers` c ON c.`id` = p.`customerId`
WHERE rn.`channel` = 'EMAIL'
AND rn.`sentAt` IS NOT NULL
AND c.`email` IS NOT NULL
AND c.`email` <> '';
+22 -5
View File
@@ -944,20 +944,31 @@ model EmailLog {
/// red and yellow; the threshold is in the
/// `level` column, 0=yellow / 1=red)
/// - TRUST_PAYMENT_CONFIRMATION → sendConfirmTrustPayment.php (TRUSTHFEE)
///
/// RENEWAL_NOTICE has no PHP ancestor — it is the insurance renewal aviso
/// (`RenewalsService`), logged here so every outbound email the platform
/// sends lands in one table. `RenewalNotice` remains the per-policy
/// "already notified" record that drives the pending list; this log is the
/// send history, including the failures and skips `RenewalNotice` cannot
/// represent.
enum EmailNotificationType {
OUTSTANDING_PAYMENT
PAYMENT_CONFIRMATION
ACCOUNT_STATUS
TRUST_PAYMENT_CONFIRMATION
RENEWAL_NOTICE
}
/// 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.
/// TRUST = the trust-fee account table (replaces `TRUSTHFEE`);
/// POLICIES = the insurance book (renewal avisos). Keeping the services
/// tagged makes a per-line report trivial — and lets the /notificaciones
/// tabs each show their own slice of the one log.
enum EmailNotificationServicio {
CUSTOMERS
TRUST
POLICIES
}
/// Outcome of a single send attempt. SENT / FAILED are the meaningful ones;
@@ -982,11 +993,17 @@ 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.
/// Per-type discriminator, null where the type has none:
/// ACCOUNT_STATUS → 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO")
/// RENEWAL_NOTICE → the aviso generation (1 = 30d before, 2 = 15d
/// before, 3 = 7d after expiry)
/// Null on the remaining jobs. Readers MUST branch on notificationType
/// before interpreting it.
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.
/// TRUST for job 4, POLICIES for renewal avisos. Tagged here so a per-line
/// audit doesn't need to join — and so the /notificaciones Servicios tab
/// (CUSTOMERS + TRUST) and Pólizas tab (POLICIES) can filter one log.
servicio EmailNotificationServicio
/// FK to the customer that triggered the send. Trust-account notifications
/// resolve the owner through `Property.customerId`, so this stays set on