Replaces the four legacy PHP scripts under email.notifications/send*.php with a single NestJS module. Four jobs (outstanding payments, payment confirmations, account-status alerts with day-of-week gates, trust payment confirmations) share one MailService modelled on StorageService: env-driven SES client, null fallback in dev with console logging, refuses to send in production when unconfigured. Schema adds email_notification_log (every attempt, sent/failed/skipped) and account_status_history (one row per threshold hit, Job 3). Enums encode the legacy wire shape so external log scrapers keep parsing notificationType keys verbatim. Web adds /notificaciones with four trigger cards, a flags panel, and a paginated log browser. New notification:send ability gates all four endpoints at MANAGER, matching the renewal:send trust tier.
175 lines
5.3 KiB
TypeScript
175 lines
5.3 KiB
TypeScript
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;
|
|
}
|
|
}
|