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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user