feat(notificaciones): one send log across servicios and pólizas
Renewal avisos left behind only a `RenewalNotice` row, whose sole job is gating: a row with `sentAt` drops the policy off the pending list. It cannot represent a failed send or a customer with no address, so the Pólizas tab had no "Registro de envíos" to show and a sent notice simply vanished from the list. Renewals now write `email_notification_log` — the same table the four bulk jobs write — as `RENEWAL_NOTICE` / `POLICIES`, with rows for failures and no-email skips too. `RenewalNotice` keeps its gating role unchanged; the two are complementary, not redundant. - extend `EmailNotificationType` (+RENEWAL_NOTICE) and `EmailNotificationServicio` (+POLICIES); `level` now carries the aviso generation on renewal rows, so every reader must branch on the type first (`notificationLevelLabel()` is the one place that lives) - backfill emailed notices (`channel = 'EMAIL'`) into the log; MAIL-channel rows are legacy printed letters and are deliberately left out - extract `NotificationLogService`/`NotificationLogModule` as the single writer, so a feature that sends mail records it without pulling the bulk-job pipelines into its module - `GET /notifications/log` and `/stats` take a comma-separated `servicio` list; each tab reads its own slice. This also fixes the "Omitidos" view, which mapped to no filter at all and showed every row - share one `NotificationLogPanel` between both tabs - pass SES_* / NOTIFICATION_ADMIN_EMAILS through the galactus compose, which was missing them entirely — mail is runtime config, not a CI secret, and the prod image sets NODE_ENV=production so a blank config fails loudly instead of falling back to stdout Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,29 +2,26 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDateTime } from "@/lib/labels";
|
||||
import {
|
||||
NOTIFICATION_STATUS_COLORS,
|
||||
formatDateTime,
|
||||
NOTIFICATION_STATUS_LABELS,
|
||||
NOTIFICATION_SERVICIO_LABELS,
|
||||
NOTIFICATION_TYPE_LABELS,
|
||||
} from "@/lib/labels";
|
||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
||||
import {
|
||||
getNotificationStats,
|
||||
listNotificationLog,
|
||||
runAccountStatus,
|
||||
runAllNotifications,
|
||||
runOutstandingPayments,
|
||||
runPaymentConfirmation,
|
||||
runTrustConfirmation,
|
||||
SERVICIOS_LOG_SCOPE,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
NotificationFlags,
|
||||
NotificationJobResponse,
|
||||
NotificationLogPage,
|
||||
NotificationRunAllResponse,
|
||||
NotificationStats,
|
||||
NotificationStatus,
|
||||
} from "@/lib/api";
|
||||
|
||||
/**
|
||||
@@ -87,24 +84,13 @@ const JOB_TITLES: Record<JobKind, string> = JOBS.reduce(
|
||||
{} as Record<JobKind, string>,
|
||||
);
|
||||
|
||||
const LOG_VIEWS = [
|
||||
{ key: "all", label: "Todos" },
|
||||
{ key: "sent", label: "Enviados" },
|
||||
{ key: "failed", label: "Fallidos" },
|
||||
{ key: "skipped", label: "Omitidos" },
|
||||
] as const;
|
||||
|
||||
export function NotificacionesServicios() {
|
||||
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);
|
||||
/** 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
|
||||
@@ -113,22 +99,13 @@ export function NotificacionesServicios() {
|
||||
|
||||
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);
|
||||
setStats(await getNotificationStats(SERVICIOS_LOG_SCOPE));
|
||||
setLogToken((t) => t + 1);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [logPage, logFilter]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
@@ -420,111 +397,10 @@ export function NotificacionesServicios() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<h2 className="section-title">Registro de envíos</h2>
|
||||
<div className="seg" role="tablist">
|
||||
{LOG_VIEWS.map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={logFilter.view === v.key}
|
||||
className={`seg-btn ${logFilter.view === v.key ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setLogFilter({ view: v.key });
|
||||
setLogPage(1);
|
||||
}}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tx-scroll" style={{ marginTop: 12 }}>
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Tipo</th>
|
||||
<th>Servicio</th>
|
||||
<th>Cliente</th>
|
||||
<th>Email</th>
|
||||
<th>Estado</th>
|
||||
<th>Asunto</th>
|
||||
<th>Provider</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{log?.items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{formatDateTime(row.sendDate)}</td>
|
||||
<td>
|
||||
{NOTIFICATION_TYPE_LABELS[row.notificationType]}
|
||||
{row.level !== null && (row.level === 0 ? " (amarilla)" : " (roja)")}
|
||||
</td>
|
||||
<td>{NOTIFICATION_SERVICIO_LABELS[row.servicio]}</td>
|
||||
<td>
|
||||
{row.customerName}
|
||||
{row.debug ? " · debug" : ""}
|
||||
</td>
|
||||
<td>{row.customerEmail}</td>
|
||||
<td style={{ color: NOTIFICATION_STATUS_COLORS[row.status] }}>
|
||||
{NOTIFICATION_STATUS_LABELS[row.status]}
|
||||
</td>
|
||||
<td>{row.subject}</td>
|
||||
<td className="muted small">
|
||||
{row.providerMessageId ?? row.error ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{log && log.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8}>
|
||||
<span className="empty-inline">
|
||||
Sin envíos con el filtro actual.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{log && log.pageCount > 1 && (
|
||||
<div className="pager" style={{ marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={log.page <= 1}
|
||||
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<span className="pager-info">
|
||||
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de{" "}
|
||||
{log.pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={log.page >= log.pageCount}
|
||||
onClick={() => setLogPage((p) => Math.min(log.pageCount, p + 1))}
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<NotificationLogPanel
|
||||
servicio={SERVICIOS_LOG_SCOPE}
|
||||
reloadToken={logToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user