import { BadRequestException, Body, Controller, Get, HttpCode, Param, Post, Put, Query, Req, UseGuards, } from "@nestjs/common"; import { Request } from "express"; import { EmailNotificationServicio, EmailNotificationStatus, EmailNotificationType, } from "@jorgecuadros/database"; import { Transform, Type } from "class-transformer"; import { ArrayMaxSize, IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, 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 { invalidEmails, SettingsService } from "../settings/settings.service"; import { NotificationScheduleService, parseSchedule, SCHEDULE_KINDS, ScheduleKind, } from "./notification-schedule.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; /** One or more servicios, comma-separated. The /notificaciones tabs each * read their own slice of the one log: Servicios passes * `CUSTOMERS,TRUST`, Pólizas passes `POLICIES`. Omitted = every servicio. */ @IsOptional() @Transform(({ value }) => typeof value === "string" ? value.split(",").map((s) => s.trim()).filter(Boolean) : value, ) @IsEnum(EmailNotificationServicio, { each: true }) servicio?: EmailNotificationServicio[]; @IsOptional() @IsEnum(EmailNotificationStatus) status?: EmailNotificationStatus; @IsOptional() @IsEnum(["sent", "failed", "skipped", "all"]) view?: "sent" | "failed" | "skipped" | "all"; } /** An empty array is valid and means "send no summaries" — the cap only * exists so a paste accident can't write an unbounded blob. */ class AdminEmailsDto { @IsArray() @ArrayMaxSize(50) @IsString({ each: true }) emails!: string[]; } /** Cadence of one automatic envío. Ranges are re-checked by `parseSchedule`, * which is also what the scheduler itself uses — the decorators here only * reject wrong *types* so a bad payload fails at the edge. */ class ScheduleDto { @IsBoolean() enabled!: boolean; @IsInt() @Min(0) @Max(23) hour!: number; @IsInt() @Min(0) @Max(59) minute!: number; @IsOptional() @IsArray() @IsInt({ each: true }) weekdays?: number[]; } 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, private readonly settings: SettingsService, private readonly schedule: NotificationScheduleService, ) {} /* -------------------------------------------------------------- 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; } /** * Run all four jobs sequentially with one set of flags. Audited as a * single `notification.run-all.run` entry carrying the aggregate totals * plus each job's outcome — the per-job endpoints are NOT re-audited, so * the log has exactly one row per staff click. */ @Post("run-all") @RequireAbility("notification:send") @HttpCode(200) async runAll( @Body() body: RunJobDto, @Query() query: RunJobDto, @Req() req: Request, ) { const flags = { ...query, ...body }; const result = await this.svc.runAll(flags); void this.audit.log(actingId(req), "notification.run-all.run", { debug: !!flags.debug, ignoreDayRestriction: !!flags.ignoreDayRestriction, useEmailLimit: !!flags.useEmailLimit, sent: result.sent, skipped: result.skipped, failed: result.failed, errors: result.errors, jobs: result.jobs.map((j) => ({ kind: j.kind, ok: j.ok })), }); 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(@Query() q: ListLogDto) { return this.svc.stats(q.servicio); } /* -------------------------------------------------------------- settings */ /** Who receives the per-job summary email. Readable by any logged-in user * so the UI can show the current list; editing needs `setting:manage`. */ @Get("settings/admin-emails") adminEmails() { return this.settings.notificationAdminEmails(); } @Put("settings/admin-emails") @RequireAbility("setting:manage") async setAdminEmails(@Body() dto: AdminEmailsDto, @Req() req: Request) { const emails = dto.emails.map((e) => e.trim()).filter(Boolean); const bad = invalidEmails(emails); if (bad.length) { throw new BadRequestException( `Correo inválido: ${bad.join(", ")}`, ); } const result = await this.settings.setNotificationAdminEmails( emails, actingId(req), ); void this.audit.log(actingId(req), "notification.settings.admin-emails", { emails, }); return result; } /* -------------------------------------------------------------- schedule */ /** * Cadence of both automatic envíos. Readable by any logged-in user so the * screen can show "próxima corrida" without needing edit rights; changing * it needs `setting:manage`, same as the summary recipients. */ @Get("settings/schedule") schedules() { return this.schedule.getAll(); } @Put("settings/schedule/:kind") @RequireAbility("setting:manage") async setSchedule( @Param("kind") kind: string, @Body() dto: ScheduleDto, @Req() req: Request, ) { if (!SCHEDULE_KINDS.includes(kind as ScheduleKind)) { throw new BadRequestException( `Horario desconocido: ${kind}. Use ${SCHEDULE_KINDS.join(" o ")}.`, ); } const parsed = parseSchedule({ ...dto, weekdays: dto.weekdays ?? [] }); if (!parsed.ok) throw new BadRequestException(parsed.error); const result = await this.schedule.set( kind as ScheduleKind, parsed.value, actingId(req), ); void this.audit.log(actingId(req), "notification.settings.schedule", { kind, ...parsed.value, cron: result.cron, }); return result; } /** Resolve the UI's coarse view tabs to concrete statuses. An explicit * `status` wins. "Omitidos" covers both SKIPPED_* variants, which is why * this returns a list rather than a single value. */ 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 [ EmailNotificationStatus.SKIPPED_NO_EMAIL, EmailNotificationStatus.SKIPPED_GATE, ]; } return undefined; } }