From f34b99b87b77cfbff2bf2465b609d5efe005cdee Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Sun, 2 Aug 2026 21:38:32 -0300 Subject: [PATCH] refactor: modularize notification job creation to support reminder-only flow for recurring appointments --- .../src/Models/Appointments/Appointments.ts | 125 +++++++++++++----- .../Appointments.snapshotFallback.test.ts | 67 ++++++++++ 2 files changed, 158 insertions(+), 34 deletions(-) diff --git a/server/src/Models/Appointments/Appointments.ts b/server/src/Models/Appointments/Appointments.ts index 515f6ac..f347b2d 100644 --- a/server/src/Models/Appointments/Appointments.ts +++ b/server/src/Models/Appointments/Appointments.ts @@ -533,47 +533,18 @@ class AppointmentManager implements IAppointmentsManager { } //Create jobs for email and whatsapp notifications - if (notification && newAppointment.id) { - const emailContent = await this.tryToSendNotification({ - appointmentId: String(newAppointment.id), - sessionUser: String(companyCheck.ownerId), - type: APPOINTMENT_NOTIFICATION_TYPE.CREATION, - channel: "email", - }); - const wapContent = await this.tryToSendNotification({ - appointmentId: String(newAppointment.id), - sessionUser: String(companyCheck.ownerId), - type: APPOINTMENT_NOTIFICATION_TYPE.CREATION, - 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({ + if (this.shouldCreateCustomerNotificationJobs(notification, data.repeatId) && newAppointment.id) { + await this.createAppointmentNotificationJobs({ appointmentId: String(newAppointment.id), companyId: String(companyCheck._id), clientId: String(checkClient._id), clientUserId: checkClient.userId ? String(checkClient.userId) : undefined, clientEmail: checkClient.email, - clientPhoneNumber, + client: 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, + notification, }); } @@ -2239,7 +2210,6 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { reminderWapMessage: string; }): Promise { const systemSubject = `Turno reservado en ${data.companyName}`; - const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`; const payload = { email: data.clientEmail, @@ -2263,6 +2233,93 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { payload, }); + await this.createReminderNotificationJobs(data); + } + + private shouldCreateCustomerNotificationJobs(notification: boolean, repeatId?: string): boolean { + return notification || Boolean(repeatId); + } + + private async createAppointmentNotificationJobs(data: { + appointmentId: string; + companyId: string; + clientId: string; + clientUserId?: string; + clientEmail?: string; + client: IClientDocument; + companyOwnerId: string; + companyName: string; + appointmentStart: Date; + notification: boolean; + }): Promise { + const clientPhoneNumber = await this.getOptionalClientWapNumber(data.client); + const reminderEmailContent = await this.tryToSendNotification({ + appointmentId: data.appointmentId, + sessionUser: data.companyOwnerId, + type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, + channel: "email", + }); + const reminderWapContent = await this.tryToSendNotification({ + appointmentId: data.appointmentId, + sessionUser: data.companyOwnerId, + type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, + channel: "whatsapp", + }); + + const notificationJobData = { + appointmentId: data.appointmentId, + companyId: data.companyId, + clientId: data.clientId, + clientUserId: data.clientUserId, + clientEmail: data.clientEmail, + clientPhoneNumber, + companyOwnerId: data.companyOwnerId, + companyName: data.companyName, + appointmentStart: data.appointmentStart, + reminderEmailMessage: reminderEmailContent.message, + reminderWapMessage: reminderWapContent.message, + }; + + if (!data.notification) { + await this.createReminderNotificationJobs(notificationJobData); + return; + } + + const emailContent = await this.tryToSendNotification({ + appointmentId: data.appointmentId, + sessionUser: data.companyOwnerId, + type: APPOINTMENT_NOTIFICATION_TYPE.CREATION, + channel: "email", + }); + const wapContent = await this.tryToSendNotification({ + appointmentId: data.appointmentId, + sessionUser: data.companyOwnerId, + type: APPOINTMENT_NOTIFICATION_TYPE.CREATION, + channel: "whatsapp", + }); + + await this.createCreationNotificationJobs({ + ...notificationJobData, + emailMessage: emailContent.message, + wapMessage: wapContent.message, + }); + } + + private async createReminderNotificationJobs(data: { + appointmentId: string; + companyId: string; + clientId: string; + clientUserId?: string; + clientEmail?: string; + clientPhoneNumber?: string; + companyOwnerId: string; + companyName: string; + appointmentStart: Date; + reminderEmailMessage: string; + reminderWapMessage: string; + }): Promise { + const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`; + await this.jobService.createReminderJobs({ companyId: data.companyId, clientId: data.clientId, diff --git a/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts b/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts index 00f2f84..7a4e92c 100644 --- a/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts +++ b/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts @@ -681,6 +681,73 @@ describe("creation notification jobs", () => { ); }); + it("routes customer notification jobs for creation, repeat reminder-only, and disabled notifications", () => { + expect((AppointmentsList as any).shouldCreateCustomerNotificationJobs(true, undefined)).toBe(true); + expect((AppointmentsList as any).shouldCreateCustomerNotificationJobs(false, "repeat-001")).toBe(true); + expect((AppointmentsList as any).shouldCreateCustomerNotificationJobs(false, undefined)).toBe(false); + }); + + it("creates reminder-only jobs without rendering Alta content for repeat-created appointments", async () => { + const tryToSendNotificationSpy = jest + .spyOn(AppointmentsList as any, "tryToSendNotification") + .mockImplementation(({ type, channel }: any) => { + if (type === APPOINTMENT_NOTIFICATION_TYPE.CREATION) { + throw new Error("Creation content should not be rendered"); + } + + return Promise.resolve({ + message: channel === "email" ? "EMAIL REMINDER Ada Lovelace" : "WAP REMINDER Ada Lovelace", + }); + }); + const getOptionalClientWapNumberSpy = jest + .spyOn(AppointmentsList as any, "getOptionalClientWapNumber") + .mockResolvedValue("5491112345678"); + + try { + await (AppointmentsList as any).createAppointmentNotificationJobs({ + appointmentId: "appt-001", + companyId: "company-001", + clientId: "client-001", + clientUserId: "client-user-001", + clientEmail: "ada@example.com", + client: { _id: "client-001" }, + companyOwnerId: "owner-001", + companyName: "clases llavallol", + appointmentStart: new Date("2026-07-22T11:00:00.000Z"), + notification: false, + }); + + expect(tryToSendNotificationSpy).toHaveBeenCalledTimes(2); + expect(tryToSendNotificationSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, channel: "email" }) + ); + expect(tryToSendNotificationSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER, channel: "whatsapp" }) + ); + expect((AppointmentsList as any).jobService.createMandatoryCreationJob).not.toHaveBeenCalled(); + expect((AppointmentsList as any).jobService.createReminderJobs).toHaveBeenCalledWith( + expect.objectContaining({ + type: "reminder", + appointmentStart: new Date("2026-07-22T11:00:00.000Z"), + 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", + systemSubject: "Recordatorio de turno en clases llavallol", + systemMessage: "EMAIL REMINDER Ada Lovelace", + }), + }) + ); + } finally { + tryToSendNotificationSpy.mockRestore(); + getOptionalClientWapNumberSpy.mockRestore(); + } + }); + it("falls back to organization WAP Alta template for email when email Alta is absent", async () => { (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ _id: "company-001",