feat(notificaciones): mass email notifications over SES

Replaces the four legacy PHP scripts under email.notifications/send*.php
with a single NestJS module. Four jobs (outstanding payments, payment
confirmations, account-status alerts with day-of-week gates, trust
payment confirmations) share one MailService modelled on StorageService:
env-driven SES client, null fallback in dev with console logging, refuses
to send in production when unconfigured.

Schema adds email_notification_log (every attempt, sent/failed/skipped)
and account_status_history (one row per threshold hit, Job 3). Enums
encode the legacy wire shape so external log scrapers keep parsing
notificationType keys verbatim.

Web adds /notificaciones with four trigger cards, a flags panel, and a
paginated log browser. New notification:send ability gates all four
endpoints at MANAGER, matching the renewal:send trust tier.
This commit is contained in:
2026-08-02 02:04:14 -07:00
parent 5e9cb12fba
commit a52e59cbc5
21 changed files with 3128 additions and 4 deletions
@@ -0,0 +1,926 @@
import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
Currency,
EmailNotificationServicio,
EmailNotificationStatus,
EmailNotificationType,
Prisma,
TransactionDomain,
} from "@jorgecuadros/database";
import { MailService } from "../mail/mail.service";
import { PrismaService } from "../prisma/prisma.service";
import {
SendAttempt,
NotificationJobResponse,
AttemptStatus,
} from "./notification.types";
import {
renderOutstanding,
renderPaymentConfirm,
renderAccountStatus,
renderTrustConfirm,
} from "./render";
/**
* Mass email notifications — the modern replacement for the four PHP
* scripts under `email.notifications/send*.php`. One service, four job
* methods, identical wire shape to the legacy scripts (so a log scraper
* parsing the JSON response keeps working — see
* `~/Documents/Claude-Memory/email-notifications-spec.md`).
*
* ── Recipient selection ────────────────────────────────────────────────────
* Job 1 (Outstanding): customers with at least one Transaction where
* `outstanding = true` and the movement is a charge
* (amount < 0). Equivalent to legacy
* `datosfreak WHERE NOPAGO = 1`.
* Job 2 (PaymentConf): customers with a credit (amount > 0) posted in
* the last 24 hours. Equivalent to legacy
* `pagosemail` view, which the ETL refreshed daily
* from the same predicate.
* Job 3 (AccountStatus): every customer with a non-null email; per row
* the balance = SUM(transactions.amount) per
* currency, excluding voided + outstanding — same
* arithmetic `BillingService.balances()` uses, so
* the yellow/red alert lines up with what the
* receivables worklist already shows staff.
* Job 4 (TrustConfirm): customers with a `TrustAccount` whose email is
* set, where the latest trust-domain credit was
* posted in the last 24 hours. The trust-fee ETL
* used to push one row per annual fee payment.
*
* ── Audit log ──────────────────────────────────────────────────────────────
* Every send attempt (sent, failed, or skipped) writes one row to
* `email_notification_log`. Job 3 additionally writes one row per
* threshold hit to `account_status_history`, mirroring the legacy
* `utility_dbo.send_account_status_history` table verbatim.
*
* ── Day-of-week gates (Job 3) ──────────────────────────────────────────────
* Yellow: Wed only (or `ignoreDayRestriction`).
* Red: Mon/Wed/Fri only (or `ignoreDayRestriction`).
* A customer who is red on a Tuesday is skipped (SKIPPED_GATE) until Wed,
* when both checks can run on the same row — keeps the on-demand behavior
* in lock-step with the legacy script.
*/
const ONE_HOUR_MS = 60 * 60 * 1000;
const RATE_LIMIT_EMAILS = 100;
const PAYMENT_LOOKBACK_HOURS = 24;
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
/** Override addresses — comma-separated in env. Falls back to the
* legacy defaults so a fresh deploy still has somewhere to send. */
private readonly adminEmails: string[];
constructor(
private readonly prisma: PrismaService,
private readonly mail: MailService,
config: ConfigService,
) {
const csv = config.get<string>("NOTIFICATION_ADMIN_EMAILS");
if (csv && csv.trim()) {
this.adminEmails = csv
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else {
this.adminEmails = ["rmancinas@freakma.net", "mpulido@freakma.net"];
}
}
/* ============================================================================
* Public jobs — called by the controller and by future cron sweeps alike.
* ========================================================================== */
/** Job 1 — Outstanding payments. */
async runOutstandingPayments(flags: {
debug?: boolean;
ignoreDayRestriction?: boolean;
useEmailLimit?: boolean;
}): Promise<NotificationJobResponse> {
const debug = !!flags.debug;
const candidates = await this.prisma.customer.findMany({
where: {
email: { not: null },
archivedAt: null,
transactions: {
some: {
outstanding: true,
amount: { lt: 0 },
voidedAt: null,
},
},
},
select: {
id: true,
name: true,
email: true,
transactions: {
where: { outstanding: true, amount: { lt: 0 }, voidedAt: null },
orderBy: { transactionDate: "asc" },
select: {
id: true,
transactionDate: true,
reference: true,
period: true,
amount: true,
type: { select: { nameEn: true } },
},
},
},
orderBy: { id: "asc" },
});
let sent = 0;
let skipped = 0;
let failed = 0;
for (const c of candidates) {
const email = debug ? this.debugEmail() : (c.email ?? "").toLowerCase();
if (!email || !email.includes("@")) {
await this.recordAttempt({
notificationType: "OUTSTANDING_PAYMENT",
servicio: "CUSTOMERS",
customerId: c.id,
customerName: c.name,
customerEmail: email || "(missing)",
subject: "Jorge Cuadros - Outstanding Payments",
bodySnapshot: "(skipped: no email)",
status: "SKIPPED_NO_EMAIL",
debug,
});
skipped++;
continue;
}
const total = c.transactions.reduce(
(acc, t) => acc.plus(t.amount),
new Prisma.Decimal(0),
);
let running = new Prisma.Decimal(0);
const rows = c.transactions.map((t) => {
running = running.plus(t.amount);
return {
date: t.transactionDate.toISOString().slice(0, 10),
reference: t.reference,
period: t.period,
type: t.type?.nameEn ?? null,
amount: t.amount.toFixed(2),
balance: running.toFixed(2),
};
});
const body = renderOutstanding({
customerId: c.id,
customerName: c.name,
total: total.abs().toFixed(2),
rows,
year: new Date().getFullYear(),
});
const attempt = await this.deliver({
notificationType: "OUTSTANDING_PAYMENT",
servicio: "CUSTOMERS",
customerId: c.id,
customerName: c.name,
customerEmail: email,
subject: "Jorge Cuadros - Outstanding Payments",
bodySnapshot: body,
debug,
});
if (attempt === "SENT") sent++;
else if (attempt === "SKIPPED_NO_EMAIL" || attempt === "SKIPPED_GATE") skipped++;
else failed++;
}
const response: NotificationJobResponse = {
result: "success",
notificationType: "sendPaymentConfirmation",
reason: `sent confirmation ${sent} emails`,
statusCode: 200,
sent,
skipped,
failed,
debug,
type: "OUTSTANDING_PAYMENT",
};
await this.adminSummary("Jorge Cuadros - Outstanding Payments", response);
return response;
}
/** Job 2 — Payment confirmation. One email per customer whose latest
* credit (positive amount Transaction) landed in the last 24h. */
async runPaymentConfirmation(flags: {
debug?: boolean;
ignoreDayRestriction?: boolean;
useEmailLimit?: boolean;
}): Promise<NotificationJobResponse> {
const debug = !!flags.debug;
const since = new Date(Date.now() - PAYMENT_LOOKBACK_HOURS * 3600_000);
// Find customers with a credit in the window. We then pick the most
// recent credit per customer; if multiple, we send one summary per
// customer (the PHP script also sent one per customer, picking the
// row the `pagosemail` view exposed for that NUMid).
const credits = await this.prisma.transaction.findMany({
where: {
amount: { gt: 0 },
voidedAt: null,
transactionDate: { gte: since },
customer: { archivedAt: null },
},
orderBy: { transactionDate: "desc" },
select: {
id: true,
transactionDate: true,
reference: true,
amount: true,
currency: true,
type: { select: { nameEn: true, nameEs: true } },
customer: {
select: { id: true, name: true, email: true, preferredCurrency: true },
},
},
});
// One row per customer (most recent credit wins).
const byCustomer = new Map<
string,
(typeof credits)[number]
>();
for (const t of credits) {
if (!byCustomer.has(t.customer.id)) byCustomer.set(t.customer.id, t);
}
const total = byCustomer.size;
let sent = 0;
let skipped = 0;
let failed = 0;
for (const [, t] of byCustomer) {
const email = debug
? this.debugEmail()
: (t.customer.email ?? "").toLowerCase();
if (!email || !email.includes("@")) {
await this.recordAttempt({
notificationType: "PAYMENT_CONFIRMATION",
servicio: "CUSTOMERS",
customerId: t.customer.id,
customerName: t.customer.name,
customerEmail: email || "(missing)",
subject: "Jorge Cuadros - Payment Confirmation",
bodySnapshot: "(skipped: no email)",
status: "SKIPPED_NO_EMAIL",
debug,
});
skipped++;
continue;
}
const typeOfTrx = t.type?.nameEn ?? t.type?.nameEs ?? "PAYMENT";
const body = renderPaymentConfirm({
customerId: t.customer.id,
customerName: t.customer.name,
typeOfTrx,
reference: t.reference,
amount: t.amount.toFixed(2),
year: new Date().getFullYear(),
});
// The PHP script stored the per-customer URL on the log row verbatim;
// we preserve the convention with a synthetic URL string. This is the
// single piece of legacy data the new log carries that does not come
// from a real fetch — a tag, not a request.
const bodyRequestUrl = `payment-confirmation://${t.customer.id}/${encodeURIComponent(
typeOfTrx,
)}/${t.id}`;
const attempt = await this.deliver({
notificationType: "PAYMENT_CONFIRMATION",
servicio: "CUSTOMERS",
customerId: t.customer.id,
customerName: t.customer.name,
customerEmail: email,
subject: "Jorge Cuadros - Payment Confirmation",
bodySnapshot: body,
bodyRequestUrl,
debug,
});
if (attempt === "SENT") sent++;
else if (attempt === "SKIPPED_NO_EMAIL" || attempt === "SKIPPED_GATE") skipped++;
else failed++;
}
const response: NotificationJobResponse = {
request: "success",
notificationType: "sendPaymentConfirmation",
confirmationSent: `${sent} of ${total}`,
statusCode: 200,
sent,
skipped,
failed,
debug,
type: "PAYMENT_CONFIRMATION",
};
await this.adminSummary("Jorge Cuadros - Payment Confirmations Sent", response);
return response;
}
/** Job 3 — Account status. Day gates + per-currency balance + dual
* threshold (yellow/red). The most complex of the four jobs. */
async runAccountStatus(flags: {
debug?: boolean;
ignoreDayRestriction?: boolean;
useEmailLimit?: boolean;
}): Promise<NotificationJobResponse> {
const debug = !!flags.debug;
const ignoreDayRestriction = !!flags.ignoreDayRestriction;
const useEmailLimit = !!flags.useEmailLimit;
const today = new Date()
.toLocaleString("en-US", { weekday: "short", timeZone: "America/Tijuana" })
.slice(0, 3) as "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun";
const redDay = today === "Mon" || today === "Wed" || today === "Fri";
const yellowDay = today === "Wed";
const gateOpen = ignoreDayRestriction || redDay || yellowDay;
// Pull every customer with a non-empty email and at least one movement
// that contributes to the balance (voided + outstanding excluded, same
// as `BillingService.balances()`).
const rows = await this.prisma.$queryRaw<
{
id: string;
name: string;
email: string;
balanceMxn: Prisma.Decimal | null;
balanceUsd: Prisma.Decimal | null;
}[]
>`
SELECT
c.id,
c.name,
c.email,
SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END) AS balanceMxn,
SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END) AS balanceUsd
FROM customers c
JOIN transactions t ON t.customerId = c.id
WHERE c.archivedAt IS NULL
AND c.email IS NOT NULL AND c.email <> ''
AND t.voidedAt IS NULL AND t.outstanding = 0
GROUP BY c.id, c.name, c.email
`;
const total = rows.length;
let red = 0;
let yellow = 0;
let sent = 0;
let failed = 0;
let skipped = 0;
let emailsThisRun = 0;
for (const row of rows) {
// Skip customers with no email — should never happen because of the
// WHERE clause, but defends against a row that gets archived between
// the SQL and the loop.
const rawEmail = row.email ?? "";
const email = debug ? this.debugEmail() : rawEmail.toLowerCase();
if (!email || !email.includes("@")) {
await this.recordAttempt({
notificationType: "ACCOUNT_STATUS",
servicio: "CUSTOMERS",
customerId: row.id,
customerName: row.name,
customerEmail: rawEmail || "(missing)",
subject: "Jorge Cuadros - Account Status Alert",
bodySnapshot: "(skipped: no email)",
status: "SKIPPED_NO_EMAIL",
debug,
});
skipped++;
continue;
}
// The unified ledger is per-currency and never collapsed. The legacy
// `TIPO` mapped 1:1 to USD (TIPO=50 / 100 / 200 / 300 / 500 were all
// USD thresholds with TIPO/TIPODECAMBIO implied). The simplest port
// is: convert every customer's MXN balance to USD using today's
// effective rate and apply the USD thresholds; a customer whose
// balance is genuinely USD-denominated uses balanceUsd directly.
// For the office's actual data this is "report in USD", which is
// what `Customer.minimumBalance` was set up for.
const mxn = row.balanceMxn ? Number(row.balanceMxn.toString()) : 0;
const usd = row.balanceUsd ? Number(row.balanceUsd.toString()) : 0;
// Pick the currency that holds the bulk of the debt: USD preferred
// because the legacy letter was always USD.
const balance = usd !== 0 ? usd : mxn;
// Apply both thresholds (yellow + red). The PHP script sent both
// on a Wed: yellow + red, two emails, two log rows. We match that.
// Customer.minimumBalance is the new-schema replacement for TIPO.
const minBalance = await this.customerMinimum(row.id, balance);
// RED first: a negative balance is always red. The yellow check
// requires balance >= 0, so the two never co-fire on the same row.
let firedRed = false;
let firedYellow = false;
if (balance < 0) {
if (!redDay && !ignoreDayRestriction) {
await this.recordAttempt({
notificationType: "ACCOUNT_STATUS",
level: 1,
servicio: "CUSTOMERS",
customerId: row.id,
customerName: row.name,
customerEmail: email,
subject: "Jorge Cuadros - Account Status Alert",
bodySnapshot: "(skipped: red day gate)",
status: "SKIPPED_GATE",
debug,
});
skipped++;
} else {
const ok = await this.sendAccountStatusAlert({
customerId: row.id,
customerName: row.name,
customerEmail: email,
balance,
level: 1,
tipo: balance, // red: "rush this much USD"
minBalance,
debug,
});
if (ok) {
red++;
sent++;
firedRed = true;
} else {
failed++;
}
}
}
if (balance >= 0 && minBalance !== null && balance < minBalance) {
if (!yellowDay && !ignoreDayRestriction) {
await this.recordAttempt({
notificationType: "ACCOUNT_STATUS",
level: 0,
servicio: "CUSTOMERS",
customerId: row.id,
customerName: row.name,
customerEmail: email,
subject: "Jorge Cuadros - Account Status Alert",
bodySnapshot: "(skipped: yellow day gate)",
status: "SKIPPED_GATE",
debug,
});
skipped++;
} else {
const ok = await this.sendAccountStatusAlert({
customerId: row.id,
customerName: row.name,
customerEmail: email,
balance,
level: 0,
tipo: minBalance - balance, // yellow: "top up to minimum"
minBalance,
debug,
});
if (ok) {
yellow++;
sent++;
firedYellow = true;
} else {
failed++;
}
}
}
if (!firedRed && !firedYellow) continue;
// Vestigial SMTP-era throttle (preserved for parity). Off by default;
// the controller wires useEmailLimit=true only when staff opt in.
if (useEmailLimit) {
emailsThisRun += firedRed ? 1 : 0;
emailsThisRun += firedYellow ? 1 : 0;
if (emailsThisRun >= RATE_LIMIT_EMAILS) {
this.logger.warn("SMTP email send limit reached, sleeping 1 hour.");
await new Promise((r) => setTimeout(r, ONE_HOUR_MS));
emailsThisRun = 0;
this.logger.warn("Resuming send account status emails!");
}
}
}
const response: NotificationJobResponse = {
request: "success",
notificationType: "sendAccountStatus",
statusSent: `Sent ${red} Red Emails and ${yellow} Yellow Emails of ${total} customers.`,
statusReport: "https://cpanel.jorgecuadros.com/show_email_log.php",
statusCode: 200,
red,
yellow,
total,
sent,
skipped,
failed,
debug,
type: "ACCOUNT_STATUS",
};
await this.adminSummary("Jorge Cuadros - Account Status Alerts Sent", response);
return response;
}
/** Job 4 — Trust payment confirmation. */
async runTrustConfirmation(flags: {
debug?: boolean;
ignoreDayRestriction?: boolean;
useEmailLimit?: boolean;
}): Promise<NotificationJobResponse> {
const debug = !!flags.debug;
const since = new Date(Date.now() - PAYMENT_LOOKBACK_HOURS * 3600_000);
// Latest credit per TrustAccount-bearing customer in the lookback
// window. The PHP script pulled from `TRUSTHFEE` directly; the
// equivalent here is: customer owns a property with a trust account
// AND has a credit in the trust domain in the last 24h.
const credits = await this.prisma.transaction.findMany({
where: {
amount: { gt: 0 },
domain: TransactionDomain.TRUST,
voidedAt: null,
transactionDate: { gte: since },
customer: { archivedAt: null },
},
orderBy: { transactionDate: "desc" },
select: {
id: true,
amount: true,
currency: true,
customer: {
select: {
id: true,
name: true,
email: true,
properties: {
where: { archivedAt: null },
select: {
trustAccount: { select: { id: true, trustNumber: true } },
},
},
},
},
},
});
// One row per customer (most recent credit wins). Drop customers with
// no trust account — they have nothing to confirm against.
const byCustomer = new Map<
string,
(typeof credits)[number]
>();
for (const t of credits) {
if (!t.customer.properties.some((p) => p.trustAccount)) continue;
if (!byCustomer.has(t.customer.id)) byCustomer.set(t.customer.id, t);
}
const total = byCustomer.size;
let sent = 0;
let skipped = 0;
let failed = 0;
for (const [, t] of byCustomer) {
const email = debug
? this.debugEmail()
: (t.customer.email ?? "").toLowerCase();
if (!email || !email.includes("@")) {
await this.recordAttempt({
notificationType: "TRUST_PAYMENT_CONFIRMATION",
servicio: "TRUST",
customerId: t.customer.id,
customerName: t.customer.name,
customerEmail: email || "(missing)",
subject: "Jorge Cuadros - Trust Payment Confirmation",
bodySnapshot: "(skipped: no email)",
status: "SKIPPED_NO_EMAIL",
debug,
});
skipped++;
continue;
}
const body = renderTrustConfirm({
customerId: t.customer.id,
customerName: t.customer.name,
amount: t.amount.toFixed(2),
year: new Date().getFullYear(),
});
const attempt = await this.deliver({
notificationType: "TRUST_PAYMENT_CONFIRMATION",
servicio: "TRUST",
customerId: t.customer.id,
customerName: t.customer.name,
customerEmail: email,
subject: "Jorge Cuadros - Trust Payment Confirmation",
bodySnapshot: body,
debug,
});
if (attempt === "SENT") sent++;
else if (attempt === "SKIPPED_NO_EMAIL" || attempt === "SKIPPED_GATE") skipped++;
else failed++;
}
const response: NotificationJobResponse = {
request: "success",
notificationType: "sendTrustPaymentConfirmation",
confirmationSent: `${sent} of ${total}`,
statusCode: 200,
sent,
skipped,
failed,
debug,
type: "TRUST_PAYMENT_CONFIRMATION",
};
await this.adminSummary("Jorge Cuadros - Trust Payment Confirmations Sent", response);
return response;
}
/* ============================================================================
* Log browser — list / drill-down for the UI.
* ========================================================================== */
/** Recent notification log rows, newest first, with optional filters. */
async listLog(params: {
page: number;
pageSize: number;
type?: EmailNotificationType;
servicio?: EmailNotificationServicio;
status?: EmailNotificationStatus;
customerId?: string;
}) {
const where: Prisma.EmailNotificationLogWhereInput = {};
if (params.type) where.notificationType = params.type;
if (params.servicio) where.servicio = params.servicio;
if (params.status) where.status = params.status;
if (params.customerId) where.customerId = params.customerId;
const [total, rows] = await this.prisma.$transaction([
this.prisma.emailNotificationLog.count({ where }),
this.prisma.emailNotificationLog.findMany({
where,
orderBy: { sendDate: "desc" },
skip: (params.page - 1) * params.pageSize,
take: params.pageSize,
select: {
id: true,
sendDate: true,
notificationType: true,
level: true,
servicio: true,
customerId: true,
customerName: true,
customerEmail: true,
subject: true,
debug: true,
status: true,
providerMessageId: true,
error: true,
},
}),
]);
return {
items: rows,
total,
page: params.page,
pageSize: params.pageSize,
pageCount: Math.ceil(total / params.pageSize),
};
}
/** Per-type + per-status counts for the dashboard header. */
async stats() {
const [byType, byStatus, byServicio, lastRun] = await Promise.all([
this.prisma.emailNotificationLog.groupBy({
by: ["notificationType", "status"],
_count: { _all: true },
}),
this.prisma.emailNotificationLog.groupBy({
by: ["status"],
_count: { _all: true },
}),
this.prisma.emailNotificationLog.groupBy({
by: ["servicio", "status"],
_count: { _all: true },
}),
this.prisma.emailNotificationLog.findFirst({
orderBy: { sendDate: "desc" },
select: { sendDate: true, notificationType: true },
}),
]);
return {
byType,
byStatus,
byServicio,
lastRun,
transport: {
available: this.mail.available,
devFallback: this.mail.isDevFallback,
},
};
}
/* ============================================================================
* Internals — send / log / balance helpers.
* ========================================================================== */
/** Where debug=1 sends everything. The PHP used
* `rmancinas@freakma.net`; same here. */
private debugEmail(): string {
return "rmancinas@freakma.net";
}
/** The customer's `minimumBalance`, or null if unset. The legacy TIPO
* was 50/100/200/300/500; the new schema encodes this as
* `Customer.minimumBalance` (the office already sets it per row). */
private async customerMinimum(
customerId: string,
_balance: number,
): Promise<number | null> {
const c = await this.prisma.customer.findUnique({
where: { id: customerId },
select: { minimumBalance: true },
});
if (!c?.minimumBalance) return null;
const m = Number(c.minimumBalance.toString());
return isFinite(m) && m > 0 ? m : null;
}
/** Send one account-status alert + write the parallel history row. */
private async sendAccountStatusAlert(args: {
customerId: string;
customerName: string;
customerEmail: string;
balance: number;
tipo: number;
level: 0 | 1;
minBalance: number | null;
debug: boolean;
}): Promise<boolean> {
const body = renderAccountStatus({
customerId: args.customerId,
customerName: args.customerName,
level: args.level,
balance: args.balance.toFixed(2),
tipo: args.tipo.toFixed(2),
year: new Date().getFullYear(),
});
const status = await this.deliver({
notificationType: "ACCOUNT_STATUS",
level: args.level,
servicio: "CUSTOMERS",
customerId: args.customerId,
customerName: args.customerName,
customerEmail: args.customerEmail,
subject: "Jorge Cuadros - Account Status Alert",
bodySnapshot: body,
debug: args.debug,
});
if (status === "SENT") {
// Mirrors `utility_dbo.send_account_status_history`. The legacy
// SOLICITADO formula was `0 - TIPO - BALANCE`; for yellow that
// simplifies to `minBalance - balance` (top-up amount), for red to
// `|balance|` (rush amount). We preserve the legacy `tipo` column
// as the human-readable label so downstream reports keep working.
await this.prisma.accountStatusHistory.create({
data: {
customerId: args.customerId,
customerName: args.customerName,
customerEmail: args.customerEmail,
tipo: args.level === 0 ? "DEBAJO DEL TIPO" : "EN ROJO",
balance: new Prisma.Decimal(args.balance.toFixed(2)),
solicitado: new Prisma.Decimal(
(args.level === 0
? args.minBalance! - args.balance
: Math.abs(args.balance)
).toFixed(2),
),
level: args.level,
},
});
return true;
}
return false;
}
/** Send one email + write the log row. Returns the final attempt
* status (SENT / FAILED / SKIPPED_*) so the caller can update its
* counters without re-querying the DB. */
private async deliver(attempt: SendAttempt & { debug: boolean }): Promise<AttemptStatus> {
try {
const { messageId, response } = await this.mail.send({
to: attempt.customerEmail,
toName: attempt.customerName,
subject: attempt.subject,
html: attempt.bodySnapshot,
xTracking: attempt.debug ? "debug" : "1",
});
await this.recordAttempt({
...attempt,
providerMessageId: messageId || undefined,
providerResponse: response || undefined,
status: "SENT",
});
return "SENT";
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// ServiceUnavailableException means no transport was configured and
// we are not in dev — surface that as a clear FAILED row rather than
// letting it abort the whole job mid-loop.
this.logger.warn(
`Notification send failed for ${attempt.customerName} <${attempt.customerEmail}>: ${message}`,
);
await this.recordAttempt({
...attempt,
status: "FAILED",
error: message.slice(0, 4096),
});
return "FAILED";
}
}
/** Persist one notification log row. */
private async recordAttempt(args: {
notificationType: EmailNotificationType;
servicio: EmailNotificationServicio;
customerId: string | null;
customerName: string;
customerEmail: string;
subject: string;
bodySnapshot: string;
bodyRequestUrl?: string;
level?: 0 | 1;
status: AttemptStatus;
debug: boolean;
providerMessageId?: string;
providerResponse?: string;
error?: string;
}) {
await this.prisma.emailNotificationLog.create({
data: {
notificationType: args.notificationType,
servicio: args.servicio,
level: args.level ?? null,
customerId: args.customerId,
customerName: args.customerName,
customerEmail: args.customerEmail,
subject: args.subject,
bodySnapshot: args.bodySnapshot,
bodyRequestUrl: args.bodyRequestUrl ?? null,
debug: args.debug,
providerMessageId: args.providerMessageId ?? null,
providerResponse: args.providerResponse ?? null,
status: args.status as EmailNotificationStatus,
error: args.error ?? null,
},
});
}
/** Send the admin summary email after every job. The PHP sent one to
* each admin address; we do the same. Body = JSON-stringified
* response so it matches the legacy format verbatim. */
private async adminSummary(subject: string, response: NotificationJobResponse) {
const body = JSON.stringify(response);
for (const to of this.adminEmails) {
try {
const { messageId } = await this.mail.send({
to,
toName: "Jorge Cuadros Admin",
subject,
html: `<pre style="font-family:monospace;font-size:12px;">${body.replace(
/[<>&]/g,
(c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] ?? c,
)}</pre>`,
xTracking: "admin-summary",
});
this.logger.log(
`Admin summary sent to ${to}: subject="${subject}" id=${messageId}`,
);
} catch (err) {
this.logger.warn(
`Admin summary to ${to} failed: ${(err as Error).message}`,
);
}
}
}
/** Available for tests + future cron to assert before launching a sweep. */
get transportAvailable(): boolean {
return this.mail.available;
}
}