Files
turnosxpress/server/src/Models/Appointments/Appointments.ts
T

3189 lines
118 KiB
TypeScript

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<void> {
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<number> {
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<void> {
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<number>(appointment.length, 1),
serviceLimit: serviceCheck.limit,
serviceLength: serviceCheck.length,
};
await this.updateHeatMap(availabilityConfig);
}
public async deleteAppointmentsByCompany(data: DeleteAppointmentsByCompanyParams): Promise<void> {
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<IAppointment> {
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<number>(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<number>(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<string>(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<number>(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);
const reminderEmailContent = await this.tryToSendNotification({
appointmentId: String(newAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "email",
});
const reminderWapContent = await this.tryToSendNotification({
appointmentId: String(newAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "whatsapp",
});
await this.createCreationNotificationJobs({
appointmentId: String(newAppointment.id),
companyId: String(companyCheck._id),
clientId: String(checkClient._id),
clientUserId: checkClient.userId ? String(checkClient.userId) : undefined,
clientEmail: checkClient.email,
clientPhoneNumber,
companyOwnerId: String(companyCheck.ownerId),
companyName: companyCheck.name,
appointmentStart: new Date(data.start),
emailMessage: emailContent.message,
wapMessage: wapContent.message,
reminderEmailMessage: reminderEmailContent.message,
reminderWapMessage: reminderWapContent.message,
});
}
}
//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<PaginateAppointmentsResults> {
return await this.Appointments.paginate(filters);
}
public async moveAppointmentForce(data: MoveAppointmentParams): Promise<void> {
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<void> {
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<HeatMapItem> = [];
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<IHeatMap> {
//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<IHeatMap> {
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<number>(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<void> {
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<boolean> {
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<boolean> {
const start = dayjs(data.start);
const end = start.clone().add(data.serviceLength * isNull<number>(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<boolean> {
const heatMap = await this.getHeatMapToCheckAvailability(config);
if (!heatMap.heatMap) {
return false;
}
const endDateTime = dayjs(data.start)
.clone()
.add(config.serviceLength * isNull<number>(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<AppointmentAdminByDateView> {
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<string>(appointment.id, ""),
clientId: String(appointment.clientId),
serviceId: String(appointment.serviceId),
employeeId: String(appointment.employeeId),
startTime: appointment.startHour,
endTime: appointment.endHour,
title: isNull<string>(clientName, clientUserName),
avatar: clientAvatar,
color: isNull<string>(employee.calendarColor, "#ff9900"),
repeatId: "",
discountId: isNull<string>(appointment.discountId, ""),
discountName: isNull<string>(discountData.name, ""),
discountCode: isNull<string>(discountData.code, ""),
discountValue: isNull<number>(discountData.value, 0),
discountType: isNull<DiscountType>(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<string>(repEmployee.calendarColor, "#ff9900"),
repeatId: String(repeticion.id),
payment: false,
discountId: isNull<string>(undefined, ""),
discountName: isNull<string>(discountData.name, ""),
discountCode: isNull<string>(discountData.code, ""),
discountValue: isNull<number>(discountData.value, 0),
discountType: isNull<DiscountType>(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<AppointmentEventByClient> {
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<string>(userEmployee.firstName, "")
: isNull<string>(employee.profileSnapshot?.firstName, "");
const collaboratorLastName = userEmployee
? isNull<string>(userEmployee.lastName, "")
: isNull<string>(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<string>(clientName, clientUserName),
avatar: clientAvatar,
color: isNull<string>(employee.calendarColor, "#ff9900"),
comments: appointment.comments,
price: appointment.price,
length: appointment.length,
payment: appointment.payment,
discountId: isNull<string>(appointment.discountId, ""),
discountName: isNull<string>(discountData.name, ""),
discountCode: isNull<string>(discountData.code, ""),
discountValue: isNull<number>(discountData.value, 0),
discountType: isNull<DiscountType>(discountData.type, DiscountType.PERCENTAGE),
clientCtaCteBalance: ctaBalance,
};
}
public async findAppointmentsAdminByClient(
data: FindAppointmentsParams
): Promise<AppointmentAdminByClientView> {
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<string>(userEmployee.firstName, "");
const employeeLastName = isNull<string>(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<string>(clientName, clientUserName),
avatar: clientAvatar,
color: isNull<string>(employee.calendarColor, "#ff9900"),
comments: appointment.comments,
price: appointment.price,
length: appointment.length,
payment: appointment.payment,
discountId: isNull<string>(appointment.discountId, ""),
discountName: isNull<string>(discountData.name, ""),
discountCode: isNull<string>(discountData.code, ""),
discountValue: isNull<number>(discountData.value, 0),
discountType: isNull<DiscountType>(discountData.type, DiscountType.PERCENTAGE),
clientCtaCteBalance: 0,
});
}
return {
events: returnData,
};
}
public async findAppointmentsByUser(
data: FindAppointmentsByUserParams
): Promise<AppointmentAdminByClientView> {
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<string>(userEmployee.firstName, "");
const employeeLastName = isNull<string>(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<string>(clientName, clientUserName),
avatar: clientAvatar,
color: isNull<string>(employee.calendarColor, "#ff9900"),
comments: appointment.comments,
price: appointment.price,
length: appointment.length,
payment: appointment.payment,
discountId: isNull<string>(appointment.discountId, ""),
discountName: isNull<string>(discountData.name, ""),
discountCode: isNull<string>(discountData.code, ""),
discountValue: isNull<number>(discountData.value, 0),
discountType: isNull<DiscountType>(discountData.type, DiscountType.PERCENTAGE),
clientCtaCteBalance: 0,
});
}
return {
events: returnData,
};
}
public async findAppointmentsByCollaborator(
data: FindAppointmentsByCollaboratorParams
): Promise<AppointmentAdminByClientView> {
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<string>(userEmployee.firstName, "");
const employeeLastName = isNull<string>(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<string>(clientName, clientUserName),
avatar: clientAvatar,
color: isNull<string>(employee.calendarColor, "#ff9900"),
comments: appointment.comments,
price: appointment.price,
length: appointment.length,
payment: appointment.payment,
discountId: isNull<string>(appointment.discountId, ""),
discountName: isNull<string>(discountData.name, ""),
discountCode: isNull<string>(discountData.code, ""),
discountValue: isNull<number>(discountData.value, 0),
discountType: isNull<DiscountType>(discountData.type, DiscountType.PERCENTAGE),
clientCtaCteBalance: 0,
});
}
return {
events: returnData,
};
}
public async findAppointmentsByUserPaginated(
data: FindAppointmentsByUserPaginatedParams
): Promise<PaginatedAppointmentEventByClientResult> {
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<string>(userEmployee.firstName, "");
const employeeLastName = isNull<string>(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<string>(clientName, clientUserName),
avatar: clientAvatar,
color: isNull<string>(employee.calendarColor, "#ff9900"),
comments: appointment.comments,
price: appointment.price,
length: appointment.length,
payment: appointment.payment,
discountId: isNull<string>(appointment.discountId, ""),
discountName: isNull<string>(discountData.name, ""),
discountCode: isNull<string>(discountData.code, ""),
discountValue: isNull<number>(discountData.value, 0),
discountType: isNull<DiscountType>(discountData.type, DiscountType.PERCENTAGE),
clientCtaCteBalance: 0,
});
}
return {
data: returnData,
page,
pages: Math.ceil(total / limit),
};
}
public async findAppointmentsByCollaboratorPaginated(
data: FindAppointmentsByCollaboratorPaginatedParams
): Promise<PaginatedAppointmentEventByClientResult> {
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<string>(userEmployee.firstName, "");
const employeeLastName = isNull<string>(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<string>(clientName, clientUserName),
avatar: clientAvatar,
color: isNull<string>(employee.calendarColor, "#ff9900"),
comments: appointment.comments,
price: appointment.price,
length: appointment.length,
payment: appointment.payment,
discountId: isNull<string>(appointment.discountId, ""),
discountName: isNull<string>(discountData.name, ""),
discountCode: isNull<string>(discountData.code, ""),
discountValue: isNull<number>(discountData.value, 0),
discountType: isNull<DiscountType>(discountData.type, DiscountType.PERCENTAGE),
clientCtaCteBalance: 0,
});
}
return {
data: returnData,
page,
pages: Math.ceil(total / limit),
};
}
public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
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<number>(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<number>(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<number>(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));
const emailContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.UPDATE,
channel: "email",
});
const wapContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.UPDATE,
channel: "whatsapp",
});
const reminderEmailContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "email",
});
const reminderWapContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment.id),
sessionUser: String(companyCheck.ownerId),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "whatsapp",
});
await this.createUpdateNotificationJobs({
appointmentId: String(checkAppointment.id),
companyId: String(companyCheck._id),
clientId: String(checkClient._id),
clientUserId: checkClient.userId ? String(checkClient.userId) : undefined,
clientEmail: checkClient.email,
clientPhoneNumber: await this.getOptionalClientWapNumber(checkClient),
companyOwnerId: String(companyCheck.ownerId),
companyName: companyCheck.name,
appointmentStart: new Date(data.start),
emailMessage: emailContent.message,
wapMessage: wapContent.message,
reminderEmailMessage: reminderEmailContent.message,
reminderWapMessage: reminderWapContent.message,
});
}
}
//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<number>(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<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;
reminderEmailMessage: string;
reminderWapMessage: string;
}): Promise<void> {
const systemSubject = `Turno reservado en ${data.companyName}`;
const reminderSystemSubject = `Recordatorio de turno 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,
},
reminderPayload: {
email: data.clientEmail,
phoneNumber: data.clientPhoneNumber,
userId: data.clientUserId,
companyOwnerId: data.companyOwnerId,
subject: reminderSystemSubject,
message: data.reminderEmailMessage,
emailSubject: "TurnosXpress :: Recordatorio",
emailMessage: data.reminderEmailMessage,
wapMessage: data.reminderWapMessage,
systemSubject: reminderSystemSubject,
systemMessage: data.reminderEmailMessage,
},
});
}
private async createUpdateNotificationJobs(data: {
appointmentId: string;
companyId: string;
clientId: string;
clientUserId?: string;
clientEmail?: string;
clientPhoneNumber?: string;
companyOwnerId: string;
companyName: string;
appointmentStart: Date;
emailMessage: string;
wapMessage: string;
reminderEmailMessage: string;
reminderWapMessage: string;
}): Promise<void> {
const systemSubject = `Turno actualizado en ${data.companyName}`;
const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`;
await this.jobService.createJob({
companyId: data.companyId,
clientId: data.clientId,
appointmentId: data.appointmentId,
type: "update",
appointmentStart: data.appointmentStart,
payload: {
email: data.clientEmail,
phoneNumber: data.clientPhoneNumber,
userId: data.clientUserId,
companyOwnerId: data.companyOwnerId,
subject: systemSubject,
message: data.emailMessage,
emailSubject: "TurnosXpress :: Turno actualizado",
emailMessage: data.emailMessage,
wapMessage: data.wapMessage,
systemSubject,
systemMessage: data.emailMessage,
},
reminderPayload: {
email: data.clientEmail,
phoneNumber: data.clientPhoneNumber,
userId: data.clientUserId,
companyOwnerId: data.companyOwnerId,
subject: reminderSystemSubject,
message: data.reminderEmailMessage,
emailSubject: "TurnosXpress :: Recordatorio",
emailMessage: data.reminderEmailMessage,
wapMessage: data.reminderWapMessage,
systemSubject: reminderSystemSubject,
systemMessage: data.reminderEmailMessage,
},
});
}
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, 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<TextObjectFilterResult[]> {
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<number>(service.length, defaultFraction), "minutes");
}
}
return returnData;
}
private async tryToSendNotification(
data: SendAppointmentNotificationParams
): Promise<AppointmentNotificationIntent> {
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 || data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) {
const isUpdate = data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE;
const templateId = data.channel === "email"
? isUpdate
? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId
: companyCheck.templateEmailCancellationId || companyCheck.templateWapCancellationId
: isUpdate
? companyCheck.templateWapAltaId
: 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"
? isUpdate
? process.env.EMAIL_NEW_APPOINTMENT || process.env.EMAIL_ALTA || process.env.WAP_ALTA || ""
: process.env.EMAIL_CANCELLATION || process.env.EMAIL_CANCELACION || process.env.WAP_CANCELLATION || process.env.WAP_CANCELACION || ""
: isUpdate
? process.env.WAP_ALTA || ""
: 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<void> {
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";
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) {
subjectEmail = "TurnosXpress :: Turno actualizado";
}
//Enviar email...
await NotificationsManager.sendEmail({
email: clientEmail,
subject: subjectEmail,
message: message,
});
}
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,
});
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<void> {
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<void> {
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<void> {
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<GetAvailableDatesResult> {
//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;