feat: ajustar la lógica de recordatorios automáticos para respetar las horas de silencio y la zona horaria

This commit is contained in:
2026-09-25 09:30:45 -03:00
parent fe9eb52a6a
commit ec083ddf42
2 changed files with 276 additions and 51 deletions
@@ -5,9 +5,11 @@ import {
import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose"; import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose";
import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver"; import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver";
import dayjs from "dayjs"; import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone);
export interface CreateJobServiceParams { export interface CreateJobServiceParams {
companyId: string; companyId: string;
@@ -66,12 +68,17 @@ export class NotificationJobService {
); );
for (const rule of enabledReminderRules) { for (const rule of enabledReminderRules) {
const reminderScheduledAt = this.adjustForQuietHours( const originalReminderScheduledAt = dayjs(params.appointmentStart)
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(), .subtract(rule.offset, "minute")
.toDate();
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
originalReminderScheduledAt,
now,
params.appointmentStart,
resolvedPolicy resolvedPolicy
); );
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) { if (reminderScheduledAt === null) {
continue; continue;
} }
@@ -169,12 +176,17 @@ export class NotificationJobService {
); );
for (const rule of enabledReminderRules) { for (const rule of enabledReminderRules) {
const reminderScheduledAt = this.adjustForQuietHours( const originalReminderScheduledAt = dayjs(params.appointmentStart)
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(), .subtract(rule.offset, "minute")
.toDate();
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
originalReminderScheduledAt,
now,
params.appointmentStart,
resolvedPolicy resolvedPolicy
); );
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) { if (reminderScheduledAt === null) {
continue; continue;
} }
@@ -239,6 +251,78 @@ export class NotificationJobService {
return scheduledAt; return scheduledAt;
} }
private selectAutomaticReminderScheduledAt(
originalScheduledAt: Date,
now: Date,
appointmentStart: Date,
policy: ResolvedPolicy
): Date | null {
const isValidCandidate = (candidate: Date): boolean =>
candidate >= now && candidate < appointmentStart;
if (!policy.quietHours?.from || !policy.quietHours?.to) {
return isValidCandidate(originalScheduledAt) ? originalScheduledAt : null;
}
const fromHour = parseInt(policy.quietHours.from.split(":")[0], 10);
const fromMinute = parseInt(policy.quietHours.from.split(":")[1], 10);
const toHour = parseInt(policy.quietHours.to.split(":")[0], 10);
const toMinute = parseInt(policy.quietHours.to.split(":")[1], 10);
const localScheduledAt = dayjs(originalScheduledAt).tz(policy.timezone);
const scheduledMinutes = localScheduledAt.hour() * 60 + localScheduledAt.minute();
const isInsideQuietHours = this.isTimeInQuietHours(
localScheduledAt.hour(),
localScheduledAt.minute(),
fromHour,
fromMinute,
toHour,
toMinute
);
if (!isInsideQuietHours) {
return isValidCandidate(originalScheduledAt) ? originalScheduledAt : null;
}
const fromMinutes = fromHour * 60 + fromMinute;
const toMinutes = toHour * 60 + toMinute;
const wrapsAcrossMidnight = fromMinutes >= toMinutes;
const isEarlyMorningSegment = scheduledMinutes < fromMinutes;
const localDay = localScheduledAt.startOf("day");
const quietStartDay =
wrapsAcrossMidnight && isEarlyMorningSegment
? localDay.subtract(1, "day")
: localDay;
const quietEndDay =
wrapsAcrossMidnight && !isEarlyMorningSegment
? localDay.add(1, "day")
: localDay;
const quietStart = quietStartDay
.hour(fromHour)
.minute(fromMinute)
.second(0)
.millisecond(localScheduledAt.millisecond());
const quietEnd = quietEndDay
.hour(toHour)
.minute(toMinute)
.second(0)
.millisecond(localScheduledAt.millisecond());
const advanceCandidate = quietStart.subtract(1, "minute").toDate();
const postponeCandidate = quietEnd.toDate();
const validAdvanceCandidate = isValidCandidate(advanceCandidate)
? advanceCandidate
: null;
const validPostponeCandidate = isValidCandidate(postponeCandidate)
? postponeCandidate
: null;
if (validAdvanceCandidate) {
return validAdvanceCandidate;
}
return validPostponeCandidate;
}
private getPayloadChannels(payload?: CreateJobParams["payload"]): NotificationChannel[] { private getPayloadChannels(payload?: CreateJobParams["payload"]): NotificationChannel[] {
const channels: NotificationChannel[] = []; const channels: NotificationChannel[] = [];
@@ -180,28 +180,35 @@ describe("NotificationJobService.createJob", () => {
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0); expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
}); });
it("skips reminder jobs adjusted by quiet hours after appointment start", async () => { it("keeps UTC adjustment for the createJob base job and uses local candidates for reminders", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T20:00:00.000Z")); jest.useFakeTimers().setSystemTime(new Date("2026-07-21T23:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"], channels: ["system"],
timezone: "America/Argentina/Buenos_Aires", timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" }, quietHours: { from: "22:00", to: "07:00" },
reminderRules: [{ offset: 15, enabled: true }], reminderRules: [{ offset: 30, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
const baseScheduledAt = new Date("2026-07-22T02:00:00.000Z");
await service.createJob({ await service.createJob({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "creation", type: "update",
appointmentStart: new Date("2026-07-21T23:30:00.000Z"), scheduledAt: baseScheduledAt,
appointmentStart: new Date("2026-07-22T03:00:00.000Z"),
}); });
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]); const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2); expect(createCalls).toHaveLength(2);
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0); expect(createCalls.find((job) => job.type === "update")?.scheduledAt.toISOString()).toBe(
"2026-07-22T07:00:00.000Z"
);
expect(createCalls.find((job) => job.type === "reminder")?.scheduledAt.toISOString()).toBe(
"2026-07-22T00:59:00.000Z"
);
}); });
it("creates no jobs when resolved channels are empty", async () => { it("creates no jobs when resolved channels are empty", async () => {
@@ -330,77 +337,208 @@ describe("NotificationJobService.createJob", () => {
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
}); });
it("adjusts scheduledAt for quiet hours crossing midnight", async () => { it("keeps an automatic reminder at its original instant outside quiet hours", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system"], channels: ["system"],
timezone: "America/Argentina/Buenos_Aires", timezone: "UTC",
quietHours: { from: "22:00", to: "07:00" }, quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 270, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
// scheduledAt at 23:30 UTC — should be adjusted to 07:00 next day UTC const result = await service.createReminderJobs({
const scheduledAt = new Date("2026-07-21T23:30:00.000Z");
await service.createJob({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "creation", type: "reminder",
scheduledAt, appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
}); });
const createCall = mockJobCreate.mock.calls[0][0]; expect(result).toHaveLength(1);
// 23:30 is inside quiet hours (22:00-07:00), adjusted to 07:00 next day expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
expect(createCall.scheduledAt.getUTCHours()).toBe(7); "2026-07-21T07:30:00.123Z"
expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day );
}); });
it("adjusts early morning quiet hours crossing midnight to the same day", async () => { it("prefers the pre-quiet candidate when the quiet-hours end is closer for a same-day range", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T00:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system"], channels: ["system"],
timezone: "America/Argentina/Buenos_Aires", timezone: "UTC",
quietHours: { from: "22:00", to: "07:00" }, quietHours: { from: "01:00", to: "06:00" },
reminderRules: [{ offset: 150, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
await service.createJob({ const result = await service.createReminderJobs({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "creation", type: "reminder",
scheduledAt: new Date("2026-07-21T05:00:00.000Z"), appointmentStart: new Date("2026-07-21T08:00:00.123Z"),
}); });
const createCall = mockJobCreate.mock.calls[0][0]; expect(result).toHaveLength(1);
expect(createCall.scheduledAt.toISOString()).toBe("2026-07-21T07:00:00.000Z"); expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T00:59:00.123Z"
);
}); });
it("does not adjust scheduledAt when outside quiet hours", async () => { it("uses the policy timezone for a cross-midnight range and advances to the prior local day", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T22:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system"], channels: ["system"],
timezone: "America/Argentina/Buenos_Aires", timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" }, quietHours: { from: "22:00", to: "07:00" },
reminderRules: [{ offset: 510, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
// scheduledAt at 14:00 UTC — outside quiet hours (22:00-07:00) const result = await service.createReminderJobs({
const scheduledAt = new Date("2026-07-21T14:00:00.000Z");
await service.createJob({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "creation", type: "reminder",
scheduledAt, appointmentStart: new Date("2026-07-22T12:00:00.123Z"),
}); });
const createCall = mockJobCreate.mock.calls[0][0]; expect(result).toHaveLength(1);
// 14:00 is outside quiet hours, should remain unchanged expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
expect(createCall.scheduledAt.getUTCHours()).toBe(14); "2026-07-22T00:59:00.123Z"
expect(createCall.scheduledAt.getUTCDate()).toBe(21); );
});
it("advances to one minute before quiet-hours start when both candidates are valid", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 210, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
});
expect(result).toHaveLength(1);
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T07:59:00.123Z"
);
});
it("postpones when the advance candidate is before now", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T08:30:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 150, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
});
expect(result).toHaveLength(1);
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T10:00:00.123Z"
);
});
it("prefers the pre-quiet candidate on an exact candidate-distance tie", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 180, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T11:59:30.123Z"),
});
expect(result).toHaveLength(1);
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T07:59:00.123Z"
);
});
it("treats quiet-hours start as inclusive and end as exclusive", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [
{ offset: 240, enabled: true },
{ offset: 120, enabled: true },
],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
});
const scheduledTimes = mockJobCreate.mock.calls
.map((call) => call[0].scheduledAt.toISOString())
.sort();
expect(result).toHaveLength(2);
expect(scheduledTimes).toEqual([
"2026-07-21T07:59:00.123Z",
"2026-07-21T10:00:00.123Z",
]);
});
it("omits a reminder when the advance is past and postponement reaches appointment start", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:30:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 60, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T10:00:00.000Z"),
});
expect(result).toHaveLength(0);
expect(mockJobCreate).not.toHaveBeenCalled();
}); });
it("passes appointmentId and type to job creation", async () => { it("passes appointmentId and type to job creation", async () => {
@@ -428,21 +566,24 @@ describe("NotificationJobService.createJob", () => {
expect(createCall.clientId).toBe("client-1"); expect(createCall.clientId).toBe("client-1");
}); });
it("creates immediate jobs without reminder offsets", async () => { it("does not apply quiet-hours adjustment to immediate reminder jobs", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z")); jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"], channels: ["system", "email"],
timezone: "America/Argentina/Buenos_Aires", timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 30, enabled: true }], reminderRules: [{ offset: 30, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
const scheduledAt = new Date("2026-07-21T09:30:00.123Z");
const result = await service.createImmediateJob({ const result = await service.createImmediateJob({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "reminder", type: "reminder",
scheduledAt,
appointmentStart: new Date("2026-07-21T16:00:00.000Z"), appointmentStart: new Date("2026-07-21T16:00:00.000Z"),
}); });
@@ -453,12 +594,12 @@ describe("NotificationJobService.createJob", () => {
expect.objectContaining({ expect.objectContaining({
channel: "system", channel: "system",
type: "reminder", type: "reminder",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"), scheduledAt,
}), }),
expect.objectContaining({ expect.objectContaining({
channel: "email", channel: "email",
type: "reminder", type: "reminder",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"), scheduledAt,
}), }),
]); ]);
}); });