feat(notificaciones): global send flags + editable schedules
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m47s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m3s

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>
This commit is contained in:
2026-08-02 12:23:19 -07:00
co-authored by Claude Opus 5
parent a491ef3eed
commit 89611da202
20 changed files with 1115 additions and 133 deletions
+25 -1
View File
@@ -4,6 +4,9 @@ import { useState } from "react";
import { useCan } from "@/lib/abilities";
import { NotificacionesServicios } from "@/components/NotificacionesServicios";
import { NotificacionesPolizas } from "@/components/NotificacionesPolizas";
import { NotificationFlagsCard } from "@/components/NotificationFlagsCard";
import { NotificationScheduleCard } from "@/components/NotificationScheduleCard";
import type { NotificationFlags } from "@/lib/api";
/**
* Notificaciones — one screen, two subsections:
@@ -16,6 +19,12 @@ import { NotificacionesPolizas } from "@/components/NotificacionesPolizas";
* Both are "tell a customer something by email", so they are modes of one
* screen rather than two menu entries. `/renovaciones` still resolves here on
* the pólizas tab so old bookmarks keep working (same pattern as Captura).
*
* Two things are owned by this shell rather than by a tab, because they are
* true of every notification: the send flags (`debug` in particular, which the
* pólizas half honours exactly like the servicios half) and the automatic
* cadence of both sweeps. Keeping the flags here also means switching tabs
* cannot silently drop a `debug` the operator just ticked.
*/
export type NotificacionesTab = "servicios" | "polizas";
@@ -45,6 +54,8 @@ export function Notificaciones({
const [tab, setTab] = useState<NotificacionesTab>(
tabs.some((t) => t.key === initialTab) ? initialTab : "servicios",
);
// Defaults to debug ON: the safe end of the switch is the one you land on.
const [flags, setFlags] = useState<NotificationFlags>({ debug: true });
if (!canNotify && !canRenew) {
return (
@@ -64,6 +75,15 @@ export function Notificaciones({
</p>
</div>
<div style={{ display: "grid", gap: 16, marginBottom: 20 }}>
<NotificationFlagsCard
flags={flags}
onChange={setFlags}
disabled={!canNotify && !canRenew}
/>
<NotificationScheduleCard />
</div>
{tabs.length > 1 && (
<div className="seg" role="tablist" style={{ marginBottom: 20 }}>
{tabs.map((t) => (
@@ -81,7 +101,11 @@ export function Notificaciones({
</div>
)}
{tab === "servicios" ? <NotificacionesServicios /> : <NotificacionesPolizas />}
{tab === "servicios" ? (
<NotificacionesServicios flags={flags} />
) : (
<NotificacionesPolizas flags={flags} />
)}
</>
);
}
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels";
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
import { apiFetch, POLIZAS_LOG_SCOPE } from "@/lib/api";
import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api";
/**
* Renewal notices — the "Pólizas" half of /notificaciones. Shows which
@@ -17,6 +17,11 @@ import { apiFetch, POLIZAS_LOG_SCOPE } from "@/lib/api";
* 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.
*
* `debug` comes from the shared flags card above the tabs and means the same
* thing here as it does for servicios: the mail is diverted to the override
* inbox. It additionally does NOT mark the notice as sent, so a test send
* leaves the row exactly where it was — pending.
*/
export interface RenewalLetter {
@@ -40,12 +45,15 @@ export interface RenewalSweepResult {
skipped: number;
failed: number;
failures: { policyId: string; generation: number; error: string }[];
debug: boolean;
}
export interface RenewalSendResult {
policyId: string;
generation: number;
/** Where the mail actually went — the override inbox under debug. */
to: string;
debug: boolean;
sentAt: string;
providerMessageId?: string;
}
@@ -56,8 +64,9 @@ const GENERATION_LABEL: Record<number, string> = {
3: "Tercer aviso (7 días después)",
};
export function NotificacionesPolizas() {
export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
const allowed = useCan("renewal:send");
const debug = !!flags.debug;
const [days, setDays] = useState(30);
const [pending, setPending] = useState<RenewalLetter[] | null>(null);
const [pendingError, setPendingError] = useState<string | null>(null);
@@ -89,14 +98,28 @@ export function NotificacionesPolizas() {
}, [allowed, refresh]);
async function handleSweep() {
// Only worth confirming when debug is off — that is the case where real
// customers receive mail. Mirrors "Ejecutar todos" on the servicios tab.
if (!debug) {
const ok = window.confirm(
"debug está desactivado: los avisos irán a los correos reales de los clientes. ¿Ejecutar el barrido?",
);
if (!ok) return;
}
setActionError(null);
setNotice(null);
setSweeping(true);
try {
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
method: "POST",
body: JSON.stringify({ debug }),
});
setNotice(`Enviados ${result.sent} avisos (${result.failed} con error).`);
setNotice(
`Enviados ${result.sent} avisos (${result.failed} con error).` +
(result.debug
? " Modo debug: fueron al buzón de pruebas y siguen pendientes."
: ""),
);
setLogToken((t) => t + 1);
await refresh();
} catch (e) {
@@ -122,9 +145,14 @@ export function NotificacionesPolizas() {
body: JSON.stringify({
policyId: letter.policyId,
generation: letter.generation,
debug,
}),
});
setNotice(`Aviso enviado a ${result.to}.`);
setNotice(
result.debug
? `Prueba enviada a ${result.to}. El aviso sigue pendiente: el cliente no ha recibido nada.`
: `Aviso enviado a ${result.to}.`,
);
setLogToken((t) => t + 1);
await refresh();
} catch (e) {
@@ -156,10 +184,10 @@ export function NotificacionesPolizas() {
return (
<div style={{ display: "grid", gap: 20 }}>
<p className="muted" style={{ maxWidth: 760, margin: 0 }}>
El sistema ejecuta un barrido diario a las 06:00 hora local que notifica
a los clientes a 30, 15 y 7 días antes o después del vencimiento de su
póliza. Esta sección muestra qué avisos están pendientes y permite
ejecutarlo manualmente.
El sistema ejecuta un barrido automático (ver «Programación de envíos»
arriba) que notifica a los clientes a 30, 15 y 7 días antes o después
del vencimiento de su póliza. Esta sección muestra qué avisos están
pendientes y permite ejecutarlo manualmente.
</p>
{actionError && <div className="state-box state-error">{actionError}</div>}
@@ -30,6 +30,9 @@ import type {
* 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";
@@ -85,10 +88,9 @@ const JOB_TITLES: Record<JobKind, string> = JOBS.reduce(
{} as Record<JobKind, string>,
);
export function NotificacionesServicios() {
export function NotificacionesServicios({ flags }: { flags: NotificationFlags }) {
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);
@@ -176,73 +178,14 @@ export function NotificacionesServicios() {
{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>
<h2 className="section-title">Ejecutar ahora</h2>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
marginTop: 16,
paddingTop: 16,
borderTop: "1px solid var(--line)",
marginTop: 12,
}}
>
<button
@@ -255,8 +198,9 @@ export function NotificacionesServicios() {
</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.
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>
@@ -0,0 +1,92 @@
"use client";
import type { NotificationFlags } from "@/lib/api";
/**
* The "Flags del envío" panel. It lives in the /notificaciones shell above the
* tabs, not inside one of them, because the flags are platform-wide: `debug`
* governs the pólizas avisos exactly as it governs the four servicios jobs,
* and a switch that only protected half the screen was the bug this fixes.
*
* State is per-visit, never persisted — see the note on the schedule card. A
* stored `debug` would survive a reload and silently swallow real customer
* mail; the automatic corridas therefore always send for real.
*/
export function NotificationFlagsCard({
flags,
onChange,
disabled = false,
}: {
flags: NotificationFlags;
onChange: (next: NotificationFlags) => void;
disabled?: boolean;
}) {
const set = (patch: Partial<NotificationFlags>) =>
onChange({ ...flags, ...patch });
return (
<section className="card" style={{ padding: 20 }}>
<h2 className="section-title">Flags del envío</h2>
<p className="muted small" style={{ marginTop: 4, marginBottom: 0, maxWidth: 620 }}>
Se aplican a todo lo que se envía desde esta pantalla servicios y
pólizas y solo a los envíos manuales. Las corridas automáticas siempre
mandan de verdad.
</p>
<div style={{ display: "grid", gap: 4, marginTop: 14 }}>
<label
className="field"
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
>
<input
type="checkbox"
checked={!!flags.debug}
disabled={disabled}
onChange={(e) => set({ 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. Un aviso de renovación enviado en debug
NO se marca como enviado: sigue pendiente en la lista.
</span>
</label>
<label
className="field"
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
>
<input
type="checkbox"
checked={!!flags.ignoreDayRestriction}
disabled={disabled}
onChange={(e) => set({ 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. Solo aplica a servicios.
</span>
</label>
<label
className="field"
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 0 }}
>
<input
type="checkbox"
checked={!!flags.useEmailLimit}
disabled={disabled}
onChange={(e) => set({ 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.
Solo aplica a servicios.
</span>
</label>
</div>
</section>
);
}
@@ -0,0 +1,309 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCan } from "@/lib/abilities";
import {
getNotificationSchedules,
setNotificationSchedule,
type NotificationSchedule,
type NotificationSchedules,
type ScheduleKind,
} from "@/lib/api";
import { formatDateTime } from "@/lib/labels";
/**
* When the two automatic envíos run.
*
* Both cadences used to be source code: pólizas barría a las 06:00 desde un
* `@Cron` en el servidor y servicios no corría solo en absoluto. Cambiar
* cualquiera de los dos era un redeploy. Ahora se guardan en `app_settings` y
* el servidor reinstala el job al guardar — sin reinicio.
*
* Los flags de la tarjeta de arriba NO se aplican aquí: una corrida
* automática siempre manda de verdad.
*/
const KIND_LABEL: Record<ScheduleKind, string> = {
servicios: "Servicios",
polizas: "Pólizas",
};
const KIND_HINT: Record<ScheduleKind, string> = {
servicios:
"Ejecuta los cuatro envíos en orden, igual que el botón «Ejecutar todos». El estado de cuenta sigue respetando sus gates de lunes/miércoles/viernes.",
polizas:
"Barrido de avisos de renovación: 30 y 15 días antes del vencimiento, y 7 días después.",
};
const DAYS = [
{ value: 0, label: "Dom" },
{ value: 1, label: "Lun" },
{ value: 2, label: "Mar" },
{ value: 3, label: "Mié" },
{ value: 4, label: "Jue" },
{ value: 5, label: "Vie" },
{ value: 6, label: "Sáb" },
];
function timeValue(s: NotificationSchedule): string {
return `${String(s.hour).padStart(2, "0")}:${String(s.minute).padStart(2, "0")}`;
}
function describe(s: NotificationSchedule): string {
if (!s.enabled) return "Desactivado — solo se envía manualmente.";
const days = s.weekdays.length
? s.weekdays
.map((d) => DAYS.find((x) => x.value === d)?.label ?? d)
.join(", ")
: "todos los días";
return `${days} a las ${timeValue(s)} (hora de Tijuana).`;
}
export function NotificationScheduleCard() {
const canEdit = useCan("setting:manage");
const [schedules, setSchedules] = useState<NotificationSchedules | null>(null);
const [drafts, setDrafts] = useState<Partial<Record<ScheduleKind, NotificationSchedule>>>({});
const [editing, setEditing] = useState<ScheduleKind | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState<ScheduleKind | null>(null);
const load = useCallback(async () => {
try {
setSchedules(await getNotificationSchedules());
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
void load();
}, [load]);
function startEdit(kind: ScheduleKind) {
if (!schedules) return;
setDrafts((d) => ({ ...d, [kind]: { ...schedules[kind].value } }));
setEditing(kind);
setSaved(null);
setError(null);
}
async function save(kind: ScheduleKind) {
const draft = drafts[kind];
if (!draft) return;
setSaving(true);
setError(null);
try {
const result = await setNotificationSchedule(kind, draft);
setSchedules((prev) => (prev ? { ...prev, [kind]: result } : prev));
setEditing(null);
setSaved(kind);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
}
if (!schedules) {
return (
<section className="card" style={{ padding: 20 }}>
<h2 className="section-title">Programación de envíos</h2>
{error ? (
<div className="state-box state-error" style={{ marginTop: 12 }}>
{error}
</div>
) : (
<p className="muted small" style={{ marginTop: 8, marginBottom: 0 }}>
Cargando
</p>
)}
</section>
);
}
return (
<section className="card" style={{ padding: 20 }}>
<h2 className="section-title">Programación de envíos</h2>
<p className="muted small" style={{ marginTop: 4, marginBottom: 0, maxWidth: 660 }}>
Cuándo corre solo cada envío. Los cambios aplican de inmediato, sin
reiniciar el servidor. Una corrida automática nunca usa los flags de
arriba: siempre manda a los clientes reales.
</p>
{error && (
<div className="state-box state-error" style={{ marginTop: 12 }}>
{error}
</div>
)}
<div style={{ display: "grid", gap: 12, marginTop: 14 }}>
{(Object.keys(KIND_LABEL) as ScheduleKind[]).map((kind) => {
const current = schedules[kind];
const draft = drafts[kind];
const isEditing = editing === kind && draft;
return (
<article
key={kind}
style={{
border: "1px solid var(--line)",
borderRadius: "var(--radius-sm)",
padding: 14,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 12,
flexWrap: "wrap",
}}
>
<div>
<strong>{KIND_LABEL[kind]}</strong>
<p className="muted small" style={{ margin: "4px 0 0", maxWidth: 560 }}>
{KIND_HINT[kind]}
</p>
</div>
{canEdit && !isEditing && (
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => startEdit(kind)}
>
Editar
</button>
)}
</div>
{isEditing ? (
<div style={{ marginTop: 12 }}>
<label
className="field"
style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 10 }}
>
<input
type="checkbox"
checked={draft.enabled}
disabled={saving}
onChange={(e) =>
setDrafts((d) => ({
...d,
[kind]: { ...draft, enabled: e.target.checked },
}))
}
/>
<span className="small">
<strong>Corrida automática activada</strong>
</span>
</label>
<label className="field" style={{ maxWidth: 160, marginBottom: 10 }}>
<span className="field-label">Hora (Tijuana)</span>
<input
className="input"
type="time"
value={timeValue(draft)}
disabled={saving || !draft.enabled}
onChange={(e) => {
const [h, m] = e.target.value.split(":").map(Number);
setDrafts((d) => ({
...d,
[kind]: {
...draft,
hour: Number.isFinite(h) ? h : draft.hour,
minute: Number.isFinite(m) ? m : draft.minute,
},
}));
}}
/>
</label>
<div className="field" style={{ marginBottom: 10 }}>
<span className="field-label">
Días (ninguno seleccionado = todos los días)
</span>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 4 }}>
{DAYS.map((d) => {
const on = draft.weekdays.includes(d.value);
return (
<button
key={d.value}
type="button"
className={`btn btn-sm ${on ? "btn-primary" : "btn-outline"}`}
disabled={saving || !draft.enabled}
onClick={() =>
setDrafts((prev) => ({
...prev,
[kind]: {
...draft,
weekdays: on
? draft.weekdays.filter((x) => x !== d.value)
: [...draft.weekdays, d.value].sort(),
},
}))
}
>
{d.label}
</button>
);
})}
</div>
</div>
<div className="row-actions">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={saving}
onClick={() => void save(kind)}
>
{saving ? "Guardando…" : "Guardar"}
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={saving}
onClick={() => {
setEditing(null);
setError(null);
}}
>
Cancelar
</button>
</div>
</div>
) : (
<div style={{ marginTop: 10 }}>
<p className="small" style={{ margin: 0 }}>
{describe(current.value)}
</p>
<p className="section-note" style={{ marginTop: 6, marginBottom: 0 }}>
<code>{current.cron}</code>
{current.nextRun &&
` · próxima corrida: ${formatDateTime(current.nextRun)}`}
{current.source === "default" &&
" · valor por omisión, nadie lo ha cambiado"}
{current.updatedAt &&
` · última edición: ${formatDateTime(current.updatedAt)}`}
{saved === kind && " · guardado"}
</p>
</div>
)}
</article>
);
})}
</div>
{!canEdit && (
<p className="section-note" style={{ marginTop: 12, marginBottom: 0 }}>
Solo un ADMIN puede cambiar la programación.
</p>
)}
</section>
);
}
+40
View File
@@ -1211,6 +1211,46 @@ export function setNotificationAdminEmails(
});
}
/* ----------------------------------------------------- envío scheduling */
/** The two automatic envíos, one per /notificaciones tab. */
export type ScheduleKind = "servicios" | "polizas";
export interface NotificationSchedule {
enabled: boolean;
/** Local hour/minute in America/Tijuana. */
hour: number;
minute: number;
/** 0 = domingo … 6 = sábado. Vacío = todos los días. */
weekdays: number[];
}
export interface ResolvedSchedule {
value: NotificationSchedule;
source: SettingSource;
updatedAt: string | null;
updatedById: string | null;
/** Expression the value compiles to, shown verbatim in the UI. */
cron: string;
nextRun: string | null;
}
export type NotificationSchedules = Record<ScheduleKind, ResolvedSchedule>;
export function getNotificationSchedules(): Promise<NotificationSchedules> {
return apiFetch<NotificationSchedules>("/notifications/settings/schedule");
}
export function setNotificationSchedule(
kind: ScheduleKind,
schedule: NotificationSchedule,
): Promise<ResolvedSchedule> {
return apiFetch<ResolvedSchedule>(`/notifications/settings/schedule/${kind}`, {
method: "PUT",
body: JSON.stringify(schedule),
});
}
export function getNotificationStats(
servicio?: NotificationServicio[],
): Promise<NotificationStats> {