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
@@ -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