feat(notificaciones): ejecutar todos for servicios jobs
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m53s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m13s

Add POST /notifications/run-all: runs the four notification jobs
(outstanding, payment confirmation, account status, trust confirmation)
sequentially with one shared set of flags from "Flags del envío".

Sequential rather than parallel — the jobs share the SES transport and
account status can self-throttle via useEmailLimit. A job that throws is
captured and the sweep continues, so one bad query cannot swallow the
other three envíos; the aggregate response carries per-job results plus
summed sent/skipped/failed and an errors count.

Audited as a single notification.run-all.run entry so one staff click is
one audit row. UI adds the button to the flags card, with a confirm when
debug is off, and a per-job summary in "Última respuesta".
This commit is contained in:
2026-08-02 02:32:13 -07:00
parent 0332292ae9
commit 53a5fe8076
5 changed files with 251 additions and 2 deletions
@@ -13,6 +13,7 @@ import {
getNotificationStats,
listNotificationLog,
runAccountStatus,
runAllNotifications,
runOutstandingPayments,
runPaymentConfirmation,
runTrustConfirmation,
@@ -21,6 +22,7 @@ import type {
NotificationFlags,
NotificationJobResponse,
NotificationLogPage,
NotificationRunAllResponse,
NotificationStats,
NotificationStatus,
} from "@/lib/api";
@@ -79,6 +81,12 @@ const JOBS: JobDef[] = [
},
];
/** Job title by kind — used by the run-all summary, which only carries kinds. */
const JOB_TITLES: Record<JobKind, string> = JOBS.reduce(
(acc, j) => ({ ...acc, [j.kind]: j.title }),
{} as Record<JobKind, string>,
);
const LOG_VIEWS = [
{ key: "all", label: "Todos" },
{ key: "sent", label: "Enviados" },
@@ -97,8 +105,10 @@ export function NotificacionesServicios() {
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 [busy, setBusy] = useState<JobKind | "all" | null>(null);
const [lastResult, setLastResult] = useState<
NotificationJobResponse | NotificationRunAllResponse | null
>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
@@ -146,6 +156,30 @@ export function NotificacionesServicios() {
[allowed, flags, refresh],
);
// "Ejecutar todos" — one POST, the API runs the four jobs sequentially with
// the same flags. Confirmation only matters when debug is off, since that
// is the case where real customers receive mail.
const runAll = useCallback(async () => {
if (!allowed) return;
if (!flags.debug) {
const ok = window.confirm(
"debug está desactivado: los cuatro envíos irán a los correos reales de los clientes. ¿Ejecutar todos?",
);
if (!ok) return;
}
setBusy("all");
setError(null);
try {
const res = await runAllNotifications(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: 20 }}>
<p className="muted" style={{ maxWidth: 760, margin: 0 }}>
@@ -221,6 +255,32 @@ export function NotificacionesServicios() {
</span>
</label>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
marginTop: 16,
paddingTop: 16,
borderTop: "1px solid var(--line)",
}}
>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!allowed || busy !== null}
onClick={() => void runAll()}
>
{busy === "all" ? "Ejecutando todos…" : "Ejecutar todos"}
</button>
<span className="muted small">
Dispara los cuatro envíos en orden (pagos pendientes, confirmación
de pago, estado de cuenta, fideicomiso) con estos mismos flags. Si
uno falla, los demás continúan.
</span>
</div>
</section>
<section
@@ -316,6 +376,33 @@ export function NotificacionesServicios() {
{lastResult && (
<section className="card" style={{ padding: 20 }}>
<h2 className="section-title">Última respuesta</h2>
{lastResult.type === "RUN_ALL" && (
<ul
className="small"
style={{ marginTop: 10, marginBottom: 0, paddingLeft: 18 }}
>
<li>
Totales: enviados {lastResult.sent} · omitidos{" "}
{lastResult.skipped} · fallidos {lastResult.failed}
{lastResult.errors > 0 && ` · jobs con error ${lastResult.errors}`}
</li>
{lastResult.jobs.map((j) => (
<li key={j.kind}>
{JOB_TITLES[j.kind]}:{" "}
{j.ok && j.result ? (
<>
enviados {j.result.sent} · omitidos {j.result.skipped} ·
fallidos {j.result.failed}
</>
) : (
<span style={{ color: "var(--negative)" }}>
error {j.error}
</span>
)}
</li>
))}
</ul>
)}
<pre
className="mono"
style={{