Files
jorgecuadros-platform/apps/api/src/notifications/notifications.controller.ts
T
rmancinas a52e59cbc5 feat(notificaciones): mass email notifications over SES
Replaces the four legacy PHP scripts under email.notifications/send*.php
with a single NestJS module. Four jobs (outstanding payments, payment
confirmations, account-status alerts with day-of-week gates, trust
payment confirmations) share one MailService modelled on StorageService:
env-driven SES client, null fallback in dev with console logging, refuses
to send in production when unconfigured.

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

Web adds /notificaciones with four trigger cards, a flags panel, and a
paginated log browser. New notification:send ability gates all four
endpoints at MANAGER, matching the renewal:send trust tier.
2026-08-02 02:04:14 -07:00

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;
}
}