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.
+