feat: ajustar la lógica de recordatorios automáticos para respetar las horas de silencio y la zona horaria
This commit is contained in:
@@ -5,9 +5,11 @@ import {
|
||||
import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose";
|
||||
import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export interface CreateJobServiceParams {
|
||||
companyId: string;
|
||||
@@ -66,12 +68,17 @@ export class NotificationJobService {
|
||||
);
|
||||
|
||||
for (const rule of enabledReminderRules) {
|
||||
const reminderScheduledAt = this.adjustForQuietHours(
|
||||
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(),
|
||||
const originalReminderScheduledAt = dayjs(params.appointmentStart)
|
||||
.subtract(rule.offset, "minute")
|
||||
.toDate();
|
||||
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
|
||||
originalReminderScheduledAt,
|
||||
now,
|
||||
params.appointmentStart,
|
||||
resolvedPolicy
|
||||
);
|
||||
|
||||
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) {
|
||||
if (reminderScheduledAt === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -169,12 +176,17 @@ export class NotificationJobService {
|
||||
);
|
||||
|
||||
for (const rule of enabledReminderRules) {
|
||||
const reminderScheduledAt = this.adjustForQuietHours(
|
||||
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(),
|
||||
const originalReminderScheduledAt = dayjs(params.appointmentStart)
|
||||
.subtract(rule.offset, "minute")
|
||||
.toDate();
|
||||
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
|
||||
originalReminderScheduledAt,
|
||||
now,
|
||||
params.appointmentStart,
|
||||
resolvedPolicy
|
||||
);
|
||||
|
||||
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) {
|
||||
if (reminderScheduledAt === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -239,6 +251,78 @@ export class NotificationJobService {
|
||||
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[] {
|
||||
const channels: NotificationChannel[] = [];
|
||||
|
||||
|
||||
@@ -180,28 +180,35 @@ describe("NotificationJobService.createJob", () => {
|
||||
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips reminder jobs adjusted by quiet hours after appointment start", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T20:00:00.000Z"));
|
||||
it("keeps UTC adjustment for the createJob base job and uses local candidates for reminders", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T23:00:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system", "email"],
|
||||
channels: ["system"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
quietHours: { from: "22:00", to: "07:00" },
|
||||
reminderRules: [{ offset: 15, enabled: true }],
|
||||
reminderRules: [{ offset: 30, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
const baseScheduledAt = new Date("2026-07-22T02:00:00.000Z");
|
||||
|
||||
await service.createJob({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "creation",
|
||||
appointmentStart: new Date("2026-07-21T23:30:00.000Z"),
|
||||
type: "update",
|
||||
scheduledAt: baseScheduledAt,
|
||||
appointmentStart: new Date("2026-07-22T03:00:00.000Z"),
|
||||
});
|
||||
|
||||
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
||||
expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2);
|
||||
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
|
||||
expect(createCalls).toHaveLength(2);
|
||||
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 () => {
|
||||
@@ -330,77 +337,208 @@ describe("NotificationJobService.createJob", () => {
|
||||
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 = {
|
||||
channels: ["system"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
quietHours: { from: "22:00", to: "07:00" },
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "08:00", to: "10:00" },
|
||||
reminderRules: [{ offset: 270, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
// scheduledAt at 23:30 UTC — should be adjusted to 07:00 next day UTC
|
||||
const scheduledAt = new Date("2026-07-21T23:30:00.000Z");
|
||||
|
||||
await service.createJob({
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "creation",
|
||||
scheduledAt,
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
|
||||
});
|
||||
|
||||
const createCall = mockJobCreate.mock.calls[0][0];
|
||||
// 23:30 is inside quiet hours (22:00-07:00), adjusted to 07:00 next day
|
||||
expect(createCall.scheduledAt.getUTCHours()).toBe(7);
|
||||
expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||
"2026-07-21T07:30:00.123Z"
|
||||
);
|
||||
});
|
||||
|
||||
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 = {
|
||||
channels: ["system"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
quietHours: { from: "22:00", to: "07:00" },
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "01:00", to: "06:00" },
|
||||
reminderRules: [{ offset: 150, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
await service.createJob({
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "creation",
|
||||
scheduledAt: new Date("2026-07-21T05:00:00.000Z"),
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-21T08:00:00.123Z"),
|
||||
});
|
||||
|
||||
const createCall = mockJobCreate.mock.calls[0][0];
|
||||
expect(createCall.scheduledAt.toISOString()).toBe("2026-07-21T07:00:00.000Z");
|
||||
expect(result).toHaveLength(1);
|
||||
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 = {
|
||||
channels: ["system"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
quietHours: { from: "22:00", to: "07:00" },
|
||||
reminderRules: [{ offset: 510, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
// scheduledAt at 14:00 UTC — outside quiet hours (22:00-07:00)
|
||||
const scheduledAt = new Date("2026-07-21T14:00:00.000Z");
|
||||
|
||||
await service.createJob({
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "creation",
|
||||
scheduledAt,
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-22T12:00:00.123Z"),
|
||||
});
|
||||
|
||||
const createCall = mockJobCreate.mock.calls[0][0];
|
||||
// 14:00 is outside quiet hours, should remain unchanged
|
||||
expect(createCall.scheduledAt.getUTCHours()).toBe(14);
|
||||
expect(createCall.scheduledAt.getUTCDate()).toBe(21);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||
"2026-07-22T00:59:00.123Z"
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -428,21 +566,24 @@ describe("NotificationJobService.createJob", () => {
|
||||
expect(createCall.clientId).toBe("client-1");
|
||||
});
|
||||
|
||||
it("creates immediate jobs without reminder offsets", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
|
||||
it("does not apply quiet-hours adjustment to immediate reminder jobs", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:00:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system", "email"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "08:00", to: "10:00" },
|
||||
reminderRules: [{ offset: 30, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
const scheduledAt = new Date("2026-07-21T09:30:00.123Z");
|
||||
|
||||
const result = await service.createImmediateJob({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "reminder",
|
||||
scheduledAt,
|
||||
appointmentStart: new Date("2026-07-21T16:00:00.000Z"),
|
||||
});
|
||||
|
||||
@@ -453,12 +594,12 @@ describe("NotificationJobService.createJob", () => {
|
||||
expect.objectContaining({
|
||||
channel: "system",
|
||||
type: "reminder",
|
||||
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
||||
scheduledAt,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
channel: "email",
|
||||
type: "reminder",
|
||||
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
||||
scheduledAt,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user