import { SchedulesAdapterMongoose } from "./Schedules.Adapter.Mongoose"; import { CreateScheduleParams, UpdateScheduleParams, DeleteScheduleParams, FindSchedulesParams, ISchedule, ISchedulesManager, PaginateSchedulesParams, PaginateSchedulesResults, CollaboratorSchedulesView, AvailableSchedulesParams, DeleteScheulesByCompanyParams, DeleteSchedulesByEmployeeParams, ScheduleItem, ScheduleApplyParams, ScheduleApplyResult, ScheduleConflictDetail, } from "./Schedules.Interface"; import { EmployeeRoles } from "../Employees/Employees.Interface"; import UsersManager from "../Users/Users"; import CompaniesManager from "../Companies/Companies"; import EmployeesList from "../Employees/Employee"; import { NoPermissionMessage } from "../../helpers/NoPermissionMessage"; import { joinStrings } from "../../helpers/String"; import { isNull } from "../../helpers/IsNull"; import getAvatar from "../../helpers/getAvatar"; import { NotificationsManager } from "../Notifications/Notifications"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check"; const WEEK_DAY_LABELS = ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"]; class SchedulesManager implements ISchedulesManager { schedules: SchedulesAdapterMongoose; constructor() { this.schedules = new SchedulesAdapterMongoose(); } public async deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise { await this.schedules.schedulesList.deleteMany({ employeeId: data.employeeId }); } public async deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise { await this.schedules.schedulesList.deleteMany({ companyId: data.companyId }); } public async createSchedule(data: CreateScheduleParams): 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 companyCheck = await CompaniesManager.companies.findOne({ _id: data.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 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."); } const ScheduleCheck = await this.schedules.findOne({ employeeId: data.employeeId, companyId: data.companyId, weekDay: data.weekDay, }); if (ScheduleCheck) { throw new Error("El servicio ya fue asignado para este empleado"); } const newEmplyeeService = await this.schedules.create(data); const strHorarios = data.schedules.map((horario) => { return `De ${horario.from} a ${horario.to}`; }); await NotificationsManager.sendSystemNotification({ userId: String(employeeCheck.userId), subject: "Se han agregado horarios de trabajo", message: `Se han registrado nuevos horarios en la organización ${companyCheck.name }. Horarios: ${strHorarios.join(", ")}`, type: NotificationType.SCHEDULE, code: String(employeeCheck._id) }); return newEmplyeeService; } public async updateSchedule(data: UpdateScheduleParams): 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 companyCheck = await CompaniesManager.companies.findOne({ _id: data.companyId }); if (!companyCheck) { throw new Error("La compañia no existe"); } 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 ( !(await EmployeesList.checkPermission( companyCheck, data.sessionUser, EmployeeRoles.ADMIN )) ) { throw new Error(NoPermissionMessage()); } const scheduleUpd = await this.schedules.findOne({ employeeId: data.employeeId, companyId: data.companyId, weekDay: data.weekDay, }); if (!scheduleUpd) { this.createSchedule(data); return; } scheduleUpd.schedules = data.schedules; await scheduleUpd.save(); const strHorarios = data.schedules.map((horario) => { return `De ${horario.from} a ${horario.to}`; }); await NotificationsManager.sendSystemNotification({ userId: String(employeeCheck.userId), subject: "Se han agregado horarios de trabajo", message: `Se han registrado nuevos horarios en la organización ${companyCheck.name }. Horarios: ${strHorarios.join(", ")}`, type: NotificationType.SCHEDULE, code: String(employeeCheck._id) }); } public async paginate(filters: PaginateSchedulesParams): Promise { return await this.schedules.paginate(filters); } public async find(filters: FindSchedulesParams): Promise { return await this.schedules.find(filters); } private parseTimeToMinutes(time: string): number { const [hours, minutes] = time.split(":").map((part) => Number(part)); if (!Number.isInteger(hours) || !Number.isInteger(minutes)) { throw new Error("El horario indicado no es válido"); } return hours * 60 + minutes; } private hasTimeOverlap(first: ScheduleItem, second: ScheduleItem): boolean { return this.parseTimeToMinutes(first.from) < this.parseTimeToMinutes(second.to) && this.parseTimeToMinutes(second.from) < this.parseTimeToMinutes(first.to); } private getServiceScope(schedule: ScheduleItem): "all" | "specific" { return schedule.serviceScope || "all"; } private hasServiceOverlap(first: ScheduleItem, second: ScheduleItem): boolean { const firstScope = this.getServiceScope(first); const secondScope = this.getServiceScope(second); if (firstScope === "all" || secondScope === "all") { return true; } const secondServices = new Set(second.serviceIds || []); return (first.serviceIds || []).some((serviceId) => secondServices.has(serviceId)); } private hasScheduleConflict(first: ScheduleItem, second: ScheduleItem): boolean { if (first.disabled || second.disabled) { return false; } return this.hasTimeOverlap(first, second) && this.hasServiceOverlap(first, second); } private formatSchedule(schedule: ScheduleItem): string { if (this.getServiceScope(schedule) === "all") { return `${schedule.from} a ${schedule.to} para todos los servicios`; } return `${schedule.from} a ${schedule.to} para servicios específicos`; } private getEmployeeName(employee: any): string { const snapshotName = [employee.profileSnapshot?.firstName, employee.profileSnapshot?.lastName].filter(Boolean).join(" ").trim(); return snapshotName || employee.fullName || employee.name || String(employee._id); } private buildConflict(employee: any, weekDay: number, attempted: ScheduleItem, existing: ScheduleItem): ScheduleConflictDetail { const employeeId = String(employee._id); const employeeName = this.getEmployeeName(employee); const weekDayLabel = WEEK_DAY_LABELS[weekDay] || String(weekDay); return { employeeId, employeeName, weekDay, weekDayLabel, attempted, existing, message: `${employeeName} ya tiene un horario el ${weekDayLabel} de ${this.formatSchedule(existing)} que se superpone con ${this.formatSchedule(attempted)}.`, }; } private async findScheduleDocument(companyId: string, employeeId: string, weekDay: number) { return this.schedules.findOne({ companyId, employeeId, weekDay }); } public async applyScheduleChanges(data: ScheduleApplyParams): Promise { await validateSessionUser({ sessionUser: data.sessionUser }); await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser }); if (!Array.isArray(data.employeeIds) || data.employeeIds.length === 0) { throw new Error("Tenés que seleccionar al menos un colaborador."); } if (!Array.isArray(data.weekDays) || data.weekDays.length === 0 || data.weekDays.some((day) => !Number.isInteger(day) || day < 0 || day > 6)) { throw new Error("Tenés que seleccionar días válidos entre 0 y 6."); } if (data.conflictStrategy !== "reject" && data.conflictStrategy !== "overwrite-conflicts") { throw new Error("La estrategia de conflictos no es válida."); } const employees = await Promise.all(data.employeeIds.map(async (employeeId) => { const employee = await EmployeesList.employees.findOne({ companyId: data.companyId, _id: employeeId }); if (!employee) { throw new Error("El empleado no existe o no pertenece a la compañia."); } return employee; })); const conflicts: ScheduleConflictDetail[] = []; for (const employee of employees) { const employeeId = String(employee._id); for (const weekDay of data.weekDays) { const scheduleDocument = await this.findScheduleDocument(data.companyId, employeeId, weekDay); const existingSchedules = scheduleDocument?.schedules || []; for (const attempted of data.schedules) { const existing = existingSchedules.find((schedule) => this.hasScheduleConflict(attempted, schedule)); if (existing) { conflicts.push(this.buildConflict(employee, weekDay, attempted, existing)); } } } } if (conflicts.length > 0 && data.conflictStrategy === "reject") { return { valid: false, applied: false, conflicts, message: "Encontramos horarios superpuestos. Revisalos antes de guardar o elegí sobrescribir solo esos conflictos.", }; } for (const employee of employees) { const employeeId = String(employee._id); for (const weekDay of data.weekDays) { const scheduleDocument = await this.findScheduleDocument(data.companyId, employeeId, weekDay); const existingSchedules = scheduleDocument?.schedules || []; const nextSchedules = data.conflictStrategy === "overwrite-conflicts" ? existingSchedules.filter((existing) => !data.schedules.some((attempted) => this.hasScheduleConflict(attempted, existing))) : existingSchedules; const schedules = [...nextSchedules, ...data.schedules]; if (scheduleDocument) { scheduleDocument.schedules = schedules; await scheduleDocument.save(); } else { await this.schedules.create({ companyId: data.companyId, employeeId, weekDay, schedules, sessionUser: data.sessionUser, }); } } } return { valid: conflicts.length === 0, applied: true, conflicts, message: conflicts.length > 0 ? "Se sobrescribieron solo los horarios en conflicto." : "Los horarios se guardaron correctamente.", }; } private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean { if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") { return true; } return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId); } private async getScheduleByFrom(data: AvailableSchedulesParams): Promise { const schedule = await this.schedules.findOne({ employeeId: data.employeeId, companyId: data.companyId, weekDay: data.weekDay, }); if (schedule) { for (const horario of schedule.schedules) { if (horario.disabled) { continue; } if (!this.isScheduleAvailableForService(horario, data.serviceId)) { continue; } if (horario.from == data.from) { return horario; } if (horario.from < data.from && horario.to > data.from) { return horario; } } } return null; } private checkScheduleTo(schedule: ScheduleItem, to: string): boolean { const toSchedule = schedule.to.split(":"); const toHoras = parseInt(toSchedule[0]); const toMinutos = parseInt(toSchedule[1]); const toTime = toHoras + toMinutos / 60; const dataTo = to.split(":"); const dataToHoras = parseInt(dataTo[0]); const dataToMinutos = parseInt(dataTo[1]); const dataToTime = dataToHoras + dataToMinutos / 60; // Si este rango cubre el from to del potencial turno retorna true. if (dataToTime <= toTime) { return true; } return false; } public async isAvailable(data: AvailableSchedulesParams): Promise { // Buscar un rango horario que coincida con el from. const scheduleFrom = await this.getScheduleByFrom(data); if (scheduleFrom) { // Si este rango cubre el from to del potencial turno retorna true. if (this.checkScheduleTo(scheduleFrom, data.to)) { return true; } // En caso de que no lo cubra, lo que hacemos es chequear si en ese rango que comienza con el from // es posible convinarlo con otro rango chequeando que exista un rango que comience donde termina el primero. // Entonces podemos crear un nuevo rango que convine los dos encontrados para formar uno mas grande. const scheduleTo = await this.getScheduleByFrom({ employeeId: data.employeeId, companyId: data.companyId, weekDay: data.weekDay, from: scheduleFrom.to, to: data.to, serviceId: data.serviceId, }); if (!scheduleTo) { return false; } if (this.checkScheduleTo(scheduleTo, data.to)) { return true; } } return false; } public async findAllSchedulesByCollaborator( filters: FindSchedulesParams ): Promise { const employeeCheck = await EmployeesList.employees.findOne({ _id: filters.employeeId, companyId: filters.companyId, }); if (!employeeCheck) { throw new Error("El colaborador no existe o no pertenece a la compañia."); } const userCheck = await UsersManager.users.findOne({ _id: employeeCheck.userId, }); if (!userCheck) { throw new Error("El colaborador no existe"); } const schedule = await SchedulesList.schedules.findOne({ companyId: filters.companyId, employeeId: filters.employeeId, weekDay: filters.weekDay, }); const fullName = joinStrings([userCheck.firstName, userCheck.lastName], " "); return { companyId: isNull(filters.companyId, ""), employeeId: isNull(filters.employeeId, ""), weekDay: isNull(filters.weekDay, 1), scheduleId: schedule ? schedule.id : "", schedules: schedule ? schedule.schedules : [], fullName: fullName, avatar: getAvatar(userCheck.id, userCheck.avatar, fullName), }; } public async delete(data: DeleteScheduleParams): 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 companyCheck = await CompaniesManager.companies.findOne({ _id: data.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 deleteItem = await this.schedules.findOne({ _id: data.id, }); if (!deleteItem) { throw new Error("Ha ocurrido un error al borrar el horario"); } const emplyeeId = deleteItem.employeeId; const employeeCheck = await EmployeesList.employees.findOne({ companyId: data.companyId, _id: emplyeeId, }); if (!employeeCheck) { throw new Error("El empleado no existe o no pertenece a la compañia."); } const strHorarios = deleteItem.schedules.map((horario) => { return `De ${horario.from} a ${horario.to}`; }); await this.schedules.delete(String(deleteItem._id)); await NotificationsManager.sendSystemNotification({ userId: String(employeeCheck.userId), subject: "Se han agregado horarios de trabajo", message: `Se han registrado nuevos horarios en la organización ${companyCheck.name }. Horarios: ${strHorarios.join(", ")}`, type: NotificationType.SCHEDULE, code: String(employeeCheck._id) }); } } const SchedulesList = new SchedulesManager(); export default SchedulesList;