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
@@ -27,6 +27,7 @@ import {
type PolicyStatus,
} from "./policies.service";
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
import { MarkRenewalNoticeDto } from "./renewal-notice.dto";
import {
BeneficiaryDto,
ClaimDto,
@@ -145,6 +146,26 @@ export class PoliciesController {
return p;
}
@Post(":id/renewal-notices")
@RequireAbility("renewal:send")
async markRenewalNotice(
@Param("id") id: string,
@Body() dto: MarkRenewalNoticeDto,
@Req() req: Request,
) {
const notice = await this.policies.markRenewalNotice(
id,
dto,
this.actingId(req),
);
void this.audit.log(this.actingId(req), "renewalNotice.markSent", {
policyId: id,
generation: dto.generation,
channel: dto.channel,
});
return notice;
}
// --- children (all editing a policy => policy:update) ---------------------
@Post(":id/installments")
+29
View File
@@ -6,6 +6,7 @@ import { StorageService } from "../storage/storage.service";
import { extForUpload, type UploadedFileLike } from "../storage/upload-file";
import { toDate } from "../common/coerce";
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
import { MarkRenewalNoticeDto } from "./renewal-notice.dto";
import {
BeneficiaryDto,
ClaimDto,
@@ -358,6 +359,34 @@ export class PoliciesService {
return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } });
}
async markRenewalNotice(
policyId: string,
dto: MarkRenewalNoticeDto,
sentById: string,
) {
await this.ensurePolicy(policyId);
const sentAt = toDate(dto.sentAt) ?? new Date();
return this.prisma.renewalNotice.upsert({
where: {
policyId_generation: { policyId, generation: dto.generation },
},
create: {
policyId,
generation: dto.generation,
channel: dto.channel,
sentAt,
sentById,
notes: dto.notes,
},
update: {
channel: dto.channel,
sentAt,
sentById,
notes: dto.notes,
},
});
}
private async ensurePolicy(id: string) {
const found = await this.prisma.policy.findUnique({
where: { id },
@@ -0,0 +1,28 @@
import { RenewalNoticeChannel } from "@jorgecuadros/database";
import {
IsDateString,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from "class-validator";
export class MarkRenewalNoticeDto {
@IsInt()
@Min(1)
@Max(3)
generation!: number;
@IsEnum(RenewalNoticeChannel)
channel!: RenewalNoticeChannel;
@IsOptional()
@IsDateString()
sentAt?: string;
@IsOptional()
@IsString()
notes?: string;
}