NOTIFICATION_ADMIN_EMAILS made "add Beto to the summaries" a redeploy — the wrong unit of work for a list that changes when office staff change. Adds `app_settings`, a key/value table for the configuration staff must be able to change without a deploy, and `SettingsService`, which resolves every key db -> env -> default and reports which of the three a value came from. That ladder is what makes the move safe: a deployment behaves exactly as before until somebody saves in the UI, and the screen can say "this is still coming from the deployment" rather than implying somebody chose it. - new ability `setting:manage` (ADMIN) — deliberately above `notification:send`, since redirecting the audit summaries is how someone would quietly stop them being read - GET/PUT /notifications/settings/admin-emails; read is open to any logged-in user so the UI can display the list, write is gated - resolved per job, not cached at boot, or we would reintroduce exactly the restart-to-apply behaviour being removed - a saved empty list means "nobody" and does NOT fall through to the env, or clearing the field would keep mailing the people just removed Credentials stay in env — see the model doc for where the line is drawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
410 lines
13 KiB
TypeScript
410 lines
13 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.
|
|
*/
|
|
|
|
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() {
|
|
const allowed = useCan("notification:send");
|
|
|
|
const [flags, setFlags] = useState<NotificationFlags>({ debug: true });
|
|
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">Flags del envío</h2>
|
|
<div style={{ display: "grid", gap: 4, marginTop: 12 }}>
|
|
<label
|
|
className="field"
|
|
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={!!flags.debug}
|
|
disabled={!allowed}
|
|
onChange={(e) => setFlags((f) => ({ ...f, debug: e.target.checked }))}
|
|
style={{ marginTop: 2 }}
|
|
/>
|
|
<span className="small">
|
|
<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
|
|
className="field"
|
|
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={!!flags.ignoreDayRestriction}
|
|
disabled={!allowed}
|
|
onChange={(e) =>
|
|
setFlags((f) => ({ ...f, ignoreDayRestriction: e.target.checked }))
|
|
}
|
|
style={{ marginTop: 2 }}
|
|
/>
|
|
<span className="small">
|
|
<strong>ignoreDayRestriction</strong> — salta los gates de
|
|
Mon/Wed/Fri del estado de cuenta. Útil para disparar en cualquier
|
|
día sin esperar a la próxima corrida.
|
|
</span>
|
|
</label>
|
|
<label
|
|
className="field"
|
|
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 0 }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={!!flags.useEmailLimit}
|
|
disabled={!allowed}
|
|
onChange={(e) =>
|
|
setFlags((f) => ({ ...f, useEmailLimit: e.target.checked }))
|
|
}
|
|
style={{ marginTop: 2 }}
|
|
/>
|
|
<span className="small">
|
|
<strong>useEmailLimit</strong> — pausa el estado de cuenta cada 100
|
|
correos durante 1 hora. Vestigio de la era SMTP; SES no lo necesita.
|
|
</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
|
|
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>
|
|
);
|
|
}
|