feat(renovaciones): renewal notification emails over SES
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:
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import {
|
||||
renewalLetterSelect,
|
||||
toRenewalLetterRow,
|
||||
} from "../reports/renewal-letter";
|
||||
import { MailService } from "./mail.service";
|
||||
import { renderRenewalEmail } from "./renewal-email";
|
||||
|
||||
export const RENEWAL_CADENCE = [
|
||||
{ generation: 1, offsetDays: 30 },
|
||||
{ generation: 2, offsetDays: 15 },
|
||||
{ generation: 3, offsetDays: -7 },
|
||||
] as const;
|
||||
|
||||
const JOB_NAME = "renewal-email-sweep";
|
||||
const TIME_ZONE = "America/Tijuana";
|
||||
const DAY_MS = 86400000;
|
||||
|
||||
export function dateInTimeZone(now: Date, timeZone = TIME_ZONE): Date {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(now);
|
||||
const value = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((part) => part.type === type)?.value);
|
||||
return new Date(Date.UTC(value("year"), value("month") - 1, value("day")));
|
||||
}
|
||||
|
||||
export function addUtcDays(date: Date, days: number): Date {
|
||||
return new Date(date.getTime() + days * DAY_MS);
|
||||
}
|
||||
|
||||
export function renewalWindow(
|
||||
today: Date,
|
||||
offsetDays: number,
|
||||
lastSuccessfulAt?: Date | null,
|
||||
): { from: Date; to: Date } {
|
||||
const to = addUtcDays(today, offsetDays);
|
||||
if (!lastSuccessfulAt) return { from: to, to };
|
||||
const previousDay = dateInTimeZone(lastSuccessfulAt);
|
||||
if (previousDay >= today) return { from: to, to };
|
||||
return { from: addUtcDays(previousDay, offsetDays + 1), to };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RenewalsService {
|
||||
private readonly logger = new Logger(RenewalsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mail: MailService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@Cron("0 6 * * *", { timeZone: TIME_ZONE })
|
||||
async scheduledSweep(): Promise<void> {
|
||||
try {
|
||||
await this.sweep();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Falló el barrido de renovaciones: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async pending(days = 30) {
|
||||
const today = dateInTimeZone(new Date());
|
||||
const state = await this.prisma.scheduledJobState.findUnique({
|
||||
where: { name: JOB_NAME },
|
||||
select: { lastSuccessfulAt: true },
|
||||
});
|
||||
const cadence = RENEWAL_CADENCE.filter(
|
||||
(item) => item.offsetDays < 0 || item.offsetDays <= days,
|
||||
);
|
||||
const groups = await Promise.all(
|
||||
cadence.map(async (item) => ({
|
||||
generation: item.generation,
|
||||
rows: await this.findCandidates(
|
||||
item,
|
||||
today,
|
||||
state?.lastSuccessfulAt ?? null,
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
return groups.flatMap(({ generation, rows }) =>
|
||||
rows
|
||||
.filter((policy) => Boolean(policy.customer.email?.trim()))
|
||||
.map((policy) => toRenewalLetterRow(policy, generation)),
|
||||
);
|
||||
}
|
||||
|
||||
async sweep(userId?: string) {
|
||||
const now = new Date();
|
||||
const state = await this.acquireLock(now);
|
||||
|
||||
try {
|
||||
if (!this.mail.available) {
|
||||
throw new ServiceUnavailableException(
|
||||
"El servicio de correo no está configurado.",
|
||||
);
|
||||
}
|
||||
|
||||
const today = dateInTimeZone(now);
|
||||
let eligible = 0;
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
const failures: Array<{ policyId: string; generation: number; error: string }> = [];
|
||||
|
||||
for (const cadence of RENEWAL_CADENCE) {
|
||||
const policies = await this.findCandidates(
|
||||
cadence,
|
||||
today,
|
||||
state.lastSuccessfulAt,
|
||||
);
|
||||
eligible += policies.length;
|
||||
|
||||
for (const policy of policies) {
|
||||
const to = policy.customer.email?.trim();
|
||||
if (!to) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const letter = toRenewalLetterRow(policy, cadence.generation);
|
||||
const message = renderRenewalEmail(letter);
|
||||
const result = await this.mail.send({ to, ...message });
|
||||
const sentAt = new Date();
|
||||
|
||||
await this.prisma.renewalNotice.upsert({
|
||||
where: {
|
||||
policyId_generation: {
|
||||
policyId: policy.id,
|
||||
generation: cadence.generation,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
policyId: policy.id,
|
||||
generation: cadence.generation,
|
||||
channel: "EMAIL",
|
||||
sentAt,
|
||||
sentById: userId,
|
||||
providerMessageId: result.providerId,
|
||||
},
|
||||
update: {
|
||||
channel: "EMAIL",
|
||||
sentAt,
|
||||
sentById: userId,
|
||||
providerMessageId: result.providerId,
|
||||
},
|
||||
});
|
||||
sent++;
|
||||
void this.audit.log(userId, "renewalNotice.send", {
|
||||
policyId: policy.id,
|
||||
generation: cadence.generation,
|
||||
providerMessageId: result.providerId,
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
policyId: policy.id,
|
||||
generation: cadence.generation,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = { eligible, sent, skipped, failed: failures.length, failures };
|
||||
await this.releaseLock(failures.length === 0 ? now : null);
|
||||
void this.audit.log(userId, "renewalNotice.sweep", result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
await this.releaseLock(null);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private findCandidates(
|
||||
cadence: (typeof RENEWAL_CADENCE)[number],
|
||||
today: Date,
|
||||
lastSuccessfulAt: Date | null,
|
||||
) {
|
||||
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
|
||||
return this.prisma.policy.findMany({
|
||||
where: {
|
||||
archivedAt: null,
|
||||
policyTo: { gte: window.from, lte: window.to },
|
||||
customer: {
|
||||
archivedAt: null,
|
||||
emailOptOut: false,
|
||||
email: { not: "" },
|
||||
},
|
||||
renewalNotices: {
|
||||
none: { generation: cadence.generation, sentAt: { not: null } },
|
||||
},
|
||||
},
|
||||
orderBy: [{ policyTo: "asc" }, { policyNumber: "asc" }],
|
||||
select: renewalLetterSelect(cadence.generation),
|
||||
});
|
||||
}
|
||||
|
||||
private async acquireLock(now: Date) {
|
||||
await this.prisma.scheduledJobState.upsert({
|
||||
where: { name: JOB_NAME },
|
||||
create: { name: JOB_NAME },
|
||||
update: { updatedAt: now },
|
||||
});
|
||||
|
||||
const acquired = await this.prisma.scheduledJobState.updateMany({
|
||||
where: {
|
||||
name: JOB_NAME,
|
||||
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
|
||||
},
|
||||
data: { lockedUntil: new Date(now.getTime() + 2 * 60 * 60 * 1000) },
|
||||
});
|
||||
|
||||
if (acquired.count !== 1) {
|
||||
throw new ConflictException(
|
||||
"Ya hay un barrido de renovaciones en curso.",
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.scheduledJobState.findUniqueOrThrow({
|
||||
where: { name: JOB_NAME },
|
||||
});
|
||||
}
|
||||
|
||||
private async releaseLock(lastSuccessfulAt: Date | null): Promise<void> {
|
||||
await this.prisma.scheduledJobState.update({
|
||||
where: { name: JOB_NAME },
|
||||
data: {
|
||||
lockedUntil: null,
|
||||
...(lastSuccessfulAt && { lastSuccessfulAt }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user