feat: integrate reminder payloads into appointment creation and update notification jobs and adjust quiet hours logic
This commit is contained in:
@@ -36,6 +36,7 @@ export type FindAppointmentsByCollaboratorPaginatedParams = FindAppointmentsByCo
|
|||||||
export enum APPOINTMENT_NOTIFICATION_TYPE {
|
export enum APPOINTMENT_NOTIFICATION_TYPE {
|
||||||
"CREATION" = "creation",
|
"CREATION" = "creation",
|
||||||
"REMINDER" = "reminder",
|
"REMINDER" = "reminder",
|
||||||
|
"UPDATE" = "update",
|
||||||
"CANCELLATION" = "cancellation",
|
"CANCELLATION" = "cancellation",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -545,6 +545,18 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
channel: "whatsapp",
|
channel: "whatsapp",
|
||||||
});
|
});
|
||||||
const clientPhoneNumber = await this.getOptionalClientWapNumber(checkClient);
|
const clientPhoneNumber = await this.getOptionalClientWapNumber(checkClient);
|
||||||
|
const reminderEmailContent = await this.tryToSendNotification({
|
||||||
|
appointmentId: String(newAppointment.id),
|
||||||
|
sessionUser: String(companyCheck.ownerId),
|
||||||
|
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
|
||||||
|
channel: "email",
|
||||||
|
});
|
||||||
|
const reminderWapContent = await this.tryToSendNotification({
|
||||||
|
appointmentId: String(newAppointment.id),
|
||||||
|
sessionUser: String(companyCheck.ownerId),
|
||||||
|
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
|
||||||
|
channel: "whatsapp",
|
||||||
|
});
|
||||||
|
|
||||||
await this.createCreationNotificationJobs({
|
await this.createCreationNotificationJobs({
|
||||||
appointmentId: String(newAppointment.id),
|
appointmentId: String(newAppointment.id),
|
||||||
@@ -558,6 +570,8 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
appointmentStart: new Date(data.start),
|
appointmentStart: new Date(data.start),
|
||||||
emailMessage: emailContent.message,
|
emailMessage: emailContent.message,
|
||||||
wapMessage: wapContent.message,
|
wapMessage: wapContent.message,
|
||||||
|
reminderEmailMessage: reminderEmailContent.message,
|
||||||
|
reminderWapMessage: reminderWapContent.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2077,13 +2091,45 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
|||||||
if (checkAppointment.id) {
|
if (checkAppointment.id) {
|
||||||
await this.jobService.cancelByAppointment(String(checkAppointment.id));
|
await this.jobService.cancelByAppointment(String(checkAppointment.id));
|
||||||
|
|
||||||
//Create jobs for email and whatsapp notifications
|
const emailContent = await this.tryToSendNotification({
|
||||||
await this.jobService.createJob({
|
appointmentId: String(checkAppointment.id),
|
||||||
|
sessionUser: String(companyCheck.ownerId),
|
||||||
|
type: APPOINTMENT_NOTIFICATION_TYPE.UPDATE,
|
||||||
|
channel: "email",
|
||||||
|
});
|
||||||
|
const wapContent = await this.tryToSendNotification({
|
||||||
|
appointmentId: String(checkAppointment.id),
|
||||||
|
sessionUser: String(companyCheck.ownerId),
|
||||||
|
type: APPOINTMENT_NOTIFICATION_TYPE.UPDATE,
|
||||||
|
channel: "whatsapp",
|
||||||
|
});
|
||||||
|
const reminderEmailContent = await this.tryToSendNotification({
|
||||||
|
appointmentId: String(checkAppointment.id),
|
||||||
|
sessionUser: String(companyCheck.ownerId),
|
||||||
|
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
|
||||||
|
channel: "email",
|
||||||
|
});
|
||||||
|
const reminderWapContent = await this.tryToSendNotification({
|
||||||
|
appointmentId: String(checkAppointment.id),
|
||||||
|
sessionUser: String(companyCheck.ownerId),
|
||||||
|
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
|
||||||
|
channel: "whatsapp",
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.createUpdateNotificationJobs({
|
||||||
|
appointmentId: String(checkAppointment.id),
|
||||||
companyId: String(companyCheck._id),
|
companyId: String(companyCheck._id),
|
||||||
clientId: String(checkClient._id),
|
clientId: String(checkClient._id),
|
||||||
appointmentId: String(checkAppointment.id),
|
clientUserId: checkClient.userId ? String(checkClient.userId) : undefined,
|
||||||
type: "update" as const,
|
clientEmail: checkClient.email,
|
||||||
|
clientPhoneNumber: await this.getOptionalClientWapNumber(checkClient),
|
||||||
|
companyOwnerId: String(companyCheck.ownerId),
|
||||||
|
companyName: companyCheck.name,
|
||||||
appointmentStart: new Date(data.start),
|
appointmentStart: new Date(data.start),
|
||||||
|
emailMessage: emailContent.message,
|
||||||
|
wapMessage: wapContent.message,
|
||||||
|
reminderEmailMessage: reminderEmailContent.message,
|
||||||
|
reminderWapMessage: reminderWapContent.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2188,8 +2234,11 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
|||||||
appointmentStart: Date;
|
appointmentStart: Date;
|
||||||
emailMessage: string;
|
emailMessage: string;
|
||||||
wapMessage: string;
|
wapMessage: string;
|
||||||
|
reminderEmailMessage: string;
|
||||||
|
reminderWapMessage: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const systemSubject = `Turno reservado en ${data.companyName}`;
|
const systemSubject = `Turno reservado en ${data.companyName}`;
|
||||||
|
const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`;
|
||||||
|
|
||||||
await this.jobService.createJob({
|
await this.jobService.createJob({
|
||||||
companyId: data.companyId,
|
companyId: data.companyId,
|
||||||
@@ -2210,6 +2259,72 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
|||||||
systemSubject,
|
systemSubject,
|
||||||
systemMessage: data.emailMessage,
|
systemMessage: data.emailMessage,
|
||||||
},
|
},
|
||||||
|
reminderPayload: {
|
||||||
|
email: data.clientEmail,
|
||||||
|
phoneNumber: data.clientPhoneNumber,
|
||||||
|
userId: data.clientUserId,
|
||||||
|
companyOwnerId: data.companyOwnerId,
|
||||||
|
subject: reminderSystemSubject,
|
||||||
|
message: data.reminderEmailMessage,
|
||||||
|
emailSubject: "TurnosXpress :: Recordatorio",
|
||||||
|
emailMessage: data.reminderEmailMessage,
|
||||||
|
wapMessage: data.reminderWapMessage,
|
||||||
|
systemSubject: reminderSystemSubject,
|
||||||
|
systemMessage: data.reminderEmailMessage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createUpdateNotificationJobs(data: {
|
||||||
|
appointmentId: string;
|
||||||
|
companyId: string;
|
||||||
|
clientId: string;
|
||||||
|
clientUserId?: string;
|
||||||
|
clientEmail?: string;
|
||||||
|
clientPhoneNumber?: string;
|
||||||
|
companyOwnerId: string;
|
||||||
|
companyName: string;
|
||||||
|
appointmentStart: Date;
|
||||||
|
emailMessage: string;
|
||||||
|
wapMessage: string;
|
||||||
|
reminderEmailMessage: string;
|
||||||
|
reminderWapMessage: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const systemSubject = `Turno actualizado en ${data.companyName}`;
|
||||||
|
const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`;
|
||||||
|
|
||||||
|
await this.jobService.createJob({
|
||||||
|
companyId: data.companyId,
|
||||||
|
clientId: data.clientId,
|
||||||
|
appointmentId: data.appointmentId,
|
||||||
|
type: "update",
|
||||||
|
appointmentStart: data.appointmentStart,
|
||||||
|
payload: {
|
||||||
|
email: data.clientEmail,
|
||||||
|
phoneNumber: data.clientPhoneNumber,
|
||||||
|
userId: data.clientUserId,
|
||||||
|
companyOwnerId: data.companyOwnerId,
|
||||||
|
subject: systemSubject,
|
||||||
|
message: data.emailMessage,
|
||||||
|
emailSubject: "TurnosXpress :: Turno actualizado",
|
||||||
|
emailMessage: data.emailMessage,
|
||||||
|
wapMessage: data.wapMessage,
|
||||||
|
systemSubject,
|
||||||
|
systemMessage: data.emailMessage,
|
||||||
|
},
|
||||||
|
reminderPayload: {
|
||||||
|
email: data.clientEmail,
|
||||||
|
phoneNumber: data.clientPhoneNumber,
|
||||||
|
userId: data.clientUserId,
|
||||||
|
companyOwnerId: data.companyOwnerId,
|
||||||
|
subject: reminderSystemSubject,
|
||||||
|
message: data.reminderEmailMessage,
|
||||||
|
emailSubject: "TurnosXpress :: Recordatorio",
|
||||||
|
emailMessage: data.reminderEmailMessage,
|
||||||
|
wapMessage: data.reminderWapMessage,
|
||||||
|
systemSubject: reminderSystemSubject,
|
||||||
|
systemMessage: data.reminderEmailMessage,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2605,10 +2720,15 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
|||||||
|
|
||||||
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
|
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
|
||||||
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
|
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
|
||||||
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION) {
|
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION || data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) {
|
||||||
|
const isUpdate = data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE;
|
||||||
const templateId = data.channel === "email"
|
const templateId = data.channel === "email"
|
||||||
? companyCheck.templateEmailCancellationId || companyCheck.templateWapCancellationId
|
? isUpdate
|
||||||
: companyCheck.templateWapCancellationId;
|
? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId
|
||||||
|
: companyCheck.templateEmailCancellationId || companyCheck.templateWapCancellationId
|
||||||
|
: isUpdate
|
||||||
|
? companyCheck.templateWapAltaId
|
||||||
|
: companyCheck.templateWapCancellationId;
|
||||||
|
|
||||||
if (templateId) {
|
if (templateId) {
|
||||||
const template = await Templates.templates.findOne({
|
const template = await Templates.templates.findOne({
|
||||||
@@ -2623,8 +2743,12 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
|||||||
|
|
||||||
if (!wapMessage) {
|
if (!wapMessage) {
|
||||||
wapMessage = data.channel === "email"
|
wapMessage = data.channel === "email"
|
||||||
? process.env.EMAIL_CANCELLATION || process.env.EMAIL_CANCELACION || process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || ""
|
? isUpdate
|
||||||
: process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || "";
|
? process.env.EMAIL_NEW_APPOINTMENT || process.env.EMAIL_ALTA || process.env.WAP_ALTA || ""
|
||||||
|
: process.env.EMAIL_CANCELLATION || process.env.EMAIL_CANCELACION || process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || ""
|
||||||
|
: isUpdate
|
||||||
|
? process.env.WAP_ALTA || ""
|
||||||
|
: process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
wapMessage = wapMessage.replaceAll(
|
wapMessage = wapMessage.replaceAll(
|
||||||
@@ -2703,6 +2827,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
|||||||
subjectEmail = "TurnosXpress :: Alta de turno";
|
subjectEmail = "TurnosXpress :: Alta de turno";
|
||||||
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION) {
|
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION) {
|
||||||
subjectEmail = "TurnosXpress :: Turno cancelado";
|
subjectEmail = "TurnosXpress :: Turno cancelado";
|
||||||
|
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) {
|
||||||
|
subjectEmail = "TurnosXpress :: Turno actualizado";
|
||||||
}
|
}
|
||||||
|
|
||||||
//Enviar email...
|
//Enviar email...
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ jest.mock("../../Notifications/Notifications", () => ({
|
|||||||
NotificationsManager: {
|
NotificationsManager: {
|
||||||
sendSystemNotification: jest.fn(),
|
sendSystemNotification: jest.fn(),
|
||||||
sendPushNotification: jest.fn(),
|
sendPushNotification: jest.fn(),
|
||||||
|
sendEmail: jest.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({
|
|||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: {
|
default: {
|
||||||
findOne: jest.fn(),
|
findOne: jest.fn(),
|
||||||
|
checkFeature: jest.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -187,6 +189,9 @@ import ClientsManager from "../../Clients/Clients";
|
|||||||
import ServiceList from "../../Services/Service";
|
import ServiceList from "../../Services/Service";
|
||||||
import CompaniesManager from "../../Companies/Companies";
|
import CompaniesManager from "../../Companies/Companies";
|
||||||
import Templates from "../../Templates/Templates";
|
import Templates from "../../Templates/Templates";
|
||||||
|
import PlanSubscriptionsList from "../../PlanSubscriptions/PlanSubscriptons";
|
||||||
|
import { NotificationsManager } from "../../Notifications/Notifications";
|
||||||
|
import { APPOINTMENT_NOTIFICATION_TYPE } from "../Appointments.Interface";
|
||||||
|
|
||||||
describe("getAppointmentEvent — historical read snapshot fallback", () => {
|
describe("getAppointmentEvent — historical read snapshot fallback", () => {
|
||||||
let originalAdapter: any;
|
let originalAdapter: any;
|
||||||
@@ -503,6 +508,10 @@ describe("cancellation notification jobs", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: "cancellation",
|
type: "cancellation",
|
||||||
payload: expect.objectContaining({
|
payload: expect.objectContaining({
|
||||||
|
email: "ada@example.com",
|
||||||
|
phoneNumber: "5491112345678",
|
||||||
|
userId: "client-user-001",
|
||||||
|
companyOwnerId: "owner-001",
|
||||||
emailSubject: "TurnosXpress :: Turno cancelado",
|
emailSubject: "TurnosXpress :: Turno cancelado",
|
||||||
emailMessage: "EMAIL CANCEL Ada Lovelace Contabilidad clases llavallol 22/07/2026 08:00hs.",
|
emailMessage: "EMAIL CANCEL Ada Lovelace Contabilidad clases llavallol 22/07/2026 08:00hs.",
|
||||||
wapMessage: "WAP CANCEL Ada Lovelace Contabilidad clases llavallol 22/07/2026 08:00hs.",
|
wapMessage: "WAP CANCEL Ada Lovelace Contabilidad clases llavallol 22/07/2026 08:00hs.",
|
||||||
@@ -512,6 +521,23 @@ describe("cancellation notification jobs", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends update email notifications with a non-empty subject", async () => {
|
||||||
|
(PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await (AppointmentsList as any).sendEmailNotification({
|
||||||
|
appointmentId: "appt-001",
|
||||||
|
sessionUser: "owner-001",
|
||||||
|
type: APPOINTMENT_NOTIFICATION_TYPE.UPDATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(NotificationsManager.sendEmail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
email: "ada@example.com",
|
||||||
|
subject: "TurnosXpress :: Turno actualizado",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("creation notification jobs", () => {
|
describe("creation notification jobs", () => {
|
||||||
@@ -619,6 +645,8 @@ describe("creation notification jobs", () => {
|
|||||||
appointmentStart: new Date("2026-07-22T11:00:00.000Z"),
|
appointmentStart: new Date("2026-07-22T11:00:00.000Z"),
|
||||||
emailMessage: emailContent.message,
|
emailMessage: emailContent.message,
|
||||||
wapMessage: wapContent.message,
|
wapMessage: wapContent.message,
|
||||||
|
reminderEmailMessage: "EMAIL REMINDER Ada Lovelace",
|
||||||
|
reminderWapMessage: "WAP REMINDER Ada Lovelace",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect((AppointmentsList as any).jobService.createJob).toHaveBeenCalledWith(
|
expect((AppointmentsList as any).jobService.createJob).toHaveBeenCalledWith(
|
||||||
@@ -631,6 +659,15 @@ describe("creation notification jobs", () => {
|
|||||||
systemSubject: "Turno reservado en clases llavallol",
|
systemSubject: "Turno reservado en clases llavallol",
|
||||||
systemMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.",
|
systemMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.",
|
||||||
}),
|
}),
|
||||||
|
reminderPayload: expect.objectContaining({
|
||||||
|
email: "ada@example.com",
|
||||||
|
phoneNumber: "5491112345678",
|
||||||
|
userId: "client-user-001",
|
||||||
|
companyOwnerId: "owner-001",
|
||||||
|
emailSubject: "TurnosXpress :: Recordatorio",
|
||||||
|
emailMessage: "EMAIL REMINDER Ada Lovelace",
|
||||||
|
wapMessage: "WAP REMINDER Ada Lovelace",
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -653,4 +690,62 @@ describe("creation notification jobs", () => {
|
|||||||
|
|
||||||
expect(emailContent.message).toBe("WAP ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.");
|
expect(emailContent.message).toBe("WAP ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stores organization-rendered update template content with contact snapshot fields", async () => {
|
||||||
|
const emailContent = await (AppointmentsList as any).tryToSendNotification({
|
||||||
|
appointmentId: "appt-001",
|
||||||
|
sessionUser: "owner-001",
|
||||||
|
type: "update",
|
||||||
|
channel: "email",
|
||||||
|
});
|
||||||
|
const wapContent = await (AppointmentsList as any).tryToSendNotification({
|
||||||
|
appointmentId: "appt-001",
|
||||||
|
sessionUser: "owner-001",
|
||||||
|
type: "update",
|
||||||
|
channel: "whatsapp",
|
||||||
|
});
|
||||||
|
|
||||||
|
await (AppointmentsList as any).createUpdateNotificationJobs({
|
||||||
|
appointmentId: "appt-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
clientId: "client-001",
|
||||||
|
clientUserId: "client-user-001",
|
||||||
|
clientEmail: "ada@example.com",
|
||||||
|
clientPhoneNumber: "5491112345678",
|
||||||
|
companyOwnerId: "owner-001",
|
||||||
|
companyName: "clases llavallol",
|
||||||
|
appointmentStart: new Date("2026-07-22T11:00:00.000Z"),
|
||||||
|
emailMessage: emailContent.message,
|
||||||
|
wapMessage: wapContent.message,
|
||||||
|
reminderEmailMessage: "EMAIL REMINDER Ada Lovelace",
|
||||||
|
reminderWapMessage: "WAP REMINDER Ada Lovelace",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((AppointmentsList as any).jobService.createJob).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "update",
|
||||||
|
appointmentStart: new Date("2026-07-22T11:00:00.000Z"),
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
email: "ada@example.com",
|
||||||
|
phoneNumber: "5491112345678",
|
||||||
|
userId: "client-user-001",
|
||||||
|
companyOwnerId: "owner-001",
|
||||||
|
emailSubject: "TurnosXpress :: Turno actualizado",
|
||||||
|
emailMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.",
|
||||||
|
wapMessage: "WAP ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.",
|
||||||
|
systemSubject: "Turno actualizado en clases llavallol",
|
||||||
|
systemMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.",
|
||||||
|
}),
|
||||||
|
reminderPayload: expect.objectContaining({
|
||||||
|
email: "ada@example.com",
|
||||||
|
phoneNumber: "5491112345678",
|
||||||
|
userId: "client-user-001",
|
||||||
|
companyOwnerId: "owner-001",
|
||||||
|
emailSubject: "TurnosXpress :: Recordatorio",
|
||||||
|
emailMessage: "EMAIL REMINDER Ada Lovelace",
|
||||||
|
wapMessage: "WAP REMINDER Ada Lovelace",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface CreateJobServiceParams {
|
|||||||
scheduledAt?: Date;
|
scheduledAt?: Date;
|
||||||
appointmentStart?: Date;
|
appointmentStart?: Date;
|
||||||
payload?: CreateJobParams["payload"];
|
payload?: CreateJobParams["payload"];
|
||||||
|
reminderPayload?: CreateJobParams["payload"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NotificationJobService {
|
export class NotificationJobService {
|
||||||
@@ -81,6 +82,7 @@ export class NotificationJobService {
|
|||||||
channel,
|
channel,
|
||||||
type: "reminder",
|
type: "reminder",
|
||||||
scheduledAt: reminderScheduledAt,
|
scheduledAt: reminderScheduledAt,
|
||||||
|
payload: params.reminderPayload,
|
||||||
});
|
});
|
||||||
jobs.push(job);
|
jobs.push(job);
|
||||||
}
|
}
|
||||||
@@ -153,12 +155,9 @@ export class NotificationJobService {
|
|||||||
if (isInsideQuietHours) {
|
if (isInsideQuietHours) {
|
||||||
// Shift to quiet hours end
|
// Shift to quiet hours end
|
||||||
let adjusted = scheduled.hour(toHour).minute(toMinute).second(0);
|
let adjusted = scheduled.hour(toHour).minute(toMinute).second(0);
|
||||||
// If quiet hours cross midnight (e.g. 22:00-07:00) and end is before start,
|
// If quiet hours cross midnight, only the pre-midnight segment ends next day.
|
||||||
// the end is on the next day
|
if (toHour < fromHour && scheduledHour >= fromHour) {
|
||||||
if (toHour < fromHour) {
|
adjusted = adjusted.add(1, "day");
|
||||||
if (scheduledHour >= fromHour || scheduledHour < toHour) {
|
|
||||||
adjusted = adjusted.add(1, "day");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return adjusted.toDate();
|
return adjusted.toDate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,45 @@ describe("NotificationJobService.createJob", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes reminder snapshot payload to scheduled reminder jobs", async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
|
||||||
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
|
channels: ["whatsapp", "email"],
|
||||||
|
timezone: "America/Argentina/Buenos_Aires",
|
||||||
|
reminderRules: [{ offset: 30, enabled: true }],
|
||||||
|
};
|
||||||
|
const payload = { message: "Creation body" };
|
||||||
|
const reminderPayload = {
|
||||||
|
email: "client@example.com",
|
||||||
|
phoneNumber: "5491112345678",
|
||||||
|
companyOwnerId: "owner-1",
|
||||||
|
emailMessage: "Reminder email body",
|
||||||
|
wapMessage: "Reminder WAP body",
|
||||||
|
};
|
||||||
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||||
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||||
|
|
||||||
|
await service.createJob({
|
||||||
|
companyId: "company-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
appointmentId: "appt-1",
|
||||||
|
type: "creation",
|
||||||
|
appointmentStart: new Date("2026-07-21T15:00:00.000Z"),
|
||||||
|
payload,
|
||||||
|
reminderPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
||||||
|
expect(createCalls.filter((job) => job.type === "creation")).toEqual([
|
||||||
|
expect.objectContaining({ channel: "whatsapp", payload }),
|
||||||
|
expect.objectContaining({ channel: "email", payload }),
|
||||||
|
]);
|
||||||
|
expect(createCalls.filter((job) => job.type === "reminder")).toEqual([
|
||||||
|
expect.objectContaining({ channel: "whatsapp", payload: reminderPayload }),
|
||||||
|
expect.objectContaining({ channel: "email", payload: reminderPayload }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips reminder jobs scheduled in the past", async () => {
|
it("skips reminder jobs scheduled in the past", async () => {
|
||||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:29:00.000Z"));
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:29:00.000Z"));
|
||||||
const resolvedPolicy: ResolvedPolicy = {
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
@@ -206,6 +245,27 @@ describe("NotificationJobService.createJob", () => {
|
|||||||
expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day
|
expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adjusts early morning quiet hours crossing midnight to the same day", async () => {
|
||||||
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
|
channels: ["system"],
|
||||||
|
timezone: "America/Argentina/Buenos_Aires",
|
||||||
|
quietHours: { from: "22:00", to: "07:00" },
|
||||||
|
};
|
||||||
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||||
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||||
|
|
||||||
|
await service.createJob({
|
||||||
|
companyId: "company-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
appointmentId: "appt-1",
|
||||||
|
type: "creation",
|
||||||
|
scheduledAt: new Date("2026-07-21T05:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCall = mockJobCreate.mock.calls[0][0];
|
||||||
|
expect(createCall.scheduledAt.toISOString()).toBe("2026-07-21T07:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not adjust scheduledAt when outside quiet hours", async () => {
|
it("does not adjust scheduledAt when outside quiet hours", async () => {
|
||||||
const resolvedPolicy: ResolvedPolicy = {
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
channels: ["system"],
|
channels: ["system"],
|
||||||
|
|||||||
Reference in New Issue
Block a user