diff --git a/apps/api/package.json b/apps/api/package.json index e28c3ab..b5768d4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,6 +23,7 @@ "argon2": "^0.41.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cron": "^3.2.1", "exceljs": "^4.4.0", "express-session": "^1.18.0", "passport": "^0.7.0", diff --git a/apps/api/src/notifications/notification-schedule.module.ts b/apps/api/src/notifications/notification-schedule.module.ts new file mode 100644 index 0000000..0ed032d --- /dev/null +++ b/apps/api/src/notifications/notification-schedule.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { SettingsModule } from "../settings/settings.module"; +import { NotificationScheduleService } from "./notification-schedule.service"; + +/** + * Just the cadence registry, split out for the same reason as + * `NotificationLogModule`: both `NotificationsModule` and `RenewalsModule` + * need it, and neither may import the other. + */ +@Module({ + imports: [SettingsModule], + providers: [NotificationScheduleService], + exports: [NotificationScheduleService], +}) +export class NotificationScheduleModule {} diff --git a/apps/api/src/notifications/notification-schedule.service.ts b/apps/api/src/notifications/notification-schedule.service.ts new file mode 100644 index 0000000..686a997 --- /dev/null +++ b/apps/api/src/notifications/notification-schedule.service.ts @@ -0,0 +1,203 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { SchedulerRegistry } from "@nestjs/schedule"; +import { CronJob } from "cron"; +import { SettingsService } from "../settings/settings.service"; +import type { ResolvedSetting } from "../settings/settings.service"; + +/** + * When the two automatic envíos run. + * + * Both halves of /notificaciones used to be hardcoded: pólizas swept at 06:00 + * from a `@Cron` decorator, servicios had no automatic run at all and had to + * be clicked. Neither could be changed without a redeploy. This service owns + * the cadence for both, stores it in `app_settings`, and re-installs the job + * the moment an operator saves — no restart. + * + * The owning services register their handler at boot rather than this service + * importing them: `NotificationsService` and `RenewalsService` would otherwise + * have to be injected here, and this file is imported by both. + */ + +export const SCHEDULE_TIME_ZONE = "America/Tijuana"; + +export type ScheduleKind = "servicios" | "polizas"; + +export const SCHEDULE_KINDS: ScheduleKind[] = ["servicios", "polizas"]; + +export interface NotificationSchedule { + enabled: boolean; + /** Local hour/minute in `SCHEDULE_TIME_ZONE`, not UTC — the office thinks + * in Tijuana time and DST would otherwise drift the run by an hour. */ + hour: number; + minute: number; + /** 0 = Sunday … 6 = Saturday. Empty means every day. */ + weekdays: number[]; +} + +export interface ResolvedSchedule extends ResolvedSetting { + /** The cron expression the value compiles to, shown in the UI so the + * operator can see exactly what was installed. */ + cron: string; + /** Next fire time, or null when disabled. */ + nextRun: string | null; +} + +/** + * Defaults preserve what each half did before this existed: pólizas keeps its + * 06:00 daily sweep, servicios stays OFF. Turning a mass send on is an + * operator decision — a default that starts mailing 260 customers on its own + * after a deploy is not a default, it's an incident. + */ +const DEFAULTS: Record = { + servicios: { enabled: false, hour: 7, minute: 0, weekdays: [1, 3, 5] }, + polizas: { enabled: true, hour: 6, minute: 0, weekdays: [] }, +}; + +/** Human label used in log lines and audit entries. */ +export const SCHEDULE_LABELS: Record = { + servicios: "envíos de servicios", + polizas: "avisos de renovación", +}; + +export function scheduleCron(schedule: NotificationSchedule): string { + const dow = schedule.weekdays.length + ? [...new Set(schedule.weekdays)].sort((a, b) => a - b).join(",") + : "*"; + return `${schedule.minute} ${schedule.hour} * * ${dow}`; +} + +/** Reject anything that would compile to a cron we can't install. Returns the + * normalized value, or a message naming the offending field. */ +export function parseSchedule( + raw: unknown, +): { ok: true; value: NotificationSchedule } | { ok: false; error: string } { + const v = raw as Partial | null; + if (!v || typeof v !== "object") return { ok: false, error: "Horario inválido." }; + const hour = Number(v.hour); + const minute = Number(v.minute); + if (!Number.isInteger(hour) || hour < 0 || hour > 23) { + return { ok: false, error: "La hora debe estar entre 0 y 23." }; + } + if (!Number.isInteger(minute) || minute < 0 || minute > 59) { + return { ok: false, error: "Los minutos deben estar entre 0 y 59." }; + } + const weekdays = Array.isArray(v.weekdays) ? v.weekdays.map(Number) : []; + if (weekdays.some((d) => !Number.isInteger(d) || d < 0 || d > 6)) { + return { ok: false, error: "Los días deben estar entre 0 (domingo) y 6." }; + } + return { + ok: true, + value: { + enabled: !!v.enabled, + hour, + minute, + weekdays: [...new Set(weekdays)].sort((a, b) => a - b), + }, + }; +} + +@Injectable() +export class NotificationScheduleService { + private readonly logger = new Logger(NotificationScheduleService.name); + private readonly handlers = new Map Promise>(); + + constructor( + private readonly settings: SettingsService, + private readonly registry: SchedulerRegistry, + ) {} + + /** + * Called once per kind at boot by the service that owns the sweep. Installs + * the job immediately so a freshly started process honours the stored + * cadence without waiting for someone to open the UI. + */ + async register(kind: ScheduleKind, handler: () => Promise) { + this.handlers.set(kind, handler); + await this.apply(kind); + } + + async get(kind: ScheduleKind): Promise { + const resolved = await this.settings.notificationSchedule( + kind, + DEFAULTS[kind], + ); + const cron = scheduleCron(resolved.value); + return { ...resolved, cron, nextRun: this.nextRun(kind) }; + } + + async getAll(): Promise> { + const entries = await Promise.all( + SCHEDULE_KINDS.map(async (k) => [k, await this.get(k)] as const), + ); + return Object.fromEntries(entries) as Record; + } + + async set( + kind: ScheduleKind, + schedule: NotificationSchedule, + userId: string, + ): Promise { + await this.settings.setNotificationSchedule(kind, schedule, userId); + await this.apply(kind); + return this.get(kind); + } + + /** (Re)install the cron job for one kind from whatever is stored now. */ + private async apply(kind: ScheduleKind): Promise { + const handler = this.handlers.get(kind); + if (!handler) return; + + this.remove(kind); + + const { value } = await this.settings.notificationSchedule( + kind, + DEFAULTS[kind], + ); + if (!value.enabled) { + this.logger.log(`Horario de ${SCHEDULE_LABELS[kind]}: desactivado.`); + return; + } + + const cron = scheduleCron(value); + const job = new CronJob( + cron, + () => { + void handler().catch((error) => + this.logger.error( + `Falló la corrida programada de ${SCHEDULE_LABELS[kind]}: ` + + `${(error as Error).message}`, + ), + ); + }, + null, + false, + SCHEDULE_TIME_ZONE, + ); + this.registry.addCronJob(this.jobName(kind), job); + job.start(); + this.logger.log( + `Horario de ${SCHEDULE_LABELS[kind]}: ${cron} (${SCHEDULE_TIME_ZONE}).`, + ); + } + + private remove(kind: ScheduleKind): void { + const name = this.jobName(kind); + // `deleteCronJob` throws when the job was never installed, which is the + // normal case on first apply — presence check instead of try/catch so a + // real failure still surfaces. + if (!this.registry.doesExist("cron", name)) return; + this.registry.getCronJob(name).stop(); + this.registry.deleteCronJob(name); + } + + private nextRun(kind: ScheduleKind): string | null { + const name = this.jobName(kind); + if (!this.registry.doesExist("cron", name)) return null; + const next = this.registry.getCronJob(name).nextDate(); + return next ? next.toJSDate().toISOString() : null; + } + + private jobName(kind: ScheduleKind): string { + return `notification-schedule:${kind}`; + } +} diff --git a/apps/api/src/notifications/notification-schedule.spec.ts b/apps/api/src/notifications/notification-schedule.spec.ts new file mode 100644 index 0000000..800436f --- /dev/null +++ b/apps/api/src/notifications/notification-schedule.spec.ts @@ -0,0 +1,57 @@ +import { parseSchedule, scheduleCron } from "./notification-schedule.service"; + +/** + * The cadence editor's only sharp edge: a stored value compiles to a cron + * expression that the scheduler installs verbatim. A malformed one either + * throws at install time (taking the sweep down) or silently installs the + * wrong cadence, so validation happens before anything is written. + */ + +describe("scheduleCron", () => { + it("compiles a daily schedule with no weekday filter", () => { + expect( + scheduleCron({ enabled: true, hour: 6, minute: 0, weekdays: [] }), + ).toBe("0 6 * * *"); + }); + + it("compiles the legacy Mon/Wed/Fri cadence, sorted and de-duplicated", () => { + expect( + scheduleCron({ enabled: true, hour: 7, minute: 30, weekdays: [5, 1, 3, 1] }), + ).toBe("30 7 * * 1,3,5"); + }); +}); + +describe("parseSchedule", () => { + it("normalizes weekdays and coerces enabled to a boolean", () => { + const parsed = parseSchedule({ + enabled: 1, + hour: 6, + minute: 0, + weekdays: [3, 1, 3], + }); + expect(parsed).toEqual({ + ok: true, + value: { enabled: true, hour: 6, minute: 0, weekdays: [1, 3] }, + }); + }); + + it("defaults a missing weekday list to every day", () => { + const parsed = parseSchedule({ enabled: true, hour: 0, minute: 0 }); + expect(parsed.ok && parsed.value.weekdays).toEqual([]); + }); + + it.each([ + [{ enabled: true, hour: 24, minute: 0 }, "hora"], + [{ enabled: true, hour: 6, minute: 60 }, "minutos"], + [{ enabled: true, hour: 6, minute: 0, weekdays: [7] }, "días"], + [{ enabled: true, hour: 6.5, minute: 0 }, "hora"], + ])("rejects %p", (input, field) => { + const parsed = parseSchedule(input); + expect(parsed.ok).toBe(false); + expect(!parsed.ok && parsed.error.toLowerCase()).toContain(field); + }); + + it("rejects a non-object", () => { + expect(parseSchedule(null).ok).toBe(false); + }); +}); diff --git a/apps/api/src/notifications/notification.types.ts b/apps/api/src/notifications/notification.types.ts index 5a3301e..1621b29 100644 --- a/apps/api/src/notifications/notification.types.ts +++ b/apps/api/src/notifications/notification.types.ts @@ -5,13 +5,24 @@ import { import { IsBoolean, IsEnum, IsOptional } from "class-validator"; /** - * Shared flags for the four notification jobs. Every endpoint takes the - * same shape so the UI can be uniform; each flag is documented inline so - * the per-job semantics are obvious in one place. + * Where `debug` sends everything. The PHP used `rmancinas@freakma.net`; + * same here. Exported because the flag is platform-wide — the renewal + * notices honour it too, and two copies of this address would eventually + * disagree. + */ +export const DEBUG_RECIPIENT = "rmancinas@freakma.net"; + +/** + * Shared flags for every notification send — the four servicios jobs and + * the pólizas renewal notices alike. Every endpoint takes the same shape + * so the UI can offer one set of switches for the whole screen; each flag + * is documented inline so the per-job semantics are obvious in one place. * - * `debug` — replace every recipient with the admin override - * address so a real customer never receives mail - * during a test run. Logged on every row. + * `debug` — replace every recipient with `DEBUG_RECIPIENT` so a + * real customer never receives mail during a test run. + * Logged on every row. On the renewal side a debug send + * also does NOT write the `RenewalNotice` row, so a test + * can't gate the letter the customer is still owed. * `ignoreDayRestriction` — Job 3 only: bypass the Mon/Wed/Fri (red) and * Wed-only (yellow) day gates. Off by default so * the on-demand sweep behaves like the legacy diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts index b9c4f96..2a68d58 100644 --- a/apps/api/src/notifications/notifications.controller.ts +++ b/apps/api/src/notifications/notifications.controller.ts @@ -4,6 +4,7 @@ import { Controller, Get, HttpCode, + Param, Post, Put, Query, @@ -20,6 +21,7 @@ import { Transform, Type } from "class-transformer"; import { ArrayMaxSize, IsArray, + IsBoolean, IsEnum, IsInt, IsOptional, @@ -32,6 +34,12 @@ 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"; @@ -67,6 +75,16 @@ class AdminEmailsDto { 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; } @@ -84,6 +102,7 @@ export class NotificationsController { private readonly svc: NotificationsService, private readonly audit: AuditService, private readonly settings: SettingsService, + private readonly schedule: NotificationScheduleService, ) {} /* -------------------------------------------------------------- triggers */ @@ -250,6 +269,46 @@ export class NotificationsController { 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. */ diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts index c0382dd..36989e6 100644 --- a/apps/api/src/notifications/notifications.module.ts +++ b/apps/api/src/notifications/notifications.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { NotificationLogModule } from "./notification-log.module"; +import { NotificationScheduleModule } from "./notification-schedule.module"; import { SettingsModule } from "../settings/settings.module"; import { NotificationsController } from "./notifications.controller"; import { NotificationsService } from "./notifications.service"; @@ -8,12 +9,12 @@ import { NotificationsService } from "./notifications.service"; * Mass email notifications. MailModule is global (registered in AppModule), * so this module needs no MailService import — it picks it up by injection. * - * Cron sweeps (a future `@nestjs/schedule` trigger of these four methods on - * the legacy Mon/Wed/Fri cadence) belong in this module; the per-job - * service methods are already the entry points they would call. + * The automatic sweep is registered by `NotificationsService` against + * `NotificationScheduleService`, which owns the cadence for both halves of + * /notificaciones and stores it in `app_settings`. */ @Module({ - imports: [NotificationLogModule, SettingsModule], + imports: [NotificationLogModule, NotificationScheduleModule, SettingsModule], controllers: [NotificationsController], providers: [NotificationsService], exports: [NotificationsService], diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts index 9496090..49d932b 100644 --- a/apps/api/src/notifications/notifications.service.ts +++ b/apps/api/src/notifications/notifications.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common"; +import { + Injectable, + Logger, + OnModuleInit, + ServiceUnavailableException, +} from "@nestjs/common"; import { Currency, EmailNotificationServicio, @@ -11,7 +16,9 @@ import { MailService } from "../mail/mail.service"; import { PrismaService } from "../prisma/prisma.service"; import { SettingsService } from "../settings/settings.service"; import { NotificationLogService } from "./notification-log.service"; +import { NotificationScheduleService } from "./notification-schedule.service"; import { + DEBUG_RECIPIENT, SendAttempt, NotificationJobKind, NotificationJobResponse, @@ -72,7 +79,7 @@ const RATE_LIMIT_EMAILS = 100; const PAYMENT_LOOKBACK_HOURS = 24; @Injectable() -export class NotificationsService { +export class NotificationsService implements OnModuleInit { private readonly logger = new Logger(NotificationsService.name); constructor( @@ -80,10 +87,32 @@ export class NotificationsService { private readonly mail: MailService, private readonly log: NotificationLogService, private readonly settings: SettingsService, + private readonly schedule: NotificationScheduleService, ) {} + /** The automatic servicios sweep is the same "ejecutar todos" the button + * fires. Off by default — see the defaults in `NotificationScheduleService`. */ + async onModuleInit(): Promise { + await this.schedule.register("servicios", () => this.scheduledRunAll()); + } + + /** + * Unattended run of all four jobs. Never debug, and never + * `ignoreDayRestriction`: an automatic run on the operator's own cadence is + * exactly the case the Mon/Wed/Fri gate was written for, so bypassing it + * here would mail the red list every single scheduled day. + */ + async scheduledRunAll(): Promise { + const result = await this.runAll({}); + this.logger.log( + `Corrida programada de servicios: enviados ${result.sent}, ` + + `omitidos ${result.skipped}, fallidos ${result.failed}, ` + + `jobs con error ${result.errors}.`, + ); + } + /* ============================================================================ - * Public jobs — called by the controller and by future cron sweeps alike. + * Public jobs — called by the controller, the schedule, and tests alike. * ========================================================================== */ /** Job 1 — Outstanding payments. */ @@ -802,10 +831,10 @@ export class NotificationsService { * Internals — send / log / balance helpers. * ========================================================================== */ - /** Where debug=1 sends everything. The PHP used - * `rmancinas@freakma.net`; same here. */ + /** Where debug=1 sends everything. Shared with the renewal sweep so both + * halves of /notificaciones divert to the same inbox. */ private debugEmail(): string { - return "rmancinas@freakma.net"; + return DEBUG_RECIPIENT; } /** The customer's `minimumBalance`, or null if unset. The legacy TIPO diff --git a/apps/api/src/renewals/renewals-log.spec.ts b/apps/api/src/renewals/renewals-log.spec.ts index e3f7279..825d246 100644 --- a/apps/api/src/renewals/renewals-log.spec.ts +++ b/apps/api/src/renewals/renewals-log.spec.ts @@ -72,11 +72,15 @@ function build(overrides: { }, }; + // `register` is a no-op here: these tests drive the sweep directly, so no + // cron job is ever installed. + const schedule = { register: jest.fn().mockResolvedValue(undefined) }; const service = new RenewalsService( prisma as never, { available: true, send } as never, { log: jest.fn() } as never, { record } as never, + schedule as never, ); return { service, record, send, prisma }; @@ -142,6 +146,43 @@ describe("renewal notices write the shared notification log", () => { }); }); + it("diverts a debug sweep and leaves the notice pending", async () => { + const { service, record, send, prisma } = build({}); + + const result = await service.sweep("user-1", { debug: true }); + + expect(result.sent).toBe(1); + expect(result.debug).toBe(true); + // The customer's own address is never contacted. + expect(jest.mocked(send).mock.calls[0][0]).toMatchObject({ + to: "rmancinas@freakma.net", + xTracking: "debug", + }); + expect(record.mock.calls[0][0]).toMatchObject({ + status: "SENT", + customerEmail: "rmancinas@freakma.net", + debug: true, + }); + // The letter is still owed, so nothing may gate it: no RenewalNotice row, + // and `lastSuccessfulAt` must not advance past the days we only tested. + expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled(); + const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0]; + expect(release.data.lastSuccessfulAt).toBeUndefined(); + }); + + it("sends one notice on demand in debug without marking it sent", async () => { + const { service, send, prisma } = build({}); + + const result = await service.sendOne(POLICY_ID, 1, "user-1", { + debug: true, + }); + + expect(result.debug).toBe(true); + expect(result.to).toBe("rmancinas@freakma.net"); + expect(send).toHaveBeenCalledTimes(1); + expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled(); + }); + it("does not fail a delivered notice when the log write throws", async () => { const { service, record } = build({}); record.mockRejectedValue(new Error("log table gone")); diff --git a/apps/api/src/renewals/renewals.controller.ts b/apps/api/src/renewals/renewals.controller.ts index 92e3b8d..aa791ae 100644 --- a/apps/api/src/renewals/renewals.controller.ts +++ b/apps/api/src/renewals/renewals.controller.ts @@ -10,13 +10,22 @@ import { } from "@nestjs/common"; import { Request } from "express"; import { Type } from "class-transformer"; -import { IsInt, IsString, Max, Min } from "class-validator"; +import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from "class-validator"; import { AbilityGuard } from "../auth/ability.guard"; import { AuthenticatedGuard } from "../auth/authenticated.guard"; import { RequireAbility } from "../auth/require-ability.decorator"; import { RenewalsService } from "./renewals.service"; -class SendRenewalDto { +/** The pólizas half of the shared "Flags del envío" panel. Only `debug` + * means anything here — the day gate and the send limit are estado-de-cuenta + * concepts — so the other two are simply not accepted. */ +class RenewalFlagsDto { + @IsOptional() + @IsBoolean() + debug?: boolean; +} + +class SendRenewalDto extends RenewalFlagsDto { @IsString() policyId!: string; @@ -42,8 +51,10 @@ export class RenewalsController { @Post("sweep") @RequireAbility("renewal:send") - sweep(@Req() req: Request) { - return this.renewals.sweep((req.user as { id: string }).id); + sweep(@Body() dto: RenewalFlagsDto, @Req() req: Request) { + return this.renewals.sweep((req.user as { id: string }).id, { + debug: dto?.debug, + }); } /** Send a single pending notice from the /notificaciones list. */ @@ -55,6 +66,7 @@ export class RenewalsController { dto.policyId, dto.generation, (req.user as { id: string }).id, + { debug: dto.debug }, ); } } diff --git a/apps/api/src/renewals/renewals.module.ts b/apps/api/src/renewals/renewals.module.ts index 4d9990c..c22ad23 100644 --- a/apps/api/src/renewals/renewals.module.ts +++ b/apps/api/src/renewals/renewals.module.ts @@ -1,12 +1,14 @@ import { Module } from "@nestjs/common"; import { NotificationLogModule } from "../notifications/notification-log.module"; +import { NotificationScheduleModule } from "../notifications/notification-schedule.module"; import { RenewalsController } from "./renewals.controller"; import { RenewalsService } from "./renewals.service"; @Module({ // Renewal sends write to the same `email_notification_log` the four bulk - // jobs write, so /notificaciones has one send history across both tabs. - imports: [NotificationLogModule], + // jobs write, so /notificaciones has one send history across both tabs, and + // take their cadence from the same operator-editable schedule. + imports: [NotificationLogModule, NotificationScheduleModule], controllers: [RenewalsController], providers: [RenewalsService], }) diff --git a/apps/api/src/renewals/renewals.service.ts b/apps/api/src/renewals/renewals.service.ts index fec517b..89c1288 100644 --- a/apps/api/src/renewals/renewals.service.ts +++ b/apps/api/src/renewals/renewals.service.ts @@ -4,12 +4,17 @@ import { Injectable, Logger, NotFoundException, + OnModuleInit, ServiceUnavailableException, } from "@nestjs/common"; -import { Cron } from "@nestjs/schedule"; import { AuditService } from "../common/audit.service"; import { MailService } from "../mail/mail.service"; import { NotificationLogService } from "../notifications/notification-log.service"; +import { + NotificationScheduleService, + SCHEDULE_TIME_ZONE, +} from "../notifications/notification-schedule.service"; +import { DEBUG_RECIPIENT } from "../notifications/notification.types"; import { PrismaService } from "../prisma/prisma.service"; import { RenewalLetterPolicy, @@ -25,7 +30,9 @@ export const RENEWAL_CADENCE = [ ] as const; const JOB_NAME = "renewal-email-sweep"; -const TIME_ZONE = "America/Tijuana"; +/** The window maths runs in office time; the cadence itself is owned by + * `NotificationScheduleService`, which uses the same zone. */ +const TIME_ZONE = SCHEDULE_TIME_ZONE; const DAY_MS = 86400000; export function dateInTimeZone(now: Date, timeZone = TIME_ZONE): Date { @@ -57,7 +64,7 @@ export function renewalWindow( } @Injectable() -export class RenewalsService { +export class RenewalsService implements OnModuleInit { private readonly logger = new Logger(RenewalsService.name); constructor( @@ -65,9 +72,19 @@ export class RenewalsService { private readonly mail: MailService, private readonly audit: AuditService, private readonly notificationLog: NotificationLogService, + private readonly schedule: NotificationScheduleService, ) {} - @Cron("0 6 * * *", { timeZone: TIME_ZONE }) + /** The cadence used to be a `@Cron("0 6 * * *")` literal here; it is now + * operator-editable, and the stored value defaults to that same 06:00 + * daily run. */ + async onModuleInit(): Promise { + await this.schedule.register("polizas", () => this.scheduledSweep()); + } + + /** The unattended run always sends for real: `debug` is a per-click switch + * in the UI, never persisted, so the schedule cannot inherit a forgotten + * test toggle and silently stop mailing customers. */ async scheduledSweep(): Promise { try { await this.sweep(); @@ -105,7 +122,8 @@ export class RenewalsService { ); } - async sweep(userId?: string) { + async sweep(userId?: string, flags: { debug?: boolean } = {}) { + const debug = !!flags.debug; const now = new Date(); const state = await this.acquireLock(now); @@ -138,13 +156,14 @@ export class RenewalsService { // HTTP response. await this.recordLog(policy, cadence.generation, "", { status: "SKIPPED_NO_EMAIL", + debug, }); skipped++; continue; } try { - await this.deliver(policy, cadence.generation, to, userId); + await this.deliver(policy, cadence.generation, to, userId, debug); sent++; } catch (error) { failures.push({ @@ -156,8 +175,18 @@ export class RenewalsService { } } - const result = { eligible, sent, skipped, failed: failures.length, failures }; - await this.releaseLock(failures.length === 0 ? now : null); + const result = { + eligible, + sent, + skipped, + failed: failures.length, + failures, + debug, + }; + // A debug run must not advance `lastSuccessfulAt`: it wrote no + // RenewalNotice rows, so the days it "covered" are still owed, and + // narrowing tomorrow's window back to a single day would drop them. + await this.releaseLock(!debug && failures.length === 0 ? now : null); void this.audit.log(userId, "renewalNotice.sweep", result); return result; } catch (error) { @@ -172,8 +201,17 @@ export class RenewalsService { * so a letter sent by hand is marked exactly like a swept one and drops * off the pending list. Refuses a generation already sent so a double * click can't mail the customer twice. + * + * Under `debug` the notice is NOT marked as sent, so the row stays in the + * pending list — the customer has still not been told anything. */ - async sendOne(policyId: string, generation: number, userId?: string) { + async sendOne( + policyId: string, + generation: number, + userId?: string, + flags: { debug?: boolean } = {}, + ) { + const debug = !!flags.debug; if (!this.mail.available) { throw new ServiceUnavailableException( "El servicio de correo no está configurado.", @@ -195,16 +233,21 @@ export class RenewalsService { throw new BadRequestException("El cliente no tiene correo registrado."); } - const { sentAt, providerMessageId } = await this.deliver( + const { sentAt, providerMessageId, addressedTo } = await this.deliver( policy, generation, to, userId, + debug, ); return { policyId, generation, - to, + // The address the mail actually went to — under debug that is the + // override inbox, and the UI says so rather than claiming the customer + // was notified. + to: addressedTo, + debug, sentAt: sentAt.toISOString(), providerMessageId, }; @@ -216,67 +259,79 @@ export class RenewalsService { * pending list, and an `email_notification_log` row, which is the send * history the /notificaciones "Registro de envíos" reads. A failed send * writes only the second — there is no notice to gate on — and rethrows so - * the sweep counts it as a failure. */ + * the sweep counts it as a failure. + * + * Under `debug` the mail is diverted to `DEBUG_RECIPIENT` and the + * `RenewalNotice` row is deliberately skipped: the customer was not + * notified, so nothing may gate the letter they are still owed. Only the + * log row is written, flagged `debug`. */ private async deliver( policy: RenewalLetterPolicy, generation: number, to: string, userId?: string, + debug = false, ) { const letter = toRenewalLetterRow(policy, generation); const message = renderRenewalEmail(letter); + const addressedTo = debug ? DEBUG_RECIPIENT : to; let result: Awaited>; try { result = await this.mail.send({ - to, + to: addressedTo, toName: letter.customerName, subject: message.subject, html: message.html, - xTracking: "renewals", + xTracking: debug ? "debug" : "renewals", }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - await this.recordLog(policy, generation, to, { + await this.recordLog(policy, generation, addressedTo, { status: "FAILED", error: detail, + debug, }); throw error; } const sentAt = new Date(); - await this.prisma.renewalNotice.upsert({ - where: { - policyId_generation: { policyId: policy.id, generation }, - }, - create: { - policyId: policy.id, - generation, - channel: "EMAIL", - sentAt, - sentById: userId, - providerMessageId: result.messageId, - }, - update: { - channel: "EMAIL", - sentAt, - sentById: userId, - providerMessageId: result.messageId, - }, - }); - await this.recordLog(policy, generation, to, { + if (!debug) { + await this.prisma.renewalNotice.upsert({ + where: { + policyId_generation: { policyId: policy.id, generation }, + }, + create: { + policyId: policy.id, + generation, + channel: "EMAIL", + sentAt, + sentById: userId, + providerMessageId: result.messageId, + }, + update: { + channel: "EMAIL", + sentAt, + sentById: userId, + providerMessageId: result.messageId, + }, + }); + } + await this.recordLog(policy, generation, addressedTo, { status: "SENT", providerMessageId: result.messageId || undefined, providerResponse: result.response || undefined, sendDate: sentAt, + debug, }); void this.audit.log(userId, "renewalNotice.send", { policyId: policy.id, generation, + debug, providerMessageId: result.messageId, }); - return { sentAt, providerMessageId: result.messageId }; + return { sentAt, providerMessageId: result.messageId, addressedTo }; } /** @@ -299,6 +354,7 @@ export class RenewalsService { providerResponse?: string; error?: string; sendDate?: Date; + debug?: boolean; }, ): Promise { const letter = toRenewalLetterRow(policy, generation); @@ -317,7 +373,7 @@ export class RenewalsService { subject: message.subject, bodySnapshot: message.html, status: outcome.status, - debug: false, + debug: !!outcome.debug, providerMessageId: outcome.providerMessageId, providerResponse: outcome.providerResponse, error: outcome.error, diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index eb17792..18956f6 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -18,6 +18,10 @@ import { PrismaService } from "../prisma/prisma.service"; export const SETTING_KEYS = { /** Comma-separated recipients of the per-job notification summary. */ notificationAdminEmails: "notification.adminEmails", + /** JSON cadence of the automatic servicios sweep. */ + scheduleServicios: "notification.schedule.servicios", + /** JSON cadence of the automatic pólizas renewal sweep. */ + schedulePolizas: "notification.schedule.polizas", } as const; /** Where a resolved value came from. Shown in the UI. */ @@ -114,6 +118,57 @@ export class SettingsService { return this.notificationAdminEmails(); } + /** + * Cadence of one automatic envío, stored as JSON. + * + * No env rung on this ladder: a schedule was never an environment variable + * (it was a `@Cron` literal in the source), so the only two sources are the + * operator's row and the caller's default — which is the previous hardcoded + * behaviour. A row that fails to parse is treated as absent and logged + * rather than thrown: a bad JSON blob must not take the scheduler down with + * it, and falling back to the shipped cadence is the safe reading. + */ + async notificationSchedule( + kind: "servicios" | "polizas", + fallback: T, + ): Promise> { + const key = + kind === "servicios" + ? SETTING_KEYS.scheduleServicios + : SETTING_KEYS.schedulePolizas; + const row = await this.read(key); + if (row) { + try { + return { + value: { ...fallback, ...(JSON.parse(row.value) as T) }, + source: "db", + updatedAt: row.updatedAt, + updatedById: row.updatedById, + }; + } catch (error) { + this.logger.warn( + `Setting ${key} is not valid JSON, using the default: ` + + `${(error as Error).message}`, + ); + } + } + return { value: fallback, source: "default", updatedAt: null, updatedById: null }; + } + + async setNotificationSchedule( + kind: "servicios" | "polizas", + schedule: unknown, + userId: string, + ): Promise { + await this.write( + kind === "servicios" + ? SETTING_KEYS.scheduleServicios + : SETTING_KEYS.schedulePolizas, + JSON.stringify(schedule), + userId, + ); + } + private read(key: string) { return this.prisma.appSetting.findUnique({ where: { key } }); } diff --git a/apps/web/src/components/Notificaciones.tsx b/apps/web/src/components/Notificaciones.tsx index 624d1f8..756cacb 100644 --- a/apps/web/src/components/Notificaciones.tsx +++ b/apps/web/src/components/Notificaciones.tsx @@ -4,6 +4,9 @@ import { useState } from "react"; import { useCan } from "@/lib/abilities"; import { NotificacionesServicios } from "@/components/NotificacionesServicios"; import { NotificacionesPolizas } from "@/components/NotificacionesPolizas"; +import { NotificationFlagsCard } from "@/components/NotificationFlagsCard"; +import { NotificationScheduleCard } from "@/components/NotificationScheduleCard"; +import type { NotificationFlags } from "@/lib/api"; /** * Notificaciones — one screen, two subsections: @@ -16,6 +19,12 @@ import { NotificacionesPolizas } from "@/components/NotificacionesPolizas"; * Both are "tell a customer something by email", so they are modes of one * screen rather than two menu entries. `/renovaciones` still resolves here on * the pólizas tab so old bookmarks keep working (same pattern as Captura). + * + * Two things are owned by this shell rather than by a tab, because they are + * true of every notification: the send flags (`debug` in particular, which the + * pólizas half honours exactly like the servicios half) and the automatic + * cadence of both sweeps. Keeping the flags here also means switching tabs + * cannot silently drop a `debug` the operator just ticked. */ export type NotificacionesTab = "servicios" | "polizas"; @@ -45,6 +54,8 @@ export function Notificaciones({ const [tab, setTab] = useState( tabs.some((t) => t.key === initialTab) ? initialTab : "servicios", ); + // Defaults to debug ON: the safe end of the switch is the one you land on. + const [flags, setFlags] = useState({ debug: true }); if (!canNotify && !canRenew) { return ( @@ -64,6 +75,15 @@ export function Notificaciones({

+
+ + +
+ {tabs.length > 1 && (
{tabs.map((t) => ( @@ -81,7 +101,11 @@ export function Notificaciones({
)} - {tab === "servicios" ? : } + {tab === "servicios" ? ( + + ) : ( + + )} ); } diff --git a/apps/web/src/components/NotificacionesPolizas.tsx b/apps/web/src/components/NotificacionesPolizas.tsx index 729ef7f..dfc6ad5 100644 --- a/apps/web/src/components/NotificacionesPolizas.tsx +++ b/apps/web/src/components/NotificacionesPolizas.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { useCan } from "@/lib/abilities"; import { formatDate, formatMoney } from "@/lib/labels"; import { NotificationLogPanel } from "@/components/NotificationLogPanel"; -import { apiFetch, POLIZAS_LOG_SCOPE } from "@/lib/api"; +import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api"; /** * Renewal notices — the "Pólizas" half of /notificaciones. Shows which @@ -17,6 +17,11 @@ import { apiFetch, POLIZAS_LOG_SCOPE } from "@/lib/api"; * reads, so "Registro de envíos" below is the same component with the * POLICIES slice — failures and no-email skips included, which the pending * list alone cannot show. + * + * `debug` comes from the shared flags card above the tabs and means the same + * thing here as it does for servicios: the mail is diverted to the override + * inbox. It additionally does NOT mark the notice as sent, so a test send + * leaves the row exactly where it was — pending. */ export interface RenewalLetter { @@ -40,12 +45,15 @@ export interface RenewalSweepResult { skipped: number; failed: number; failures: { policyId: string; generation: number; error: string }[]; + debug: boolean; } export interface RenewalSendResult { policyId: string; generation: number; + /** Where the mail actually went — the override inbox under debug. */ to: string; + debug: boolean; sentAt: string; providerMessageId?: string; } @@ -56,8 +64,9 @@ const GENERATION_LABEL: Record = { 3: "Tercer aviso (7 días después)", }; -export function NotificacionesPolizas() { +export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) { const allowed = useCan("renewal:send"); + const debug = !!flags.debug; const [days, setDays] = useState(30); const [pending, setPending] = useState(null); const [pendingError, setPendingError] = useState(null); @@ -89,14 +98,28 @@ export function NotificacionesPolizas() { }, [allowed, refresh]); async function handleSweep() { + // Only worth confirming when debug is off — that is the case where real + // customers receive mail. Mirrors "Ejecutar todos" on the servicios tab. + if (!debug) { + const ok = window.confirm( + "debug está desactivado: los avisos irán a los correos reales de los clientes. ¿Ejecutar el barrido?", + ); + if (!ok) return; + } setActionError(null); setNotice(null); setSweeping(true); try { const result = await apiFetch("/renewals/sweep", { method: "POST", + body: JSON.stringify({ debug }), }); - setNotice(`Enviados ${result.sent} avisos (${result.failed} con error).`); + setNotice( + `Enviados ${result.sent} avisos (${result.failed} con error).` + + (result.debug + ? " Modo debug: fueron al buzón de pruebas y siguen pendientes." + : ""), + ); setLogToken((t) => t + 1); await refresh(); } catch (e) { @@ -122,9 +145,14 @@ export function NotificacionesPolizas() { body: JSON.stringify({ policyId: letter.policyId, generation: letter.generation, + debug, }), }); - setNotice(`Aviso enviado a ${result.to}.`); + setNotice( + result.debug + ? `Prueba enviada a ${result.to}. El aviso sigue pendiente: el cliente no ha recibido nada.` + : `Aviso enviado a ${result.to}.`, + ); setLogToken((t) => t + 1); await refresh(); } catch (e) { @@ -156,10 +184,10 @@ export function NotificacionesPolizas() { return (

- El sistema ejecuta un barrido diario a las 06:00 hora local que notifica - a los clientes a 30, 15 y 7 días antes o después del vencimiento de su - póliza. Esta sección muestra qué avisos están pendientes y permite - ejecutarlo manualmente. + El sistema ejecuta un barrido automático (ver «Programación de envíos» + arriba) que notifica a los clientes a 30, 15 y 7 días antes o después + del vencimiento de su póliza. Esta sección muestra qué avisos están + pendientes y permite ejecutarlo manualmente.

{actionError &&
{actionError}
} diff --git a/apps/web/src/components/NotificacionesServicios.tsx b/apps/web/src/components/NotificacionesServicios.tsx index 74fa640..94f074c 100644 --- a/apps/web/src/components/NotificacionesServicios.tsx +++ b/apps/web/src/components/NotificacionesServicios.tsx @@ -30,6 +30,9 @@ import type { * triggers for the four jobs plus a paged log browser. Gated on * `notification:send`; a STAFF viewer sees the read-only log table but not the * trigger buttons. + * + * The send flags come from the shell above the tabs — they are shared with the + * pólizas half — so this component only consumes them. */ type JobKind = "outstanding" | "payment" | "account" | "trust"; @@ -85,10 +88,9 @@ const JOB_TITLES: Record = JOBS.reduce( {} as Record, ); -export function NotificacionesServicios() { +export function NotificacionesServicios({ flags }: { flags: NotificationFlags }) { const allowed = useCan("notification:send"); - const [flags, setFlags] = useState({ debug: true }); const [stats, setStats] = useState(null); /** Raised after every run so the shared log panel reloads. */ const [logToken, setLogToken] = useState(0); @@ -176,73 +178,14 @@ export function NotificacionesServicios() { {error &&
{error}
}
-

Flags del envío

-
- - - -
- +

Ejecutar ahora

diff --git a/apps/web/src/components/NotificationFlagsCard.tsx b/apps/web/src/components/NotificationFlagsCard.tsx new file mode 100644 index 0000000..9cf25eb --- /dev/null +++ b/apps/web/src/components/NotificationFlagsCard.tsx @@ -0,0 +1,92 @@ +"use client"; + +import type { NotificationFlags } from "@/lib/api"; + +/** + * The "Flags del envío" panel. It lives in the /notificaciones shell above the + * tabs, not inside one of them, because the flags are platform-wide: `debug` + * governs the pólizas avisos exactly as it governs the four servicios jobs, + * and a switch that only protected half the screen was the bug this fixes. + * + * State is per-visit, never persisted — see the note on the schedule card. A + * stored `debug` would survive a reload and silently swallow real customer + * mail; the automatic corridas therefore always send for real. + */ + +export function NotificationFlagsCard({ + flags, + onChange, + disabled = false, +}: { + flags: NotificationFlags; + onChange: (next: NotificationFlags) => void; + disabled?: boolean; +}) { + const set = (patch: Partial) => + onChange({ ...flags, ...patch }); + + return ( +
+

Flags del envío

+

+ Se aplican a todo lo que se envía desde esta pantalla — servicios y + pólizas — y solo a los envíos manuales. Las corridas automáticas siempre + mandan de verdad. +

+
+ + + +
+
+ ); +} diff --git a/apps/web/src/components/NotificationScheduleCard.tsx b/apps/web/src/components/NotificationScheduleCard.tsx new file mode 100644 index 0000000..dbeb23f --- /dev/null +++ b/apps/web/src/components/NotificationScheduleCard.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useCan } from "@/lib/abilities"; +import { + getNotificationSchedules, + setNotificationSchedule, + type NotificationSchedule, + type NotificationSchedules, + type ScheduleKind, +} from "@/lib/api"; +import { formatDateTime } from "@/lib/labels"; + +/** + * When the two automatic envíos run. + * + * Both cadences used to be source code: pólizas barría a las 06:00 desde un + * `@Cron` en el servidor y servicios no corría solo en absoluto. Cambiar + * cualquiera de los dos era un redeploy. Ahora se guardan en `app_settings` y + * el servidor reinstala el job al guardar — sin reinicio. + * + * Los flags de la tarjeta de arriba NO se aplican aquí: una corrida + * automática siempre manda de verdad. + */ + +const KIND_LABEL: Record = { + servicios: "Servicios", + polizas: "Pólizas", +}; + +const KIND_HINT: Record = { + servicios: + "Ejecuta los cuatro envíos en orden, igual que el botón «Ejecutar todos». El estado de cuenta sigue respetando sus gates de lunes/miércoles/viernes.", + polizas: + "Barrido de avisos de renovación: 30 y 15 días antes del vencimiento, y 7 días después.", +}; + +const DAYS = [ + { value: 0, label: "Dom" }, + { value: 1, label: "Lun" }, + { value: 2, label: "Mar" }, + { value: 3, label: "Mié" }, + { value: 4, label: "Jue" }, + { value: 5, label: "Vie" }, + { value: 6, label: "Sáb" }, +]; + +function timeValue(s: NotificationSchedule): string { + return `${String(s.hour).padStart(2, "0")}:${String(s.minute).padStart(2, "0")}`; +} + +function describe(s: NotificationSchedule): string { + if (!s.enabled) return "Desactivado — solo se envía manualmente."; + const days = s.weekdays.length + ? s.weekdays + .map((d) => DAYS.find((x) => x.value === d)?.label ?? d) + .join(", ") + : "todos los días"; + return `${days} a las ${timeValue(s)} (hora de Tijuana).`; +} + +export function NotificationScheduleCard() { + const canEdit = useCan("setting:manage"); + + const [schedules, setSchedules] = useState(null); + const [drafts, setDrafts] = useState>>({}); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(null); + + const load = useCallback(async () => { + try { + setSchedules(await getNotificationSchedules()); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + function startEdit(kind: ScheduleKind) { + if (!schedules) return; + setDrafts((d) => ({ ...d, [kind]: { ...schedules[kind].value } })); + setEditing(kind); + setSaved(null); + setError(null); + } + + async function save(kind: ScheduleKind) { + const draft = drafts[kind]; + if (!draft) return; + setSaving(true); + setError(null); + try { + const result = await setNotificationSchedule(kind, draft); + setSchedules((prev) => (prev ? { ...prev, [kind]: result } : prev)); + setEditing(null); + setSaved(kind); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + } + + if (!schedules) { + return ( +
+

Programación de envíos

+ {error ? ( +
+ {error} +
+ ) : ( +

+ Cargando… +

+ )} +
+ ); + } + + return ( +
+

Programación de envíos

+

+ Cuándo corre solo cada envío. Los cambios aplican de inmediato, sin + reiniciar el servidor. Una corrida automática nunca usa los flags de + arriba: siempre manda a los clientes reales. +

+ + {error && ( +
+ {error} +
+ )} + +
+ {(Object.keys(KIND_LABEL) as ScheduleKind[]).map((kind) => { + const current = schedules[kind]; + const draft = drafts[kind]; + const isEditing = editing === kind && draft; + + return ( +
+
+
+ {KIND_LABEL[kind]} +

+ {KIND_HINT[kind]} +

+
+ {canEdit && !isEditing && ( + + )} +
+ + {isEditing ? ( +
+ + + + +
+ + Días (ninguno seleccionado = todos los días) + +
+ {DAYS.map((d) => { + const on = draft.weekdays.includes(d.value); + return ( + + ); + })} +
+
+ +
+ + +
+
+ ) : ( +
+

+ {describe(current.value)} +

+

+ {current.cron} + {current.nextRun && + ` · próxima corrida: ${formatDateTime(current.nextRun)}`} + {current.source === "default" && + " · valor por omisión, nadie lo ha cambiado"} + {current.updatedAt && + ` · última edición: ${formatDateTime(current.updatedAt)}`} + {saved === kind && " · guardado"} +

+
+ )} +
+ ); + })} +
+ + {!canEdit && ( +

+ Solo un ADMIN puede cambiar la programación. +

+ )} +
+ ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f4f77b3..ac9d241 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1211,6 +1211,46 @@ export function setNotificationAdminEmails( }); } +/* ----------------------------------------------------- envío scheduling */ + +/** The two automatic envíos, one per /notificaciones tab. */ +export type ScheduleKind = "servicios" | "polizas"; + +export interface NotificationSchedule { + enabled: boolean; + /** Local hour/minute in America/Tijuana. */ + hour: number; + minute: number; + /** 0 = domingo … 6 = sábado. Vacío = todos los días. */ + weekdays: number[]; +} + +export interface ResolvedSchedule { + value: NotificationSchedule; + source: SettingSource; + updatedAt: string | null; + updatedById: string | null; + /** Expression the value compiles to, shown verbatim in the UI. */ + cron: string; + nextRun: string | null; +} + +export type NotificationSchedules = Record; + +export function getNotificationSchedules(): Promise { + return apiFetch("/notifications/settings/schedule"); +} + +export function setNotificationSchedule( + kind: ScheduleKind, + schedule: NotificationSchedule, +): Promise { + return apiFetch(`/notifications/settings/schedule/${kind}`, { + method: "PUT", + body: JSON.stringify(schedule), + }); +} + export function getNotificationStats( servicio?: NotificationServicio[], ): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2736ee..fe35bbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: class-validator: specifier: ^0.14.1 version: 0.14.4 + cron: + specifier: ^3.2.1 + version: 3.2.1 exceljs: specifier: ^4.4.0 version: 4.4.0