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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user