feat(notificaciones): one send log across servicios and pólizas
Renewal avisos left behind only a `RenewalNotice` row, whose sole job is gating: a row with `sentAt` drops the policy off the pending list. It cannot represent a failed send or a customer with no address, so the Pólizas tab had no "Registro de envíos" to show and a sent notice simply vanished from the list. Renewals now write `email_notification_log` — the same table the four bulk jobs write — as `RENEWAL_NOTICE` / `POLICIES`, with rows for failures and no-email skips too. `RenewalNotice` keeps its gating role unchanged; the two are complementary, not redundant. - extend `EmailNotificationType` (+RENEWAL_NOTICE) and `EmailNotificationServicio` (+POLICIES); `level` now carries the aviso generation on renewal rows, so every reader must branch on the type first (`notificationLevelLabel()` is the one place that lives) - backfill emailed notices (`channel = 'EMAIL'`) into the log; MAIL-channel rows are legacy printed letters and are deliberately left out - extract `NotificationLogService`/`NotificationLogModule` as the single writer, so a feature that sends mail records it without pulling the bulk-job pipelines into its module - `GET /notifications/log` and `/stats` take a comma-separated `servicio` list; each tab reads its own slice. This also fixes the "Omitidos" view, which mapped to no filter at all and showed every row - share one `NotificationLogPanel` between both tabs - pass SES_* / NOTIFICATION_ADMIN_EMAILS through the galactus compose, which was missing them entirely — mail is runtime config, not a CI secret, and the prod image sets NODE_ENV=production so a blank config fails loudly instead of falling back to stdout Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { MailService } from "../mail/mail.service";
|
||||
import { NotificationLogService } from "../notifications/notification-log.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import {
|
||||
RenewalLetterPolicy,
|
||||
@@ -63,6 +64,7 @@ export class RenewalsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mail: MailService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly notificationLog: NotificationLogService,
|
||||
) {}
|
||||
|
||||
@Cron("0 6 * * *", { timeZone: TIME_ZONE })
|
||||
@@ -131,6 +133,12 @@ export class RenewalsService {
|
||||
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",
|
||||
});
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -202,7 +210,13 @@ export class RenewalsService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Render + send + record one notice. Shared by the sweep and `sendOne`. */
|
||||
/** 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. */
|
||||
private async deliver(
|
||||
policy: RenewalLetterPolicy,
|
||||
generation: number,
|
||||
@@ -211,12 +225,25 @@ export class RenewalsService {
|
||||
) {
|
||||
const letter = toRenewalLetterRow(policy, generation);
|
||||
const message = renderRenewalEmail(letter);
|
||||
const result = await this.mail.send({
|
||||
to,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
xTracking: "renewals",
|
||||
});
|
||||
|
||||
let result: Awaited<ReturnType<MailService["send"]>>;
|
||||
try {
|
||||
result = await this.mail.send({
|
||||
to,
|
||||
toName: letter.customerName,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
xTracking: "renewals",
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
await this.recordLog(policy, generation, to, {
|
||||
status: "FAILED",
|
||||
error: detail,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const sentAt = new Date();
|
||||
|
||||
await this.prisma.renewalNotice.upsert({
|
||||
@@ -238,6 +265,12 @@ export class RenewalsService {
|
||||
providerMessageId: result.messageId,
|
||||
},
|
||||
});
|
||||
await this.recordLog(policy, generation, to, {
|
||||
status: "SENT",
|
||||
providerMessageId: result.messageId || undefined,
|
||||
providerResponse: result.response || undefined,
|
||||
sendDate: sentAt,
|
||||
});
|
||||
void this.audit.log(userId, "renewalNotice.send", {
|
||||
policyId: policy.id,
|
||||
generation,
|
||||
@@ -246,6 +279,58 @@ export class RenewalsService {
|
||||
return { sentAt, providerMessageId: result.messageId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
): 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: false,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user