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
@@ -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>