feat: implement collaborator reactivation logic, add access denied handling, and improve metric filtering
This commit is contained in:
@@ -12,6 +12,7 @@ export type FindAppointmentsParams = {
|
||||
clientId?: string;
|
||||
start?: Date;
|
||||
dateDay?: Date;
|
||||
sessionUser?: string;
|
||||
};
|
||||
|
||||
export type FindAppointmentsByUserParams = {
|
||||
|
||||
@@ -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<string>(userEmployee.firstName, "")
|
||||
: isNull<string>(employee.profileSnapshot?.firstName, "");
|
||||
const collaboratorLastName = userEmployee
|
||||
? isNull<string>(userEmployee.lastName, "")
|
||||
: isNull<string>(employee.profileSnapshot?.lastName, "");
|
||||
const collaboratorFullName = joinStrings([collaboratorFirstName, collaboratorLastName], " ");
|
||||
const collaboratorAvatar = userEmployee
|
||||
? getAvatar(employee.userId, userEmployee.avatar, collaboratorFullName)
|
||||
: (employee.profileSnapshot?.avatar || collaboratorFullName);
|
||||
|
||||
const service = await ServiceList.services.findOne({
|
||||
_id: String(appointment.serviceId),
|
||||
@@ -1271,14 +1282,6 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
? getAvatar(client.userId, userClient.avatar, clientUserName)
|
||||
: clientUserName;
|
||||
|
||||
const employeeFirstName = isNull<string>(userEmployee.firstName, "");
|
||||
const employeeLastName = isNull<string>(userEmployee.lastName, "");
|
||||
const employeeFullName = joinStrings([employeeFirstName, employeeLastName], " ");
|
||||
|
||||
const employeeAvatar = userEmployee
|
||||
? getAvatar(employee.userId, userEmployee.avatar, employeeFullName)
|
||||
: clientUserName;
|
||||
|
||||
const discountData = await Discounts.getDiscountData(appointment.discountId, appointment.companyId);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<boolean> {
|
||||
//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<CompanyEmployeesView[]> {
|
||||
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<TextObjectFilterResult[]> {
|
||||
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<void> {
|
||||
// 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;
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
rejectGuest(data: ValidateEmployeeParams): Promise<void>;
|
||||
getIncompleteSetup(sessionUser: string): Promise<IncompleteCollaboratorView[]>;
|
||||
removeCollaboratorFromCompany(data: RemoveCollaboratorParams): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, any> = {}) {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
+318
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<IAppointment> {
|
||||
@@ -63,6 +64,15 @@ export class AppointmentService {
|
||||
): Promise<AppointmentAdminByDateView> {
|
||||
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;
|
||||
|
||||
@@ -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<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "El colaborador fue removido con éxito")
|
||||
@Post()
|
||||
public async removeCollaborator(
|
||||
@Body() requestBody: RemoveCollaboratorParams
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
await EmployeesList.removeCollaboratorFromCompany(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ export type FindAppointmentsParams = {
|
||||
clientId?: string;
|
||||
start?: string;
|
||||
dateDay?: string;
|
||||
sessionUser?: string;
|
||||
};
|
||||
|
||||
export type FindAppointmentsByUserParams = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -52,6 +52,7 @@ export default function ClientDashboardLayout({
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
const [client, setClient] = useState<OrganizationClientView | null>(null);
|
||||
const [isCheckingAccess, setIsCheckingAccess] = useState(true);
|
||||
const [clientName, setClientName] = useState<string>("");
|
||||
const [, setClientEmail] = useState<string>("");
|
||||
const [clientAvatar, setClientAvatar] = useState<string>("");
|
||||
@@ -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({
|
||||
<Content>
|
||||
<Suspense fallback={<SuspenseLoading />}>
|
||||
<Secure>
|
||||
<SectionContainer style={{ top: "70px" }}>
|
||||
{isCheckingAccess && <SuspenseLoading />}
|
||||
{client && <SectionContainer style={{ top: "70px" }}>
|
||||
<div className={style.clientProfileHeader}>
|
||||
<Avatar
|
||||
name="userProfileAvatar"
|
||||
@@ -195,7 +204,7 @@ export default function ClientDashboardLayout({
|
||||
</div>
|
||||
</div>
|
||||
<Menu />
|
||||
</SectionContainer>
|
||||
</SectionContainer>}
|
||||
</Secure>
|
||||
</Suspense>
|
||||
</Content>
|
||||
|
||||
+6
-1
@@ -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(() => {
|
||||
|
||||
+5
@@ -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<ApiVoidResult> => {
|
||||
return ApiRequest.post<ApiVoidResult>("schedules-overrides/delete", data);
|
||||
};
|
||||
|
||||
export const removeCollaborator = async (data: RemoveCollaboratorParams): Promise<ApiVoidResult> => {
|
||||
return ApiRequest.post<ApiVoidResult>("employees/remove-collaborator", data);
|
||||
};
|
||||
|
||||
+223
@@ -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;
|
||||
}
|
||||
+229
-4
@@ -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<string>("");
|
||||
const [collaboratorUserId, setCollaboratorUserId] = useState<string>("");
|
||||
const [collaboratorColor, setCollaboratorColor] = useState<string>("");
|
||||
const [collaborators, setCollaborators] = useState<CompanyEmployeesView[]>([]);
|
||||
const [isOwner, setIsOwner] = useState<boolean>(false);
|
||||
const [replacementDialogOpen, setReplacementDialogOpen] = useState<boolean>(false);
|
||||
const [selectedReplacement, setSelectedReplacement] = useState<string>("");
|
||||
const [removalCollapsed, setRemovalCollapsed] = useState<boolean>(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 (
|
||||
<>
|
||||
<Location />
|
||||
@@ -119,7 +227,124 @@ export default function OrganizationEdit() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ marginBottom: "10px" }}> </div>
|
||||
{!isOwner && (
|
||||
<div className={style.removalCard}>
|
||||
<button
|
||||
type="button"
|
||||
className={style.removalToggle}
|
||||
onClick={toggleRemoval}
|
||||
aria-expanded={!removalCollapsed}
|
||||
aria-controls="removal-details"
|
||||
>
|
||||
<div className={style.removalToggleLeft}>
|
||||
<div className={style.removalIconCircle}>
|
||||
<PersonOffOutlinedIcon fontSize="medium" />
|
||||
</div>
|
||||
<div className={style.removalToggleText}>
|
||||
<h3 className={style.removalTitle}>
|
||||
Remover colaborador
|
||||
</h3>
|
||||
{!removalCollapsed && (
|
||||
<span className={style.removalSubtitle}>
|
||||
Acción irreversible — revisá los efectos antes de continuar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.removalToggleRight}>
|
||||
{removalCollapsed ? (
|
||||
<>
|
||||
<span className={style.removalExpandLabel}>
|
||||
Mostrar opciones de remoción
|
||||
</span>
|
||||
<ExpandMoreIcon className={style.removalChevron} />
|
||||
</>
|
||||
) : (
|
||||
<ExpandLessIcon className={style.removalChevron} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!removalCollapsed && (
|
||||
<div id="removal-details" className={style.removalBody}>
|
||||
<div className={style.removalDivider} />
|
||||
|
||||
<p className={style.removalDescription}>
|
||||
Al remover este colaborador de la organización:
|
||||
</p>
|
||||
|
||||
<ul className={style.removalList}>
|
||||
<li>Dejará de pertenecer a la organización.</li>
|
||||
<li>
|
||||
Los turnos futuros y repeticiones activas serán
|
||||
transferidos a otro colaborador.
|
||||
</li>
|
||||
<li>El historial de turnos permanecerá preservado.</li>
|
||||
<li>
|
||||
Ya no recibirá nuevas reservas ni turnos asignados.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className={style.removalActions}>
|
||||
<button
|
||||
className={style.removeButton}
|
||||
type="button"
|
||||
onClick={handleRemoveClick}
|
||||
>
|
||||
<PersonOffOutlinedIcon className={style.removeButtonIcon} />
|
||||
Remover colaborador
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<Dialog
|
||||
open={replacementDialogOpen}
|
||||
onClose={() => setReplacementDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Seleccionar reemplazo</DialogTitle>
|
||||
<DialogContent>
|
||||
<p style={{ marginBottom: "16px", color: "#666" }}>
|
||||
Este colaborador tiene turnos futuros asignados.
|
||||
Seleccione un colaborador de reemplazo para transferir los turnos.
|
||||
</p>
|
||||
<FormControl component="fieldset">
|
||||
<RadioGroup
|
||||
value={selectedReplacement}
|
||||
onChange={(e) => setSelectedReplacement(e.target.value)}
|
||||
>
|
||||
{collaborators
|
||||
.filter((c) => c.id !== cid)
|
||||
.map((collaborator) => (
|
||||
<FormControlLabel
|
||||
key={collaborator.id}
|
||||
value={collaborator.id}
|
||||
control={<Radio />}
|
||||
label={`${collaborator.fullName} (${collaborator.email})`}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MuiButton onClick={() => setReplacementDialogOpen(false)}>
|
||||
Cancelar
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
onClick={handleConfirmReplace}
|
||||
disabled={!selectedReplacement}
|
||||
color="success"
|
||||
variant="contained"
|
||||
autoFocus
|
||||
>
|
||||
Aceptar
|
||||
</MuiButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<MyOranizationsView | undefined>();
|
||||
const [isCheckingAccess, setIsCheckingAccess] = useState(true);
|
||||
const data = useParams<UrlData>();
|
||||
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 }) {
|
||||
<Content>
|
||||
<Suspense fallback={<SuspenseLoading />}>
|
||||
<Secure>
|
||||
{isCheckingAccess && <SuspenseLoading />}
|
||||
{organization && <OrganizationHeader data={organization} />}
|
||||
<OrganizationContent>{children}</OrganizationContent>
|
||||
<Menu />
|
||||
{organization && (
|
||||
<>
|
||||
<OrganizationContent>{children}</OrganizationContent>
|
||||
<Menu />
|
||||
</>
|
||||
)}
|
||||
</Secure>
|
||||
</Suspense>
|
||||
</Content>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={style.page}>
|
||||
<div className={style.card}>
|
||||
<div className={style.accent} />
|
||||
<div className={style.content}>
|
||||
<div className={style.iconWrap}>
|
||||
<ShieldOutlinedIcon sx={{ fontSize: 42 }} />
|
||||
</div>
|
||||
<span className={style.eyebrow}>Acceso restringido</span>
|
||||
<h1 className={style.title}>No tenés permisos para acceder</h1>
|
||||
<p className={style.description}>
|
||||
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.
|
||||
</p>
|
||||
<div className={style.actions}>
|
||||
<Link className={style.primaryAction} href="/admin/org">
|
||||
<GroupsOutlinedIcon sx={{ fontSize: 19, mr: 0.75 }} />
|
||||
Ir a Mis organizaciones
|
||||
</Link>
|
||||
<Link className={style.secondaryAction} href="/landing/dashboard">
|
||||
<HomeOutlinedIcon sx={{ fontSize: 19, mr: 0.75 }} />
|
||||
Volver al dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user