From ec083ddf42fd7f206dd6cff526dbbb9771dcbbc7 Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Fri, 25 Sep 2026 09:30:45 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20ajustar=20la=20l=C3=B3gica=20de=20recor?= =?UTF-8?q?datorios=20autom=C3=A1ticos=20para=20respetar=20las=20horas=20d?= =?UTF-8?q?e=20silencio=20y=20la=20zona=20horaria?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationJobService.ts | 96 +++++++- .../__tests__/NotificationJobService.test.ts | 231 ++++++++++++++---- 2 files changed, 276 insertions(+), 51 deletions(-) diff --git a/server/src/Models/NotificationPolicies/NotificationJobService.ts b/server/src/Models/NotificationPolicies/NotificationJobService.ts index 5d01f5e..622bc99 100644 --- a/server/src/Models/NotificationPolicies/NotificationJobService.ts +++ b/server/src/Models/NotificationPolicies/NotificationJobService.ts @@ -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[] = []; diff --git a/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts b/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts index 21b82c2..ba8762a 100644 --- a/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts +++ b/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts @@ -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, }), ]); });