feat(notificaciones): one send log across servicios and pólizas
Renewal avisos left behind only a `RenewalNotice` row, whose sole job is gating: a row with `sentAt` drops the policy off the pending list. It cannot represent a failed send or a customer with no address, so the Pólizas tab had no "Registro de envíos" to show and a sent notice simply vanished from the list. Renewals now write `email_notification_log` — the same table the four bulk jobs write — as `RENEWAL_NOTICE` / `POLICIES`, with rows for failures and no-email skips too. `RenewalNotice` keeps its gating role unchanged; the two are complementary, not redundant. - extend `EmailNotificationType` (+RENEWAL_NOTICE) and `EmailNotificationServicio` (+POLICIES); `level` now carries the aviso generation on renewal rows, so every reader must branch on the type first (`notificationLevelLabel()` is the one place that lives) - backfill emailed notices (`channel = 'EMAIL'`) into the log; MAIL-channel rows are legacy printed letters and are deliberately left out - extract `NotificationLogService`/`NotificationLogModule` as the single writer, so a feature that sends mail records it without pulling the bulk-job pipelines into its module - `GET /notifications/log` and `/stats` take a comma-separated `servicio` list; each tab reads its own slice. This also fixes the "Omitidos" view, which mapped to no filter at all and showed every row - share one `NotificationLogPanel` between both tabs - pass SES_* / NOTIFICATION_ADMIN_EMAILS through the galactus compose, which was missing them entirely — mail is runtime config, not a CI secret, and the prod image sets NODE_ENV=production so a blank config fails loudly instead of falling back to stdout Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+67
@@ -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` <> '';
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user