diff --git a/notification-sender/package-lock.json b/notification-sender/package-lock.json index f7a9ea0..9280685 100644 --- a/notification-sender/package-lock.json +++ b/notification-sender/package-lock.json @@ -20,6 +20,7 @@ "@types/jest": "^30.0.0", "@types/mongoose": "^5.11.96", "@types/node": "^24.3.3", + "cross-env": "^10.1.0", "jest": "^30.4.2", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", @@ -600,6 +601,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2322,6 +2330,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/notification-sender/package.json b/notification-sender/package.json index e9d6b76..aacdf5f 100644 --- a/notification-sender/package.json +++ b/notification-sender/package.json @@ -4,7 +4,7 @@ "type": "module", "main": "index.js", "scripts": { - "dev": "NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts", + "dev": "cross-env NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts", "build": "tsc", "start": "node dist/index.js", "test": "jest --config jest.config.cjs" @@ -18,6 +18,7 @@ "@types/jest": "^30.0.0", "@types/mongoose": "^5.11.96", "@types/node": "^24.3.3", + "cross-env": "^10.1.0", "jest": "^30.4.2", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", diff --git a/notification-sender/src/Models/Jobs/ChannelDispatchers.ts b/notification-sender/src/Models/Jobs/ChannelDispatchers.ts index 4892de7..ffbdda3 100644 --- a/notification-sender/src/Models/Jobs/ChannelDispatchers.ts +++ b/notification-sender/src/Models/Jobs/ChannelDispatchers.ts @@ -91,12 +91,17 @@ export async function dispatchEmail(params: { systemToken: string; type: string; email: string; - subject: string; - message: string; + subject?: string; + message?: string; + templateId?: string; + context?: Record; }): Promise { try { - if (!params.email.trim() || !params.subject.trim() || !params.message.trim()) { - return { success: false, error: "Email dispatch requires email, subject, and message" }; + const hasContent = Boolean(params.subject?.trim() && params.message?.trim()); + const hasTemplate = Boolean(params.templateId?.trim() && params.context); + + if (!params.email.trim() || (!hasContent && !hasTemplate)) { + return { success: false, error: "Email dispatch requires email and either subject/message or templateId/context" }; } // Send worker-rendered content directly so cancellation jobs survive appointment deletion. @@ -106,8 +111,10 @@ export async function dispatchEmail(params: { { systemToken: params.systemToken, email: params.email, - subject: params.subject, - message: params.message, + ...(params.subject ? { subject: params.subject } : {}), + ...(params.message ? { message: params.message } : {}), + ...(params.templateId ? { templateId: params.templateId } : {}), + ...(params.context ? { context: params.context } : {}), }, { headers: { "Content-Type": "application/json" }, diff --git a/notification-sender/src/Models/Jobs/JobProcessor.ts b/notification-sender/src/Models/Jobs/JobProcessor.ts index 6a74268..4ed6934 100644 --- a/notification-sender/src/Models/Jobs/JobProcessor.ts +++ b/notification-sender/src/Models/Jobs/JobProcessor.ts @@ -202,6 +202,8 @@ export class JobProcessor { email: content.email || "", subject: content.subject, message: content.message, + templateId: content.emailTemplateId, + context: content.emailContext, }); break; case "system": diff --git a/notification-sender/src/Models/Jobs/NotificationContentResolver.ts b/notification-sender/src/Models/Jobs/NotificationContentResolver.ts index 0923b48..fda9625 100644 --- a/notification-sender/src/Models/Jobs/NotificationContentResolver.ts +++ b/notification-sender/src/Models/Jobs/NotificationContentResolver.ts @@ -45,6 +45,8 @@ export interface ResolvedNotificationContent { companyOwnerId?: string; subject: string; message: string; + emailTemplateId?: string; + emailContext?: Record; } const appointmentSchema = new Schema( @@ -169,6 +171,8 @@ export async function resolveNotificationContent(job: INotificationJobDocument): companyOwnerId: companyOwnerId ? String(companyOwnerId) : undefined, subject: fallbackSubject.trim(), message: fallbackMessage.trim(), + emailTemplateId: job.payload.emailTemplateId?.trim(), + emailContext: job.payload.emailContext, }; } } diff --git a/notification-sender/src/Models/Jobs/NotificationJob.Adapter.Mongoose.ts b/notification-sender/src/Models/Jobs/NotificationJob.Adapter.Mongoose.ts index ad6e8d2..01690de 100644 --- a/notification-sender/src/Models/Jobs/NotificationJob.Adapter.Mongoose.ts +++ b/notification-sender/src/Models/Jobs/NotificationJob.Adapter.Mongoose.ts @@ -15,6 +15,8 @@ export interface NotificationJobPayload { message?: string; emailSubject?: string; emailMessage?: string; + emailTemplateId?: string; + emailContext?: Record; wapMessage?: string; systemSubject?: string; systemMessage?: string; diff --git a/notification-sender/src/Models/Jobs/__tests__/ChannelDispatchers.test.ts b/notification-sender/src/Models/Jobs/__tests__/ChannelDispatchers.test.ts index fdfa015..4b7debc 100644 --- a/notification-sender/src/Models/Jobs/__tests__/ChannelDispatchers.test.ts +++ b/notification-sender/src/Models/Jobs/__tests__/ChannelDispatchers.test.ts @@ -43,6 +43,30 @@ describe("ChannelDispatchers", () => { ); }); + it("sends email template data when provided", async () => { + await dispatchEmail({ + appointmentId: "appointment-1", + systemToken: "system-token", + type: "reminder", + email: "client@example.com", + templateId: "template-1", + context: { username: "Client Name" }, + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + "https://api.example.com/notifications/send-email", + { + systemToken: "system-token", + email: "client@example.com", + templateId: "template-1", + context: { username: "Client Name" }, + }, + { + headers: { "Content-Type": "application/json" }, + } + ); + }); + it("sends the job type in the WhatsApp system notification payload", async () => { await dispatchWhatsApp({ companyId: "company-1", diff --git a/resources/ngrok.txt b/resources/ngrok.txt new file mode 100644 index 0000000..e3febaa --- /dev/null +++ b/resources/ngrok.txt @@ -0,0 +1,3 @@ +ngrok config add-authtoken 3I94dMHXyh3wjRjGBu0oGQ8D8h4_2yBusck6ZWHJBx7WcbBFf + +ngrok http 3000 \ No newline at end of file diff --git a/server/.env b/server/.env index a9a1163..9dde7f7 100644 --- a/server/.env +++ b/server/.env @@ -45,7 +45,7 @@ MP_SUBSCRIPTION_FIND_URL = https://api.mercadopago.com/preapproval/ MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648 MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard MP_CHECK_PAYMENT_MINUTES = 1 -MP_WEBHOOK_URL = https://9300-186-132-200-176.ngrok-free.app/mercadopago/webhook +MP_WEBHOOK_URL = https://30e1-186-132-147-13.ngrok-free.app/mercadopago/webhook DEFAULT_BOT_ADMIN_PORT = 3005 diff --git a/server/src/Models/Appointments/Appointments.Interface.ts b/server/src/Models/Appointments/Appointments.Interface.ts index b888010..5d0ee16 100644 --- a/server/src/Models/Appointments/Appointments.Interface.ts +++ b/server/src/Models/Appointments/Appointments.Interface.ts @@ -204,6 +204,8 @@ export interface AppointmentNotificationIntent { message: string; checkClient: IClientDocument; companyCheck: ICompanyDocument; + emailTemplateId?: string; + emailContext?: Record; } export interface IAppointment { diff --git a/server/src/Models/Appointments/Appointments.ts b/server/src/Models/Appointments/Appointments.ts index e080e08..0637590 100644 --- a/server/src/Models/Appointments/Appointments.ts +++ b/server/src/Models/Appointments/Appointments.ts @@ -287,6 +287,23 @@ class AppointmentManager implements IAppointmentsManager { return parseInt(`${process.env.CANCELLATION_TIME}`) || 24; } + private formatAppointmentDuration(startHour: number, endHour: number): string { + const durationHours = endHour >= startHour ? endHour - startHour : endHour + 24 - startHour; + const durationMinutes = Math.round(durationHours * 60); + const hours = Math.floor(durationMinutes / 60); + const minutes = durationMinutes % 60; + + if (hours > 0 && minutes > 0) { + return `${hours} ${hours === 1 ? "hora" : "horas"} y ${minutes} minutos`; + } + + if (hours > 0) { + return `${hours} ${hours === 1 ? "hora" : "horas"}`; + } + + return `${minutes} minutos`; + } + private validateCancellationTime(aTime: number | undefined, start: string | Date): boolean { let cancellationTime = this.getCancellationTime(aTime); @@ -2168,6 +2185,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { companyOwnerId: string; companyName: string; emailMessage: string; + emailTemplateId?: string; + emailContext?: Record; wapMessage: string; }): Promise { const systemSubject = `Turno cancelado en ${data.companyName}`; @@ -2187,6 +2206,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { message: data.emailMessage, emailSubject: "TurnosXpress :: Turno cancelado", emailMessage: data.emailMessage, + emailTemplateId: data.emailTemplateId, + emailContext: data.emailContext, wapMessage: data.wapMessage, systemSubject, systemMessage: data.emailMessage, @@ -2206,8 +2227,12 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { appointmentStart: Date; emailMessage: string; wapMessage: string; + emailTemplateId?: string; + emailContext?: Record; reminderEmailMessage: string; reminderWapMessage: string; + reminderEmailTemplateId?: string; + reminderEmailContext?: Record; }): Promise { const systemSubject = `Turno reservado en ${data.companyName}`; @@ -2220,6 +2245,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { message: data.emailMessage, emailSubject: "TurnosXpress :: Alta de turno", emailMessage: data.emailMessage, + emailTemplateId: data.emailTemplateId, + emailContext: data.emailContext, wapMessage: data.wapMessage, systemSubject, systemMessage: data.emailMessage, @@ -2277,6 +2304,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { companyName: data.companyName, appointmentStart: data.appointmentStart, reminderEmailMessage: reminderEmailContent.message, + reminderEmailTemplateId: reminderEmailContent.emailTemplateId, + reminderEmailContext: reminderEmailContent.emailContext, reminderWapMessage: reminderWapContent.message, }; @@ -2301,6 +2330,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { await this.createCreationNotificationJobs({ ...notificationJobData, emailMessage: emailContent.message, + emailTemplateId: emailContent.emailTemplateId, + emailContext: emailContent.emailContext, wapMessage: wapContent.message, }); } @@ -2317,6 +2348,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { appointmentStart: Date; reminderEmailMessage: string; reminderWapMessage: string; + reminderEmailTemplateId?: string; + reminderEmailContext?: Record; }): Promise { const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`; @@ -2335,6 +2368,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { message: data.reminderEmailMessage, emailSubject: "TurnosXpress :: Recordatorio", emailMessage: data.reminderEmailMessage, + emailTemplateId: data.reminderEmailTemplateId, + emailContext: data.reminderEmailContext, wapMessage: data.reminderWapMessage, systemSubject: reminderSystemSubject, systemMessage: data.reminderEmailMessage, @@ -2511,6 +2546,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { companyOwnerId: String(companyCheck.ownerId), companyName: companyCheck.name, emailMessage: emailContent.message, + emailTemplateId: emailContent.emailTemplateId, + emailContext: emailContent.emailContext, wapMessage: wapContent.message, }); } @@ -2705,6 +2742,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { } let wapMessage = ""; + let emailTemplateId: string | undefined; + let emailContext: Record | undefined; if (data.type == APPOINTMENT_NOTIFICATION_TYPE.REMINDER) { if (companyCheck.templateWapNotifId) { @@ -2748,6 +2787,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); + + emailTemplateId = "6a86ef631898c6948d0f5308"; + emailContext = { + username: ClientsManager.getClientFullName(checkClient), + nombre_organizacion: companyCheck.name, + fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"), + hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.", + nombre_servicio: checkService.name, + nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser), + duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour), + precio: formatCurrency(checkAppointment.price), + direccion: CompaniesManager.getCompanyAddress(companyCheck), + }; } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) { const templateId = data.channel === "email" ? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId @@ -2797,6 +2849,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); + + emailTemplateId = "6a86f55e3edd051e8a04c711"; + emailContext = { + username: ClientsManager.getClientFullName(checkClient), + nombre_organizacion: companyCheck.name, + fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"), + hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.", + nombre_servicio: checkService.name, + nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser), + duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour), + precio: formatCurrency(checkAppointment.price), + direccion: CompaniesManager.getCompanyAddress(companyCheck), + }; } 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" @@ -2852,12 +2917,29 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price)); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); + + emailTemplateId = isUpdate + ? "6a86febf6713e0bdd104c7ca" + : "6a86f77d376a7371170b21c5"; + emailContext = { + username: ClientsManager.getClientFullName(checkClient), + nombre_organizacion: companyCheck.name, + fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"), + hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.", + nombre_servicio: checkService.name, + nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser), + duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour), + precio: formatCurrency(checkAppointment.price), + direccion: CompaniesManager.getCompanyAddress(companyCheck), + }; } return { message: wapMessage, checkClient: checkClient, companyCheck: companyCheck, + emailTemplateId, + emailContext, }; } @@ -2876,7 +2958,7 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { } } - const { message, checkClient, companyCheck } = await this.tryToSendNotification({ + const { message, checkClient, companyCheck, emailTemplateId, emailContext } = await this.tryToSendNotification({ ...data, channel: "email", }); @@ -2913,6 +2995,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { email: clientEmail, subject: subjectEmail, message: message, + templateId: emailTemplateId, + context: emailContext, }); } @@ -2964,6 +3048,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise { message: emailContent.message, emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio", emailMessage: emailContent.message, + emailTemplateId: emailContent.emailTemplateId, + emailContext: emailContent.emailContext, wapMessage: wapContent.message, systemSubject, systemMessage: emailContent.message, diff --git a/server/src/Models/NotificationJobs/NotificationJobs.Interface.ts b/server/src/Models/NotificationJobs/NotificationJobs.Interface.ts index 87cff4d..251aa1d 100644 --- a/server/src/Models/NotificationJobs/NotificationJobs.Interface.ts +++ b/server/src/Models/NotificationJobs/NotificationJobs.Interface.ts @@ -23,6 +23,8 @@ export interface NotificationJobPayload { message?: string; emailSubject?: string; emailMessage?: string; + emailTemplateId?: string; + emailContext?: Record; wapMessage?: string; systemSubject?: string; systemMessage?: string; diff --git a/server/src/api/Notifications/Notifications.Controller.ts b/server/src/api/Notifications/Notifications.Controller.ts index 0a86f97..19a3f8c 100644 --- a/server/src/api/Notifications/Notifications.Controller.ts +++ b/server/src/api/Notifications/Notifications.Controller.ts @@ -7,8 +7,10 @@ import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; interface SendEmailByContentParams { systemToken: string; email: string; - subject: string; - message: string; + subject?: string; + message?: string; + templateId?: string; + context?: Record; } interface SendWapByContentParams { @@ -29,6 +31,8 @@ export class SendEmailByContentController extends Controller { email: requestBody.email, subject: requestBody.subject, message: requestBody.message, + templateId: requestBody.templateId, + context: requestBody.context, }); this.setStatus(200);