diff --git a/apps/api/src/notifications/notification-log.module.ts b/apps/api/src/notifications/notification-log.module.ts new file mode 100644 index 0000000..3f766d0 --- /dev/null +++ b/apps/api/src/notifications/notification-log.module.ts @@ -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 {} diff --git a/apps/api/src/notifications/notification-log.service.ts b/apps/api/src/notifications/notification-log.service.ts new file mode 100644 index 0000000..21fdd64 --- /dev/null +++ b/apps/api/src/notifications/notification-log.service.ts @@ -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 { + 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, + }, + }); + } +} diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts index 1f72da4..2fbb0ba 100644 --- a/apps/api/src/notifications/notifications.controller.ts +++ b/apps/api/src/notifications/notifications.controller.ts @@ -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; } } diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts index c6b9252..c4bf55b 100644 --- a/apps/api/src/notifications/notifications.module.ts +++ b/apps/api/src/notifications/notifications.module.ts @@ -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], diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts index 5de6c06..fadf3e6 100644 --- a/apps/api/src/notifications/notifications.service.ts +++ b/apps/api/src/notifications/notifications.service.ts @@ -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("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 diff --git a/apps/api/src/renewals/renewals-log.spec.ts b/apps/api/src/renewals/renewals-log.spec.ts new file mode 100644 index 0000000..e3f7279 --- /dev/null +++ b/apps/api/src/renewals/renewals-log.spec.ts @@ -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[]; + 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); + }); +}); diff --git a/apps/api/src/renewals/renewals.module.ts b/apps/api/src/renewals/renewals.module.ts index f632155..4d9990c 100644 --- a/apps/api/src/renewals/renewals.module.ts +++ b/apps/api/src/renewals/renewals.module.ts @@ -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], }) diff --git a/apps/api/src/renewals/renewals.service.ts b/apps/api/src/renewals/renewals.service.ts index 5353ae1..fec517b 100644 --- a/apps/api/src/renewals/renewals.service.ts +++ b/apps/api/src/renewals/renewals.service.ts @@ -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>; + 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 { + 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, diff --git a/apps/api/src/reports/renewal-letter.ts b/apps/api/src/reports/renewal-letter.ts index a14380b..fd33568 100644 --- a/apps/api/src/reports/renewal-letter.ts +++ b/apps/api/src/reports/renewal-letter.ts @@ -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, diff --git a/apps/web/src/components/NotificacionesPolizas.tsx b/apps/web/src/components/NotificacionesPolizas.tsx index 72b7418..729ef7f 100644 --- a/apps/web/src/components/NotificacionesPolizas.tsx +++ b/apps/web/src/components/NotificacionesPolizas.tsx @@ -3,7 +3,8 @@ import { useCallback, useEffect, useState } from "react"; import { useCan } from "@/lib/abilities"; import { formatDate, formatMoney } from "@/lib/labels"; -import { apiFetch } from "@/lib/api"; +import { NotificationLogPanel } from "@/components/NotificationLogPanel"; +import { apiFetch, POLIZAS_LOG_SCOPE } from "@/lib/api"; /** * Renewal notices — the "Pólizas" half of /notificaciones. Shows which @@ -11,6 +12,11 @@ import { apiFetch } from "@/lib/api"; * one row at a time or as a whole sweep. Sending is what marks a notice as * delivered — there is no manual "mark as sent", so the list can never claim * a letter went out when no mail was ever sent. Gated on `renewal:send`. + * + * Sends are recorded in the same `email_notification_log` the Servicios tab + * reads, so "Registro de envíos" below is the same component with the + * POLICIES slice — failures and no-email skips included, which the pending + * list alone cannot show. */ export interface RenewalLetter { @@ -60,6 +66,8 @@ export function NotificacionesPolizas() { const [sweeping, setSweeping] = useState(false); /** `policyId-generation` of the row currently being sent, if any. */ const [sendingKey, setSendingKey] = useState(null); + /** Raised after every send so the log panel reloads. */ + const [logToken, setLogToken] = useState(0); const refresh = useCallback(async () => { setPendingError(null); @@ -89,9 +97,11 @@ export function NotificacionesPolizas() { method: "POST", }); setNotice(`Enviados ${result.sent} avisos (${result.failed} con error).`); + setLogToken((t) => t + 1); await refresh(); } catch (e) { setActionError((e as Error)?.message ?? "No se pudo ejecutar el barrido."); + setLogToken((t) => t + 1); } finally { setSweeping(false); } @@ -115,9 +125,12 @@ export function NotificacionesPolizas() { }), }); setNotice(`Aviso enviado a ${result.to}.`); + setLogToken((t) => t + 1); await refresh(); } catch (e) { setActionError((e as Error)?.message ?? "No se pudo enviar el aviso."); + // A rejected send may still have written a FAILED row; reload either way. + setLogToken((t) => t + 1); } finally { setSendingKey(null); } @@ -253,6 +266,12 @@ export function NotificacionesPolizas() { ))} + + ); } diff --git a/apps/web/src/components/NotificacionesServicios.tsx b/apps/web/src/components/NotificacionesServicios.tsx index 21e8a41..96250c7 100644 --- a/apps/web/src/components/NotificacionesServicios.tsx +++ b/apps/web/src/components/NotificacionesServicios.tsx @@ -2,29 +2,26 @@ import { useCallback, useEffect, useState } from "react"; import { useCan } from "@/lib/abilities"; -import { formatDateTime } from "@/lib/labels"; import { - NOTIFICATION_STATUS_COLORS, + formatDateTime, NOTIFICATION_STATUS_LABELS, - NOTIFICATION_SERVICIO_LABELS, NOTIFICATION_TYPE_LABELS, } from "@/lib/labels"; +import { NotificationLogPanel } from "@/components/NotificationLogPanel"; import { getNotificationStats, - listNotificationLog, runAccountStatus, runAllNotifications, runOutstandingPayments, runPaymentConfirmation, runTrustConfirmation, + SERVICIOS_LOG_SCOPE, } from "@/lib/api"; import type { NotificationFlags, NotificationJobResponse, - NotificationLogPage, NotificationRunAllResponse, NotificationStats, - NotificationStatus, } from "@/lib/api"; /** @@ -87,24 +84,13 @@ const JOB_TITLES: Record = JOBS.reduce( {} as Record, ); -const LOG_VIEWS = [ - { key: "all", label: "Todos" }, - { key: "sent", label: "Enviados" }, - { key: "failed", label: "Fallidos" }, - { key: "skipped", label: "Omitidos" }, -] as const; - export function NotificacionesServicios() { const allowed = useCan("notification:send"); const [flags, setFlags] = useState({ debug: true }); const [stats, setStats] = useState(null); - const [log, setLog] = useState(null); - const [logFilter, setLogFilter] = useState<{ - status?: NotificationStatus; - view: "all" | "sent" | "failed" | "skipped"; - }>({ view: "all" }); - const [logPage, setLogPage] = useState(1); + /** Raised after every run so the shared log panel reloads. */ + const [logToken, setLogToken] = useState(0); const [busy, setBusy] = useState(null); const [lastResult, setLastResult] = useState< NotificationJobResponse | NotificationRunAllResponse | null @@ -113,22 +99,13 @@ export function NotificacionesServicios() { const refresh = useCallback(async () => { try { - const [s, l] = await Promise.all([ - getNotificationStats(), - listNotificationLog({ - page: logPage, - pageSize: 50, - status: logFilter.status, - view: logFilter.view === "all" ? undefined : logFilter.view, - }), - ]); - setStats(s); - setLog(l); + setStats(await getNotificationStats(SERVICIOS_LOG_SCOPE)); + setLogToken((t) => t + 1); setError(null); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } - }, [logPage, logFilter]); + }, []); useEffect(() => { void refresh(); @@ -420,111 +397,10 @@ export function NotificacionesServicios() { )} -
-
-

Registro de envíos

-
- {LOG_VIEWS.map((v) => ( - - ))} -
-
- -
- - - - - - - - - - - - - - - {log?.items.map((row) => ( - - - - - - - - - - - ))} - {log && log.items.length === 0 && ( - - - - )} - -
FechaTipoServicioClienteEmailEstadoAsuntoProvider
{formatDateTime(row.sendDate)} - {NOTIFICATION_TYPE_LABELS[row.notificationType]} - {row.level !== null && (row.level === 0 ? " (amarilla)" : " (roja)")} - {NOTIFICATION_SERVICIO_LABELS[row.servicio]} - {row.customerName} - {row.debug ? " · debug" : ""} - {row.customerEmail} - {NOTIFICATION_STATUS_LABELS[row.status]} - {row.subject} - {row.providerMessageId ?? row.error ?? "—"} -
- - Sin envíos con el filtro actual. - -
-
- - {log && log.pageCount > 1 && ( -
- - - {log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de{" "} - {log.pageCount} - - -
- )} -
+ ); } diff --git a/apps/web/src/components/NotificationLogPanel.tsx b/apps/web/src/components/NotificationLogPanel.tsx new file mode 100644 index 0000000..114bdb6 --- /dev/null +++ b/apps/web/src/components/NotificationLogPanel.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + listNotificationLog, + type NotificationLogPage, + type NotificationServicio, +} from "@/lib/api"; +import { + formatDateTime, + NOTIFICATION_SERVICIO_LABELS, + NOTIFICATION_STATUS_COLORS, + NOTIFICATION_STATUS_LABELS, + NOTIFICATION_TYPE_LABELS, + notificationLevelLabel, +} from "@/lib/labels"; + +/** + * "Registro de envíos" — the send history over `email_notification_log`. + * + * Every outbound email the platform sends writes to that one table (the four + * bulk jobs and the renewal avisos alike), so this component is shared by + * both /notificaciones tabs; each passes the `servicio` slice it owns. Rows + * cover failures and skips too, which is the whole point: a notice that never + * left is invisible everywhere else. + */ + +const LOG_VIEWS = [ + { key: "all", label: "Todos" }, + { key: "sent", label: "Enviados" }, + { key: "failed", label: "Fallidos" }, + { key: "skipped", label: "Omitidos" }, +] as const; + +export type LogView = (typeof LOG_VIEWS)[number]["key"]; + +export function NotificationLogPanel({ + servicio, + emptyHint = "Sin envíos con el filtro actual.", + /** Bump to force a reload — the parent raises it after a send. */ + reloadToken = 0, +}: { + servicio: NotificationServicio[]; + emptyHint?: string; + reloadToken?: number; +}) { + const [log, setLog] = useState(null); + const [error, setError] = useState(null); + const [view, setView] = useState("all"); + const [page, setPage] = useState(1); + + // `servicio` is a literal array at every call site, so a new identity each + // render would re-fetch forever. Key the effect on its contents instead. + const servicioKey = servicio.join(","); + + const refresh = useCallback(async () => { + try { + const data = await listNotificationLog({ + page, + pageSize: 50, + servicio: servicioKey.split(",") as NotificationServicio[], + view: view === "all" ? undefined : view, + }); + setLog(data); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, [page, view, servicioKey]); + + useEffect(() => { + void refresh(); + }, [refresh, reloadToken]); + + return ( +
+
+

Registro de envíos

+
+ {LOG_VIEWS.map((v) => ( + + ))} +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + + + + + + + + + + {log?.items.map((row) => ( + + + + + + + + + + + ))} + {log && log.items.length === 0 && ( + + + + )} + +
FechaTipoServicioClienteEmailEstadoAsuntoProvider
{formatDateTime(row.sendDate)} + {NOTIFICATION_TYPE_LABELS[row.notificationType]} + {notificationLevelLabel(row.notificationType, row.level)} + {NOTIFICATION_SERVICIO_LABELS[row.servicio]} + {row.customerName} + {row.debug ? " · debug" : ""} + {row.customerEmail || "—"} + {NOTIFICATION_STATUS_LABELS[row.status]} + {row.subject} + {row.providerMessageId ?? row.error ?? "—"} +
+ {emptyHint} +
+
+ + {log && log.pageCount > 1 && ( +
+ + + {log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de{" "} + {log.pageCount} + + +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 45d220a..6d86b41 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -979,9 +979,14 @@ export type NotificationType = | "OUTSTANDING_PAYMENT" | "PAYMENT_CONFIRMATION" | "ACCOUNT_STATUS" - | "TRUST_PAYMENT_CONFIRMATION"; + | "TRUST_PAYMENT_CONFIRMATION" + | "RENEWAL_NOTICE"; -export type NotificationServicio = "CUSTOMERS" | "TRUST"; +export type NotificationServicio = "CUSTOMERS" | "TRUST" | "POLICIES"; + +/** Which servicios each /notificaciones tab reads out of the shared log. */ +export const SERVICIOS_LOG_SCOPE: NotificationServicio[] = ["CUSTOMERS", "TRUST"]; +export const POLIZAS_LOG_SCOPE: NotificationServicio[] = ["POLICIES"]; export type NotificationStatus = | "SENT" @@ -1161,7 +1166,8 @@ export interface NotificationLogQuery { page?: number; pageSize?: number; type?: NotificationType; - servicio?: NotificationServicio; + /** One or more servicios; omitted = the whole log. */ + servicio?: NotificationServicio[]; status?: NotificationStatus; view?: "sent" | "failed" | "skipped" | "all"; } @@ -1173,15 +1179,20 @@ export function listNotificationLog( if (q.page) qs.set("page", String(q.page)); if (q.pageSize) qs.set("pageSize", String(q.pageSize)); if (q.type) qs.set("type", q.type); - if (q.servicio) qs.set("servicio", q.servicio); + if (q.servicio?.length) qs.set("servicio", q.servicio.join(",")); if (q.status) qs.set("status", q.status); if (q.view) qs.set("view", q.view); const tail = qs.toString(); return apiFetch(`/notifications/log${tail ? `?${tail}` : ""}`); } -export function getNotificationStats(): Promise { - return apiFetch("/notifications/stats"); +export function getNotificationStats( + servicio?: NotificationServicio[], +): Promise { + const tail = servicio?.length + ? `?servicio=${encodeURIComponent(servicio.join(","))}` + : ""; + return apiFetch(`/notifications/stats${tail}`); } /** Build a download URL for a report's file output. The session cookie diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts index a5307a2..c1c65bf 100644 --- a/apps/web/src/lib/labels.ts +++ b/apps/web/src/lib/labels.ts @@ -393,13 +393,35 @@ export const NOTIFICATION_TYPE_LABELS: Record = { PAYMENT_CONFIRMATION: "Confirmación de pago", ACCOUNT_STATUS: "Estado de cuenta", TRUST_PAYMENT_CONFIRMATION: "Confirmación fideicomiso", + RENEWAL_NOTICE: "Aviso de renovación", }; export const NOTIFICATION_SERVICIO_LABELS: Record = { CUSTOMERS: "Clientes", TRUST: "Fideicomiso", + POLICIES: "Pólizas", }; +/** + * The `level` column means something different per notification type, so it + * can only be read alongside one. ACCOUNT_STATUS uses it for the alert colour; + * RENEWAL_NOTICE for the aviso generation. Everything else leaves it null. + */ +export function notificationLevelLabel( + type: NotificationType, + level: number | null, +): string { + if (level === null) return ""; + if (type === "ACCOUNT_STATUS") return level === 0 ? " (amarilla)" : " (roja)"; + if (type === "RENEWAL_NOTICE") { + if (level === 1) return " (1.º, 30 días antes)"; + if (level === 2) return " (2.º, 15 días antes)"; + if (level === 3) return " (3.º, 7 días después)"; + return ` (aviso ${level})`; + } + return ""; +} + export const NOTIFICATION_STATUS_LABELS: Record = { SENT: "Enviado", FAILED: "Falló", diff --git a/deploy/galactus/jorgecuadros-app.compose.yml b/deploy/galactus/jorgecuadros-app.compose.yml index 7cd152e..321ee37 100644 --- a/deploy/galactus/jorgecuadros-app.compose.yml +++ b/deploy/galactus/jorgecuadros-app.compose.yml @@ -73,6 +73,22 @@ services: S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents} MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set} + # Outbound mail (SES). Runtime config, never baked into the image and + # never a CI secret — the build does not send mail, this container does. + # The image sets NODE_ENV=production, which disables MailService's + # stdout dev fallback: leave these blank and every notification and + # renewal aviso fails with "El envío de correo no está configurado." + # rather than silently going nowhere. Values live in this stack's env + # file on galactus (deploy/.env.prod), same as DATABASE_URL. + SES_REGION: ${SES_REGION:-} + SES_FROM: ${SES_FROM:-} + SES_FROM_NAME: ${SES_FROM_NAME:-} + SES_ACCESS_KEY: ${SES_ACCESS_KEY:-} + SES_SECRET_KEY: ${SES_SECRET_KEY:-} + SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET:-} + # Who gets the per-job summary mail. Falls back to the two hardcoded + # defaults in NotificationsService when unset. + NOTIFICATION_ADMIN_EMAILS: ${NOTIFICATION_ADMIN_EMAILS:-} ports: - "${API_PORT:-3001}:3001" volumes: diff --git a/deploy/jorgecuadros-app.env.example b/deploy/jorgecuadros-app.env.example index 4c705e5..5bb2235 100644 --- a/deploy/jorgecuadros-app.env.example +++ b/deploy/jorgecuadros-app.env.example @@ -33,8 +33,25 @@ S3_BUCKET=jorgecuadros-documents MINIO_ROOT_USER=jc_minio MINIO_ROOT_PASSWORD=CHANGE_ME -SES_REGION= -SES_FROM= +# --- Outbound mail (Amazon SES) ---------------------------------------------- +# Belongs HERE, in the stack's env file on the host — not in Gitea Actions +# secrets. The build never sends mail; the running container does, and it reads +# these at boot (apps/api/src/mail/mail.service.ts). +# +# The production image sets NODE_ENV=production, which turns OFF the stdout dev +# fallback. Leaving these blank does not silently swallow mail — every send +# fails with "El envío de correo no está configurado.", and the failure is +# recorded in the notification log. Fill them in before enabling any envío. +# +# SES_FROM must be a verified SES sending identity. +SES_REGION=us-west-2 +SES_FROM=mail@jorgecuadros.com +SES_FROM_NAME=Information Server SES_ACCESS_KEY= SES_SECRET_KEY= +# Optional — only needed to publish bounce/complaint events. SES_CONFIGURATION_SET= + +# Recipients of the per-job summary email. Comma-separated; unset falls back to +# the defaults in NotificationsService. +NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net diff --git a/deploy/jorgecuadros-app.stack.yml b/deploy/jorgecuadros-app.stack.yml index bd2cefb..2068dd5 100644 --- a/deploy/jorgecuadros-app.stack.yml +++ b/deploy/jorgecuadros-app.stack.yml @@ -55,11 +55,14 @@ services: S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents} MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set} + # Outbound mail (SES) — runtime config, not a build-time CI secret. SES_REGION: ${SES_REGION:-} SES_FROM: ${SES_FROM:-} + SES_FROM_NAME: ${SES_FROM_NAME:-} SES_ACCESS_KEY: ${SES_ACCESS_KEY:-} SES_SECRET_KEY: ${SES_SECRET_KEY:-} SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET:-} + NOTIFICATION_ADMIN_EMAILS: ${NOTIFICATION_ADMIN_EMAILS:-} ports: - target: 3001 published: ${API_PORT:-3001} diff --git a/docs/MASS_EMAIL_NOTIFICATIONS.md b/docs/MASS_EMAIL_NOTIFICATIONS.md index 6a48594..9a2baf6 100644 --- a/docs/MASS_EMAIL_NOTIFICATIONS.md +++ b/docs/MASS_EMAIL_NOTIFICATIONS.md @@ -93,6 +93,24 @@ correlation. Indexes: `(sendDate)`, `(notificationType, sendDate)`, `(customerId, sendDate)`. +**This table is not job-specific.** Insurance renewal avisos +(`RenewalsService`, see [`RENEWAL_NOTICES.md`](RENEWAL_NOTICES.md)) write +here too, as `notificationType = RENEWAL_NOTICE` / +`servicio = POLICIES` — one send history for the whole platform rather +than one per feature. `NotificationLogService` is the only writer; +anything that sends mail goes through it. + +`level` is therefore per-type and cannot be read without its +`notificationType`: 0/1 (yellow/red) on `ACCOUNT_STATUS`, the aviso +generation 1/2/3 on `RENEWAL_NOTICE`, null elsewhere. On the web side +`notificationLevelLabel()` is the only place that branch lives. + +Renewals keep their own `renewal_notices` row as well. The two are not +redundant: `renewal_notices` is *gating* state (one row per +policy+generation, "already notified" — it drives the pending list), +while this log is *history* (every attempt, including the failures and +no-email skips a gating row cannot represent). + ### `account_status_history` Mirrors the legacy `utility_dbo.send_account_status_history` table: @@ -120,12 +138,25 @@ Without SES_* the API still boots and `MailService` falls back to stdout in dev (`NODE_ENV !== "production"`). In production every send throws `ServiceUnavailableException` and the row is recorded as `FAILED`. +These are **runtime** config, set in the deployed stack's env file on the +host (`deploy/jorgecuadros-app.env.example` documents the full set) — not +Gitea Actions secrets. The build never sends mail; only the running +container does, and the production image sets `NODE_ENV=production`, so a +blank SES config fails loudly rather than falling back to stdout. + ## UI -`/notificaciones` (gated on `notification:send`) — four trigger cards, -a debug/ignoreDayRestriction/useEmailLimit flags panel, a transport -status header, and a paginated log table. STAFF users see the log -read-only. +`/notificaciones`, two tabs over the one log: + +- **Servicios** (`notification:send`) — four trigger cards, a + debug/ignoreDayRestriction/useEmailLimit flags panel, a transport status + header. Reads the `CUSTOMERS` + `TRUST` slice. +- **Pólizas** (`renewal:send`) — pending avisos and the manual sweep. + Reads the `POLICIES` slice. + +Both render the same `NotificationLogPanel` ("Registro de envíos"), which +filters by servicio and by view (todos / enviados / fallidos / omitidos). +STAFF users see the Servicios log read-only. ## Cron (future) diff --git a/packages/database/prisma/migrations/20260802120000_renewal_notices_unified_log/migration.sql b/packages/database/prisma/migrations/20260802120000_renewal_notices_unified_log/migration.sql new file mode 100644 index 0000000..962a22c --- /dev/null +++ b/packages/database/prisma/migrations/20260802120000_renewal_notices_unified_log/migration.sql @@ -0,0 +1,67 @@ +-- Fold insurance renewal avisos into the one notification log. +-- +-- Until now `renewal_notices` was the only record a renewal send left +-- behind, and its sole job is gating: a row with `sentAt` removes the +-- policy from the pending list. It cannot represent a failed send, a +-- customer with no email, or a second attempt — so the Pólizas tab of +-- /notificaciones had no "Registro de envíos" to show. +-- +-- Extending the two enums lets `RenewalsService` write the same +-- `email_notification_log` rows the four bulk jobs write. `renewal_notices` +-- keeps its gating role unchanged. + +-- AlterEnum: EmailNotificationType += RENEWAL_NOTICE +ALTER TABLE `email_notification_log` + MODIFY `notificationType` ENUM('OUTSTANDING_PAYMENT', 'PAYMENT_CONFIRMATION', 'ACCOUNT_STATUS', 'TRUST_PAYMENT_CONFIRMATION', 'RENEWAL_NOTICE') NOT NULL; + +-- AlterEnum: EmailNotificationServicio += POLICIES +ALTER TABLE `email_notification_log` + MODIFY `servicio` ENUM('CUSTOMERS', 'TRUST', 'POLICIES') NOT NULL; + +-- Backfill: every renewal notice this platform actually emailed. +-- +-- Scope is deliberately `channel = 'EMAIL' AND sentAt IS NOT NULL`. MAIL- +-- channel rows are printed letters carried over from the legacy book — they +-- were never emails, and inventing log rows for them would misreport the +-- send history. Emailed rows carry a real recipient (resolved through the +-- policy's customer) and a real timestamp; the only fields we cannot +-- recover are the rendered body and the exact subject, so `bodySnapshot` +-- stays empty and the subject is reconstructed from the same two templates +-- `renderRenewalEmail()` uses (generation 3 = expired wording). +-- +-- Rows whose customer has no email are skipped: `customerEmail` is NOT NULL +-- and a blank recipient would be a lie. Their `renewal_notices` row still +-- gates the pending list exactly as before. +INSERT INTO `email_notification_log` ( + `id`, `sendDate`, `notificationType`, `level`, `servicio`, + `customerId`, `customerName`, `customerEmail`, `subject`, + `bodyRequestUrl`, `bodySnapshot`, `debug`, + `providerMessageId`, `providerResponse`, `status`, `error` +) +SELECT + UUID(), + rn.`sentAt`, + 'RENEWAL_NOTICE', + rn.`generation`, + 'POLICIES', + c.`id`, + c.`name`, + c.`email`, + CASE WHEN rn.`generation` = 3 + THEN CONCAT('Póliza vencida: ', p.`policyNumber`) + ELSE CONCAT('Aviso de renovación: póliza ', p.`policyNumber`) + END, + NULL, + '', + FALSE, + rn.`providerMessageId`, + 'backfill:20260802120000', + 'SENT', + NULL +FROM `renewal_notices` rn +JOIN `policies` p ON p.`id` = rn.`policyId` +JOIN `customers` c ON c.`id` = p.`customerId` +WHERE rn.`channel` = 'EMAIL' + AND rn.`sentAt` IS NOT NULL + AND c.`email` IS NOT NULL + AND c.`email` <> ''; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 264e2d9..87ce761 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -944,20 +944,31 @@ model EmailLog { /// red and yellow; the threshold is in the /// `level` column, 0=yellow / 1=red) /// - TRUST_PAYMENT_CONFIRMATION → sendConfirmTrustPayment.php (TRUSTHFEE) +/// +/// RENEWAL_NOTICE has no PHP ancestor — it is the insurance renewal aviso +/// (`RenewalsService`), logged here so every outbound email the platform +/// sends lands in one table. `RenewalNotice` remains the per-policy +/// "already notified" record that drives the pending list; this log is the +/// send history, including the failures and skips `RenewalNotice` cannot +/// represent. enum EmailNotificationType { OUTSTANDING_PAYMENT PAYMENT_CONFIRMATION ACCOUNT_STATUS TRUST_PAYMENT_CONFIRMATION + RENEWAL_NOTICE } /// Which "servicio" (line of business) the notification draws its recipients /// from. CUSTOMERS = the unified customers ledger (replaces `datosfreak`); -/// TRUST = the trust-fee account table (replaces `TRUSTHFEE`). Keeping the -/// two services tagged makes a per-line report trivial. +/// TRUST = the trust-fee account table (replaces `TRUSTHFEE`); +/// POLICIES = the insurance book (renewal avisos). Keeping the services +/// tagged makes a per-line report trivial — and lets the /notificaciones +/// tabs each show their own slice of the one log. enum EmailNotificationServicio { CUSTOMERS TRUST + POLICIES } /// Outcome of a single send attempt. SENT / FAILED are the meaningful ones; @@ -982,11 +993,17 @@ model EmailNotificationLog { id String @id @default(uuid()) sendDate DateTime @default(now()) notificationType EmailNotificationType - /// 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO"). Only set on - /// ACCOUNT_STATUS rows; null on the other three jobs. + /// Per-type discriminator, null where the type has none: + /// ACCOUNT_STATUS → 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO") + /// RENEWAL_NOTICE → the aviso generation (1 = 30d before, 2 = 15d + /// before, 3 = 7d after expiry) + /// Null on the remaining jobs. Readers MUST branch on notificationType + /// before interpreting it. level Int? /// Which servicio sourced the recipient list. CUSTOMERS for jobs 1/2/3, - /// TRUST for job 4. Tagged here so a per-line audit doesn't need to join. + /// TRUST for job 4, POLICIES for renewal avisos. Tagged here so a per-line + /// audit doesn't need to join — and so the /notificaciones Servicios tab + /// (CUSTOMERS + TRUST) and Pólizas tab (POLICIES) can filter one log. servicio EmailNotificationServicio /// FK to the customer that triggered the send. Trust-account notifications /// resolve the owner through `Property.customerId`, so this stays set on