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