feat(notificaciones): ejecutar todos for servicios jobs
Add POST /notifications/run-all: runs the four notification jobs (outstanding, payment confirmation, account status, trust confirmation) sequentially with one shared set of flags from "Flags del envío". Sequential rather than parallel — the jobs share the SES transport and account status can self-throttle via useEmailLimit. A job that throws is captured and the sweep continues, so one bad query cannot swallow the other three envíos; the aggregate response carries per-job results plus summed sent/skipped/failed and an errors count. Audited as a single notification.run-all.run entry so one staff click is one audit row. UI adds the button to the flags card, with a confirm when debug is off, and a per-job summary in "Última respuesta".
This commit is contained in:
@@ -103,6 +103,44 @@ export type NotificationJobResponse =
|
|||||||
type: "TRUST_PAYMENT_CONFIRMATION";
|
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. */
|
/** Normalized record for a single send attempt, fed by all four jobs. */
|
||||||
export interface SendAttempt {
|
export interface SendAttempt {
|
||||||
notificationType: EmailNotificationType;
|
notificationType: EmailNotificationType;
|
||||||
|
|||||||
@@ -139,6 +139,35 @@ export class NotificationsController {
|
|||||||
return result;
|
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 */
|
/* ----------------------------------------------------------- read views */
|
||||||
|
|
||||||
@Get("log")
|
@Get("log")
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { MailService } from "../mail/mail.service";
|
|||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import {
|
import {
|
||||||
SendAttempt,
|
SendAttempt,
|
||||||
|
NotificationJobKind,
|
||||||
NotificationJobResponse,
|
NotificationJobResponse,
|
||||||
|
NotificationRunAllJobResult,
|
||||||
|
NotificationRunAllResponse,
|
||||||
AttemptStatus,
|
AttemptStatus,
|
||||||
} from "./notification.types";
|
} from "./notification.types";
|
||||||
import {
|
import {
|
||||||
@@ -649,6 +652,66 @@ export class NotificationsService {
|
|||||||
return response;
|
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<NotificationRunAllResponse> {
|
||||||
|
const debug = !!flags.debug;
|
||||||
|
const steps: {
|
||||||
|
kind: NotificationJobKind;
|
||||||
|
run: () => Promise<NotificationJobResponse>;
|
||||||
|
}[] = [
|
||||||
|
{ 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.
|
* Log browser — list / drill-down for the UI.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getNotificationStats,
|
getNotificationStats,
|
||||||
listNotificationLog,
|
listNotificationLog,
|
||||||
runAccountStatus,
|
runAccountStatus,
|
||||||
|
runAllNotifications,
|
||||||
runOutstandingPayments,
|
runOutstandingPayments,
|
||||||
runPaymentConfirmation,
|
runPaymentConfirmation,
|
||||||
runTrustConfirmation,
|
runTrustConfirmation,
|
||||||
@@ -21,6 +22,7 @@ import type {
|
|||||||
NotificationFlags,
|
NotificationFlags,
|
||||||
NotificationJobResponse,
|
NotificationJobResponse,
|
||||||
NotificationLogPage,
|
NotificationLogPage,
|
||||||
|
NotificationRunAllResponse,
|
||||||
NotificationStats,
|
NotificationStats,
|
||||||
NotificationStatus,
|
NotificationStatus,
|
||||||
} from "@/lib/api";
|
} 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<JobKind, string> = JOBS.reduce(
|
||||||
|
(acc, j) => ({ ...acc, [j.kind]: j.title }),
|
||||||
|
{} as Record<JobKind, string>,
|
||||||
|
);
|
||||||
|
|
||||||
const LOG_VIEWS = [
|
const LOG_VIEWS = [
|
||||||
{ key: "all", label: "Todos" },
|
{ key: "all", label: "Todos" },
|
||||||
{ key: "sent", label: "Enviados" },
|
{ key: "sent", label: "Enviados" },
|
||||||
@@ -97,8 +105,10 @@ export function NotificacionesServicios() {
|
|||||||
view: "all" | "sent" | "failed" | "skipped";
|
view: "all" | "sent" | "failed" | "skipped";
|
||||||
}>({ view: "all" });
|
}>({ view: "all" });
|
||||||
const [logPage, setLogPage] = useState(1);
|
const [logPage, setLogPage] = useState(1);
|
||||||
const [busy, setBusy] = useState<JobKind | null>(null);
|
const [busy, setBusy] = useState<JobKind | "all" | null>(null);
|
||||||
const [lastResult, setLastResult] = useState<NotificationJobResponse | null>(null);
|
const [lastResult, setLastResult] = useState<
|
||||||
|
NotificationJobResponse | NotificationRunAllResponse | null
|
||||||
|
>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
@@ -146,6 +156,30 @@ export function NotificacionesServicios() {
|
|||||||
[allowed, flags, refresh],
|
[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 (
|
return (
|
||||||
<div style={{ display: "grid", gap: 20 }}>
|
<div style={{ display: "grid", gap: 20 }}>
|
||||||
<p className="muted" style={{ maxWidth: 760, margin: 0 }}>
|
<p className="muted" style={{ maxWidth: 760, margin: 0 }}>
|
||||||
@@ -221,6 +255,32 @@ export function NotificacionesServicios() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
marginTop: 16,
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTop: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={!allowed || busy !== null}
|
||||||
|
onClick={() => void runAll()}
|
||||||
|
>
|
||||||
|
{busy === "all" ? "Ejecutando todos…" : "Ejecutar todos"}
|
||||||
|
</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.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -316,6 +376,33 @@ export function NotificacionesServicios() {
|
|||||||
{lastResult && (
|
{lastResult && (
|
||||||
<section className="card" style={{ padding: 20 }}>
|
<section className="card" style={{ padding: 20 }}>
|
||||||
<h2 className="section-title">Última respuesta</h2>
|
<h2 className="section-title">Última respuesta</h2>
|
||||||
|
{lastResult.type === "RUN_ALL" && (
|
||||||
|
<ul
|
||||||
|
className="small"
|
||||||
|
style={{ marginTop: 10, marginBottom: 0, paddingLeft: 18 }}
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
Totales: enviados {lastResult.sent} · omitidos{" "}
|
||||||
|
{lastResult.skipped} · fallidos {lastResult.failed}
|
||||||
|
{lastResult.errors > 0 && ` · jobs con error ${lastResult.errors}`}
|
||||||
|
</li>
|
||||||
|
{lastResult.jobs.map((j) => (
|
||||||
|
<li key={j.kind}>
|
||||||
|
{JOB_TITLES[j.kind]}:{" "}
|
||||||
|
{j.ok && j.result ? (
|
||||||
|
<>
|
||||||
|
enviados {j.result.sent} · omitidos {j.result.skipped} ·
|
||||||
|
fallidos {j.result.failed}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--negative)" }}>
|
||||||
|
error — {j.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
<pre
|
<pre
|
||||||
className="mono"
|
className="mono"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1125,6 +1125,38 @@ export function runTrustConfirmation(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NotificationJobKind = "outstanding" | "payment" | "account" | "trust";
|
||||||
|
|
||||||
|
export interface NotificationRunAllJobResult {
|
||||||
|
kind: NotificationJobKind;
|
||||||
|
ok: boolean;
|
||||||
|
result?: NotificationJobResponse;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregate response of the "Ejecutar todos" sweep. */
|
||||||
|
export interface NotificationRunAllResponse {
|
||||||
|
request: "success";
|
||||||
|
notificationType: "runAllNotifications";
|
||||||
|
statusCode: 200;
|
||||||
|
debug: boolean;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
errors: number;
|
||||||
|
jobs: NotificationRunAllJobResult[];
|
||||||
|
type: "RUN_ALL";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runAllNotifications(
|
||||||
|
flags: NotificationFlags = {},
|
||||||
|
): Promise<NotificationRunAllResponse> {
|
||||||
|
return apiFetch<NotificationRunAllResponse>("/notifications/run-all", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(flags),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotificationLogQuery {
|
export interface NotificationLogQuery {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user