diff --git a/server/src/Models/Appointments/Appointments.Interface.ts b/server/src/Models/Appointments/Appointments.Interface.ts index 5fbb6af..5326d60 100644 --- a/server/src/Models/Appointments/Appointments.Interface.ts +++ b/server/src/Models/Appointments/Appointments.Interface.ts @@ -36,6 +36,7 @@ export type FindAppointmentsByCollaboratorPaginatedParams = FindAppointmentsByCo export enum APPOINTMENT_NOTIFICATION_TYPE { "CREATION" = "creation", "REMINDER" = "reminder", + "UPDATE" = "update", "CANCELLATION" = "cancellation", } diff --git a/server/src/Models/Appointments/Appointments.ts b/server/src/Models/Appointments/Appointments.ts index 7d1d3e0..f7c3eee 100644 --- a/server/src/Models/Appointments/Appointments.ts +++ b/server/src/Models/Appointments/Appointments.ts @@ -545,6 +545,18 @@ class AppointmentManager implements IAppointmentsManager { channel: "whatsapp", }); 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({ appointmentId: String(newAppointment.id), @@ -558,6 +570,8 @@ class AppointmentManager implements IAppointmentsManager { appointmentStart: new Date(data.start), emailMessage: emailContent.message, wapMessage: wapContent.message, + reminderEmailMessage: reminderEmailContent.message, + reminderWapMessage: reminderWapContent.message, }); } } @@ -2077,13 +2091,45 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { if (checkAppointment.id) { await this.jobService.cancelByAppointment(String(checkAppointment.id)); - //Create jobs for email and whatsapp notifications - await this.jobService.createJob({ + const emailContent = await this.tryToSendNotification({ + 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), clientId: String(checkClient._id), - appointmentId: String(checkAppointment.id), - type: "update" as const, + clientUserId: checkClient.userId ? String(checkClient.userId) : undefined, + clientEmail: checkClient.email, + clientPhoneNumber: await this.getOptionalClientWapNumber(checkClient), + companyOwnerId: String(companyCheck.ownerId), + companyName: companyCheck.name, 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 { appointmentStart: Date; emailMessage: string; wapMessage: string; + reminderEmailMessage: string; + reminderWapMessage: string; }): Promise { const systemSubject = `Turno reservado en ${data.companyName}`; + const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`; await this.jobService.createJob({ companyId: data.companyId, @@ -2210,6 +2259,72 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { 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, + }, + }); + } + + 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 { + 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 { wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); 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" - ? companyCheck.templateEmailCancellationId || companyCheck.templateWapCancellationId - : companyCheck.templateWapCancellationId; + ? isUpdate + ? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId + : companyCheck.templateEmailCancellationId || companyCheck.templateWapCancellationId + : isUpdate + ? companyCheck.templateWapAltaId + : companyCheck.templateWapCancellationId; if (templateId) { const template = await Templates.templates.findOne({ @@ -2623,8 +2743,12 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { if (!wapMessage) { wapMessage = data.channel === "email" - ? process.env.EMAIL_CANCELLATION || process.env.EMAIL_CANCELACION || process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || "" - : process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || ""; + ? isUpdate + ? 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( @@ -2703,6 +2827,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { subjectEmail = "TurnosXpress :: Alta de turno"; } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION) { subjectEmail = "TurnosXpress :: Turno cancelado"; + } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) { + subjectEmail = "TurnosXpress :: Turno actualizado"; } //Enviar email... diff --git a/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts b/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts index 39bd606..8de2395 100644 --- a/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts +++ b/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts @@ -105,6 +105,7 @@ jest.mock("../../Notifications/Notifications", () => ({ NotificationsManager: { sendSystemNotification: jest.fn(), sendPushNotification: jest.fn(), + sendEmail: jest.fn(), }, })); @@ -119,6 +120,7 @@ jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({ __esModule: true, default: { findOne: jest.fn(), + checkFeature: jest.fn(), }, })); @@ -187,6 +189,9 @@ import ClientsManager from "../../Clients/Clients"; import ServiceList from "../../Services/Service"; import CompaniesManager from "../../Companies/Companies"; 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", () => { let originalAdapter: any; @@ -503,6 +508,10 @@ describe("cancellation notification jobs", () => { expect.objectContaining({ type: "cancellation", payload: expect.objectContaining({ + email: "ada@example.com", + phoneNumber: "5491112345678", + userId: "client-user-001", + companyOwnerId: "owner-001", emailSubject: "TurnosXpress :: Turno cancelado", 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.", @@ -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", () => { @@ -619,6 +645,8 @@ describe("creation notification jobs", () => { 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( @@ -631,6 +659,15 @@ describe("creation notification jobs", () => { systemSubject: "Turno reservado 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", + }), }) ); }); @@ -653,4 +690,62 @@ describe("creation notification jobs", () => { 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", + }), + }) + ); + }); }); diff --git a/server/src/Models/NotificationPolicies/NotificationJobService.ts b/server/src/Models/NotificationPolicies/NotificationJobService.ts index 7aca90d..3ccc4fd 100644 --- a/server/src/Models/NotificationPolicies/NotificationJobService.ts +++ b/server/src/Models/NotificationPolicies/NotificationJobService.ts @@ -16,6 +16,7 @@ export interface CreateJobServiceParams { scheduledAt?: Date; appointmentStart?: Date; payload?: CreateJobParams["payload"]; + reminderPayload?: CreateJobParams["payload"]; } export class NotificationJobService { @@ -81,6 +82,7 @@ export class NotificationJobService { channel, type: "reminder", scheduledAt: reminderScheduledAt, + payload: params.reminderPayload, }); jobs.push(job); } @@ -153,12 +155,9 @@ export class NotificationJobService { if (isInsideQuietHours) { // Shift to quiet hours end 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, - // the end is on the next day - if (toHour < fromHour) { - if (scheduledHour >= fromHour || scheduledHour < toHour) { - adjusted = adjusted.add(1, "day"); - } + // If quiet hours cross midnight, only the pre-midnight segment ends next day. + if (toHour < fromHour && scheduledHour >= fromHour) { + adjusted = adjusted.add(1, "day"); } return adjusted.toDate(); } diff --git a/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts b/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts index dd75dcd..b1e55de 100644 --- a/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts +++ b/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts @@ -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 () => { jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:29:00.000Z")); const resolvedPolicy: ResolvedPolicy = { @@ -206,6 +245,27 @@ describe("NotificationJobService.createJob", () => { 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 () => { const resolvedPolicy: ResolvedPolicy = { channels: ["system"],