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.
445 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|