From 68d98e8b4007087adf15ee9ee5aebf5e11528511 Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Mon, 27 Jul 2026 22:51:33 -0300 Subject: [PATCH] refactor: modularize notification job creation and enforce plan-based channel availability in policy resolver --- .../src/Models/Appointments/Appointments.ts | 123 ++++++++++-------- .../Appointments.snapshotFallback.test.ts | 11 +- .../NotificationJobService.ts | 92 +++++++++++++ .../NotificationPolicies/PolicyResolver.ts | 15 +++ .../__tests__/NotificationJobService.test.ts | 111 ++++++++++++++++ .../__tests__/PolicyResolver.test.ts | 39 ++++++ 6 files changed, 333 insertions(+), 58 deletions(-) diff --git a/server/src/Models/Appointments/Appointments.ts b/server/src/Models/Appointments/Appointments.ts index f7c3eee..799587f 100644 --- a/server/src/Models/Appointments/Appointments.ts +++ b/server/src/Models/Appointments/Appointments.ts @@ -529,51 +529,51 @@ class AppointmentManager implements IAppointmentsManager { type: NotificationType.APPOINTMENT, code: String((newAppointment as any)._id) }); + } - //Create jobs for email and whatsapp notifications - if (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", - }); + //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({ - appointmentId: String(newAppointment.id), - companyId: String(companyCheck._id), - clientId: String(checkClient._id), - clientUserId: checkClient.userId ? String(checkClient.userId) : undefined, - clientEmail: checkClient.email, - clientPhoneNumber, - companyOwnerId: String(companyCheck.ownerId), - companyName: companyCheck.name, - appointmentStart: new Date(data.start), - emailMessage: emailContent.message, - wapMessage: wapContent.message, - reminderEmailMessage: reminderEmailContent.message, - reminderWapMessage: reminderWapContent.message, - }); - } + await this.createCreationNotificationJobs({ + appointmentId: String(newAppointment.id), + companyId: String(companyCheck._id), + clientId: String(checkClient._id), + clientUserId: checkClient.userId ? String(checkClient.userId) : undefined, + clientEmail: checkClient.email, + clientPhoneNumber, + companyOwnerId: String(companyCheck.ownerId), + companyName: companyCheck.name, + appointmentStart: new Date(data.start), + emailMessage: emailContent.message, + wapMessage: wapContent.message, + reminderEmailMessage: reminderEmailContent.message, + reminderWapMessage: reminderWapContent.message, + }); } //Envio la notificacion al profesional. @@ -2240,25 +2240,34 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { const systemSubject = `Turno reservado en ${data.companyName}`; const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`; - await this.jobService.createJob({ + const payload = { + email: data.clientEmail, + phoneNumber: data.clientPhoneNumber, + userId: data.clientUserId, + companyOwnerId: data.companyOwnerId, + subject: systemSubject, + message: data.emailMessage, + emailSubject: "TurnosXpress :: Alta de turno", + emailMessage: data.emailMessage, + wapMessage: data.wapMessage, + systemSubject, + systemMessage: data.emailMessage, + }; + + await this.jobService.createMandatoryCreationJob({ companyId: data.companyId, clientId: data.clientId, appointmentId: data.appointmentId, type: "creation", + payload, + }); + + await this.jobService.createReminderJobs({ + companyId: data.companyId, + clientId: data.clientId, + appointmentId: data.appointmentId, + type: "reminder", appointmentStart: data.appointmentStart, - payload: { - email: data.clientEmail, - phoneNumber: data.clientPhoneNumber, - userId: data.clientUserId, - companyOwnerId: data.companyOwnerId, - subject: systemSubject, - message: data.emailMessage, - emailSubject: "TurnosXpress :: Alta de turno", - emailMessage: data.emailMessage, - wapMessage: data.wapMessage, - systemSubject, - systemMessage: data.emailMessage, - }, reminderPayload: { email: data.clientEmail, phoneNumber: data.clientPhoneNumber, diff --git a/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts b/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts index 8de2395..00f2f84 100644 --- a/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts +++ b/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts @@ -421,6 +421,8 @@ describe("cancellation notification jobs", () => { }; (AppointmentsList as any).jobService = { createJob: jest.fn().mockResolvedValue([]), + createMandatoryCreationJob: jest.fn().mockResolvedValue([]), + createReminderJobs: jest.fn().mockResolvedValue([]), }; (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ @@ -563,6 +565,8 @@ describe("creation notification jobs", () => { }; (AppointmentsList as any).jobService = { createJob: jest.fn().mockResolvedValue([]), + createMandatoryCreationJob: jest.fn().mockResolvedValue([]), + createReminderJobs: jest.fn().mockResolvedValue([]), }; (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ @@ -649,7 +653,7 @@ describe("creation notification jobs", () => { reminderWapMessage: "WAP REMINDER Ada Lovelace", }); - expect((AppointmentsList as any).jobService.createJob).toHaveBeenCalledWith( + expect((AppointmentsList as any).jobService.createMandatoryCreationJob).toHaveBeenCalledWith( expect.objectContaining({ type: "creation", payload: expect.objectContaining({ @@ -659,6 +663,11 @@ describe("creation notification jobs", () => { systemSubject: "Turno reservado en clases llavallol", systemMessage: "EMAIL ALTA Ada Lovelace Historia clases llavallol 22/07/2026 08:00hs.", }), + }) + ); + expect((AppointmentsList as any).jobService.createReminderJobs).toHaveBeenCalledWith( + expect.objectContaining({ + type: "reminder", reminderPayload: expect.objectContaining({ email: "ada@example.com", phoneNumber: "5491112345678", diff --git a/server/src/Models/NotificationPolicies/NotificationJobService.ts b/server/src/Models/NotificationPolicies/NotificationJobService.ts index 3ccc4fd..5d01f5e 100644 --- a/server/src/Models/NotificationPolicies/NotificationJobService.ts +++ b/server/src/Models/NotificationPolicies/NotificationJobService.ts @@ -1,5 +1,6 @@ import { CreateJobParams, + NotificationChannel, } from "../NotificationJobs/NotificationJobs.Interface"; import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose"; import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver"; @@ -121,6 +122,79 @@ export class NotificationJobService { return jobs; } + async createMandatoryCreationJob(params: CreateJobServiceParams): Promise { + const payloadChannels = this.getPayloadChannels(params.payload); + const channels = await this.policyResolver.resolveAvailableChannels( + params.companyId, + payloadChannels + ); + + if (channels.length === 0) { + return []; + } + + const scheduledAt = params.scheduledAt || new Date(); + const jobs: any[] = []; + + for (const channel of channels) { + const job = await this.jobAdapter.create({ + companyId: params.companyId, + appointmentId: params.appointmentId, + clientId: params.clientId, + channel, + type: "creation", + scheduledAt, + payload: params.payload, + }); + jobs.push(job); + } + + return jobs; + } + + async createReminderJobs(params: CreateJobServiceParams): Promise { + const resolvedPolicy = await this.policyResolver.resolve( + params.companyId, + params.clientId + ); + + if (!params.appointmentStart || resolvedPolicy.channels.length === 0) { + return []; + } + + const now = new Date(); + const jobs: any[] = []; + const enabledReminderRules = (resolvedPolicy.reminderRules || []).filter( + (rule) => rule.enabled + ); + + for (const rule of enabledReminderRules) { + const reminderScheduledAt = this.adjustForQuietHours( + dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(), + resolvedPolicy + ); + + if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) { + continue; + } + + for (const channel of resolvedPolicy.channels) { + const job = await this.jobAdapter.create({ + companyId: params.companyId, + appointmentId: params.appointmentId, + clientId: params.clientId, + channel, + type: "reminder", + scheduledAt: reminderScheduledAt, + payload: params.reminderPayload, + }); + jobs.push(job); + } + } + + return jobs; + } + /** * Cancel all pending jobs for an appointment. * Called when an appointment is deleted or rescheduled. @@ -165,6 +239,24 @@ export class NotificationJobService { return scheduledAt; } + private getPayloadChannels(payload?: CreateJobParams["payload"]): NotificationChannel[] { + const channels: NotificationChannel[] = []; + + if (payload?.email) { + channels.push("email"); + } + + if (payload?.phoneNumber) { + channels.push("whatsapp"); + } + + if (payload?.userId) { + channels.push("system"); + } + + return channels; + } + private isTimeInQuietHours( hour: number, minute: number, diff --git a/server/src/Models/NotificationPolicies/PolicyResolver.ts b/server/src/Models/NotificationPolicies/PolicyResolver.ts index c5606b6..ba6aa3a 100644 --- a/server/src/Models/NotificationPolicies/PolicyResolver.ts +++ b/server/src/Models/NotificationPolicies/PolicyResolver.ts @@ -93,6 +93,21 @@ export class PolicyResolver { }; } + async resolveAvailableChannels( + companyId: string, + channels: NotificationChannel[] + ): Promise { + const companyPolicy = await this.companyPolicyAdapter.findOne({ companyId }); + const enabledChannels = companyPolicy + ? companyPolicy.defaultChannels + : SYSTEM_DEFAULT_CHANNELS; + const companyEnabledChannels = channels.filter((channel) => + enabledChannels.includes(channel) + ); + + return this.applyPlanLimits(companyId, companyEnabledChannels); + } + /** * Returns muted channels from the winning cascade level only. * Per spec, muted channels are NOT merged across levels. diff --git a/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts b/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts index b1e55de..21b82c2 100644 --- a/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts +++ b/server/src/Models/NotificationPolicies/__tests__/NotificationJobService.test.ts @@ -10,6 +10,7 @@ jest.mock("../../NotificationJobs/NotificationJobs.Adapter.Mongoose", () => ({ jest.mock("../PolicyResolver", () => ({ PolicyResolver: jest.fn().mockImplementation(() => ({ resolve: jest.fn(), + resolveAvailableChannels: jest.fn(), })), })); @@ -27,6 +28,7 @@ describe("NotificationJobService.createJob", () => { let service: NotificationJobService; let mockJobCreate: jest.Mock; let mockResolverResolve: jest.Mock; + let mockResolveAvailableChannels: jest.Mock; beforeEach(() => { jest.clearAllMocks(); @@ -36,6 +38,7 @@ describe("NotificationJobService.createJob", () => { mockJobCreate = (service as any).jobAdapter.create as jest.Mock; mockResolverResolve = (service as any).policyResolver.resolve as jest.Mock; + mockResolveAvailableChannels = (service as any).policyResolver.resolveAvailableChannels as jest.Mock; }); it("creates a job with resolved channels", async () => { @@ -219,6 +222,114 @@ describe("NotificationJobService.createJob", () => { expect(result).toHaveLength(0); }); + it("creates mandatory creation jobs from available payload destinations without resolving reminder policy", async () => { + jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z")); + mockJobCreate.mockResolvedValue({ id: "job-1" }); + mockResolveAvailableChannels.mockResolvedValue(["email", "whatsapp"]); + const payload = { + email: "client@example.com", + phoneNumber: "5491112345678", + companyOwnerId: "owner-1", + emailMessage: "Alta email", + wapMessage: "Alta WAP", + }; + + const result = await service.createMandatoryCreationJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-1", + type: "creation", + payload, + }); + + const createCalls = mockJobCreate.mock.calls.map((call) => call[0]); + expect(mockResolverResolve).not.toHaveBeenCalled(); + expect(mockResolveAvailableChannels).toHaveBeenCalledWith("company-1", [ + "email", + "whatsapp", + ]); + expect(result).toHaveLength(2); + expect(createCalls).toEqual([ + expect.objectContaining({ + channel: "email", + type: "creation", + scheduledAt: new Date("2026-07-21T14:00:00.000Z"), + payload, + }), + expect.objectContaining({ + channel: "whatsapp", + type: "creation", + scheduledAt: new Date("2026-07-21T14:00:00.000Z"), + payload, + }), + ]); + }); + + it("does not create mandatory creation jobs for channels missing destination data", async () => { + mockResolveAvailableChannels.mockResolvedValue([]); + + await service.createMandatoryCreationJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-1", + type: "creation", + payload: { + companyOwnerId: "owner-1", + emailMessage: "Alta email", + wapMessage: "Alta WAP", + }, + }); + + expect(mockResolverResolve).not.toHaveBeenCalled(); + expect(mockResolveAvailableChannels).toHaveBeenCalledWith("company-1", []); + expect(mockJobCreate).not.toHaveBeenCalled(); + }); + + it("does not create mandatory creation jobs for unavailable channels", async () => { + mockResolveAvailableChannels.mockResolvedValue(["email"]); + const payload = { + email: "client@example.com", + phoneNumber: "5491112345678", + companyOwnerId: "owner-1", + }; + + const result = await service.createMandatoryCreationJob({ + companyId: "company-1", + clientId: "client-1", + appointmentId: "appt-1", + type: "creation", + payload, + }); + + const createCalls = mockJobCreate.mock.calls.map((call) => call[0]); + expect(result).toHaveLength(1); + expect(createCalls).toEqual([ + expect.objectContaining({ channel: "email", type: "creation", payload }), + ]); + }); + + it("keeps reminder jobs policy-aware when resolved channels are empty", async () => { + const resolvedPolicy: ResolvedPolicy = { + channels: [], + timezone: "America/Argentina/Buenos_Aires", + reminderRules: [{ offset: 30, 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-21T15:00:00.000Z"), + reminderPayload: { email: "client@example.com" }, + }); + + expect(mockResolverResolve).toHaveBeenCalledWith("company-1", "client-1"); + expect(mockJobCreate).not.toHaveBeenCalled(); + expect(result).toHaveLength(0); + }); + it("adjusts scheduledAt for quiet hours crossing midnight", async () => { const resolvedPolicy: ResolvedPolicy = { channels: ["system"], diff --git a/server/src/Models/NotificationPolicies/__tests__/PolicyResolver.test.ts b/server/src/Models/NotificationPolicies/__tests__/PolicyResolver.test.ts index 7ef8589..f082efe 100644 --- a/server/src/Models/NotificationPolicies/__tests__/PolicyResolver.test.ts +++ b/server/src/Models/NotificationPolicies/__tests__/PolicyResolver.test.ts @@ -309,4 +309,43 @@ describe("PolicyResolver", () => { expect(result.channels).toEqual([]); }); + + it("resolves available channels from company defaults and plan limits without client preferences", async () => { + mockCompanyPolicyFindOne.mockResolvedValue({ + companyId: "company-1", + defaultChannels: ["whatsapp" as NotificationChannel, "email" as NotificationChannel], + mutedChannels: ["email" as NotificationChannel], + timezone: "America/Argentina/Buenos_Aires", + }); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockResolvedValue(true); + + const result = await resolver.resolveAvailableChannels("company-1", [ + "whatsapp", + "email", + "system", + ]); + + expect(result).toEqual(["whatsapp", "email"]); + expect(mockClientPreferencesFindOne).not.toHaveBeenCalled(); + expect(mockOverrideFindOne).not.toHaveBeenCalled(); + }); + + it("removes available channels disallowed by the owner plan", async () => { + mockCompanyPolicyFindOne.mockResolvedValue({ + companyId: "company-1", + defaultChannels: ["whatsapp" as NotificationChannel, "email" as NotificationChannel], + timezone: "America/Argentina/Buenos_Aires", + }); + (PlanSubscriptionsList.checkFeature as jest.Mock).mockImplementation( + async (params: { feature: string }) => + params.feature !== PlanFeatures.WAP_NOTIFICATIONS + ); + + const result = await resolver.resolveAvailableChannels("company-1", [ + "whatsapp", + "email", + ]); + + expect(result).toEqual(["email"]); + }); });