import { AppointmentsAdapterMongoose } from "./Appointments.Adapter.Mongoose"; import { ApplyAppointmentDiscountParams, APPOINTMENT_NOTIFICATION_TYPE, AppointmentAdminByClientView, AppointmentAdminByDateView, AppointmentEvent, AppointmentEventByClient, AppointmentNotificationIntent, ChangeEmployeeToOwnerParams, CountAppointmentsByMonthParams, CreateAppointmentParams, CreateImmediateAppointmentNotificationJobsParams, DeleteAppointmentDiscountParams, DeleteAppointmentParams, DeleteAppointmentsByCompanyParams, FindAppointmentsByUserParams, FindAppointmentsByCollaboratorParams, FindAppointmentsByUserPaginatedParams, FindAppointmentsByCollaboratorPaginatedParams, PaginatedAppointmentEventByClientResult, FindAppointmentSchedulesParams, FindAppointmentsParams, GetAppointmentEventParams, GetAvailableDatesParams, GetAvailableDatesResult, HeatMapConfig, IAppointment, IAppointmentsManager, PaginateAppointmentsParams, PaginateAppointmentsResults, SendAppointmentNotificationParams, SendWapSystemNotificationParams, SetPaymentStatusParams, UpdateAppointmentParams, MoveAppointmentParams, ChangeServiceParams, } from "./Appointments.Interface"; import UsersManager from "../Users/Users"; import ClientsManager from "../Clients/Clients"; import CompaniesManager from "../Companies/Companies"; import EmployeesList from "../Employees/Employee"; import ServicesManager from "../Services/Service"; import { HeatMapItem, IHeatMap } from "../../Models/HeatMap/HeatMap.Interface"; import HeatMapList from "../HeatMap/HeatMap"; import dayjs from "dayjs"; import "dayjs/locale/es"; import isSameOrAfter from "dayjs/plugin/isSameOrAfter"; import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; import weekday from "dayjs/plugin/weekday"; import { IClientDocument } from "../../Models/Clients/Clients.Adapter.Mongoose"; import { isNull } from "../../helpers/IsNull"; import { joinStrings } from "../../helpers/String"; import getAvatar from "../../helpers/getAvatar"; import ServiceList from "../Services/Service"; import { EmployeeRoles } from "../../Models/Employees/Employees.Interface"; import { NoPermissionMessage } from "../../helpers/NoPermissionMessage"; import SchedulesEnabledList from "../../Models/SchedulesEnabled/SchedulesEnabled"; import SchedulesList from "../../Models/Schedules/Schedules"; import MetricsList from "../../Models/Metrics/Metrics"; import { TextObjectFilterResult } from "../TextObjectFilter.model"; import { NotificationsManager } from "../Notifications/Notifications"; import SystemNoficationsList from "../SystemNotifications/SystemNotification"; import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons"; import { PlanFeatures } from "../Plans/Plans.interface"; import { formatCurrency } from "../../helpers/formatCurrency"; import RepeatsList from "../Repeats/Repeats"; import { IUserDocument } from "../Users/Users.Adapter.Mongoose"; import UserList from "../Users/Users"; import { DiscountType } from "../Discounts/Discounts.Interface"; import Discounts from "../Discounts/Discounts"; import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check"; import Templates from "../Templates/Templates"; import { CollaboratorSchedulesView, ScheduleItem } from "../Schedules/Schedules.Interface"; import ClientAccount from "../ClientAccounts/ClientAccount"; 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); dayjs.extend(isSameOrBefore); 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 { if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") { return true; } return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId); } public async setPaymentStatus(data: SetPaymentStatusParams): Promise { 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 appointment = await this.Appointments.findOne({ _id: data.appointmentId, }); if (!appointment) { throw new Error("El turno no existe"); } const companyCheck = await CompaniesManager.companies.findOne({ _id: String(appointment.companyId), }); if (!companyCheck) { throw new Error("La compañia no existe"); } if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) { throw new Error(NoPermissionMessage()); } appointment.payment = data.status; await appointment.save(); } public async countAppointmentsByMonth(data: CountAppointmentsByMonthParams): Promise { const startMonth = dayjs(new Date(data.year, data.month, 1)).startOf("month"); const endMonth = dayjs(new Date(data.year, data.month, 1)).endOf("month"); const appointments = await this.Appointments.findByDateRange( startMonth.toDate(), endMonth.toDate(), data.companyId ); return appointments.length; } public async changeEmployeeToOwner(data: ChangeEmployeeToOwnerParams): Promise { const org = await CompaniesManager.companies.findOne({ _id: data.companyId, }); if (!org) { throw new Error("La compañia no existe"); } const appointment = await this.Appointments.AppointmentList.findOne({ _id: data.appointmentId, }); if (!appointment) { throw new Error("El turno no existe"); } const employee = await EmployeesList.employees.findOne({ _id: String(appointment.employeeId), }); if (!employee) { throw new Error("El empleado no existe"); } const userEmployee = await UsersManager.users.findOne({ _id: String(employee.userId), }); if (!userEmployee) { throw new Error("El empleado no tiene usuario asociado"); } const employeeOwner = await EmployeesList.employees.findOne({ companyId: String(org._id), userId: String(org.ownerId), }); if (!employeeOwner) { throw new Error("El dueño de la compañia no tiene colaborador asociado"); } const appClient = await ClientsManager.clients.findOne({ _id: String(appointment.clientId), }); if (!appClient) { throw new Error("El cliente no existe"); } const appService = await ServicesManager.services.findOne({ _id: String(appointment.serviceId), }); if (!appService) { throw new Error("El servicio no existe"); } appointment.employeeId = String(employeeOwner.id); appointment.comments = String( appointment.comments + " Turno transferido de " + UsersManager.getUserFullName(userEmployee) ).trimStart(); await appointment.save(); //Si el turno es para el dia actual o posterior, enviar notificacion al dueño de la compañia if (dayjs(appointment.dateDay).isSameOrAfter(dayjs().startOf("day"))) { await SystemNoficationsList.createNotification({ userId: String(org.ownerId), subject: "Turno transferido", message: `El turno del dia ${dayjs(appointment.dateDay).format( "DD/MM/YYYY" )} de ${UsersManager.getUserFullName(userEmployee)} del servicio ${ appService.name } para el cliente ${ClientsManager.getClientFullName( appClient )} ha sido transferido al dueño de la compañia.`, }); } //Actualizar el heatmap. const defaultFraction = Number(process.env.DEFAULT_COMPANY_SERVICE_FRACTION); const serviceCheck = await ServicesManager.services.findOne({ _id: String(appointment.serviceId), companyId: String(appointment.companyId), }); if (!serviceCheck) { throw new Error("El servicio no existe o no pertenece a la compañia."); } const availabilityConfig: HeatMapConfig = { companyId: String(appointment.companyId), serviceId: String(appointment.serviceId), employeeId: String(appointment.employeeId), heatMapDate: dayjs(appointment.start).startOf("day").toDate(), start: dayjs(appointment.start).toDate(), fraction: defaultFraction, length: isNull(appointment.length, 1), serviceLimit: serviceCheck.limit, serviceLength: serviceCheck.length, }; await this.updateHeatMap(availabilityConfig); } public async deleteAppointmentsByCompany(data: DeleteAppointmentsByCompanyParams): Promise { await this.Appointments.AppointmentList.deleteMany({ companyId: data.companyId, }); } private getAppointmentTime(appointmentTime: number | undefined): number { if (appointmentTime) { return Math.abs(appointmentTime); } return parseInt(`${process.env.APPOINTMEENT_TIME}`) || 24; } private validateAppointmentTime(aTime: number | undefined, start: string | Date): boolean { let appointmentTime = this.getAppointmentTime(aTime); const checkDate = dayjs(start).clone().add(-appointmentTime, "hours"); const nowDate = dayjs().clone(); if (nowDate.isBefore(checkDate)) { return true; } return false; } private getCancellationTime(cancellationTime: number | undefined): number { if (cancellationTime) { return Math.abs(cancellationTime); } return parseInt(`${process.env.CANCELLATION_TIME}`) || 24; } private validateCancellationTime(aTime: number | undefined, start: string | Date): boolean { let cancellationTime = this.getCancellationTime(aTime); const checkDate = dayjs(start).clone().add(-cancellationTime, "hours"); const nowDate = dayjs().clone(); if (nowDate.isBefore(checkDate)) { return true; } return false; } public async createAppointment(data: CreateAppointmentParams): Promise { const { validation = true, notification = true } = data; 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 companyCheck = await CompaniesManager.companies.findOne({ _id: data.companyId, }); if (!companyCheck) { throw new Error("La compañia no existe"); } const serviceCheck = await ServicesManager.services.findOne({ _id: data.serviceId, companyId: data.companyId, }); if (!serviceCheck) { throw new Error("El servicio no existe o no pertenece a la compañia."); } let servicePrice = serviceCheck.price; if (!data.price) { data.price = servicePrice; } if (!data.price) { throw new Error("Error al determinar el valor del turno."); } data.length = isNull(data.length, 1); if (!data.length) { throw new Error("Error al determinar la duración del turno."); } const employeeCheck = await EmployeesList.employees.findOne({ companyId: data.companyId, _id: data.employeeId, }); if (!employeeCheck) { throw new Error("El empleado no existe o no pertenece a la compañia."); } if (!this.validateAppointmentTime(companyCheck.appointmentTime, data.start) && validation) { throw new Error( `No es posible reservar el turno con menos de ${this.getAppointmentTime( companyCheck.appointmentTime )} horas de anticipación.` ); } let checkClient = {} as IClientDocument | null; if (data.userId) { checkClient = await ClientsManager.clients.findOne({ userId: data.userId, companyId: data.companyId, }); if (!checkClient) { //Si el cliente no existe hay que crearlo. checkClient = await ClientsManager.createClientByUser({ userId: data.userId, companyId: data.companyId, sessionUser: data.sessionUser, }); if (!checkClient) { throw new Error("No se ha podido crear el cliente"); } } if (!checkClient.status) { throw new Error("El cliente no se encuentra activo."); } } else if (data.clientId) { checkClient = await ClientsManager.clients.findOne({ _id: data.clientId, }); if (!checkClient) { throw new Error("El cliente no existe."); } } if (!checkClient) { throw new Error("Ha ocurrido un error al identificar el cliente."); } if (checkClient.banned) { throw new Error(`La reserva no se pudo completar. Por favor comuniquese con ${companyCheck.name}`); } const checkAppointment = await this.Appointments.findOne({ companyId: data.companyId, serviceId: data.serviceId, employeeId: data.employeeId, clientId: String(checkClient.id), start: dayjs(data.start).toDate(), }); if (checkAppointment) { throw new Error("Ya existe una reserva para la fecha y horario seleccionados"); } const defaultFraction = Number(process.env.DEFAULT_COMPANY_SERVICE_FRACTION); const availabilityConfig: HeatMapConfig = { companyId: data.companyId, serviceId: data.serviceId, employeeId: data.employeeId, heatMapDate: dayjs(data.start).startOf("day").toDate(), start: dayjs(data.start).toDate(), fraction: defaultFraction, length: isNull(data.length, 1), serviceLimit: serviceCheck.limit, serviceLength: serviceCheck.length, }; if (validation) { const checkAvailable = await this.checkAvailability(availabilityConfig, data); if (checkAvailable === false) { throw new Error("No hay disponibilidad para el horario seleccionado."); } const checkSchedules = await this.checkSchedules(availabilityConfig); if (checkSchedules === false) { throw new Error("No hay disponibilidad para el dia o el horario seleccionado."); } const checkDisabled = await this.checkSchedulesDisabled(availabilityConfig); if (!checkDisabled) { throw new Error("El profesional no se encuentra disponible en el horario seleccionado."); } } data.comments = isNull(data.comments, ""); const appointmentReserved = await MetricsList.reserveAppointment({ userId: String(companyCheck.ownerId), companyId: data.companyId, quantity: 1, }); if (!appointmentReserved) { throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan."); } let appointmentCreated = false; try { const start = dayjs(data.start).clone(); const end = dayjs(data.start) .clone() .add(serviceCheck.length * isNull(data.length, 1), "minutes"); const startHour = start.clone().hour() + start.clone().minute() / 60; const endHour = end.clone().hour() + end.clone().minute() / 60; const newAppointment = await this.Appointments.create({ companyId: data.companyId, serviceId: data.serviceId, employeeId: data.employeeId, clientId: String(checkClient._id), userId: checkClient.userId ? String(checkClient.userId) : undefined, start: dayjs(data.start).toDate(), startHour: startHour, endHour: endHour, dateDay: dayjs(data.start).clone().startOf("day").toDate(), price: data.price, length: data.length, comments: data.comments, sessionUser: data.sessionUser, }); appointmentCreated = true; if (data.repeatId) { const repeat = await RepeatsList.repeats.findOne({ _id: data.repeatId, }); if (repeat && repeat.discountId) { const discount = await Discounts.discounts.discountsList.findOne({ _id: repeat.discountId, }); if (discount) { await this.applyDiscount({ appointmentId: String(newAppointment.id), discountId: String(discount._id), companyId: String(newAppointment.companyId), sessionUser: String(data.sessionUser), }); } } } await MetricsList.metrics.addAppointment({ userId: companyCheck.ownerId, companyId: data.companyId, quantity: 1, }); await this.updateHeatMap(availabilityConfig); await this.tryApplyDiscounts(newAppointment); if (notification && checkClient.userId) { //Envio la notificacion al cliente. await NotificationsManager.sendSystemNotification({ userId: String(checkClient.userId), subject: "Turno reservado", message: `Se ha reservado un turno para ${serviceCheck.name} el día ${dayjs( data.start ).format("DD/MM/YYYY")} a las ${dayjs(data.start).format("HH:mm")}`, 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); 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, }); } } //Envio la notificacion al profesional. await NotificationsManager.sendSystemNotification({ userId: String(employeeCheck.userId), subject: "Turno reservado", message: `${ClientsManager.getClientFullName(checkClient)} ha reservado un turno para ${ serviceCheck.name } el día ${dayjs(data.start).format("DD/MM/YYYY")} a las ${dayjs(data.start).format( "HH:mm" )}`, type: NotificationType.APPOINTMENT, code: String((newAppointment as any)._id) }); return newAppointment; } catch (err) { console.log(err); if (!appointmentCreated) { await MetricsList.releaseAppointment({ userId: String(companyCheck.ownerId), companyId: data.companyId, quantity: 1, }); } throw new Error("Ha ocurrido un error al crear el turno."); } } private async tryApplyDiscounts(appointment: IAppointment) { //Intenta aplicar un descuento al turno si esta especificado en DiscountsEmail // y la fecha coincide con el rango especificado. try { //Obtener el mail del cliente. const client = await ClientsManager.clients.findOne({ _id: String(appointment.clientId), }); if (!client) { return; } let email = client.email; const userClient = await UsersManager.users.findOne({ _id: String(client.userId), }); if (userClient) { email = userClient.email; } //Obtener los descuentos del cliente. const existDiscount = await DiscountsEmail.discountsEmail.findOne({ email: email, companyId: String(appointment.companyId), searchDate: appointment.start, }); if (!existDiscount) { return; } //Ahora aplicamos el descuento await this.applyDiscount({ appointmentId: String(appointment.id), discountId: String(existDiscount.discountId), companyId: String(appointment.companyId), sessionUser: String(appointment.userId), }); } catch (err) { console.log(err); } } public async paginate(filters: PaginateAppointmentsParams): Promise { return await this.Appointments.paginate(filters); } public async moveAppointmentForce(data: MoveAppointmentParams): Promise { await validateSessionUser({ sessionUser: data.sessionUser || "" }); const appointment: any = await this.Appointments.findOne({ _id: data.id, }); if (!appointment) { throw new Error("El turno no existe"); } await validatePermissionsByCompany({ companyId: String(appointment.companyId), sessionUser: data.sessionUser || "" }); const serviceCheck = await ServicesManager.services.findOne({ _id: String(appointment.serviceId), companyId: String(appointment.companyId), }); if (!serviceCheck) { throw new Error("El servicio no existe o no pertenece a la compañia."); } // Get HeatMapConfig of the original date before changing it const originalConfig = this.getHeatMapConfig(appointment, serviceCheck.length); // Update the appointment's start times const newStart = dayjs(data.newStart).clone(); appointment.start = newStart.toDate(); appointment.dateDay = newStart.clone().startOf("day").toDate(); appointment.startHour = newStart.clone().hour() + newStart.clone().minute() / 60; const end = newStart.clone().add(serviceCheck.length * (appointment.length ? appointment.length : 1), "minutes"); appointment.endHour = end.clone().hour() + end.clone().minute() / 60; // Save appointment (directly, bypassing notifications & checks) await appointment.save(); // Compute the new HeatMapConfig const newConfig = this.getHeatMapConfig(appointment, serviceCheck.length); // Update both heatmaps await this.updateHeatMap(originalConfig); await this.updateHeatMap(newConfig); } public async changeServiceForce(data: ChangeServiceParams): Promise { await validateSessionUser({ sessionUser: data.sessionUser || "" }); const appointment: any = await this.Appointments.findOne({ _id: data.id, }); if (!appointment) { throw new Error("El turno no existe"); } await validatePermissionsByCompany({ companyId: String(appointment.companyId), sessionUser: data.sessionUser || "" }); const oldServiceCheck = await ServicesManager.services.findOne({ _id: String(appointment.serviceId), companyId: String(appointment.companyId), }); if (!oldServiceCheck) { throw new Error("El servicio original no existe o no pertenece a la compañia."); } const newServiceCheck = await ServicesManager.services.findOne({ _id: data.newServiceId, companyId: String(appointment.companyId), }); if (!newServiceCheck) { throw new Error("El nuevo servicio no existe o no pertenece a la compañia."); } const newEmployeeCheck = await EmployeesList.employees.findOne({ _id: data.newEmployeeId, companyId: String(appointment.companyId), }); if (!newEmployeeCheck) { throw new Error("El nuevo profesional no existe o no pertenece a la compañia."); } // Get HeatMapConfig of the original appointment before changing it const originalConfig = this.getHeatMapConfig(appointment, oldServiceCheck.length); // Update appointment details appointment.serviceId = data.newServiceId; appointment.employeeId = data.newEmployeeId; appointment.price = newServiceCheck.price; // Recalculate endHour based on new service length const startHour = appointment.startHour; const newEndHour = startHour + (newServiceCheck.length * (appointment.length || 1)) / 60; appointment.endHour = newEndHour; // Save appointment await appointment.save(); // If there is a discount, reapply it to update the finalPrice, else just save. if (appointment.discountId) { await this.applyDiscount({ appointmentId: String(appointment._id), discountId: String(appointment.discountId), companyId: String(appointment.companyId), sessionUser: data.sessionUser }); } // Compute the new HeatMapConfig const newConfig = this.getHeatMapConfig(appointment, newServiceCheck.length); // Update both heatmaps await this.updateHeatMap(originalConfig); await this.updateHeatMap(newConfig); } public buildHeatMapEmpty(config: HeatMapConfig): IHeatMap { const heatMapData: Array = []; const startDate = dayjs(config.heatMapDate).clone().startOf("day"); const endDate = dayjs(config.heatMapDate).clone().endOf("day"); let currentDate = startDate.clone(); while (currentDate.isBefore(endDate)) { heatMapData.push({ keyDate: currentDate.toDate(), count: 0, }); currentDate = currentDate.clone().add(config.fraction, "minutes"); } return { companyId: config.companyId, employeeId: config.employeeId, serviceId: config.serviceId, heatMap: heatMapData, heatMapDate: startDate.clone().toDate(), }; } public async getHeatMapToCheckAvailability(config: HeatMapConfig): Promise { //TODO: debe devolver un heatmap unificado entre todos los servicios del empleado. //Podria crear un heatmap cada 15 minutos. Siendo esta la menor unidad de reserva. //Y luego rellenarlo segun cada servicio sobre el que tenga reservas hechas el empleado. const globalHeatMap = this.buildHeatMapEmpty({ companyId: config.companyId, employeeId: config.employeeId, serviceId: "", //no importa el servicio. es global. heatMapDate: config.heatMapDate, start: config.start, fraction: config.fraction, //minima fracción de tiempo length: 1, serviceLimit: -1, //no lo usa serviceLength: 15, }); //console.table(globalHeatMap.heatMap); //Busco todos los heatmaps para todos los servicios. let heatMaps = await HeatMapList.find({ companyId: config.companyId, employeeId: config.employeeId, heatMapDate: config.heatMapDate, }); if (!globalHeatMap.heatMap) { return globalHeatMap; } for (let i = 0; i < globalHeatMap.heatMap.length; i++) { const currentHeatMap = globalHeatMap.heatMap[i]; for (const serviceItem of heatMaps) { if (!serviceItem.heatMap) { continue; } const serviceHeatMap = serviceItem.heatMap[i]; currentHeatMap.count += serviceHeatMap.count; } const repeatsCount = await RepeatsList.countRepeats({ companyId: config.companyId, employeeId: config.employeeId, countDate: currentHeatMap.keyDate, heatMapFraction: config.fraction, }); currentHeatMap.count += repeatsCount; } //console.table(globalHeatMap.heatMap); return globalHeatMap; } public async getHeatMap(config: HeatMapConfig): Promise { const appointments = await this.Appointments.find({ companyId: config.companyId, employeeId: config.employeeId, serviceId: config.serviceId, dateDay: config.heatMapDate, }); let heatMap = this.buildHeatMapEmpty(config); if (!heatMap.heatMap) { throw new Error("El mapa de calor no existe"); } for (const appointment of appointments) { const endDateTime = dayjs(appointment.start) .clone() .add(config.serviceLength * isNull(appointment.length, 1), "minutes"); const startIndex = this.getHeatMapPosition(heatMap, dayjs(appointment.start).clone().toDate()); for (let i = startIndex; i < heatMap.heatMap.length; i++) { const heatMapItem = heatMap.heatMap[i]; const currentDate = dayjs(heatMap.heatMap[i].keyDate).clone(); if (currentDate.isSameOrAfter(endDateTime)) { break; } heatMapItem.count += 1; } } return heatMap; } public async updateHeatMap(config: HeatMapConfig): Promise { const heatMap = await this.getHeatMap(config); if (!heatMap.heatMap) { throw new Error("El mapa de calor no existe"); } await HeatMapList.deleteHeatMapByDate({ companyId: config.companyId, employeeId: config.employeeId, serviceId: config.serviceId, heatMapDate: dayjs(config.start).startOf("day").toDate(), }); await HeatMapList.createHeatMap({ companyId: config.companyId, employeeId: config.employeeId, serviceId: config.serviceId, heatmap: heatMap, heatMapDate: dayjs(config.start).startOf("day").toDate(), }); } public async checkSchedulesDisabled(data: HeatMapConfig): Promise { const check = await SchedulesDisabledList.exists({ employeeId: data.employeeId, companyId: data.companyId, dateDay: dayjs(data.start).toDate(), }); if (check) { return false; } return true; } public async checkSchedules(data: HeatMapConfig): Promise { const start = dayjs(data.start); const end = start.clone().add(data.serviceLength * isNull(data.length, 1), "minutes"); const weekDay = start.weekday() + 1; const checkFrom = start.format("HH:mm"); const checkTo = end.format("HH:mm"); //chequear si hay un horario excepcional. const hasOverride = await SchedulesOverridesList.findOne({ employeeId: data.employeeId, companyId: data.companyId, date: dayjs(data.start).startOf("day").toDate(), }); if (!hasOverride) { //chequear que la fecha este habilitada. const check = await SchedulesEnabledList.exists({ employeeId: data.employeeId, companyId: data.companyId, dateDay: dayjs(data.start).startOf("day").toDate(), }); if (!check) { return false; } } let checkColaborator = false; if (hasOverride) { checkColaborator = await SchedulesOverridesList.isAvailable({ employeeId: data.employeeId, companyId: data.companyId, dateDay: dayjs(data.start).startOf("day").toDate(), from: checkFrom, to: checkTo, serviceId: data.serviceId, }); } else { //chequear que el colaborador este disponible. checkColaborator = await SchedulesList.isAvailable({ employeeId: data.employeeId, companyId: data.companyId, weekDay: weekDay, from: checkFrom, to: checkTo, serviceId: data.serviceId, }); } if (!checkColaborator) { return false; } return true; } public async checkAvailability(config: HeatMapConfig, data: CreateAppointmentParams): Promise { const heatMap = await this.getHeatMapToCheckAvailability(config); if (!heatMap.heatMap) { return false; } const endDateTime = dayjs(data.start) .clone() .add(config.serviceLength * isNull(data.length, 1), "minutes"); const startIndex = this.getHeatMapPosition(heatMap, dayjs(data.start).clone().toDate()); if (startIndex < 0) { return false; } for (let i = startIndex; i < heatMap.heatMap.length; i++) { const heatMapItem = heatMap.heatMap[i]; const currentDate = dayjs(heatMap.heatMap[i].keyDate).clone(); if (currentDate.isSame(endDateTime)) { break; } /* console.log( "heatmapCount:", heatMapItem.count, "serviceLimit:", config.serviceLimit, "check:", heatMapItem.count >= config.serviceLimit ); */ if (heatMapItem.count >= config.serviceLimit) { return false; } } return true; } private getHeatMapPosition(heatMap: IHeatMap, date: Date): number { if (!heatMap.heatMap) { return -1; } for (let i = 0; i < heatMap.heatMap.length; i++) { const currentDate = dayjs(heatMap.heatMap[i].keyDate).clone(); if (currentDate.isSame(dayjs(date).clone())) { return i; } } return -1; } public async findAppointmentsAdminByDate( data: FindAppointmentsParams ): Promise { const returnData: AppointmentEvent[] = []; const filterData: FindAppointmentsParams = {}; if (data.dateDay) { filterData.dateDay = dayjs(data.dateDay).clone().startOf("day").toDate(); } if (data.companyId) { filterData.companyId = data.companyId; } if (data.employeeId) { filterData.employeeId = data.employeeId; } const appointments = await this.Appointments.find(filterData); for (const appointment of appointments) { const client = await ClientsManager.clients.findOne({ _id: String(appointment.clientId), }); if (!client) { continue; } const userClient = await UsersManager.users.findOne({ _id: client.userId ? String(client.userId) : undefined, }); const employee = await EmployeesList.employees.findOne({ _id: String(appointment.employeeId), }); if (!employee) { continue; } // Historical read: Employee lookup includes removed employees (historical reads allow them) // No snapshot fallback needed here as employee.calendarColor is used directly const clientName = joinStrings([client.firstName, client.lastName], " "); const clientUserName = userClient ? joinStrings([userClient.firstName, userClient.lastName], clientName) : joinStrings([client.firstName, client.lastName], " "); const clientAvatar = userClient ? getAvatar(client.userId, userClient.avatar, clientUserName) : clientUserName; const discountData = await Discounts.getDiscountData( appointment.discountId, appointment.companyId ); returnData.push({ appointmentId: isNull(appointment.id, ""), clientId: String(appointment.clientId), serviceId: String(appointment.serviceId), employeeId: String(appointment.employeeId), startTime: appointment.startHour, endTime: appointment.endHour, title: isNull(clientName, clientUserName), avatar: clientAvatar, color: isNull(employee.calendarColor, "#ff9900"), repeatId: "", discountId: isNull(appointment.discountId, ""), discountName: isNull(discountData.name, ""), discountCode: isNull(discountData.code, ""), discountValue: isNull(discountData.value, 0), discountType: isNull(discountData.type, DiscountType.PERCENTAGE), payment: appointment.payment, }); } const searchWeekDay = dayjs(data.dateDay).clone().weekday() + 1; //Agregar las repeticiones. const repeticiones = await RepeatsList.repeats.find({ companyId: data.companyId, weekDay: searchWeekDay, }); for (const repeticion of repeticiones) { const repClient = await ClientsManager.clients.findOne({ _id: String(repeticion.clientId), }); if (!repClient) { continue; } let userRepClient: IUserDocument | null = null; if (repClient.userId) { userRepClient = await UsersManager.users.findOne({ _id: String(repClient.userId), }); if (!userRepClient) { continue; } } const repEmployee = await EmployeesList.employees.findOne({ _id: String(repeticion.employeeId), }); if (!repEmployee) { continue; } const repService = await ServicesManager.services.findOne({ _id: String(repeticion.serviceId), }); if (!repService) { continue; } const searchHour = Math.floor(repeticion.startHour); const searchMinute = (repeticion.startHour - searchHour) * 60; const searchDJS = dayjs(data.dateDay) .clone() .startOf("day") .hour(searchHour) .minute(searchMinute); const searchDate = searchDJS.clone().toDate(); if (searchDJS.isSameOrBefore(dayjs())) { continue; } //Solo hago push si la repeticion no tiene el turno creado. const appointmentCreated = await this.Appointments.findOne({ companyId: data.companyId, clientId: String(repeticion.clientId), serviceId: String(repeticion.serviceId), employeeId: String(repeticion.employeeId), start: searchDate, }); if (appointmentCreated) { continue; } //Tengo que chequear que la fecha no este en disabledDates. const checkDisabledDate = dayjs(data.dateDay) .clone() .startOf("day") .hour(searchHour) .minute(searchMinute); if (repeticion.disabledDates) { if (RepeatsList.existDisabledDate(checkDisabledDate.toDate(), repeticion.disabledDates)) { continue; } } const discountData = await Discounts.getDiscountData(repeticion.discountId, repeticion.companyId); returnData.push({ appointmentId: "", clientId: String(repeticion.clientId), serviceId: String(repeticion.serviceId), employeeId: String(repeticion.employeeId), startTime: repeticion.startHour, endTime: repeticion.startHour + repService.length / 60, title: ClientsManager.getClientFullName(repClient), avatar: getAvatar( String(repClient.userId), userRepClient ? userRepClient.avatar : "", ClientsManager.getClientFullName(repClient) ), color: isNull(repEmployee.calendarColor, "#ff9900"), repeatId: String(repeticion.id), payment: false, discountId: isNull(undefined, ""), discountName: isNull(discountData.name, ""), discountCode: isNull(discountData.code, ""), discountValue: isNull(discountData.value, 0), discountType: isNull(discountData.type, DiscountType.PERCENTAGE), }); } //get min startTime hour from returnData let minStartTime = 24; for (const event of returnData) { if (event.startTime < minStartTime) { minStartTime = event.startTime; } } return { events: returnData, from: Math.floor(minStartTime), to: 24, }; } public async getAppointmentEvent(data: GetAppointmentEventParams): Promise { const sessionUser = UserList.users.findOne({ _id: data.sessionUser, }); if (!sessionUser) { throw new Error("El usuario no existe"); } const appointment = await this.Appointments.AppointmentList.findOne({ _id: data.appointmentId, }); if (!appointment) { throw new Error("El turno no existe"); } const client = await ClientsManager.clients.findOne({ _id: String(appointment.clientId), }); if (!client) { throw new Error("El cliente no existe"); } const userClient = await UsersManager.users.findOne({ _id: client.userId ? String(client.userId) : undefined, }); const employee = await EmployeesList.employees.findOne({ _id: String(appointment.employeeId), }); if (!employee) { throw new Error("El colaborador no existe"); } const userEmployee = await UsersManager.users.findOne({ _id: String(employee.userId), }); // Historical read snapshot fallback: use Employee snapshot if User is deleted const collaboratorFirstName = userEmployee ? isNull(userEmployee.firstName, "") : isNull(employee.profileSnapshot?.firstName, ""); const collaboratorLastName = userEmployee ? isNull(userEmployee.lastName, "") : isNull(employee.profileSnapshot?.lastName, ""); const collaboratorFullName = joinStrings([collaboratorFirstName, collaboratorLastName], " "); const collaboratorAvatar = userEmployee ? getAvatar(employee.userId, userEmployee.avatar, collaboratorFullName) : (employee.profileSnapshot?.avatar || collaboratorFullName); const service = await ServiceList.services.findOne({ _id: String(appointment.serviceId), }); if (!service) { throw new Error("El servicio no existe"); } const clientName = joinStrings([client.firstName, client.lastName], " "); const clientUserName = userClient ? joinStrings([userClient.firstName, userClient.lastName], clientName) : joinStrings([client.firstName, client.lastName], " "); const clientAvatar = userClient ? getAvatar(client.userId, userClient.avatar, clientUserName) : clientUserName; const discountData = await Discounts.getDiscountData(appointment.discountId, appointment.companyId); let ctaBalance = 0; try { ctaBalance = await ClientAccount.getAvailableAmount({ clientId: appointment.clientId, sessionUser: data.sessionUser, }); } catch (e) { // Ignore error if user does not have payments plan } return { id: appointment.id, companyId: String(appointment.companyId), clientId: appointment.clientId, clientName: clientUserName, serviceId: appointment.serviceId, serviceName: service.name, serviceDescription: service.description, collaboratorId: employee.id, collaboratorUserId: String(employee.userId), collaboratorName: collaboratorFullName, collaboratorAvatar: collaboratorAvatar, appointmentDate: dayjs(appointment.start).toISOString(), startTime: appointment.startHour, endTime: appointment.endHour, title: isNull(clientName, clientUserName), avatar: clientAvatar, color: isNull(employee.calendarColor, "#ff9900"), comments: appointment.comments, price: appointment.price, length: appointment.length, payment: appointment.payment, discountId: isNull(appointment.discountId, ""), discountName: isNull(discountData.name, ""), discountCode: isNull(discountData.code, ""), discountValue: isNull(discountData.value, 0), discountType: isNull(discountData.type, DiscountType.PERCENTAGE), clientCtaCteBalance: ctaBalance, }; } public async findAppointmentsAdminByClient( data: FindAppointmentsParams ): Promise { const returnData: AppointmentEventByClient[] = []; let filterData: Object = { dateDay: { $gte: dayjs(new Date()).clone().startOf("day").toDate(), }, companyId: data.companyId, clientId: data.clientId, }; if (data._id) { filterData = { _id: data._id, }; } const appointments = await this.Appointments.AppointmentList.find(filterData); for (const appointment of appointments) { const client = await ClientsManager.clients.findOne({ _id: String(appointment.clientId), }); if (!client) { continue; } const userClient = await UsersManager.users.findOne({ _id: client.userId ? String(client.userId) : undefined, }); const employee = await EmployeesList.employees.findOne({ _id: String(appointment.employeeId), }); if (!employee) { continue; } const userEmployee = await UsersManager.users.findOne({ _id: String(employee.userId), }); if (!userEmployee) { continue; } const service = await ServiceList.services.findOne({ _id: String(appointment.serviceId), }); if (!service) { continue; } const clientName = joinStrings([client.firstName, client.lastName], " "); const clientUserName = userClient ? joinStrings([userClient.firstName, userClient.lastName], clientName) : joinStrings([client.firstName, client.lastName], " "); const clientAvatar = userClient ? getAvatar(client.userId, userClient.avatar, clientUserName) : clientUserName; const employeeFirstName = isNull(userEmployee.firstName, ""); const employeeLastName = isNull(userEmployee.lastName, ""); const employeeFullName = joinStrings([employeeFirstName, employeeLastName], " "); const employeeAvatar = userEmployee ? getAvatar(employee.userId, userEmployee.avatar, employeeFullName) : clientUserName; const discountData = await Discounts.getDiscountData( appointment.discountId, appointment.companyId ); returnData.push({ id: appointment.id, companyId: String(appointment.companyId), clientId: appointment.clientId, clientName: clientUserName, serviceId: appointment.serviceId, serviceName: service.name, serviceDescription: service.description, collaboratorId: employee.id, collaboratorUserId: String(employee.userId), collaboratorName: employeeFullName, collaboratorAvatar: employeeAvatar, appointmentDate: dayjs(appointment.start).toISOString(), startTime: appointment.startHour, endTime: appointment.endHour, title: isNull(clientName, clientUserName), avatar: clientAvatar, color: isNull(employee.calendarColor, "#ff9900"), comments: appointment.comments, price: appointment.price, length: appointment.length, payment: appointment.payment, discountId: isNull(appointment.discountId, ""), discountName: isNull(discountData.name, ""), discountCode: isNull(discountData.code, ""), discountValue: isNull(discountData.value, 0), discountType: isNull(discountData.type, DiscountType.PERCENTAGE), clientCtaCteBalance: 0, }); } return { events: returnData, }; } public async findAppointmentsByUser( data: FindAppointmentsByUserParams ): Promise { const returnData: AppointmentEventByClient[] = []; let filterData: Object = { dateDay: { $gte: dayjs(new Date()).clone().startOf("day").toDate(), }, userId: data.sessionUser, }; const appointments = await this.Appointments.AppointmentList.find(filterData).sort({ start: 1, }); for (const appointment of appointments) { const client = await ClientsManager.clients.findOne({ _id: String(appointment.clientId), }); if (!client) { continue; } const userClient = await UsersManager.users.findOne({ _id: client.userId ? String(client.userId) : undefined, }); const employee = await EmployeesList.employees.findOne({ _id: String(appointment.employeeId), }); if (!employee) { continue; } const userEmployee = await UsersManager.users.findOne({ _id: String(employee.userId), }); if (!userEmployee) { continue; } const service = await ServiceList.services.findOne({ _id: String(appointment.serviceId), }); if (!service) { continue; } const clientName = joinStrings([client.firstName, client.lastName], " "); const clientUserName = userClient ? joinStrings([userClient.firstName, userClient.lastName], clientName) : joinStrings([client.firstName, client.lastName], " "); const clientAvatar = userClient ? getAvatar(client.userId, userClient.avatar, clientUserName) : clientUserName; const employeeFirstName = isNull(userEmployee.firstName, ""); const employeeLastName = isNull(userEmployee.lastName, ""); const employeeFullName = joinStrings([employeeFirstName, employeeLastName], " "); const employeeAvatar = userEmployee ? getAvatar(employee.userId, userEmployee.avatar, employeeFullName) : clientUserName; const discountData = await Discounts.getDiscountData( appointment.discountId, appointment.companyId ); returnData.push({ id: appointment.id, companyId: String(appointment.companyId), clientId: appointment.clientId, clientName: clientUserName, serviceId: appointment.serviceId, serviceName: service.name, serviceDescription: service.description, collaboratorId: employee.id, collaboratorUserId: String(employee.userId), collaboratorName: employeeFullName, collaboratorAvatar: employeeAvatar, appointmentDate: dayjs(appointment.start).toISOString(), startTime: appointment.startHour, endTime: appointment.endHour, title: isNull(clientName, clientUserName), avatar: clientAvatar, color: isNull(employee.calendarColor, "#ff9900"), comments: appointment.comments, price: appointment.price, length: appointment.length, payment: appointment.payment, discountId: isNull(appointment.discountId, ""), discountName: isNull(discountData.name, ""), discountCode: isNull(discountData.code, ""), discountValue: isNull(discountData.value, 0), discountType: isNull(discountData.type, DiscountType.PERCENTAGE), clientCtaCteBalance: 0, }); } return { events: returnData, }; } public async findAppointmentsByCollaborator( data: FindAppointmentsByCollaboratorParams ): Promise { const returnData: AppointmentEventByClient[] = []; const employeeProfiles = await EmployeesList.employees.find({ userId: data.sessionUser, }); if (!employeeProfiles || employeeProfiles.length === 0) { return { events: returnData }; } const employeeIds = employeeProfiles.map((e: any) => e.id || e._id); let filterData: any = { dateDay: { $gte: dayjs(new Date()).clone().startOf("day").toDate(), }, employeeId: { $in: employeeIds }, }; const appointments = await this.Appointments.AppointmentList.find(filterData).sort({ start: 1, }); for (const appointment of appointments) { const client = await ClientsManager.clients.findOne({ _id: String(appointment.clientId), }); if (!client) { continue; } const userClient = await UsersManager.users.findOne({ _id: client.userId ? String(client.userId) : undefined, }); const employee = employeeProfiles.find((e: any) => String(e.id || e._id) === String(appointment.employeeId)); if (!employee) { continue; } const userEmployee = await UsersManager.users.findOne({ _id: String(employee.userId), }); if (!userEmployee) { continue; } const service = await ServiceList.services.findOne({ _id: String(appointment.serviceId), }); if (!service) { continue; } const clientName = joinStrings([client.firstName, client.lastName], " "); const clientUserName = userClient ? joinStrings([userClient.firstName, userClient.lastName], clientName) : joinStrings([client.firstName, client.lastName], " "); const clientAvatar = userClient ? getAvatar(client.userId, userClient.avatar, clientUserName) : clientUserName; const employeeFirstName = isNull(userEmployee.firstName, ""); const employeeLastName = isNull(userEmployee.lastName, ""); const employeeFullName = joinStrings([employeeFirstName, employeeLastName], " "); const employeeAvatar = userEmployee ? getAvatar(employee.userId, userEmployee.avatar, employeeFullName) : clientUserName; const discountData = await Discounts.getDiscountData( appointment.discountId, appointment.companyId ); returnData.push({ id: appointment.id, companyId: String(appointment.companyId), clientId: appointment.clientId, clientName: clientUserName, serviceId: appointment.serviceId, serviceName: service.name, serviceDescription: service.description, collaboratorId: String(employee.id || (employee as any)._id), collaboratorUserId: String(employee.userId), collaboratorName: employeeFullName, collaboratorAvatar: employeeAvatar, appointmentDate: dayjs(appointment.start).toISOString(), startTime: appointment.startHour, endTime: appointment.endHour, title: isNull(clientName, clientUserName), avatar: clientAvatar, color: isNull(employee.calendarColor, "#ff9900"), comments: appointment.comments, price: appointment.price, length: appointment.length, payment: appointment.payment, discountId: isNull(appointment.discountId, ""), discountName: isNull(discountData.name, ""), discountCode: isNull(discountData.code, ""), discountValue: isNull(discountData.value, 0), discountType: isNull(discountData.type, DiscountType.PERCENTAGE), clientCtaCteBalance: 0, }); } return { events: returnData, }; } public async findAppointmentsByUserPaginated( data: FindAppointmentsByUserPaginatedParams ): Promise { const returnData: AppointmentEventByClient[] = []; let filterData: Object = { dateDay: { $gte: dayjs(new Date()).clone().startOf("day").toDate(), }, userId: data.sessionUser, }; const total = await this.Appointments.AppointmentList.countDocuments(filterData); const limit = data.limit || 10; const page = data.page || 1; const skip = (page - 1) * limit; const appointments = await this.Appointments.AppointmentList.find(filterData) .sort({ start: 1 }) .skip(skip) .limit(limit); for (const appointment of appointments) { const client = await ClientsManager.clients.findOne({ _id: String(appointment.clientId), }); if (!client) { continue; } const userClient = await UsersManager.users.findOne({ _id: client.userId ? String(client.userId) : undefined, }); const employee = await EmployeesList.employees.findOne({ _id: String(appointment.employeeId), }); if (!employee) { continue; } const userEmployee = await UsersManager.users.findOne({ _id: String(employee.userId), }); if (!userEmployee) { continue; } const service = await ServiceList.services.findOne({ _id: String(appointment.serviceId), }); if (!service) { continue; } const clientName = joinStrings([client.firstName, client.lastName], " "); const clientUserName = userClient ? joinStrings([userClient.firstName, userClient.lastName], clientName) : joinStrings([client.firstName, client.lastName], " "); const clientAvatar = userClient ? getAvatar(client.userId, userClient.avatar, clientUserName) : clientUserName; const employeeFirstName = isNull(userEmployee.firstName, ""); const employeeLastName = isNull(userEmployee.lastName, ""); const employeeFullName = joinStrings([employeeFirstName, employeeLastName], " "); const employeeAvatar = userEmployee ? getAvatar(employee.userId, userEmployee.avatar, employeeFullName) : clientUserName; const discountData = await Discounts.getDiscountData( appointment.discountId, appointment.companyId ); returnData.push({ id: appointment.id, companyId: String(appointment.companyId), clientId: appointment.clientId, clientName: clientUserName, serviceId: appointment.serviceId, serviceName: service.name, serviceDescription: service.description, collaboratorId: employee.id, collaboratorUserId: String(employee.userId), collaboratorName: employeeFullName, collaboratorAvatar: employeeAvatar, appointmentDate: dayjs(appointment.start).toISOString(), startTime: appointment.startHour, endTime: appointment.endHour, title: isNull(clientName, clientUserName), avatar: clientAvatar, color: isNull(employee.calendarColor, "#ff9900"), comments: appointment.comments, price: appointment.price, length: appointment.length, payment: appointment.payment, discountId: isNull(appointment.discountId, ""), discountName: isNull(discountData.name, ""), discountCode: isNull(discountData.code, ""), discountValue: isNull(discountData.value, 0), discountType: isNull(discountData.type, DiscountType.PERCENTAGE), clientCtaCteBalance: 0, }); } return { data: returnData, page, pages: Math.ceil(total / limit), }; } public async findAppointmentsByCollaboratorPaginated( data: FindAppointmentsByCollaboratorPaginatedParams ): Promise { const returnData: AppointmentEventByClient[] = []; const employeeProfiles = await EmployeesList.employees.find({ userId: data.sessionUser, }); if (!employeeProfiles || employeeProfiles.length === 0) { return { data: returnData, page: data.page || 1, pages: 0 }; } const employeeIds = employeeProfiles.map((e: any) => e.id || e._id); let filterData: any = { dateDay: { $gte: dayjs(new Date()).clone().startOf("day").toDate(), }, employeeId: { $in: employeeIds }, }; const total = await this.Appointments.AppointmentList.countDocuments(filterData); const limit = data.limit || 10; const page = data.page || 1; const skip = (page - 1) * limit; const appointments = await this.Appointments.AppointmentList.find(filterData) .sort({ start: 1 }) .skip(skip) .limit(limit); for (const appointment of appointments) { const client = await ClientsManager.clients.findOne({ _id: String(appointment.clientId), }); if (!client) { continue; } const userClient = await UsersManager.users.findOne({ _id: client.userId ? String(client.userId) : undefined, }); const employee = employeeProfiles.find((e: any) => String(e.id || e._id) === String(appointment.employeeId)); if (!employee) { continue; } const userEmployee = await UsersManager.users.findOne({ _id: String(employee.userId), }); if (!userEmployee) { continue; } const service = await ServiceList.services.findOne({ _id: String(appointment.serviceId), }); if (!service) { continue; } const clientName = joinStrings([client.firstName, client.lastName], " "); const clientUserName = userClient ? joinStrings([userClient.firstName, userClient.lastName], clientName) : joinStrings([client.firstName, client.lastName], " "); const clientAvatar = userClient ? getAvatar(client.userId, userClient.avatar, clientUserName) : clientUserName; const employeeFirstName = isNull(userEmployee.firstName, ""); const employeeLastName = isNull(userEmployee.lastName, ""); const employeeFullName = joinStrings([employeeFirstName, employeeLastName], " "); const employeeAvatar = userEmployee ? getAvatar(employee.userId, userEmployee.avatar, employeeFullName) : clientUserName; const discountData = await Discounts.getDiscountData( appointment.discountId, appointment.companyId ); returnData.push({ id: appointment.id, companyId: String(appointment.companyId), clientId: appointment.clientId, clientName: clientUserName, serviceId: appointment.serviceId, serviceName: service.name, serviceDescription: service.description, collaboratorId: String(employee.id || (employee as any)._id), collaboratorUserId: String(employee.userId), collaboratorName: employeeFullName, collaboratorAvatar: employeeAvatar, appointmentDate: dayjs(appointment.start).toISOString(), startTime: appointment.startHour, endTime: appointment.endHour, title: isNull(clientName, clientUserName), avatar: clientAvatar, color: isNull(employee.calendarColor, "#ff9900"), comments: appointment.comments, price: appointment.price, length: appointment.length, payment: appointment.payment, discountId: isNull(appointment.discountId, ""), discountName: isNull(discountData.name, ""), discountCode: isNull(discountData.code, ""), discountValue: isNull(discountData.value, 0), discountType: isNull(discountData.type, DiscountType.PERCENTAGE), clientCtaCteBalance: 0, }); } return { data: returnData, page, pages: Math.ceil(total / limit), }; } public async updateAppointment(data: UpdateAppointmentParams): Promise { const { notification = true, validation = false } = data; 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 checkAppointment = await this.Appointments.findOne({ _id: data.id, }); if (!checkAppointment) { throw new Error("El turno no existe"); } const companyCheck = await CompaniesManager.companies.findOne({ _id: String(checkAppointment.companyId), }); if (!companyCheck) { throw new Error("La compañia no existe"); } if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) { throw new Error(NoPermissionMessage()); } const serviceCheck = await ServicesManager.services.findOne({ _id: String(checkAppointment.serviceId), companyId: String(checkAppointment.companyId), }); if (!serviceCheck) { throw new Error("El servicio no existe o no pertenece a la compañia."); } const appointmentLength = data.length ? data.length : checkAppointment.length; const start = dayjs(data.start).clone(); const end = dayjs(data.start) .clone() .add(serviceCheck.length * isNull(appointmentLength, 1), "minutes"); const startHour = start.clone().hour() + start.clone().minute() / 60; const endHour = end.clone().hour() + end.clone().minute() / 60; const dateDay = dayjs(data.start).clone().startOf("day").toDate(); const originalConfig = this.getHeatMapConfig(checkAppointment, serviceCheck.length); const originalDateDay = dayjs(checkAppointment.dateDay).clone(); const defaultFraction = Number(process.env.DEFAULT_COMPANY_SERVICE_FRACTION); const availabilityConfig: HeatMapConfig = { companyId: String(checkAppointment.companyId), serviceId: String(checkAppointment.serviceId), employeeId: String(checkAppointment.employeeId), heatMapDate: dayjs(data.start).startOf("day").toDate(), start: dayjs(data.start).toDate(), fraction: defaultFraction, //companyCheck.heatMapFraction ? companyCheck.heatMapFraction : defaultFraction, length: isNull(appointmentLength, 1), serviceLimit: serviceCheck.limit, serviceLength: serviceCheck.length, }; const availabilityData = { companyId: String(checkAppointment.companyId), employeeId: String(checkAppointment.employeeId), serviceId: String(checkAppointment.serviceId), start: dayjs(data.start).toDate(), length: isNull(appointmentLength, 1), dateDay: dateDay, price: 0, comments: "", sessionUser: data.sessionUser, }; const newStart = dayjs(data.start).toDate(); if (validation) { const checkAvailable = await this.checkAvailability(availabilityConfig, availabilityData); //Solo chequeo disponibilidad si se cambia el start o el length del turno, ya que es una modificacion. if (newStart !== checkAppointment.start || data.length !== checkAppointment.length) { if (checkAvailable === false) { throw new Error("No hay disponibilidad para el horario seleccionado"); } } } checkAppointment.start = newStart; checkAppointment.startHour = startHour; checkAppointment.endHour = endHour; checkAppointment.dateDay = dateDay; if (data.price) { checkAppointment.price = data.price; } if (data.length) { checkAppointment.length = data.length; } if (data.comments) { checkAppointment.comments = data.comments; } try { await checkAppointment.save(); const checkClient = await ClientsManager.clients.findOne({ _id: String(checkAppointment.clientId), }); if (!checkClient) { throw new Error("El cliente no existe"); } const employeeCheck = await EmployeesList.employees.findOne({ _id: String(checkAppointment.employeeId), }); if (!employeeCheck) { throw new Error("El colaborador no existe"); } if (notification && checkClient.userId) { //Envio la notificacion al cliente. await NotificationsManager.sendSystemNotification({ userId: String(checkClient.userId), subject: "Turno actualizado", message: `Se ha actualizado un turno para el día ${dayjs(data.start).format( "DD/MM/YYYY" )} a las ${dayjs(data.start).format("HH:mm")}`, type: NotificationType.APPOINTMENT, code: String(checkAppointment._id) }); //Cancel existing pending jobs before creating new ones for the rescheduled appointment if (checkAppointment.id) { await this.jobService.cancelByAppointment(String(checkAppointment.id)); //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), }); } } //Envio la notificacion al profesional. await NotificationsManager.sendSystemNotification({ userId: String(employeeCheck.userId), subject: "Turno actualizado", message: `Se ha actualizado un turno para el cliente ${ClientsManager.getClientFullName( checkClient )} para el día ${dayjs(data.start).format("DD/MM/YYYY")} a las ${dayjs(data.start).format( "HH:mm" )}`, type: NotificationType.APPOINTMENT, code: String(checkAppointment._id) }); //Actualiza el heatmap de la fecha nueva. await this.updateHeatMap(availabilityConfig); if (!originalDateDay.isSame(dateDay)) { //actualizar el heatmap de la fecha original await this.updateHeatMap(originalConfig); } } catch (error) { console.log(error); throw new Error("Ha ocurrido un error al actualizar el turno."); } } private getHeatMapConfig( appointment: IAppointment, serviceLength: number, heatMapDate?: Date, start?: Date ): HeatMapConfig { const hmDate = heatMapDate ? heatMapDate : dayjs(appointment.start).startOf("day").toDate(); const hmStart = start ? start : dayjs(appointment.start).toDate(); const defaultFraction = Number(process.env.DEFAULT_COMPANY_SERVICE_FRACTION); const config: HeatMapConfig = { companyId: appointment.companyId, serviceId: appointment.serviceId, employeeId: appointment.employeeId, heatMapDate: hmDate, start: hmStart, fraction: defaultFraction, length: isNull(appointment.length, 1), serviceLimit: 1, //el service limit no importa si no voy a validar si hay lugar. serviceLength: serviceLength, }; 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 { 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 { 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 { try { return await ClientsManager.getClientWapNumber(client); } catch { return undefined; } } public async deleteAppointment(data: DeleteAppointmentParams): Promise { const { validation = false, notification = false } = data; 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 checkAppointment = await this.Appointments.findOne({ _id: data.id, }); if (!checkAppointment) { throw new Error("El turno no existe"); } if (checkAppointment.payment === true) { throw new Error("No se puede cancelar un turno que se encuentra pagado."); } const companyCheck = await CompaniesManager.companies.findOne({ _id: String(checkAppointment.companyId), }); if (!companyCheck) { throw new Error("La compañia no existe"); } //Chequeo el empleado. const employeeCheck = await EmployeesList.employees.findOne({ _id: String(checkAppointment.employeeId), }); if (!employeeCheck) { throw new Error("El colaborador no existe"); } //Si el sessionUser es el cliente, si puede borrarlo. const checkClient = await ClientsManager.clients.findOne({ _id: String(checkAppointment.clientId), }); if (!checkClient) { throw new Error("El cliente no existe"); } //TODO: Check cancellationTime. if ( !this.validateCancellationTime(companyCheck.cancellationTime, checkAppointment.start) && validation ) { throw new Error( `No se puede cancelar el turno con menos de ${this.getCancellationTime( companyCheck.cancellationTime )} horas de anticipación.` ); } if (String(checkClient.userId) !== data.sessionUser) { //Solo valida si el usuario que intenta borrar no es el cliente. if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) { throw new Error(NoPermissionMessage()); } } const serviceCheck = await ServicesManager.services.findOne({ _id: String(checkAppointment.serviceId), companyId: String(checkAppointment.companyId), }); if (!serviceCheck) { throw new Error("El servicio no existe o no pertenece a la compañia."); } 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); //Enviamos la notificacion de cancelacion al profesional await NotificationsManager.sendSystemNotification({ userId: String(employeeCheck.userId), subject: "Turno Cancelado", message: `Se ha cancelado un turno para el cliente ${ClientsManager.getClientFullName( checkClient )} 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) }); } catch (error) { console.log(error); throw new Error("Ha ocurrido un error al eliminar el turno."); } } public async getSchedules(data: FindAppointmentSchedulesParams): Promise { const returnData: TextObjectFilterResult[] = []; const dateDay = dayjs(data.dateDay).startOf("day"); const weekDay = dateDay.weekday() + 1; const schedules: CollaboratorSchedulesView = await SchedulesList.findAllSchedulesByCollaborator({ companyId: data.companyId, employeeId: data.employeeId, weekDay: weekDay, }); const hasOverride = await SchedulesOverridesList.findOne({ companyId: data.companyId, employeeId: data.employeeId, date: dateDay.toDate(), }); const listOfSchedules = hasOverride ? hasOverride.schedules : schedules.schedules; const service = await ServiceList.services.findOne({ _id: data.serviceId, }); if (!service) { console.log("El servicio no existe"); return []; } const defaultFraction = Number(process.env.DEFAULT_COMPANY_SERVICE_FRACTION); for (const schedule of listOfSchedules) { if (schedule.disabled) { continue; } if (!this.isScheduleAvailableForService(schedule, data.serviceId)) { continue; } const scheduleDataFrom = schedule.from.split(":"); const scheduleDataTo = schedule.to.split(":"); const hourFrom = parseInt(scheduleDataFrom[0]); const hourTo = parseInt(scheduleDataTo[0]); const minutesFrom = parseInt(scheduleDataFrom[1]); const minutesTo = parseInt(scheduleDataTo[1]); const startHour = dateDay.clone().hour(hourFrom).minute(minutesFrom); const endHour = dateDay.clone().hour(hourTo).minute(minutesTo); let currentHour = startHour.clone(); while (currentHour.isBefore(endHour)) { const availabilityConfig: HeatMapConfig = { companyId: data.companyId, serviceId: data.serviceId, employeeId: data.employeeId, heatMapDate: dayjs(dateDay).startOf("day").toDate(), start: dayjs(currentHour).toDate(), fraction: defaultFraction, length: 1, serviceLimit: service.limit, serviceLength: service.length, }; const available = await this.checkAvailability(availabilityConfig, { companyId: data.companyId, serviceId: data.serviceId, employeeId: data.employeeId, userId: "", start: dayjs(currentHour).toDate(), startHour: currentHour.hour(), dateDay: dateDay.toDate(), length: 1, price: 0, sessionUser: "", }); if (available) { returnData.push({ id: currentHour.toISOString(), name: currentHour.format("HH:mm") + "hs. - " + currentHour.add(service.length, "minute").format("HH:mm") + "hs.", }); } currentHour = currentHour .clone() .add(isNull(service.length, defaultFraction), "minutes"); } } return returnData; } private async tryToSendNotification( data: SendAppointmentNotificationParams ): Promise { const checkAppointment = await this.Appointments.findOne({ _id: data.appointmentId, }); if (!checkAppointment) { throw new Error("El turno no existe"); } const companyCheck = await CompaniesManager.companies.findOne({ _id: String(checkAppointment.companyId), }); if (!companyCheck) { throw new Error("La compañia no existe"); } 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({ _id: String(checkAppointment.clientId), }); if (!checkClient) { throw new Error("El cliente no existe"); } const checkEmployee = await EmployeesList.employees.findOne({ _id: String(checkAppointment.employeeId), }); if (!checkEmployee) { throw new Error("El profesional no existe"); } const checkEmployeeUser = await UsersManager.users.findOne({ _id: String(checkEmployee.userId), }); if (!checkEmployeeUser) { throw new Error("El profesional no tiene usuario asociado"); } const checkService = await ServiceList.services.findOne({ _id: String(checkAppointment.serviceId), }); if (!checkService) { throw new Error("El servicio no existe"); } let wapMessage = ""; if (data.type == APPOINTMENT_NOTIFICATION_TYPE.REMINDER) { if (companyCheck.templateWapNotifId) { const template = await Templates.templates.findOne({ id: String(companyCheck.templateWapNotifId), companyId: String(companyCheck._id), }); if (template) { wapMessage = template.template; } } if (!wapMessage) { wapMessage = process.env.WAP_REMINDER ? process.env.WAP_REMINDER : ""; } 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"); } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) { const templateId = data.channel === "email" ? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId : companyCheck.templateWapAltaId; 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_NEW_APPOINTMENT || process.env.EMAIL_ALTA || process.env.WAP_ALTA || "" : process.env.WAP_ALTA || ""; } 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"); } 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"); } return { message: wapMessage, checkClient: checkClient, companyCheck: companyCheck, }; } public async sendEmailNotification(data: SendAppointmentNotificationParams): Promise { 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"); } 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, channel: "email", }); const canSend = await PlanSubscriptionsList.checkFeature({ userId: String(companyCheck.ownerId), feature: PlanFeatures.MAIL_NOTIFICATIONS, }); if (!canSend) { throw new Error("La organizacion no tiene un plan que soporte notificaciones por email"); } const clientEmail = checkClient.email; if (!clientEmail || clientEmail == "") { throw new Error("El cliente no tiene un número telefónico asociado"); } let subjectEmail = ""; if (data.type == APPOINTMENT_NOTIFICATION_TYPE.REMINDER) { 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... await NotificationsManager.sendEmail({ email: clientEmail, subject: subjectEmail, message: message, }); } public async createImmediateNotificationJobs(data: CreateImmediateAppointmentNotificationJobsParams): Promise { 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 { 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, }); 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, channel: "whatsapp", }); const canSend = await PlanSubscriptionsList.checkFeature({ userId: String(companyCheck.ownerId), feature: PlanFeatures.WAP_NOTIFICATIONS, }); if (!canSend) { throw new Error("La organizacion no tiene un plan que soporte notificaciones por Whatsapp"); } const wapNumber = await ClientsManager.getClientWapNumber(checkClient); if (!wapNumber || wapNumber == "") { throw new Error("El cliente no tiene un número telefónico asociado"); } await NotificationsManager.sendWap({ phoneNumber: wapNumber, message: message, companyId: String(companyCheck._id), sessionUser: data.sessionUser, }); } public async sendWapSystemNotification(data: SendWapSystemNotificationParams): Promise { if (!this.hasValidSystemToken(data.systemToken)) { throw new Error("No tiene permisos para realizar esta acción"); } const checkAppointment = await this.Appointments.findOne({ _id: data.appointmentId, }); if (!checkAppointment) { throw new Error("El turno no existe"); } const company = await CompaniesManager.companies.findOne({ _id: String(checkAppointment.companyId), }); if (!company) { throw new Error("La organización no existe"); } const { message, checkClient, companyCheck } = await this.tryToSendNotification({ appointmentId: data.appointmentId, sessionUser: String(company.ownerId), systemToken: data.systemToken, type: data.type, }); const canSend = await PlanSubscriptionsList.checkFeature({ userId: String(companyCheck.ownerId), feature: PlanFeatures.WAP_NOTIFICATIONS, }); if (!canSend) { throw new Error("La organizacion no tiene un plan que soporte notificaciones por Whatsapp"); } const wapNumber = await ClientsManager.getClientWapNumber(checkClient); if (!wapNumber || wapNumber == "") { throw new Error("El cliente no tiene un número telefónico asociado"); } await NotificationsManager.sendWap({ phoneNumber: wapNumber, message: message, companyId: String(companyCheck._id), sessionUser: String(company.ownerId), }); } public async applyDiscount(data: ApplyAppointmentDiscountParams): Promise { await validateSessionUser({ sessionUser: data.sessionUser }); await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser, }); const appointment = await this.Appointments.AppointmentList.findOne({ _id: data.appointmentId, }); if (!appointment) { throw new Error("El turno no existe"); } if (appointment.payment) { throw new Error("No se puede aplicar un descuento a un turno pagado"); } const service = await ServicesManager.services.serviceList.findOne({ _id: String(appointment.serviceId), }); if (!service) { throw new Error("El servicio no existe"); } const discount = await Discounts.discounts.discountsList.findOne({ _id: data.discountId, companyId: data.companyId, }); if (!discount) { throw new Error("El descuento no existe"); } let price = service.price; if (service.discountId) { //Como el servicio ya tiene un descuento, aplico el descuento del turno sobre el precio original. //Ya que la idea es que los descuentos no sean acumulativos. price = service.originalPrice; } if (discount.type == DiscountType.FIXED) { price = price - discount.value; } else if (discount.type == DiscountType.PERCENTAGE) { price = price - price * (discount.value / 100); } appointment.discountId = data.discountId; appointment.price = price; await appointment.save(); } public async deleteDiscount(data: DeleteAppointmentDiscountParams): Promise { await validateSessionUser({ sessionUser: data.sessionUser }); await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser, }); const appointment = await this.Appointments.AppointmentList.findOne({ _id: data.appointmentId, }); if (!appointment) { throw new Error("El turno no existe"); } const service = await ServicesManager.services.serviceList.findOne({ _id: String(appointment.serviceId), }); if (!service) { throw new Error("El servicio no existe"); } if (appointment.payment) { throw new Error("No se puede eliminar el descuento de un turno pagado"); } appointment.set("discountId", null); appointment.price = service.originalPrice; await appointment.save(); } public async getAvailableDates(data: GetAvailableDatesParams): Promise { //Solamente validamos que sea un usuario registrado. await validateSessionUser({ sessionUser: data.sessionUser }); const company = await CompaniesManager.companies.findOne({ _id: data.companyId, }); if (!company) { throw new Error("La organización no existe"); } const employee = await EmployeesList.employees.findOne({ _id: data.employeeId, }); if (!employee) { throw new Error("El profesional no existe"); } //Obtener rangos de fechas habilitados para la organizacion/profesional. //cuto startDate este entre nuestro dateFrom y dateTo en base a la fecha de //consulta y el rango de tiempo recibido por parametro. const length = data.length > 60 ? 60 : data.length; const dateFrom = dayjs().clone().startOf("week").add(-1, "days"); const dateTo = dayjs().clone().add(length, "days").endOf("day"); const schedulesEnabled = await SchedulesEnabledList.schedulesEnabled.schedulesEnabledList .find({ companyId: String(data.companyId), employeeId: String(data.employeeId), startDate: { $gte: dateFrom.toDate(), $lte: dateTo.toDate() }, }) .exec(); const returnAvailableDates: Date[] = []; let currentDate = dateFrom.clone().startOf("day"); while (currentDate.isSameOrBefore(dateTo)) { const hasOverride = await SchedulesOverridesList.findOne({ companyId: String(data.companyId), employeeId: String(data.employeeId), date: currentDate.toDate(), }); if ( hasOverride && hasOverride.schedules.some( (schedule) => !schedule.disabled && (!schedule.serviceScope || schedule.serviceScope === "all" || (schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(data.serviceId))) ) ) { returnAvailableDates.push(currentDate.toDate()); } else if (!hasOverride) { // Check if the current date is within any enabled schedule range let isEnabled = false; if (schedulesEnabled) { for (const scheduleItem of schedulesEnabled) { const start = dayjs(scheduleItem.startDate).startOf("day"); const end = dayjs(scheduleItem.endDate).endOf("day"); if (currentDate.isSameOrAfter(start) && currentDate.isSameOrBefore(end)) { isEnabled = true; break; } } } if (isEnabled) { const schedule = await SchedulesList.schedules.schedulesList.findOne({ companyId: String(data.companyId), employeeId: String(data.employeeId), weekDay: currentDate.day(), }); if ( schedule && schedule.schedules.some( (scheduleItem) => !scheduleItem.disabled && (!scheduleItem.serviceScope || scheduleItem.serviceScope === "all" || (scheduleItem.serviceScope === "specific" && (scheduleItem.serviceIds || []).includes(data.serviceId))) ) ) { returnAvailableDates.push(currentDate.toDate()); } } } currentDate = currentDate.add(1, "day"); } return { availableDates: returnAvailableDates, 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(); export default AppointmentList;