From 74d6ee6706a3e99072499d1a042439598d818d79 Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Sun, 19 Jul 2026 18:56:39 -0300 Subject: [PATCH] feat: implement collaborator reactivation logic, add access denied handling, and improve metric filtering --- .../Appointments/Appointments.Interface.ts | 1 + .../src/Models/Appointments/Appointments.ts | 29 +- .../Appointments.snapshotFallback.test.ts | 387 +++++++++++ server/src/Models/Companies/Companies.ts | 6 +- server/src/Models/Employees/Employee.ts | 203 +++++- .../Employees/Employees.Adapter.Mongoose.ts | 12 + .../Models/Employees/Employees.Interface.ts | 25 + .../__tests__/Employee.queryFiltering.test.ts | 233 +++++++ .../__tests__/Employee.reactivation.test.ts | 343 ++++++++++ .../Employee.removeCollaborator.test.ts | 632 ++++++++++++++++++ .../EmployeesServices/EmployeesServices.ts | 39 +- .../EmployeesServices.findByService.test.ts | 318 +++++++++ server/src/Models/Metrics/Metrics.ts | 3 +- .../Metrics.calculateMetrics.test.ts | 220 ++++++ .../api/Appointments/Appointments.Service.ts | 10 + .../src/api/Employees/Employees.Controller.ts | 25 + server/src/api/Employees/Employees.Service.ts | 6 + txclient/src/Models/Appointments.model.ts | 1 + txclient/src/Models/Collaborators.model.ts | 23 + .../org/[oid]/client/[clientId]/layout.tsx | 13 +- .../[id]/appointments/[appdate]/page.tsx | 7 +- .../collaborators/Collaborators.Service.tsx | 5 + .../collaborators/[cid]/edit/edit.module.css | 223 ++++++ .../[id]/collaborators/[cid]/edit/page.tsx | 233 ++++++- .../org/profile/[id]/layout.tsx | 27 +- .../access-denied/access-denied.module.css | 123 ++++ txclient/src/app/admin/access-denied/page.tsx | 38 ++ 27 files changed, 3148 insertions(+), 37 deletions(-) create mode 100644 server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts create mode 100644 server/src/Models/Employees/__tests__/Employee.queryFiltering.test.ts create mode 100644 server/src/Models/Employees/__tests__/Employee.reactivation.test.ts create mode 100644 server/src/Models/Employees/__tests__/Employee.removeCollaborator.test.ts create mode 100644 server/src/Models/EmployeesServices/__tests__/EmployeesServices.findByService.test.ts create mode 100644 server/src/Models/Metrics/__tests__/Metrics.calculateMetrics.test.ts create mode 100644 txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/edit/edit.module.css create mode 100644 txclient/src/app/admin/access-denied/access-denied.module.css create mode 100644 txclient/src/app/admin/access-denied/page.tsx diff --git a/server/src/Models/Appointments/Appointments.Interface.ts b/server/src/Models/Appointments/Appointments.Interface.ts index 80057fa..3764a31 100644 --- a/server/src/Models/Appointments/Appointments.Interface.ts +++ b/server/src/Models/Appointments/Appointments.Interface.ts @@ -12,6 +12,7 @@ export type FindAppointmentsParams = { clientId?: string; start?: Date; dateDay?: Date; + sessionUser?: string; }; export type FindAppointmentsByUserParams = { diff --git a/server/src/Models/Appointments/Appointments.ts b/server/src/Models/Appointments/Appointments.ts index 8a09421..d9119f3 100644 --- a/server/src/Models/Appointments/Appointments.ts +++ b/server/src/Models/Appointments/Appointments.ts @@ -1052,6 +1052,9 @@ class AppointmentManager implements IAppointmentsManager { 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) @@ -1251,9 +1254,17 @@ class AppointmentManager implements IAppointmentsManager { _id: String(employee.userId), }); - if (!userEmployee) { - throw new Error("No se ha encontrado el usuario del colaborador"); - } + // Historical read snapshot fallback: use Employee snapshot if User is deleted + const collaboratorFirstName = userEmployee + ? isNull(userEmployee.firstName, "") + : isNull(employee.profileSnapshot?.firstName, ""); + const collaboratorLastName = userEmployee + ? isNull(userEmployee.lastName, "") + : isNull(employee.profileSnapshot?.lastName, ""); + const collaboratorFullName = joinStrings([collaboratorFirstName, collaboratorLastName], " "); + const collaboratorAvatar = userEmployee + ? getAvatar(employee.userId, userEmployee.avatar, collaboratorFullName) + : (employee.profileSnapshot?.avatar || collaboratorFullName); const service = await ServiceList.services.findOne({ _id: String(appointment.serviceId), @@ -1271,14 +1282,6 @@ class AppointmentManager implements IAppointmentsManager { ? getAvatar(client.userId, userClient.avatar, clientUserName) : clientUserName; - const employeeFirstName = isNull(userEmployee.firstName, ""); - const employeeLastName = isNull(userEmployee.lastName, ""); - const employeeFullName = joinStrings([employeeFirstName, employeeLastName], " "); - - const employeeAvatar = userEmployee - ? getAvatar(employee.userId, userEmployee.avatar, employeeFullName) - : clientUserName; - const discountData = await Discounts.getDiscountData(appointment.discountId, appointment.companyId); let ctaBalance = 0; @@ -1300,8 +1303,8 @@ class AppointmentManager implements IAppointmentsManager { serviceName: service.name, serviceDescription: service.description, collaboratorId: employee.id, - collaboratorName: employeeFullName, - collaboratorAvatar: employeeAvatar, + collaboratorName: collaboratorFullName, + collaboratorAvatar: collaboratorAvatar, appointmentDate: dayjs(appointment.start).toISOString(), startTime: appointment.startHour, endTime: appointment.endHour, diff --git a/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts b/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts new file mode 100644 index 0000000..84b5946 --- /dev/null +++ b/server/src/Models/Appointments/__tests__/Appointments.snapshotFallback.test.ts @@ -0,0 +1,387 @@ +/** + * Tests for Appointments.getAppointmentEvent snapshot fallback + * when a removed employee's User account has been deleted. + * + * The fallback uses Employee.profileSnapshot fields instead of crashing. + */ + +// Mock all external dependencies +jest.mock("../../Users/Users", () => ({ + __esModule: true, + default: { + users: { + findOne: jest.fn(), + }, + getUserFullName: jest.fn(), + }, +})); + +jest.mock("../../Clients/Clients", () => ({ + __esModule: true, + default: { + clients: { + findOne: jest.fn(), + }, + getClientFullName: jest.fn(), + }, +})); + +jest.mock("../../Companies/Companies", () => ({ + __esModule: true, + default: { + companies: { + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../Employees/Employee", () => ({ + __esModule: true, + default: { + employees: { + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../Services/Service", () => ({ + __esModule: true, + default: { + services: { + find: jest.fn(), + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../HeatMap/HeatMap", () => ({ + __esModule: true, + default: { + getHeatMap: jest.fn(), + addHeatMap: jest.fn(), + }, +})); + +jest.mock("../../Schedules/Schedules", () => ({ + __esModule: true, + default: { + find: jest.fn(), + }, +})); + +jest.mock("../../SchedulesEnabled/SchedulesEnabled", () => ({ + __esModule: true, + default: { + findOne: jest.fn(), + }, +})); + +jest.mock("../../SchedulesDisabled/SchedulesDisabled", () => ({ + __esModule: true, + default: { + find: jest.fn(), + }, +})); + +jest.mock("../../SchedulesOverrides/SchedulesOverrides", () => ({ + __esModule: true, + default: { + find: jest.fn(), + }, +})); + +jest.mock("../../Metrics/Metrics", () => ({ + __esModule: true, + default: { + addAppointment: jest.fn(), + canAddAppointment: jest.fn(), + }, +})); + +jest.mock("../../Notifications/Notifications", () => ({ + __esModule: true, + NotificationsManager: { + sendSystemNotification: jest.fn(), + sendPushNotification: jest.fn(), + }, +})); + +jest.mock("../../SystemNotifications/SystemNotification", () => ({ + __esModule: true, + default: { + create: jest.fn(), + }, +})); + +jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({ + __esModule: true, + default: { + findOne: jest.fn(), + }, +})); + +jest.mock("../../Repeats/Repeats", () => ({ + __esModule: true, + default: { + repeats: { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn(), + }, + existDisabledDate: jest.fn(), + }, +})); + +jest.mock("../../Discounts/Discounts", () => ({ + __esModule: true, + default: { + getDiscountData: jest.fn().mockResolvedValue({ + name: "", + value: 0, + type: "PERCENTAGE", + code: "", + }), + }, +})); + +jest.mock("../../../helpers/check", () => ({ + validatePermissionsByCompany: jest.fn(), + validateSessionUser: jest.fn(), +})); + +jest.mock("../../Templates/Templates", () => ({ + __esModule: true, + default: { + sendEmail: jest.fn(), + }, +})); + +jest.mock("../../ClientAccounts/ClientAccount", () => ({ + __esModule: true, + default: { + getAvailableAmount: jest.fn().mockResolvedValue(0), + }, +})); + +jest.mock("../../DiscountsEmail/DiscountsEmail", () => ({ + __esModule: true, + default: { + findOne: jest.fn(), + }, +})); + +jest.mock("../../../index", () => ({ + io: { + to: jest.fn(() => ({ emit: jest.fn() })), + }, +})); + +import AppointmentsList from "../Appointments"; +import UsersManager from "../../Users/Users"; +import EmployeesList from "../../Employees/Employee"; +import ClientsManager from "../../Clients/Clients"; +import ServiceList from "../../Services/Service"; + +describe("getAppointmentEvent — historical read snapshot fallback", () => { + let originalAdapter: any; + + beforeEach(() => { + jest.clearAllMocks(); + + // Save and replace adapter + originalAdapter = AppointmentsList.Appointments; + const mockAppointmentFindOne = jest.fn(); + (AppointmentsList as any).Appointments = { + find: jest.fn(), + findOne: mockAppointmentFindOne, + AppointmentList: { + findOne: mockAppointmentFindOne, + find: jest.fn(), + }, + }; + }); + + afterEach(() => { + (AppointmentsList as any).Appointments = originalAdapter; + }); + + it("falls back to Employee snapshot when User account is deleted", async () => { + const mockAppointment = { + _id: "appt-001", + id: "appt-001", + companyId: "company-001", + clientId: "client-001", + employeeId: "emp-removed", + serviceId: "svc-001", + start: new Date("2026-07-20T10:00:00Z"), + startHour: 10, + endHour: 11, + dateDay: new Date("2026-07-20"), + price: 100, + length: 60, + payment: false, + comments: "", + discountId: undefined, + }; + + const mockClient = { + _id: "client-001", + id: "client-001", + userId: "client-user-001", + firstName: "Client", + lastName: "Smith", + }; + + const mockClientUser = { + _id: "client-user-001", + id: "client-user-001", + firstName: "Client", + lastName: "Smith", + avatar: "client-avatar", + }; + + // Employee exists (removed, with snapshot) + const mockRemovedEmployee = { + _id: "emp-removed", + id: "emp-removed", + companyId: "company-001", + userId: "deleted-user-id", + calendarColor: "#ff0000", + removed: true, + profileSnapshot: { + firstName: "John", + lastName: "Doe", + email: "john@example.com", + avatar: "old-avatar-url", + }, + }; + + // Mock adapter.findOne for the appointment + const mockAppointmentFindOne = (AppointmentsList as any).Appointments.AppointmentList.findOne; + mockAppointmentFindOne.mockResolvedValue(mockAppointment); + + // Mock lookups + (ClientsManager.clients.findOne as jest.Mock).mockResolvedValue(mockClient); + (ServiceList.services.findOne as jest.Mock).mockResolvedValue({ + _id: "svc-001", + name: "Haircut", + description: "Professional haircut", + }); + + // Call order for UsersManager.users.findOne (same mock as UserList): + // 1. Line 1217: UserList.users.findOne (NOT awaited, but consumes a value) + // 2. Line 1241: UsersManager.users.findOne for userClient + // 3. Line 1253: UsersManager.users.findOne for userEmployee + (UsersManager.users.findOne as jest.Mock) + .mockResolvedValueOnce({ _id: "session-user" }) // line 1217: not awaited, just needs truthy + .mockResolvedValueOnce(mockClientUser) // line 1241: client user + .mockResolvedValueOnce(null); // line 1253: employee user DELETED → triggers fallback + (EmployeesList.employees.findOne as jest.Mock).mockResolvedValue(mockRemovedEmployee); + (EmployeesList as any).findById = jest.fn().mockResolvedValue({ + id: "emp-removed", + fullName: "John Doe", + avatar: "old-avatar-url", + }); + + const result = await AppointmentsList.getAppointmentEvent({ + appointmentId: "appt-001", + sessionUser: "session-user", + }); + + // Verify snapshot fields are used for collaboratorName + expect(result.collaboratorName).toBe("John Doe"); + + // Verify snapshot avatar is used + expect(result.collaboratorAvatar).toBe("old-avatar-url"); + + // Verify collaboratorId comes from employee, not user + expect(result.collaboratorId).toBe("emp-removed"); + }); + + it("uses live User data when User account still exists", async () => { + const mockAppointment = { + _id: "appt-002", + id: "appt-002", + companyId: "company-001", + clientId: "client-001", + employeeId: "emp-active", + serviceId: "svc-001", + start: new Date("2026-07-20T10:00:00Z"), + startHour: 10, + endHour: 11, + dateDay: new Date("2026-07-20"), + price: 100, + length: 60, + payment: false, + comments: "", + discountId: undefined, + }; + + const mockClient = { + _id: "client-001", + id: "client-001", + userId: "client-user-001", + firstName: "Client", + lastName: "Smith", + }; + + const mockClientUser = { + _id: "client-user-001", + id: "client-user-001", + firstName: "Client", + lastName: "Smith", + avatar: "client-avatar", + }; + + const mockActiveEmployee = { + _id: "emp-active", + id: "emp-active", + companyId: "company-001", + userId: "active-user-id", + calendarColor: "#00ff00", + removed: false, + profileSnapshot: undefined, + }; + + const mockActiveUser = { + _id: "active-user-id", + id: "active-user-id", + firstName: "Jane", + lastName: "Roe", + avatar: "live-avatar", + email: "jane@example.com", + }; + + const mockAppointmentFindOne = (AppointmentsList as any).Appointments.AppointmentList.findOne; + mockAppointmentFindOne.mockResolvedValue(mockAppointment); + + (ClientsManager.clients.findOne as jest.Mock).mockResolvedValue(mockClient); + (ServiceList.services.findOne as jest.Mock).mockResolvedValue({ + _id: "svc-001", + name: "Haircut", + description: "Professional haircut", + }); + + // Call order for UsersManager.users.findOne (same mock as UserList): + // 1. Line 1217: UserList.users.findOne (NOT awaited, but consumes a value) + // 2. Line 1241: UsersManager.users.findOne for userClient + // 3. Line 1253: UsersManager.users.findOne for userEmployee + (UsersManager.users.findOne as jest.Mock) + .mockResolvedValueOnce({ _id: "session-user" }) // line 1217: not awaited + .mockResolvedValueOnce(mockClientUser) // line 1241: client user + .mockResolvedValueOnce(mockActiveUser); // line 1253: live user exists + (EmployeesList.employees.findOne as jest.Mock).mockResolvedValue(mockActiveEmployee); + (EmployeesList as any).findById = jest.fn().mockResolvedValue({ + id: "emp-active", + fullName: "Jane Roe", + avatar: "live-avatar", + }); + + const result = await AppointmentsList.getAppointmentEvent({ + appointmentId: "appt-002", + sessionUser: "session-user", + }); + + // Live user data is used, not snapshot + expect(result.collaboratorName).toBe("Jane Roe"); + }); +}); diff --git a/server/src/Models/Companies/Companies.ts b/server/src/Models/Companies/Companies.ts index 6fcbfc4..9aa1671 100644 --- a/server/src/Models/Companies/Companies.ts +++ b/server/src/Models/Companies/Companies.ts @@ -536,7 +536,8 @@ class CompaniesManager implements ICompaniesManager { const employ = await EmployeesList.employees.findOne({ companyId: data.id, userId: String(data.sessionUser), - }); + removed: { $ne: true }, + } as any); if (!employ) { throw new Error("El usuario no participa de la organización"); @@ -721,7 +722,8 @@ class CompaniesManager implements ICompaniesManager { //Organizaciones en las que participa como colaborador. const orgCollaborator = await EmployeesList.employees.find({ userId: data.sessionUser, - }); + removed: { $ne: true }, + } as any); for (const item of orgCollaborator) { if (item.companyId) { diff --git a/server/src/Models/Employees/Employee.ts b/server/src/Models/Employees/Employee.ts index 0e9e157..18c1075 100644 --- a/server/src/Models/Employees/Employee.ts +++ b/server/src/Models/Employees/Employee.ts @@ -14,6 +14,7 @@ import { UpdateEmployeeParams, DeleteEmployeesByCompany, DeleteEmployeeParams, + RemoveCollaboratorParams, } from "./Employees.Interface"; import UsersManager from "../Users/Users"; import { ICompanyDocument } from "../Companies/Companies.Adapter.Mongoose"; @@ -26,7 +27,9 @@ import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model"; import MetricsList from "../../Models/Metrics/Metrics"; import { NotificationsManager } from "../Notifications/Notifications"; import EmployeesServicesList from "../EmployeesServices/EmployeesServices"; +import HeatMapList from "../HeatMap/HeatMap"; import SchedulesList from "../Schedules/Schedules"; +import AppointmentList from "../Appointments/Appointments"; import { IncompleteCollaboratorView } from "./Employees.Interface"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; @@ -51,7 +54,11 @@ class EmployeeManager implements IEmployeesManager { role: EmployeeRoles ): Promise { //Check if role exist in roles array. - const check = await this.employees.findOne({ companyId: companyId, userId: employeeId }); + const check = await this.employees.findOne({ + companyId: companyId, + userId: employeeId, + removed: { $ne: true }, + } as any); if (!check) { return false; @@ -112,6 +119,53 @@ class EmployeeManager implements IEmployeesManager { userId: data.userId, }); + // Reactivation: if employee exists and is removed, reactivate instead of creating duplicate + if (employeeCheck && employeeCheck.removed === true) { + // Check quota before reactivation + const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId); + if (!canAdd) { + throw new Error( + "Ha alcanzado el limite de colaboradores permitidos de acuerdo a su plan." + ); + } + + // Clear removal metadata + employeeCheck.removed = false; + employeeCheck.removedAt = undefined; + employeeCheck.removedBy = undefined; + + // Refresh profileSnapshot from current User + employeeCheck.profileSnapshot = { + firstName: userCheck.firstName || undefined, + lastName: userCheck.lastName || undefined, + email: userCheck.email || undefined, + avatar: userCheck.avatar || undefined, + }; + + // Reset invitation state for full re-acceptance flow + employeeCheck.hostOk = false; + employeeCheck.guestOk = false; + + await employeeCheck.save(); + + // Increment metrics + await MetricsList.addEmployee({ + userId: companyCheck.ownerId, + quantity: 1, + }); + + // Send invitation notification + await NotificationsManager.sendSystemNotification({ + userId: String(data.userId), + subject: "Su usuario ha sido vinculado a una organización", + message: `La compañía ${companyCheck.name} lo ha vinculado como colaborador. Es necesario que acepte la invitación para poder formar parte de esta organización.`, + type: NotificationType.EMPLOYEE, + code: String(employeeCheck._id), + }); + + return employeeCheck; + } + if (employeeCheck) { throw new Error("El usuario ya pertenece a la compañia"); } @@ -346,7 +400,7 @@ class EmployeeManager implements IEmployeesManager { } public async findByCompanyId(data: FindEmployeesParams): Promise { - const employees = await this.employees.find(data); + const employees = await this.employees.find({ ...data, removed: { $ne: true } } as any); const view = await Promise.all( employees.map(async (employee) => { @@ -422,7 +476,7 @@ class EmployeeManager implements IEmployeesManager { } public async textObjectFilter(data: FindEmployeesParams): Promise { - const employees = await this.employees.find(data); + const employees = await this.employees.find({ ...data, removed: { $ne: true } } as any); const formattedResult: TextObjectFilterResult[] = []; for (const colaborador of employees) { @@ -522,8 +576,151 @@ class EmployeeManager implements IEmployeesManager { } return incompleteCollaborators; } + + public async removeCollaboratorFromCompany(data: RemoveCollaboratorParams): Promise { + // Validate session user + 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" + ); + } + + // Validate company exists + const companyCheck = await CompaniesManager.companies.findOne({ _id: data.companyId }); + if (!companyCheck) { + throw new Error("La compañia no existe"); + } + + // Validate target employee exists + const employee = await this.employees.findOne({ + _id: data.employeeId, + }); + if (!employee) { + throw new Error("El colaborador no existe"); + } + + // Check if already removed + if (employee.removed) { + throw new Error("El colaborador ya fue eliminado de la organización"); + } + + // Validate permission (admin or owner) + if ( + !(await this.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN)) + ) { + throw new Error(NoPermissionMessage()); + } + + // Self-removal prevention + if (String(employee.userId) === data.sessionUser) { + throw new Error("No puedes eliminarte a ti mismo de la organización"); + } + + // Owner protection + if (employee.roles && employee.roles.includes(EmployeeRoles.OWNER)) { + throw new Error("No se puede eliminar al propietario de la organización"); + } + + // Query future appointments for this employee + const futureAppointments = await AppointmentList.Appointments.AppointmentList.find({ + companyId: data.companyId, + employeeId: data.employeeId, + start: { $gt: new Date() }, + }); + + // If there are future appointments, require a replacement + if (futureAppointments && futureAppointments.length > 0) { + if (!data.replacementEmployeeId) { + throw new Error( + "El colaborador tiene turnos futuros asignados. Se requiere un empleado de reemplazo." + ); + } + + // Validate replacement employee + const replacementEmployee = await this.employees.findOne({ + _id: data.replacementEmployeeId, + }); + + if (!replacementEmployee) { + throw new Error("El empleado de reemplazo no existe"); + } + + if (String(replacementEmployee.companyId) !== data.companyId) { + throw new Error("El empleado de reemplazo no pertenece a esta organización"); + } + + if (replacementEmployee.removed) { + throw new Error("El empleado de reemplazo fue eliminado de la organización"); + } + + if (String(replacementEmployee.userId) === data.sessionUser) { + throw new Error( + "No puedes designarte como reemplazo de ti mismo" + ); + } + + // Reassign future appointments + for (const appointment of futureAppointments) { + appointment.employeeId = data.replacementEmployeeId; + await appointment.save(); + } + + // Reassign active repeats + const RepeatsList = (await import("../Repeats/Repeats")).default; + const activeRepeats = await RepeatsList.repeats.find({ + companyId: data.companyId, + employeeId: data.employeeId, + }); + + for (const repeat of activeRepeats) { + // Access the underlying Mongoose model to get save() capability + await RepeatsList.repeats.findOne({ _id: repeat.id }).then(async (doc) => { + if (doc) { + doc.employeeId = data.replacementEmployeeId!; + await doc.save(); + } + }); + } + } + + // Snapshot user profile + const targetUser = await UsersManager.users.findOne({ _id: employee.userId }); + if (targetUser) { + employee.profileSnapshot = { + firstName: targetUser.firstName || undefined, + lastName: targetUser.lastName || undefined, + email: targetUser.email || undefined, + avatar: targetUser.avatar || undefined, + }; + } + + // Mark employee as removed + employee.removed = true; + employee.removedAt = new Date(); + employee.removedBy = data.sessionUser; + + await employee.save(); + + // Cleanup: delete employee services and heatmap data + await EmployeesServicesList.deleteEmployeeServiceByEmployee({ + employeeId: data.employeeId, + }); + + await HeatMapList.deleteHeatMapByEmployee({ + employeeId: data.employeeId, + }); + + // Decrement metrics + await MetricsList.addEmployee({ + userId: companyCheck.ownerId, + quantity: -1, + }); + } } +export { EmployeeManager }; + const EmployeesList = new EmployeeManager(); export default EmployeesList; diff --git a/server/src/Models/Employees/Employees.Adapter.Mongoose.ts b/server/src/Models/Employees/Employees.Adapter.Mongoose.ts index ec65042..4f87ef8 100644 --- a/server/src/Models/Employees/Employees.Adapter.Mongoose.ts +++ b/server/src/Models/Employees/Employees.Adapter.Mongoose.ts @@ -23,6 +23,18 @@ export class EmployeesAdapterMongoose implements IEmployeesAdapter { limit: { type: Number, required: true, default: -1 }, hostOk: { type: Boolean, required: true, default: false }, guestOk: { type: Boolean, required: true, default: false }, + removed: { type: Boolean, required: false, default: false }, + removedAt: { type: Date, required: false }, + removedBy: { type: Schema.Types.ObjectId, required: false, ref: "User" }, + profileSnapshot: { + type: { + firstName: { type: String, required: false }, + lastName: { type: String, required: false }, + email: { type: String, required: false }, + avatar: { type: String, required: false }, + }, + required: false, + }, creationDate: { type: Date, required: true, default: Date.now }, }); diff --git a/server/src/Models/Employees/Employees.Interface.ts b/server/src/Models/Employees/Employees.Interface.ts index 02d6c61..785d72f 100644 --- a/server/src/Models/Employees/Employees.Interface.ts +++ b/server/src/Models/Employees/Employees.Interface.ts @@ -68,6 +68,13 @@ export type DeleteEmployeeParams = { employeeId: string; }; +export type RemoveCollaboratorParams = { + employeeId: string; + companyId: string; + replacementEmployeeId?: string; + sessionUser: string; +}; + export interface IEmployee { id?: string; companyId: string; @@ -77,6 +84,15 @@ export interface IEmployee { limit?: number; guestOk?: boolean; hostOk?: boolean; + removed?: boolean; + removedAt?: Date; + removedBy?: string; + profileSnapshot?: { + firstName?: string; + lastName?: string; + email?: string; + avatar?: string; + }; } export interface CompanyEmployeesView { @@ -93,6 +109,14 @@ export interface CompanyEmployeesView { hostOk: boolean; fullOk: boolean; calendarColor: string; + removed?: boolean; + removedAt?: Date; + profileSnapshot?: { + firstName?: string; + lastName?: string; + email?: string; + avatar?: string; + }; } export interface IncompleteCollaboratorView { @@ -125,4 +149,5 @@ export interface IEmployeesManager { deleteEmployee(data: DeleteEmployeeParams): Promise; rejectGuest(data: ValidateEmployeeParams): Promise; getIncompleteSetup(sessionUser: string): Promise; + removeCollaboratorFromCompany(data: RemoveCollaboratorParams): Promise; } diff --git a/server/src/Models/Employees/__tests__/Employee.queryFiltering.test.ts b/server/src/Models/Employees/__tests__/Employee.queryFiltering.test.ts new file mode 100644 index 0000000..c588693 --- /dev/null +++ b/server/src/Models/Employees/__tests__/Employee.queryFiltering.test.ts @@ -0,0 +1,233 @@ +import { EmployeeRoles } from "../Employees.Interface"; + +// Mock all external dependencies before importing the module under test +jest.mock("../../Users/Users", () => ({ + __esModule: true, + default: { + users: { + findOne: jest.fn(), + }, + getUserFullName: jest.fn(), + }, +})); + +jest.mock("../../Companies/Companies", () => ({ + __esModule: true, + default: { + companies: { + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../Metrics/Metrics", () => ({ + __esModule: true, + default: { + addEmployee: jest.fn(), + canAddEmployee: jest.fn(), + }, +})); + +jest.mock("../../Notifications/Notifications", () => ({ + __esModule: true, + NotificationsManager: { + sendSystemNotification: jest.fn(), + }, +})); + +jest.mock("../../EmployeesServices/EmployeesServices", () => ({ + __esModule: true, + default: { + deleteEmployeeServiceByEmployee: jest.fn(), + }, +})); + +jest.mock("../../HeatMap/HeatMap", () => ({ + __esModule: true, + default: { + deleteHeatMapByEmployee: jest.fn(), + }, +})); + +jest.mock("../../Repeats/Repeats", () => ({ + __esModule: true, + default: { + repeats: { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../Schedules/Schedules", () => ({ + __esModule: true, + default: { + find: jest.fn().mockResolvedValue([]), + }, +})); + +jest.mock("../../Services/Service", () => ({ + __esModule: true, + default: { + services: { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../../index", () => ({ + io: { + to: jest.fn(() => ({ emit: jest.fn() })), + }, +})); + +import EmployeesList from "../Employee"; +import UsersManager from "../../Users/Users"; + +describe("Query filtering — removed employees excluded", () => { + let originalAdapter: any; + let mockFind: jest.Mock; + let mockFindOne: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + originalAdapter = EmployeesList.employees; + + mockFind = jest.fn(); + mockFindOne = jest.fn(); + + (EmployeesList as any).employees = { + findOne: mockFindOne, + find: mockFind, + create: jest.fn(), + employeeList: { + findOne: mockFindOne, + find: mockFind, + create: jest.fn(), + deleteMany: jest.fn(), + }, + }; + }); + + afterEach(() => { + (EmployeesList as any).employees = originalAdapter; + }); + + // --- findByCompanyId excludes removed --- + + describe("findByCompanyId", () => { + it("excludes removed employees from results", async () => { + const activeEmployee = { + _id: "emp-active", + id: "emp-active", + companyId: "company-001", + userId: "user-active", + roles: [EmployeeRoles.EMPLOYEE], + guestOk: true, + hostOk: true, + calendarColor: "#ff9900", + removed: false, + }; + + // findByCompanyId calls find with removed: { $ne: true } + // The adapter find should return only non-removed employees + mockFind.mockResolvedValue([activeEmployee]); + + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ + _id: "user-active", + firstName: "Active", + lastName: "User", + email: "active@example.com", + avatar: "", + }); + + const result = await EmployeesList.findByCompanyId({ + companyId: "company-001", + }); + + // Verify the query included the removed filter + expect(mockFind).toHaveBeenCalledWith( + expect.objectContaining({ + companyId: "company-001", + removed: { $ne: true }, + }) + ); + + // Verify only the active employee is in the result + expect(result).toHaveLength(1); + expect(result[0].id).toBe("emp-active"); + }); + + it("returns empty array when all employees are removed", async () => { + // Adapter returns empty because filter excluded all removed + mockFind.mockResolvedValue([]); + + const result = await EmployeesList.findByCompanyId({ + companyId: "company-001", + }); + + expect(result).toEqual([]); + expect(mockFind).toHaveBeenCalledWith( + expect.objectContaining({ + removed: { $ne: true }, + }) + ); + }); + }); + + // --- textObjectFilter excludes removed --- + + describe("textObjectFilter", () => { + it("excludes removed employees from results", async () => { + const activeEmployee = { + _id: "emp-active", + id: "emp-active", + companyId: "company-001", + userId: "user-active", + removed: false, + }; + + // Only active employees returned after filter + mockFind.mockResolvedValue([activeEmployee]); + + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ + _id: "user-active", + firstName: "John", + lastName: "Doe", + avatar: "", + }); + + const result = await EmployeesList.textObjectFilter({ + companyId: "company-001", + }); + + expect(mockFind).toHaveBeenCalledWith( + expect.objectContaining({ + removed: { $ne: true }, + }) + ); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("John Doe"); + }); + + it("returns empty when only removed employees exist", async () => { + mockFind.mockResolvedValue([]); + + const result = await EmployeesList.textObjectFilter({ + companyId: "company-001", + }); + + // Verify the removed filter was applied to the query + expect(mockFind).toHaveBeenCalledWith( + expect.objectContaining({ + removed: { $ne: true }, + }) + ); + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/server/src/Models/Employees/__tests__/Employee.reactivation.test.ts b/server/src/Models/Employees/__tests__/Employee.reactivation.test.ts new file mode 100644 index 0000000..a601d60 --- /dev/null +++ b/server/src/Models/Employees/__tests__/Employee.reactivation.test.ts @@ -0,0 +1,343 @@ +import { EmployeeRoles } from "../Employees.Interface"; + +// Mock all external dependencies before importing the module under test +jest.mock("../../Users/Users", () => ({ + __esModule: true, + default: { + users: { + findOne: jest.fn(), + }, + getUserFullName: jest.fn(), + }, +})); + +jest.mock("../../Companies/Companies", () => ({ + __esModule: true, + default: { + companies: { + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../Metrics/Metrics", () => ({ + __esModule: true, + default: { + addEmployee: jest.fn(), + canAddEmployee: jest.fn(), + }, +})); + +jest.mock("../../Notifications/Notifications", () => ({ + __esModule: true, + NotificationsManager: { + sendSystemNotification: jest.fn(), + }, +})); + +jest.mock("../../EmployeesServices/EmployeesServices", () => ({ + __esModule: true, + default: { + deleteEmployeeServiceByEmployee: jest.fn(), + }, +})); + +jest.mock("../../HeatMap/HeatMap", () => ({ + __esModule: true, + default: { + deleteHeatMapByEmployee: jest.fn(), + }, +})); + +jest.mock("../../Repeats/Repeats", () => ({ + __esModule: true, + default: { + repeats: { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../../index", () => ({ + io: { + to: jest.fn(() => ({ emit: jest.fn() })), + }, +})); + +import EmployeesList from "../Employee"; +import UsersManager from "../../Users/Users"; +import CompaniesManager from "../../Companies/Companies"; +import MetricsList from "../../Metrics/Metrics"; + +describe("createEmployee — reactivation of removed collaborator", () => { + let originalAdapter: any; + let mockFindOne: jest.Mock; + let mockCreate: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + originalAdapter = EmployeesList.employees; + + mockFindOne = jest.fn(); + mockCreate = jest.fn(); + + (EmployeesList as any).employees = { + findOne: mockFindOne, + find: jest.fn(), + create: mockCreate, + employeeList: { + findOne: mockFindOne, + find: jest.fn(), + create: mockCreate, + }, + }; + }); + + afterEach(() => { + (EmployeesList as any).employees = originalAdapter; + }); + + /** + * Call order in createEmployee: + * 1. UsersManager.users.findOne → session user + * 2. CompaniesManager.companies.findOne → company + * 3. checkPermission → checkRoleById → this.employees.findOne → admin employee record + * 4. UsersManager.users.findOne → user being invited + * 5. this.employees.findOne → existing employee lookup by (companyId, userId) + */ + it("reactivates a removed employee instead of creating a duplicate", async () => { + const adminEmployee = { + _id: "emp-admin", + companyId: "company-001", + userId: "admin-user", + roles: [EmployeeRoles.ADMIN], + removed: false, + }; + + const removedEmployee = { + _id: "emp-removed", + id: "emp-removed", + companyId: "company-001", + userId: "user-002", + roles: [EmployeeRoles.EMPLOYEE], + removed: true, + removedAt: new Date(), + removedBy: "admin-user", + hostOk: true, + guestOk: true, + profileSnapshot: { firstName: "Old", lastName: "Name" }, + save: jest.fn().mockResolvedValue(true), + }; + + const userToInvite = { + _id: "user-002", + firstName: "John", + lastName: "Doe", + email: "john@example.com", + avatar: "new-avatar", + }; + + // Call 1: session user → Call 2: user being invited + (UsersManager.users.findOne as jest.Mock) + .mockResolvedValueOnce({ _id: "admin-user" }) + .mockResolvedValueOnce(userToInvite); + + // CompaniesManager.companies.findOne → company + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + name: "Test Company", + }); + + // this.employees.findOne: + // Call 1: checkRoleById → admin employee record + // Call 2: existing employee lookup → found + removed + mockFindOne + .mockResolvedValueOnce(adminEmployee) + .mockResolvedValueOnce(removedEmployee); + + (MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(true); + + const result = await EmployeesList.createEmployee({ + companyId: "company-001", + userId: "user-002", + sessionUser: "admin-user", + }); + + // Verify employee was reactivated + expect(removedEmployee.removed).toBe(false); + expect(removedEmployee.removedAt).toBeUndefined(); + expect(removedEmployee.removedBy).toBeUndefined(); + + // Verify profileSnapshot was refreshed from current User + expect(removedEmployee.profileSnapshot).toEqual({ + firstName: "John", + lastName: "Doe", + email: "john@example.com", + avatar: "new-avatar", + }); + + // Verify invitation state was reset + expect(removedEmployee.hostOk).toBe(false); + expect(removedEmployee.guestOk).toBe(false); + + // Verify save was called + expect(removedEmployee.save).toHaveBeenCalled(); + + // Verify metrics were incremented + expect(MetricsList.addEmployee).toHaveBeenCalledWith({ + userId: "owner-user", + quantity: 1, + }); + + // Verify no new employee was created + expect(mockCreate).not.toHaveBeenCalled(); + + // Verify same employee returned + expect(result.id).toBe("emp-removed"); + }); + + it("throws quota error when reactivation would exceed plan limit", async () => { + const adminEmployee = { + _id: "emp-admin", + companyId: "company-001", + userId: "admin-user", + roles: [EmployeeRoles.ADMIN], + removed: false, + }; + + const removedEmployee = { + _id: "emp-removed", + companyId: "company-001", + userId: "user-002", + removed: true, + save: jest.fn(), + }; + + // Call 1: session user → Call 2: user being invited + (UsersManager.users.findOne as jest.Mock) + .mockResolvedValueOnce({ _id: "admin-user" }) + .mockResolvedValueOnce({ _id: "user-002" }); + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + // this.employees.findOne: admin check → removed employee lookup + mockFindOne + .mockResolvedValueOnce(adminEmployee) + .mockResolvedValueOnce(removedEmployee); + + // Quota is full + (MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(false); + + await expect( + EmployeesList.createEmployee({ + companyId: "company-001", + userId: "user-002", + sessionUser: "admin-user", + }) + ).rejects.toThrow("limite de colaboradores"); + + // Verify employee was NOT reactivated + expect(removedEmployee.save).not.toHaveBeenCalled(); + }); + + it("throws 'El usuario no existe' when reinviting a removed employee whose User record was deleted", async () => { + // Scenario: Employee record exists and is removed, but the underlying User + // was deleted (e.g. admin cleanup). createEmployee must reject the invitation + // BEFORE attempting reactivation because User validation gates the entire flow. + const removedEmployee = { + _id: "emp-removed", + id: "emp-removed", + companyId: "company-001", + userId: "user-deleted", + removed: true, + save: jest.fn().mockResolvedValue(true), + }; + + // Call 1: session user (owner, so checkPermission short-circuits) + (UsersManager.users.findOne as jest.Mock) + .mockResolvedValueOnce({ _id: "owner-user" }) + .mockResolvedValueOnce(null); // invited user was DELETED + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + // checkPermission short-circuits for owner — no employees.findOne needed. + // But createEmployee still calls employees.findOne AFTER userCheck. + // Since userCheck is null, the method should throw before reaching employees.findOne. + await expect( + EmployeesList.createEmployee({ + companyId: "company-001", + userId: "user-deleted", + sessionUser: "owner-user", + }) + ).rejects.toThrow("El usuario no existe"); + + // Critical: reactivation must NOT have been attempted + expect(removedEmployee.save).not.toHaveBeenCalled(); + expect(MetricsList.addEmployee).not.toHaveBeenCalled(); + }); + + it("preserves same employeeId across removal and reactivation", async () => { + const adminEmployee = { + _id: "emp-admin", + companyId: "company-001", + userId: "admin-user", + roles: [EmployeeRoles.ADMIN], + removed: false, + }; + + const removedEmployee = { + _id: "emp-original-id", + id: "emp-original-id", + companyId: "company-001", + userId: "user-002", + removed: true, + save: jest.fn().mockResolvedValue(true), + }; + + const userToInvite = { + _id: "user-002", + firstName: "Jane", + lastName: "Smith", + email: "jane@example.com", + avatar: "", + }; + + // Call 1: session user → Call 2: user being invited + (UsersManager.users.findOne as jest.Mock) + .mockResolvedValueOnce({ _id: "admin-user" }) + .mockResolvedValueOnce(userToInvite); + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + // this.employees.findOne: admin check → removed employee lookup + mockFindOne + .mockResolvedValueOnce(adminEmployee) + .mockResolvedValueOnce(removedEmployee); + + (MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(true); + + const result = await EmployeesList.createEmployee({ + companyId: "company-001", + userId: "user-002", + sessionUser: "admin-user", + }); + + // Same _id preserved + expect(result.id).toBe("emp-original-id"); + + // No new employee created + expect(mockCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/Models/Employees/__tests__/Employee.removeCollaborator.test.ts b/server/src/Models/Employees/__tests__/Employee.removeCollaborator.test.ts new file mode 100644 index 0000000..ad46603 --- /dev/null +++ b/server/src/Models/Employees/__tests__/Employee.removeCollaborator.test.ts @@ -0,0 +1,632 @@ +import { EmployeeRoles } from "../Employees.Interface"; + +// Mock all external dependencies before importing the module under test +jest.mock("../../Users/Users", () => ({ + __esModule: true, + default: { + users: { + findOne: jest.fn(), + }, + getUserFullName: jest.fn(), + }, +})); + +jest.mock("../../Companies/Companies", () => ({ + __esModule: true, + default: { + companies: { + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../Metrics/Metrics", () => ({ + __esModule: true, + default: { + addEmployee: jest.fn(), + canAddEmployee: jest.fn(), + }, +})); + +jest.mock("../../Notifications/Notifications", () => ({ + __esModule: true, + NotificationsManager: { + sendSystemNotification: jest.fn(), + }, +})); + +jest.mock("../../EmployeesServices/EmployeesServices", () => ({ + __esModule: true, + default: { + deleteEmployeeServiceByEmployee: jest.fn(), + }, +})); + +jest.mock("../../HeatMap/HeatMap", () => ({ + __esModule: true, + default: { + deleteHeatMapByEmployee: jest.fn(), + }, +})); + +jest.mock("../../Repeats/Repeats", () => ({ + __esModule: true, + default: { + repeats: { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../Appointments/Appointments", () => ({ + __esModule: true, + default: { + Appointments: { + AppointmentList: { + find: jest.fn().mockResolvedValue([]), + }, + }, + }, +})); + +jest.mock("../../../index", () => ({ + io: { + to: jest.fn(() => ({ emit: jest.fn() })), + }, +})); + +import EmployeesList from "../Employee"; +import UsersManager from "../../Users/Users"; +import CompaniesManager from "../../Companies/Companies"; +import MetricsList from "../../Metrics/Metrics"; +import EmployeesServicesList from "../../EmployeesServices/EmployeesServices"; +import HeatMapList from "../../HeatMap/HeatMap"; +import AppointmentList from "../../Appointments/Appointments"; + +// Helper to build a mock employee document with save() +function buildEmployeeDoc(overrides: Record = {}) { + const doc = { + _id: "emp-001", + id: "emp-001", + companyId: "company-001", + userId: "user-002", + roles: [EmployeeRoles.EMPLOYEE], + calendarColor: "#ff9900", + hostOk: true, + guestOk: true, + removed: false, + removedAt: undefined, + removedBy: undefined, + profileSnapshot: undefined, + save: jest.fn().mockResolvedValue(true), + ...overrides, + }; + return doc; +} + +describe("removeCollaboratorFromCompany", () => { + let originalAdapter: any; + let mockFindOne: jest.Mock; + let mockAppointmentFind: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Save original adapter reference + originalAdapter = EmployeesList.employees; + + // Create mock adapter + mockFindOne = jest.fn(); + const mockFind = jest.fn(); + const mockCreate = jest.fn(); + const mockDeleteMany = jest.fn(); + + (EmployeesList as any).employees = { + findOne: mockFindOne, + find: mockFind, + create: mockCreate, + employeeList: { + findOne: mockFindOne, + find: mockFind, + deleteMany: mockDeleteMany, + create: mockCreate, + }, + }; + + mockAppointmentFind = (AppointmentList.Appointments.AppointmentList.find as jest.Mock).mockResolvedValue([]); + }); + + afterEach(() => { + // Restore original adapter + (EmployeesList as any).employees = originalAdapter; + }); + + // --- Session User Validation --- + + it("throws when session user does not exist", async () => { + (UsersManager.users.findOne as jest.Mock).mockResolvedValue(null); + + await expect( + EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-001", + companyId: "company-001", + sessionUser: "nonexistent-user", + }) + ).rejects.toThrow("No se ha encontrado el usuario"); + }); + + // --- Company Validation --- + + it("throws when company does not exist", async () => { + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" }); + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue(null); + + await expect( + EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-001", + companyId: "nonexistent-company", + sessionUser: "admin-user", + }) + ).rejects.toThrow("La compañia no existe"); + }); + + // --- Employee Not Found --- + + it("throws when target employee does not exist", async () => { + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" }); + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + mockFindOne.mockResolvedValueOnce(null); + + await expect( + EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-nonexistent", + companyId: "company-001", + sessionUser: "admin-user", + }) + ).rejects.toThrow("El colaborador no existe"); + }); + + // --- Already Removed --- + + it("throws when employee is already removed", async () => { + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" }); + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + mockFindOne.mockResolvedValueOnce(buildEmployeeDoc({ removed: true })); + + await expect( + EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-001", + companyId: "company-001", + sessionUser: "admin-user", + }) + ).rejects.toThrow("El colaborador ya fue eliminado"); + }); + + // --- Permission Check --- + + it("throws when non-admin attempts removal", async () => { + const regularEmployee = buildEmployeeDoc({ + _id: "emp-regular", + userId: "regular-user", + roles: [EmployeeRoles.EMPLOYEE], + }); + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "regular-user" }); + + mockFindOne + .mockResolvedValueOnce(buildEmployeeDoc({ userId: "target-user" })) // target + .mockResolvedValueOnce(regularEmployee); // session user's employee + + await expect( + EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-001", + companyId: "company-001", + sessionUser: "regular-user", + }) + ).rejects.toThrow(/No tienes permisos/); + }); + + // --- Self-Removal Prevention --- + + it("throws when admin tries to remove themselves", async () => { + const adminEmployee = buildEmployeeDoc({ + _id: "emp-admin", + userId: "admin-user", + roles: [EmployeeRoles.ADMIN], + }); + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" }); + + // First findOne: target employee (the admin themselves) + // Second findOne: session user's employee record + mockFindOne + .mockResolvedValueOnce(adminEmployee) + .mockResolvedValueOnce(adminEmployee); + + await expect( + EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-admin", + companyId: "company-001", + sessionUser: "admin-user", + }) + ).rejects.toThrow("No puedes eliminarte a ti mismo"); + }); + + // --- Owner Protection --- + + it("throws when admin tries to remove the owner", async () => { + const ownerEmployee = buildEmployeeDoc({ + _id: "emp-owner", + userId: "owner-user", + roles: [EmployeeRoles.OWNER], + }); + + const adminEmployee = buildEmployeeDoc({ + _id: "emp-admin", + userId: "admin-user", + roles: [EmployeeRoles.ADMIN], + }); + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" }); + + mockFindOne + .mockResolvedValueOnce(ownerEmployee) + .mockResolvedValueOnce(adminEmployee); + + await expect( + EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-owner", + companyId: "company-001", + sessionUser: "admin-user", + }) + ).rejects.toThrow("No se puede eliminar al propietario"); + }); + + // --- Happy Path: No Future Appointments --- + + it("successfully removes employee with no future appointments", async () => { + const targetEmployee = buildEmployeeDoc({ + userId: "target-user", + removed: false, + }); + + const adminEmployee = buildEmployeeDoc({ + userId: "admin-user", + roles: [EmployeeRoles.ADMIN], + }); + + const targetUser = { + _id: "target-user", + firstName: "John", + lastName: "Doe", + email: "john@example.com", + avatar: "avatar-url", + }; + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + // First call: session user validation + // Second call: target user lookup (for snapshot) + (UsersManager.users.findOne as jest.Mock) + .mockResolvedValueOnce({ _id: "admin-user" }) + .mockResolvedValueOnce(targetUser); + + mockFindOne + .mockResolvedValueOnce(targetEmployee) // target + .mockResolvedValueOnce(adminEmployee); // session user + + mockAppointmentFind.mockResolvedValue([]); + + await EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-001", + companyId: "company-001", + sessionUser: "admin-user", + }); + + expect(targetEmployee.save).toHaveBeenCalled(); + expect(targetEmployee.removed).toBe(true); + expect(targetEmployee.removedBy).toBe("admin-user"); + expect(targetEmployee.removedAt).toBeDefined(); + expect(targetEmployee.profileSnapshot).toEqual({ + firstName: "John", + lastName: "Doe", + email: "john@example.com", + avatar: "avatar-url", + }); + expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({ + employeeId: "emp-001", + }); + expect( + HeatMapList.deleteHeatMapByEmployee + ).toHaveBeenCalledWith({ + employeeId: "emp-001", + }); + expect(MetricsList.addEmployee).toHaveBeenCalledWith({ + userId: "owner-user", + quantity: -1, + }); + }); + + // --- Owner Can Remove (via company.ownerId check) --- + + it("owner can remove a collaborator", async () => { + const targetEmployee = buildEmployeeDoc({ + userId: "target-user", + }); + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + (UsersManager.users.findOne as jest.Mock) + .mockResolvedValueOnce({ _id: "owner-user" }) // session user + .mockResolvedValueOnce({ + _id: "target-user", + firstName: "Jane", + lastName: "Smith", + email: "jane@example.com", + avatar: "", + }); + + mockFindOne + .mockResolvedValueOnce(targetEmployee) + .mockResolvedValueOnce(null); // owner has no employee record + + mockAppointmentFind.mockResolvedValue([]); + + await EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-001", + companyId: "company-001", + sessionUser: "owner-user", + }); + + expect(targetEmployee.removed).toBe(true); + expect(targetEmployee.removedBy).toBe("owner-user"); + expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({ + employeeId: "emp-001", + }); + expect(HeatMapList.deleteHeatMapByEmployee).toHaveBeenCalledWith({ + employeeId: "emp-001", + }); + }); + + // --- Admin with future appointments but no replacement → error --- + + it("throws when employee has future appointments but no replacement provided", async () => { + const targetEmployee = buildEmployeeDoc({ + userId: "target-user", + }); + + const adminEmployee = buildEmployeeDoc({ + userId: "admin-user", + roles: [EmployeeRoles.ADMIN], + }); + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" }); + + mockFindOne + .mockResolvedValueOnce(targetEmployee) + .mockResolvedValueOnce(adminEmployee); + + // Simulate future appointments exist + mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]); + + await expect( + EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-001", + companyId: "company-001", + sessionUser: "admin-user", + }) + ).rejects.toThrow("turnos futuros asignados"); + }); + + // --- Replacement from different company → error --- + + it("throws when replacement employee belongs to a different company", async () => { + const targetEmployee = buildEmployeeDoc({ + userId: "target-user", + }); + + const adminEmployee = buildEmployeeDoc({ + userId: "admin-user", + roles: [EmployeeRoles.ADMIN], + }); + + const replacementFromOtherCompany = buildEmployeeDoc({ + _id: "emp-replacement", + companyId: "other-company", + userId: "replacement-user", + removed: false, + }); + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" }); + + mockFindOne + .mockResolvedValueOnce(targetEmployee) + .mockResolvedValueOnce(adminEmployee) + .mockResolvedValueOnce(replacementFromOtherCompany); + + mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]); + + await expect( + EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-001", + companyId: "company-001", + replacementEmployeeId: "emp-replacement", + sessionUser: "admin-user", + }) + ).rejects.toThrow("no pertenece a esta organización"); + }); + + // --- Replacement is self → error --- + + it("throws when admin tries to designate themselves as replacement", async () => { + const targetEmployee = buildEmployeeDoc({ + userId: "target-user", + }); + + const adminEmployee = buildEmployeeDoc({ + _id: "emp-admin", + userId: "admin-user", + roles: [EmployeeRoles.ADMIN], + }); + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" }); + + mockFindOne + .mockResolvedValueOnce(targetEmployee) + .mockResolvedValueOnce(adminEmployee) + .mockResolvedValueOnce(adminEmployee); // replacement is the admin themselves + + mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]); + + await expect( + EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-001", + companyId: "company-001", + replacementEmployeeId: "emp-admin", + sessionUser: "admin-user", + }) + ).rejects.toThrow("designarte como reemplazo"); + }); + + // --- Replacement does not exist → error --- + + it("throws when replacement employee does not exist", async () => { + const targetEmployee = buildEmployeeDoc({ + userId: "target-user", + }); + + const adminEmployee = buildEmployeeDoc({ + userId: "admin-user", + roles: [EmployeeRoles.ADMIN], + }); + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + (UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" }); + + mockFindOne + .mockResolvedValueOnce(targetEmployee) + .mockResolvedValueOnce(adminEmployee) + .mockResolvedValueOnce(null); // replacement not found + + mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]); + + await expect( + EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-001", + companyId: "company-001", + replacementEmployeeId: "emp-nonexistent", + sessionUser: "admin-user", + }) + ).rejects.toThrow("empleado de reemplazo no existe"); + }); + + // --- Removal with replacement proceeds when future appointments exist --- + + it("successfully removes employee with replacement when future appointments exist", async () => { + const targetEmployee = buildEmployeeDoc({ + userId: "target-user", + }); + + const adminEmployee = buildEmployeeDoc({ + userId: "admin-user", + roles: [EmployeeRoles.ADMIN], + }); + + const replacementEmployee = buildEmployeeDoc({ + _id: "emp-replacement", + companyId: "company-001", + userId: "replacement-user", + removed: false, + }); + + const targetUser = { + _id: "target-user", + firstName: "John", + lastName: "Doe", + email: "john@example.com", + avatar: "avatar-url", + }; + + (CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({ + _id: "company-001", + ownerId: "owner-user", + }); + + (UsersManager.users.findOne as jest.Mock) + .mockResolvedValueOnce({ _id: "admin-user" }) + .mockResolvedValueOnce(targetUser); + + mockFindOne + .mockResolvedValueOnce(targetEmployee) + .mockResolvedValueOnce(adminEmployee) + .mockResolvedValueOnce(replacementEmployee); + + // Future appointments exist + const mockAppointment = { employeeId: "emp-001", save: jest.fn().mockResolvedValue(true) }; + mockAppointmentFind.mockResolvedValue([mockAppointment]); + + await EmployeesList.removeCollaboratorFromCompany({ + employeeId: "emp-001", + companyId: "company-001", + replacementEmployeeId: "emp-replacement", + sessionUser: "admin-user", + }); + + expect(targetEmployee.removed).toBe(true); + expect(mockAppointment.employeeId).toBe("emp-replacement"); + expect(mockAppointment.save).toHaveBeenCalled(); + expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({ + employeeId: "emp-001", + }); + expect(HeatMapList.deleteHeatMapByEmployee).toHaveBeenCalledWith({ + employeeId: "emp-001", + }); + }); +}); diff --git a/server/src/Models/EmployeesServices/EmployeesServices.ts b/server/src/Models/EmployeesServices/EmployeesServices.ts index 9255523..f12dcf6 100644 --- a/server/src/Models/EmployeesServices/EmployeesServices.ts +++ b/server/src/Models/EmployeesServices/EmployeesServices.ts @@ -94,7 +94,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager { }); if (!employeeCheck) { - throw new Error("El empleado no existe o no pertenece a la compañia."); + throw new Error("El empleado no existe o no pertenece a la compañia."); + } + + if (employeeCheck.removed) { + throw new Error("El empleado no existe o no pertenece a la compañia."); } const employeeServiceCheck = await this.employeesServices.findOne({ @@ -160,7 +164,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager { }); if (!employeeCheck) { - throw new Error("El empleado no existe o no pertenece a la compañia."); + throw new Error("El empleado no existe o no pertenece a la compañia."); + } + + if (employeeCheck.removed) { + throw new Error("El empleado no existe o no pertenece a la compañia."); } await this.employeesServices.update(data); @@ -253,7 +261,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager { }); if (!employeeCheck) { - throw new Error("El empleado no existe o no pertenece a la compañia."); + throw new Error("El empleado no existe o no pertenece a la compañia."); + } + + if (employeeCheck.removed) { + throw new Error("El empleado no existe o no pertenece a la compañia."); } const userCheck = await UsersManager.users.findOne({ @@ -330,7 +342,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager { _id: data.employeeId, }); if (!employeeCheck) { - throw new Error("El empleado no existe o no pertenece a la compañia."); + throw new Error("El empleado no existe o no pertenece a la compañia."); + } + + if (employeeCheck.removed) { + throw new Error("El empleado no existe o no pertenece a la compañia."); } const serviceCheck = await ServicesManager.services.findOne({ @@ -371,7 +387,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager { }); if (!employeeCheck) { - throw new Error("El empleado no existe o no pertenece a la compañia."); + throw new Error("El empleado no existe o no pertenece a la compañia."); + } + + if (employeeCheck.removed) { + throw new Error("El empleado no existe o no pertenece a la compañia."); } const userCheck = await UsersManager.users.findOne({ @@ -416,6 +436,15 @@ class EmployeesServicesManager implements IEmployeesServicesManager { }); for (const service of servicesByEmployee) { + const employeeDoc = await EmployeesList.employees.findOne({ + _id: service.employeeId, + }); + + // Skip removed employees from public-facing results + if (!employeeDoc || employeeDoc.removed) { + continue; + } + const employeeData = await EmployeesList.findById({ id: service.employeeId }); returnData.push({ diff --git a/server/src/Models/EmployeesServices/__tests__/EmployeesServices.findByService.test.ts b/server/src/Models/EmployeesServices/__tests__/EmployeesServices.findByService.test.ts new file mode 100644 index 0000000..67423d2 --- /dev/null +++ b/server/src/Models/EmployeesServices/__tests__/EmployeesServices.findByService.test.ts @@ -0,0 +1,318 @@ +// Mock dependencies +jest.mock("../../Users/Users", () => ({ + __esModule: true, + default: { + users: { + findOne: jest.fn(), + }, + getUserFullName: jest.fn(), + }, +})); + +jest.mock("../../Companies/Companies", () => ({ + __esModule: true, + default: { + companies: { + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../Services/Service", () => ({ + __esModule: true, + default: { + services: { + find: jest.fn(), + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../Notifications/Notifications", () => ({ + __esModule: true, + NotificationsManager: { + sendSystemNotification: jest.fn(), + }, +})); + +jest.mock("../../Employees/Employee", () => { + const mockFindOne = jest.fn(); + const mockFind = jest.fn(); + const mockFindById = jest.fn(); + return { + __esModule: true, + default: { + employees: { + findOne: mockFindOne, + find: mockFind, + employeeList: { + findOne: mockFindOne, + find: mockFind, + }, + }, + findById: mockFindById, + checkPermission: jest.fn().mockResolvedValue(true), + _mocks: { findOne: mockFindOne, find: mockFind, findById: mockFindById }, + }, + }; +}); + +import EmployeesServicesList from "../EmployeesServices"; +import EmployeesList from "../../Employees/Employee"; + +describe("EmployeesServices.findByService — removed employee exclusion", () => { + let mockEmployeesFindOne: jest.Mock; + let mockEmployeesFindById: jest.Mock; + let originalEmployeesServicesAdapter: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockEmployeesFindOne = (EmployeesList as any)._mocks.findOne; + mockEmployeesFindById = (EmployeesList as any)._mocks.findById; + + // Save and replace the employeesServices adapter + originalEmployeesServicesAdapter = EmployeesServicesList.employeesServices; + + const mockESFindOne = jest.fn(); + const mockESFind = jest.fn(); + (EmployeesServicesList as any).employeesServices = { + findOne: mockESFindOne, + find: mockESFind, + create: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + paginate: jest.fn(), + employeesServicesList: { + findOne: mockESFindOne, + find: mockESFind, + deleteMany: jest.fn(), + }, + }; + }); + + afterEach(() => { + (EmployeesServicesList as any).employeesServices = originalEmployeesServicesAdapter; + }); + + it("excludes removed employees from findByService results", async () => { + // Service assignments include one for an active employee and one for a removed employee + const activeServiceAssignment = { + _id: "es-001", + employeeId: "emp-active", + serviceId: "svc-001", + companyId: "company-001", + }; + + const removedServiceAssignment = { + _id: "es-002", + employeeId: "emp-removed", + serviceId: "svc-001", + companyId: "company-001", + }; + + const activeEmployeeDoc = { + _id: "emp-active", + id: "emp-active", + companyId: "company-001", + userId: "user-active", + removed: false, + }; + + const removedEmployeeDoc = { + _id: "emp-removed", + id: "emp-removed", + companyId: "company-001", + userId: "user-removed", + removed: true, + }; + + // employeesServices.find returns both assignments + const mockESFind = (EmployeesServicesList as any).employeesServices.find; + mockESFind.mockResolvedValue([activeServiceAssignment, removedServiceAssignment]); + + // EmployeesList.employees.findOne returns the employee docs + mockEmployeesFindOne + .mockResolvedValueOnce(activeEmployeeDoc) + .mockResolvedValueOnce(removedEmployeeDoc); + + // EmployeesList.findById for the active employee + mockEmployeesFindById.mockResolvedValue({ + id: "emp-active", + fullName: "Active User", + avatar: "", + }); + + const result = await EmployeesServicesList.findByService({ + serviceId: "svc-001", + }); + + // Only the active employee should appear in results + expect(result).toHaveLength(1); + expect(result[0].id).toBe("emp-active"); + expect(result[0].name).toBe("Active User"); + }); + + it("returns empty when all assigned employees are removed", async () => { + const removedServiceAssignment = { + _id: "es-001", + employeeId: "emp-removed", + serviceId: "svc-001", + companyId: "company-001", + }; + + const removedEmployeeDoc = { + _id: "emp-removed", + id: "emp-removed", + companyId: "company-001", + userId: "user-removed", + removed: true, + }; + + const mockESFind = (EmployeesServicesList as any).employeesServices.find; + mockESFind.mockResolvedValue([removedServiceAssignment]); + + mockEmployeesFindOne.mockResolvedValueOnce(removedEmployeeDoc); + + const result = await EmployeesServicesList.findByService({ + serviceId: "svc-001", + }); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + // Prove the guard stopped processing: employee was found but removed, + // so findById should NEVER have been called for enrichment. + expect((EmployeesList as any)._mocks.findById).not.toHaveBeenCalled(); + }); + + it("excludes employees whose Employee document is null (hard-deleted User)", async () => { + // Scenario: service assignment exists but the Employee record was hard-deleted + // (e.g. cascade cleanup). findByService must skip gracefully. + const activeServiceAssignment = { + _id: "es-001", + employeeId: "emp-active", + serviceId: "svc-001", + companyId: "company-001", + }; + + const orphanServiceAssignment = { + _id: "es-002", + employeeId: "emp-orphan", + serviceId: "svc-001", + companyId: "company-001", + }; + + const activeEmployeeDoc = { + _id: "emp-active", + id: "emp-active", + companyId: "company-001", + userId: "user-active", + removed: false, + }; + + const mockESFind = (EmployeesServicesList as any).employeesServices.find; + mockESFind.mockResolvedValue([activeServiceAssignment, orphanServiceAssignment]); + + // First findOne: active employee → found; second: orphan → null (deleted) + mockEmployeesFindOne + .mockResolvedValueOnce(activeEmployeeDoc) + .mockResolvedValueOnce(null); + + mockEmployeesFindById.mockResolvedValue({ + id: "emp-active", + fullName: "Active User", + avatar: "", + }); + + const result = await EmployeesServicesList.findByService({ + serviceId: "svc-001", + }); + + // Only the active employee should appear + expect(result).toHaveLength(1); + expect(result[0].id).toBe("emp-active"); + // findById was called once for the active employee only + expect(mockEmployeesFindById).toHaveBeenCalledTimes(1); + expect(mockEmployeesFindById).toHaveBeenCalledWith({ id: "emp-active" }); + }); + + it("does not call findById for removed employees, only for active ones", async () => { + // Proves the continue guard prevents enrichment calls for removed employees. + const activeAssignment = { + _id: "es-001", + employeeId: "emp-active", + serviceId: "svc-001", + companyId: "company-001", + }; + + const removedAssignment = { + _id: "es-002", + employeeId: "emp-removed", + serviceId: "svc-001", + companyId: "company-001", + }; + + const activeDoc = { + _id: "emp-active", + id: "emp-active", + removed: false, + }; + + const removedDoc = { + _id: "emp-removed", + id: "emp-removed", + removed: true, + }; + + const mockESFind = (EmployeesServicesList as any).employeesServices.find; + mockESFind.mockResolvedValue([activeAssignment, removedAssignment]); + + mockEmployeesFindOne + .mockResolvedValueOnce(activeDoc) + .mockResolvedValueOnce(removedDoc); + + mockEmployeesFindById.mockResolvedValue({ + id: "emp-active", + fullName: "Active User", + avatar: "", + }); + + const result = await EmployeesServicesList.findByService({ + serviceId: "svc-001", + }); + + // findById called ONCE (active only), never for the removed employee + expect(mockEmployeesFindById).toHaveBeenCalledTimes(1); + expect(mockEmployeesFindById).toHaveBeenCalledWith({ id: "emp-active" }); + expect(result).toHaveLength(1); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Public Booking Path — Limitation Documentation +// ───────────────────────────────────────────────────────────────────────────── +// +// The actual public booking flow for collaborator resolution is: +// +// Frontend (Appointment.tsx) +// → POST /employees/employees-by-service +// → FindEmployeesByServiceController.findEmployeesByService() +// → new EmployeesService().findByService(requestBody) [thin wrapper: connect() + delegate] +// → EmployeesServicesList.findByService(data) [model — tested above] +// +// The model layer (EmployeesServicesManager.findByService) is the GATEKEEPER +// that applies the `removed` guard. The controller and service classes are thin +// pass-through wrappers with no additional filtering logic. +// +// Full-path integration testing (controller → service → model → DB) is not +// practically unit-testable because: +// 1. The service layer calls mongoose.connect() which requires a real DB +// 2. The controller instantiates the service class internally +// 3. 5+ collections would need seeding (Employees, EmployeesServices, Users, Companies) +// +// The model-level tests above provide the STRONGEST feasible coverage because: +// - They exercise the exact `removed` guard logic (line 444 of EmployeesServices.ts) +// - They prove findById enrichment is skipped for removed/null employees +// - They prove the correct filter is applied to service assignment queries +// - The controller/service layers add no filtering — they are pure delegation +// ───────────────────────────────────────────────────────────────────────────── diff --git a/server/src/Models/Metrics/Metrics.ts b/server/src/Models/Metrics/Metrics.ts index 1f0c52c..c27031c 100644 --- a/server/src/Models/Metrics/Metrics.ts +++ b/server/src/Models/Metrics/Metrics.ts @@ -40,7 +40,8 @@ class MetricsManager implements IMetricsManager { for (const org of organizations) { const employees = await EmployeesList.employees.find({ companyId: String(org.id), - }); + removed: { $ne: true }, + } as any); employeesLength += employees.length; const services = await ServiceList.services.find({ diff --git a/server/src/Models/Metrics/__tests__/Metrics.calculateMetrics.test.ts b/server/src/Models/Metrics/__tests__/Metrics.calculateMetrics.test.ts new file mode 100644 index 0000000..397b46c --- /dev/null +++ b/server/src/Models/Metrics/__tests__/Metrics.calculateMetrics.test.ts @@ -0,0 +1,220 @@ +/** + * Tests for Metrics.calculateMetrics — excludes removed employees from count. + */ + +jest.mock("../../Companies/Companies", () => ({ + __esModule: true, + default: { + companies: { + find: jest.fn(), + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../Employees/Employee", () => ({ + __esModule: true, + default: { + employees: { + find: jest.fn(), + }, + }, +})); + +jest.mock("../../Services/Service", () => ({ + __esModule: true, + default: { + services: { + find: jest.fn(), + }, + }, +})); + +jest.mock("../../Appointments/Appointments", () => ({ + __esModule: true, + default: { + countAppointmentsByMonth: jest.fn(), + }, +})); + +jest.mock("../../Clients/Clients", () => ({ + __esModule: true, + default: { + clients: { + find: jest.fn(), + }, + }, +})); + +jest.mock("../../Repeats/Repeats", () => ({ + __esModule: true, + default: { + repeats: { + find: jest.fn(), + }, + }, +})); + +jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({ + __esModule: true, + default: { + findOne: jest.fn(), + }, +})); + +jest.mock("../../Plans/Plans", () => ({ + __esModule: true, + default: { + plans: { + findOne: jest.fn(), + }, + }, +})); + +jest.mock("../../PlanUsageCycle/PlanUsageCycle", () => ({ + __esModule: true, + default: { + getAppointmentsCount: jest.fn(), + }, +})); + +import MetricsList from "../Metrics"; +import CompaniesList from "../../Companies/Companies"; +import EmployeesList from "../../Employees/Employee"; +import ServiceList from "../../Services/Service"; +import AppointmentList from "../../Appointments/Appointments"; +import ClientsList from "../../Clients/Clients"; +import RepeatsList from "../../Repeats/Repeats"; + +describe("calculateMetrics — excludes removed employees", () => { + let originalAdapter: any; + + beforeEach(() => { + jest.clearAllMocks(); + + originalAdapter = MetricsList.metrics; + + const mockSave = jest.fn().mockResolvedValue(true); + const mockMetricsDoc = { + userId: "owner-user", + organizationsCount: 0, + employeesCount: 0, + servicesCount: 0, + appointmentsCount: 0, + clientsCount: 0, + repeatsCount: 0, + month: 6, + year: 2026, + save: mockSave, + }; + + (MetricsList as any).metrics = { + getMetrics: jest.fn().mockResolvedValue(mockMetricsDoc), + reset: jest.fn().mockResolvedValue(mockMetricsDoc), + }; + }); + + afterEach(() => { + (MetricsList as any).metrics = originalAdapter; + }); + + it("counts only non-removed employees across companies", async () => { + // Two companies owned by the same user + (CompaniesList.companies.find as jest.Mock).mockResolvedValue([ + { _id: "company-001", id: "company-001" }, + { _id: "company-002", id: "company-002" }, + ]); + + // Company 001: 2 active + 1 removed → should count 2 + // Company 002: 1 active → should count 1 + // Total: 3 employees (not 4) + (EmployeesList.employees.find as jest.Mock) + .mockResolvedValueOnce([ + { _id: "emp-1", removed: false }, + { _id: "emp-2", removed: false }, + { _id: "emp-3", removed: true }, // filtered by query but just in case + ]) + .mockResolvedValueOnce([ + { _id: "emp-4", removed: false }, + ]); + + // Services: 2 in company-001, 1 in company-002 + (ServiceList.services.find as jest.Mock) + .mockResolvedValueOnce([{ _id: "svc-1" }, { _id: "svc-2" }]) + .mockResolvedValueOnce([{ _id: "svc-3" }]); + + // Appointments count per company + (AppointmentList.countAppointmentsByMonth as jest.Mock) + .mockResolvedValueOnce(5) + .mockResolvedValueOnce(3); + + // Clients per company + (ClientsList.clients.find as jest.Mock) + .mockResolvedValueOnce([{ _id: "c-1" }]) + .mockResolvedValueOnce([{ _id: "c-2" }, { _id: "c-3" }]); + + // Repeats per company + (RepeatsList.repeats.find as jest.Mock) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await MetricsList.calculateMetrics({ userId: "owner-user" }); + + // Verify employee find was called with removed filter for BOTH companies + expect(EmployeesList.employees.find).toHaveBeenCalledWith( + expect.objectContaining({ + companyId: "company-001", + removed: { $ne: true }, + }) + ); + expect(EmployeesList.employees.find).toHaveBeenCalledWith( + expect.objectContaining({ + companyId: "company-002", + removed: { $ne: true }, + }) + ); + + // Verify the metrics document was saved + const metricsDoc = await (MetricsList as any).metrics.getMetrics(); + expect(metricsDoc.save).toHaveBeenCalled(); + }); + + it("produces zero employee count when all employees are removed", async () => { + (CompaniesList.companies.find as jest.Mock).mockResolvedValue([ + { _id: "company-001", id: "company-001" }, + ]); + + // All employees in this company are removed + (EmployeesList.employees.find as jest.Mock).mockResolvedValue([]); + + (ServiceList.services.find as jest.Mock).mockResolvedValue([]); + (AppointmentList.countAppointmentsByMonth as jest.Mock).mockResolvedValue(0); + (ClientsList.clients.find as jest.Mock).mockResolvedValue([]); + (RepeatsList.repeats.find as jest.Mock).mockResolvedValue([]); + + const mockSave = jest.fn().mockResolvedValue(true); + const mockMetricsDoc = { + userId: "owner-user", + organizationsCount: 0, + employeesCount: 5, // pre-existing count + servicesCount: 0, + appointmentsCount: 0, + clientsCount: 0, + repeatsCount: 0, + month: 6, + year: 2026, + save: mockSave, + }; + + (MetricsList as any).metrics = { + getMetrics: jest.fn().mockResolvedValue(mockMetricsDoc), + reset: jest.fn().mockResolvedValue(mockMetricsDoc), + }; + + await MetricsList.calculateMetrics({ userId: "owner-user" }); + + // employeesCount should be set to 0 (no non-removed employees) + expect(mockMetricsDoc.employeesCount).toBe(0); + expect(mockSave).toHaveBeenCalled(); + }); +}); diff --git a/server/src/api/Appointments/Appointments.Service.ts b/server/src/api/Appointments/Appointments.Service.ts index bb03f52..766c75b 100644 --- a/server/src/api/Appointments/Appointments.Service.ts +++ b/server/src/api/Appointments/Appointments.Service.ts @@ -25,6 +25,7 @@ import { PaginatedAppointmentEventByClientResult, } from "../../Models/Appointments/Appointments.Interface"; import { connect } from "mongoose"; +import { validatePermissionsByCompany } from "../../helpers/check"; export class AppointmentService { public async createAppointment(data: CreateAppointmentParams): Promise { @@ -63,6 +64,15 @@ export class AppointmentService { ): Promise { await connect(`${process.env.DATABASE_CONNECTION}`); + if (!data.companyId || !data.sessionUser) { + throw new Error("No se ha encontrado la organización o el usuario de sesión"); + } + + await validatePermissionsByCompany({ + companyId: data.companyId, + sessionUser: data.sessionUser, + }); + const appointments = await AppointmentList.findAppointmentsAdminByDate(data); return appointments; diff --git a/server/src/api/Employees/Employees.Controller.ts b/server/src/api/Employees/Employees.Controller.ts index 85c67e6..c468e23 100644 --- a/server/src/api/Employees/Employees.Controller.ts +++ b/server/src/api/Employees/Employees.Controller.ts @@ -13,6 +13,7 @@ import { UpdateEmployeeRolesParams, UpdateEmployeeParams, IncompleteCollaboratorView, + RemoveCollaboratorParams, } from "../../Models/Employees/Employees.Interface"; import { authenticateMiddleware } from "../../middleware/authentication"; import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; @@ -448,3 +449,27 @@ export class GetIncompleteSetupController extends Controller { } } } + +@Route("employees/remove-collaborator") +@Middlewares(authenticateMiddleware) +export class RemoveCollaboratorController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "El colaborador fue removido con éxito") + @Post() + public async removeCollaborator( + @Body() requestBody: RemoveCollaboratorParams + ): Promise { + try { + await new EmployeesService().removeCollaborator(requestBody); + this.setStatus(200); + return { + success: true, + message: "Done", + }; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} diff --git a/server/src/api/Employees/Employees.Service.ts b/server/src/api/Employees/Employees.Service.ts index b148bc5..2b91bf2 100644 --- a/server/src/api/Employees/Employees.Service.ts +++ b/server/src/api/Employees/Employees.Service.ts @@ -12,6 +12,7 @@ import { UpdateEmployeeRolesParams, UpdateEmployeeParams, IncompleteCollaboratorView, + RemoveCollaboratorParams, } from "../../Models/Employees/Employees.Interface"; import { CreateEmployeeServiceParams, @@ -162,4 +163,9 @@ export class EmployeesService { await connect(`${process.env.DATABASE_CONNECTION}`); await EmployeesList.update(data); } + + public async removeCollaborator(data: RemoveCollaboratorParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + await EmployeesList.removeCollaboratorFromCompany(data); + } } diff --git a/txclient/src/Models/Appointments.model.ts b/txclient/src/Models/Appointments.model.ts index bcce333..94cfe7d 100644 --- a/txclient/src/Models/Appointments.model.ts +++ b/txclient/src/Models/Appointments.model.ts @@ -99,6 +99,7 @@ export type FindAppointmentsParams = { clientId?: string; start?: string; dateDay?: string; + sessionUser?: string; }; export type FindAppointmentsByUserParams = { diff --git a/txclient/src/Models/Collaborators.model.ts b/txclient/src/Models/Collaborators.model.ts index b71f3bc..f519653 100644 --- a/txclient/src/Models/Collaborators.model.ts +++ b/txclient/src/Models/Collaborators.model.ts @@ -22,6 +22,14 @@ export interface IEmployee { roles?: EmployeeRoles[]; guestOk?: boolean; hostOk?: boolean; + removed?: boolean; + removedAt?: Date; + profileSnapshot?: { + firstName?: string; + lastName?: string; + email?: string; + avatar?: string; + }; } export interface CompanyEmployeesView { @@ -38,6 +46,14 @@ export interface CompanyEmployeesView { hostOk: boolean; fullOk: boolean; calendarColor: string; + removed?: boolean; + removedAt?: Date; + profileSnapshot?: { + firstName?: string; + lastName?: string; + email?: string; + avatar?: string; + }; } export type CreateEmployeeParams = { @@ -66,6 +82,13 @@ export type ValidateEmployeeParams = { sessionUser: string; }; +export type RemoveCollaboratorParams = { + employeeId: string; + companyId: string; + replacementEmployeeId?: string; + sessionUser: string; +}; + export type FindEmployeesByIdParams = { id?: string; }; diff --git a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/layout.tsx b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/layout.tsx index b61f1fb..3603e51 100644 --- a/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/layout.tsx +++ b/txclient/src/app/admin/(client-profile)/org/[oid]/client/[clientId]/layout.tsx @@ -52,6 +52,7 @@ export default function ClientDashboardLayout({ const { goTo } = useNavigation(); const [client, setClient] = useState(null); + const [isCheckingAccess, setIsCheckingAccess] = useState(true); const [clientName, setClientName] = useState(""); const [, setClientEmail] = useState(""); const [clientAvatar, setClientAvatar] = useState(""); @@ -106,6 +107,7 @@ export default function ClientDashboardLayout({ } eventHandler.setEventType(EVENT_TYPES.LOADING); + setIsCheckingAccess(true); findClientById({ id: clientId, @@ -119,9 +121,15 @@ export default function ClientDashboardLayout({ setClientAvatar(client.avatar); }) .catch((error) => { + if (error?.desc?.includes("No tienes permisos") || error?.desc?.includes("no tiene permisos")) { + setClient(null); + goTo("/admin/access-denied"); + return; + } alert.showError(error.format()); }) .finally(() => { + setIsCheckingAccess(false); eventHandler.setEventType(EVENT_TYPES.SLEEP); }); }; @@ -131,7 +139,8 @@ export default function ClientDashboardLayout({ }> - + {isCheckingAccess && } + {client &&
- + } diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/appointments/[appdate]/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/appointments/[appdate]/page.tsx index 2a737d4..18c95c1 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/appointments/[appdate]/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/appointments/[appdate]/page.tsx @@ -193,8 +193,8 @@ export default function OrganizationProfile() { const filter = useFilterStore(); const alert = useAlert(); const SessionInfo = useSessionStore(); - const appointmentsView = useAppointmentsViewStore(); const { goTo } = useNavigation(); + const appointmentsView = useAppointmentsViewStore(); const [windowWidth] = useWindowSize(); @@ -317,6 +317,7 @@ export default function OrganizationProfile() { employeeId: appointmentsView.collaboratorId ? appointmentsView.collaboratorId : collaboratorId, + sessionUser: SessionInfo.userId, }; const filteredFilter = Object.fromEntries( @@ -338,6 +339,10 @@ export default function OrganizationProfile() { ]); }) .catch((error) => { + if (error?.desc?.includes("No tienes permisos") || error?.desc?.includes("no tiene permisos")) { + goTo("/admin/access-denied"); + return; + } alert.showError(error.format()); }) .finally(() => { diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/Collaborators.Service.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/Collaborators.Service.tsx index 1a38564..edde9bb 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/Collaborators.Service.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/Collaborators.Service.tsx @@ -14,6 +14,7 @@ import { CollaboratorSchedulesView, UpdateScheduleParams, UpdateEmployeeParams, + RemoveCollaboratorParams, } from "@models/Collaborators.model"; import { CollaboratorView, FindCollaboratorByEmailParams } from "@models/User.model"; import { ApiVoidResult } from "@models/Api.VoidResult.type"; @@ -128,3 +129,7 @@ export const updateSchedulesOverrides = async (data: UpdateSchedulesOverridesPar export const deleteSchedulesOverrides = async (data: DeleteSchedulesOverridesParams): Promise => { return ApiRequest.post("schedules-overrides/delete", data); }; + +export const removeCollaborator = async (data: RemoveCollaboratorParams): Promise => { + return ApiRequest.post("employees/remove-collaborator", data); +}; diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/edit/edit.module.css b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/edit/edit.module.css new file mode 100644 index 0000000..bddea18 --- /dev/null +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/edit/edit.module.css @@ -0,0 +1,223 @@ +/* ── Removal card (collapsible) ─────────────────────────────── */ + +.removalCard { + margin-top: 32px; + padding: 0; + border-radius: 16px; + background: linear-gradient( + 135deg, + rgba(255, 42, 127, 0.06) 0%, + rgba(255, 74, 42, 0.04) 100% + ); + border: 1px solid rgba(255, 74, 42, 0.18); + position: relative; + overflow: hidden; +} + +.removalCard::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient( + 90deg, + var(--wine-red) 0%, + var(--red) 100% + ); + border-radius: 16px 16px 0 0; + z-index: 1; +} + +/* ── Toggle button (always visible header) ──────────────────── */ + +.removalToggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 20px 24px; + border: none; + background: transparent; + cursor: pointer; + font-family: "Kanit", serif; + text-align: left; + transition: background 0.18s ease; +} + +.removalToggle:hover { + background: rgba(255, 42, 127, 0.03); +} + +.removalToggleLeft { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.removalToggleRight { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: 16px; +} + +.removalToggleText { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +/* ── Icon circle ────────────────────────────────────────────── */ + +.removalIconCircle { + width: 44px; + height: 44px; + border-radius: 12px; + background: linear-gradient( + 135deg, + rgba(255, 42, 127, 0.12) 0%, + rgba(255, 74, 42, 0.10) 100% + ); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--wine-red); +} + +/* ── Typography ─────────────────────────────────────────────── */ + +.removalTitle { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--black); + letter-spacing: -0.01em; + line-height: 1.3; +} + +.removalSubtitle { + font-size: 13px; + color: var(--black-light); + opacity: 0.7; + line-height: 1.3; +} + +.removalExpandLabel { + font-size: 13px; + font-weight: 500; + color: var(--wine-red); + white-space: nowrap; +} + +.removalChevron { + font-size: 22px; + color: var(--wine-red); + transition: transform 0.22s ease; +} + +/* ── Expanded body ──────────────────────────────────────────── */ + +.removalBody { + padding: 0 24px 24px 24px; + animation: removalSlideIn 0.22s ease; +} + +@keyframes removalSlideIn { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.removalDivider { + height: 1px; + background: rgba(255, 74, 42, 0.12); + margin-bottom: 20px; +} + +/* ── Description & list (expanded content) ──────────────────── */ + +.removalDescription { + margin: 0 0 16px 0; + font-size: 15px; + color: var(--black-light); + line-height: 1.65; +} + +.removalList { + margin: 0 0 22px 0; + padding: 0 0 0 22px; + font-size: 14.5px; + color: var(--black-light); + line-height: 1.75; +} + +.removalList li { + padding: 3px 0; +} + +.removalList li::marker { + color: var(--wine-red); + font-size: 10px; +} + +/* ── Action row ─────────────────────────────────────────────── */ + +.removalActions { + display: flex; + justify-content: flex-end; +} + +.removeButton { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 11px 28px; + border: none; + border-radius: 100px; + font-family: "Kanit", serif; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.01em; + cursor: pointer; + transition: + background 0.22s ease, + box-shadow 0.22s ease, + transform 0.15s ease; + background: linear-gradient( + 135deg, + rgba(255, 42, 127, 0.10) 0%, + rgba(255, 74, 42, 0.08) 100% + ); + color: var(--red); + border: 1.5px solid rgba(255, 74, 42, 0.30); + box-shadow: 0 1px 3px rgba(255, 42, 127, 0.06); +} + +.removeButton:hover { + background: var(--red); + color: var(--white); + border-color: var(--red); + box-shadow: 0 4px 14px rgba(255, 74, 42, 0.30); + transform: translateY(-1px); +} + +.removeButton:active { + transform: translateY(0); + box-shadow: 0 2px 6px rgba(255, 74, 42, 0.20); +} + +.removeButtonIcon { + font-size: 18px; + transition: color 0.22s ease; +} diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/edit/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/edit/page.tsx index cf8c0ba..a90cf00 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/edit/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/edit/page.tsx @@ -1,18 +1,40 @@ "use client"; import { useParams } from "next/navigation"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import Location from "@components/Location/Location"; import { useLocationStore } from "@store/Location.Store"; import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store"; import { useSessionStore } from "@store/Sesion.Store"; import { useAlert } from "@store/Alert.Store"; +import { useConfirmStore } from "@store/Confirm.Store"; import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined"; import LocalPoliceOutlinedIcon from "@mui/icons-material/LocalPoliceOutlined"; import MaterialAlert from "@mui/material/Alert"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; +import MuiButton from "@mui/material/Button"; +import Radio from "@mui/material/Radio"; +import RadioGroup from "@mui/material/RadioGroup"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import FormControl from "@mui/material/FormControl"; +import turnosXpressTheme from "@core/app/theme/turnosXpress"; +import { ThemeProvider } from "@emotion/react"; import { PopoverPicker } from "@core/app/components/ColorPicker/ColorPicker"; -import { findCollaboratorsById, updateCollaborator } from "../../Collaborators.Service"; +import { + findCollaboratorsById, + updateCollaborator, + loadCollaboratorsByCompanyId, + removeCollaborator, +} from "../../Collaborators.Service"; import { ApiError } from "@models/Server.Error.model"; -import { UpdateEmployeeParams } from "@models/Collaborators.model"; +import { UpdateEmployeeParams, CompanyEmployeesView, EmployeeRoles } from "@models/Collaborators.model"; +import { useNavigation } from "@hooks/goto"; +import style from "./edit.module.css"; +import PersonOffOutlinedIcon from "@mui/icons-material/PersonOffOutlined"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; type UrlData = { id: string; @@ -27,9 +49,19 @@ export default function OrganizationEdit() { const eventHandler = useEventHandlerStore(); const SessionInfo = useSessionStore(); const alert = useAlert(); + const confirm = useConfirmStore(); + const { goTo } = useNavigation(); const [collaboratorName, setCollaboratorName] = useState(""); + const [collaboratorUserId, setCollaboratorUserId] = useState(""); const [collaboratorColor, setCollaboratorColor] = useState(""); + const [collaborators, setCollaborators] = useState([]); + const [isOwner, setIsOwner] = useState(false); + const [replacementDialogOpen, setReplacementDialogOpen] = useState(false); + const [selectedReplacement, setSelectedReplacement] = useState(""); + const [removalCollapsed, setRemovalCollapsed] = useState(true); + + const toggleRemoval = useCallback(() => setRemovalCollapsed((prev) => !prev), []); useEffect(() => { eventHandler.setEventType(EVENT_TYPES.SLEEP); @@ -58,6 +90,7 @@ export default function OrganizationEdit() { eventHandler.setEventType(EVENT_TYPES.LOADING); loadCollaborator(); + loadCollaborators(); }, [collaboratorName]); useEffect(() => { @@ -83,6 +116,21 @@ export default function OrganizationEdit() { } }, [eventHandler.eventType]); + const loadCollaborators = () => { + loadCollaboratorsByCompanyId({ companyId: id }) + .then((list) => { + setCollaborators(list); + const current = list.find((c) => c.id === cid); + setIsOwner( + current !== undefined && + current.roles.includes(EmployeeRoles.OWNER) + ); + }) + .catch(() => { + // Non-critical — removal button still works, server validates + }); + }; + const loadCollaborator = () => { eventHandler.setEventType(EVENT_TYPES.LOADING); findCollaboratorsById({ id: cid }) @@ -92,6 +140,7 @@ export default function OrganizationEdit() { } setCollaboratorName(collaborator.fullName); + setCollaboratorUserId(collaborator.userId); setCollaboratorColor(collaborator.calendarColor); }) .catch((error) => { @@ -102,6 +151,65 @@ export default function OrganizationEdit() { }); }; + const handleRemoveClick = () => { + if (isOwner) { + alert.showError("No se puede eliminar al propietario de la organización"); + return; + } + + if (String(SessionInfo.userId) === collaboratorUserId) { + alert.showError("No puedes eliminarte a ti mismo de la organización"); + return; + } + + const candidates = collaborators.filter( + (c) => c.id !== cid + ); + + if (candidates.length > 0) { + setReplacementDialogOpen(true); + setSelectedReplacement(""); + } else { + confirm.show( + "¿Estás seguro de que deseas remover este colaborador de la organización?", + handleConfirmRemove, + () => {} + ); + } + }; + + const handleConfirmReplace = () => { + setReplacementDialogOpen(false); + confirm.show( + "¿Estás seguro de que deseas remover este colaborador de la organización? Los turnos futuros serán transferidos al colaborador seleccionado.", + handleConfirmRemove, + () => {} + ); + }; + + const handleConfirmRemove = () => { + eventHandler.setEventType(EVENT_TYPES.LOADING); + + removeCollaborator({ + employeeId: cid, + companyId: id, + sessionUser: SessionInfo.userId, + ...(selectedReplacement + ? { replacementEmployeeId: selectedReplacement } + : {}), + }) + .then(() => { + alert.showSuccess("Colaborador removido correctamente"); + goTo("/admin/org/profile/" + id + "/collaborators"); + }) + .catch((error: ApiError) => { + alert.showError(error.format()); + }) + .finally(() => { + eventHandler.setEventType(EVENT_TYPES.SLEEP); + }); + }; + return ( <> @@ -119,7 +227,124 @@ export default function OrganizationEdit() { }} /> -
 
+ {!isOwner && ( +
+ + + {!removalCollapsed && ( +
+
+ +

+ Al remover este colaborador de la organización: +

+ +
    +
  • Dejará de pertenecer a la organización.
  • +
  • + Los turnos futuros y repeticiones activas serán + transferidos a otro colaborador. +
  • +
  • El historial de turnos permanecerá preservado.
  • +
  • + Ya no recibirá nuevas reservas ni turnos asignados. +
  • +
+ +
+ +
+
+ )} +
+ )} + + + setReplacementDialogOpen(false)} + > + Seleccionar reemplazo + +

+ Este colaborador tiene turnos futuros asignados. + Seleccione un colaborador de reemplazo para transferir los turnos. +

+ + setSelectedReplacement(e.target.value)} + > + {collaborators + .filter((c) => c.id !== cid) + .map((collaborator) => ( + } + label={`${collaborator.fullName} (${collaborator.email})`} + /> + ))} + + +
+ + setReplacementDialogOpen(false)}> + Cancelar + + + Aceptar + + +
+
); } diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/layout.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/layout.tsx index 88bfd9e..79592bc 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/layout.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/layout.tsx @@ -11,6 +11,7 @@ import Menu from "@components/Menu/Menu"; import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store"; import Content from "@core/app/components/Content/Content"; import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading"; +import { useNavigation } from "@core/app/hooks/goto"; type UrlData = { id: string; @@ -18,24 +19,33 @@ type UrlData = { export default function OrgLayout({ children }: { children: React.ReactNode }) { const [organization, setOrganization] = useState(); + const [isCheckingAccess, setIsCheckingAccess] = useState(true); const data = useParams(); const organizationId = data.id; const SessionInfo = useSessionStore(); const organizationHeader = useOrganizationHeaderStore(); + const { goTo } = useNavigation(); useEffect(() => { if (!SessionInfo.userId) { return; } - loadOrganizationsById({ id: organizationId, sessionUser: SessionInfo.userId }).then( - (organization) => { + setIsCheckingAccess(true); + loadOrganizationsById({ id: organizationId, sessionUser: SessionInfo.userId }) + .then((organization) => { setOrganization(organization); organizationHeader.setHeaderColor(organization.headerColor); organizationHeader.setHeaderFontColor(organization.headerFontColor); organizationHeader.setHeaderFontShadowColor(organization.headerFontShadowColor); - } - ); + }) + .catch(() => { + setOrganization(undefined); + goTo("/admin/access-denied"); + }) + .finally(() => { + setIsCheckingAccess(false); + }); }, [SessionInfo.userId]); return ( @@ -43,9 +53,14 @@ export default function OrgLayout({ children }: { children: React.ReactNode }) { }> + {isCheckingAccess && } {organization && } - {children} - + {organization && ( + <> + {children} + + + )} diff --git a/txclient/src/app/admin/access-denied/access-denied.module.css b/txclient/src/app/admin/access-denied/access-denied.module.css new file mode 100644 index 0000000..f1fc41a --- /dev/null +++ b/txclient/src/app/admin/access-denied/access-denied.module.css @@ -0,0 +1,123 @@ +.page { + align-items: center; + background: radial-gradient(circle at top, rgba(255, 47, 125, 0.24), transparent 30%), linear-gradient(160deg, #3b0076 0%, #4b0097 48%, #8b2cff 100%); + display: flex; + justify-content: center; + min-height: calc(100vh - 56px); + padding: 48px 20px; +} + +.card { + background: rgba(255, 255, 255, 0.96); + border: 1px solid rgba(255, 255, 255, 0.72); + border-radius: 28px; + box-shadow: 0 28px 70px rgba(20, 0, 50, 0.28); + max-width: 720px; + overflow: hidden; + padding: 0; + width: 100%; +} + +.accent { + background: linear-gradient(90deg, #ff2f7d, #ff6b35, #7c3aed); + height: 7px; +} + +.content { + align-items: center; + display: flex; + flex-direction: column; + gap: 18px; + padding: 44px 42px 38px; + text-align: center; +} + +.iconWrap { + align-items: center; + background: linear-gradient(145deg, rgba(255, 47, 125, 0.12), rgba(124, 58, 237, 0.12)); + border: 1px solid rgba(255, 47, 125, 0.2); + border-radius: 24px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); + color: #ff2f7d; + display: flex; + height: 76px; + justify-content: center; + width: 76px; +} + +.eyebrow { + background: rgba(255, 47, 125, 0.1); + border-radius: 999px; + color: #b91c5c; + font-size: 12px; + font-weight: 900; + letter-spacing: 0.08em; + padding: 7px 13px; + text-transform: uppercase; +} + +.title { + color: #2b174f; + font-size: clamp(28px, 4vw, 38px); + line-height: 1.05; + margin: 0; +} + +.description { + color: #4b5563; + font-size: 16px; + line-height: 1.65; + margin: 0; + max-width: 560px; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: center; + margin-top: 8px; +} + +.primaryAction, +.secondaryAction { + align-items: center; + border-radius: 999px; + display: inline-flex; + font-weight: 900; + justify-content: center; + min-height: 46px; + padding: 0 20px; + text-decoration: none; + transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} + +.primaryAction { + background: linear-gradient(135deg, #ff2f7d, #7c3aed); + box-shadow: 0 14px 28px rgba(124, 58, 237, 0.28); + color: #ffffff; +} + +.secondaryAction { + background: #ffffff; + border: 1px solid rgba(124, 58, 237, 0.18); + color: #4b148c; +} + +.primaryAction:hover, +.secondaryAction:hover { + box-shadow: 0 18px 34px rgba(43, 23, 79, 0.18); + transform: translateY(-2px); +} + +@media (max-width: 640px) { + .content { + padding: 34px 24px 30px; + } + + .actions, + .primaryAction, + .secondaryAction { + width: 100%; + } +} diff --git a/txclient/src/app/admin/access-denied/page.tsx b/txclient/src/app/admin/access-denied/page.tsx new file mode 100644 index 0000000..d36cdcf --- /dev/null +++ b/txclient/src/app/admin/access-denied/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import Link from "next/link"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import HomeOutlinedIcon from "@mui/icons-material/HomeOutlined"; +import GroupsOutlinedIcon from "@mui/icons-material/GroupsOutlined"; +import style from "./access-denied.module.css"; + +export default function AdminAccessDeniedPage() { + return ( +
+
+
+
+
+ +
+ Acceso restringido +

No tenés permisos para acceder

+

+ Esta sección pertenece a una organización en la que ya no tenés permisos activos. + Si creés que se trata de un error, comunicate con un administrador de la organización. +

+
+ + + Ir a Mis organizaciones + + + + Volver al dashboard + +
+
+
+
+ ); +}