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 { 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,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user