feat(notificaciones): mass email notifications over SES

Replaces the four legacy PHP scripts under email.notifications/send*.php
with a single NestJS module. Four jobs (outstanding payments, payment
confirmations, account-status alerts with day-of-week gates, trust
payment confirmations) share one MailService modelled on StorageService:
env-driven SES client, null fallback in dev with console logging, refuses
to send in production when unconfigured.

Schema adds email_notification_log (every attempt, sent/failed/skipped)
and account_status_history (one row per threshold hit, Job 3). Enums
encode the legacy wire shape so external log scrapers keep parsing
notificationType keys verbatim.

Web adds /notificaciones with four trigger cards, a flags panel, and a
paginated log browser. New notification:send ability gates all four
endpoints at MANAGER, matching the renewal:send trust tier.
This commit is contained in:
2026-08-02 02:04:14 -07:00
parent 5e9cb12fba
commit a52e59cbc5
21 changed files with 3128 additions and 4 deletions
+444
View File
@@ -0,0 +1,444 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { useCan } from "@/lib/abilities";
import { formatDateTime } from "@/lib/labels";
import {
NOTIFICATION_STATUS_COLORS,
NOTIFICATION_STATUS_LABELS,
NOTIFICATION_SERVICIO_LABELS,
NOTIFICATION_TYPE_LABELS,
} from "@/lib/labels";
import {
getNotificationStats,
listNotificationLog,
runAccountStatus,
runOutstandingPayments,
runPaymentConfirmation,
runTrustConfirmation,
} from "@/lib/api";
import type {
NotificationFlags,
NotificationJobResponse,
NotificationLogPage,
NotificationStats,
NotificationStatus,
} from "@/lib/api";
/**
* Mass email notifications UI. Manual triggers for the four jobs plus a
* paged log browser. The page is gated on `notification:send`; a STAFF
* viewer sees the read-only log table but not the trigger buttons.
*/
export default function NotificacionesPage() {
return (
<AppShell>
<Notificaciones />
</AppShell>
);
}
type JobKind = "outstanding" | "payment" | "account" | "trust";
interface JobDef {
kind: JobKind;
title: string;
endpoint: string;
description: string;
servicio: "Clientes" | "Fideicomiso";
flagsHint?: string;
}
const JOBS: JobDef[] = [
{
kind: "outstanding",
title: "Pagos pendientes",
endpoint: "sendOutstandingPaymentAlerts",
servicio: "Clientes",
description:
"Clientes con al menos un movimiento marcado como pendiente (outstanding). Equivale a la columna NOPAGO=1 del antiguo datosfreak.",
},
{
kind: "payment",
title: "Confirmación de pago",
endpoint: "sendPaymentConfirmation",
servicio: "Clientes",
description:
"Clientes con un crédito (abono) en las últimas 24 horas. Un correo por cliente con el pago más reciente.",
},
{
kind: "account",
title: "Estado de cuenta",
endpoint: "sendAccountStatus",
servicio: "Clientes",
description:
"Alerta amarilla (DEBAJO DEL TIPO) los miércoles y roja (EN ROJO) lunes/miércoles/viernes. El flag ignoreDayRestriction salta los gates.",
flagsHint: "Solo este job respeta ignoreDayRestriction y useEmailLimit.",
},
{
kind: "trust",
title: "Confirmación fideicomiso",
endpoint: "sendConfirmTrustPayment",
servicio: "Fideicomiso",
description:
"Clientes con TrustAccount que recibieron un crédito en el dominio TRUST en las últimas 24 horas.",
},
];
function Notificaciones() {
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);
const [busy, setBusy] = useState<JobKind | null>(null);
const [lastResult, setLastResult] = useState<NotificationJobResponse | null>(null);
const [error, setError] = useState<string | null>(null);
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);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, [logPage, logFilter]);
useEffect(() => {
void refresh();
}, [refresh]);
const run = useCallback(
async (job: JobDef) => {
if (!allowed) return;
setBusy(job.kind);
setError(null);
try {
let res: NotificationJobResponse;
if (job.kind === "outstanding") res = await runOutstandingPayments(flags);
else if (job.kind === "payment") res = await runPaymentConfirmation(flags);
else if (job.kind === "account") res = await runAccountStatus(flags);
else res = await runTrustConfirmation(flags);
setLastResult(res);
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(null);
}
},
[allowed, flags, refresh],
);
return (
<div style={{ display: "grid", gap: 24, padding: 24 }}>
<header>
<h1 style={{ margin: 0 }}>Notificaciones masivas</h1>
<p style={{ color: "#666", marginTop: 4 }}>
Disparo manual de los cuatro envíos equivalentes a los scripts PHP
de <code>email.notifications/</code>. Cada ejecución registra todas
las filas (enviado, fallido, omitido) en <code>email_notification_log</code>.
</p>
</header>
<section
style={{
background: "#fff",
border: "1px solid #ddd",
borderRadius: 8,
padding: 16,
display: "grid",
gap: 12,
}}
>
<strong>Flags del envío</strong>
<label style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
type="checkbox"
checked={!!flags.debug}
disabled={!allowed}
onChange={(e) => setFlags((f) => ({ ...f, debug: e.target.checked }))}
/>
<span>
<strong>debug</strong> reescribe todos los destinatarios a{" "}
<code>rmancinas@freakma.net</code>. Ningún cliente real recibe el
correo mientras esté activo.
</span>
</label>
<label style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
type="checkbox"
checked={!!flags.ignoreDayRestriction}
disabled={!allowed}
onChange={(e) =>
setFlags((f) => ({ ...f, ignoreDayRestriction: e.target.checked }))
}
/>
<span>
<strong>ignoreDayRestriction</strong> salta los gates de
Mon/Wed/Fri del estado de cuenta (job 3). Útil para disparar en
cualquier día sin esperar a la próxima corrida.
</span>
</label>
<label style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
type="checkbox"
checked={!!flags.useEmailLimit}
disabled={!allowed}
onChange={(e) =>
setFlags((f) => ({ ...f, useEmailLimit: e.target.checked }))
}
/>
<span>
<strong>useEmailLimit</strong> pausa el job 3 cada 100 correos
durante 1 hora. Vestigio de la era SMTP; SES no lo necesita.
</span>
</label>
</section>
<section
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
gap: 12,
}}
>
{JOBS.map((j) => (
<article
key={j.kind}
style={{
background: "#fff",
border: "1px solid #ddd",
borderRadius: 8,
padding: 16,
display: "grid",
gap: 8,
}}
>
<header style={{ display: "flex", justifyContent: "space-between" }}>
<strong>{j.title}</strong>
<span style={{ fontSize: 12, color: "#666" }}>{j.servicio}</span>
</header>
<p style={{ margin: 0, color: "#444", fontSize: 13 }}>{j.description}</p>
{j.flagsHint && (
<p style={{ margin: 0, color: "#666", fontSize: 12, fontStyle: "italic" }}>
{j.flagsHint}
</p>
)}
<button
type="button"
disabled={!allowed || busy !== null}
onClick={() => void run(j)}
style={{
padding: "8px 12px",
background: !allowed || busy !== null ? "#bbb" : "#1f4eaf",
color: "#fff",
border: "none",
borderRadius: 6,
cursor: !allowed || busy !== null ? "not-allowed" : "pointer",
fontWeight: 600,
}}
>
{busy === j.kind ? "Ejecutando…" : "Ejecutar"}
</button>
</article>
))}
</section>
{!allowed && (
<div
style={{
background: "#fff8e1",
border: "1px solid #f1c40f",
borderRadius: 8,
padding: 12,
}}
>
Tu rol no incluye <code>notification:send</code>. Solo puedes ver el
registro. Para disparar envíos pide a un MANAGER/ADMIN.
</div>
)}
{stats && (
<section
style={{
background: "#fff",
border: "1px solid #ddd",
borderRadius: 8,
padding: 16,
}}
>
<strong>Estado del transporte</strong>
<ul style={{ marginTop: 8, marginBottom: 0 }}>
<li>
SES configurado:{" "}
<strong style={{ color: stats.transport.available ? "#1f7a3a" : "#b3261e" }}>
{stats.transport.available ? "sí" : "no"}
</strong>
{stats.transport.devFallback && " (fallback dev: stdout)"}
</li>
<li>Último envío registrado: {stats.lastRun ? `${NOTIFICATION_TYPE_LABELS[stats.lastRun.notificationType]}${formatDateTime(stats.lastRun.sendDate)}` : "—"}</li>
<li>
Totales:{" "}
{stats.byStatus.map((s) => (
<span key={s.status} style={{ marginRight: 12 }}>
{NOTIFICATION_STATUS_LABELS[s.status]}: {s._count._all}
</span>
))}
</li>
</ul>
</section>
)}
{lastResult && (
<section
style={{
background: "#eef6ff",
border: "1px solid #b3d4fc",
borderRadius: 8,
padding: 12,
}}
>
<strong>Última respuesta</strong>
<pre style={{ margin: 0, fontSize: 12, overflow: "auto" }}>
{JSON.stringify(lastResult, null, 2)}
</pre>
</section>
)}
{error && (
<div
style={{
background: "#fdecea",
border: "1px solid #b3261e",
borderRadius: 8,
padding: 12,
color: "#b3261e",
}}
>
{error}
</div>
)}
<section
style={{
background: "#fff",
border: "1px solid #ddd",
borderRadius: 8,
padding: 16,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<strong>Registro de envíos</strong>
<div style={{ display: "flex", gap: 8 }}>
{(["all", "sent", "failed", "skipped"] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => {
setLogFilter({ view: v });
setLogPage(1);
}}
style={{
padding: "4px 10px",
background: logFilter.view === v ? "#1f4eaf" : "#eee",
color: logFilter.view === v ? "#fff" : "#333",
border: "none",
borderRadius: 4,
cursor: "pointer",
fontSize: 12,
}}
>
{v === "all" ? "Todos" : v === "sent" ? "Enviados" : v === "failed" ? "Fallidos" : "Omitidos"}
</button>
))}
</div>
</div>
<table style={{ width: "100%", borderCollapse: "collapse", marginTop: 12 }}>
<thead>
<tr style={{ borderBottom: "2px solid #ddd" }}>
<th style={{ textAlign: "left", padding: 6 }}>Fecha</th>
<th style={{ textAlign: "left", padding: 6 }}>Tipo</th>
<th style={{ textAlign: "left", padding: 6 }}>Servicio</th>
<th style={{ textAlign: "left", padding: 6 }}>Cliente</th>
<th style={{ textAlign: "left", padding: 6 }}>Email</th>
<th style={{ textAlign: "left", padding: 6 }}>Estado</th>
<th style={{ textAlign: "left", padding: 6 }}>Asunto</th>
<th style={{ textAlign: "left", padding: 6 }}>Provider</th>
</tr>
</thead>
<tbody>
{log?.items.map((row) => (
<tr key={row.id} style={{ borderBottom: "1px solid #eee" }}>
<td style={{ padding: 6, fontSize: 12 }}>{formatDateTime(row.sendDate)}</td>
<td style={{ padding: 6, fontSize: 12 }}>
{NOTIFICATION_TYPE_LABELS[row.notificationType]}
{row.level !== null && (row.level === 0 ? " (amarilla)" : " (roja)")}
</td>
<td style={{ padding: 6, fontSize: 12 }}>{NOTIFICATION_SERVICIO_LABELS[row.servicio]}</td>
<td style={{ padding: 6, fontSize: 12 }}>{row.customerName}{row.debug ? " · debug" : ""}</td>
<td style={{ padding: 6, fontSize: 12 }}>{row.customerEmail}</td>
<td style={{ padding: 6, fontSize: 12, color: NOTIFICATION_STATUS_COLORS[row.status] }}>
{NOTIFICATION_STATUS_LABELS[row.status]}
</td>
<td style={{ padding: 6, fontSize: 12 }}>{row.subject}</td>
<td style={{ padding: 6, fontSize: 11, color: "#666" }}>
{row.providerMessageId ?? row.error ?? "—"}
</td>
</tr>
))}
{log && log.items.length === 0 && (
<tr>
<td colSpan={8} style={{ padding: 12, color: "#666", textAlign: "center" }}>
Sin envíos con el filtro actual.
</td>
</tr>
)}
</tbody>
</table>
{log && log.pageCount > 1 && (
<div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: 12, color: "#666" }}>
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de {log.pageCount}
</span>
<div style={{ display: "flex", gap: 4 }}>
<button
type="button"
disabled={log.page <= 1}
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
>
</button>
<button
type="button"
disabled={log.page >= log.pageCount}
onClick={() => setLogPage((p) => Math.min(log.pageCount, p + 1))}
>
</button>
</div>
</div>
)}
</section>
</div>
);
}
+5
View File
@@ -78,6 +78,11 @@ const NAV: NavEntry[] = [
label: "Cuentas de chequera",
ability: "bank:manage-accounts",
},
{
href: "/notificaciones",
label: "Notificaciones masivas",
ability: "notification:send",
},
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
],
+179
View File
@@ -972,6 +972,185 @@ export function runReport(
return apiFetch<ReportRunResult>(`/reports/${slug}${tail ? `?${tail}` : ""}`);
}
/* ------------------------------------------------- Mass email notifications */
export type NotificationType =
| "OUTSTANDING_PAYMENT"
| "PAYMENT_CONFIRMATION"
| "ACCOUNT_STATUS"
| "TRUST_PAYMENT_CONFIRMATION";
export type NotificationServicio = "CUSTOMERS" | "TRUST";
export type NotificationStatus =
| "SENT"
| "FAILED"
| "SKIPPED_NO_EMAIL"
| "SKIPPED_GATE";
export interface NotificationLogRow {
id: string;
sendDate: string;
notificationType: NotificationType;
level: number | null;
servicio: NotificationServicio;
customerId: string | null;
customerName: string;
customerEmail: string;
subject: string;
debug: boolean;
status: NotificationStatus;
providerMessageId: string | null;
error: string | null;
}
export interface NotificationLogPage {
items: NotificationLogRow[];
total: number;
page: number;
pageSize: number;
pageCount: number;
}
export interface NotificationStats {
byType: { notificationType: NotificationType; status: NotificationStatus; _count: { _all: number } }[];
byStatus: { status: NotificationStatus; _count: { _all: number } }[];
byServicio: { servicio: NotificationServicio; status: NotificationStatus; _count: { _all: number } }[];
lastRun: { sendDate: string; notificationType: NotificationType } | null;
transport: { available: boolean; devFallback: boolean };
}
export type NotificationFlags = {
debug?: boolean;
ignoreDayRestriction?: boolean;
useEmailLimit?: boolean;
};
/** Job 1 (Outstanding) response — legacy `result` field. */
export interface OutstandingResponse {
result: "success";
notificationType: "sendPaymentConfirmation";
reason: string;
statusCode: 200;
sent: number;
skipped: number;
failed: number;
debug: boolean;
type: "OUTSTANDING_PAYMENT";
}
/** Job 2 (Payment Confirmation) response. */
export interface PaymentConfirmResponse {
request: "success";
notificationType: "sendPaymentConfirmation";
confirmationSent: string;
statusCode: 200;
sent: number;
skipped: number;
failed: number;
debug: boolean;
type: "PAYMENT_CONFIRMATION";
}
/** Job 3 (Account Status) response. */
export interface AccountStatusResponse {
request: "success";
notificationType: "sendAccountStatus";
statusSent: string;
statusReport: string;
statusCode: 200;
red: number;
yellow: number;
total: number;
sent: number;
skipped: number;
failed: number;
debug: boolean;
type: "ACCOUNT_STATUS";
}
/** Job 4 (Trust Confirmation) response. */
export interface TrustConfirmResponse {
request: "success";
notificationType: "sendTrustPaymentConfirmation";
confirmationSent: string;
statusCode: 200;
sent: number;
skipped: number;
failed: number;
debug: boolean;
type: "TRUST_PAYMENT_CONFIRMATION";
}
export type NotificationJobResponse =
| OutstandingResponse
| PaymentConfirmResponse
| AccountStatusResponse
| TrustConfirmResponse;
export function runOutstandingPayments(
flags: NotificationFlags = {},
): Promise<OutstandingResponse> {
return apiFetch<OutstandingResponse>("/notifications/outstanding-payments", {
method: "POST",
body: JSON.stringify(flags),
});
}
export function runPaymentConfirmation(
flags: NotificationFlags = {},
): Promise<PaymentConfirmResponse> {
return apiFetch<PaymentConfirmResponse>("/notifications/payment-confirmation", {
method: "POST",
body: JSON.stringify(flags),
});
}
export function runAccountStatus(
flags: NotificationFlags = {},
): Promise<AccountStatusResponse> {
return apiFetch<AccountStatusResponse>("/notifications/account-status", {
method: "POST",
body: JSON.stringify(flags),
});
}
export function runTrustConfirmation(
flags: NotificationFlags = {},
): Promise<TrustConfirmResponse> {
return apiFetch<TrustConfirmResponse>("/notifications/trust-payment-confirmation", {
method: "POST",
body: JSON.stringify(flags),
});
}
export interface NotificationLogQuery {
page?: number;
pageSize?: number;
type?: NotificationType;
servicio?: NotificationServicio;
status?: NotificationStatus;
view?: "sent" | "failed" | "skipped" | "all";
}
export function listNotificationLog(
q: NotificationLogQuery = {},
): Promise<NotificationLogPage> {
const qs = new URLSearchParams();
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.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");
}
/** Build a download URL for a report's file output. The session cookie
* travels with the browser's same-origin navigation, so a plain `href`
* is enough — no fetch-with-credentials dance. */
+34
View File
@@ -379,3 +379,37 @@ export function sourceSystemLabel(source: string): string {
};
return map[source] ?? source;
}
// ----- Mass email notifications -----
import type {
NotificationStatus,
NotificationType,
NotificationServicio,
} from "./api";
export const NOTIFICATION_TYPE_LABELS: Record<NotificationType, string> = {
OUTSTANDING_PAYMENT: "Pagos pendientes",
PAYMENT_CONFIRMATION: "Confirmación de pago",
ACCOUNT_STATUS: "Estado de cuenta",
TRUST_PAYMENT_CONFIRMATION: "Confirmación fideicomiso",
};
export const NOTIFICATION_SERVICIO_LABELS: Record<NotificationServicio, string> = {
CUSTOMERS: "Clientes",
TRUST: "Fideicomiso",
};
export const NOTIFICATION_STATUS_LABELS: Record<NotificationStatus, string> = {
SENT: "Enviado",
FAILED: "Falló",
SKIPPED_NO_EMAIL: "Sin email",
SKIPPED_GATE: "Fuera de día",
};
export const NOTIFICATION_STATUS_COLORS: Record<NotificationStatus, string> = {
SENT: "#1f7a3a",
FAILED: "#b3261e",
SKIPPED_NO_EMAIL: "#666",
SKIPPED_GATE: "#888",
};
+2 -1
View File
@@ -26,7 +26,8 @@ export type Ability =
| "statement:review"
| "lookup:manage"
| "user:manage"
| "db:manage";
| "db:manage"
| "notification:send";
export interface AuthUser {
id: string;