The "Flags del envío" panel lived inside the Servicios tab and only
governed the four bulk jobs. The pólizas half had no debug at all, so
there was no way to test a renewal notice without mailing a real
customer. The panel now lives in the /notificaciones shell above the
tabs and both halves read it.
`debug` on the renewal path diverts to the same override inbox as the
servicios jobs and deliberately does NOT write the `RenewalNotice` row
or advance the sweep's `lastSuccessfulAt` — the customer was not
notified, so nothing may gate the letter they are still owed.
`ignoreDayRestriction` and `useEmailLimit` stay estado-de-cuenta-only
and are labelled as such.
Both automatic sweeps are now operator-editable. The renewal cadence
was a `@Cron("0 6 * * *")` literal and servicios had no automatic run
at all; both now resolve through `NotificationScheduleService`, which
stores the cadence in `app_settings` and reinstalls the cron job on
save — no redeploy, no restart. Defaults preserve current behaviour:
pólizas 06:00 daily, servicios off. A scheduled run never inherits the
UI flags; it always sends for real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
450 lines
14 KiB
TypeScript
450 lines
14 KiB
TypeScript
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<void> {
|
|
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<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, 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<ReturnType<MailService["send"]>>;
|
|
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<void> {
|
|
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<void> {
|
|
await this.prisma.scheduledJobState.update({
|
|
where: { name: JOB_NAME },
|
|
data: {
|
|
lockedUntil: null,
|
|
...(lastSuccessfulAt && { lastSuccessfulAt }),
|
|
},
|
|
});
|
|
}
|
|
}
|