feat(notificaciones): one send log across servicios and pólizas
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m30s
Build and Push Images / Build jorgecuadros-api (push) Failing after 3h13m42s

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:
2026-08-02 03:01:03 -07:00
co-authored by Claude Opus 5
parent c0cc0d2ac2
commit 33833c3af9
20 changed files with 813 additions and 194 deletions
@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { NotificationLogService } from "./notification-log.service";
/**
* Just the log writer, so a feature that sends mail can record it without
* importing `NotificationsModule` (which carries the four bulk-job pipelines
* and their controller). Imported by `NotificationsModule` and
* `RenewalsModule`.
*/
@Module({
providers: [NotificationLogService],
exports: [NotificationLogService],
})
export class NotificationLogModule {}
@@ -0,0 +1,74 @@
import { Injectable } from "@nestjs/common";
import {
EmailNotificationServicio,
EmailNotificationStatus,
EmailNotificationType,
} from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { AttemptStatus } from "./notification.types";
/**
* The single writer for `email_notification_log`.
*
* Extracted out of `NotificationsService` so the renewal sweep can write the
* same rows as the four bulk jobs without pulling that service (and its four
* job pipelines) into `RenewalsModule`. Every outbound email the platform
* sends goes through here, which is what makes /notificaciones' "Registro de
* envíos" complete rather than per-feature.
*/
export interface NotificationLogEntry {
notificationType: EmailNotificationType;
servicio: EmailNotificationServicio;
/** Defaults to now(). Pass it when the row must line up exactly with
* another record of the same send (the renewal sweep pins it to
* `RenewalNotice.sentAt`). */
sendDate?: Date;
/** Type-dependent discriminator — see the `level` doc on the Prisma model.
* 0/1 for ACCOUNT_STATUS, the generation for RENEWAL_NOTICE. */
level?: number | null;
customerId: string | null;
customerName: string;
customerEmail: string;
subject: string;
bodySnapshot: string;
bodyRequestUrl?: string;
status: AttemptStatus;
debug: boolean;
providerMessageId?: string;
providerResponse?: string;
error?: string;
}
/** `providerResponse` is a VARCHAR(191); anything longer is a provider dump
* we only need the head of. Errors go to the TEXT `error` column and get
* the 4k cap the schema documents. */
const PROVIDER_RESPONSE_MAX = 180;
const ERROR_MAX = 4096;
@Injectable()
export class NotificationLogService {
constructor(private readonly prisma: PrismaService) {}
async record(entry: NotificationLogEntry): Promise<void> {
await this.prisma.emailNotificationLog.create({
data: {
notificationType: entry.notificationType,
servicio: entry.servicio,
...(entry.sendDate && { sendDate: entry.sendDate }),
level: entry.level ?? null,
customerId: entry.customerId,
customerName: entry.customerName,
customerEmail: entry.customerEmail,
subject: entry.subject,
bodySnapshot: entry.bodySnapshot,
bodyRequestUrl: entry.bodyRequestUrl ?? null,
debug: entry.debug,
providerMessageId: entry.providerMessageId ?? null,
providerResponse:
entry.providerResponse?.slice(0, PROVIDER_RESPONSE_MAX) ?? null,
status: entry.status as EmailNotificationStatus,
error: entry.error?.slice(0, ERROR_MAX) ?? null,
},
});
}
}
@@ -31,7 +31,17 @@ class ListLogDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize?: number;
@IsOptional() @IsEnum(EmailNotificationType) type?: EmailNotificationType;
@IsOptional() @IsEnum(EmailNotificationServicio) servicio?: EmailNotificationServicio;
/** One or more servicios, comma-separated. The /notificaciones tabs each
* read their own slice of the one log: Servicios passes
* `CUSTOMERS,TRUST`, Pólizas passes `POLICIES`. Omitted = every servicio. */
@IsOptional()
@Transform(({ value }) =>
typeof value === "string"
? value.split(",").map((s) => s.trim()).filter(Boolean)
: value,
)
@IsEnum(EmailNotificationServicio, { each: true })
servicio?: EmailNotificationServicio[];
@IsOptional() @IsEnum(EmailNotificationStatus) status?: EmailNotificationStatus;
@IsOptional() @IsEnum(["sent", "failed", "skipped", "all"]) view?: "sent" | "failed" | "skipped" | "all";
}
@@ -185,19 +195,27 @@ export class NotificationsController {
}
@Get("stats")
stats() {
return this.svc.stats();
stats(@Query() q: ListLogDto) {
return this.svc.stats(q.servicio);
}
/** Resolve the UI's coarse view tabs to concrete statuses. An explicit
* `status` wins. "Omitidos" covers both SKIPPED_* variants, which is why
* this returns a list rather than a single value. */
private mapViewStatus(
view: ListLogDto["view"],
status: ListLogDto["status"],
): EmailNotificationStatus | undefined {
if (status) return status;
): EmailNotificationStatus[] | undefined {
if (status) return [status];
if (!view || view === "all") return undefined;
if (view === "sent") return EmailNotificationStatus.SENT;
if (view === "failed") return EmailNotificationStatus.FAILED;
if (view === "skipped") return undefined; // both SKIPPED_* variants
if (view === "sent") return [EmailNotificationStatus.SENT];
if (view === "failed") return [EmailNotificationStatus.FAILED];
if (view === "skipped") {
return [
EmailNotificationStatus.SKIPPED_NO_EMAIL,
EmailNotificationStatus.SKIPPED_GATE,
];
}
return undefined;
}
}
@@ -1,4 +1,5 @@
import { Module } from "@nestjs/common";
import { NotificationLogModule } from "./notification-log.module";
import { NotificationsController } from "./notifications.controller";
import { NotificationsService } from "./notifications.service";
@@ -11,6 +12,7 @@ import { NotificationsService } from "./notifications.service";
* service methods are already the entry points they would call.
*/
@Module({
imports: [NotificationLogModule],
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
@@ -10,6 +10,7 @@ import {
} from "@jorgecuadros/database";
import { MailService } from "../mail/mail.service";
import { PrismaService } from "../prisma/prisma.service";
import { NotificationLogService } from "./notification-log.service";
import {
SendAttempt,
NotificationJobKind,
@@ -81,6 +82,7 @@ export class NotificationsService {
constructor(
private readonly prisma: PrismaService,
private readonly mail: MailService,
private readonly log: NotificationLogService,
config: ConfigService,
) {
const csv = config.get<string>("NOTIFICATION_ADMIN_EMAILS");
@@ -721,14 +723,16 @@ export class NotificationsService {
page: number;
pageSize: number;
type?: EmailNotificationType;
servicio?: EmailNotificationServicio;
status?: EmailNotificationStatus;
/** Empty/omitted = every servicio. The /notificaciones tabs pass their
* own slice (Servicios: CUSTOMERS+TRUST, Pólizas: POLICIES). */
servicio?: EmailNotificationServicio[];
status?: EmailNotificationStatus[];
customerId?: string;
}) {
const where: Prisma.EmailNotificationLogWhereInput = {};
if (params.type) where.notificationType = params.type;
if (params.servicio) where.servicio = params.servicio;
if (params.status) where.status = params.status;
if (params.servicio?.length) where.servicio = { in: params.servicio };
if (params.status?.length) where.status = { in: params.status };
if (params.customerId) where.customerId = params.customerId;
const [total, rows] = await this.prisma.$transaction([
@@ -765,22 +769,32 @@ export class NotificationsService {
};
}
/** Per-type + per-status counts for the dashboard header. */
async stats() {
/** Per-type + per-status counts for the dashboard header. Scoped by
* servicio so each /notificaciones tab reports its own totals instead of
* the whole platform's. */
async stats(servicio?: EmailNotificationServicio[]) {
const where: Prisma.EmailNotificationLogWhereInput = servicio?.length
? { servicio: { in: servicio } }
: {};
const [byType, byStatus, byServicio, lastRun] = await Promise.all([
this.prisma.emailNotificationLog.groupBy({
by: ["notificationType", "status"],
where,
_count: { _all: true },
}),
this.prisma.emailNotificationLog.groupBy({
by: ["status"],
where,
_count: { _all: true },
}),
this.prisma.emailNotificationLog.groupBy({
by: ["servicio", "status"],
where,
_count: { _all: true },
}),
this.prisma.emailNotificationLog.findFirst({
where,
orderBy: { sendDate: "desc" },
select: { sendDate: true, notificationType: true },
}),
@@ -917,7 +931,9 @@ export class NotificationsService {
}
}
/** Persist one notification log row. */
/** Persist one notification log row. Thin pass-through to the shared
* writer — the renewal sweep writes the same rows through the same
* service, which is what keeps /notificaciones' log complete. */
private async recordAttempt(args: {
notificationType: EmailNotificationType;
servicio: EmailNotificationServicio;
@@ -934,24 +950,7 @@ export class NotificationsService {
providerResponse?: string;
error?: string;
}) {
await this.prisma.emailNotificationLog.create({
data: {
notificationType: args.notificationType,
servicio: args.servicio,
level: args.level ?? null,
customerId: args.customerId,
customerName: args.customerName,
customerEmail: args.customerEmail,
subject: args.subject,
bodySnapshot: args.bodySnapshot,
bodyRequestUrl: args.bodyRequestUrl ?? null,
debug: args.debug,
providerMessageId: args.providerMessageId ?? null,
providerResponse: args.providerResponse ?? null,
status: args.status as EmailNotificationStatus,
error: args.error ?? null,
},
});
await this.log.record(args);
}
/** Send the admin summary email after every job. The PHP sent one to
+156
View File
@@ -0,0 +1,156 @@
import { RenewalsService } from "./renewals.service";
/**
* The renewal sweep's half of the unified notification log.
*
* `RenewalNotice` only records that a policy WAS notified — it has no way to
* say a send failed or that a customer had no address. Those rows exist only
* in `email_notification_log`, so they are what these tests pin down.
*/
const POLICY_ID = "policy-1";
const CUSTOMER_ID = "cust-1";
function makePolicy(email: string | null) {
return {
id: POLICY_ID,
policyNumber: "700442181",
policyTo: new Date("2026-09-01T00:00:00.000Z"),
netPremium: null,
policyFee: null,
total: null,
currency: "MXN",
coveragesJson: null,
customer: {
id: CUSTOMER_ID,
name: "ACME SA DE CV",
nameMissing: false,
email,
phone: null,
mobile: null,
addressLine1: null,
addressLine2: null,
city: null,
state: null,
zipCode: null,
country: null,
},
policyType: { name: "AUTO" },
insuranceProvider: { name: "GMX" },
vehicles: [],
renewalNotices: [],
};
}
function build(overrides: {
policies?: ReturnType<typeof makePolicy>[];
sendImpl?: () => Promise<{ messageId: string; response: string }>;
}) {
const policies = overrides.policies ?? [makePolicy("cliente@example.com")];
const record = jest.fn().mockResolvedValue(undefined);
const send =
overrides.sendImpl ??
jest.fn().mockResolvedValue({ messageId: "ses-1", response: "{}" });
const prisma = {
// Only generation 1 has a candidate; the other two cadences return none,
// so a sweep produces exactly one outcome to assert on.
policy: {
findMany: jest
.fn()
.mockResolvedValueOnce(policies)
.mockResolvedValue([]),
findFirst: jest.fn().mockResolvedValue(policies[0]),
},
renewalNotice: { upsert: jest.fn().mockResolvedValue({}) },
scheduledJobState: {
upsert: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findUniqueOrThrow: jest.fn().mockResolvedValue({ lastSuccessfulAt: null }),
update: jest.fn().mockResolvedValue({}),
},
};
const service = new RenewalsService(
prisma as never,
{ available: true, send } as never,
{ log: jest.fn() } as never,
{ record } as never,
);
return { service, record, send, prisma };
}
describe("renewal notices write the shared notification log", () => {
it("records a SENT row tagged RENEWAL_NOTICE / POLICIES", async () => {
const { service, record, prisma } = build({});
await service.sweep("user-1");
expect(record).toHaveBeenCalledTimes(1);
const row = record.mock.calls[0][0];
expect(row).toMatchObject({
notificationType: "RENEWAL_NOTICE",
servicio: "POLICIES",
status: "SENT",
customerId: CUSTOMER_ID,
customerEmail: "cliente@example.com",
providerMessageId: "ses-1",
debug: false,
});
// `level` carries the aviso generation, not an alert colour.
expect(row.level).toBe(1);
expect(row.subject).toContain("700442181");
expect(row.bodySnapshot).toContain("ACME SA DE CV");
// The gating row is still written — the log does not replace it.
expect(prisma.renewalNotice.upsert).toHaveBeenCalledTimes(1);
});
it("records a FAILED row and no gating row when the send throws", async () => {
const { service, record, prisma } = build({
sendImpl: jest.fn().mockRejectedValue(new Error("SES rejected")),
});
const result = await service.sweep("user-1");
expect(result.sent).toBe(0);
expect(result.failed).toBe(1);
expect(record).toHaveBeenCalledTimes(1);
expect(record.mock.calls[0][0]).toMatchObject({
status: "FAILED",
error: "SES rejected",
notificationType: "RENEWAL_NOTICE",
});
// Nothing was delivered, so nothing may gate tomorrow's retry.
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
});
it("records SKIPPED_NO_EMAIL for a candidate with no address", async () => {
const { service, record, send, prisma } = build({
policies: [makePolicy(" ")],
});
const result = await service.sweep("user-1");
expect(result.skipped).toBe(1);
expect(send).not.toHaveBeenCalled();
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
expect(record.mock.calls[0][0]).toMatchObject({
status: "SKIPPED_NO_EMAIL",
customerEmail: "",
});
});
it("does not fail a delivered notice when the log write throws", async () => {
const { service, record } = build({});
record.mockRejectedValue(new Error("log table gone"));
const result = await service.sweep("user-1");
// The mail went out and the gating row was written; a lost audit row must
// not report that as a failure, which would re-send tomorrow.
expect(result.sent).toBe(1);
expect(result.failed).toBe(0);
});
});
+4
View File
@@ -1,8 +1,12 @@
import { Module } from "@nestjs/common";
import { NotificationLogModule } from "../notifications/notification-log.module";
import { RenewalsController } from "./renewals.controller";
import { RenewalsService } from "./renewals.service";
@Module({
// Renewal sends write to the same `email_notification_log` the four bulk
// jobs write, so /notificaciones has one send history across both tabs.
imports: [NotificationLogModule],
controllers: [RenewalsController],
providers: [RenewalsService],
})
+92 -7
View File
@@ -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,
+2
View File
@@ -12,6 +12,8 @@ export function renewalLetterSelect(generation: number) {
coveragesJson: true,
customer: {
select: {
// Needed by the notification log's customerId FK, not by the letter.
id: true,
name: true,
nameMissing: true,
email: true,