diff --git a/apps/api/src/notifications/notification.types.ts b/apps/api/src/notifications/notification.types.ts index 7b25368..5a3301e 100644 --- a/apps/api/src/notifications/notification.types.ts +++ b/apps/api/src/notifications/notification.types.ts @@ -103,6 +103,44 @@ export type NotificationJobResponse = type: "TRUST_PAYMENT_CONFIRMATION"; }; +/** The four jobs, in the order the "ejecutar todos" sweep runs them. */ +export type NotificationJobKind = + | "outstanding" + | "payment" + | "account" + | "trust"; + +/** + * One entry of the run-all sweep. A job that throws does NOT abort the + * sweep — it is recorded with `ok: false` and the next job still runs, so a + * single bad query can't silently block the other three envíos. + */ +export interface NotificationRunAllJobResult { + kind: NotificationJobKind; + ok: boolean; + result?: NotificationJobResponse; + error?: string; +} + +/** + * Aggregate response for `POST /notifications/run-all`. `sent/skipped/failed` + * are the sums across every job that completed; `jobs` keeps each job's own + * verbatim legacy response so the UI can still show per-job detail. + */ +export interface NotificationRunAllResponse { + request: "success"; + notificationType: "runAllNotifications"; + statusCode: 200; + debug: boolean; + sent: number; + skipped: number; + failed: number; + /** Jobs that threw — sweep continued past them. */ + errors: number; + jobs: NotificationRunAllJobResult[]; + type: "RUN_ALL"; +} + /** Normalized record for a single send attempt, fed by all four jobs. */ export interface SendAttempt { notificationType: EmailNotificationType; diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts index 62bd222..1f72da4 100644 --- a/apps/api/src/notifications/notifications.controller.ts +++ b/apps/api/src/notifications/notifications.controller.ts @@ -139,6 +139,35 @@ export class NotificationsController { return result; } + /** + * Run all four jobs sequentially with one set of flags. Audited as a + * single `notification.run-all.run` entry carrying the aggregate totals + * plus each job's outcome — the per-job endpoints are NOT re-audited, so + * the log has exactly one row per staff click. + */ + @Post("run-all") + @RequireAbility("notification:send") + @HttpCode(200) + async runAll( + @Body() body: RunJobDto, + @Query() query: RunJobDto, + @Req() req: Request, + ) { + const flags = { ...query, ...body }; + const result = await this.svc.runAll(flags); + void this.audit.log(actingId(req), "notification.run-all.run", { + debug: !!flags.debug, + ignoreDayRestriction: !!flags.ignoreDayRestriction, + useEmailLimit: !!flags.useEmailLimit, + sent: result.sent, + skipped: result.skipped, + failed: result.failed, + errors: result.errors, + jobs: result.jobs.map((j) => ({ kind: j.kind, ok: j.ok })), + }); + return result; + } + /* ----------------------------------------------------------- read views */ @Get("log") diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts index 357b5e1..5de6c06 100644 --- a/apps/api/src/notifications/notifications.service.ts +++ b/apps/api/src/notifications/notifications.service.ts @@ -12,7 +12,10 @@ import { MailService } from "../mail/mail.service"; import { PrismaService } from "../prisma/prisma.service"; import { SendAttempt, + NotificationJobKind, NotificationJobResponse, + NotificationRunAllJobResult, + NotificationRunAllResponse, AttemptStatus, } from "./notification.types"; import { @@ -649,6 +652,66 @@ export class NotificationsService { return response; } + /** + * "Ejecutar todos" — run all four jobs back to back with one set of flags. + * + * Sequential on purpose: the jobs share the SES transport and Job 3 can + * self-throttle via `useEmailLimit`, so firing them in parallel would both + * defeat that pause and interleave `email_notification_log` writes for no + * gain. A job that throws is captured and the sweep continues — one bad + * query must not swallow the other three envíos. + */ + async runAll(flags: { + debug?: boolean; + ignoreDayRestriction?: boolean; + useEmailLimit?: boolean; + }): Promise { + const debug = !!flags.debug; + const steps: { + kind: NotificationJobKind; + run: () => Promise; + }[] = [ + { kind: "outstanding", run: () => this.runOutstandingPayments(flags) }, + { kind: "payment", run: () => this.runPaymentConfirmation(flags) }, + { kind: "account", run: () => this.runAccountStatus(flags) }, + { kind: "trust", run: () => this.runTrustConfirmation(flags) }, + ]; + + const jobs: NotificationRunAllJobResult[] = []; + let sent = 0; + let skipped = 0; + let failed = 0; + let errors = 0; + + for (const step of steps) { + try { + const result = await step.run(); + sent += result.sent; + skipped += result.skipped; + failed += result.failed; + jobs.push({ kind: step.kind, ok: true, result }); + } catch (e) { + errors++; + const message = e instanceof Error ? e.message : String(e); + this.logger.error(`run-all: job ${step.kind} failed — ${message}`); + jobs.push({ kind: step.kind, ok: false, error: message }); + } + } + + return { + request: "success", + notificationType: "runAllNotifications", + statusCode: 200, + debug, + sent, + skipped, + failed, + errors, + jobs, + type: "RUN_ALL", + }; + } + /* ============================================================================ * Log browser — list / drill-down for the UI. * ========================================================================== */ diff --git a/apps/web/src/components/NotificacionesServicios.tsx b/apps/web/src/components/NotificacionesServicios.tsx index eb7e3c4..21e8a41 100644 --- a/apps/web/src/components/NotificacionesServicios.tsx +++ b/apps/web/src/components/NotificacionesServicios.tsx @@ -13,6 +13,7 @@ import { getNotificationStats, listNotificationLog, runAccountStatus, + runAllNotifications, runOutstandingPayments, runPaymentConfirmation, runTrustConfirmation, @@ -21,6 +22,7 @@ import type { NotificationFlags, NotificationJobResponse, NotificationLogPage, + NotificationRunAllResponse, NotificationStats, NotificationStatus, } from "@/lib/api"; @@ -79,6 +81,12 @@ const JOBS: JobDef[] = [ }, ]; +/** Job title by kind — used by the run-all summary, which only carries kinds. */ +const JOB_TITLES: Record = JOBS.reduce( + (acc, j) => ({ ...acc, [j.kind]: j.title }), + {} as Record, +); + const LOG_VIEWS = [ { key: "all", label: "Todos" }, { key: "sent", label: "Enviados" }, @@ -97,8 +105,10 @@ export function NotificacionesServicios() { view: "all" | "sent" | "failed" | "skipped"; }>({ view: "all" }); const [logPage, setLogPage] = useState(1); - const [busy, setBusy] = useState(null); - const [lastResult, setLastResult] = useState(null); + const [busy, setBusy] = useState(null); + const [lastResult, setLastResult] = useState< + NotificationJobResponse | NotificationRunAllResponse | null + >(null); const [error, setError] = useState(null); const refresh = useCallback(async () => { @@ -146,6 +156,30 @@ export function NotificacionesServicios() { [allowed, flags, refresh], ); + // "Ejecutar todos" — one POST, the API runs the four jobs sequentially with + // the same flags. Confirmation only matters when debug is off, since that + // is the case where real customers receive mail. + const runAll = useCallback(async () => { + if (!allowed) return; + if (!flags.debug) { + const ok = window.confirm( + "debug está desactivado: los cuatro envíos irán a los correos reales de los clientes. ¿Ejecutar todos?", + ); + if (!ok) return; + } + setBusy("all"); + setError(null); + try { + const res = await runAllNotifications(flags); + setLastResult(res); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(null); + } + }, [allowed, flags, refresh]); + return (

@@ -221,6 +255,32 @@ export function NotificacionesServicios() {

+ +
+ + + 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. + +

Última respuesta

+ {lastResult.type === "RUN_ALL" && ( +
    +
  • + Totales: enviados {lastResult.sent} · omitidos{" "} + {lastResult.skipped} · fallidos {lastResult.failed} + {lastResult.errors > 0 && ` · jobs con error ${lastResult.errors}`} +
  • + {lastResult.jobs.map((j) => ( +
  • + {JOB_TITLES[j.kind]}:{" "} + {j.ok && j.result ? ( + <> + enviados {j.result.sent} · omitidos {j.result.skipped} · + fallidos {j.result.failed} + + ) : ( + + error — {j.error} + + )} +
  • + ))} +
+ )}
 {
+  return apiFetch("/notifications/run-all", {
+    method: "POST",
+    body: JSON.stringify(flags),
+  });
+}
+
 export interface NotificationLogQuery {
   page?: number;
   pageSize?: number;