feat(renovaciones): renewal notification emails over SES
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m48s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m4s

INSURANCE_FEATURES_SPEC §1. The office printed and mailed renewal letters
from the legacy CONTROL <ramo> RENEW[2/3] paper log; 91% of policyholders
have an email on file, so send the notice instead and keep the paper log
as the fallback.

A daily cron (06:00 America/Tijuana) sweeps three generations off
policyTo — 30 and 15 days before expiry, 7 days after — sends each
through SES, and upserts RenewalNotice by [policyId, generation] so a
policy is never notified twice for the same milestone. RenewalNotice now
records providerMessageId, so a later bounce or complaint webhook can be
traced back to the row that sent it.

- customers.emailOptOut excludes a customer from every sweep; editable
  from the customer form
- scheduled_job_states holds the sweep's lock and last successful run;
  the window is widened to cover days the job did not run, so a weekend
  outage does not silently drop a generation
- SES unconfigured is not an error outside production — messages are
  logged and skipped, so dev and CI never send
- /renovaciones (renewal:send, MANAGER+) lists what is pending per
  generation, runs the sweep by hand, and marks a notice sent by mail
  for the customers with no email
- POST /policies/:id/renewal-notices records that manual mark
- the aviso-renovacion report and the emails now share one projection
  (reports/renewal-letter.ts) instead of two copies of the mapping
This commit is contained in:
2026-08-02 02:00:02 -07:00
parent 3125b52057
commit 87d8743251
29 changed files with 1450 additions and 82 deletions
@@ -0,0 +1,15 @@
ALTER TABLE `customers`
ADD COLUMN `emailOptOut` BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE `renewal_notices`
ADD COLUMN `providerMessageId` VARCHAR(191) NULL;
CREATE TABLE `scheduled_job_states` (
`name` VARCHAR(191) NOT NULL,
`lockedUntil` DATETIME(3) NULL,
`lastSuccessfulAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`name`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+21 -9
View File
@@ -99,6 +99,7 @@ model Customer {
mobile String?
fax String?
email String?
emailOptOut Boolean @default(false)
notes String? @db.Text
identificationType String?
identificationNumber String?
@@ -230,16 +231,17 @@ enum RenewalNoticeChannel {
/// 3rd notice and when" is a query instead of a paper trail. See
/// docs/RENEWAL_NOTICES.md for the legacy report chain this replaces.
model RenewalNotice {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
// 1 = first notice (bare RENEW), 2 = RENEW2, 3 = RENEW3 in the legacy naming.
generation Int
channel RenewalNoticeChannel @default(MAIL)
sentAt DateTime?
sentById String?
notes String? @db.Text
createdAt DateTime @default(now())
generation Int
channel RenewalNoticeChannel @default(MAIL)
sentAt DateTime?
sentById String?
providerMessageId String?
notes String? @db.Text
createdAt DateTime @default(now())
// One row per generation per policy — matches the legacy's 1st/2nd/3rd
// notice cadence; re-running the same generation for a policy updates it
@@ -953,3 +955,13 @@ model OpsJob {
@@index([startedAt])
@@map("ops_jobs")
}
model ScheduledJobState {
name String @id
lockedUntil DateTime?
lastSuccessfulAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("scheduled_job_states")
}