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) @Max(200) pageSize?: number;
|
||||
@IsOptional() @IsEnum(EmailNotificationType) type?: EmailNotificationType;
|
||||
@IsOptional() @IsEnum(EmailNotificationServicio) servicio?: EmailNotificationServicio;
|
||||
/** One or more servicios, comma-separated. The /notificaciones tabs each
|
||||
* read their own slice of the one log: Servicios passes
|
||||
* `CUSTOMERS,TRUST`, Pólizas passes `POLICIES`. Omitted = every servicio. */
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === "string"
|
||||
? value.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: value,
|
||||
)
|
||||
@IsEnum(EmailNotificationServicio, { each: true })
|
||||
servicio?: EmailNotificationServicio[];
|
||||
@IsOptional() @IsEnum(EmailNotificationStatus) status?: EmailNotificationStatus;
|
||||
@IsOptional() @IsEnum(["sent", "failed", "skipped", "all"]) view?: "sent" | "failed" | "skipped" | "all";
|
||||
}
|
||||
@@ -185,19 +195,27 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
stats() {
|
||||
return this.svc.stats();
|
||||
stats(@Query() q: ListLogDto) {
|
||||
return this.svc.stats(q.servicio);
|
||||
}
|
||||
|
||||
/** Resolve the UI's coarse view tabs to concrete statuses. An explicit
|
||||
* `status` wins. "Omitidos" covers both SKIPPED_* variants, which is why
|
||||
* this returns a list rather than a single value. */
|
||||
private mapViewStatus(
|
||||
view: ListLogDto["view"],
|
||||
status: ListLogDto["status"],
|
||||
): EmailNotificationStatus | undefined {
|
||||
if (status) return status;
|
||||
): EmailNotificationStatus[] | undefined {
|
||||
if (status) return [status];
|
||||
if (!view || view === "all") return undefined;
|
||||
if (view === "sent") return EmailNotificationStatus.SENT;
|
||||
if (view === "failed") return EmailNotificationStatus.FAILED;
|
||||
if (view === "skipped") return undefined; // both SKIPPED_* variants
|
||||
if (view === "sent") return [EmailNotificationStatus.SENT];
|
||||
if (view === "failed") return [EmailNotificationStatus.FAILED];
|
||||
if (view === "skipped") {
|
||||
return [
|
||||
EmailNotificationStatus.SKIPPED_NO_EMAIL,
|
||||
EmailNotificationStatus.SKIPPED_GATE,
|
||||
];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { NotificationLogModule } from "./notification-log.module";
|
||||
import { NotificationsController } from "./notifications.controller";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
|
||||
@@ -11,6 +12,7 @@ import { NotificationsService } from "./notifications.service";
|
||||
* service methods are already the entry points they would call.
|
||||
*/
|
||||
@Module({
|
||||
imports: [NotificationLogModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@jorgecuadros/database";
|
||||
import { MailService } from "../mail/mail.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { NotificationLogService } from "./notification-log.service";
|
||||
import {
|
||||
SendAttempt,
|
||||
NotificationJobKind,
|
||||
@@ -81,6 +82,7 @@ export class NotificationsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mail: MailService,
|
||||
private readonly log: NotificationLogService,
|
||||
config: ConfigService,
|
||||
) {
|
||||
const csv = config.get<string>("NOTIFICATION_ADMIN_EMAILS");
|
||||
@@ -721,14 +723,16 @@ export class NotificationsService {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
type?: EmailNotificationType;
|
||||
servicio?: EmailNotificationServicio;
|
||||
status?: EmailNotificationStatus;
|
||||
/** Empty/omitted = every servicio. The /notificaciones tabs pass their
|
||||
* own slice (Servicios: CUSTOMERS+TRUST, Pólizas: POLICIES). */
|
||||
servicio?: EmailNotificationServicio[];
|
||||
status?: EmailNotificationStatus[];
|
||||
customerId?: string;
|
||||
}) {
|
||||
const where: Prisma.EmailNotificationLogWhereInput = {};
|
||||
if (params.type) where.notificationType = params.type;
|
||||
if (params.servicio) where.servicio = params.servicio;
|
||||
if (params.status) where.status = params.status;
|
||||
if (params.servicio?.length) where.servicio = { in: params.servicio };
|
||||
if (params.status?.length) where.status = { in: params.status };
|
||||
if (params.customerId) where.customerId = params.customerId;
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
@@ -765,22 +769,32 @@ export class NotificationsService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-type + per-status counts for the dashboard header. */
|
||||
async stats() {
|
||||
/** Per-type + per-status counts for the dashboard header. Scoped by
|
||||
* servicio so each /notificaciones tab reports its own totals instead of
|
||||
* the whole platform's. */
|
||||
async stats(servicio?: EmailNotificationServicio[]) {
|
||||
const where: Prisma.EmailNotificationLogWhereInput = servicio?.length
|
||||
? { servicio: { in: servicio } }
|
||||
: {};
|
||||
|
||||
const [byType, byStatus, byServicio, lastRun] = await Promise.all([
|
||||
this.prisma.emailNotificationLog.groupBy({
|
||||
by: ["notificationType", "status"],
|
||||
where,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.emailNotificationLog.groupBy({
|
||||
by: ["status"],
|
||||
where,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.emailNotificationLog.groupBy({
|
||||
by: ["servicio", "status"],
|
||||
where,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.emailNotificationLog.findFirst({
|
||||
where,
|
||||
orderBy: { sendDate: "desc" },
|
||||
select: { sendDate: true, notificationType: true },
|
||||
}),
|
||||
@@ -917,7 +931,9 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist one notification log row. */
|
||||
/** Persist one notification log row. Thin pass-through to the shared
|
||||
* writer — the renewal sweep writes the same rows through the same
|
||||
* service, which is what keeps /notificaciones' log complete. */
|
||||
private async recordAttempt(args: {
|
||||
notificationType: EmailNotificationType;
|
||||
servicio: EmailNotificationServicio;
|
||||
@@ -934,24 +950,7 @@ export class NotificationsService {
|
||||
providerResponse?: string;
|
||||
error?: string;
|
||||
}) {
|
||||
await this.prisma.emailNotificationLog.create({
|
||||
data: {
|
||||
notificationType: args.notificationType,
|
||||
servicio: args.servicio,
|
||||
level: args.level ?? null,
|
||||
customerId: args.customerId,
|
||||
customerName: args.customerName,
|
||||
customerEmail: args.customerEmail,
|
||||
subject: args.subject,
|
||||
bodySnapshot: args.bodySnapshot,
|
||||
bodyRequestUrl: args.bodyRequestUrl ?? null,
|
||||
debug: args.debug,
|
||||
providerMessageId: args.providerMessageId ?? null,
|
||||
providerResponse: args.providerResponse ?? null,
|
||||
status: args.status as EmailNotificationStatus,
|
||||
error: args.error ?? null,
|
||||
},
|
||||
});
|
||||
await this.log.record(args);
|
||||
}
|
||||
|
||||
/** Send the admin summary email after every job. The PHP sent one to
|
||||
|
||||
@@ -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 { NotificationLogModule } from "../notifications/notification-log.module";
|
||||
import { RenewalsController } from "./renewals.controller";
|
||||
import { RenewalsService } from "./renewals.service";
|
||||
|
||||
@Module({
|
||||
// Renewal sends write to the same `email_notification_log` the four bulk
|
||||
// jobs write, so /notificaciones has one send history across both tabs.
|
||||
imports: [NotificationLogModule],
|
||||
controllers: [RenewalsController],
|
||||
providers: [RenewalsService],
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { MailService } from "../mail/mail.service";
|
||||
import { NotificationLogService } from "../notifications/notification-log.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import {
|
||||
RenewalLetterPolicy,
|
||||
@@ -63,6 +64,7 @@ export class RenewalsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mail: MailService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly notificationLog: NotificationLogService,
|
||||
) {}
|
||||
|
||||
@Cron("0 6 * * *", { timeZone: TIME_ZONE })
|
||||
@@ -131,6 +133,12 @@ export class RenewalsService {
|
||||
for (const policy of policies) {
|
||||
const to = policy.customer.email?.trim();
|
||||
if (!to) {
|
||||
// Logged rather than silently counted: "we had nobody to mail"
|
||||
// is a finding the office acts on, and only the log survives the
|
||||
// HTTP response.
|
||||
await this.recordLog(policy, cadence.generation, "", {
|
||||
status: "SKIPPED_NO_EMAIL",
|
||||
});
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -202,7 +210,13 @@ export class RenewalsService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Render + send + record one notice. Shared by the sweep and `sendOne`. */
|
||||
/** Render + send + record one notice. Shared by the sweep and `sendOne`.
|
||||
*
|
||||
* Two records come out of a send: the `RenewalNotice` row, which gates the
|
||||
* pending list, and an `email_notification_log` row, which is the send
|
||||
* history the /notificaciones "Registro de envíos" reads. A failed send
|
||||
* writes only the second — there is no notice to gate on — and rethrows so
|
||||
* the sweep counts it as a failure. */
|
||||
private async deliver(
|
||||
policy: RenewalLetterPolicy,
|
||||
generation: number,
|
||||
@@ -211,12 +225,25 @@ export class RenewalsService {
|
||||
) {
|
||||
const letter = toRenewalLetterRow(policy, generation);
|
||||
const message = renderRenewalEmail(letter);
|
||||
const result = await this.mail.send({
|
||||
to,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
xTracking: "renewals",
|
||||
});
|
||||
|
||||
let result: Awaited<ReturnType<MailService["send"]>>;
|
||||
try {
|
||||
result = await this.mail.send({
|
||||
to,
|
||||
toName: letter.customerName,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
xTracking: "renewals",
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
await this.recordLog(policy, generation, to, {
|
||||
status: "FAILED",
|
||||
error: detail,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const sentAt = new Date();
|
||||
|
||||
await this.prisma.renewalNotice.upsert({
|
||||
@@ -238,6 +265,12 @@ export class RenewalsService {
|
||||
providerMessageId: result.messageId,
|
||||
},
|
||||
});
|
||||
await this.recordLog(policy, generation, to, {
|
||||
status: "SENT",
|
||||
providerMessageId: result.messageId || undefined,
|
||||
providerResponse: result.response || undefined,
|
||||
sendDate: sentAt,
|
||||
});
|
||||
void this.audit.log(userId, "renewalNotice.send", {
|
||||
policyId: policy.id,
|
||||
generation,
|
||||
@@ -246,6 +279,58 @@ export class RenewalsService {
|
||||
return { sentAt, providerMessageId: result.messageId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one row to the shared notification log.
|
||||
*
|
||||
* Never throws: the mail is already gone (or already failed) by the time we
|
||||
* get here, and losing the audit row must not turn a delivered notice into
|
||||
* a reported failure — which on the SENT path would also strand the
|
||||
* `RenewalNotice` we just wrote and re-send tomorrow.
|
||||
*/
|
||||
private async recordLog(
|
||||
policy: RenewalLetterPolicy,
|
||||
generation: number,
|
||||
/** Recipient as addressed. Empty on the SKIPPED_NO_EMAIL path — that
|
||||
* emptiness IS the reason the row exists. */
|
||||
to: string,
|
||||
outcome: {
|
||||
status: "SENT" | "FAILED" | "SKIPPED_NO_EMAIL";
|
||||
providerMessageId?: string;
|
||||
providerResponse?: string;
|
||||
error?: string;
|
||||
sendDate?: Date;
|
||||
},
|
||||
): Promise<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(
|
||||
cadence: (typeof RENEWAL_CADENCE)[number],
|
||||
today: Date,
|
||||
|
||||
@@ -12,6 +12,8 @@ export function renewalLetterSelect(generation: number) {
|
||||
coveragesJson: true,
|
||||
customer: {
|
||||
select: {
|
||||
// Needed by the notification log's customerId FK, not by the letter.
|
||||
id: true,
|
||||
name: true,
|
||||
nameMissing: true,
|
||||
email: true,
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, formatMoney } from "@/lib/labels";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
||||
import { apiFetch, POLIZAS_LOG_SCOPE } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Renewal notices — the "Pólizas" half of /notificaciones. Shows which
|
||||
@@ -11,6 +12,11 @@ import { apiFetch } from "@/lib/api";
|
||||
* one row at a time or as a whole sweep. Sending is what marks a notice as
|
||||
* delivered — there is no manual "mark as sent", so the list can never claim
|
||||
* a letter went out when no mail was ever sent. Gated on `renewal:send`.
|
||||
*
|
||||
* Sends are recorded in the same `email_notification_log` the Servicios tab
|
||||
* reads, so "Registro de envíos" below is the same component with the
|
||||
* POLICIES slice — failures and no-email skips included, which the pending
|
||||
* list alone cannot show.
|
||||
*/
|
||||
|
||||
export interface RenewalLetter {
|
||||
@@ -60,6 +66,8 @@ export function NotificacionesPolizas() {
|
||||
const [sweeping, setSweeping] = useState(false);
|
||||
/** `policyId-generation` of the row currently being sent, if any. */
|
||||
const [sendingKey, setSendingKey] = useState<string | null>(null);
|
||||
/** Raised after every send so the log panel reloads. */
|
||||
const [logToken, setLogToken] = useState(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setPendingError(null);
|
||||
@@ -89,9 +97,11 @@ export function NotificacionesPolizas() {
|
||||
method: "POST",
|
||||
});
|
||||
setNotice(`Enviados ${result.sent} avisos (${result.failed} con error).`);
|
||||
setLogToken((t) => t + 1);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setActionError((e as Error)?.message ?? "No se pudo ejecutar el barrido.");
|
||||
setLogToken((t) => t + 1);
|
||||
} finally {
|
||||
setSweeping(false);
|
||||
}
|
||||
@@ -115,9 +125,12 @@ export function NotificacionesPolizas() {
|
||||
}),
|
||||
});
|
||||
setNotice(`Aviso enviado a ${result.to}.`);
|
||||
setLogToken((t) => t + 1);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setActionError((e as Error)?.message ?? "No se pudo enviar el aviso.");
|
||||
// A rejected send may still have written a FAILED row; reload either way.
|
||||
setLogToken((t) => t + 1);
|
||||
} finally {
|
||||
setSendingKey(null);
|
||||
}
|
||||
@@ -253,6 +266,12 @@ export function NotificacionesPolizas() {
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,29 +2,26 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDateTime } from "@/lib/labels";
|
||||
import {
|
||||
NOTIFICATION_STATUS_COLORS,
|
||||
formatDateTime,
|
||||
NOTIFICATION_STATUS_LABELS,
|
||||
NOTIFICATION_SERVICIO_LABELS,
|
||||
NOTIFICATION_TYPE_LABELS,
|
||||
} from "@/lib/labels";
|
||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
||||
import {
|
||||
getNotificationStats,
|
||||
listNotificationLog,
|
||||
runAccountStatus,
|
||||
runAllNotifications,
|
||||
runOutstandingPayments,
|
||||
runPaymentConfirmation,
|
||||
runTrustConfirmation,
|
||||
SERVICIOS_LOG_SCOPE,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
NotificationFlags,
|
||||
NotificationJobResponse,
|
||||
NotificationLogPage,
|
||||
NotificationRunAllResponse,
|
||||
NotificationStats,
|
||||
NotificationStatus,
|
||||
} from "@/lib/api";
|
||||
|
||||
/**
|
||||
@@ -87,24 +84,13 @@ const JOB_TITLES: Record<JobKind, string> = JOBS.reduce(
|
||||
{} 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() {
|
||||
const allowed = useCan("notification:send");
|
||||
|
||||
const [flags, setFlags] = useState<NotificationFlags>({ debug: true });
|
||||
const [stats, setStats] = useState<NotificationStats | null>(null);
|
||||
const [log, setLog] = useState<NotificationLogPage | null>(null);
|
||||
const [logFilter, setLogFilter] = useState<{
|
||||
status?: NotificationStatus;
|
||||
view: "all" | "sent" | "failed" | "skipped";
|
||||
}>({ view: "all" });
|
||||
const [logPage, setLogPage] = useState(1);
|
||||
/** Raised after every run so the shared log panel reloads. */
|
||||
const [logToken, setLogToken] = useState(0);
|
||||
const [busy, setBusy] = useState<JobKind | "all" | null>(null);
|
||||
const [lastResult, setLastResult] = useState<
|
||||
NotificationJobResponse | NotificationRunAllResponse | null
|
||||
@@ -113,22 +99,13 @@ export function NotificacionesServicios() {
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [s, l] = await Promise.all([
|
||||
getNotificationStats(),
|
||||
listNotificationLog({
|
||||
page: logPage,
|
||||
pageSize: 50,
|
||||
status: logFilter.status,
|
||||
view: logFilter.view === "all" ? undefined : logFilter.view,
|
||||
}),
|
||||
]);
|
||||
setStats(s);
|
||||
setLog(l);
|
||||
setStats(await getNotificationStats(SERVICIOS_LOG_SCOPE));
|
||||
setLogToken((t) => t + 1);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [logPage, logFilter]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
@@ -420,111 +397,10 @@ export function NotificacionesServicios() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<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={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>
|
||||
<NotificationLogPanel
|
||||
servicio={SERVICIOS_LOG_SCOPE}
|
||||
reloadToken={logToken}
|
||||
/>
|
||||
</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"
|
||||
| "PAYMENT_CONFIRMATION"
|
||||
| "ACCOUNT_STATUS"
|
||||
| "TRUST_PAYMENT_CONFIRMATION";
|
||||
| "TRUST_PAYMENT_CONFIRMATION"
|
||||
| "RENEWAL_NOTICE";
|
||||
|
||||
export type NotificationServicio = "CUSTOMERS" | "TRUST";
|
||||
export type NotificationServicio = "CUSTOMERS" | "TRUST" | "POLICIES";
|
||||
|
||||
/** Which servicios each /notificaciones tab reads out of the shared log. */
|
||||
export const SERVICIOS_LOG_SCOPE: NotificationServicio[] = ["CUSTOMERS", "TRUST"];
|
||||
export const POLIZAS_LOG_SCOPE: NotificationServicio[] = ["POLICIES"];
|
||||
|
||||
export type NotificationStatus =
|
||||
| "SENT"
|
||||
@@ -1161,7 +1166,8 @@ export interface NotificationLogQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
type?: NotificationType;
|
||||
servicio?: NotificationServicio;
|
||||
/** One or more servicios; omitted = the whole log. */
|
||||
servicio?: NotificationServicio[];
|
||||
status?: NotificationStatus;
|
||||
view?: "sent" | "failed" | "skipped" | "all";
|
||||
}
|
||||
@@ -1173,15 +1179,20 @@ export function listNotificationLog(
|
||||
if (q.page) qs.set("page", String(q.page));
|
||||
if (q.pageSize) qs.set("pageSize", String(q.pageSize));
|
||||
if (q.type) qs.set("type", q.type);
|
||||
if (q.servicio) qs.set("servicio", q.servicio);
|
||||
if (q.servicio?.length) qs.set("servicio", q.servicio.join(","));
|
||||
if (q.status) qs.set("status", q.status);
|
||||
if (q.view) qs.set("view", q.view);
|
||||
const tail = qs.toString();
|
||||
return apiFetch<NotificationLogPage>(`/notifications/log${tail ? `?${tail}` : ""}`);
|
||||
}
|
||||
|
||||
export function getNotificationStats(): Promise<NotificationStats> {
|
||||
return apiFetch<NotificationStats>("/notifications/stats");
|
||||
export function getNotificationStats(
|
||||
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
|
||||
|
||||
@@ -393,13 +393,35 @@ export const NOTIFICATION_TYPE_LABELS: Record<NotificationType, string> = {
|
||||
PAYMENT_CONFIRMATION: "Confirmación de pago",
|
||||
ACCOUNT_STATUS: "Estado de cuenta",
|
||||
TRUST_PAYMENT_CONFIRMATION: "Confirmación fideicomiso",
|
||||
RENEWAL_NOTICE: "Aviso de renovación",
|
||||
};
|
||||
|
||||
export const NOTIFICATION_SERVICIO_LABELS: Record<NotificationServicio, string> = {
|
||||
CUSTOMERS: "Clientes",
|
||||
TRUST: "Fideicomiso",
|
||||
POLICIES: "Pólizas",
|
||||
};
|
||||
|
||||
/**
|
||||
* The `level` column means something different per notification type, so it
|
||||
* can only be read alongside one. ACCOUNT_STATUS uses it for the alert colour;
|
||||
* RENEWAL_NOTICE for the aviso generation. Everything else leaves it null.
|
||||
*/
|
||||
export function notificationLevelLabel(
|
||||
type: NotificationType,
|
||||
level: number | null,
|
||||
): string {
|
||||
if (level === null) return "";
|
||||
if (type === "ACCOUNT_STATUS") return level === 0 ? " (amarilla)" : " (roja)";
|
||||
if (type === "RENEWAL_NOTICE") {
|
||||
if (level === 1) return " (1.º, 30 días antes)";
|
||||
if (level === 2) return " (2.º, 15 días antes)";
|
||||
if (level === 3) return " (3.º, 7 días después)";
|
||||
return ` (aviso ${level})`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export const NOTIFICATION_STATUS_LABELS: Record<NotificationStatus, string> = {
|
||||
SENT: "Enviado",
|
||||
FAILED: "Falló",
|
||||
|
||||
Reference in New Issue
Block a user