Files
jorgecuadros-platform/apps/web/src/app/notificaciones/page.tsx
T
rmancinas a52e59cbc5 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.
2026-08-02 02:04:14 -07:00

445 lines
15 KiB
TypeScript

"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>
);
}