import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, OnModuleInit, ServiceUnavailableException, } from "@nestjs/common"; import { AuditService } from "../common/audit.service"; import { MailService } from "../mail/mail.service"; import { NotificationLogService } from "../notifications/notification-log.service"; import { NotificationScheduleService, SCHEDULE_TIME_ZONE, } from "../notifications/notification-schedule.service"; import { DEBUG_RECIPIENT } from "../notifications/notification.types"; import { PrismaService } from "../prisma/prisma.service"; import { RenewalLetterPolicy, renewalLetterSelect, toRenewalLetterRow, } from "../reports/renewal-letter"; 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"; /** The window maths runs in office time; the cadence itself is owned by * `NotificationScheduleService`, which uses the same zone. */ const TIME_ZONE = SCHEDULE_TIME_ZONE; 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 implements OnModuleInit { private readonly logger = new Logger(RenewalsService.name); constructor( private readonly prisma: PrismaService, private readonly mail: MailService, private readonly audit: AuditService, private readonly notificationLog: NotificationLogService, private readonly schedule: NotificationScheduleService, ) {} /** The cadence used to be a `@Cron("0 6 * * *")` literal here; it is now * operator-editable, and the stored value defaults to that same 06:00 * daily run. */ async onModuleInit(): Promise { await this.schedule.register("polizas", () => this.scheduledSweep()); } /** The unattended run always sends for real: `debug` is a per-click switch * in the UI, never persisted, so the schedule cannot inherit a forgotten * test toggle and silently stop mailing customers. */ async scheduledSweep(): Promise { 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, flags: { debug?: boolean } = {}) { const debug = !!flags.debug; 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) { // Logged rather than silently counted: "we had nobody to mail" // is a finding the office acts on, and only the log survives the // HTTP response. await this.recordLog(policy, cadence.generation, "", { status: "SKIPPED_NO_EMAIL", debug, }); skipped++; continue; } try { await this.deliver(policy, cadence.generation, to, userId, debug); sent++; } catch (error) { failures.push({ policyId: policy.id, generation: cadence.generation, error: (error as Error).message, }); } } } const result = { eligible, sent, skipped, failed: failures.length, failures, debug, }; // A debug run must not advance `lastSuccessfulAt`: it wrote no // RenewalNotice rows, so the days it "covered" are still owed, and // narrowing tomorrow's window back to a single day would drop them. await this.releaseLock(!debug && failures.length === 0 ? now : null); void this.audit.log(userId, "renewalNotice.sweep", result); return result; } catch (error) { await this.releaseLock(null); throw error; } } /** * Send one pending renewal notice on demand, from the /notificaciones * list. Same path the sweep takes — render, send, then record the notice — * so a letter sent by hand is marked exactly like a swept one and drops * off the pending list. Refuses a generation already sent so a double * click can't mail the customer twice. * * Under `debug` the notice is NOT marked as sent, so the row stays in the * pending list — the customer has still not been told anything. */ async sendOne( policyId: string, generation: number, userId?: string, flags: { debug?: boolean } = {}, ) { const debug = !!flags.debug; if (!this.mail.available) { throw new ServiceUnavailableException( "El servicio de correo no está configurado.", ); } const policy = await this.prisma.policy.findFirst({ where: { id: policyId, archivedAt: null }, select: renewalLetterSelect(generation), }); if (!policy) { throw new NotFoundException("Póliza no encontrada."); } if (policy.renewalNotices.some((notice) => notice.sentAt)) { throw new ConflictException("Este aviso ya fue enviado."); } const to = policy.customer.email?.trim(); if (!to) { throw new BadRequestException("El cliente no tiene correo registrado."); } const { sentAt, providerMessageId, addressedTo } = await this.deliver( policy, generation, to, userId, debug, ); return { policyId, generation, // The address the mail actually went to — under debug that is the // override inbox, and the UI says so rather than claiming the customer // was notified. to: addressedTo, debug, sentAt: sentAt.toISOString(), providerMessageId, }; } /** Render + send + record one notice. Shared by the sweep and `sendOne`. * * Two records come out of a send: the `RenewalNotice` row, which gates the * pending list, and an `email_notification_log` row, which is the send * history the /notificaciones "Registro de envíos" reads. A failed send * writes only the second — there is no notice to gate on — and rethrows so * the sweep counts it as a failure. * * Under `debug` the mail is diverted to `DEBUG_RECIPIENT` and the * `RenewalNotice` row is deliberately skipped: the customer was not * notified, so nothing may gate the letter they are still owed. Only the * log row is written, flagged `debug`. */ private async deliver( policy: RenewalLetterPolicy, generation: number, to: string, userId?: string, debug = false, ) { const letter = toRenewalLetterRow(policy, generation); const message = renderRenewalEmail(letter); const addressedTo = debug ? DEBUG_RECIPIENT : to; let result: Awaited>; try { result = await this.mail.send({ to: addressedTo, toName: letter.customerName, subject: message.subject, html: message.html, xTracking: debug ? "debug" : "renewals", }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); await this.recordLog(policy, generation, addressedTo, { status: "FAILED", error: detail, debug, }); throw error; } const sentAt = new Date(); if (!debug) { await this.prisma.renewalNotice.upsert({ where: { policyId_generation: { policyId: policy.id, generation }, }, create: { policyId: policy.id, generation, channel: "EMAIL", sentAt, sentById: userId, providerMessageId: result.messageId, }, update: { channel: "EMAIL", sentAt, sentById: userId, providerMessageId: result.messageId, }, }); } await this.recordLog(policy, generation, addressedTo, { status: "SENT", providerMessageId: result.messageId || undefined, providerResponse: result.response || undefined, sendDate: sentAt, debug, }); void this.audit.log(userId, "renewalNotice.send", { policyId: policy.id, generation, debug, providerMessageId: result.messageId, }); return { sentAt, providerMessageId: result.messageId, addressedTo }; } /** * Write one row to the shared notification log. * * Never throws: the mail is already gone (or already failed) by the time we * get here, and losing the audit row must not turn a delivered notice into * a reported failure — which on the SENT path would also strand the * `RenewalNotice` we just wrote and re-send tomorrow. */ private async recordLog( policy: RenewalLetterPolicy, generation: number, /** Recipient as addressed. Empty on the SKIPPED_NO_EMAIL path — that * emptiness IS the reason the row exists. */ to: string, outcome: { status: "SENT" | "FAILED" | "SKIPPED_NO_EMAIL"; providerMessageId?: string; providerResponse?: string; error?: string; sendDate?: Date; debug?: boolean; }, ): Promise { const letter = toRenewalLetterRow(policy, generation); const message = renderRenewalEmail(letter); try { await this.notificationLog.record({ notificationType: "RENEWAL_NOTICE", servicio: "POLICIES", sendDate: outcome.sendDate, // `level` carries the aviso generation for RENEWAL_NOTICE rows — see // the column doc on the Prisma model. level: generation, customerId: policy.customer.id, customerName: letter.customerName, customerEmail: to, subject: message.subject, bodySnapshot: message.html, status: outcome.status, debug: !!outcome.debug, providerMessageId: outcome.providerMessageId, providerResponse: outcome.providerResponse, error: outcome.error, }); } catch (error) { this.logger.warn( `No se pudo registrar el aviso de renovación en el log ` + `(póliza ${policy.id}, aviso ${generation}): ` + `${(error as Error).message}`, ); } } 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 { await this.prisma.scheduledJobState.update({ where: { name: JOB_NAME }, data: { lockedUntil: null, ...(lastSuccessfulAt && { lastSuccessfulAt }), }, }); } }