feat: implement comprehensive notification engine with policies, preferences, and automated dispatching services

This commit is contained in:
2026-07-21 18:53:11 -03:00
parent 40090cdec5
commit 10ca449f88
82 changed files with 10249 additions and 171 deletions
+309 -92
View File
@@ -10,6 +10,7 @@ import {
ChangeEmployeeToOwnerParams,
CountAppointmentsByMonthParams,
CreateAppointmentParams,
CreateImmediateAppointmentNotificationJobsParams,
DeleteAppointmentDiscountParams,
DeleteAppointmentParams,
DeleteAppointmentsByCompanyParams,
@@ -76,6 +77,7 @@ import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import DiscountsEmail from "../DiscountsEmail/DiscountsEmail";
import SchedulesOverridesList from "../SchedulesOverrides/SchedulesOverrides";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { NotificationJobService } from "../NotificationPolicies/NotificationJobService";
dayjs.locale("es");
dayjs.extend(isSameOrAfter);
@@ -84,9 +86,11 @@ dayjs.extend(weekday);
class AppointmentManager implements IAppointmentsManager {
Appointments: AppointmentsAdapterMongoose;
private jobService: NotificationJobService;
constructor() {
this.Appointments = new AppointmentsAdapterMongoose();
this.jobService = new NotificationJobService();
}
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
@@ -526,36 +530,35 @@ class AppointmentManager implements IAppointmentsManager {
code: String((newAppointment as any)._id)
});
//Intento enviar notificacion al cliente por Email
try {
if (newAppointment.id) {
await this.sendEmailNotification({
appointmentId: String(newAppointment.id),
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
sessionUser: String(companyCheck.ownerId),
});
}
} catch (err) {
console.log(
`Error al enviar la notificacion por Email para ${companyCheck.name}(${companyCheck._id}):`,
err
);
}
//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);
//Intento enviar notificacion al cliente por Whatsapp
try {
if (newAppointment.id) {
await this.sendWapNotification({
appointmentId: String(newAppointment.id),
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
sessionUser: String(companyCheck.ownerId),
});
}
} catch (err) {
console.log(
`Error al enviar la notificacion por Whatsapp para ${companyCheck.name}(${companyCheck._id}):`,
err
);
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,
});
}
}
@@ -2070,36 +2073,18 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
code: String(checkAppointment._id)
});
//Intento enviar notificacion al cliente por Email
try {
if (checkAppointment.id) {
await this.sendEmailNotification({
appointmentId: String(checkAppointment.id),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
sessionUser: String(companyCheck.ownerId),
});
}
} catch (err) {
console.log(
`Error al enviar la notificacion por Email para ${companyCheck.name}(${companyCheck._id}):`,
err
);
}
//Cancel existing pending jobs before creating new ones for the rescheduled appointment
if (checkAppointment.id) {
await this.jobService.cancelByAppointment(String(checkAppointment.id));
//Intento enviar notificacion al cliente por Whatsapp
try {
if (checkAppointment.id) {
await this.sendWapNotification({
appointmentId: String(checkAppointment.id),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
sessionUser: String(companyCheck.ownerId),
});
}
} catch (err) {
console.log(
`Error al enviar la notificacion por Whatsapp para ${companyCheck.name}(${companyCheck._id}):`,
err
);
//Create jobs for email and whatsapp notifications
await this.jobService.createJob({
companyId: String(companyCheck._id),
clientId: String(checkClient._id),
appointmentId: String(checkAppointment.id),
type: "update" as const,
appointmentStart: new Date(data.start),
});
}
}
@@ -2155,8 +2140,89 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
return config;
}
private async createCancellationNotificationJobs(data: {
appointmentId: string;
companyId: string;
clientId: string;
clientUserId?: string;
clientEmail?: string;
clientPhoneNumber?: string;
companyOwnerId: string;
companyName: string;
emailMessage: string;
wapMessage: string;
}): Promise<void> {
const systemSubject = `Turno cancelado en ${data.companyName}`;
await this.jobService.createJob({
companyId: data.companyId,
clientId: data.clientId,
appointmentId: data.appointmentId,
type: "cancellation",
scheduledAt: new Date(),
payload: {
email: data.clientEmail,
phoneNumber: data.clientPhoneNumber,
userId: data.clientUserId,
companyOwnerId: data.companyOwnerId,
subject: systemSubject,
message: data.emailMessage,
emailSubject: "TurnosXpress :: Turno cancelado",
emailMessage: data.emailMessage,
wapMessage: data.wapMessage,
systemSubject,
systemMessage: data.emailMessage,
},
});
}
private async createCreationNotificationJobs(data: {
appointmentId: string;
companyId: string;
clientId: string;
clientUserId?: string;
clientEmail?: string;
clientPhoneNumber?: string;
companyOwnerId: string;
companyName: string;
appointmentStart: Date;
emailMessage: string;
wapMessage: string;
}): Promise<void> {
const systemSubject = `Turno reservado en ${data.companyName}`;
await this.jobService.createJob({
companyId: data.companyId,
clientId: data.clientId,
appointmentId: data.appointmentId,
type: "creation",
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,
},
});
}
private async getOptionalClientWapNumber(client: IClientDocument): Promise<string | undefined> {
try {
return await ClientsManager.getClientWapNumber(client);
} catch {
return undefined;
}
}
public async deleteAppointment(data: DeleteAppointmentParams): Promise<void> {
const { validation = false } = data;
const { validation = false, notification = false } = data;
const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser,
});
@@ -2234,23 +2300,41 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
try {
const config = this.getHeatMapConfig(checkAppointment, serviceCheck.length);
// Cancel pending notification jobs for this appointment before deleting it
await this.jobService.cancelByAppointment(String(checkAppointment._id));
if (notification) {
const emailContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment._id),
sessionUser: data.sessionUser,
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
channel: "email",
});
const wapContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment._id),
sessionUser: data.sessionUser,
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
channel: "whatsapp",
});
await this.createCancellationNotificationJobs({
companyId: String(checkAppointment.companyId),
clientId: String(checkAppointment.clientId),
appointmentId: String(checkAppointment._id),
clientUserId: checkClient.userId ? String(checkClient.userId) : undefined,
clientEmail: checkClient.email,
clientPhoneNumber: await this.getOptionalClientWapNumber(checkClient),
companyOwnerId: String(companyCheck.ownerId),
companyName: companyCheck.name,
emailMessage: emailContent.message,
wapMessage: wapContent.message,
});
}
await this.Appointments.AppointmentList.deleteOne({ _id: data.id });
//Actualizar el heatmap.
await this.updateHeatMap(config);
if (checkClient.userId) {
//Enviamos la notificacion de cancelacion al cliente
await NotificationsManager.sendSystemNotification({
userId: String(checkClient.userId),
subject: "Turno Cancelado",
message: `Se ha cancelado el turno para el día ${dayjs(checkAppointment.start).format(
"DD/MM/YYYY"
)} a las ${dayjs(checkAppointment.start).format("HH:mm")}`,
type: NotificationType.APPOINTMENT,
code: String(checkAppointment._id)
});
}
//Enviamos la notificacion de cancelacion al profesional
await NotificationsManager.sendSystemNotification({
userId: String(employeeCheck.userId),
@@ -2384,8 +2468,16 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
throw new Error("La compañia no existe");
}
if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) {
throw new Error(NoPermissionMessage());
const hasSystemToken = this.hasValidSystemToken(data.systemToken);
if (!hasSystemToken) {
if (!data.sessionUser) {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) {
throw new Error(NoPermissionMessage());
}
}
const checkClient = await ClientsManager.clients.findOne({
@@ -2465,9 +2557,13 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) {
if (companyCheck.templateWapAltaId) {
const templateId = data.channel === "email"
? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId
: companyCheck.templateWapAltaId;
if (templateId) {
const template = await Templates.templates.findOne({
id: String(companyCheck.templateWapAltaId),
id: String(templateId),
companyId: String(companyCheck._id),
});
@@ -2477,7 +2573,9 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
}
if (!wapMessage) {
wapMessage = process.env.WAP_ALTA ? process.env.WAP_ALTA : "";
wapMessage = data.channel === "email"
? process.env.EMAIL_NEW_APPOINTMENT || process.env.EMAIL_ALTA || process.env.WAP_ALTA || ""
: process.env.WAP_ALTA || "";
}
wapMessage = wapMessage.replaceAll(
@@ -2505,6 +2603,52 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---SERVICIO--->", checkService.name);
wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price));
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION) {
const templateId = data.channel === "email"
? companyCheck.templateEmailCancellationId || companyCheck.templateWapCancellationId
: companyCheck.templateWapCancellationId;
if (templateId) {
const template = await Templates.templates.findOne({
id: String(templateId),
companyId: String(companyCheck._id),
});
if (template) {
wapMessage = template.template;
}
}
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 || "";
}
wapMessage = wapMessage.replaceAll(
"<---CLIENTE--->",
ClientsManager.getClientFullName(checkClient)
);
wapMessage = wapMessage.replaceAll(
"<---FECHA--->",
dayjs(checkAppointment.start).format("DD/MM/YYYY")
);
wapMessage = wapMessage.replaceAll(
"<---HORARIO--->",
dayjs(checkAppointment.start).format("HH:mm") + "hs."
);
wapMessage = wapMessage.replaceAll(
"<---DIRECCION--->",
CompaniesManager.getCompanyAddress(companyCheck)
);
wapMessage = wapMessage.replaceAll(
"<---PROFESIONAL--->",
UsersManager.getUserFullName(checkEmployeeUser)
);
wapMessage = wapMessage.replaceAll("<---SERVICIO--->", checkService.name);
wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price));
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
}
@@ -2517,15 +2661,24 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
}
public async sendEmailNotification(data: SendAppointmentNotificationParams): Promise<void> {
const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser,
});
if (!this.hasValidSystemToken(data.systemToken)) {
if (!data.sessionUser) {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
if (!sessionUser) {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser,
});
if (!sessionUser) {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
}
const { message, checkClient, companyCheck } = await this.tryToSendNotification(data);
const { message, checkClient, companyCheck } = await this.tryToSendNotification({
...data,
channel: "email",
});
const canSend = await PlanSubscriptionsList.checkFeature({
userId: String(companyCheck.ownerId),
@@ -2548,6 +2701,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
subjectEmail = "TurnosXpress :: Recordatorio";
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) {
subjectEmail = "TurnosXpress :: Alta de turno";
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION) {
subjectEmail = "TurnosXpress :: Turno cancelado";
}
//Enviar email...
@@ -2558,7 +2713,66 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
});
}
public async createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise<void> {
if (![APPOINTMENT_NOTIFICATION_TYPE.CREATION, APPOINTMENT_NOTIFICATION_TYPE.REMINDER].includes(data.type)) {
throw new Error("El tipo de notificación no es válido para esta acción");
}
const emailContent = await this.tryToSendNotification({
appointmentId: data.appointmentId,
sessionUser: data.sessionUser,
type: data.type,
channel: "email",
});
const wapContent = await this.tryToSendNotification({
appointmentId: data.appointmentId,
sessionUser: data.sessionUser,
type: data.type,
channel: "whatsapp",
});
const checkAppointment = await this.Appointments.findOne({
_id: data.appointmentId,
});
if (!checkAppointment) {
throw new Error("El turno no existe");
}
const checkClient = emailContent.checkClient;
const companyCheck = emailContent.companyCheck;
const isCreation = data.type === APPOINTMENT_NOTIFICATION_TYPE.CREATION;
const systemSubject = isCreation
? `Turno reservado en ${companyCheck.name}`
: `Recordatorio de turno en ${companyCheck.name}`;
await this.jobService.createImmediateJob({
companyId: String(checkAppointment.companyId),
clientId: String(checkAppointment.clientId),
appointmentId: String(checkAppointment._id),
type: data.type,
scheduledAt: new Date(),
payload: {
email: checkClient.email,
phoneNumber: await this.getOptionalClientWapNumber(checkClient),
userId: checkClient.userId ? String(checkClient.userId) : undefined,
companyOwnerId: String(companyCheck.ownerId),
subject: systemSubject,
message: emailContent.message,
emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio",
emailMessage: emailContent.message,
wapMessage: wapContent.message,
systemSubject,
systemMessage: emailContent.message,
},
});
}
public async sendWapNotification(data: SendAppointmentNotificationParams): Promise<void> {
if (!data.sessionUser) {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser,
});
@@ -2567,7 +2781,10 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
const { message, checkClient, companyCheck } = await this.tryToSendNotification(data);
const { message, checkClient, companyCheck } = await this.tryToSendNotification({
...data,
channel: "whatsapp",
});
const canSend = await PlanSubscriptionsList.checkFeature({
userId: String(companyCheck.ownerId),
@@ -2593,13 +2810,7 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
}
public async sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise<void> {
if (!data.systemToken || data.systemToken == "") {
throw new Error("No tiene permisos para realizar esta acción");
}
const systemToken = `${process.env.SYSTEM_KEY}`;
if (systemToken != data.systemToken) {
if (!this.hasValidSystemToken(data.systemToken)) {
throw new Error("No tiene permisos para realizar esta acción");
}
@@ -2622,7 +2833,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
const { message, checkClient, companyCheck } = await this.tryToSendNotification({
appointmentId: data.appointmentId,
sessionUser: String(company.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
systemToken: data.systemToken,
type: data.type,
});
const canSend = await PlanSubscriptionsList.checkFeature({
@@ -2838,6 +3050,11 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
inRangeDates: [],
} as GetAvailableDatesResult;
}
private hasValidSystemToken(systemToken?: string): boolean {
const expectedSystemToken = process.env.SYSTEM_KEY || process.env.API_KEY;
return Boolean(systemToken && expectedSystemToken && systemToken === expectedSystemToken);
}
}
const AppointmentList = new AppointmentManager();