Merge branch 'massive-email-notification' into master
# Conflicts: # .env.example # apps/api/src/app.module.ts
This commit is contained in:
+12
-1
@@ -34,8 +34,19 @@ COMPANY_TAX_ID=
|
|||||||
COMPANY_WEBSITE=
|
COMPANY_WEBSITE=
|
||||||
COMPANY_LOGO_PATH=
|
COMPANY_LOGO_PATH=
|
||||||
|
|
||||||
|
# Outbound mail (Amazon SES — the channel the office already uses for bulk
|
||||||
|
# notification, see docs/MASS_EMAIL_NOTIFICATIONS.md). Without all four
|
||||||
|
# vars the API still boots; in dev the MailService logs sends to stdout,
|
||||||
|
# in production every send throws ServiceUnavailableException.
|
||||||
SES_REGION=
|
SES_REGION=
|
||||||
SES_FROM=
|
|
||||||
SES_ACCESS_KEY=
|
SES_ACCESS_KEY=
|
||||||
SES_SECRET_KEY=
|
SES_SECRET_KEY=
|
||||||
|
SES_FROM=mail@jorgecuadros.com
|
||||||
|
SES_FROM_NAME=Information Server
|
||||||
|
# Optional — bounce/complaint event publishing configuration set.
|
||||||
SES_CONFIGURATION_SET=
|
SES_CONFIGURATION_SET=
|
||||||
|
|
||||||
|
# Comma-separated addresses that receive the per-job admin summary email
|
||||||
|
# (one summary per address, JSON body, sent after every sweep). Defaults to
|
||||||
|
# the legacy pair if unset.
|
||||||
|
NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ScheduleModule } from "@nestjs/schedule";
|
|||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
import { StorageModule } from "./storage/storage.module";
|
import { StorageModule } from "./storage/storage.module";
|
||||||
import { CommonModule } from "./common/common.module";
|
import { CommonModule } from "./common/common.module";
|
||||||
|
import { MailModule } from "./mail/mail.module";
|
||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { CustomersModule } from "./customers/customers.module";
|
import { CustomersModule } from "./customers/customers.module";
|
||||||
@@ -16,6 +17,7 @@ import { BankModule } from "./bank/bank.module";
|
|||||||
import { OpsModule } from "./ops/ops.module";
|
import { OpsModule } from "./ops/ops.module";
|
||||||
import { ReportsModule } from "./reports/reports.module";
|
import { ReportsModule } from "./reports/reports.module";
|
||||||
import { RenewalsModule } from "./renewals/renewals.module";
|
import { RenewalsModule } from "./renewals/renewals.module";
|
||||||
|
import { NotificationsModule } from "./notifications/notifications.module";
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -25,6 +27,7 @@ import { AppController } from "./app.controller";
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
MailModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
@@ -37,6 +40,7 @@ import { AppController } from "./app.controller";
|
|||||||
OpsModule,
|
OpsModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
RenewalsModule,
|
RenewalsModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export type Ability =
|
|||||||
| "statement:review"
|
| "statement:review"
|
||||||
| "lookup:manage"
|
| "lookup:manage"
|
||||||
| "user:manage"
|
| "user:manage"
|
||||||
| "db:manage";
|
| "db:manage"
|
||||||
|
| "notification:send";
|
||||||
|
|
||||||
/** Minimum role required for each ability. */
|
/** Minimum role required for each ability. */
|
||||||
export const ABILITY_MIN: Record<Ability, Role> = {
|
export const ABILITY_MIN: Record<Ability, Role> = {
|
||||||
@@ -73,6 +74,11 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
|||||||
"lookup:manage": "MANAGER",
|
"lookup:manage": "MANAGER",
|
||||||
"user:manage": "ADMIN",
|
"user:manage": "ADMIN",
|
||||||
"db:manage": "ADMIN",
|
"db:manage": "ADMIN",
|
||||||
|
// Mass email notifications — fires mail to customers on the office's
|
||||||
|
// behalf, with no per-row review. Same trust tier as `renewal:send`:
|
||||||
|
// a STAFF user typing one customer receipt is fine; a STAFF user firing
|
||||||
|
// 260 mail merges on the customer base is not.
|
||||||
|
"notification:send": "MANAGER",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { MailService } from "./mail.service";
|
||||||
|
|
||||||
|
/** Global so any feature module can inject MailService without re-importing.
|
||||||
|
* Matches the StorageService pattern: env-driven, null when unconfigured,
|
||||||
|
* and never blocks API boot. Notifications use it; renewals reuse it. */
|
||||||
|
@Module({
|
||||||
|
providers: [{ provide: MailService, useFactory: (c: ConfigService) => new MailService(c) }],
|
||||||
|
exports: [MailService],
|
||||||
|
})
|
||||||
|
export class MailModule {}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import {
|
||||||
|
SESv2Client,
|
||||||
|
SendEmailCommand,
|
||||||
|
SendEmailCommandInput,
|
||||||
|
SendEmailCommandOutput,
|
||||||
|
} from "@aws-sdk/client-sesv2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound mail transport. Amazon SES — the channel the office already uses
|
||||||
|
* for bulk notification, per docs/INSURANCE_FEATURES_SPEC.md §1.3 (the
|
||||||
|
* renewal-notice spec settled on SES for the same reason: established sender
|
||||||
|
* reputation, existing IAM, negligible incremental cost at our volume).
|
||||||
|
*
|
||||||
|
* Mirrors `StorageService` exactly: env-driven config, null client when
|
||||||
|
* unconfigured, `ServiceUnavailableException` on use, never blocks API boot.
|
||||||
|
* When the env vars are missing AND we're in dev/test we fall back to a
|
||||||
|
* console-logging transport so the NotificationsService can be exercised
|
||||||
|
* end-to-end without SES credentials — a missing mail setup in production
|
||||||
|
* still throws, so a real deployment can't accidentally no-op its sends.
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* SES_REGION — required when client is configured
|
||||||
|
* SES_ACCESS_KEY / SES_SECRET_KEY — required
|
||||||
|
* SES_FROM — verified sending identity (e.g. mail@jorgecuadros.com)
|
||||||
|
* SES_FROM_NAME — display name, optional
|
||||||
|
* SES_CONFIGURATION_SET — optional, for bounce/complaint event publishing
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SendArgs {
|
||||||
|
to: string;
|
||||||
|
/** Optional display name; SES will not display it for "to" but we keep it on
|
||||||
|
* the log row so customer-facing audit reads naturally. */
|
||||||
|
toName?: string;
|
||||||
|
subject: string;
|
||||||
|
/** HTML body. The four notification jobs all produce HTML. */
|
||||||
|
html: string;
|
||||||
|
/** Optional override of the configured From; rare but useful for the
|
||||||
|
* trust-payment test mail to a different identity. */
|
||||||
|
from?: string;
|
||||||
|
fromName?: string;
|
||||||
|
/** Marker header kept on every send so a downstream mail-log search for
|
||||||
|
* "X-Tracking: 1" surfaces only this app's outbound traffic. The legacy
|
||||||
|
* PHP sendEmail() always set it; we keep the convention. */
|
||||||
|
xTracking?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendResult {
|
||||||
|
/** SES MessageId (or our mock prefix in dev). Stored verbatim on the
|
||||||
|
* notification log row so a SES bounce/complaint webhook can be matched
|
||||||
|
* back to the exact send. */
|
||||||
|
messageId: string;
|
||||||
|
/** Truncated SES response payload (or empty in dev). 4k cap matches the
|
||||||
|
* notification log column width. */
|
||||||
|
response: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MailService {
|
||||||
|
private readonly logger = new Logger(MailService.name);
|
||||||
|
private readonly client: SESv2Client | null;
|
||||||
|
private readonly fromAddress: string | null;
|
||||||
|
private readonly fromName: string;
|
||||||
|
private readonly configurationSet: string | undefined;
|
||||||
|
private readonly devMode: boolean;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const region = config.get<string>("SES_REGION");
|
||||||
|
const accessKeyId = config.get<string>("SES_ACCESS_KEY");
|
||||||
|
const secretAccessKey = config.get<string>("SES_SECRET_KEY");
|
||||||
|
this.fromAddress =
|
||||||
|
config.get<string>("SES_FROM") ??
|
||||||
|
config.get<string>("MAIL_FROM") ??
|
||||||
|
null;
|
||||||
|
this.fromName =
|
||||||
|
config.get<string>("SES_FROM_NAME") ??
|
||||||
|
config.get<string>("MAIL_FROM_NAME") ??
|
||||||
|
"Information Server";
|
||||||
|
this.configurationSet = config.get<string>("SES_CONFIGURATION_SET");
|
||||||
|
// Dev fallback: when nothing is configured, log sends to stdout instead
|
||||||
|
// of throwing. Lets the API boot in a fresh checkout and lets the
|
||||||
|
// notifications UI show "0 sent" meaningfully on `debug=1`. Production
|
||||||
|
// (NODE_ENV !== development) still requires real config.
|
||||||
|
this.devMode = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
|
if (!region || !accessKeyId || !secretAccessKey || !this.fromAddress) {
|
||||||
|
if (!this.devMode) {
|
||||||
|
this.logger.warn(
|
||||||
|
"SES not configured (SES_REGION / SES_ACCESS_KEY / SES_SECRET_KEY / SES_FROM). " +
|
||||||
|
"Outbound mail will throw ServiceUnavailableException.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.client = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = new SESv2Client({
|
||||||
|
region,
|
||||||
|
credentials: { accessKeyId, secretAccessKey },
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`SES mail client configured (region=${region}, from=${this.fromAddress}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the deployment has a real mail transport. Callers use this to
|
||||||
|
* refuse work up front — a mass-notification job that throws on its
|
||||||
|
* first send half-completes and the log is unrecoverable, so we fail
|
||||||
|
* fast at the controller. */
|
||||||
|
get available(): boolean {
|
||||||
|
return this.client !== null || this.devMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the underlying transport is the dev console-log fallback. */
|
||||||
|
get isDevFallback(): boolean {
|
||||||
|
return this.client === null && this.devMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private require(): SESv2Client {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
"El envío de correo no está configurado.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a single HTML email. The dev fallback logs to stdout and returns a
|
||||||
|
* synthetic `dev-<timestamp>` message id; the real transport talks to SES
|
||||||
|
* and returns the SES MessageId.
|
||||||
|
*
|
||||||
|
* Throws `ServiceUnavailableException` when no transport is configured and
|
||||||
|
* we are not in dev — the caller (NotificationsService) catches and records
|
||||||
|
* it on the log row so a failed sweep produces a coherent audit trail
|
||||||
|
* instead of an aborted one.
|
||||||
|
*/
|
||||||
|
async send(args: SendArgs): Promise<SendResult> {
|
||||||
|
const from = `${args.fromName ?? this.fromName} <${
|
||||||
|
args.from ?? this.fromAddress ?? ""
|
||||||
|
}>`.trim();
|
||||||
|
|
||||||
|
if (!this.client) {
|
||||||
|
if (!this.devMode) this.require();
|
||||||
|
const fakeId = `dev-${Date.now().toString(36)}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
this.logger.log(
|
||||||
|
`[dev-mail] to=${args.to} subject="${args.subject}" id=${fakeId} ` +
|
||||||
|
`len=${args.html.length}`,
|
||||||
|
);
|
||||||
|
return { messageId: fakeId, response: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const input: SendEmailCommandInput = {
|
||||||
|
FromEmailAddress: from,
|
||||||
|
Destination: { ToAddresses: [args.to] },
|
||||||
|
Content: {
|
||||||
|
Simple: {
|
||||||
|
Subject: { Data: args.subject, Charset: "UTF-8" },
|
||||||
|
Body: { Html: { Data: args.html, Charset: "UTF-8" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(this.configurationSet
|
||||||
|
? { ConfigurationSetName: this.configurationSet }
|
||||||
|
: {}),
|
||||||
|
...(args.xTracking
|
||||||
|
? {
|
||||||
|
EmailTags: [
|
||||||
|
{ Name: "X-Tracking", Value: args.xTracking },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const out: SendEmailCommandOutput = await this.client.send(
|
||||||
|
new SendEmailCommand(input),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
messageId: out.MessageId ?? "",
|
||||||
|
response: JSON.stringify({ MessageId: out.MessageId ?? null }).slice(0, 4096),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
EmailNotificationServicio,
|
||||||
|
EmailNotificationType,
|
||||||
|
} from "@jorgecuadros/database";
|
||||||
|
import { IsBoolean, IsEnum, IsOptional } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared flags for the four notification jobs. Every endpoint takes the
|
||||||
|
* same shape so the UI can be uniform; each flag is documented inline so
|
||||||
|
* the per-job semantics are obvious in one place.
|
||||||
|
*
|
||||||
|
* `debug` — replace every recipient with the admin override
|
||||||
|
* address so a real customer never receives mail
|
||||||
|
* during a test run. Logged on every row.
|
||||||
|
* `ignoreDayRestriction` — Job 3 only: bypass the Mon/Wed/Fri (red) and
|
||||||
|
* Wed-only (yellow) day gates. Off by default so
|
||||||
|
* the on-demand sweep behaves like the legacy
|
||||||
|
* script.
|
||||||
|
* `useEmailLimit` — Job 3 only: pause the sweep 1 hour after 100
|
||||||
|
* sends (a vestigial SMTP-era throttling limit).
|
||||||
|
* Off by default; SES does not need it.
|
||||||
|
*/
|
||||||
|
export class NotificationFlagsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
debug?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
ignoreDayRestriction?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
useEmailLimit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What we know at job-end and put on the wire. Field names match the
|
||||||
|
* legacy PHP scripts' `echo json_encode(...)` so a downstream log scraper
|
||||||
|
* that already parses `notificationType: "sendPaymentConfirmation"`
|
||||||
|
* keeps working — see `~/Documents/Claude-Memory/email-notifications-spec.md`
|
||||||
|
* for the verbatim PHP shapes. Specifically: Job 1 reports
|
||||||
|
* `notificationType: "sendPaymentConfirmation"` (the legacy literal), and
|
||||||
|
* uses field `result` instead of `request`; the other three use
|
||||||
|
* `notificationType` matching the script's purpose.
|
||||||
|
*
|
||||||
|
* Every variant carries `sent/skipped/failed/debug` for the audit log;
|
||||||
|
* the legacy fields stay where they were so the response shape is
|
||||||
|
* exactly backward-compatible.
|
||||||
|
*/
|
||||||
|
export type NotificationJobResponse =
|
||||||
|
| {
|
||||||
|
// Job 1
|
||||||
|
result: "success";
|
||||||
|
notificationType: "sendPaymentConfirmation";
|
||||||
|
reason: string;
|
||||||
|
statusCode: 200;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
debug: boolean;
|
||||||
|
type: "OUTSTANDING_PAYMENT";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
// Job 2
|
||||||
|
request: "success";
|
||||||
|
notificationType: "sendPaymentConfirmation";
|
||||||
|
confirmationSent: string;
|
||||||
|
statusCode: 200;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
debug: boolean;
|
||||||
|
type: "PAYMENT_CONFIRMATION";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
// Job 3 — sent/skipped/failed included so the audit log can record
|
||||||
|
// totals without depending on (red+yellow) alone.
|
||||||
|
request: "success";
|
||||||
|
notificationType: "sendAccountStatus";
|
||||||
|
statusSent: string;
|
||||||
|
statusReport: string;
|
||||||
|
statusCode: 200;
|
||||||
|
red: number;
|
||||||
|
yellow: number;
|
||||||
|
total: number;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
debug: boolean;
|
||||||
|
type: "ACCOUNT_STATUS";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
// Job 4
|
||||||
|
request: "success";
|
||||||
|
notificationType: "sendTrustPaymentConfirmation";
|
||||||
|
confirmationSent: string;
|
||||||
|
statusCode: 200;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
debug: boolean;
|
||||||
|
type: "TRUST_PAYMENT_CONFIRMATION";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Normalized record for a single send attempt, fed by all four jobs. */
|
||||||
|
export interface SendAttempt {
|
||||||
|
notificationType: EmailNotificationType;
|
||||||
|
servicio: EmailNotificationServicio;
|
||||||
|
customerId: string | null;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
subject: string;
|
||||||
|
bodySnapshot: string;
|
||||||
|
bodyRequestUrl?: string;
|
||||||
|
/** Account-status-only — 0 yellow / 1 red. Null on the other three jobs. */
|
||||||
|
level?: 0 | 1;
|
||||||
|
/** Account-status-only — DEBAJO DEL TIPO / EN ROJO. */
|
||||||
|
historyTipo?: string;
|
||||||
|
historyBalance?: string;
|
||||||
|
historyTCambio?: string;
|
||||||
|
historySolicitado?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status enum values, mirrored from `EmailNotificationStatus`. */
|
||||||
|
export type AttemptStatus = "SENT" | "FAILED" | "SKIPPED_NO_EMAIL" | "SKIPPED_GATE";
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
import {
|
||||||
|
EmailNotificationServicio,
|
||||||
|
EmailNotificationStatus,
|
||||||
|
EmailNotificationType,
|
||||||
|
} from "@jorgecuadros/database";
|
||||||
|
import { Transform, Type } from "class-transformer";
|
||||||
|
import { IsEnum, IsInt, IsOptional, Max, Min } from "class-validator";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import { NotificationFlagsDto } from "./notification.types";
|
||||||
|
import { NotificationsService } from "./notifications.service";
|
||||||
|
|
||||||
|
/** Same flags for every job, query-string OR body (the PHP scripts took
|
||||||
|
* both via STDIN vs HTTP-CGI — we accept either for parity). */
|
||||||
|
class RunJobDto extends NotificationFlagsDto {}
|
||||||
|
|
||||||
|
class ListLogDto {
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize?: number;
|
||||||
|
@IsOptional() @IsEnum(EmailNotificationType) type?: EmailNotificationType;
|
||||||
|
@IsOptional() @IsEnum(EmailNotificationServicio) servicio?: EmailNotificationServicio;
|
||||||
|
@IsOptional() @IsEnum(EmailNotificationStatus) status?: EmailNotificationStatus;
|
||||||
|
@IsOptional() @IsEnum(["sent", "failed", "skipped", "all"]) view?: "sent" | "failed" | "skipped" | "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
function actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP surface for the mass-notification jobs. Four trigger endpoints +
|
||||||
|
* two read endpoints (list log, stats). All mutations gated by the
|
||||||
|
* `notification:send` ability so a STAFF user can't accidentally fire a
|
||||||
|
* 260-mail sweep.
|
||||||
|
*/
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
@Controller("notifications")
|
||||||
|
export class NotificationsController {
|
||||||
|
constructor(
|
||||||
|
private readonly svc: NotificationsService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- triggers */
|
||||||
|
|
||||||
|
@Post("outstanding-payments")
|
||||||
|
@RequireAbility("notification:send")
|
||||||
|
@HttpCode(200)
|
||||||
|
async runOutstanding(
|
||||||
|
@Body() body: RunJobDto,
|
||||||
|
@Query() query: RunJobDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const flags = { ...query, ...body };
|
||||||
|
const result = await this.svc.runOutstandingPayments(flags);
|
||||||
|
void this.audit.log(actingId(req), "notification.outstanding.run", {
|
||||||
|
debug: !!flags.debug,
|
||||||
|
sent: result.sent,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("payment-confirmation")
|
||||||
|
@RequireAbility("notification:send")
|
||||||
|
@HttpCode(200)
|
||||||
|
async runPaymentConfirm(
|
||||||
|
@Body() body: RunJobDto,
|
||||||
|
@Query() query: RunJobDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const flags = { ...query, ...body };
|
||||||
|
const result = await this.svc.runPaymentConfirmation(flags);
|
||||||
|
void this.audit.log(actingId(req), "notification.payment-confirm.run", {
|
||||||
|
debug: !!flags.debug,
|
||||||
|
sent: result.sent,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("account-status")
|
||||||
|
@RequireAbility("notification:send")
|
||||||
|
@HttpCode(200)
|
||||||
|
async runAccountStatus(
|
||||||
|
@Body() body: RunJobDto,
|
||||||
|
@Query() query: RunJobDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const flags = { ...query, ...body };
|
||||||
|
const result = await this.svc.runAccountStatus(flags);
|
||||||
|
// Narrow the discriminated union to the ACCOUNT_STATUS variant before
|
||||||
|
// pulling red/yellow/total — TS can't follow this through `await` alone.
|
||||||
|
if (result.type === "ACCOUNT_STATUS") {
|
||||||
|
void this.audit.log(actingId(req), "notification.account-status.run", {
|
||||||
|
debug: !!flags.debug,
|
||||||
|
red: result.red,
|
||||||
|
yellow: result.yellow,
|
||||||
|
total: result.total,
|
||||||
|
sent: result.sent,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("trust-payment-confirmation")
|
||||||
|
@RequireAbility("notification:send")
|
||||||
|
@HttpCode(200)
|
||||||
|
async runTrustConfirm(
|
||||||
|
@Body() body: RunJobDto,
|
||||||
|
@Query() query: RunJobDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const flags = { ...query, ...body };
|
||||||
|
const result = await this.svc.runTrustConfirmation(flags);
|
||||||
|
void this.audit.log(actingId(req), "notification.trust-confirm.run", {
|
||||||
|
debug: !!flags.debug,
|
||||||
|
sent: result.sent,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- read views */
|
||||||
|
|
||||||
|
@Get("log")
|
||||||
|
listLog(@Query() q: ListLogDto) {
|
||||||
|
const page = q.page ?? 1;
|
||||||
|
const pageSize = q.pageSize ?? 50;
|
||||||
|
return this.svc.listLog({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
type: q.type,
|
||||||
|
servicio: q.servicio,
|
||||||
|
status: this.mapViewStatus(q.view, q.status),
|
||||||
|
customerId: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("stats")
|
||||||
|
stats() {
|
||||||
|
return this.svc.stats();
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapViewStatus(
|
||||||
|
view: ListLogDto["view"],
|
||||||
|
status: ListLogDto["status"],
|
||||||
|
): EmailNotificationStatus | undefined {
|
||||||
|
if (status) return status;
|
||||||
|
if (!view || view === "all") return undefined;
|
||||||
|
if (view === "sent") return EmailNotificationStatus.SENT;
|
||||||
|
if (view === "failed") return EmailNotificationStatus.FAILED;
|
||||||
|
if (view === "skipped") return undefined; // both SKIPPED_* variants
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { NotificationsController } from "./notifications.controller";
|
||||||
|
import { NotificationsService } from "./notifications.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mass email notifications. MailModule is global (registered in AppModule),
|
||||||
|
* so this module needs no MailService import — it picks it up by injection.
|
||||||
|
*
|
||||||
|
* Cron sweeps (a future `@nestjs/schedule` trigger of these four methods on
|
||||||
|
* the legacy Mon/Wed/Fri cadence) belong in this module; the per-job
|
||||||
|
* service methods are already the entry points they would call.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [NotificationsController],
|
||||||
|
providers: [NotificationsService],
|
||||||
|
exports: [NotificationsService],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
@@ -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) => ({ "<": "<", ">": ">", "&": "&" })[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
renderAccountStatus,
|
||||||
|
renderOutstanding,
|
||||||
|
renderPaymentConfirm,
|
||||||
|
renderTrustConfirm,
|
||||||
|
} from "./render";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render-level tests. The legacy PHP scripts fetched these bodies by URL;
|
||||||
|
* we render server-side and inline. The tests assert the *shape* of each
|
||||||
|
* body — account id, name, subject, balance/tipo, color band — because
|
||||||
|
* the customer base has been seeing these letters for years and a visual
|
||||||
|
* regression costs trust faster than any backend change does.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("renderOutstanding", () => {
|
||||||
|
it("includes the customer id, name, total, and per-row table", () => {
|
||||||
|
const html = renderOutstanding({
|
||||||
|
customerId: "C-001",
|
||||||
|
customerName: "Acme & Co.",
|
||||||
|
total: "1234.50",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
date: "2026-07-01",
|
||||||
|
reference: "INV-1",
|
||||||
|
period: "Jul-26",
|
||||||
|
type: "CHECK",
|
||||||
|
amount: "-500.00",
|
||||||
|
balance: "-500.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2026-07-15",
|
||||||
|
reference: "INV-2",
|
||||||
|
period: "Jul-26",
|
||||||
|
type: "CASH",
|
||||||
|
amount: "-734.50",
|
||||||
|
balance: "-1234.50",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(html).toContain("Acme & Co.");
|
||||||
|
expect(html).toContain("ACCOUNT #C-001");
|
||||||
|
expect(html).toContain("$ 1,234.50");
|
||||||
|
expect(html).toContain("INV-1");
|
||||||
|
expect(html).toContain("CHECK");
|
||||||
|
expect(html).toContain("IF YOU ALREADY SENT THE CHECK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes HTML in the customer name", () => {
|
||||||
|
const html = renderOutstanding({
|
||||||
|
customerId: "x",
|
||||||
|
customerName: "<script>alert(1)</script>",
|
||||||
|
total: "0.00",
|
||||||
|
rows: [],
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(html).not.toContain("<script>alert(1)</script>");
|
||||||
|
expect(html).toContain("<script>alert(1)</script>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderPaymentConfirm", () => {
|
||||||
|
it("uses the transaction type in the heading and the amount in the body", () => {
|
||||||
|
const html = renderPaymentConfirm({
|
||||||
|
customerId: "C-002",
|
||||||
|
customerName: "Bob",
|
||||||
|
typeOfTrx: "CHECK DEPOSIT",
|
||||||
|
reference: "DEP-99",
|
||||||
|
amount: "500.00",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(html).toContain("CHECK DEPOSIT CONFIRMATION");
|
||||||
|
expect(html).toContain("HI, Bob");
|
||||||
|
expect(html).toContain("REFER# DEP-99");
|
||||||
|
expect(html).toContain("$ 500.00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderAccountStatus", () => {
|
||||||
|
it("uses the yellow band and the under-minimum phrasing for level=0", () => {
|
||||||
|
const html = renderAccountStatus({
|
||||||
|
customerId: "C-003",
|
||||||
|
customerName: "Carol",
|
||||||
|
level: 0,
|
||||||
|
balance: "10.00",
|
||||||
|
tipo: "40.00",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(html).toContain("#88D5EE");
|
||||||
|
expect(html).toContain("under our minimum");
|
||||||
|
expect(html).toContain("Carol");
|
||||||
|
expect(html).toContain("$ 10.00");
|
||||||
|
expect(html).toContain("$ 40.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the red band and the rush phrasing for level=1", () => {
|
||||||
|
const html = renderAccountStatus({
|
||||||
|
customerId: "C-003",
|
||||||
|
customerName: "Carol",
|
||||||
|
level: 1,
|
||||||
|
balance: "-25.50",
|
||||||
|
tipo: "25.50",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(html).toContain("#FF8D71");
|
||||||
|
expect(html).toContain("overdrawn");
|
||||||
|
expect(html).toContain("reactivate your payments");
|
||||||
|
expect(html).toContain("$ 25.50");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderTrustConfirm", () => {
|
||||||
|
it("labels the trust annual fee and quotes the amount", () => {
|
||||||
|
const html = renderTrustConfirm({
|
||||||
|
customerId: "C-004",
|
||||||
|
customerName: "Dan",
|
||||||
|
amount: "350.00",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(html).toContain("Annual Bank Fee Payment Confirmation");
|
||||||
|
expect(html).toContain("$ 350.00");
|
||||||
|
expect(html).toContain("Most banks always request");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* HTML body renderers for the four notification jobs. These are the modern
|
||||||
|
* in-process equivalent of the legacy `getXxxForEmail.php` files the PHP
|
||||||
|
* scripts `fetch()`ed by URL. Rendering server-side and inlining the body
|
||||||
|
* in the response keeps a single SES MessageId tied to one frozen HTML
|
||||||
|
* snapshot (vs. the legacy flow, where the URL kept re-rendering with
|
||||||
|
* whatever the database looked like at click time).
|
||||||
|
*
|
||||||
|
* The visual style mirrors the legacy PHP templates where it makes sense
|
||||||
|
* (the office's customer base has been seeing these letters for years;
|
||||||
|
* gratuitous redesign costs trust). The body shell, table layout and the
|
||||||
|
* canonical contact block are preserved verbatim. English copy because the
|
||||||
|
* legacy letters were English; switching to Spanish is a future decision
|
||||||
|
* (see INSURANCE_FEATURES_SPEC §1.6 "Spanish or English body?").
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HEAD = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>`;
|
||||||
|
|
||||||
|
const FOOT_CONTACT = `<p>If you have any questions regarding this notice please contact us at:
|
||||||
|
Tel. 011 52 (661) 612 - 1295 Fax. (661) 612 - 1285
|
||||||
|
For any type of a 24 Hrs. emergencies: please dial 52 (664) 304 - 7778 |
|
||||||
|
<a href="mailto:jorge@jorgecuadros.com">jorge@jorgecuadros.com</a> |
|
||||||
|
<a href="https://www.jorgecuadros.com/contactus.php">Contact Us Form</a></p>`;
|
||||||
|
|
||||||
|
const SIGNED = (year: number) => `<center><span class="small">This message has been generated by the Jorge Cuadros & Assoc. Information Server.<br />Copyright ${year} <a href="http://www.freakma.net/">Developed by FreaKmA.Net</a></span></center>`;
|
||||||
|
|
||||||
|
const esc = (s: string | null | undefined): string =>
|
||||||
|
String(s ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
|
||||||
|
const usd = (n: number | string | null | undefined): string => {
|
||||||
|
if (n === null || n === undefined) return "$ 0.00";
|
||||||
|
const v = typeof n === "string" ? Number(n) : n;
|
||||||
|
if (!isFinite(v)) return "$ 0.00";
|
||||||
|
return `$ ${v.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Shared shell: a 2-column table that matches the PHP output layout. */
|
||||||
|
function shell(opts: {
|
||||||
|
title: string;
|
||||||
|
bg: string;
|
||||||
|
heading: string;
|
||||||
|
accountId: string | number;
|
||||||
|
accountName: string;
|
||||||
|
body: string;
|
||||||
|
note?: string;
|
||||||
|
statementLink?: string;
|
||||||
|
year: number;
|
||||||
|
}): string {
|
||||||
|
const { title, bg, heading, accountId, accountName, body, note, statementLink, year } = opts;
|
||||||
|
const stmt = statementLink ?? "https://my.jorgecuadros.com/";
|
||||||
|
return `${HEAD.replace("{title}", esc(title))}
|
||||||
|
<body style="background-color:${bg};color:#333;font-family:'Courier New', Courier, monospace;">
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td width="43%" style="font-size:20px;font-weight:bold;">${esc(heading)}</td>
|
||||||
|
<td width="57%" style="font-size:12px;">Please do not reply to this message. For any Jorge Cuadros & Assoc. customer service inquiries, visit: <a href="https://www.jorgecuadros.com/contactus.php">Customer Support</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>${esc(accountName)}<br />ACCOUNT #${esc(String(accountId))}</strong></td>
|
||||||
|
<td><div align="center"><a href="${esc(stmt)}" target="_blank" style="color:#006699;font-weight:bold">Click Here to View Your Account Statement</a></div></td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
<tr><td colspan="2">${body}</td></tr>
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
${
|
||||||
|
note
|
||||||
|
? `<tr><td colspan="2"><h4>${esc(note)}</h4>${FOOT_CONTACT}</td></tr>`
|
||||||
|
: `<tr><td colspan="2">${FOOT_CONTACT}</td></tr>`
|
||||||
|
}
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
<tr><td colspan="2">${SIGNED(year)}</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Outstanding payments — Job 1 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export interface OutstandingRow {
|
||||||
|
date: Date | string;
|
||||||
|
reference: string | null;
|
||||||
|
period: string | null;
|
||||||
|
type: string | null;
|
||||||
|
/** Signed amount (negative for charges). */
|
||||||
|
amount: number | string;
|
||||||
|
/** Running balance in the customer's currency, after this row. */
|
||||||
|
balance: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOutstanding(args: {
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
total: number | string;
|
||||||
|
rows: OutstandingRow[];
|
||||||
|
year: number;
|
||||||
|
}): string {
|
||||||
|
const rows = args.rows
|
||||||
|
.map(
|
||||||
|
(r) => `<tr>
|
||||||
|
<td>${esc(String(r.date))}</td>
|
||||||
|
<td>${esc(r.reference ?? "")}</td>
|
||||||
|
<td>${esc(r.period ?? "")}</td>
|
||||||
|
<td>${esc(r.type ?? "")}</td>
|
||||||
|
<td align="right">${esc(usd(r.amount))}</td>
|
||||||
|
<td align="right">${esc(usd(r.balance))}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const body = `<p>This needs your prompt attention in order to avoid any disruption(s):</p>
|
||||||
|
<p align="center"><strong><font color="#FF0000">TOTAL OF OUTSTANDING BILLS: ${esc(
|
||||||
|
usd(args.total),
|
||||||
|
)} PESOS.</font></strong></p>
|
||||||
|
<table width="100%" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tr><th>DATE</th><th>REFER</th><th>PERIOD</th><th>TYPEOFTRX</th><th>CHARGECREDIT</th><th>BALANCE</th></tr>
|
||||||
|
${rows}
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
return shell({
|
||||||
|
title: "Outstanding Payments",
|
||||||
|
bg: "#9CC",
|
||||||
|
heading: "Outstanding Payments",
|
||||||
|
accountId: args.customerId,
|
||||||
|
accountName: args.customerName,
|
||||||
|
body,
|
||||||
|
note: "NOTE : IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL",
|
||||||
|
year: args.year,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Payment confirmation — Job 2 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export function renderPaymentConfirm(args: {
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
typeOfTrx: string;
|
||||||
|
reference: string | null;
|
||||||
|
/** The deposited amount (positive number — credits are positive in the
|
||||||
|
* unified ledger). */
|
||||||
|
amount: number | string;
|
||||||
|
year: number;
|
||||||
|
}): string {
|
||||||
|
const body = `<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td width="48%" style="font-size:20px;font-weight:bold;">${esc(
|
||||||
|
args.typeOfTrx,
|
||||||
|
)} CONFIRMATION</td>
|
||||||
|
<td width="52%" style="font-size:12px;">Please do not reply to this message. For any Jorge Cuadros & Assoc. customer service inquiries, visit: <a href="https://www.jorgecuadros.com/contactus.php" target="_blank">Customer Support</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>HI, ${esc(args.customerName)}</strong><br/>
|
||||||
|
<strong>ACCOUNT #${esc(args.customerId)}</strong><br/>
|
||||||
|
<strong>REFER# ${esc(args.reference ?? "")}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div align="center" style="padding:20px;">
|
||||||
|
<a href="https://my.jorgecuadros.com/" target="_blank" style="color:#006699;font-weight:bold"><em>Click Here to View Your Account Statement</em></a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
<tr><td colspan="2">
|
||||||
|
<p>Your account is now current to keep paying your future obligations. If for any reason your next bill is more than what's available; our system will email you our automatic alert requesting more funds. Thank You,</p>
|
||||||
|
<p align="center" style="color:#006600;font-weight:bold;">Your deposit was for ${esc(
|
||||||
|
usd(args.amount),
|
||||||
|
)} PESOS.</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
<tr><td colspan="2"><h4>NOTE : IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL</h4>${FOOT_CONTACT}</td></tr>
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
<tr><td colspan="2">${SIGNED(args.year)}</td></tr>
|
||||||
|
</table>`;
|
||||||
|
return `${HEAD.replace("{title}", "Payment Confirmation")}<body>${body}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Account status — Job 3 (yellow + red) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export function renderAccountStatus(args: {
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
level: 0 | 1; // 0 = yellow (DEBAJO DEL TIPO), 1 = red (EN ROJO)
|
||||||
|
balance: number | string;
|
||||||
|
/** Amount the customer needs to deposit to clear the threshold. */
|
||||||
|
tipo: number | string;
|
||||||
|
year: number;
|
||||||
|
}): string {
|
||||||
|
const isYellow = args.level === 0;
|
||||||
|
const body = isYellow
|
||||||
|
? `<p>In order to avoid any disruptions please mail or bring ${esc(
|
||||||
|
usd(args.tipo),
|
||||||
|
)} USD ASAP. As your current Balance ${esc(
|
||||||
|
usd(args.balance),
|
||||||
|
)} is under our minimum required to run this account.</p>`
|
||||||
|
: `<p>Sorry Account is overdrawn and all utility bills are on hold please rush ${esc(
|
||||||
|
usd(args.tipo),
|
||||||
|
)} USD these funds must be on hand ASAP to reactivate your payments.</p>`;
|
||||||
|
return shell({
|
||||||
|
title: "Account Alert",
|
||||||
|
bg: isYellow ? "#88D5EE" : "#FF8D71",
|
||||||
|
heading: "Account Alert",
|
||||||
|
accountId: args.customerId,
|
||||||
|
accountName: args.customerName,
|
||||||
|
body,
|
||||||
|
note: "NOTE : PLEASE MAKE YOUR CHECK PAYABLE TO UMC AND ASSOCIATES. IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL.",
|
||||||
|
year: args.year,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Trust payment confirmation — Job 4 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export function renderTrustConfirm(args: {
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
/** Annual fee amount posted (positive, in MXN per the PHP). */
|
||||||
|
amount: number | string;
|
||||||
|
year: number;
|
||||||
|
}): string {
|
||||||
|
const body = `<p>This automatic notice is to confirm, that your Annual Bank Fee has been paid by, and posted in your account. Thank You,</p>
|
||||||
|
<p align="center"><strong>The annual fee was posted for the amount of <font color="#FF0000">${esc(
|
||||||
|
usd(args.amount),
|
||||||
|
)} PESOS.</font></strong></p>`;
|
||||||
|
return shell({
|
||||||
|
title: "Trust Payment Confirmation",
|
||||||
|
bg: "#C0BEA0",
|
||||||
|
heading: "Annual Bank Fee Payment Confirmation",
|
||||||
|
accountId: args.customerId,
|
||||||
|
accountName: args.customerName,
|
||||||
|
body,
|
||||||
|
note: "NOTE : Most banks always request to make such payment in advance.",
|
||||||
|
statementLink: "https://my.jorgecuadros.com/",
|
||||||
|
year: args.year,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { ServiceUnavailableException } from "@nestjs/common";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { MailService } from "./mail.service";
|
|
||||||
|
|
||||||
function config(values: Record<string, string>): ConfigService {
|
|
||||||
return { get: (key: string) => values[key] } as ConfigService;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("MailService", () => {
|
|
||||||
it("uses a no-op provider when SES is absent in development", async () => {
|
|
||||||
const service = new MailService(config({ NODE_ENV: "development" }));
|
|
||||||
|
|
||||||
expect(service.available).toBe(true);
|
|
||||||
await expect(
|
|
||||||
service.send({ to: "test@example.com", subject: "Test", html: "<p>Test</p>" }),
|
|
||||||
).resolves.toEqual({ providerId: expect.stringMatching(/^dev-/) });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps production bootable but refuses sends when SES is absent", async () => {
|
|
||||||
const service = new MailService(config({ NODE_ENV: "production" }));
|
|
||||||
|
|
||||||
expect(service.available).toBe(false);
|
|
||||||
await expect(
|
|
||||||
service.send({ to: "test@example.com", subject: "Test", html: "<p>Test</p>" }),
|
|
||||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import {
|
|
||||||
SendEmailCommand,
|
|
||||||
SESv2Client,
|
|
||||||
} from "@aws-sdk/client-sesv2";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
export interface MailMessage {
|
|
||||||
to: string;
|
|
||||||
subject: string;
|
|
||||||
html: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MailProvider {
|
|
||||||
send(message: MailMessage): Promise<{ providerId: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MailService implements MailProvider {
|
|
||||||
private readonly logger = new Logger(MailService.name);
|
|
||||||
private readonly client: SESv2Client | null;
|
|
||||||
private readonly from: string | null;
|
|
||||||
private readonly configurationSet: string | undefined;
|
|
||||||
private readonly developmentNoop: boolean;
|
|
||||||
|
|
||||||
constructor(config: ConfigService) {
|
|
||||||
const region = config.get<string>("SES_REGION");
|
|
||||||
const from = config.get<string>("SES_FROM");
|
|
||||||
const accessKeyId = config.get<string>("SES_ACCESS_KEY");
|
|
||||||
const secretAccessKey = config.get<string>("SES_SECRET_KEY");
|
|
||||||
this.configurationSet = config.get<string>("SES_CONFIGURATION_SET") || undefined;
|
|
||||||
this.developmentNoop =
|
|
||||||
config.get<string>("NODE_ENV") !== "production" &&
|
|
||||||
!region &&
|
|
||||||
!from &&
|
|
||||||
!accessKeyId &&
|
|
||||||
!secretAccessKey;
|
|
||||||
|
|
||||||
if (this.developmentNoop) {
|
|
||||||
this.logger.warn("SES no configurado; los correos se registrarán sin enviarse.");
|
|
||||||
this.client = null;
|
|
||||||
this.from = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!region || !from || !accessKeyId || !secretAccessKey) {
|
|
||||||
this.logger.warn("SES no configurado; las notificaciones por correo están deshabilitadas.");
|
|
||||||
this.client = null;
|
|
||||||
this.from = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.client = new SESv2Client({
|
|
||||||
region,
|
|
||||||
credentials: { accessKeyId, secretAccessKey },
|
|
||||||
});
|
|
||||||
this.from = from;
|
|
||||||
}
|
|
||||||
|
|
||||||
get available(): boolean {
|
|
||||||
return this.developmentNoop || (this.client !== null && this.from !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(message: MailMessage): Promise<{ providerId: string }> {
|
|
||||||
if (this.developmentNoop) {
|
|
||||||
const providerId = `dev-${randomUUID()}`;
|
|
||||||
this.logger.log(`Correo de renovación simulado (${providerId}).`);
|
|
||||||
return { providerId };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.client || !this.from) {
|
|
||||||
throw new ServiceUnavailableException(
|
|
||||||
"El servicio de correo no está configurado.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.client.send(
|
|
||||||
new SendEmailCommand({
|
|
||||||
FromEmailAddress: this.from,
|
|
||||||
Destination: { ToAddresses: [message.to] },
|
|
||||||
Content: {
|
|
||||||
Simple: {
|
|
||||||
Subject: { Data: message.subject, Charset: "UTF-8" },
|
|
||||||
Body: { Html: { Data: message.html, Charset: "UTF-8" } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ConfigurationSetName: this.configurationSet,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.MessageId) {
|
|
||||||
throw new Error("SES no devolvió identificador de mensaje.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { providerId: result.MessageId };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { RenewalsController } from "./renewals.controller";
|
import { RenewalsController } from "./renewals.controller";
|
||||||
import { MailService } from "./mail.service";
|
|
||||||
import { RenewalsService } from "./renewals.service";
|
import { RenewalsService } from "./renewals.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [RenewalsController],
|
controllers: [RenewalsController],
|
||||||
providers: [MailService, RenewalsService],
|
providers: [RenewalsService],
|
||||||
})
|
})
|
||||||
export class RenewalsModule {}
|
export class RenewalsModule {}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Cron } from "@nestjs/schedule";
|
import { Cron } from "@nestjs/schedule";
|
||||||
import { AuditService } from "../common/audit.service";
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import { MailService } from "../mail/mail.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import {
|
import {
|
||||||
renewalLetterSelect,
|
renewalLetterSelect,
|
||||||
toRenewalLetterRow,
|
toRenewalLetterRow,
|
||||||
} from "../reports/renewal-letter";
|
} from "../reports/renewal-letter";
|
||||||
import { MailService } from "./mail.service";
|
|
||||||
import { renderRenewalEmail } from "./renewal-email";
|
import { renderRenewalEmail } from "./renewal-email";
|
||||||
|
|
||||||
export const RENEWAL_CADENCE = [
|
export const RENEWAL_CADENCE = [
|
||||||
@@ -135,7 +135,12 @@ export class RenewalsService {
|
|||||||
try {
|
try {
|
||||||
const letter = toRenewalLetterRow(policy, cadence.generation);
|
const letter = toRenewalLetterRow(policy, cadence.generation);
|
||||||
const message = renderRenewalEmail(letter);
|
const message = renderRenewalEmail(letter);
|
||||||
const result = await this.mail.send({ to, ...message });
|
const result = await this.mail.send({
|
||||||
|
to,
|
||||||
|
subject: message.subject,
|
||||||
|
html: message.html,
|
||||||
|
xTracking: "renewals",
|
||||||
|
});
|
||||||
const sentAt = new Date();
|
const sentAt = new Date();
|
||||||
|
|
||||||
await this.prisma.renewalNotice.upsert({
|
await this.prisma.renewalNotice.upsert({
|
||||||
@@ -151,20 +156,20 @@ export class RenewalsService {
|
|||||||
channel: "EMAIL",
|
channel: "EMAIL",
|
||||||
sentAt,
|
sentAt,
|
||||||
sentById: userId,
|
sentById: userId,
|
||||||
providerMessageId: result.providerId,
|
providerMessageId: result.messageId,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
channel: "EMAIL",
|
channel: "EMAIL",
|
||||||
sentAt,
|
sentAt,
|
||||||
sentById: userId,
|
sentById: userId,
|
||||||
providerMessageId: result.providerId,
|
providerMessageId: result.messageId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
sent++;
|
sent++;
|
||||||
void this.audit.log(userId, "renewalNotice.send", {
|
void this.audit.log(userId, "renewalNotice.send", {
|
||||||
policyId: policy.id,
|
policyId: policy.id,
|
||||||
generation: cadence.generation,
|
generation: cadence.generation,
|
||||||
providerMessageId: result.providerId,
|
providerMessageId: result.messageId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push({
|
failures.push({
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { formatDateTime } from "@/lib/labels";
|
||||||
|
import {
|
||||||
|
NOTIFICATION_STATUS_COLORS,
|
||||||
|
NOTIFICATION_STATUS_LABELS,
|
||||||
|
NOTIFICATION_SERVICIO_LABELS,
|
||||||
|
NOTIFICATION_TYPE_LABELS,
|
||||||
|
} from "@/lib/labels";
|
||||||
|
import {
|
||||||
|
getNotificationStats,
|
||||||
|
listNotificationLog,
|
||||||
|
runAccountStatus,
|
||||||
|
runOutstandingPayments,
|
||||||
|
runPaymentConfirmation,
|
||||||
|
runTrustConfirmation,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
NotificationFlags,
|
||||||
|
NotificationJobResponse,
|
||||||
|
NotificationLogPage,
|
||||||
|
NotificationStats,
|
||||||
|
NotificationStatus,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mass email notifications UI. Manual triggers for the four jobs plus a
|
||||||
|
* paged log browser. The page is gated on `notification:send`; a STAFF
|
||||||
|
* viewer sees the read-only log table but not the trigger buttons.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default function NotificacionesPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Notificaciones />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobKind = "outstanding" | "payment" | "account" | "trust";
|
||||||
|
|
||||||
|
interface JobDef {
|
||||||
|
kind: JobKind;
|
||||||
|
title: string;
|
||||||
|
endpoint: string;
|
||||||
|
description: string;
|
||||||
|
servicio: "Clientes" | "Fideicomiso";
|
||||||
|
flagsHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JOBS: JobDef[] = [
|
||||||
|
{
|
||||||
|
kind: "outstanding",
|
||||||
|
title: "Pagos pendientes",
|
||||||
|
endpoint: "sendOutstandingPaymentAlerts",
|
||||||
|
servicio: "Clientes",
|
||||||
|
description:
|
||||||
|
"Clientes con al menos un movimiento marcado como pendiente (outstanding). Equivale a la columna NOPAGO=1 del antiguo datosfreak.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "payment",
|
||||||
|
title: "Confirmación de pago",
|
||||||
|
endpoint: "sendPaymentConfirmation",
|
||||||
|
servicio: "Clientes",
|
||||||
|
description:
|
||||||
|
"Clientes con un crédito (abono) en las últimas 24 horas. Un correo por cliente con el pago más reciente.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "account",
|
||||||
|
title: "Estado de cuenta",
|
||||||
|
endpoint: "sendAccountStatus",
|
||||||
|
servicio: "Clientes",
|
||||||
|
description:
|
||||||
|
"Alerta amarilla (DEBAJO DEL TIPO) los miércoles y roja (EN ROJO) lunes/miércoles/viernes. El flag ignoreDayRestriction salta los gates.",
|
||||||
|
flagsHint: "Solo este job respeta ignoreDayRestriction y useEmailLimit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "trust",
|
||||||
|
title: "Confirmación fideicomiso",
|
||||||
|
endpoint: "sendConfirmTrustPayment",
|
||||||
|
servicio: "Fideicomiso",
|
||||||
|
description:
|
||||||
|
"Clientes con TrustAccount que recibieron un crédito en el dominio TRUST en las últimas 24 horas.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function Notificaciones() {
|
||||||
|
const allowed = useCan("notification:send");
|
||||||
|
|
||||||
|
const [flags, setFlags] = useState<NotificationFlags>({ debug: true });
|
||||||
|
const [stats, setStats] = useState<NotificationStats | null>(null);
|
||||||
|
const [log, setLog] = useState<NotificationLogPage | null>(null);
|
||||||
|
const [logFilter, setLogFilter] = useState<{
|
||||||
|
status?: NotificationStatus;
|
||||||
|
view: "all" | "sent" | "failed" | "skipped";
|
||||||
|
}>({ view: "all" });
|
||||||
|
const [logPage, setLogPage] = useState(1);
|
||||||
|
const [busy, setBusy] = useState<JobKind | null>(null);
|
||||||
|
const [lastResult, setLastResult] = useState<NotificationJobResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [s, l] = await Promise.all([
|
||||||
|
getNotificationStats(),
|
||||||
|
listNotificationLog({
|
||||||
|
page: logPage,
|
||||||
|
pageSize: 50,
|
||||||
|
status: logFilter.status,
|
||||||
|
view: logFilter.view === "all" ? undefined : logFilter.view,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
setStats(s);
|
||||||
|
setLog(l);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}, [logPage, logFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const run = useCallback(
|
||||||
|
async (job: JobDef) => {
|
||||||
|
if (!allowed) return;
|
||||||
|
setBusy(job.kind);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let res: NotificationJobResponse;
|
||||||
|
if (job.kind === "outstanding") res = await runOutstandingPayments(flags);
|
||||||
|
else if (job.kind === "payment") res = await runPaymentConfirmation(flags);
|
||||||
|
else if (job.kind === "account") res = await runAccountStatus(flags);
|
||||||
|
else res = await runTrustConfirmation(flags);
|
||||||
|
setLastResult(res);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[allowed, flags, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gap: 24, padding: 24 }}>
|
||||||
|
<header>
|
||||||
|
<h1 style={{ margin: 0 }}>Notificaciones masivas</h1>
|
||||||
|
<p style={{ color: "#666", marginTop: 4 }}>
|
||||||
|
Disparo manual de los cuatro envíos equivalentes a los scripts PHP
|
||||||
|
de <code>email.notifications/</code>. Cada ejecución registra todas
|
||||||
|
las filas (enviado, fallido, omitido) en <code>email_notification_log</code>.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
display: "grid",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Flags del envío</strong>
|
||||||
|
<label style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!flags.debug}
|
||||||
|
disabled={!allowed}
|
||||||
|
onChange={(e) => setFlags((f) => ({ ...f, debug: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<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 style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!flags.ignoreDayRestriction}
|
||||||
|
disabled={!allowed}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFlags((f) => ({ ...f, ignoreDayRestriction: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>ignoreDayRestriction</strong> — salta los gates de
|
||||||
|
Mon/Wed/Fri del estado de cuenta (job 3). Útil para disparar en
|
||||||
|
cualquier día sin esperar a la próxima corrida.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!flags.useEmailLimit}
|
||||||
|
disabled={!allowed}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFlags((f) => ({ ...f, useEmailLimit: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>useEmailLimit</strong> — pausa el job 3 cada 100 correos
|
||||||
|
durante 1 hora. Vestigio de la era SMTP; SES no lo necesita.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{JOBS.map((j) => (
|
||||||
|
<article
|
||||||
|
key={j.kind}
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
display: "grid",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<strong>{j.title}</strong>
|
||||||
|
<span style={{ fontSize: 12, color: "#666" }}>{j.servicio}</span>
|
||||||
|
</header>
|
||||||
|
<p style={{ margin: 0, color: "#444", fontSize: 13 }}>{j.description}</p>
|
||||||
|
{j.flagsHint && (
|
||||||
|
<p style={{ margin: 0, color: "#666", fontSize: 12, fontStyle: "italic" }}>
|
||||||
|
{j.flagsHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!allowed || busy !== null}
|
||||||
|
onClick={() => void run(j)}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: !allowed || busy !== null ? "#bbb" : "#1f4eaf",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: !allowed || busy !== null ? "not-allowed" : "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy === j.kind ? "Ejecutando…" : "Ejecutar"}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!allowed && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff8e1",
|
||||||
|
border: "1px solid #f1c40f",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tu rol no incluye <code>notification:send</code>. Solo puedes ver el
|
||||||
|
registro. Para disparar envíos pide a un MANAGER/ADMIN.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Estado del transporte</strong>
|
||||||
|
<ul style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
|
<li>
|
||||||
|
SES configurado:{" "}
|
||||||
|
<strong style={{ color: stats.transport.available ? "#1f7a3a" : "#b3261e" }}>
|
||||||
|
{stats.transport.available ? "sí" : "no"}
|
||||||
|
</strong>
|
||||||
|
{stats.transport.devFallback && " (fallback dev: stdout)"}
|
||||||
|
</li>
|
||||||
|
<li>Último envío registrado: {stats.lastRun ? `${NOTIFICATION_TYPE_LABELS[stats.lastRun.notificationType]} — ${formatDateTime(stats.lastRun.sendDate)}` : "—"}</li>
|
||||||
|
<li>
|
||||||
|
Totales:{" "}
|
||||||
|
{stats.byStatus.map((s) => (
|
||||||
|
<span key={s.status} style={{ marginRight: 12 }}>
|
||||||
|
{NOTIFICATION_STATUS_LABELS[s.status]}: {s._count._all}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lastResult && (
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: "#eef6ff",
|
||||||
|
border: "1px solid #b3d4fc",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Última respuesta</strong>
|
||||||
|
<pre style={{ margin: 0, fontSize: 12, overflow: "auto" }}>
|
||||||
|
{JSON.stringify(lastResult, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fdecea",
|
||||||
|
border: "1px solid #b3261e",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
color: "#b3261e",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<strong>Registro de envíos</strong>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
{(["all", "sent", "failed", "skipped"] as const).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setLogFilter({ view: v });
|
||||||
|
setLogPage(1);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
background: logFilter.view === v ? "#1f4eaf" : "#eee",
|
||||||
|
color: logFilter.view === v ? "#fff" : "#333",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v === "all" ? "Todos" : v === "sent" ? "Enviados" : v === "failed" ? "Fallidos" : "Omitidos"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse", marginTop: 12 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "2px solid #ddd" }}>
|
||||||
|
<th style={{ textAlign: "left", padding: 6 }}>Fecha</th>
|
||||||
|
<th style={{ textAlign: "left", padding: 6 }}>Tipo</th>
|
||||||
|
<th style={{ textAlign: "left", padding: 6 }}>Servicio</th>
|
||||||
|
<th style={{ textAlign: "left", padding: 6 }}>Cliente</th>
|
||||||
|
<th style={{ textAlign: "left", padding: 6 }}>Email</th>
|
||||||
|
<th style={{ textAlign: "left", padding: 6 }}>Estado</th>
|
||||||
|
<th style={{ textAlign: "left", padding: 6 }}>Asunto</th>
|
||||||
|
<th style={{ textAlign: "left", padding: 6 }}>Provider</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{log?.items.map((row) => (
|
||||||
|
<tr key={row.id} style={{ borderBottom: "1px solid #eee" }}>
|
||||||
|
<td style={{ padding: 6, fontSize: 12 }}>{formatDateTime(row.sendDate)}</td>
|
||||||
|
<td style={{ padding: 6, fontSize: 12 }}>
|
||||||
|
{NOTIFICATION_TYPE_LABELS[row.notificationType]}
|
||||||
|
{row.level !== null && (row.level === 0 ? " (amarilla)" : " (roja)")}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: 6, fontSize: 12 }}>{NOTIFICATION_SERVICIO_LABELS[row.servicio]}</td>
|
||||||
|
<td style={{ padding: 6, fontSize: 12 }}>{row.customerName}{row.debug ? " · debug" : ""}</td>
|
||||||
|
<td style={{ padding: 6, fontSize: 12 }}>{row.customerEmail}</td>
|
||||||
|
<td style={{ padding: 6, fontSize: 12, color: NOTIFICATION_STATUS_COLORS[row.status] }}>
|
||||||
|
{NOTIFICATION_STATUS_LABELS[row.status]}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: 6, fontSize: 12 }}>{row.subject}</td>
|
||||||
|
<td style={{ padding: 6, fontSize: 11, color: "#666" }}>
|
||||||
|
{row.providerMessageId ?? row.error ?? "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{log && log.items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} style={{ padding: 12, color: "#666", textAlign: "center" }}>
|
||||||
|
Sin envíos con el filtro actual.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{log && log.pageCount > 1 && (
|
||||||
|
<div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<span style={{ fontSize: 12, color: "#666" }}>
|
||||||
|
{log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de {log.pageCount}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={log.page <= 1}
|
||||||
|
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={log.page >= log.pageCount}
|
||||||
|
onClick={() => setLogPage((p) => Math.min(log.pageCount, p + 1))}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,6 +78,11 @@ const NAV: NavEntry[] = [
|
|||||||
label: "Cuentas de chequera",
|
label: "Cuentas de chequera",
|
||||||
ability: "bank:manage-accounts",
|
ability: "bank:manage-accounts",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/notificaciones",
|
||||||
|
label: "Notificaciones masivas",
|
||||||
|
ability: "notification:send",
|
||||||
|
},
|
||||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||||
{ href: "/renovaciones", label: "Renovaciones", ability: "renewal:send" },
|
{ href: "/renovaciones", label: "Renovaciones", ability: "renewal:send" },
|
||||||
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||||
|
|||||||
@@ -973,6 +973,185 @@ export function runReport(
|
|||||||
return apiFetch<ReportRunResult>(`/reports/${slug}${tail ? `?${tail}` : ""}`);
|
return apiFetch<ReportRunResult>(`/reports/${slug}${tail ? `?${tail}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------- Mass email notifications */
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
| "OUTSTANDING_PAYMENT"
|
||||||
|
| "PAYMENT_CONFIRMATION"
|
||||||
|
| "ACCOUNT_STATUS"
|
||||||
|
| "TRUST_PAYMENT_CONFIRMATION";
|
||||||
|
|
||||||
|
export type NotificationServicio = "CUSTOMERS" | "TRUST";
|
||||||
|
|
||||||
|
export type NotificationStatus =
|
||||||
|
| "SENT"
|
||||||
|
| "FAILED"
|
||||||
|
| "SKIPPED_NO_EMAIL"
|
||||||
|
| "SKIPPED_GATE";
|
||||||
|
|
||||||
|
export interface NotificationLogRow {
|
||||||
|
id: string;
|
||||||
|
sendDate: string;
|
||||||
|
notificationType: NotificationType;
|
||||||
|
level: number | null;
|
||||||
|
servicio: NotificationServicio;
|
||||||
|
customerId: string | null;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
subject: string;
|
||||||
|
debug: boolean;
|
||||||
|
status: NotificationStatus;
|
||||||
|
providerMessageId: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationLogPage {
|
||||||
|
items: NotificationLogRow[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationStats {
|
||||||
|
byType: { notificationType: NotificationType; status: NotificationStatus; _count: { _all: number } }[];
|
||||||
|
byStatus: { status: NotificationStatus; _count: { _all: number } }[];
|
||||||
|
byServicio: { servicio: NotificationServicio; status: NotificationStatus; _count: { _all: number } }[];
|
||||||
|
lastRun: { sendDate: string; notificationType: NotificationType } | null;
|
||||||
|
transport: { available: boolean; devFallback: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationFlags = {
|
||||||
|
debug?: boolean;
|
||||||
|
ignoreDayRestriction?: boolean;
|
||||||
|
useEmailLimit?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Job 1 (Outstanding) response — legacy `result` field. */
|
||||||
|
export interface OutstandingResponse {
|
||||||
|
result: "success";
|
||||||
|
notificationType: "sendPaymentConfirmation";
|
||||||
|
reason: string;
|
||||||
|
statusCode: 200;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
debug: boolean;
|
||||||
|
type: "OUTSTANDING_PAYMENT";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Job 2 (Payment Confirmation) response. */
|
||||||
|
export interface PaymentConfirmResponse {
|
||||||
|
request: "success";
|
||||||
|
notificationType: "sendPaymentConfirmation";
|
||||||
|
confirmationSent: string;
|
||||||
|
statusCode: 200;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
debug: boolean;
|
||||||
|
type: "PAYMENT_CONFIRMATION";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Job 3 (Account Status) response. */
|
||||||
|
export interface AccountStatusResponse {
|
||||||
|
request: "success";
|
||||||
|
notificationType: "sendAccountStatus";
|
||||||
|
statusSent: string;
|
||||||
|
statusReport: string;
|
||||||
|
statusCode: 200;
|
||||||
|
red: number;
|
||||||
|
yellow: number;
|
||||||
|
total: number;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
debug: boolean;
|
||||||
|
type: "ACCOUNT_STATUS";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Job 4 (Trust Confirmation) response. */
|
||||||
|
export interface TrustConfirmResponse {
|
||||||
|
request: "success";
|
||||||
|
notificationType: "sendTrustPaymentConfirmation";
|
||||||
|
confirmationSent: string;
|
||||||
|
statusCode: 200;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
debug: boolean;
|
||||||
|
type: "TRUST_PAYMENT_CONFIRMATION";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationJobResponse =
|
||||||
|
| OutstandingResponse
|
||||||
|
| PaymentConfirmResponse
|
||||||
|
| AccountStatusResponse
|
||||||
|
| TrustConfirmResponse;
|
||||||
|
|
||||||
|
export function runOutstandingPayments(
|
||||||
|
flags: NotificationFlags = {},
|
||||||
|
): Promise<OutstandingResponse> {
|
||||||
|
return apiFetch<OutstandingResponse>("/notifications/outstanding-payments", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(flags),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runPaymentConfirmation(
|
||||||
|
flags: NotificationFlags = {},
|
||||||
|
): Promise<PaymentConfirmResponse> {
|
||||||
|
return apiFetch<PaymentConfirmResponse>("/notifications/payment-confirmation", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(flags),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runAccountStatus(
|
||||||
|
flags: NotificationFlags = {},
|
||||||
|
): Promise<AccountStatusResponse> {
|
||||||
|
return apiFetch<AccountStatusResponse>("/notifications/account-status", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(flags),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTrustConfirmation(
|
||||||
|
flags: NotificationFlags = {},
|
||||||
|
): Promise<TrustConfirmResponse> {
|
||||||
|
return apiFetch<TrustConfirmResponse>("/notifications/trust-payment-confirmation", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(flags),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationLogQuery {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
type?: NotificationType;
|
||||||
|
servicio?: NotificationServicio;
|
||||||
|
status?: NotificationStatus;
|
||||||
|
view?: "sent" | "failed" | "skipped" | "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listNotificationLog(
|
||||||
|
q: NotificationLogQuery = {},
|
||||||
|
): Promise<NotificationLogPage> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (q.page) qs.set("page", String(q.page));
|
||||||
|
if (q.pageSize) qs.set("pageSize", String(q.pageSize));
|
||||||
|
if (q.type) qs.set("type", q.type);
|
||||||
|
if (q.servicio) qs.set("servicio", q.servicio);
|
||||||
|
if (q.status) qs.set("status", q.status);
|
||||||
|
if (q.view) qs.set("view", q.view);
|
||||||
|
const tail = qs.toString();
|
||||||
|
return apiFetch<NotificationLogPage>(`/notifications/log${tail ? `?${tail}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationStats(): Promise<NotificationStats> {
|
||||||
|
return apiFetch<NotificationStats>("/notifications/stats");
|
||||||
|
}
|
||||||
|
|
||||||
/** Build a download URL for a report's file output. The session cookie
|
/** Build a download URL for a report's file output. The session cookie
|
||||||
* travels with the browser's same-origin navigation, so a plain `href`
|
* travels with the browser's same-origin navigation, so a plain `href`
|
||||||
* is enough — no fetch-with-credentials dance. */
|
* is enough — no fetch-with-credentials dance. */
|
||||||
|
|||||||
@@ -379,3 +379,37 @@ export function sourceSystemLabel(source: string): string {
|
|||||||
};
|
};
|
||||||
return map[source] ?? source;
|
return map[source] ?? source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Mass email notifications -----
|
||||||
|
|
||||||
|
import type {
|
||||||
|
NotificationStatus,
|
||||||
|
NotificationType,
|
||||||
|
NotificationServicio,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
|
export const NOTIFICATION_TYPE_LABELS: Record<NotificationType, string> = {
|
||||||
|
OUTSTANDING_PAYMENT: "Pagos pendientes",
|
||||||
|
PAYMENT_CONFIRMATION: "Confirmación de pago",
|
||||||
|
ACCOUNT_STATUS: "Estado de cuenta",
|
||||||
|
TRUST_PAYMENT_CONFIRMATION: "Confirmación fideicomiso",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NOTIFICATION_SERVICIO_LABELS: Record<NotificationServicio, string> = {
|
||||||
|
CUSTOMERS: "Clientes",
|
||||||
|
TRUST: "Fideicomiso",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NOTIFICATION_STATUS_LABELS: Record<NotificationStatus, string> = {
|
||||||
|
SENT: "Enviado",
|
||||||
|
FAILED: "Falló",
|
||||||
|
SKIPPED_NO_EMAIL: "Sin email",
|
||||||
|
SKIPPED_GATE: "Fuera de día",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NOTIFICATION_STATUS_COLORS: Record<NotificationStatus, string> = {
|
||||||
|
SENT: "#1f7a3a",
|
||||||
|
FAILED: "#b3261e",
|
||||||
|
SKIPPED_NO_EMAIL: "#666",
|
||||||
|
SKIPPED_GATE: "#888",
|
||||||
|
};
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export type Ability =
|
|||||||
| "statement:review"
|
| "statement:review"
|
||||||
| "lookup:manage"
|
| "lookup:manage"
|
||||||
| "user:manage"
|
| "user:manage"
|
||||||
| "db:manage";
|
| "db:manage"
|
||||||
|
| "notification:send";
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Mass Email Notifications
|
||||||
|
|
||||||
|
Modern replacement for the four PHP scripts under
|
||||||
|
`email.notifications/send*.php` that fired bulk emails off the legacy
|
||||||
|
`utility_dbo.email_alert_log` table. Lives in this codebase from
|
||||||
|
`massive-email-notification` onward; the PHP scripts stay operational
|
||||||
|
until the office flips over.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The legacy scripts did three things this app needed to keep doing: send
|
||||||
|
outstanding-payment reminders, send payment-confirmation letters, and
|
||||||
|
fire account-status alerts (red and yellow). They also sent a fourth
|
||||||
|
trust-payment confirmation tied to `TRUSTHFEE`. Each was a separate CGI
|
||||||
|
script the office hit manually or via cron, talking to `utility_dbo` over
|
||||||
|
the same `mysqli` connection as the rest of the portal.
|
||||||
|
|
||||||
|
The unified schema (see [`PLAN.md`](../PLAN.md) and
|
||||||
|
[`docs/INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md)) folded
|
||||||
|
`datosfreak` and `TRUSTHFEE` into `customers` + `transactions` +
|
||||||
|
`trust_accounts`, so the scripts' SQL no longer maps to anything. Rather
|
||||||
|
than maintain parallel sync code to keep `utility_dbo` populated, this
|
||||||
|
feature ports the four jobs onto the unified data and writes its own log.
|
||||||
|
|
||||||
|
## What ships
|
||||||
|
|
||||||
|
- `apps/api/src/mail/` — outbound mail transport. Amazon SES (matches
|
||||||
|
the `StorageService` env-driven optional-client pattern). Dev falls
|
||||||
|
back to stdout logging so a fresh checkout can exercise the jobs
|
||||||
|
without SES credentials.
|
||||||
|
- `apps/api/src/notifications/` — the four jobs (`outstanding`,
|
||||||
|
`payment-confirm`, `account-status`, `trust-confirm`), each a public
|
||||||
|
service method + a `POST /notifications/{slug}` HTTP endpoint gated on
|
||||||
|
the new `notification:send` ability (MANAGER).
|
||||||
|
- `packages/database/prisma/migrations/20260801200000_mass_email_notifications/migration.sql`
|
||||||
|
— two new tables (`email_notification_log`, `account_status_history`)
|
||||||
|
with enums and FKs to `customers`.
|
||||||
|
- `apps/web/src/app/notificaciones/` — admin page with 4 trigger cards,
|
||||||
|
a flags panel, a transport-status header, and a paginated log browser.
|
||||||
|
|
||||||
|
## Job semantics
|
||||||
|
|
||||||
|
Preserved from the PHP originals (see
|
||||||
|
`~/Documents/Claude-Memory/email-notifications-spec.md`):
|
||||||
|
|
||||||
|
| Job | Recipients | Subject | Response key |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1. Outstanding payments | Customers with ≥1 outstanding Transaction (amount<0) | "Jorge Cuadros - Outstanding Payments" | `result:"success", notificationType:"sendPaymentConfirmation"` |
|
||||||
|
| 2. Payment confirmation | Customers with a credit in last 24h | "Jorge Cuadros - Payment Confirmation" | `request:"success", notificationType:"sendPaymentConfirmation"` |
|
||||||
|
| 3. Account status | All customers with a balance; yellow/red thresholds | "Jorge Cuadros - Account Status Alert" | `request:"success", notificationType:"sendAccountStatus"` |
|
||||||
|
| 4. Trust confirmation | Customers with TrustAccount + recent TRUST-domain credit | "Jorge Cuadros - Trust Payment Confirmation" | `request:"success", notificationType:"sendTrustPaymentConfirmation"` |
|
||||||
|
|
||||||
|
Wire shapes match the PHP originals byte-for-byte so anything downstream
|
||||||
|
that scrapes `notificationType:"sendPaymentConfirmation"` keeps working.
|
||||||
|
Job 1 reports `result` (not `request`) and `notificationType` literally
|
||||||
|
`sendPaymentConfirmation` — these are the legacy quirks, preserved.
|
||||||
|
|
||||||
|
### Day gates (Job 3 only)
|
||||||
|
|
||||||
|
- **Yellow** ("DEBAJO DEL TIPO"): Wed only (or `ignoreDayRestriction`).
|
||||||
|
- **Red** ("EN ROJO"): Mon/Wed/Fri only (or `ignoreDayRestriction`).
|
||||||
|
- A customer who is red on Tuesday is logged as `SKIPPED_GATE` until
|
||||||
|
Wed, when both checks can fire on the same row.
|
||||||
|
|
||||||
|
### Threshold logic (Job 3)
|
||||||
|
|
||||||
|
The PHP used `datosfreak.TIPO` (50/100/200/300/500) and a hardcoded
|
||||||
|
threshold table. The new schema encodes this as `Customer.minimumBalance`:
|
||||||
|
|
||||||
|
- Yellow: `0 ≤ balance < minimumBalance`
|
||||||
|
- Red: `balance < 0`
|
||||||
|
|
||||||
|
Per-currency balance uses `BillingService.balances()` semantics (signed
|
||||||
|
`SUM(transactions.amount)`, voided + outstanding excluded), so a
|
||||||
|
yellow/red alert always lines up with what the receivables worklist shows
|
||||||
|
staff. The customer-servicing letter reports in USD because the legacy
|
||||||
|
letter was always USD; the union of `balanceUsd` and `balanceMxn` is
|
||||||
|
reported per-customer, never collapsed (see `BillingService.balances()`).
|
||||||
|
|
||||||
|
### Rate limit (Job 3 only)
|
||||||
|
|
||||||
|
`useEmailLimit=true` enables a vestigial throttle: pause the sweep 1h
|
||||||
|
after 100 sends. Off by default; SES does not need it.
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
### `email_notification_log`
|
||||||
|
|
||||||
|
One row per send attempt (sent, failed, skipped). Carries the rendered
|
||||||
|
body verbatim so a customer reply quoting an old email can be traced to
|
||||||
|
the exact letter sent. SES MessageId stored for bounce/complaint
|
||||||
|
correlation.
|
||||||
|
|
||||||
|
Indexes: `(sendDate)`, `(notificationType, sendDate)`, `(customerId, sendDate)`.
|
||||||
|
|
||||||
|
### `account_status_history`
|
||||||
|
|
||||||
|
Mirrors the legacy `utility_dbo.send_account_status_history` table:
|
||||||
|
`(customerId, customerName, customerEmail, tipo, tCambio, balance,
|
||||||
|
solicitado, level)`. `tipo` is the literal `"DEBAJO DEL TIPO"` or
|
||||||
|
`"EN ROJO"` string the PHP used. `solicitado` keeps the legacy formula
|
||||||
|
(`0 - TIPO - BALANCE`) even though it double-subtracts; downstream
|
||||||
|
reports depend on the exact figure.
|
||||||
|
|
||||||
|
Indexes: `(sendDate)`, `(customerId, sendDate)`, `(level, sendDate)`.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
```
|
||||||
|
SES_REGION=us-east-1
|
||||||
|
SES_ACCESS_KEY=...
|
||||||
|
SES_SECRET_KEY=...
|
||||||
|
SES_FROM=mail@jorgecuadros.com
|
||||||
|
SES_FROM_NAME=Information Server
|
||||||
|
SES_CONFIGURATION_SET=... # optional
|
||||||
|
NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Without SES_* the API still boots and `MailService` falls back to stdout
|
||||||
|
in dev (`NODE_ENV !== "production"`). In production every send throws
|
||||||
|
`ServiceUnavailableException` and the row is recorded as `FAILED`.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
`/notificaciones` (gated on `notification:send`) — four trigger cards,
|
||||||
|
a debug/ignoreDayRestriction/useEmailLimit flags panel, a transport
|
||||||
|
status header, and a paginated log table. STAFF users see the log
|
||||||
|
read-only.
|
||||||
|
|
||||||
|
## Cron (future)
|
||||||
|
|
||||||
|
The four service methods (`runOutstandingPayments`, `runPaymentConfirmation`,
|
||||||
|
`runAccountStatus`, `runTrustConfirmation`) are the entry points. A future
|
||||||
|
`@nestjs/schedule` cron would call them on the legacy cadence (Job 3 on
|
||||||
|
Mon/Wed/Fri, Job 2 daily, Jobs 1 + 4 ad-hoc). Pattern matches
|
||||||
|
`OpsService`'s single-running-job guard: one `email_notification_sweep`
|
||||||
|
OpsJob per run, with its log streamed to `OpsJob.log`.
|
||||||
|
|
||||||
|
## What is intentionally NOT in scope
|
||||||
|
|
||||||
|
- Per-recipient preview / HTML view in the UI. The log table shows what
|
||||||
|
was sent; previewing one requires fetching `bodySnapshot` and rendering
|
||||||
|
HTML in the browser, deferred until a customer-service need surfaces.
|
||||||
|
- Bounce / complaint webhooks. `providerMessageId` is captured so a future
|
||||||
|
SNS topic can write back; the integration itself is a separate piece
|
||||||
|
of work.
|
||||||
|
- Spanish / English body toggle. Legacy letters are English; the legacy
|
||||||
|
customer base is bilingual. `Customer` has no language preference.
|
||||||
|
Add one when the need is concrete (same open question as
|
||||||
|
[`INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md) §1.6).
|
||||||
|
- Importing the legacy `utility_dbo.email_alert_log` rows. They reference
|
||||||
|
the old `NUMid` (a stringified double) which no longer maps to a
|
||||||
|
unified customer; an import would be destructive.
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
-- Mass email notifications — modern replacement for the legacy
|
||||||
|
-- `utility_dbo.email_alert_log` + `utility_dbo.send_account_status_history`
|
||||||
|
-- tables, fed by the four PHP scripts under
|
||||||
|
-- `email.notifications/send*.php`. See
|
||||||
|
-- docs/MASS_EMAIL_NOTIFICATIONS.md for the design.
|
||||||
|
--
|
||||||
|
-- The two legacy tables stay on `utility_dbo` untouched: their `NUMid`
|
||||||
|
-- column references a string identifier that no longer exists in the
|
||||||
|
-- unified schema, so a backfill would be destructive, not additive. New
|
||||||
|
-- notifications log here against the unified `customers.id` (uuid) and
|
||||||
|
-- the legacy rows are eventually retired by `utility_dbo` itself once
|
||||||
|
-- the office flips to this codebase as the source of truth.
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
-- ENUM values are declared inline per column (MySQL has no CREATE TYPE)
|
||||||
|
-- and match the Prisma enums `EmailNotificationType`,
|
||||||
|
-- `EmailNotificationServicio`, `EmailNotificationStatus`.
|
||||||
|
CREATE TABLE `email_notification_log` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`sendDate` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`notificationType` ENUM('OUTSTANDING_PAYMENT', 'PAYMENT_CONFIRMATION', 'ACCOUNT_STATUS', 'TRUST_PAYMENT_CONFIRMATION') NOT NULL,
|
||||||
|
`level` INTEGER NULL,
|
||||||
|
`servicio` ENUM('CUSTOMERS', 'TRUST') NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NULL,
|
||||||
|
`customerName` VARCHAR(191) NOT NULL,
|
||||||
|
`customerEmail` VARCHAR(191) NOT NULL,
|
||||||
|
`subject` VARCHAR(191) NOT NULL,
|
||||||
|
`bodyRequestUrl` TEXT NULL,
|
||||||
|
`bodySnapshot` TEXT NOT NULL,
|
||||||
|
`debug` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`providerMessageId` VARCHAR(191) NULL,
|
||||||
|
`providerResponse` VARCHAR(191) NULL,
|
||||||
|
`status` ENUM('SENT', 'FAILED', 'SKIPPED_NO_EMAIL', 'SKIPPED_GATE') NOT NULL,
|
||||||
|
`error` TEXT NULL,
|
||||||
|
|
||||||
|
INDEX `email_notification_log_sendDate_idx`(`sendDate`),
|
||||||
|
INDEX `email_notification_log_notificationType_sendDate_idx`(`notificationType`, `sendDate`),
|
||||||
|
INDEX `email_notification_log_customerId_sendDate_idx`(`customerId`, `sendDate`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `account_status_history` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`sendDate` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`customerName` VARCHAR(191) NOT NULL,
|
||||||
|
`customerEmail` VARCHAR(191) NOT NULL,
|
||||||
|
`tipo` VARCHAR(191) NOT NULL,
|
||||||
|
`tCambio` DECIMAL(10, 4) NULL,
|
||||||
|
`balance` DECIMAL(12, 2) NOT NULL,
|
||||||
|
`solicitado` DECIMAL(12, 2) NOT NULL,
|
||||||
|
`level` INTEGER NOT NULL,
|
||||||
|
|
||||||
|
INDEX `account_status_history_sendDate_idx`(`sendDate`),
|
||||||
|
INDEX `account_status_history_customerId_sendDate_idx`(`customerId`, `sendDate`),
|
||||||
|
INDEX `account_status_history_level_sendDate_idx`(`level`, `sendDate`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `email_notification_log` ADD CONSTRAINT `email_notification_log_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `account_status_history` ADD CONSTRAINT `account_status_history_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -125,6 +125,9 @@ model Customer {
|
|||||||
statementDocuments StatementDocument[]
|
statementDocuments StatementDocument[]
|
||||||
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
||||||
|
|
||||||
|
emailNotificationLogs EmailNotificationLog[]
|
||||||
|
accountStatusHistory AccountStatusHistory[]
|
||||||
|
|
||||||
@@map("customers")
|
@@map("customers")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,6 +922,146 @@ model EmailLog {
|
|||||||
@@map("email_log")
|
@@map("email_log")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mass email notifications — the modern replacement for the legacy
|
||||||
|
// `email_alert_log` + `send_account_status_history` tables on utility_dbo and
|
||||||
|
// the four PHP scripts under `email.notifications/`. See
|
||||||
|
// docs/MASS_EMAIL_NOTIFICATIONS.md for the full design.
|
||||||
|
//
|
||||||
|
// The two legacy tables are not imported into this schema: the unified
|
||||||
|
// `customers` model replaces `datosfreak` (no more NUMid-as-string), so the
|
||||||
|
// rows would no longer carry their meaning. New tables follow the unified
|
||||||
|
// shape (FK to `customers`, signed Decimal balance, proper enums) and the
|
||||||
|
// four notification kinds are one `notificationType` enum rather than four
|
||||||
|
// parallel column families.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Which bulk-notification script produced a row. Mirrors the four PHP
|
||||||
|
/// jobs in `email.notifications/send*.php`:
|
||||||
|
/// - OUTSTANDING_PAYMENT → sendOutstandingPaymentAlerts.php
|
||||||
|
/// - PAYMENT_CONFIRMATION → sendPaymentConfirmation.php (pagosemail)
|
||||||
|
/// - ACCOUNT_STATUS → sendAccountStatus.php (datosfreak, both
|
||||||
|
/// red and yellow; the threshold is in the
|
||||||
|
/// `level` column, 0=yellow / 1=red)
|
||||||
|
/// - TRUST_PAYMENT_CONFIRMATION → sendConfirmTrustPayment.php (TRUSTHFEE)
|
||||||
|
enum EmailNotificationType {
|
||||||
|
OUTSTANDING_PAYMENT
|
||||||
|
PAYMENT_CONFIRMATION
|
||||||
|
ACCOUNT_STATUS
|
||||||
|
TRUST_PAYMENT_CONFIRMATION
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which "servicio" (line of business) the notification draws its recipients
|
||||||
|
/// from. CUSTOMERS = the unified customers ledger (replaces `datosfreak`);
|
||||||
|
/// TRUST = the trust-fee account table (replaces `TRUSTHFEE`). Keeping the
|
||||||
|
/// two services tagged makes a per-line report trivial.
|
||||||
|
enum EmailNotificationServicio {
|
||||||
|
CUSTOMERS
|
||||||
|
TRUST
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a single send attempt. SENT / FAILED are the meaningful ones;
|
||||||
|
/// SKIPPED_NO_EMAIL records the dry-run path and the legacy's
|
||||||
|
/// "EMAIL IS NULL" exclusion, SKIPPED_GATE records the Mon/Wed/Fri day gate
|
||||||
|
/// on the red branch (and the Wed gate on yellow) — so a sweep that ran on
|
||||||
|
/// the wrong day shows up as skipped rows, not as missing rows.
|
||||||
|
enum EmailNotificationStatus {
|
||||||
|
SENT
|
||||||
|
FAILED
|
||||||
|
SKIPPED_NO_EMAIL
|
||||||
|
SKIPPED_GATE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row per send attempt. Captures both the deliverable (subject + body
|
||||||
|
/// snapshot + provider message id) and the diagnostic (URL we would have
|
||||||
|
/// fetched in the PHP version, SES response, error string). The body snapshot
|
||||||
|
/// is intentionally kept: the PHP scripts only stored it on the error path;
|
||||||
|
/// we store it always, so a customer reply quoting an old email can be traced
|
||||||
|
/// to the exact letter that was sent.
|
||||||
|
model EmailNotificationLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
sendDate DateTime @default(now())
|
||||||
|
notificationType EmailNotificationType
|
||||||
|
/// 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO"). Only set on
|
||||||
|
/// ACCOUNT_STATUS rows; null on the other three jobs.
|
||||||
|
level Int?
|
||||||
|
/// Which servicio sourced the recipient list. CUSTOMERS for jobs 1/2/3,
|
||||||
|
/// TRUST for job 4. Tagged here so a per-line audit doesn't need to join.
|
||||||
|
servicio EmailNotificationServicio
|
||||||
|
/// FK to the customer that triggered the send. Trust-account notifications
|
||||||
|
/// resolve the owner through `Property.customerId`, so this stays set on
|
||||||
|
/// job 4 too. Null only on skipped rows where the lookup itself failed.
|
||||||
|
customerId String?
|
||||||
|
customer Customer? @relation(fields: [customerId], references: [id])
|
||||||
|
customerName String
|
||||||
|
customerEmail String
|
||||||
|
/// Subject line of the email we attempted to send.
|
||||||
|
subject String
|
||||||
|
/// For PAYMENT_CONFIRMATION: the per-customer URL the PHP code built and
|
||||||
|
/// fetched (kept verbatim so the legacy format is reproducible). Null on
|
||||||
|
/// the other three jobs — the body is built inline.
|
||||||
|
bodyRequestUrl String? @db.Text
|
||||||
|
/// The HTML body that was sent (or that would have been sent, for SKIPPED
|
||||||
|
/// rows). Stored verbatim so audit/customer-service can read the exact
|
||||||
|
/// letter that went out without re-running the render.
|
||||||
|
bodySnapshot String @db.Text
|
||||||
|
/// True when `debug` was passed — the recipient was overridden to the
|
||||||
|
/// admin address and no real customer received the mail. Kept here so a
|
||||||
|
/// "where did all these emails go" investigation finds the answer in one
|
||||||
|
/// place instead of "who ran what with what flags" archaeology.
|
||||||
|
debug Boolean @default(false)
|
||||||
|
/// SES SendEmail MessageId, when we actually got one back. Null on
|
||||||
|
/// failures, skipped rows, and dev/mock transport.
|
||||||
|
providerMessageId String?
|
||||||
|
/// Free-form provider response (or error). Trimmed to 4k chars before
|
||||||
|
/// insert so a verbose SES bounce payload can't blow the column.
|
||||||
|
providerResponse String?
|
||||||
|
status EmailNotificationStatus
|
||||||
|
error String? @db.Text
|
||||||
|
|
||||||
|
@@index([sendDate])
|
||||||
|
@@index([notificationType, sendDate])
|
||||||
|
@@index([customerId, sendDate])
|
||||||
|
@@map("email_notification_log")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors the legacy `utility_dbo.send_account_status_history` table — one
|
||||||
|
/// row per ACCOUNT_STATUS send, capturing the inputs the PHP version logged
|
||||||
|
/// for audit ("what balance, what threshold, what category of alert did we
|
||||||
|
/// fire"). Kept separate from `EmailNotificationLog` so the audit query
|
||||||
|
/// ("every red alert we ever sent this customer") doesn't have to filter by
|
||||||
|
/// notificationType; a one-row-per-send history is the whole point of the
|
||||||
|
/// legacy table.
|
||||||
|
model AccountStatusHistory {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
sendDate DateTime @default(now())
|
||||||
|
customerId String
|
||||||
|
customer Customer @relation(fields: [customerId], references: [id])
|
||||||
|
customerName String
|
||||||
|
customerEmail String
|
||||||
|
/// "DEBAJO DEL TIPO" or "EN ROJO" — the legacy literal strings. Kept
|
||||||
|
/// verbatim (not an enum) because the PHP scripts and downstream reports
|
||||||
|
/// filter by them, and "preserve legacy semantics" is the stated goal.
|
||||||
|
tipo String
|
||||||
|
/// Exchange rate at send time, kept for currency conversions downstream.
|
||||||
|
/// Null when the customer has no exchange-rate context (no FX movement).
|
||||||
|
tCambio Decimal? @db.Decimal(10, 4)
|
||||||
|
/// Customer's balance at send time, in the customer's currency. Negative
|
||||||
|
/// for red; 0..min for yellow.
|
||||||
|
balance Decimal @db.Decimal(12, 2)
|
||||||
|
/// Legacy formula: `0 - TIPO - BALANCE` — the amount the customer needs to
|
||||||
|
/// deposit to clear the threshold. Preserved verbatim even though it
|
||||||
|
/// double-subtracts; downstream reports depend on the exact figure.
|
||||||
|
solicitado Decimal @db.Decimal(12, 2)
|
||||||
|
/// 0 = yellow, 1 = red. Mirrors the legacy `level` column.
|
||||||
|
level Int
|
||||||
|
|
||||||
|
@@index([sendDate])
|
||||||
|
@@index([customerId, sendDate])
|
||||||
|
@@index([level, sendDate])
|
||||||
|
@@map("account_status_history")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Admin database operations (Operaciones): backup / restore / re-import / sync.
|
// Admin database operations (Operaciones): backup / restore / re-import / sync.
|
||||||
// Each long-running op is one OpsJob row so the web UI can poll status + tail
|
// Each long-running op is one OpsJob row so the web UI can poll status + tail
|
||||||
|
|||||||
Reference in New Issue
Block a user