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) page?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize?: number; @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize?: number;
@IsOptional() @IsEnum(EmailNotificationType) type?: EmailNotificationType; @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(EmailNotificationStatus) status?: EmailNotificationStatus;
@IsOptional() @IsEnum(["sent", "failed", "skipped", "all"]) view?: "sent" | "failed" | "skipped" | "all"; @IsOptional() @IsEnum(["sent", "failed", "skipped", "all"]) view?: "sent" | "failed" | "skipped" | "all";
} }
@@ -185,19 +195,27 @@ export class NotificationsController {
} }
@Get("stats") @Get("stats")
stats() { stats(@Query() q: ListLogDto) {
return this.svc.stats(); 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( private mapViewStatus(
view: ListLogDto["view"], view: ListLogDto["view"],
status: ListLogDto["status"], status: ListLogDto["status"],
): EmailNotificationStatus | undefined { ): EmailNotificationStatus[] | undefined {
if (status) return status; if (status) return [status];
if (!view || view === "all") return undefined; if (!view || view === "all") return undefined;
if (view === "sent") return EmailNotificationStatus.SENT; if (view === "sent") return [EmailNotificationStatus.SENT];
if (view === "failed") return EmailNotificationStatus.FAILED; if (view === "failed") return [EmailNotificationStatus.FAILED];
if (view === "skipped") return undefined; // both SKIPPED_* variants if (view === "skipped") {
return [
EmailNotificationStatus.SKIPPED_NO_EMAIL,
EmailNotificationStatus.SKIPPED_GATE,
];
}
return undefined; return undefined;
} }
} }
@@ -1,4 +1,5 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { NotificationLogModule } from "./notification-log.module";
import { NotificationsController } from "./notifications.controller"; import { NotificationsController } from "./notifications.controller";
import { NotificationsService } from "./notifications.service"; import { NotificationsService } from "./notifications.service";
@@ -11,6 +12,7 @@ import { NotificationsService } from "./notifications.service";
* service methods are already the entry points they would call. * service methods are already the entry points they would call.
*/ */
@Module({ @Module({
imports: [NotificationLogModule],
controllers: [NotificationsController], controllers: [NotificationsController],
providers: [NotificationsService], providers: [NotificationsService],
exports: [NotificationsService], exports: [NotificationsService],
@@ -10,6 +10,7 @@ import {
} from "@jorgecuadros/database"; } from "@jorgecuadros/database";
import { MailService } from "../mail/mail.service"; import { MailService } from "../mail/mail.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { NotificationLogService } from "./notification-log.service";
import { import {
SendAttempt, SendAttempt,
NotificationJobKind, NotificationJobKind,
@@ -81,6 +82,7 @@ export class NotificationsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly mail: MailService, private readonly mail: MailService,
private readonly log: NotificationLogService,
config: ConfigService, config: ConfigService,
) { ) {
const csv = config.get<string>("NOTIFICATION_ADMIN_EMAILS"); const csv = config.get<string>("NOTIFICATION_ADMIN_EMAILS");
@@ -721,14 +723,16 @@ export class NotificationsService {
page: number; page: number;
pageSize: number; pageSize: number;
type?: EmailNotificationType; type?: EmailNotificationType;
servicio?: EmailNotificationServicio; /** Empty/omitted = every servicio. The /notificaciones tabs pass their
status?: EmailNotificationStatus; * own slice (Servicios: CUSTOMERS+TRUST, Pólizas: POLICIES). */
servicio?: EmailNotificationServicio[];
status?: EmailNotificationStatus[];
customerId?: string; customerId?: string;
}) { }) {
const where: Prisma.EmailNotificationLogWhereInput = {}; const where: Prisma.EmailNotificationLogWhereInput = {};
if (params.type) where.notificationType = params.type; if (params.type) where.notificationType = params.type;
if (params.servicio) where.servicio = params.servicio; if (params.servicio?.length) where.servicio = { in: params.servicio };
if (params.status) where.status = params.status; if (params.status?.length) where.status = { in: params.status };
if (params.customerId) where.customerId = params.customerId; if (params.customerId) where.customerId = params.customerId;
const [total, rows] = await this.prisma.$transaction([ const [total, rows] = await this.prisma.$transaction([
@@ -765,22 +769,32 @@ export class NotificationsService {
}; };
} }
/** Per-type + per-status counts for the dashboard header. */ /** Per-type + per-status counts for the dashboard header. Scoped by
async stats() { * 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([ const [byType, byStatus, byServicio, lastRun] = await Promise.all([
this.prisma.emailNotificationLog.groupBy({ this.prisma.emailNotificationLog.groupBy({
by: ["notificationType", "status"], by: ["notificationType", "status"],
where,
_count: { _all: true }, _count: { _all: true },
}), }),
this.prisma.emailNotificationLog.groupBy({ this.prisma.emailNotificationLog.groupBy({
by: ["status"], by: ["status"],
where,
_count: { _all: true }, _count: { _all: true },
}), }),
this.prisma.emailNotificationLog.groupBy({ this.prisma.emailNotificationLog.groupBy({
by: ["servicio", "status"], by: ["servicio", "status"],
where,
_count: { _all: true }, _count: { _all: true },
}), }),
this.prisma.emailNotificationLog.findFirst({ this.prisma.emailNotificationLog.findFirst({
where,
orderBy: { sendDate: "desc" }, orderBy: { sendDate: "desc" },
select: { sendDate: true, notificationType: true }, 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: { private async recordAttempt(args: {
notificationType: EmailNotificationType; notificationType: EmailNotificationType;
servicio: EmailNotificationServicio; servicio: EmailNotificationServicio;
@@ -934,24 +950,7 @@ export class NotificationsService {
providerResponse?: string; providerResponse?: string;
error?: string; error?: string;
}) { }) {
await this.prisma.emailNotificationLog.create({ await this.log.record(args);
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,
},
});
} }
/** Send the admin summary email after every job. The PHP sent one to /** 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 { Module } from "@nestjs/common";
import { NotificationLogModule } from "../notifications/notification-log.module";
import { RenewalsController } from "./renewals.controller"; import { RenewalsController } from "./renewals.controller";
import { RenewalsService } from "./renewals.service"; import { RenewalsService } from "./renewals.service";
@Module({ @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], controllers: [RenewalsController],
providers: [RenewalsService], providers: [RenewalsService],
}) })
+92 -7
View File
@@ -9,6 +9,7 @@ import {
import { Cron } from "@nestjs/schedule"; import { Cron } from "@nestjs/schedule";
import { AuditService } from "../common/audit.service"; import { AuditService } from "../common/audit.service";
import { MailService } from "../mail/mail.service"; import { MailService } from "../mail/mail.service";
import { NotificationLogService } from "../notifications/notification-log.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { import {
RenewalLetterPolicy, RenewalLetterPolicy,
@@ -63,6 +64,7 @@ export class RenewalsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly mail: MailService, private readonly mail: MailService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly notificationLog: NotificationLogService,
) {} ) {}
@Cron("0 6 * * *", { timeZone: TIME_ZONE }) @Cron("0 6 * * *", { timeZone: TIME_ZONE })
@@ -131,6 +133,12 @@ export class RenewalsService {
for (const policy of policies) { for (const policy of policies) {
const to = policy.customer.email?.trim(); const to = policy.customer.email?.trim();
if (!to) { 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++; skipped++;
continue; 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( private async deliver(
policy: RenewalLetterPolicy, policy: RenewalLetterPolicy,
generation: number, generation: number,
@@ -211,12 +225,25 @@ export class RenewalsService {
) { ) {
const letter = toRenewalLetterRow(policy, generation); const letter = toRenewalLetterRow(policy, generation);
const message = renderRenewalEmail(letter); const message = renderRenewalEmail(letter);
const result = await this.mail.send({
to, let result: Awaited<ReturnType<MailService["send"]>>;
subject: message.subject, try {
html: message.html, result = await this.mail.send({
xTracking: "renewals", 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(); const sentAt = new Date();
await this.prisma.renewalNotice.upsert({ await this.prisma.renewalNotice.upsert({
@@ -238,6 +265,12 @@ export class RenewalsService {
providerMessageId: result.messageId, 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", { void this.audit.log(userId, "renewalNotice.send", {
policyId: policy.id, policyId: policy.id,
generation, generation,
@@ -246,6 +279,58 @@ export class RenewalsService {
return { sentAt, providerMessageId: result.messageId }; 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( private findCandidates(
cadence: (typeof RENEWAL_CADENCE)[number], cadence: (typeof RENEWAL_CADENCE)[number],
today: Date, today: Date,
+2
View File
@@ -12,6 +12,8 @@ export function renewalLetterSelect(generation: number) {
coveragesJson: true, coveragesJson: true,
customer: { customer: {
select: { select: {
// Needed by the notification log's customerId FK, not by the letter.
id: true,
name: true, name: true,
nameMissing: true, nameMissing: true,
email: true, email: true,
@@ -3,7 +3,8 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useCan } from "@/lib/abilities"; import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels"; 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 * 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 * 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 * 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`. * 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 { export interface RenewalLetter {
@@ -60,6 +66,8 @@ export function NotificacionesPolizas() {
const [sweeping, setSweeping] = useState(false); const [sweeping, setSweeping] = useState(false);
/** `policyId-generation` of the row currently being sent, if any. */ /** `policyId-generation` of the row currently being sent, if any. */
const [sendingKey, setSendingKey] = useState<string | null>(null); const [sendingKey, setSendingKey] = useState<string | null>(null);
/** Raised after every send so the log panel reloads. */
const [logToken, setLogToken] = useState(0);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setPendingError(null); setPendingError(null);
@@ -89,9 +97,11 @@ export function NotificacionesPolizas() {
method: "POST", method: "POST",
}); });
setNotice(`Enviados ${result.sent} avisos (${result.failed} con error).`); setNotice(`Enviados ${result.sent} avisos (${result.failed} con error).`);
setLogToken((t) => t + 1);
await refresh(); await refresh();
} catch (e) { } catch (e) {
setActionError((e as Error)?.message ?? "No se pudo ejecutar el barrido."); setActionError((e as Error)?.message ?? "No se pudo ejecutar el barrido.");
setLogToken((t) => t + 1);
} finally { } finally {
setSweeping(false); setSweeping(false);
} }
@@ -115,9 +125,12 @@ export function NotificacionesPolizas() {
}), }),
}); });
setNotice(`Aviso enviado a ${result.to}.`); setNotice(`Aviso enviado a ${result.to}.`);
setLogToken((t) => t + 1);
await refresh(); await refresh();
} catch (e) { } catch (e) {
setActionError((e as Error)?.message ?? "No se pudo enviar el aviso."); 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 { } finally {
setSendingKey(null); setSendingKey(null);
} }
@@ -253,6 +266,12 @@ export function NotificacionesPolizas() {
</div> </div>
</section> </section>
))} ))}
<NotificationLogPanel
servicio={POLIZAS_LOG_SCOPE}
reloadToken={logToken}
emptyHint="Todavía no se ha enviado ningún aviso de renovación con este filtro."
/>
</div> </div>
); );
} }
@@ -2,29 +2,26 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useCan } from "@/lib/abilities"; import { useCan } from "@/lib/abilities";
import { formatDateTime } from "@/lib/labels";
import { import {
NOTIFICATION_STATUS_COLORS, formatDateTime,
NOTIFICATION_STATUS_LABELS, NOTIFICATION_STATUS_LABELS,
NOTIFICATION_SERVICIO_LABELS,
NOTIFICATION_TYPE_LABELS, NOTIFICATION_TYPE_LABELS,
} from "@/lib/labels"; } from "@/lib/labels";
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
import { import {
getNotificationStats, getNotificationStats,
listNotificationLog,
runAccountStatus, runAccountStatus,
runAllNotifications, runAllNotifications,
runOutstandingPayments, runOutstandingPayments,
runPaymentConfirmation, runPaymentConfirmation,
runTrustConfirmation, runTrustConfirmation,
SERVICIOS_LOG_SCOPE,
} from "@/lib/api"; } from "@/lib/api";
import type { import type {
NotificationFlags, NotificationFlags,
NotificationJobResponse, NotificationJobResponse,
NotificationLogPage,
NotificationRunAllResponse, NotificationRunAllResponse,
NotificationStats, NotificationStats,
NotificationStatus,
} from "@/lib/api"; } from "@/lib/api";
/** /**
@@ -87,24 +84,13 @@ const JOB_TITLES: Record<JobKind, string> = JOBS.reduce(
{} as Record<JobKind, string>, {} as Record<JobKind, string>,
); );
const LOG_VIEWS = [
{ key: "all", label: "Todos" },
{ key: "sent", label: "Enviados" },
{ key: "failed", label: "Fallidos" },
{ key: "skipped", label: "Omitidos" },
] as const;
export function NotificacionesServicios() { export function NotificacionesServicios() {
const allowed = useCan("notification:send"); const allowed = useCan("notification:send");
const [flags, setFlags] = useState<NotificationFlags>({ debug: true }); const [flags, setFlags] = useState<NotificationFlags>({ debug: true });
const [stats, setStats] = useState<NotificationStats | null>(null); const [stats, setStats] = useState<NotificationStats | null>(null);
const [log, setLog] = useState<NotificationLogPage | null>(null); /** Raised after every run so the shared log panel reloads. */
const [logFilter, setLogFilter] = useState<{ const [logToken, setLogToken] = useState(0);
status?: NotificationStatus;
view: "all" | "sent" | "failed" | "skipped";
}>({ view: "all" });
const [logPage, setLogPage] = useState(1);
const [busy, setBusy] = useState<JobKind | "all" | null>(null); const [busy, setBusy] = useState<JobKind | "all" | null>(null);
const [lastResult, setLastResult] = useState< const [lastResult, setLastResult] = useState<
NotificationJobResponse | NotificationRunAllResponse | null NotificationJobResponse | NotificationRunAllResponse | null
@@ -113,22 +99,13 @@ export function NotificacionesServicios() {
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const [s, l] = await Promise.all([ setStats(await getNotificationStats(SERVICIOS_LOG_SCOPE));
getNotificationStats(), setLogToken((t) => t + 1);
listNotificationLog({
page: logPage,
pageSize: 50,
status: logFilter.status,
view: logFilter.view === "all" ? undefined : logFilter.view,
}),
]);
setStats(s);
setLog(l);
setError(null); setError(null);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
} }
}, [logPage, logFilter]); }, []);
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
@@ -420,111 +397,10 @@ export function NotificacionesServicios() {
</section> </section>
)} )}
<section className="card" style={{ padding: 20 }}> <NotificationLogPanel
<div servicio={SERVICIOS_LOG_SCOPE}
style={{ reloadToken={logToken}
display: "flex", />
justifyContent: "space-between",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
}}
>
<h2 className="section-title">Registro de envíos</h2>
<div className="seg" role="tablist">
{LOG_VIEWS.map((v) => (
<button
key={v.key}
type="button"
role="tab"
aria-selected={logFilter.view === v.key}
className={`seg-btn ${logFilter.view === v.key ? "active" : ""}`}
onClick={() => {
setLogFilter({ view: v.key });
setLogPage(1);
}}
>
{v.label}
</button>
))}
</div>
</div>
<div className="tx-scroll" style={{ marginTop: 12 }}>
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Tipo</th>
<th>Servicio</th>
<th>Cliente</th>
<th>Email</th>
<th>Estado</th>
<th>Asunto</th>
<th>Provider</th>
</tr>
</thead>
<tbody>
{log?.items.map((row) => (
<tr key={row.id}>
<td>{formatDateTime(row.sendDate)}</td>
<td>
{NOTIFICATION_TYPE_LABELS[row.notificationType]}
{row.level !== null && (row.level === 0 ? " (amarilla)" : " (roja)")}
</td>
<td>{NOTIFICATION_SERVICIO_LABELS[row.servicio]}</td>
<td>
{row.customerName}
{row.debug ? " · debug" : ""}
</td>
<td>{row.customerEmail}</td>
<td style={{ color: NOTIFICATION_STATUS_COLORS[row.status] }}>
{NOTIFICATION_STATUS_LABELS[row.status]}
</td>
<td>{row.subject}</td>
<td className="muted small">
{row.providerMessageId ?? row.error ?? "—"}
</td>
</tr>
))}
{log && log.items.length === 0 && (
<tr>
<td colSpan={8}>
<span className="empty-inline">
Sin envíos con el filtro actual.
</span>
</td>
</tr>
)}
</tbody>
</table>
</div>
{log && log.pageCount > 1 && (
<div className="pager" style={{ marginTop: 14 }}>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={log.page <= 1}
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
>
Anterior
</button>
<span className="pager-info">
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de{" "}
{log.pageCount}
</span>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={log.page >= log.pageCount}
onClick={() => setLogPage((p) => Math.min(log.pageCount, p + 1))}
>
Siguiente
</button>
</div>
)}
</section>
</div> </div>
); );
} }
@@ -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<NotificationLogPage | null>(null);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<LogView>("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 (
<section className="card" style={{ padding: 20 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
}}
>
<h2 className="section-title">Registro de envíos</h2>
<div className="seg" role="tablist">
{LOG_VIEWS.map((v) => (
<button
key={v.key}
type="button"
role="tab"
aria-selected={view === v.key}
className={`seg-btn ${view === v.key ? "active" : ""}`}
onClick={() => {
setView(v.key);
setPage(1);
}}
>
{v.label}
</button>
))}
</div>
</div>
{error && (
<div className="state-box state-error" style={{ marginTop: 12 }}>
{error}
</div>
)}
<div className="tx-scroll" style={{ marginTop: 12 }}>
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Tipo</th>
<th>Servicio</th>
<th>Cliente</th>
<th>Email</th>
<th>Estado</th>
<th>Asunto</th>
<th>Provider</th>
</tr>
</thead>
<tbody>
{log?.items.map((row) => (
<tr key={row.id}>
<td>{formatDateTime(row.sendDate)}</td>
<td>
{NOTIFICATION_TYPE_LABELS[row.notificationType]}
{notificationLevelLabel(row.notificationType, row.level)}
</td>
<td>{NOTIFICATION_SERVICIO_LABELS[row.servicio]}</td>
<td>
{row.customerName}
{row.debug ? " · debug" : ""}
</td>
<td>{row.customerEmail || "—"}</td>
<td style={{ color: NOTIFICATION_STATUS_COLORS[row.status] }}>
{NOTIFICATION_STATUS_LABELS[row.status]}
</td>
<td>{row.subject}</td>
<td className="muted small">
{row.providerMessageId ?? row.error ?? "—"}
</td>
</tr>
))}
{log && log.items.length === 0 && (
<tr>
<td colSpan={8}>
<span className="empty-inline">{emptyHint}</span>
</td>
</tr>
)}
</tbody>
</table>
</div>
{log && log.pageCount > 1 && (
<div className="pager" style={{ marginTop: 14 }}>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={log.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Anterior
</button>
<span className="pager-info">
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de{" "}
{log.pageCount}
</span>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={log.page >= log.pageCount}
onClick={() => setPage((p) => Math.min(log.pageCount, p + 1))}
>
Siguiente
</button>
</div>
)}
</section>
);
}
+17 -6
View File
@@ -979,9 +979,14 @@ export type NotificationType =
| "OUTSTANDING_PAYMENT" | "OUTSTANDING_PAYMENT"
| "PAYMENT_CONFIRMATION" | "PAYMENT_CONFIRMATION"
| "ACCOUNT_STATUS" | "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 = export type NotificationStatus =
| "SENT" | "SENT"
@@ -1161,7 +1166,8 @@ export interface NotificationLogQuery {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
type?: NotificationType; type?: NotificationType;
servicio?: NotificationServicio; /** One or more servicios; omitted = the whole log. */
servicio?: NotificationServicio[];
status?: NotificationStatus; status?: NotificationStatus;
view?: "sent" | "failed" | "skipped" | "all"; view?: "sent" | "failed" | "skipped" | "all";
} }
@@ -1173,15 +1179,20 @@ export function listNotificationLog(
if (q.page) qs.set("page", String(q.page)); if (q.page) qs.set("page", String(q.page));
if (q.pageSize) qs.set("pageSize", String(q.pageSize)); if (q.pageSize) qs.set("pageSize", String(q.pageSize));
if (q.type) qs.set("type", q.type); 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.status) qs.set("status", q.status);
if (q.view) qs.set("view", q.view); if (q.view) qs.set("view", q.view);
const tail = qs.toString(); const tail = qs.toString();
return apiFetch<NotificationLogPage>(`/notifications/log${tail ? `?${tail}` : ""}`); return apiFetch<NotificationLogPage>(`/notifications/log${tail ? `?${tail}` : ""}`);
} }
export function getNotificationStats(): Promise<NotificationStats> { export function getNotificationStats(
return apiFetch<NotificationStats>("/notifications/stats"); servicio?: NotificationServicio[],
): Promise<NotificationStats> {
const tail = servicio?.length
? `?servicio=${encodeURIComponent(servicio.join(","))}`
: "";
return apiFetch<NotificationStats>(`/notifications/stats${tail}`);
} }
/** Build a download URL for a report's file output. The session cookie /** Build a download URL for a report's file output. The session cookie
+22
View File
@@ -393,13 +393,35 @@ export const NOTIFICATION_TYPE_LABELS: Record<NotificationType, string> = {
PAYMENT_CONFIRMATION: "Confirmación de pago", PAYMENT_CONFIRMATION: "Confirmación de pago",
ACCOUNT_STATUS: "Estado de cuenta", ACCOUNT_STATUS: "Estado de cuenta",
TRUST_PAYMENT_CONFIRMATION: "Confirmación fideicomiso", TRUST_PAYMENT_CONFIRMATION: "Confirmación fideicomiso",
RENEWAL_NOTICE: "Aviso de renovación",
}; };
export const NOTIFICATION_SERVICIO_LABELS: Record<NotificationServicio, string> = { export const NOTIFICATION_SERVICIO_LABELS: Record<NotificationServicio, string> = {
CUSTOMERS: "Clientes", CUSTOMERS: "Clientes",
TRUST: "Fideicomiso", 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<NotificationStatus, string> = { export const NOTIFICATION_STATUS_LABELS: Record<NotificationStatus, string> = {
SENT: "Enviado", SENT: "Enviado",
FAILED: "Falló", FAILED: "Falló",
@@ -73,6 +73,22 @@ services:
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents} S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set} MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD 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: ports:
- "${API_PORT:-3001}:3001" - "${API_PORT:-3001}:3001"
volumes: volumes:
+19 -2
View File
@@ -33,8 +33,25 @@ S3_BUCKET=jorgecuadros-documents
MINIO_ROOT_USER=jc_minio MINIO_ROOT_USER=jc_minio
MINIO_ROOT_PASSWORD=CHANGE_ME MINIO_ROOT_PASSWORD=CHANGE_ME
SES_REGION= # --- Outbound mail (Amazon SES) ----------------------------------------------
SES_FROM= # 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_ACCESS_KEY=
SES_SECRET_KEY= SES_SECRET_KEY=
# Optional — only needed to publish bounce/complaint events.
SES_CONFIGURATION_SET= 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
+3
View File
@@ -55,11 +55,14 @@ services:
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents} S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set} MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD 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_REGION: ${SES_REGION:-}
SES_FROM: ${SES_FROM:-} SES_FROM: ${SES_FROM:-}
SES_FROM_NAME: ${SES_FROM_NAME:-}
SES_ACCESS_KEY: ${SES_ACCESS_KEY:-} SES_ACCESS_KEY: ${SES_ACCESS_KEY:-}
SES_SECRET_KEY: ${SES_SECRET_KEY:-} SES_SECRET_KEY: ${SES_SECRET_KEY:-}
SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET:-} SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET:-}
NOTIFICATION_ADMIN_EMAILS: ${NOTIFICATION_ADMIN_EMAILS:-}
ports: ports:
- target: 3001 - target: 3001
published: ${API_PORT:-3001} published: ${API_PORT:-3001}
+35 -4
View File
@@ -93,6 +93,24 @@ correlation.
Indexes: `(sendDate)`, `(notificationType, sendDate)`, `(customerId, sendDate)`. 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` ### `account_status_history`
Mirrors the legacy `utility_dbo.send_account_status_history` table: 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 in dev (`NODE_ENV !== "production"`). In production every send throws
`ServiceUnavailableException` and the row is recorded as `FAILED`. `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 ## UI
`/notificaciones` (gated on `notification:send`) — four trigger cards, `/notificaciones`, two tabs over the one log:
a debug/ignoreDayRestriction/useEmailLimit flags panel, a transport
status header, and a paginated log table. STAFF users see the log - **Servicios** (`notification:send`) — four trigger cards, a
read-only. 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) ## Cron (future)
@@ -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` <> '';
+22 -5
View File
@@ -944,20 +944,31 @@ model EmailLog {
/// red and yellow; the threshold is in the /// red and yellow; the threshold is in the
/// `level` column, 0=yellow / 1=red) /// `level` column, 0=yellow / 1=red)
/// - TRUST_PAYMENT_CONFIRMATION → sendConfirmTrustPayment.php (TRUSTHFEE) /// - 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 { enum EmailNotificationType {
OUTSTANDING_PAYMENT OUTSTANDING_PAYMENT
PAYMENT_CONFIRMATION PAYMENT_CONFIRMATION
ACCOUNT_STATUS ACCOUNT_STATUS
TRUST_PAYMENT_CONFIRMATION TRUST_PAYMENT_CONFIRMATION
RENEWAL_NOTICE
} }
/// Which "servicio" (line of business) the notification draws its recipients /// Which "servicio" (line of business) the notification draws its recipients
/// from. CUSTOMERS = the unified customers ledger (replaces `datosfreak`); /// from. CUSTOMERS = the unified customers ledger (replaces `datosfreak`);
/// TRUST = the trust-fee account table (replaces `TRUSTHFEE`). Keeping the /// TRUST = the trust-fee account table (replaces `TRUSTHFEE`);
/// two services tagged makes a per-line report trivial. /// 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 { enum EmailNotificationServicio {
CUSTOMERS CUSTOMERS
TRUST TRUST
POLICIES
} }
/// Outcome of a single send attempt. SENT / FAILED are the meaningful ones; /// Outcome of a single send attempt. SENT / FAILED are the meaningful ones;
@@ -982,11 +993,17 @@ model EmailNotificationLog {
id String @id @default(uuid()) id String @id @default(uuid())
sendDate DateTime @default(now()) sendDate DateTime @default(now())
notificationType EmailNotificationType notificationType EmailNotificationType
/// 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO"). Only set on /// Per-type discriminator, null where the type has none:
/// ACCOUNT_STATUS rows; null on the other three jobs. /// 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? level Int?
/// Which servicio sourced the recipient list. CUSTOMERS for jobs 1/2/3, /// 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 servicio EmailNotificationServicio
/// FK to the customer that triggered the send. Trust-account notifications /// FK to the customer that triggered the send. Trust-account notifications
/// resolve the owner through `Property.customerId`, so this stays set on /// resolve the owner through `Property.customerId`, so this stays set on