The "Flags del envío" panel lived inside the Servicios tab and only
governed the four bulk jobs. The pólizas half had no debug at all, so
there was no way to test a renewal notice without mailing a real
customer. The panel now lives in the /notificaciones shell above the
tabs and both halves read it.
`debug` on the renewal path diverts to the same override inbox as the
servicios jobs and deliberately does NOT write the `RenewalNotice` row
or advance the sweep's `lastSuccessfulAt` — the customer was not
notified, so nothing may gate the letter they are still owed.
`ignoreDayRestriction` and `useEmailLimit` stay estado-de-cuenta-only
and are labelled as such.
Both automatic sweeps are now operator-editable. The renewal cadence
was a `@Cron("0 6 * * *")` literal and servicios had no automatic run
at all; both now resolve through `NotificationScheduleService`, which
stores the cadence in `app_settings` and reinstalls the cron job on
save — no redeploy, no restart. Defaults preserve current behaviour:
pólizas 06:00 daily, servicios off. A scheduled run never inherits the
UI flags; it always sends for real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
332 lines
10 KiB
TypeScript
332 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|