Files
jorgecuadros-platform/apps/web/src/components/NotificationLogPanel.tsx
T
rmancinasandClaude Opus 5 33833c3af9
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m30s
Build and Push Images / Build jorgecuadros-api (push) Failing after 3h13m42s
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>
2026-08-02 03:01:03 -07:00

187 lines
5.6 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import {
listNotificationLog,
type NotificationLogPage,
type NotificationServicio,
} from "@/lib/api";
import {
formatDateTime,
NOTIFICATION_SERVICIO_LABELS,
NOTIFICATION_STATUS_COLORS,
NOTIFICATION_STATUS_LABELS,
NOTIFICATION_TYPE_LABELS,
notificationLevelLabel,
} from "@/lib/labels";
/**
* "Registro de envíos" — the send history over `email_notification_log`.
*
* Every outbound email the platform sends writes to that one table (the four
* bulk jobs and the renewal avisos alike), so this component is shared by
* both /notificaciones tabs; each passes the `servicio` slice it owns. Rows
* cover failures and skips too, which is the whole point: a notice that never
* left is invisible everywhere else.
*/
const LOG_VIEWS = [
{ key: "all", label: "Todos" },
{ key: "sent", label: "Enviados" },
{ key: "failed", label: "Fallidos" },
{ key: "skipped", label: "Omitidos" },
] as const;
export type LogView = (typeof LOG_VIEWS)[number]["key"];
export function NotificationLogPanel({
servicio,
emptyHint = "Sin envíos con el filtro actual.",
/** Bump to force a reload — the parent raises it after a send. */
reloadToken = 0,
}: {
servicio: NotificationServicio[];
emptyHint?: string;
reloadToken?: number;
}) {
const [log, setLog] = useState<NotificationLogPage | null>(null);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<LogView>("all");
const [page, setPage] = useState(1);
// `servicio` is a literal array at every call site, so a new identity each
// render would re-fetch forever. Key the effect on its contents instead.
const servicioKey = servicio.join(",");
const refresh = useCallback(async () => {
try {
const data = await listNotificationLog({
page,
pageSize: 50,
servicio: servicioKey.split(",") as NotificationServicio[],
view: view === "all" ? undefined : view,
});
setLog(data);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, [page, view, servicioKey]);
useEffect(() => {
void refresh();
}, [refresh, reloadToken]);
return (
<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={view === v.key}
className={`seg-btn ${view === v.key ? "active" : ""}`}
onClick={() => {
setView(v.key);
setPage(1);
}}
>
{v.label}
</button>
))}
</div>
</div>
{error && (
<div className="state-box state-error" style={{ marginTop: 12 }}>
{error}
</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]}
{notificationLevelLabel(row.notificationType, row.level)}
</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">{emptyHint}</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={() => setPage((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={() => setPage((p) => Math.min(log.pageCount, p + 1))}
>
Siguiente
</button>
</div>
)}
</section>
);
}