feat(notificaciones): one send log across servicios and pólizas
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m30s
Build and Push Images / Build jorgecuadros-api (push) Failing after 3h13m42s

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:
2026-08-02 03:01:03 -07:00
co-authored by Claude Opus 5
parent c0cc0d2ac2
commit 33833c3af9
20 changed files with 813 additions and 194 deletions
@@ -3,7 +3,8 @@
import { useCallback, useEffect, useState } from "react";
import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels";
import { apiFetch } from "@/lib/api";
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
import { apiFetch, POLIZAS_LOG_SCOPE } from "@/lib/api";
/**
* Renewal notices — the "Pólizas" half of /notificaciones. Shows which
@@ -11,6 +12,11 @@ import { apiFetch } from "@/lib/api";
* one row at a time or as a whole sweep. Sending is what marks a notice as
* delivered — there is no manual "mark as sent", so the list can never claim
* a letter went out when no mail was ever sent. Gated on `renewal:send`.
*
* Sends are recorded in the same `email_notification_log` the Servicios tab
* reads, so "Registro de envíos" below is the same component with the
* POLICIES slice — failures and no-email skips included, which the pending
* list alone cannot show.
*/
export interface RenewalLetter {
@@ -60,6 +66,8 @@ export function NotificacionesPolizas() {
const [sweeping, setSweeping] = useState(false);
/** `policyId-generation` of the row currently being sent, if any. */
const [sendingKey, setSendingKey] = useState<string | null>(null);
/** Raised after every send so the log panel reloads. */
const [logToken, setLogToken] = useState(0);
const refresh = useCallback(async () => {
setPendingError(null);
@@ -89,9 +97,11 @@ export function NotificacionesPolizas() {
method: "POST",
});
setNotice(`Enviados ${result.sent} avisos (${result.failed} con error).`);
setLogToken((t) => t + 1);
await refresh();
} catch (e) {
setActionError((e as Error)?.message ?? "No se pudo ejecutar el barrido.");
setLogToken((t) => t + 1);
} finally {
setSweeping(false);
}
@@ -115,9 +125,12 @@ export function NotificacionesPolizas() {
}),
});
setNotice(`Aviso enviado a ${result.to}.`);
setLogToken((t) => t + 1);
await refresh();
} catch (e) {
setActionError((e as Error)?.message ?? "No se pudo enviar el aviso.");
// A rejected send may still have written a FAILED row; reload either way.
setLogToken((t) => t + 1);
} finally {
setSendingKey(null);
}
@@ -253,6 +266,12 @@ export function NotificacionesPolizas() {
</div>
</section>
))}
<NotificationLogPanel
servicio={POLIZAS_LOG_SCOPE}
reloadToken={logToken}
emptyHint="Todavía no se ha enviado ningún aviso de renovación con este filtro."
/>
</div>
);
}