feat(notificaciones): global send flags + editable schedules
The "Flags del envío" panel lived inside the Servicios tab and only
governed the four bulk jobs. The pólizas half had no debug at all, so
there was no way to test a renewal notice without mailing a real
customer. The panel now lives in the /notificaciones shell above the
tabs and both halves read it.
`debug` on the renewal path diverts to the same override inbox as the
servicios jobs and deliberately does NOT write the `RenewalNotice` row
or advance the sweep's `lastSuccessfulAt` — the customer was not
notified, so nothing may gate the letter they are still owed.
`ignoreDayRestriction` and `useEmailLimit` stay estado-de-cuenta-only
and are labelled as such.
Both automatic sweeps are now operator-editable. The renewal cadence
was a `@Cron("0 6 * * *")` literal and servicios had no automatic run
at all; both now resolve through `NotificationScheduleService`, which
stores the cadence in `app_settings` and reinstalls the cron job on
save — no redeploy, no restart. Defaults preserve current behaviour:
pólizas 06:00 daily, servicios off. A scheduled run never inherits the
UI flags; it always sends for real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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<NotificationSchedule> {
|
||||
/** 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<ScheduleKind, NotificationSchedule> = {
|
||||
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<ScheduleKind, string> = {
|
||||
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<NotificationSchedule> | 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<ScheduleKind, () => Promise<unknown>>();
|
||||
|
||||
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<unknown>) {
|
||||
this.handlers.set(kind, handler);
|
||||
await this.apply(kind);
|
||||
}
|
||||
|
||||
async get(kind: ScheduleKind): Promise<ResolvedSchedule> {
|
||||
const resolved = await this.settings.notificationSchedule(
|
||||
kind,
|
||||
DEFAULTS[kind],
|
||||
);
|
||||
const cron = scheduleCron(resolved.value);
|
||||
return { ...resolved, cron, nextRun: this.nextRun(kind) };
|
||||
}
|
||||
|
||||
async getAll(): Promise<Record<ScheduleKind, ResolvedSchedule>> {
|
||||
const entries = await Promise.all(
|
||||
SCHEDULE_KINDS.map(async (k) => [k, await this.get(k)] as const),
|
||||
);
|
||||
return Object.fromEntries(entries) as Record<ScheduleKind, ResolvedSchedule>;
|
||||
}
|
||||
|
||||
async set(
|
||||
kind: ScheduleKind,
|
||||
schedule: NotificationSchedule,
|
||||
userId: string,
|
||||
): Promise<ResolvedSchedule> {
|
||||
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<void> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<ReturnType<MailService["send"]>>;
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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<T>(
|
||||
kind: "servicios" | "polizas",
|
||||
fallback: T,
|
||||
): Promise<ResolvedSetting<T>> {
|
||||
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<void> {
|
||||
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 } });
|
||||
}
|
||||
|
||||
@@ -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<NotificacionesTab>(
|
||||
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<NotificationFlags>({ debug: true });
|
||||
|
||||
if (!canNotify && !canRenew) {
|
||||
return (
|
||||
@@ -64,6 +75,15 @@ export function Notificaciones({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 16, marginBottom: 20 }}>
|
||||
<NotificationFlagsCard
|
||||
flags={flags}
|
||||
onChange={setFlags}
|
||||
disabled={!canNotify && !canRenew}
|
||||
/>
|
||||
<NotificationScheduleCard />
|
||||
</div>
|
||||
|
||||
{tabs.length > 1 && (
|
||||
<div className="seg" role="tablist" style={{ marginBottom: 20 }}>
|
||||
{tabs.map((t) => (
|
||||
@@ -81,7 +101,11 @@ export function Notificaciones({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "servicios" ? <NotificacionesServicios /> : <NotificacionesPolizas />}
|
||||
{tab === "servicios" ? (
|
||||
<NotificacionesServicios flags={flags} />
|
||||
) : (
|
||||
<NotificacionesPolizas flags={flags} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<number, string> = {
|
||||
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<RenewalLetter[] | null>(null);
|
||||
const [pendingError, setPendingError] = useState<string | null>(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<RenewalSweepResult>("/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 (
|
||||
<div style={{ display: "grid", gap: 20 }}>
|
||||
<p className="muted" style={{ maxWidth: 760, margin: 0 }}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
{actionError && <div className="state-box state-error">{actionError}</div>}
|
||||
|
||||
@@ -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<JobKind, string> = JOBS.reduce(
|
||||
{} as Record<JobKind, string>,
|
||||
);
|
||||
|
||||
export function NotificacionesServicios() {
|
||||
export function NotificacionesServicios({ flags }: { flags: NotificationFlags }) {
|
||||
const allowed = useCan("notification:send");
|
||||
|
||||
const [flags, setFlags] = useState<NotificationFlags>({ debug: true });
|
||||
const [stats, setStats] = useState<NotificationStats | null>(null);
|
||||
/** Raised after every run so the shared log panel reloads. */
|
||||
const [logToken, setLogToken] = useState(0);
|
||||
@@ -176,73 +178,14 @@ export function NotificacionesServicios() {
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Flags del envío</h2>
|
||||
<div style={{ display: "grid", gap: 4, marginTop: 12 }}>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags.debug}
|
||||
disabled={!allowed}
|
||||
onChange={(e) => setFlags((f) => ({ ...f, debug: e.target.checked }))}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>debug</strong> — reescribe todos los destinatarios a{" "}
|
||||
<code>rmancinas@freakma.net</code>. Ningún cliente real recibe el
|
||||
correo mientras esté activo.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags.ignoreDayRestriction}
|
||||
disabled={!allowed}
|
||||
onChange={(e) =>
|
||||
setFlags((f) => ({ ...f, ignoreDayRestriction: e.target.checked }))
|
||||
}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>ignoreDayRestriction</strong> — salta los gates de
|
||||
Mon/Wed/Fri del estado de cuenta. Útil para disparar en cualquier
|
||||
día sin esperar a la próxima corrida.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 0 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags.useEmailLimit}
|
||||
disabled={!allowed}
|
||||
onChange={(e) =>
|
||||
setFlags((f) => ({ ...f, useEmailLimit: e.target.checked }))
|
||||
}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>useEmailLimit</strong> — pausa el estado de cuenta cada 100
|
||||
correos durante 1 hora. Vestigio de la era SMTP; SES no lo necesita.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h2 className="section-title">Ejecutar ahora</h2>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTop: "1px solid var(--line)",
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@@ -255,8 +198,9 @@ export function NotificacionesServicios() {
|
||||
</button>
|
||||
<span className="muted small">
|
||||
Dispara los cuatro envíos en orden (pagos pendientes, confirmación
|
||||
de pago, estado de cuenta, fideicomiso) con estos mismos flags. Si
|
||||
uno falla, los demás continúan.
|
||||
de pago, estado de cuenta, fideicomiso) con los flags de arriba. Si
|
||||
uno falla, los demás continúan. Es lo mismo que ejecuta la corrida
|
||||
programada de Servicios.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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<NotificationFlags>) =>
|
||||
onChange({ ...flags, ...patch });
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Flags del envío</h2>
|
||||
<p className="muted small" style={{ marginTop: 4, marginBottom: 0, maxWidth: 620 }}>
|
||||
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.
|
||||
</p>
|
||||
<div style={{ display: "grid", gap: 4, marginTop: 14 }}>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags.debug}
|
||||
disabled={disabled}
|
||||
onChange={(e) => set({ debug: e.target.checked })}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>debug</strong> — reescribe todos los destinatarios a{" "}
|
||||
<code>rmancinas@freakma.net</code>. Ningún cliente real recibe el
|
||||
correo mientras esté activo. Un aviso de renovación enviado en debug
|
||||
NO se marca como enviado: sigue pendiente en la lista.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags.ignoreDayRestriction}
|
||||
disabled={disabled}
|
||||
onChange={(e) => set({ ignoreDayRestriction: e.target.checked })}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>ignoreDayRestriction</strong> — salta los gates de
|
||||
Mon/Wed/Fri del estado de cuenta. Útil para disparar en cualquier
|
||||
día sin esperar a la próxima corrida. Solo aplica a servicios.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 0 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags.useEmailLimit}
|
||||
disabled={disabled}
|
||||
onChange={(e) => set({ useEmailLimit: e.target.checked })}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>useEmailLimit</strong> — pausa el estado de cuenta cada 100
|
||||
correos durante 1 hora. Vestigio de la era SMTP; SES no lo necesita.
|
||||
Solo aplica a servicios.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<ScheduleKind, string> = {
|
||||
servicios: "Servicios",
|
||||
polizas: "Pólizas",
|
||||
};
|
||||
|
||||
const KIND_HINT: Record<ScheduleKind, string> = {
|
||||
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<NotificationSchedules | null>(null);
|
||||
const [drafts, setDrafts] = useState<Partial<Record<ScheduleKind, NotificationSchedule>>>({});
|
||||
const [editing, setEditing] = useState<ScheduleKind | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState<ScheduleKind | null>(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 (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Programación de envíos</h2>
|
||||
{error ? (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted small" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
Cargando…
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 20 }}>
|
||||
<h2 className="section-title">Programación de envíos</h2>
|
||||
<p className="muted small" style={{ marginTop: 4, marginBottom: 0, maxWidth: 660 }}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gap: 12, marginTop: 14 }}>
|
||||
{(Object.keys(KIND_LABEL) as ScheduleKind[]).map((kind) => {
|
||||
const current = schedules[kind];
|
||||
const draft = drafts[kind];
|
||||
const isEditing = editing === kind && draft;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={kind}
|
||||
style={{
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
padding: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{KIND_LABEL[kind]}</strong>
|
||||
<p className="muted small" style={{ margin: "4px 0 0", maxWidth: 560 }}>
|
||||
{KIND_HINT[kind]}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && !isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => startEdit(kind)}
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<label
|
||||
className="field"
|
||||
style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 10 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.enabled}
|
||||
disabled={saving}
|
||||
onChange={(e) =>
|
||||
setDrafts((d) => ({
|
||||
...d,
|
||||
[kind]: { ...draft, enabled: e.target.checked },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="small">
|
||||
<strong>Corrida automática activada</strong>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="field" style={{ maxWidth: 160, marginBottom: 10 }}>
|
||||
<span className="field-label">Hora (Tijuana)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="time"
|
||||
value={timeValue(draft)}
|
||||
disabled={saving || !draft.enabled}
|
||||
onChange={(e) => {
|
||||
const [h, m] = e.target.value.split(":").map(Number);
|
||||
setDrafts((d) => ({
|
||||
...d,
|
||||
[kind]: {
|
||||
...draft,
|
||||
hour: Number.isFinite(h) ? h : draft.hour,
|
||||
minute: Number.isFinite(m) ? m : draft.minute,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="field" style={{ marginBottom: 10 }}>
|
||||
<span className="field-label">
|
||||
Días (ninguno seleccionado = todos los días)
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 4 }}>
|
||||
{DAYS.map((d) => {
|
||||
const on = draft.weekdays.includes(d.value);
|
||||
return (
|
||||
<button
|
||||
key={d.value}
|
||||
type="button"
|
||||
className={`btn btn-sm ${on ? "btn-primary" : "btn-outline"}`}
|
||||
disabled={saving || !draft.enabled}
|
||||
onClick={() =>
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
[kind]: {
|
||||
...draft,
|
||||
weekdays: on
|
||||
? draft.weekdays.filter((x) => x !== d.value)
|
||||
: [...draft.weekdays, d.value].sort(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saving}
|
||||
onClick={() => void save(kind)}
|
||||
>
|
||||
{saving ? "Guardando…" : "Guardar"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<p className="small" style={{ margin: 0 }}>
|
||||
{describe(current.value)}
|
||||
</p>
|
||||
<p className="section-note" style={{ marginTop: 6, marginBottom: 0 }}>
|
||||
<code>{current.cron}</code>
|
||||
{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"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!canEdit && (
|
||||
<p className="section-note" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||
Solo un ADMIN puede cambiar la programación.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<ScheduleKind, ResolvedSchedule>;
|
||||
|
||||
export function getNotificationSchedules(): Promise<NotificationSchedules> {
|
||||
return apiFetch<NotificationSchedules>("/notifications/settings/schedule");
|
||||
}
|
||||
|
||||
export function setNotificationSchedule(
|
||||
kind: ScheduleKind,
|
||||
schedule: NotificationSchedule,
|
||||
): Promise<ResolvedSchedule> {
|
||||
return apiFetch<ResolvedSchedule>(`/notifications/settings/schedule/${kind}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(schedule),
|
||||
});
|
||||
}
|
||||
|
||||
export function getNotificationStats(
|
||||
servicio?: NotificationServicio[],
|
||||
): Promise<NotificationStats> {
|
||||
|
||||
Reference in New Issue
Block a user