The "Flags del envío" panel lived inside the Servicios tab and only
governed the four bulk jobs. The pólizas half had no debug at all, so
there was no way to test a renewal notice without mailing a real
customer. The panel now lives in the /notificaciones shell above the
tabs and both halves read it.
`debug` on the renewal path diverts to the same override inbox as the
servicios jobs and deliberately does NOT write the `RenewalNotice` row
or advance the sweep's `lastSuccessfulAt` — the customer was not
notified, so nothing may gate the letter they are still owed.
`ignoreDayRestriction` and `useEmailLimit` stay estado-de-cuenta-only
and are labelled as such.
Both automatic sweeps are now operator-editable. The renewal cadence
was a `@Cron("0 6 * * *")` literal and servicios had no automatic run
at all; both now resolve through `NotificationScheduleService`, which
stores the cadence in `app_settings` and reinstalls the cron job on
save — no redeploy, no restart. Defaults preserve current behaviour:
pólizas 06:00 daily, servicios off. A scheduled run never inherits the
UI flags; it always sends for real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
11 KiB
TypeScript
354 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useCan } from "@/lib/abilities";
|
|
import {
|
|
formatDateTime,
|
|
NOTIFICATION_STATUS_LABELS,
|
|
NOTIFICATION_TYPE_LABELS,
|
|
} from "@/lib/labels";
|
|
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
|
import { AdminEmailsSetting } from "@/components/AdminEmailsSetting";
|
|
import {
|
|
getNotificationStats,
|
|
runAccountStatus,
|
|
runAllNotifications,
|
|
runOutstandingPayments,
|
|
runPaymentConfirmation,
|
|
runTrustConfirmation,
|
|
SERVICIOS_LOG_SCOPE,
|
|
} from "@/lib/api";
|
|
import type {
|
|
NotificationFlags,
|
|
NotificationJobResponse,
|
|
NotificationRunAllResponse,
|
|
NotificationStats,
|
|
} from "@/lib/api";
|
|
|
|
/**
|
|
* Mass email notifications — the "Servicios" half of /notificaciones. Manual
|
|
* triggers for the four jobs plus a paged log browser. Gated on
|
|
* `notification:send`; a STAFF viewer sees the read-only log table but not the
|
|
* trigger buttons.
|
|
*
|
|
* The send flags come from the shell above the tabs — they are shared with the
|
|
* pólizas half — so this component only consumes them.
|
|
*/
|
|
|
|
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.",
|
|
},
|
|
];
|
|
|
|
/** 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>,
|
|
);
|
|
|
|
export function NotificacionesServicios({ flags }: { flags: NotificationFlags }) {
|
|
const allowed = useCan("notification:send");
|
|
|
|
const [stats, setStats] = useState<NotificationStats | null>(null);
|
|
/** Raised after every run so the shared log panel reloads. */
|
|
const [logToken, setLogToken] = useState(0);
|
|
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 () => {
|
|
try {
|
|
setStats(await getNotificationStats(SERVICIOS_LOG_SCOPE));
|
|
setLogToken((t) => t + 1);
|
|
setError(null);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}, []);
|
|
|
|
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],
|
|
);
|
|
|
|
// "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 }}>
|
|
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>
|
|
|
|
{!allowed && (
|
|
<div className="empty-inline">
|
|
Tu rol no incluye <code>notification:send</code>. Solo puedes ver el
|
|
registro. Para disparar envíos pide a un MANAGER/ADMIN.
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="state-box state-error">{error}</div>}
|
|
|
|
<section className="card" style={{ padding: 20 }}>
|
|
<h2 className="section-title">Ejecutar ahora</h2>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
flexWrap: "wrap",
|
|
marginTop: 12,
|
|
}}
|
|
>
|
|
<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 los flags de arriba. Si
|
|
uno falla, los demás continúan. Es lo mismo que ejecuta la corrida
|
|
programada de Servicios.
|
|
</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
{JOBS.map((j) => (
|
|
<article
|
|
key={j.kind}
|
|
className="card"
|
|
style={{ padding: 18, display: "grid", gap: 8, alignContent: "start" }}
|
|
>
|
|
<header
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<strong>{j.title}</strong>
|
|
<span
|
|
className={
|
|
j.servicio === "Fideicomiso"
|
|
? "badge badge-fideicomiso"
|
|
: "badge badge-servicios"
|
|
}
|
|
>
|
|
<span className="dot" />
|
|
{j.servicio}
|
|
</span>
|
|
</header>
|
|
<p className="muted small" style={{ margin: 0 }}>
|
|
{j.description}
|
|
</p>
|
|
{j.flagsHint && (
|
|
<p className="section-note" style={{ margin: 0, fontStyle: "italic" }}>
|
|
{j.flagsHint}
|
|
</p>
|
|
)}
|
|
<div className="row-actions" style={{ marginTop: 4 }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
disabled={!allowed || busy !== null}
|
|
onClick={() => void run(j)}
|
|
>
|
|
{busy === j.kind ? "Ejecutando…" : "Ejecutar"}
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</section>
|
|
|
|
{stats && (
|
|
<section className="card" style={{ padding: 20 }}>
|
|
<h2 className="section-title">Estado del transporte</h2>
|
|
<ul className="small" style={{ marginTop: 10, marginBottom: 0, paddingLeft: 18 }}>
|
|
<li>
|
|
SES configurado:{" "}
|
|
<strong
|
|
style={{
|
|
color: stats.transport.available
|
|
? "var(--positive)"
|
|
: "var(--negative)",
|
|
}}
|
|
>
|
|
{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>
|
|
)}
|
|
|
|
<AdminEmailsSetting />
|
|
|
|
{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={{
|
|
margin: "10px 0 0",
|
|
fontSize: 12,
|
|
overflow: "auto",
|
|
background: "var(--surface-2)",
|
|
border: "1px solid var(--line)",
|
|
borderRadius: "var(--radius-sm)",
|
|
padding: 12,
|
|
}}
|
|
>
|
|
{JSON.stringify(lastResult, null, 2)}
|
|
</pre>
|
|
</section>
|
|
)}
|
|
|
|
<NotificationLogPanel
|
|
servicio={SERVICIOS_LOG_SCOPE}
|
|
reloadToken={logToken}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|