16 Commits

Author SHA1 Message Date
horacio 40090cdec5 feat: add collaboratorUserId to appointment model and update dashboard filtering logic 2026-07-21 10:34:52 -03:00
horacio a9afb13d15 feat: update collaborator invitation flow to handle existing users and pending invitations in StepEngine 2026-07-20 14:46:14 -03:00
horacio b01149a19e feat: introduce service-based availability filtering for collaborator schedules and overrides 2026-07-19 23:36:18 -03:00
horacio f017f7e9aa feat: redesign CategoriesExplorer component with a modern card-based grid layout and dynamic palettes 2026-07-19 20:11:01 -03:00
horacio 10ec32139c refactor: replace client deletion with user detachment and update client schema to support optional user association 2026-07-19 19:52:45 -03:00
horacio 74d6ee6706 feat: implement collaborator reactivation logic, add access denied handling, and improve metric filtering 2026-07-19 18:56:39 -03:00
horacio af6c87acb6 feat: add manual subscription finalization functionality and support for expired plan notifications 2026-07-19 13:20:33 -03:00
horacio 78ee86ac48 feat: integrate consumption metrics into plan usage cycle tracking and UI display 2026-07-18 22:02:48 -03:00
horacio 0f76ef3118 feat: expand dashboard with navigation cards for companies, plans, and active WAP server management 2026-07-18 21:36:39 -03:00
horacio 4f3b56784e feat: implement sysadmin subscription management and plan usage cycle recalculation tools 2026-07-18 21:32:47 -03:00
horacio 1c8c47cf76 refactor: update error message for subscription cancellation to clarify user options 2026-07-18 15:50:30 -03:00
horacio 8d74ae95c8 feat: implement plan upgrade logic with prorated pricing and MercadoPago integration 2026-07-18 15:39:07 -03:00
horacio f6e2bb8372 refactor: redesign current plan UI with features panel and expandable capacity limits view 2026-07-18 11:05:26 -03:00
horacio 81616c7c1c feat: add collapsible state and item count to dashboard agenda component 2026-07-18 10:54:27 -03:00
horacio 8d9a03cd23 feat: plan management 2026-07-17 21:28:41 -03:00
horacio d6a4aab52b primer commit hacia el nuevo servidor de git 2026-07-17 18:22:52 -03:00
124 changed files with 12324 additions and 1669 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+3
View File
@@ -34,3 +34,6 @@ server/build
*.njsproj
*.sln
*.sw?
# AI tooling
.atl/
+1
View File
@@ -1,2 +1,3 @@
# turnosxpress
ngrok http 3000
+1 -1
View File
@@ -45,7 +45,7 @@ MP_SUBSCRIPTION_FIND_URL = https://api.mercadopago.com/preapproval/
MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
MP_CHECK_PAYMENT_MINUTES = 1
MP_WEBHOOK_URL = https://5747-186-132-168-62.ngrok-free.app/mercadopago/webhook
MP_WEBHOOK_URL = https://9300-186-132-200-176.ngrok-free.app/mercadopago/webhook
DEFAULT_BOT_ADMIN_PORT = 3005
+822 -488
View File
File diff suppressed because it is too large Load Diff
+25 -25
View File
@@ -14,39 +14,39 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/cors": "^2.8.13",
"@types/express": "4.17.17",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.25",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.2",
"@types/sanitize-html": "^2.16.0",
"@types/socket.io": "^3.0.1",
"@types/swagger-ui-express": "^4.1.3",
"concurrently": "^8.2.0",
"csv-parse": "^5.4.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/nodemailer": "^6.4.24",
"@types/sanitize-html": "^2.16.1",
"@types/socket.io": "^3.0.2",
"@types/swagger-ui-express": "^4.1.8",
"concurrently": "^8.2.2",
"csv-parse": "^5.6.0",
"jest": "^30.4.2",
"ts-jest": "^29.4.11",
"ts-node-dev": "^2.0.0",
"typescript": "5.0.4"
"typescript": "^5.9.3"
},
"dependencies": {
"@types/nodemailer": "^6.4.16",
"axios": "^1.7.7",
"axios": "^1.18.1",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dayjs": "^1.11.13",
"dotenv": "^16.0.3",
"express": "^4.21.1",
"google-auth-library": "^10.1.0",
"joi": "^17.9.2",
"jsonwebtoken": "^9.0.1",
"mongoose": "^8.8.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.15",
"sanitize-html": "^2.17.0",
"cors": "^2.8.6",
"dayjs": "^1.11.21",
"dotenv": "^16.6.1",
"express": "^4.22.2",
"google-auth-library": "^10.9.0",
"joi": "^17.13.4",
"jsonwebtoken": "^9.0.3",
"mongoose": "8.8.3",
"multer": "^1.4.5-lts.2",
"nodemailer": "^6.10.1",
"sanitize-html": "^2.17.6",
"sharp": "^0.33.5",
"socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.0",
"socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.1",
"tsoa": "^5.1.1"
}
}
@@ -12,6 +12,7 @@ export type FindAppointmentsParams = {
clientId?: string;
start?: Date;
dateDay?: Date;
sessionUser?: string;
};
export type FindAppointmentsByUserParams = {
@@ -244,6 +245,7 @@ export interface AppointmentEventByClient {
serviceName: string;
serviceDescription: string;
collaboratorId: string;
collaboratorUserId: string;
collaboratorName: string;
collaboratorAvatar: string;
appointmentDate: string;
+81 -23
View File
@@ -70,7 +70,7 @@ import { DiscountType } from "../Discounts/Discounts.Interface";
import Discounts from "../Discounts/Discounts";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import Templates from "../Templates/Templates";
import { CollaboratorSchedulesView } from "../Schedules/Schedules.Interface";
import { CollaboratorSchedulesView, ScheduleItem } from "../Schedules/Schedules.Interface";
import ClientAccount from "../ClientAccounts/ClientAccount";
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import DiscountsEmail from "../DiscountsEmail/DiscountsEmail";
@@ -89,6 +89,14 @@ class AppointmentManager implements IAppointmentsManager {
this.Appointments = new AppointmentsAdapterMongoose();
}
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true;
}
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
}
public async setPaymentStatus(data: SetPaymentStatusParams): Promise<void> {
const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser,
@@ -305,12 +313,6 @@ class AppointmentManager implements IAppointmentsManager {
throw new Error("La compañia no existe");
}
const canAdd = await MetricsList.canAddAppointment(companyCheck.ownerId);
if (!canAdd) {
throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan.");
}
const serviceCheck = await ServicesManager.services.findOne({
_id: data.serviceId,
companyId: data.companyId,
@@ -443,6 +445,18 @@ class AppointmentManager implements IAppointmentsManager {
data.comments = isNull<string>(data.comments, "");
const appointmentReserved = await MetricsList.reserveAppointment({
userId: String(companyCheck.ownerId),
companyId: data.companyId,
quantity: 1,
});
if (!appointmentReserved) {
throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan.");
}
let appointmentCreated = false;
try {
const start = dayjs(data.start).clone();
const end = dayjs(data.start)
@@ -467,6 +481,7 @@ class AppointmentManager implements IAppointmentsManager {
comments: data.comments,
sessionUser: data.sessionUser,
});
appointmentCreated = true;
if (data.repeatId) {
const repeat = await RepeatsList.repeats.findOne({
@@ -489,8 +504,9 @@ class AppointmentManager implements IAppointmentsManager {
}
}
await MetricsList.addAppointment({
await MetricsList.metrics.addAppointment({
userId: companyCheck.ownerId,
companyId: data.companyId,
quantity: 1,
});
@@ -559,6 +575,13 @@ class AppointmentManager implements IAppointmentsManager {
return newAppointment;
} catch (err) {
console.log(err);
if (!appointmentCreated) {
await MetricsList.releaseAppointment({
userId: String(companyCheck.ownerId),
companyId: data.companyId,
quantity: 1,
});
}
throw new Error("Ha ocurrido un error al crear el turno.");
}
}
@@ -920,6 +943,7 @@ class AppointmentManager implements IAppointmentsManager {
dateDay: dayjs(data.start).startOf("day").toDate(),
from: checkFrom,
to: checkTo,
serviceId: data.serviceId,
});
} else {
//chequear que el colaborador este disponible.
@@ -929,6 +953,7 @@ class AppointmentManager implements IAppointmentsManager {
weekDay: weekDay,
from: checkFrom,
to: checkTo,
serviceId: data.serviceId,
});
}
@@ -1037,6 +1062,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)
@@ -1236,9 +1264,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),
@@ -1256,14 +1292,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;
@@ -1285,8 +1313,9 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name,
serviceDescription: service.description,
collaboratorId: employee.id,
collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar,
collaboratorUserId: String(employee.userId),
collaboratorName: collaboratorFullName,
collaboratorAvatar: collaboratorAvatar,
appointmentDate: dayjs(appointment.start).toISOString(),
startTime: appointment.startHour,
endTime: appointment.endHour,
@@ -1394,6 +1423,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name,
serviceDescription: service.description,
collaboratorId: employee.id,
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1503,6 +1533,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name,
serviceDescription: service.description,
collaboratorId: employee.id,
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1621,6 +1652,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name,
serviceDescription: service.description,
collaboratorId: String(employee.id || (employee as any)._id),
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1738,6 +1770,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name,
serviceDescription: service.description,
collaboratorId: employee.id,
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1865,6 +1898,7 @@ public async findAppointmentsByCollaboratorPaginated(
serviceName: service.name,
serviceDescription: service.description,
collaboratorId: String(employee.id || (employee as any)._id),
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(),
@@ -2271,6 +2305,10 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
continue;
}
if (!this.isScheduleAvailableForService(schedule, data.serviceId)) {
continue;
}
const scheduleDataFrom = schedule.from.split(":");
const scheduleDataTo = schedule.to.split(":");
const hourFrom = parseInt(scheduleDataFrom[0]);
@@ -2743,7 +2781,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
date: currentDate.toDate(),
});
if (hasOverride && hasOverride.schedules.length > 0) {
if (
hasOverride &&
hasOverride.schedules.some(
(schedule) =>
!schedule.disabled &&
(!schedule.serviceScope ||
schedule.serviceScope === "all" ||
(schedule.serviceScope === "specific" &&
(schedule.serviceIds || []).includes(data.serviceId)))
)
) {
returnAvailableDates.push(currentDate.toDate());
} else if (!hasOverride) {
// Check if the current date is within any enabled schedule range
@@ -2766,7 +2814,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
weekDay: currentDate.day(),
});
if (schedule && schedule.schedules.length > 0) {
if (
schedule &&
schedule.schedules.some(
(scheduleItem) =>
!scheduleItem.disabled &&
(!scheduleItem.serviceScope ||
scheduleItem.serviceScope === "all" ||
(scheduleItem.serviceScope === "specific" &&
(scheduleItem.serviceIds || []).includes(data.serviceId)))
)
) {
returnAvailableDates.push(currentDate.toDate());
}
}
@@ -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");
});
});
@@ -6,6 +6,7 @@ import {
CreateClientByUserParams,
PaginateClientsParams,
PaginateClientsResults,
DetachClientUserParams,
} from "./Clients.Interface";
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
@@ -96,4 +97,8 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
pages: Math.ceil(count / filters.limit),
};
}
public async detachUser(data: DetachClientUserParams): Promise<void> {
await this.clientList.updateOne({ _id: data.clientId }, { $unset: { userId: "" } }).exec();
}
}
@@ -14,6 +14,10 @@ export type FindClientsParams = {
status?: boolean;
};
export type DetachClientUserParams = {
clientId: string;
};
export type FindClientByIdParams = {
id: string;
companyId: string;
@@ -77,7 +81,7 @@ export type DeleteClientParams = {
export interface IClient {
id?: string;
companyId: string;
userId: string;
userId?: string;
fromClientId: string;
status: boolean;
firstName: string;
@@ -116,6 +120,7 @@ export interface IClientsAdapter {
find(filters: FindClientsParams): Promise<IClient[]>;
findOne(filters: FindClientsParams): Promise<IClientDocument | null>;
paginate(filters: PaginateClientsParams): Promise<PaginateClientsResults>;
detachUser(data: DetachClientUserParams): Promise<void>;
}
export interface IClientsManager {
+3 -1
View File
@@ -195,7 +195,9 @@ class ClientManager implements IClientsManager {
if (appointment) {
appointment.clientId = data.toClientId;
appointment.userId = clientTo.userId;
if (clientTo.userId) {
appointment.userId = clientTo.userId;
}
await appointment.save();
}
}
+4 -2
View File
@@ -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) {
+205 -4
View File
@@ -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,8 +119,59 @@ 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");
if (!employeeCheck.guestOk) {
throw new Error("La invitación para este usuario ya está pendiente");
}
throw new Error("El usuario ya pertenece a la compañía");
}
const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId);
@@ -346,7 +404,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 +480,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 +580,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 compaia.");
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 compaia.");
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 compaia.");
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 compaia.");
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 compaia.");
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({
@@ -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
// ─────────────────────────────────────────────────────────────────────────────
@@ -2,7 +2,9 @@ import { IMetricsDocument } from "./Metrics.Adapter.Mongoose";
export type MetricsParams = {
userId?: string;
companyId?: string;
quantity?: number;
limit?: number;
};
export type DeleteMetricsByCompany = {
@@ -45,6 +47,8 @@ export interface IMetricsManager {
addEmployee(data: MetricsParams): Promise<void>;
addService(data: MetricsParams): Promise<void>;
addAppointment(data: MetricsParams): Promise<void>;
reserveAppointment(data: MetricsParams): Promise<boolean>;
releaseAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>;
canAddOrganization(userId: string): Promise<boolean>;
canAddEmployee(userId: string): Promise<boolean>;
+52 -3
View File
@@ -14,6 +14,7 @@ import AppointmentList from "../Appointments/Appointments";
import ClientsList from "../Clients/Clients";
import RepeatsList from "../Repeats/Repeats";
import { isNull } from "../../helpers/IsNull";
import PlanUsageCycleList from "../PlanUsageCycle/PlanUsageCycle";
class MetricsManager implements IMetricsManager {
metrics: MetricsAdapterMongoose;
@@ -39,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({
@@ -231,17 +233,64 @@ class MetricsManager implements IMetricsManager {
if (plan.limitAppointments < 0) {
return true;
}
const metrics = await this.getMetrics(userId);
if (metrics.appointmentsCount >= plan.limitAppointments) {
const appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({ userId });
if (appointmentsCount >= plan.limitAppointments) {
return false;
}
return true;
}
public async addAppointment(data: MetricsParams): Promise<void> {
if (data.userId) {
await PlanUsageCycleList.addAppointment({
userId: data.userId,
quantity: data.quantity,
});
}
await this.metrics.addAppointment(data);
}
public async reserveAppointment(data: MetricsParams): Promise<boolean> {
if (!data.userId) {
return false;
}
const subscription = await PlanSubscriptionsList.findOne({
sessionUser: data.userId,
});
if (!subscription) {
return false;
}
const plan = await PlansList.plans.findOne({
_id: subscription.planId,
});
if (!plan) {
return false;
}
if (plan.limitAppointments < 0) {
await PlanUsageCycleList.addAppointment({
userId: data.userId,
quantity: data.quantity,
});
return true;
}
return await PlanUsageCycleList.reserveAppointment({
userId: data.userId,
quantity: data.quantity,
limit: plan.limitAppointments,
});
}
public async releaseAppointment(data: MetricsParams): Promise<void> {
if (data.userId) {
await PlanUsageCycleList.releaseAppointment({
userId: data.userId,
quantity: data.quantity,
});
}
}
public async addClient(data: MetricsParams): Promise<void> {
await this.metrics.addClient(data);
}
@@ -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();
});
});
@@ -0,0 +1,49 @@
import { Document, Model, Schema, model } from "mongoose";
import {
CreatePlanPaymentParams,
FindPlanPaymentsParams,
IPlanPayment,
IPlanPaymentsAdapter,
} from "./PlanPayments.interface";
export interface IPlanPaymentDocument extends Omit<IPlanPayment, "id">, Document {}
export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
schema: Schema;
planPaymentList: Model<IPlanPaymentDocument>;
constructor() {
this.schema = new Schema({
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
subscriptionId: { type: Schema.Types.ObjectId, required: true, ref: "PlanSuscription" },
amount: { type: Number, required: true, default: 0 },
paymentDate: { type: Date, required: true, default: Date.now },
paymentMethod: { type: String, required: false },
status: { type: String, required: true },
transactionId: { type: String, required: false },
});
this.schema.index({ transactionId: 1 }, { unique: true, sparse: true });
this.planPaymentList = model<IPlanPaymentDocument>("PlanPayment", this.schema);
}
public async find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
return this.planPaymentList.find(filters).exec();
}
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
return this.planPaymentList.create(data);
}
public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
if (!data.transactionId) {
return this.create(data);
}
return this.planPaymentList.findOneAndUpdate(
{ transactionId: data.transactionId },
{ $setOnInsert: data },
{ new: true, upsert: true, setDefaultsOnInsert: true }
).exec();
}
}
@@ -1,7 +1,12 @@
export type FindPlanPaymentsParams = {
_id?: string;
userId?: string;
subscriptionId?: string;
transactionId?: string;
};
export type CreatePlanPaymentParams = Omit<IPlanPayment, "id">;
export interface IPlanPayment {
id?: string;
userId: string; // Referencia al ID del usuario
@@ -15,9 +20,13 @@ export interface IPlanPayment {
export interface IPlanPaymentsAdapter {
find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
}
export interface IPlanPaymentsManager {
planPayments: IPlanPaymentsAdapter;
find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
}
@@ -0,0 +1,31 @@
import { PlanPaymentsAdapterMongoose } from "./PlanPayments.Adapter.Mongoose";
import {
CreatePlanPaymentParams,
FindPlanPaymentsParams,
IPlanPayment,
IPlanPaymentsManager,
} from "./PlanPayments.interface";
class PlanPaymentsManager implements IPlanPaymentsManager {
planPayments: PlanPaymentsAdapterMongoose;
constructor() {
this.planPayments = new PlanPaymentsAdapterMongoose();
}
public async find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
return this.planPayments.find(data);
}
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
return this.planPayments.create(data);
}
public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
return this.planPayments.createIfMissingByTransactionId(data);
}
}
const PlanPaymentsList = new PlanPaymentsManager();
export default PlanPaymentsList;
@@ -1,5 +1,4 @@
import dayjs from "dayjs";
import {
FindPlanSuscripcionsParams,
IPlanSuscriptionsAdapter,
@@ -38,6 +37,26 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
mpDateCreated: { type: Date, required: false },
mpInitPoint: { type: String, required: false },
mpPreferenceId: { type: String, required: false },
billingMonths: { type: Number, required: false },
pendingPaymentInitPoint: { type: String, required: false },
pendingPaymentPreferenceId: { type: String, required: false },
pendingPaymentBillingMonths: { type: Number, required: false },
pendingPaymentType: { type: String, required: false, enum: ["extension", "upgrade"] },
pendingPaymentPlanId: { type: Schema.Types.ObjectId, required: false, ref: "Plan" },
pendingPaymentCurrentPlanPrice: { type: Number, required: false },
pendingPaymentRequestedPlanPrice: { type: Number, required: false },
pendingPaymentProratedAmount: { type: Number, required: false },
pendingPaymentRemainingDays: { type: Number, required: false },
pendingPaymentPeriodEndDate: { type: Date, required: false },
lastPaymentStatus: { type: String, required: false, enum: ["rejected", "failed", "cancelled"] },
lastPaymentPreferenceId: { type: String, required: false },
lastPaymentAt: { type: Date, required: false },
lastPaymentType: { type: String, required: false, enum: ["new", "extension", "upgrade"] },
downgradedFromPlanId: { type: Schema.Types.ObjectId, required: false, ref: "Plan" },
downgradedFromPlanName: { type: String, required: false },
downgradedFromPlanCode: { type: String, required: false },
downgradedAt: { type: Date, required: false },
downgradeReason: { type: String, required: false, enum: ["expired"] },
});
this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
@@ -54,20 +73,32 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
public async findOne(
filters: FindPlanSuscripcionsParams
): Promise<IPlanSuscriptionDocument | null> {
let subscription = await this.planSuscriptionList
.findOne({ userId: filters.sessionUser })
.exec();
const searchCriteria: Record<string, unknown> = {};
if (filters.sessionUser) {
searchCriteria.userId = filters.sessionUser;
}
if (filters.mpPreferenceId) {
searchCriteria.mpPreferenceId = filters.mpPreferenceId;
}
if (filters.mpStatus) {
searchCriteria.mpStatus = filters.mpStatus;
}
let subscription = await this.planSuscriptionList.findOne(searchCriteria).exec();
if (!subscription) {
return null;
}
const dateNow = dayjs(new Date());
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
try {
if (subscription.mpPreferenceId) {
if (dateNow.isAfter(dateEnd)) {
if (dateNow > dateEnd) {
if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) {
subscription.mpPreferenceId = "";
subscription.isActive = false;
@@ -77,17 +108,7 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
await subscription.save();
return subscription;
}
// Without subscriptions, we wait for the webhook to update the status.
// If it's expired and not updated by webhook, we just cancel it.
if (subscription.mpStatus === MP_SUBS_STATUS.PENDING) {
subscription.mpPreferenceId = "";
subscription.isActive = false;
subscription.autoRenew = false;
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.mpInitPoint = "";
await subscription.save();
return subscription;
}
// Pending Checkout Preferences do not consume paid time; webhook decides the final status.
}
}
} catch (e) {
@@ -3,6 +3,8 @@ import { IPlanSuscriptionDocument } from "./PlanSubscriptions.Adapter.Mongoose";
export type FindPlanSuscripcionsParams = {
sessionUser: string;
mpPreferenceId?: string;
mpStatus?: string;
};
export type CreatePlanSuscriptionParams = {
@@ -11,6 +13,7 @@ export type CreatePlanSuscriptionParams = {
mpPlanId: string;
startDate: Date;
endDate: Date;
isActive?: boolean;
autoRenew: boolean;
mpPayerId?: number;
mpPayerEmail?: string;
@@ -18,6 +21,26 @@ export type CreatePlanSuscriptionParams = {
mpDateCreated?: Date;
mpInitPoint?: string;
mpPreferenceId?: string;
billingMonths?: number;
pendingPaymentInitPoint?: string;
pendingPaymentPreferenceId?: string;
pendingPaymentBillingMonths?: number;
pendingPaymentType?: "extension" | "upgrade";
pendingPaymentPlanId?: string;
pendingPaymentCurrentPlanPrice?: number;
pendingPaymentRequestedPlanPrice?: number;
pendingPaymentProratedAmount?: number;
pendingPaymentRemainingDays?: number;
pendingPaymentPeriodEndDate?: Date;
lastPaymentStatus?: LastPlanPaymentStatus;
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
};
export type CancellPlanSuscriptionParams = {
@@ -47,6 +70,7 @@ export type MPPreApprovalResponse = {
export type ToFreePlanParams = {
sessionUser: string;
downgradeReason?: DowngradeReason;
};
export type ToMpParams = {
@@ -68,6 +92,23 @@ export type GetSubscriptionInitPointParams = {
sessionUser: string;
};
export type VerifyPendingPlanPaymentParams = {
sessionUser: string;
};
export type VerifyPendingPlanPaymentResponse = {
status: "approved" | "pending" | "not_found" | "rejected" | "failed" | "cancelled";
approved: boolean;
message: string;
subscriptionId?: string;
paymentId?: string;
paymentType?: "new" | "extension" | "upgrade";
};
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
export type LastPlanPaymentType = "new" | "extension" | "upgrade";
export type DowngradeReason = "expired";
export interface GetSuscriptionInitPointResponse {
init_point: string;
}
@@ -88,6 +129,26 @@ export interface IPlanSuscription {
mpDateCreated: Date; // Fecha de creación de la suscripción en MercadoPago.
mpInitPoint: string; // URL de inicio de la suscripción en MercadoPago.
mpPreferenceId: string; // ID de la preferencia en MercadoPago.
billingMonths?: number; // Cantidad de meses comprados para pagos únicos.
pendingPaymentInitPoint?: string; // URL de pago pendiente para extender el plan actual.
pendingPaymentPreferenceId?: string; // ID de preferencia pendiente para extender el plan actual.
pendingPaymentBillingMonths?: number; // Meses pendientes de acreditar al aprobarse la extensión.
pendingPaymentType?: "extension" | "upgrade";
pendingPaymentPlanId?: string;
pendingPaymentCurrentPlanPrice?: number;
pendingPaymentRequestedPlanPrice?: number;
pendingPaymentProratedAmount?: number;
pendingPaymentRemainingDays?: number;
pendingPaymentPeriodEndDate?: Date;
lastPaymentStatus?: LastPlanPaymentStatus;
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
}
export interface ISubscriptionInfo {
@@ -99,6 +160,18 @@ export interface ISubscriptionInfo {
autoRenew: boolean;
mpStatus: string;
mpDateCreated: Date;
pendingPaymentInitPoint?: string;
pendingPaymentPreferenceId?: string;
pendingPaymentType?: "extension" | "upgrade";
lastPaymentStatus?: LastPlanPaymentStatus;
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
}
export interface IPlanSuscriptionsAdapter {
@@ -0,0 +1,11 @@
const DEFAULT_BILLING_PERIOD_DAYS = 30;
export function calculateProratedUpgradeAmount(
currentPlanPrice: number,
requestedPlanPrice: number,
remainingDays: number,
billingPeriodDays = DEFAULT_BILLING_PERIOD_DAYS
): number {
const dailyDifference = (requestedPlanPrice - currentPlanPrice) / billingPeriodDays;
return Math.round(dailyDifference * remainingDays * 100) / 100;
}
@@ -23,7 +23,6 @@ import {
import axios from "axios";
import PlansList from "../../Models/Plans/Plans";
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import "dayjs/locale/es";
import { isNull } from "../../helpers/IsNull";
import { PlanFeatures } from "../Plans/Plans.interface";
@@ -31,9 +30,11 @@ import { NotificationsManager } from "../Notifications/Notifications";
import WapServerList from "../WapServer/WapServer";
import CompaniesList from "../Companies/Companies";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing";
dayjs.locale("es");
dayjs.extend(isSameOrAfter);
const DAY_MS = 24 * 60 * 60 * 1000;
class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
planSuscriptions: PlanSuscriptionsAdapterMongoose;
@@ -42,6 +43,24 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
this.planSuscriptions = new PlanSuscriptionsAdapterMongoose();
}
private shouldDowngradeExpiredSubscription(subscription: IPlanSuscriptionDocument): boolean {
return subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || subscription.mpStatus === MP_SUBS_STATUS.CANCELLED;
}
private buildMercadoPagoBackUrl(returnStatus: "success" | "failure" | "pending"): string {
const backUrl = process.env.MP_BACK_URL || "";
if (!backUrl) return backUrl;
try {
const url = new URL(backUrl);
url.searchParams.set("mp_return", returnStatus);
return url.toString();
} catch {
const separator = backUrl.includes("?") ? "&" : "?";
return `${backUrl}${separator}mp_return=${returnStatus}`;
}
}
public async getInitPoint(
data: GetSubscriptionInitPointParams
): Promise<GetSuscriptionInitPointResponse> {
@@ -135,8 +154,9 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
planId: data.planId,
mpPlanId: data.planId,
startDate: subscriptionStartDate.toDate(),
endDate: subscriptionStartDate.clone().add(data.months || 1, "months").toDate(),
autoRenew: true,
endDate: subscriptionStartDate.clone().add(10, "years").toDate(),
autoRenew: false,
mpStatus: "",
mpInitPoint: "/landing/dashboard",
});
} else if (plan.price > 0) {
@@ -168,8 +188,16 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
if (subscription) {
const subscriptionStartDate = dayjs(new Date());
subscription.endDate = subscriptionStartDate.clone().add(data.months || 1, "months").toDate();
subscription.startDate = subscriptionStartDate.toDate();
subscription.endDate = subscriptionStartDate.clone().add(10, "years").toDate();
subscription.autoRenew = false;
subscription.mpStatus = "";
subscription.mpInitPoint = dashboardUrl || "";
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
await subscription.save();
}
} else {
@@ -182,6 +210,23 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
months: data.months || 1,
});
} else {
const isActivePaidSubscription =
actualPlan.price > 0 &&
(subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
dayjs(subscription.endDate).isAfter(new Date());
if (isActivePaidSubscription && actualPlan.id !== data.planId) {
if (plan.price <= actualPlan.price) {
throw new Error("No se puede cambiar a un plan igual o inferior desde esta ruta de actualización.");
}
subscription = await this.toMP({
planId: data.planId,
sessionUser: data.sessionUser,
months: data.months || 1,
});
} else {
if (subscription.mpStatus != MP_SUBS_STATUS.CANCELLED && actualPlan.id !== data.planId) {
throw new Error(
"No se puede cambiar el plan, ya que el usuario ya tiene una suscripción activa. Para cambiarlo primero debes cancelar tu plan actual."
@@ -193,6 +238,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
sessionUser: data.sessionUser,
months: data.months || 1,
});
}
}
}
}
@@ -227,7 +273,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
}
try {
const subscriptionStartDate = dayjs(new Date());
const preferenceDate = dayjs(new Date());
let discount = 0;
if (data.months === 3) discount = plan.discount3Months || 0;
@@ -237,21 +283,74 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const totalPrice = plan.price * data.months;
const finalPrice = totalPrice - (totalPrice * (discount / 100));
const currentSubscription = await this.planSuscriptions.findOne({
sessionUser: data.sessionUser,
});
const currentPlan = currentSubscription
? await PlansList.plans.findOne({ _id: String(currentSubscription.planId) })
: null;
const isUpgradePayment =
currentSubscription &&
currentPlan &&
String(currentSubscription.planId) !== String(data.planId) &&
currentPlan.price > 0 &&
plan.price > currentPlan.price &&
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
dayjs(currentSubscription.endDate).isAfter(preferenceDate);
const remainingDays = isUpgradePayment
? Math.max(0, Math.ceil((new Date(currentSubscription.endDate).getTime() - preferenceDate.toDate().getTime()) / DAY_MS))
: 0;
const proratedAmount = isUpgradePayment
? calculateProratedUpgradeAmount(currentPlan.price, plan.price, remainingDays)
: 0;
if (isUpgradePayment && remainingDays <= 0) {
throw new Error("No se puede actualizar el plan porque la suscripción actual no tiene días restantes.");
}
if (
currentSubscription?.pendingPaymentPreferenceId &&
currentSubscription.pendingPaymentInitPoint
) {
if (String(currentSubscription.pendingPaymentPlanId || currentSubscription.planId) === String(data.planId)) {
return currentSubscription;
}
throw new Error("Ya tenés un pago pendiente para tu plan. Verificalo o esperá a que se actualice antes de intentar otro cambio.");
}
if (
currentSubscription &&
String(currentSubscription.planId) === String(data.planId) &&
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
currentSubscription.pendingPaymentPreferenceId &&
currentSubscription.pendingPaymentInitPoint
) {
return currentSubscription;
}
const response = await axios.post(
"https://api.mercadopago.com/checkout/preferences",
{
items: [
{
title: "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)",
title: isUpgradePayment
? "TurnosXpress.com.ar - Upgrade al plan " + plan.name
: "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)",
quantity: 1,
currency_id: "ARS",
unit_price: finalPrice
unit_price: isUpgradePayment ? proratedAmount : finalPrice
}
],
back_urls: {
success: process.env.MP_BACK_URL,
failure: process.env.MP_BACK_URL,
pending: process.env.MP_BACK_URL
success: this.buildMercadoPagoBackUrl("success"),
failure: this.buildMercadoPagoBackUrl("failure"),
pending: this.buildMercadoPagoBackUrl("pending")
},
auto_return: "approved",
payer: {
@@ -270,20 +369,102 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const subscriptionData = response.data;
this.planSuscriptions.delete({ sessionUser: data.sessionUser });
if (isUpgradePayment && currentSubscription && currentPlan) {
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
currentSubscription.pendingPaymentBillingMonths = undefined;
currentSubscription.pendingPaymentType = "upgrade";
currentSubscription.pendingPaymentPlanId = data.planId;
currentSubscription.pendingPaymentCurrentPlanPrice = currentPlan.price;
currentSubscription.pendingPaymentRequestedPlanPrice = plan.price;
currentSubscription.pendingPaymentProratedAmount = proratedAmount;
currentSubscription.pendingPaymentRemainingDays = remainingDays;
currentSubscription.pendingPaymentPeriodEndDate = currentSubscription.endDate;
currentSubscription.lastPaymentStatus = undefined;
currentSubscription.lastPaymentPreferenceId = undefined;
currentSubscription.lastPaymentAt = undefined;
currentSubscription.lastPaymentType = undefined;
currentSubscription.downgradedFromPlanId = undefined;
currentSubscription.downgradedFromPlanName = undefined;
currentSubscription.downgradedFromPlanCode = undefined;
currentSubscription.downgradedAt = undefined;
currentSubscription.downgradeReason = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save();
await NotificationsManager.sendSystemNotification({
userId: String(data.sessionUser),
subject: "Se ha creado una actualización de suscripción",
message: `Se ha creado una actualización al plan ${plan.name}.`,
type: NotificationType.BILLING,
code: String(currentSubscription._id)
});
return currentSubscription;
}
if (
currentSubscription &&
String(currentSubscription.planId) === String(data.planId) &&
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED)
) {
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
currentSubscription.pendingPaymentBillingMonths = data.months;
currentSubscription.pendingPaymentType = "extension";
currentSubscription.lastPaymentStatus = undefined;
currentSubscription.lastPaymentPreferenceId = undefined;
currentSubscription.lastPaymentAt = undefined;
currentSubscription.lastPaymentType = undefined;
currentSubscription.downgradedFromPlanId = undefined;
currentSubscription.downgradedFromPlanName = undefined;
currentSubscription.downgradedFromPlanCode = undefined;
currentSubscription.downgradedAt = undefined;
currentSubscription.downgradeReason = undefined;
currentSubscription.mpInitPoint = subscriptionData.init_point;
await currentSubscription.save();
await NotificationsManager.sendSystemNotification({
userId: String(data.sessionUser),
subject: "Se ha creado una nueva extensión de suscripción",
message: `Se ha creado una nueva extensión para el plan ${plan.name}.`,
type: NotificationType.BILLING,
code: String(currentSubscription._id)
});
return currentSubscription;
}
await this.planSuscriptions.delete({ sessionUser: data.sessionUser });
const subscription = await this.planSuscriptions.create({
userId: data.sessionUser,
planId: data.planId,
mpPlanId: data.planId,
startDate: subscriptionStartDate.toDate(),
endDate: subscriptionStartDate.clone().add(data.months, "months").toDate(),
startDate: preferenceDate.toDate(),
endDate: preferenceDate.toDate(),
isActive: false,
autoRenew: false,
mpPayerEmail: sessionUser.mpPayerEmail,
mpStatus: MP_SUBS_STATUS.PENDING,
mpDateCreated: new Date(),
mpInitPoint: subscriptionData.init_point,
mpPreferenceId: subscriptionData.id,
billingMonths: data.months,
pendingPaymentInitPoint: "",
pendingPaymentPreferenceId: "",
pendingPaymentBillingMonths: undefined,
pendingPaymentType: undefined,
lastPaymentStatus: undefined,
lastPaymentPreferenceId: undefined,
lastPaymentAt: undefined,
lastPaymentType: undefined,
downgradedFromPlanId: undefined,
downgradedFromPlanName: undefined,
downgradedFromPlanCode: undefined,
downgradedAt: undefined,
downgradeReason: undefined,
});
await NotificationsManager.sendSystemNotification({
@@ -330,18 +511,29 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
// We just cancel it locally if not paid, or change to free plan if paid.
}
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) {
this.toFreePlan({
if (dateNow > dateEnd) {
await this.toFreePlan({
sessionUser: data.sessionUser,
downgradeReason: "expired",
});
} else {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.mpInitPoint = "";
subscription.mpPayerEmail = "";
subscription.mpPayerId = -1;
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
subscription.pendingPaymentType = undefined;
subscription.pendingPaymentPlanId = undefined;
subscription.pendingPaymentCurrentPlanPrice = undefined;
subscription.pendingPaymentRequestedPlanPrice = undefined;
subscription.pendingPaymentProratedAmount = undefined;
subscription.pendingPaymentRemainingDays = undefined;
subscription.pendingPaymentPeriodEndDate = undefined;
await subscription.save();
}
} catch (error) {
@@ -400,12 +592,43 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
//console.log("guardando el plan gratuito...");
//try {
const downgradedFromPlan = data.downgradeReason === "expired"
? await PlansList.plans.findOne({ _id: String(subscription.planId) })
: null;
console.log("cambiando al free plan:", freePlan.id);
subscription.planId = freePlan.id;
subscription.mpStatus = "";
subscription.isActive = true;
subscription.autoRenew = false;
subscription.mpInitPoint = "";
subscription.mpPreferenceId = "";
subscription.billingMonths = undefined;
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
subscription.pendingPaymentType = undefined;
subscription.pendingPaymentPlanId = undefined;
subscription.pendingPaymentCurrentPlanPrice = undefined;
subscription.pendingPaymentRequestedPlanPrice = undefined;
subscription.pendingPaymentProratedAmount = undefined;
subscription.pendingPaymentRemainingDays = undefined;
subscription.pendingPaymentPeriodEndDate = undefined;
subscription.startDate = new Date();
subscription.endDate = dayjs(new Date()).add(10, "years").toDate();
if (data.downgradeReason === "expired" && downgradedFromPlan) {
subscription.downgradedFromPlanId = String(downgradedFromPlan.id || downgradedFromPlan._id);
subscription.downgradedFromPlanName = downgradedFromPlan.name;
subscription.downgradedFromPlanCode = downgradedFromPlan.code;
subscription.downgradedAt = new Date();
subscription.downgradeReason = "expired";
} else {
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
}
await subscription.save();
// console.log("plan gratuito guardado:", freePlan.id);
@@ -430,6 +653,15 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
autoRenew: subscription.autoRenew,
mpStatus: subscription.mpStatus,
mpDateCreated: subscription.mpDateCreated,
lastPaymentStatus: subscription.lastPaymentStatus,
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
lastPaymentAt: subscription.lastPaymentAt,
lastPaymentType: subscription.lastPaymentType,
downgradedFromPlanId: subscription.downgradedFromPlanId,
downgradedFromPlanName: subscription.downgradedFromPlanName,
downgradedFromPlanCode: subscription.downgradedFromPlanCode,
downgradedAt: subscription.downgradedAt,
downgradeReason: subscription.downgradeReason,
};
io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo);
@@ -446,15 +678,14 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null;
}
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) {
if (subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) {
this.toFreePlan({
sessionUser: data.sessionUser,
});
}
if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
await this.toFreePlan({
sessionUser: data.sessionUser,
downgradeReason: "expired",
});
}
return await this.planSuscriptions.findOne(data);
@@ -469,26 +700,30 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null;
}
const dateNow = dayjs(new Date()).endOf("day");
const dateEnd = dayjs(subscription.endDate);
const dateNow = new Date();
const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) {
//Esta condicion era la culpable de que todo el tiempo se queria aplicar el free plan.
//Ya que la fecha de finalizacion queda guardada en la base de datos a pesar de que ya no se use.
if (
subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED &&
subscription.mpStatus !== ""
) {
this.toFreePlan({
sessionUser: data.sessionUser,
});
}
if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
await this.toFreePlan({
sessionUser: data.sessionUser,
downgradeReason: "expired",
});
return this.getSubscriptionByUser(data);
}
const plan = await PlansList.plans.findOne({
let plan = await PlansList.findOne({
_id: subscription.planId,
});
const isPendingPaidSubscription = subscription.mpStatus === MP_SUBS_STATUS.PENDING && plan.price > 0;
if (!isPendingPaidSubscription && (!subscription.isActive || subscription.mpStatus === MP_SUBS_STATUS.PENDING)) {
const freePlan = await PlansList.findOne({ price: 0 });
if (freePlan) {
plan = freePlan;
}
}
if (!plan) {
throw new Error("No se ha encontrado el plan");
}
@@ -502,6 +737,18 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
autoRenew: subscription.autoRenew,
mpStatus: subscription.mpStatus,
mpDateCreated: subscription.mpDateCreated,
pendingPaymentInitPoint: subscription.pendingPaymentInitPoint,
pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId,
pendingPaymentType: subscription.pendingPaymentType,
lastPaymentStatus: subscription.lastPaymentStatus,
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
lastPaymentAt: subscription.lastPaymentAt,
lastPaymentType: subscription.lastPaymentType,
downgradedFromPlanId: subscription.downgradedFromPlanId,
downgradedFromPlanName: subscription.downgradedFromPlanName,
downgradedFromPlanCode: subscription.downgradedFromPlanCode,
downgradedAt: subscription.downgradedAt,
downgradeReason: subscription.downgradeReason,
};
}
@@ -514,6 +761,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
}
const plan = subscriptionInfo.plan;
if (!subscriptionInfo.isActive) {
return false;
}
if (!plan) {
throw new Error("No se encontro el plan de la suscripcion");
}
@@ -553,7 +804,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
if (!subscriptionInfo) {
throw new Error("No se encontro una suscripcion activa");
}
return subscriptionInfo.isActive;
return subscriptionInfo.isActive && subscriptionInfo.mpStatus !== MP_SUBS_STATUS.PENDING;
}
}
@@ -1,4 +1,14 @@
import dayjs from "dayjs";
import { calculateProratedUpgradeAmount } from "../PlanSubscriptions.pricing";
import PlanSubscriptionsList from "../PlanSubscriptons";
import { MP_SUBS_STATUS } from "../PlanSubscriptions.Adapter.Mongoose";
import PlansList from "../../Plans/Plans";
jest.mock("../../../index", () => ({
io: {
to: jest.fn(() => ({ emit: jest.fn() })),
},
}));
describe("PlanSubscriptions Logic", () => {
it("should calculate correct endDate for 1, 3, 6, 12 months", () => {
@@ -40,4 +50,71 @@ describe("PlanSubscriptions Logic", () => {
const final12 = total12 - (total12 * (30 / 100)); // 12000 - 3600 = 8400
expect(final12).toBe(8400);
});
it("should calculate only the prorated difference for paid plan upgrades", () => {
const currentPlanPrice = 1000;
const requestedPlanPrice = 2500;
const remainingDays = 15;
const proratedAmount = calculateProratedUpgradeAmount(
currentPlanPrice,
requestedPlanPrice,
remainingDays
);
expect(proratedAmount).toBe(750);
});
it("should keep pending paid subscription plan instead of masking it as free", async () => {
const paidPlan = {
id: "paid-plan-id",
name: "Intermedio",
description: "",
features: [],
code: "intermediate",
price: 1000,
annualPrice: 0,
limitOrganizations: 1,
limitEmployees: 1,
limitServices: 1,
limitAppointments: 1,
limitClients: 1,
limitRepeats: 1,
mailNotifications: false,
smsNotifications: false,
wapNotifications: false,
bot: false,
active: true,
dateLimit: false,
payments: false,
};
const subscription = {
id: "subscription-id",
planId: paidPlan.id,
startDate: new Date(),
endDate: new Date(),
isActive: false,
autoRenew: false,
mpStatus: MP_SUBS_STATUS.PENDING,
mpDateCreated: new Date(),
};
const originalPlanSuscriptions = PlanSubscriptionsList.planSuscriptions;
const findOneSpy = jest.spyOn(PlansList, "findOne").mockResolvedValue(paidPlan);
PlanSubscriptionsList.planSuscriptions = {
findOne: jest.fn().mockResolvedValue(subscription),
} as any;
try {
const result = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: "user-id" });
expect(result?.plan).toEqual(paidPlan);
expect(result?.mpStatus).toBe(MP_SUBS_STATUS.PENDING);
expect(findOneSpy).toHaveBeenCalledTimes(1);
expect(findOneSpy).toHaveBeenCalledWith({ _id: subscription.planId });
} finally {
PlanSubscriptionsList.planSuscriptions = originalPlanSuscriptions;
findOneSpy.mockRestore();
}
});
});
@@ -0,0 +1,153 @@
import { Document, Model, Schema, model } from "mongoose";
import {
CreatePlanUsageCycleParams,
FindPlanUsageCycleParams,
IPlanUsageCycle,
IPlanUsageCycleAdapter,
UpdatePlanUsageCycleParams,
} from "./PlanUsageCycle.Interface";
export interface IPlanUsageCycleDocument extends Omit<IPlanUsageCycle, "id">, Document {}
export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
schema: Schema;
planUsageCycleList: Model<IPlanUsageCycleDocument>;
constructor() {
this.schema = new Schema({
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
subscriptionId: { type: Schema.Types.ObjectId, required: false, ref: "PlanSuscription" },
planId: { type: Schema.Types.ObjectId, required: true, ref: "Plan" },
cycleStart: { type: Date, required: true },
cycleEnd: { type: Date, required: true },
appointmentsCount: { type: Number, required: true, default: 0 },
creationDate: { type: Date, required: true, default: Date.now },
updateDate: { type: Date, required: true, default: Date.now },
});
this.schema.index({ userId: 1, subscriptionId: 1, planId: 1, cycleStart: 1 }, { unique: true });
this.planUsageCycleList = model<IPlanUsageCycleDocument>("PlanUsageCycle", this.schema);
}
public async findOne(filters: FindPlanUsageCycleParams): Promise<IPlanUsageCycleDocument | null> {
return await this.planUsageCycleList.findOne(filters);
}
public async create(data: CreatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
return await this.planUsageCycleList.create({
...data,
appointmentsCount: data.appointmentsCount || 0,
updateDate: new Date(),
});
}
public async getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
const now = new Date();
return await this.planUsageCycleList.findOneAndUpdate(
{
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
},
{
$setOnInsert: {
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
cycleEnd: data.cycleEnd,
creationDate: now,
},
$set: { updateDate: now },
},
{ new: true, upsert: true }
);
}
public async incrementAppointments(
data: UpdatePlanUsageCycleParams & { quantity: number }
): Promise<IPlanUsageCycleDocument | null> {
const now = new Date();
return await this.planUsageCycleList.findOneAndUpdate(
{
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
},
{
$setOnInsert: {
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
cycleEnd: data.cycleEnd,
creationDate: now,
},
$inc: { appointmentsCount: data.quantity },
$set: { updateDate: now },
},
{ new: true, upsert: true }
);
}
public async incrementAppointmentsIfWithinLimit(
data: UpdatePlanUsageCycleParams & { quantity: number; limit: number }
): Promise<IPlanUsageCycleDocument | null> {
const now = new Date();
return await this.planUsageCycleList.findOneAndUpdate(
{
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
appointmentsCount: { $lte: data.limit - data.quantity },
},
{
$setOnInsert: {
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
cycleEnd: data.cycleEnd,
appointmentsCount: 0,
creationDate: now,
},
$inc: { appointmentsCount: data.quantity },
$set: { updateDate: now },
},
{ new: true }
);
}
public async setAppointmentsCount(
data: UpdatePlanUsageCycleParams & { appointmentsCount: number }
): Promise<IPlanUsageCycleDocument | null> {
const now = new Date();
return await this.planUsageCycleList.findOneAndUpdate(
{
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
},
{
$setOnInsert: {
userId: data.userId,
subscriptionId: data.subscriptionId,
planId: data.planId,
cycleStart: data.cycleStart,
cycleEnd: data.cycleEnd,
creationDate: now,
},
$set: {
appointmentsCount: Math.max(data.appointmentsCount, 0),
updateDate: now,
},
},
{ new: true, upsert: true }
);
}
}
@@ -0,0 +1,99 @@
import { IPlanUsageCycleDocument } from "./PlanUsageCycle.Adapter.Mongoose";
export interface IPlanUsageCycle {
id?: string;
userId: string;
subscriptionId?: string;
planId: string;
cycleStart: Date;
cycleEnd: Date;
appointmentsCount: number;
creationDate: Date;
updateDate: Date;
}
export type FindPlanUsageCycleParams = {
userId?: string;
subscriptionId?: string;
planId?: string;
cycleStart?: Date;
cycleEnd?: Date;
};
export type CreatePlanUsageCycleParams = {
userId: string;
subscriptionId?: string;
planId: string;
cycleStart: Date;
cycleEnd: Date;
appointmentsCount?: number;
};
export type CurrentPlanUsageCycleParams = {
userId: string;
now?: Date;
limit?: number;
};
export type SetPlanUsageCycleAppointmentsParams = CurrentPlanUsageCycleParams & {
appointmentsCount: number;
};
export type SysAdminRecalculatePlanUsageCycleParams = {
userId: string;
};
export type SysAdminRecalculatePlanUsageCycleResult = {
userId: string;
subscriptionId: string;
planId: string;
cycleStart: Date;
cycleEnd: Date;
organizationsCount: number;
employeesCount: number;
servicesCount: number;
clientsCount: number;
repeatsCount: number;
appointmentsCount: number;
};
export type UpdatePlanUsageCycleParams = {
userId: string;
subscriptionId: string;
planId: string;
cycleStart: Date;
cycleEnd: Date;
};
export type CalculateCurrentCycleParams = {
subscriptionStartDate: Date;
now?: Date;
};
export type PlanUsageCycleRange = {
cycleStart: Date;
cycleEnd: Date;
};
export interface IPlanUsageCycleAdapter {
findOne(filters: FindPlanUsageCycleParams): Promise<IPlanUsageCycleDocument | null>;
create(data: CreatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
incrementAppointments(data: UpdatePlanUsageCycleParams & { quantity: number }): Promise<IPlanUsageCycleDocument | null>;
incrementAppointmentsIfWithinLimit(
data: UpdatePlanUsageCycleParams & { quantity: number; limit: number }
): Promise<IPlanUsageCycleDocument | null>;
setAppointmentsCount(
data: UpdatePlanUsageCycleParams & { appointmentsCount: number }
): Promise<IPlanUsageCycleDocument | null>;
}
export interface IPlanUsageCycleManager {
planUsageCycle: IPlanUsageCycleAdapter;
getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void>;
reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<boolean>;
releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void>;
getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise<number>;
setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise<IPlanUsageCycleDocument | null>;
}
@@ -0,0 +1,20 @@
import dayjs from "dayjs";
import { CalculateCurrentCycleParams, PlanUsageCycleRange } from "./PlanUsageCycle.Interface";
export function calculateCurrentPlanUsageCycle(data: CalculateCurrentCycleParams): PlanUsageCycleRange {
const now = dayjs(data.now || new Date());
const anchor = dayjs(data.subscriptionStartDate);
let cycleStart = anchor.clone();
let cycleEnd = cycleStart.clone().add(1, "month");
while (!cycleEnd.isAfter(now)) {
cycleStart = cycleEnd;
cycleEnd = cycleStart.clone().add(1, "month");
}
return {
cycleStart: cycleStart.toDate(),
cycleEnd: cycleEnd.toDate(),
};
}
@@ -0,0 +1,102 @@
import { isNull } from "../../helpers/IsNull";
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
import {
CurrentPlanUsageCycleParams,
IPlanUsageCycleManager,
SetPlanUsageCycleAppointmentsParams,
UpdatePlanUsageCycleParams,
} from "./PlanUsageCycle.Interface";
import {
IPlanUsageCycleDocument,
PlanUsageCycleAdapterMongoose,
} from "./PlanUsageCycle.Adapter.Mongoose";
import { calculateCurrentPlanUsageCycle } from "./PlanUsageCycle.helpers";
export { calculateCurrentPlanUsageCycle } from "./PlanUsageCycle.helpers";
class PlanUsageCycleManager implements IPlanUsageCycleManager {
planUsageCycle: PlanUsageCycleAdapterMongoose;
constructor() {
this.planUsageCycle = new PlanUsageCycleAdapterMongoose();
}
private async getCurrentCycleData(data: CurrentPlanUsageCycleParams): Promise<UpdatePlanUsageCycleParams> {
const subscription = await PlanSubscriptionsList.findOne({
sessionUser: data.userId,
});
if (!subscription) {
throw new Error("No se ha encontrado una suscripción activa");
}
const { cycleStart, cycleEnd } = calculateCurrentPlanUsageCycle({
subscriptionStartDate: subscription.startDate,
now: data.now,
});
return {
userId: data.userId,
subscriptionId: String(subscription.id),
planId: String(subscription.planId),
cycleStart,
cycleEnd,
};
}
public async getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
return await this.planUsageCycle.getOrCreateCurrent(await this.getCurrentCycleData(data));
}
public async addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void> {
await this.planUsageCycle.incrementAppointments({
...(await this.getCurrentCycleData(data)),
quantity: isNull<number>(data.quantity, 1),
});
}
public async reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<boolean> {
if (typeof data.limit !== "number") {
return false;
}
const quantity = isNull<number>(data.quantity, 1);
const usageCycleData = await this.getCurrentCycleData(data);
const usageCycle = await this.planUsageCycle.getOrCreateCurrent(usageCycleData);
if (usageCycle.appointmentsCount + quantity > data.limit) {
return false;
}
const updatedCycle = await this.planUsageCycle.incrementAppointmentsIfWithinLimit({
...usageCycleData,
quantity,
limit: data.limit,
});
return !!updatedCycle;
}
public async releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void> {
await this.addAppointment({
...data,
quantity: -Math.abs(isNull<number>(data.quantity, 1)),
});
}
public async getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise<number> {
const usageCycle = await this.getCurrentCycle(data);
return usageCycle.appointmentsCount;
}
public async setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise<IPlanUsageCycleDocument | null> {
return await this.planUsageCycle.setAppointmentsCount({
...(await this.getCurrentCycleData(data)),
appointmentsCount: data.appointmentsCount,
});
}
}
const PlanUsageCycleList = new PlanUsageCycleManager();
export default PlanUsageCycleList;
@@ -1,4 +1,4 @@
import { FindPlansParams, IPlansAdapter, IPlan } from "./Plans.interface";
import { FindPlansParams, IPlansAdapter, IPlan, SysAdminUpdatePlanParams } from "./Plans.interface";
import { Document, Model, Schema, model } from "mongoose";
export interface IPlanDocument extends Omit<IPlan, "id">, Document {}
@@ -45,4 +45,8 @@ export class PlansAdapterMongoose implements IPlansAdapter {
public async findOne(filters: FindPlansParams): Promise<IPlanDocument | null> {
return this.planList.findOne(filters).exec();
}
public async updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void> {
await this.planList.updateOne({ _id: planId }, { $set: data }).exec();
}
}
@@ -12,6 +12,33 @@ export type FindPlansParams = {
price?: number;
};
export type SysAdminUpdatePlanParams = {
planId: string;
name?: string;
description?: string;
features?: string[];
code?: string;
price?: number;
annualPrice?: number;
limitOrganizations?: number;
limitEmployees?: number;
limitServices?: number;
limitAppointments?: number;
limitClients?: number;
limitRepeats?: number;
mailNotifications?: boolean;
smsNotifications?: boolean;
wapNotifications?: boolean;
payments?: boolean;
bot?: boolean;
active?: boolean;
dateLimit?: boolean;
discount3Months?: number;
discount6Months?: number;
discount12Months?: number;
featured?: boolean;
};
export interface IPlan {
id?: string;
name: string;
@@ -44,10 +71,12 @@ export interface IPlan {
export interface IPlansAdapter {
find(filters: FindPlansParams): Promise<IPlan[]>;
findOne(filters: FindPlansParams): Promise<IPlan | null>;
updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void>;
}
export interface IPlansManager {
plans: IPlansAdapter;
find(data: FindPlansParams): Promise<IPlan[]>;
findOne(data: FindPlansParams): Promise<IPlan>;
sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise<void>;
}
+7 -1
View File
@@ -1,6 +1,6 @@
import { isNull } from "../../helpers/IsNull";
import { PlansAdapterMongoose } from "./Plans.Adapter.Mongoose";
import { FindPlansParams, IPlan, IPlansManager } from "./Plans.interface";
import { FindPlansParams, IPlan, IPlansManager, SysAdminUpdatePlanParams } from "./Plans.interface";
class PlansManager implements IPlansManager {
plans: PlansAdapterMongoose;
@@ -59,6 +59,12 @@ class PlansManager implements IPlansManager {
return this.getOnePlan(plan);
}
public async sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise<void> {
const { planId, ...planData } = data;
await this.findOne({ _id: planId });
await this.plans.updateOne(planId, planData);
}
}
const PlansList = new PlansManager();
@@ -4,6 +4,8 @@ export type ScheduleItem = {
from: string;
to: string;
disabled: boolean;
serviceScope?: "all" | "specific";
serviceIds?: string[];
};
export type FindSchedulesParams = {
@@ -19,6 +21,7 @@ export type AvailableSchedulesParams = {
weekDay: number;
from: string;
to: string;
serviceId?: string;
};
export type PaginateSchedulesParams = FindSchedulesParams & {
+12
View File
@@ -173,6 +173,14 @@ class SchedulesManager implements ISchedulesManager {
return await this.schedules.find(filters);
}
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true;
}
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
}
private async getScheduleByFrom(data: AvailableSchedulesParams): Promise<ScheduleItem | null> {
const schedule = await this.schedules.findOne({
employeeId: data.employeeId,
@@ -185,6 +193,9 @@ class SchedulesManager implements ISchedulesManager {
if (horario.disabled) {
continue;
}
if (!this.isScheduleAvailableForService(horario, data.serviceId)) {
continue;
}
if (horario.from == data.from) {
return horario;
}
@@ -236,6 +247,7 @@ class SchedulesManager implements ISchedulesManager {
weekDay: data.weekDay,
from: scheduleFrom.to,
to: data.to,
serviceId: data.serviceId,
});
if (!scheduleTo) {
@@ -14,6 +14,7 @@ export type AvailableSchedulesOverridesParams = {
dateDay: Date;
from: string;
to: string;
serviceId?: string;
};
export type PaginateSchedulesOverridesParams = FindSchedulesOverridesParams & {
@@ -154,6 +154,14 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
return await this.schedulesOverrides.findOne(filters);
}
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true;
}
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
}
private async getScheduleByFrom(data: AvailableSchedulesOverridesParams): Promise<ScheduleItem | null> {
const exactDate = dayjs(data.dateDay).startOf("day").toDate();
const schedule = await this.schedulesOverrides.findOne({
@@ -165,6 +173,7 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
if (schedule) {
for (const horario of schedule.schedules) {
if (horario.disabled) continue;
if (!this.isScheduleAvailableForService(horario, data.serviceId)) continue;
if (horario.from == data.from) return horario;
if (horario.from < data.from && horario.to > data.from) return horario;
}
@@ -205,6 +205,73 @@ export type SysAdminOrganizationsStatusParams = {
userId: string;
};
export type SysAdminUserSubscriptionDetailsParams = {
userId: string;
};
export type SysAdminExtendUserSubscriptionMode = "add_months" | "set_end_date";
export type SysAdminExtendUserSubscriptionParams = {
userId: string;
mode: SysAdminExtendUserSubscriptionMode;
months?: 1 | 2;
endDate?: string;
reason?: string;
};
export type SysAdminExtendUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: Date;
newEndDate: Date;
paymentId: string;
transactionId?: string;
};
export type SysAdminFinalizeUserSubscriptionParams = {
userId: string;
reason?: string;
};
export type SysAdminFinalizeUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: Date;
newEndDate: Date;
};
export type SysAdminUserSubscriptionPayment = {
id: string;
subscriptionId: string;
amount: number;
paymentDate: Date;
paymentMethod?: string;
status: string;
transactionId?: string;
};
export type SysAdminUserSubscriptionDetailsResult = {
userId: string;
currentSubscription: {
id: string;
plan: {
id: string;
name: string;
code: string;
price: number;
} | null;
startDate: Date;
endDate: Date;
isActive: boolean;
mpStatus?: string;
billingMonths?: number;
pendingPaymentType?: "extension" | "upgrade";
pendingPaymentPreferenceId?: string;
lastPaymentStatus?: string;
} | null;
payments: SysAdminUserSubscriptionPayment[];
};
export type SysAdminUserOrganizationsResult = {
clientIn: { companyId: string; companyName: string }[];
collaboratorIn: { companyId: string; companyName: string }[];
+10 -31
View File
@@ -197,7 +197,7 @@ class UsersManager implements IUsersManager {
if (sub) {
if (sub.plan.price > 0) {
throw new Error("Para darte de baja primero tenes que cancelar tu suscripción.");
throw new Error("No podés darte de baja mientras tengas un plan pago vigente. Podés eliminar tus organizaciones para borrar su contenido y seguir usando la cuenta hasta el vencimiento.");
}
}
@@ -284,7 +284,7 @@ class UsersManager implements IUsersManager {
userId: data.sessionUser,
});
//Eliminar los clientes creados con el usuario.
//Desvincular los clientes creados con el usuario sin borrar el historial de la organizacion.
const clients = await ClientsList.clients.find({
userId: data.sessionUser,
});
@@ -294,21 +294,7 @@ class UsersManager implements IUsersManager {
continue;
}
//Tengo que pasarlo como sesionUSer al duenio de la organizacion. para que pueda eliminar.
const orgClient = await CompaniesList.companies.findOne({
_id: String(client.companyId),
});
if (!orgClient) {
continue;
}
//En este caso borra el cliente pero no chequea el saldo de la cuenta.
await ClientsList.deleteClient({
clientId: String(client.id),
checkCashOnAccount: false,
sessionUser: String(orgClient.ownerId),
});
await ClientsList.clients.detachUser({ clientId: String(client.id) });
}
//Eliminar las conversaciones
@@ -630,7 +616,7 @@ class UsersManager implements IUsersManager {
// Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
});
// Create a user object by merging the payload and the token
@@ -684,7 +670,7 @@ class UsersManager implements IUsersManager {
// If the token expires in less than 5 minutes, generate a new one.
if (decodedPayload.exp < nowInSeconds + fiveMinutesInSeconds) {
finalToken = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
});
}
@@ -765,7 +751,7 @@ class UsersManager implements IUsersManager {
// Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
});
// Create a user object by merging the payload and the token
@@ -810,7 +796,7 @@ class UsersManager implements IUsersManager {
// Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
});
// Create a user object by merging the payload and the token
@@ -862,7 +848,7 @@ class UsersManager implements IUsersManager {
// Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
});
// Create a user object by merging the payload and the token
@@ -907,7 +893,7 @@ class UsersManager implements IUsersManager {
// Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
});
// Create a user object by merging the payload and the token
@@ -1279,14 +1265,7 @@ class UsersManager implements IUsersManager {
for (const client of clients) {
if (!client.id) continue;
const orgClient = await CompaniesList.companies.findOne({ _id: String(client.companyId) });
if (!orgClient) continue;
await ClientsList.deleteClient({
clientId: String(client.id),
checkCashOnAccount: false,
sessionUser: String(orgClient.ownerId),
});
await ClientsList.clients.detachUser({ clientId: String(client.id) });
}
await MessageConversationList.deleteConversationsByUser({ userId: data.userId });
@@ -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;
@@ -10,6 +10,7 @@ import CashFlowList from "../../Models/CashFlow/CashFlow";
import CompaniesList from "../../Models/Companies/Companies";
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
import MetricsList from "../../Models/Metrics/Metrics";
import PlanUsageCycleList from "../../Models/PlanUsageCycle/PlanUsageCycle";
export class DashboardService {
public async getDashboardSummary(sessionUser: string): Promise<DashboardSummaryResult> {
@@ -198,11 +199,18 @@ export class DashboardService {
const isOwner = String(ownerId) === String(params.sessionUser);
// Fetch metrics for owner
const metrics = await MetricsList.getMetrics(String(ownerId));
const metricsDocument = await MetricsList.getMetrics(String(ownerId));
const metrics = metricsDocument.toObject ? metricsDocument.toObject() : { ...metricsDocument };
// Fetch subscription for owner
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: String(ownerId) });
if (subscription) {
metrics.appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({
userId: String(ownerId),
});
}
return {
metrics,
subscription,
@@ -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);
}
}
@@ -2,6 +2,43 @@ import axios from "axios";
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
import { connect } from "mongoose";
import dayjs from "dayjs";
import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments";
import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
type MercadoPagoPaymentData = {
id: string | number;
status: string;
external_reference?: string;
preference_id?: string;
order?: { id?: string };
date_approved?: string;
payer?: { id?: number; email?: string };
transaction_amount?: number;
payment_method_id?: string;
payment_type_id?: string;
date_last_updated?: string;
};
type MercadoPagoMerchantOrder = {
id: string | number;
preference_id?: string;
external_reference?: string;
payments?: MercadoPagoPaymentData[];
};
type MercadoPagoPaymentContext = {
preferenceId?: string;
externalReference?: string;
};
type MercadoPagoPaymentVerification = {
status: "approved" | "rejected" | "failed" | "cancelled" | "pending";
payment: MercadoPagoPaymentData | null;
};
const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"];
export class MercadoPagoWebhookService {
public async handleWebhook(body: any): Promise<void> {
@@ -24,31 +61,20 @@ export class MercadoPagoWebhookService {
}
);
const paymentData = paymentResponse.data;
const paymentData: MercadoPagoPaymentData = paymentResponse.data;
const status = paymentData.status; // approved, pending, rejected, etc.
const userId = paymentData.external_reference; // We passed this when creating preference
if (userId && status === "approved") {
// Update subscription status in DB
const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
sessionUser: userId,
});
const paymentContext = await this.resolvePaymentContext(paymentData);
const userId = paymentContext.externalReference || paymentData.external_reference; // We passed this when creating preference
const preferenceId = paymentContext.preferenceId || paymentData.preference_id;
const subscription = await this.findSubscriptionForPayment(userId, preferenceId);
if (userId && preferenceId && status === "approved") {
if (subscription) {
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED;
subscription.isActive = true;
// Assuming endDate is correctly set for the months purchased when preference was created
await subscription.save();
await this.applyApprovedPayment(subscription, paymentData, userId, preferenceId);
}
} else if (userId && status === "rejected") {
const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
sessionUser: userId,
});
if (subscription && subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED) {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.isActive = false;
await subscription.save();
} else if (userId && preferenceId && REJECTED_PAYMENT_STATUSES.includes(status)) {
if (subscription) {
await this.applyRejectedPayment(subscription, paymentData, userId, preferenceId);
}
}
} catch (error) {
@@ -56,4 +82,320 @@ export class MercadoPagoWebhookService {
}
}
}
public async verifyPendingPlanPayment(sessionUser: string): Promise<VerifyPendingPlanPaymentResponse> {
const pendingSubscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
userId: sessionUser,
mpPreferenceId: { $exists: true, $ne: "" },
mpStatus: MP_SUBS_STATUS.PENDING,
}).exec();
const pendingExistingSubscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
userId: sessionUser,
pendingPaymentPreferenceId: { $exists: true, $ne: "" },
mpStatus: MP_SUBS_STATUS.AUTHORIZED,
}).exec();
const subscription = pendingSubscription || pendingExistingSubscription;
if (!subscription) {
return {
status: "not_found",
approved: false,
message: "No se encontró un pago pendiente para verificar.",
};
}
const preferenceId = pendingSubscription
? subscription.mpPreferenceId
: subscription.pendingPaymentPreferenceId;
if (!preferenceId) {
return {
status: "not_found",
approved: false,
message: "No se encontró una preferencia de pago pendiente para verificar.",
subscriptionId: String(subscription._id),
};
}
const paymentVerification = await this.findPaymentForPreference(sessionUser, preferenceId);
if (!paymentVerification.payment || paymentVerification.status === "pending") {
return {
status: "pending",
approved: false,
message: "El pago todavía no figura confirmado.",
subscriptionId: String(subscription._id),
paymentType: this.getPaymentType(subscription, preferenceId),
};
}
const paymentType = this.getPaymentType(subscription, preferenceId);
const payment = paymentVerification.payment;
if (paymentVerification.status !== "approved") {
await this.applyRejectedPayment(subscription, payment, sessionUser, preferenceId);
return {
status: paymentVerification.status,
approved: false,
message: "El pago fue rechazado. Tu plan actual no cambió y podés intentarlo nuevamente.",
subscriptionId: String(subscription._id),
paymentId: String(payment.id),
paymentType,
};
}
await this.applyApprovedPayment(subscription, payment, sessionUser, preferenceId);
return {
status: "approved",
approved: true,
message: "El pago fue aprobado y la suscripción fue actualizada.",
subscriptionId: String(subscription._id),
paymentId: String(payment.id),
paymentType,
};
}
private async findPaymentForPreference(sessionUser: string, preferenceId: string): Promise<MercadoPagoPaymentVerification> {
const merchantOrders = await this.searchMerchantOrdersByPreferenceId(preferenceId);
const merchantOrder = merchantOrders.find((order) => {
return !order.external_reference || order.external_reference === sessionUser;
});
const merchantOrderPayment = this.pickMostRelevantPayment(merchantOrder?.payments || []);
if (merchantOrderPayment) {
const payment = await this.getPaymentById(merchantOrderPayment.id);
return {
status: this.normalizePaymentStatus(payment.status),
payment,
};
}
const response = await axios.get("https://api.mercadopago.com/v1/payments/search", {
headers: this.getMercadoPagoHeaders(),
params: {
external_reference: sessionUser,
sort: "date_created",
criteria: "desc",
limit: 20,
},
});
const payments: MercadoPagoPaymentData[] = response.data?.results || [];
const payment = this.pickMostRelevantPayment(payments.filter((payment) => payment.preference_id === preferenceId));
if (!payment) {
return { status: "pending", payment: null };
}
const fullPayment = await this.getPaymentById(payment.id);
return {
status: this.normalizePaymentStatus(fullPayment.status),
payment: fullPayment,
};
}
private pickMostRelevantPayment(payments: MercadoPagoPaymentData[]): MercadoPagoPaymentData | null {
return payments.find((payment) => payment.status === "approved")
|| payments.find((payment) => REJECTED_PAYMENT_STATUSES.includes(payment.status))
|| payments[0]
|| null;
}
private normalizePaymentStatus(status: string): MercadoPagoPaymentVerification["status"] {
if (status === "approved") return "approved";
if (status === "cancelled") return "cancelled";
if (status === "failed") return "failed";
if (status === "rejected") return "rejected";
return "pending";
}
private async resolvePaymentContext(paymentData: MercadoPagoPaymentData): Promise<MercadoPagoPaymentContext> {
if (paymentData.preference_id) {
return {
preferenceId: paymentData.preference_id,
externalReference: paymentData.external_reference,
};
}
if (!paymentData.order?.id) {
return {
externalReference: paymentData.external_reference,
};
}
const merchantOrder = await this.getMerchantOrderById(paymentData.order.id);
return {
preferenceId: merchantOrder?.preference_id,
externalReference: merchantOrder?.external_reference || paymentData.external_reference,
};
}
private async searchMerchantOrdersByPreferenceId(preferenceId: string): Promise<MercadoPagoMerchantOrder[]> {
const response = await axios.get("https://api.mercadopago.com/merchant_orders/search", {
headers: this.getMercadoPagoHeaders(),
params: { preference_id: preferenceId },
});
return response.data?.elements || [];
}
private async getMerchantOrderById(merchantOrderId: string): Promise<MercadoPagoMerchantOrder | null> {
const response = await axios.get(`https://api.mercadopago.com/merchant_orders/${merchantOrderId}`, {
headers: this.getMercadoPagoHeaders(),
});
return response.data || null;
}
private async getPaymentById(paymentId: string | number): Promise<MercadoPagoPaymentData> {
const response = await axios.get(`https://api.mercadopago.com/v1/payments/${paymentId}`, {
headers: this.getMercadoPagoHeaders(),
});
return response.data;
}
private getMercadoPagoHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}`,
"Content-Type": "application/json",
};
}
private async findSubscriptionForPayment(userId?: string, preferenceId?: string): Promise<IPlanSuscriptionDocument | null> {
if (!userId || !preferenceId) return null;
let subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
sessionUser: userId,
mpPreferenceId: preferenceId,
mpStatus: MP_SUBS_STATUS.PENDING,
});
if (!subscription) {
subscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
userId,
pendingPaymentPreferenceId: preferenceId,
mpStatus: MP_SUBS_STATUS.AUTHORIZED,
}).exec();
}
return subscription;
}
private async applyApprovedPayment(
subscription: IPlanSuscriptionDocument,
paymentData: MercadoPagoPaymentData,
userId: string,
preferenceId: string
): Promise<void> {
const approvedAt = paymentData.date_approved ? new Date(paymentData.date_approved) : new Date();
const billingMonths = subscription.pendingPaymentBillingMonths || subscription.billingMonths || 1;
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
const isUpgradePayment = isExtensionPayment && subscription.pendingPaymentType === "upgrade";
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
? subscription.endDate
: approvedAt;
if (isUpgradePayment && subscription.pendingPaymentPlanId) {
subscription.planId = subscription.pendingPaymentPlanId;
}
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED;
subscription.isActive = true;
subscription.autoRenew = false;
subscription.startDate = isExtensionPayment ? subscription.startDate : approvedAt;
subscription.endDate = isUpgradePayment
? subscription.endDate
: dayjs(periodStart).add(billingMonths, "months").toDate();
subscription.mpPayerId = paymentData.payer?.id || subscription.mpPayerId;
subscription.mpPayerEmail = paymentData.payer?.email || subscription.mpPayerEmail;
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
subscription.pendingPaymentType = undefined;
subscription.pendingPaymentPlanId = undefined;
subscription.pendingPaymentCurrentPlanPrice = undefined;
subscription.pendingPaymentRequestedPlanPrice = undefined;
subscription.pendingPaymentProratedAmount = undefined;
subscription.pendingPaymentRemainingDays = undefined;
subscription.pendingPaymentPeriodEndDate = undefined;
subscription.lastPaymentStatus = undefined;
subscription.lastPaymentPreferenceId = undefined;
subscription.lastPaymentAt = undefined;
subscription.lastPaymentType = undefined;
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
await subscription.save();
await PlanPaymentsList.createIfMissingByTransactionId({
userId,
subscriptionId: String(subscription._id),
amount: paymentData.transaction_amount || 0,
paymentDate: approvedAt,
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
status: "completed",
transactionId: String(paymentData.id),
});
}
private async applyRejectedPayment(
subscription: IPlanSuscriptionDocument,
paymentData: MercadoPagoPaymentData,
userId: string,
preferenceId: string
): Promise<void> {
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
const paymentStatus = this.normalizePaymentStatus(paymentData.status);
const paymentType = this.getPaymentType(subscription, preferenceId);
if (isExtensionPayment) {
subscription.pendingPaymentInitPoint = "";
subscription.pendingPaymentPreferenceId = "";
subscription.pendingPaymentBillingMonths = undefined;
subscription.pendingPaymentType = undefined;
subscription.pendingPaymentPlanId = undefined;
subscription.pendingPaymentCurrentPlanPrice = undefined;
subscription.pendingPaymentRequestedPlanPrice = undefined;
subscription.pendingPaymentProratedAmount = undefined;
subscription.pendingPaymentRemainingDays = undefined;
subscription.pendingPaymentPeriodEndDate = undefined;
} else {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.isActive = false;
}
if (paymentStatus !== "pending" && paymentStatus !== "approved") {
subscription.lastPaymentStatus = paymentStatus;
subscription.lastPaymentPreferenceId = preferenceId;
subscription.lastPaymentAt = paymentData.date_last_updated ? new Date(paymentData.date_last_updated) : new Date();
subscription.lastPaymentType = paymentType;
}
await subscription.save();
await PlanPaymentsList.createIfMissingByTransactionId({
userId,
subscriptionId: String(subscription._id),
amount: paymentData.transaction_amount || 0,
paymentDate: new Date(),
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
status: "failed",
transactionId: String(paymentData.id),
});
}
private getPaymentType(subscription: IPlanSuscriptionDocument, preferenceId: string): "new" | "extension" | "upgrade" {
if (subscription.pendingPaymentPreferenceId === preferenceId) {
return subscription.pendingPaymentType === "upgrade" ? "upgrade" : "extension";
}
return "new";
}
}
@@ -0,0 +1,74 @@
import { MercadoPagoWebhookService } from "../MercadoPagoWebhook.Service";
import PlanPaymentsList from "../../../Models/PlanPayments/PlanPayments";
jest.mock("../../../Models/PlanPayments/PlanPayments", () => ({
__esModule: true,
default: {
createIfMissingByTransactionId: jest.fn(),
},
}));
jest.mock("../../../Models/PlanSubscriptions/PlanSubscriptons", () => ({
__esModule: true,
default: {},
}));
describe("MercadoPagoWebhookService", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should clear pending upgrade metadata without changing the current plan or dates when payment is rejected", async () => {
const startDate = new Date("2026-07-01T00:00:00.000Z");
const endDate = new Date("2026-08-01T00:00:00.000Z");
const subscription = {
_id: "subscription-id",
planId: "current-plan-id",
startDate,
endDate,
pendingPaymentInitPoint: "https://checkout.example",
pendingPaymentPreferenceId: "preference-id",
pendingPaymentBillingMonths: undefined,
pendingPaymentType: "upgrade",
pendingPaymentPlanId: "requested-plan-id",
pendingPaymentCurrentPlanPrice: 1000,
pendingPaymentRequestedPlanPrice: 2000,
pendingPaymentProratedAmount: 500,
pendingPaymentRemainingDays: 15,
pendingPaymentPeriodEndDate: endDate,
save: jest.fn().mockResolvedValue(undefined),
};
const service = new MercadoPagoWebhookService() as any;
await service.applyRejectedPayment(
subscription,
{
id: "payment-id",
status: "rejected",
transaction_amount: 500,
payment_method_id: "visa",
},
"user-id",
"preference-id"
);
expect(subscription.planId).toBe("current-plan-id");
expect(subscription.startDate).toBe(startDate);
expect(subscription.endDate).toBe(endDate);
expect(subscription.pendingPaymentInitPoint).toBe("");
expect(subscription.pendingPaymentPreferenceId).toBe("");
expect(subscription.pendingPaymentType).toBeUndefined();
expect(subscription.pendingPaymentPlanId).toBeUndefined();
expect(subscription.pendingPaymentProratedAmount).toBeUndefined();
expect(subscription.save).toHaveBeenCalledTimes(1);
expect(PlanPaymentsList.createIfMissingByTransactionId).toHaveBeenCalledWith({
userId: "user-id",
subscriptionId: "subscription-id",
amount: 500,
paymentDate: expect.any(Date),
paymentMethod: "visa",
status: "failed",
transactionId: "payment-id",
});
});
});
@@ -7,9 +7,12 @@ import {
GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse,
MPPreApprovalResponse,
VerifyPendingPlanPaymentParams,
VerifyPendingPlanPaymentResponse,
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
import { rateLimiter } from "../../middleware/ratelimiter";
@Route("suscriptions/get-init-point")
@Middlewares(authenticateMiddleware)
@@ -75,3 +78,24 @@ export class PlanSubscriptionsCancellController extends Controller {
}
}
}
@Route("suscriptions/verify-pending-payment")
@Middlewares([rateLimiter, authenticateMiddleware])
export class PlanSubscriptionsVerifyPendingPaymentController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async verifyPendingPayment(
@Body() requestBody: VerifyPendingPlanPaymentParams
): Promise<VerifyPendingPlanPaymentResponse | ApiValidationError> {
try {
const result = await new PlanSubscriptionsService().verifyPendingPayment(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -5,8 +5,12 @@ import {
GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse,
MPPreApprovalResponse,
VerifyPendingPlanPaymentParams,
VerifyPendingPlanPaymentResponse,
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import { connect } from "mongoose";
import { validateSessionUser } from "../../helpers/check";
import { MercadoPagoWebhookService } from "../MercadoPago/MercadoPagoWebhook.Service";
export class PlanSubscriptionsService {
public async getInitPoint(
@@ -27,4 +31,10 @@ export class PlanSubscriptionsService {
await connect(`${process.env.DATABASE_CONNECTION}`);
await PlanSubscriptionsList.cancell(data);
}
public async verifyPendingPayment(data: VerifyPendingPlanPaymentParams): Promise<VerifyPendingPlanPaymentResponse> {
await connect(`${process.env.DATABASE_CONNECTION}`);
await validateSessionUser({ sessionUser: data.sessionUser });
return new MercadoPagoWebhookService().verifyPendingPlanPayment(data.sessionUser);
}
}
@@ -0,0 +1,29 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { ApiValidationError } from "../../../Models/Server.Error.model";
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
import {
SysAdminRecalculatePlanUsageCycleParams,
SysAdminRecalculatePlanUsageCycleResult,
} from "../../../Models/PlanUsageCycle/PlanUsageCycle.Interface";
import { SysAdminPlanUsageCycleService } from "./SysAdminPlanUsageCycle.Service";
@Route("sysadmin/plan-usage-cycle")
@Middlewares(sysAdminAuthMiddleware)
export class SysAdminPlanUsageCycleController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("recalculate-user")
public async recalculateUserUsageCycle(
@Body() requestBody: SysAdminRecalculatePlanUsageCycleParams
): Promise<SysAdminRecalculatePlanUsageCycleResult | ApiValidationError> {
try {
const result = await new SysAdminPlanUsageCycleService().recalculateUserUsageCycle(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,58 @@
import { connect } from "mongoose";
import dayjs from "dayjs";
import AppointmentList from "../../../Models/Appointments/Appointments";
import CompaniesList from "../../../Models/Companies/Companies";
import MetricsList from "../../../Models/Metrics/Metrics";
import PlanUsageCycleList from "../../../Models/PlanUsageCycle/PlanUsageCycle";
import {
SysAdminRecalculatePlanUsageCycleParams,
SysAdminRecalculatePlanUsageCycleResult,
} from "../../../Models/PlanUsageCycle/PlanUsageCycle.Interface";
export class SysAdminPlanUsageCycleService {
public async recalculateUserUsageCycle(
params: SysAdminRecalculatePlanUsageCycleParams
): Promise<SysAdminRecalculatePlanUsageCycleResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
const usageCycle = await PlanUsageCycleList.getCurrentCycle({ userId: params.userId });
const companies = await CompaniesList.companies.find({ ownerId: params.userId });
const companyIds = companies.map((company) => String(company.id));
const appointmentsCount = companyIds.length === 0
? 0
: await AppointmentList.Appointments.AppointmentList.countDocuments({
companyId: { $in: companyIds },
creationDate: {
$gte: dayjs(usageCycle.cycleStart).toDate(),
$lt: dayjs(usageCycle.cycleEnd).toDate(),
},
});
const updatedCycle = await PlanUsageCycleList.setAppointmentsCount({
userId: params.userId,
appointmentsCount,
});
if (!updatedCycle) {
throw new Error("No se pudo recalcular el uso del plan.");
}
await MetricsList.calculateMetrics({ userId: params.userId });
const metrics = await MetricsList.getMetrics(params.userId);
return {
userId: params.userId,
subscriptionId: String(updatedCycle.subscriptionId),
planId: String(updatedCycle.planId),
cycleStart: updatedCycle.cycleStart,
cycleEnd: updatedCycle.cycleEnd,
organizationsCount: metrics.organizationsCount,
employeesCount: metrics.employeesCount,
servicesCount: metrics.servicesCount,
clientsCount: metrics.clientsCount,
repeatsCount: metrics.repeatsCount || 0,
appointmentsCount: updatedCycle.appointmentsCount,
};
}
}
@@ -0,0 +1,44 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { ApiValidationError } from "../../../Models/Server.Error.model";
import { ApiVoidResult } from "../../../Models/Api.VoidResult.type";
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
import { IPlan, SysAdminUpdatePlanParams } from "../../../Models/Plans/Plans.interface";
import { SysAdminPlansService } from "./SysAdminPlans.Service";
@Route("sysadmin/plans")
@Middlewares(sysAdminAuthMiddleware)
export class SysAdminPlansController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("list")
public async listPlans(): Promise<IPlan[] | ApiValidationError> {
try {
const plans = await new SysAdminPlansService().listPlans();
this.setStatus(200);
return plans;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "El plan fue modificado con éxito")
@Post("update")
public async updatePlan(@Body() requestBody: SysAdminUpdatePlanParams): Promise<ApiVoidResult | ApiValidationError> {
try {
await new SysAdminPlansService().updatePlan(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);
}
}
}
@@ -0,0 +1,83 @@
import PlansList from "../../../Models/Plans/Plans";
import { IPlan, SysAdminUpdatePlanParams } from "../../../Models/Plans/Plans.interface";
import { connect } from "mongoose";
export class SysAdminPlansService {
public async listPlans(): Promise<IPlan[]> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await PlansList.find({});
}
public async updatePlan(data: SysAdminUpdatePlanParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`);
await PlansList.sysAdminUpdatePlan(this.sanitizeUpdate(data));
}
private sanitizeUpdate(data: SysAdminUpdatePlanParams): SysAdminUpdatePlanParams {
if (!data.planId) {
throw new Error("Plan id is required");
}
const sanitized: SysAdminUpdatePlanParams = { planId: data.planId };
const stringFields: (keyof SysAdminUpdatePlanParams)[] = ["name", "description", "code"];
const nonNegativeNumberFields: (keyof SysAdminUpdatePlanParams)[] = [
"price",
"annualPrice",
"discount3Months",
"discount6Months",
"discount12Months",
];
const limitFields: (keyof SysAdminUpdatePlanParams)[] = [
"limitOrganizations",
"limitEmployees",
"limitServices",
"limitAppointments",
"limitClients",
"limitRepeats",
];
const booleanFields: (keyof SysAdminUpdatePlanParams)[] = [
"mailNotifications",
"smsNotifications",
"wapNotifications",
"payments",
"bot",
"active",
"dateLimit",
"featured",
];
for (const field of stringFields) {
const value = data[field];
if (typeof value === "string") {
sanitized[field] = value.trim() as never;
}
}
for (const field of nonNegativeNumberFields) {
const value = data[field];
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
sanitized[field] = value as never;
}
}
for (const field of limitFields) {
const value = data[field];
if (typeof value === "number" && Number.isFinite(value)) {
sanitized[field] = value as never;
}
}
for (const field of booleanFields) {
const value = data[field];
if (typeof value === "boolean") {
sanitized[field] = value as never;
}
}
if (Array.isArray(data.features)) {
sanitized.features = data.features.filter((feature) => typeof feature === "string").map((feature) => feature.trim()).filter(Boolean);
}
return sanitized;
}
}
@@ -0,0 +1,67 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { ApiValidationError } from "../../../Models/Server.Error.model";
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
import {
SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult,
SysAdminFinalizeUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult,
} from "../../../Models/Users/Users.Interface";
import { SysAdminSubscriptionsService } from "./SysAdminSubscriptions.Service";
@Route("sysadmin/subscriptions")
@Middlewares(sysAdminAuthMiddleware)
export class SysAdminSubscriptionsController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("extend-user")
public async extendUserSubscription(
@Body() requestBody: SysAdminExtendUserSubscriptionParams
): Promise<SysAdminExtendUserSubscriptionResult | ApiValidationError> {
try {
const result = await new SysAdminSubscriptionsService().extendUserSubscription(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("finalize-user")
public async finalizeUserSubscription(
@Body() requestBody: SysAdminFinalizeUserSubscriptionParams
): Promise<SysAdminFinalizeUserSubscriptionResult | ApiValidationError> {
try {
const result = await new SysAdminSubscriptionsService().finalizeUserSubscription(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("user-details")
public async getUserSubscriptionDetails(
@Body() requestBody: SysAdminUserSubscriptionDetailsParams
): Promise<SysAdminUserSubscriptionDetailsResult | ApiValidationError> {
try {
const result = await new SysAdminSubscriptionsService().getUserSubscriptionDetails(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,185 @@
import { connect, isValidObjectId } from "mongoose";
import dayjs from "dayjs";
import PlanSubscriptionsList from "../../../Models/PlanSubscriptions/PlanSubscriptons";
import PlanPaymentsList from "../../../Models/PlanPayments/PlanPayments";
import PlansList from "../../../Models/Plans/Plans";
import {
SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult,
SysAdminFinalizeUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult,
} from "../../../Models/Users/Users.Interface";
export class SysAdminSubscriptionsService {
public async extendUserSubscription(
params: SysAdminExtendUserSubscriptionParams
): Promise<SysAdminExtendUserSubscriptionResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
if (!params.userId || !isValidObjectId(params.userId)) {
throw new Error("User id is required");
}
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
if (!subscription) {
throw new Error("No subscription found for user");
}
const now = new Date();
const previousEndDate = subscription.endDate ? new Date(subscription.endDate) : now;
const baseDate = previousEndDate.getTime() > now.getTime() ? previousEndDate : now;
let newEndDate: Date;
if (params.mode === "add_months") {
if (params.months !== 1 && params.months !== 2) {
throw new Error("Months must be 1 or 2");
}
newEndDate = dayjs(baseDate).add(params.months, "month").toDate();
} else if (params.mode === "set_end_date") {
if (!params.endDate) {
throw new Error("End date is required");
}
const parsedEndDate = dayjs(params.endDate);
if (!parsedEndDate.isValid()) {
throw new Error("End date is invalid");
}
newEndDate = parsedEndDate.endOf("day").toDate();
if (newEndDate.getTime() <= now.getTime()) {
throw new Error("End date must be in the future");
}
if (newEndDate.getTime() <= baseDate.getTime()) {
throw new Error("End date must be after the current effective subscription end date");
}
} else {
throw new Error("Invalid extension mode");
}
subscription.endDate = newEndDate;
subscription.isActive = true;
if (subscription.mpStatus) {
subscription.mpStatus = "authorized";
}
await subscription.save();
const transactionId = [
"sysadmin_bonus",
String(subscription.id),
now.getTime(),
].join(":");
const payment = await PlanPaymentsList.createIfMissingByTransactionId({
userId: params.userId,
subscriptionId: String(subscription.id),
amount: 0,
paymentDate: now,
paymentMethod: "sysadmin_bonus",
status: "completed",
transactionId,
});
return {
userId: params.userId,
subscriptionId: String(subscription.id),
previousEndDate,
newEndDate,
paymentId: String(payment.id),
transactionId,
};
}
public async finalizeUserSubscription(
params: SysAdminFinalizeUserSubscriptionParams
): Promise<SysAdminFinalizeUserSubscriptionResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
if (!params.userId || !isValidObjectId(params.userId)) {
throw new Error("User id is required");
}
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
if (!subscription) {
throw new Error("No subscription found for user");
}
const previousEndDate = subscription.endDate ? new Date(subscription.endDate) : new Date();
const newEndDate = dayjs().subtract(1, "day").startOf("day").toDate();
const plan = await PlansList.plans.findOne({ _id: String(subscription.planId) });
subscription.endDate = newEndDate;
await subscription.save();
if (plan && plan.price > 0) {
await PlanSubscriptionsList.toFreePlan({
sessionUser: params.userId,
downgradeReason: "expired",
});
}
return {
userId: params.userId,
subscriptionId: String(subscription.id),
previousEndDate,
newEndDate,
};
}
public async getUserSubscriptionDetails(
params: SysAdminUserSubscriptionDetailsParams
): Promise<SysAdminUserSubscriptionDetailsResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
const payments = await PlanPaymentsList.find({ userId: params.userId });
if (!subscription) {
return {
userId: params.userId,
currentSubscription: null,
payments: payments.map((payment) => ({
id: String(payment.id),
subscriptionId: String(payment.subscriptionId),
amount: payment.amount,
paymentDate: payment.paymentDate,
paymentMethod: payment.paymentMethod,
status: payment.status,
transactionId: payment.transactionId,
})),
};
}
const plan = await PlansList.plans.findOne({ _id: String(subscription.planId) });
return {
userId: params.userId,
currentSubscription: {
id: String(subscription.id),
plan: plan
? {
id: String(plan.id),
name: plan.name,
code: plan.code,
price: plan.price,
}
: null,
startDate: subscription.startDate,
endDate: subscription.endDate,
isActive: subscription.isActive,
mpStatus: subscription.mpStatus,
billingMonths: subscription.billingMonths,
pendingPaymentType: subscription.pendingPaymentType,
pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId,
lastPaymentStatus: subscription.lastPaymentStatus,
},
payments: payments.map((payment) => ({
id: String(payment.id),
subscriptionId: String(payment.subscriptionId),
amount: payment.amount,
paymentDate: payment.paymentDate,
paymentMethod: payment.paymentMethod,
status: payment.status,
transactionId: payment.transactionId,
})),
};
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import fs from "fs";
import path from "path";
export default function getAvatar(
userId: string,
userId: string | null | undefined,
avatar: string | null | undefined,
fallback: string
): string {
+2
View File
@@ -4,6 +4,7 @@ import { DashboardPage } from './pages/DashboardPage';
import { UsersPage } from './pages/UsersPage';
import { WapServersPage } from './pages/WapServersPage';
import { CompaniesPage } from './pages/CompaniesPage';
import { PlansPage } from './pages/PlansPage';
function App() {
return (
@@ -13,6 +14,7 @@ function App() {
<Route index element={<DashboardPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="companies" element={<CompaniesPage />} />
<Route path="plans" element={<PlansPage />} />
<Route path="wapservers" element={<WapServersPage />} />
</Route>
</Routes>
+135
View File
@@ -33,6 +33,141 @@ export const getOrganizationsStatus = async (userId: string) => {
return res.data;
};
export type RecalculatePlanUsageCycleResult = {
userId: string;
subscriptionId: string;
planId: string;
cycleStart: string;
cycleEnd: string;
organizationsCount: number;
employeesCount: number;
servicesCount: number;
clientsCount: number;
repeatsCount: number;
appointmentsCount: number;
};
export const recalculatePlanUsageCycle = async (userId: string): Promise<RecalculatePlanUsageCycleResult> => {
const res = await sysadminApi.post('/users/recalculate-plan-usage-cycle', { userId });
return res.data;
};
export type UserSubscriptionDetails = {
userId: string;
currentSubscription: {
id: string;
plan: {
id: string;
name: string;
code: string;
price: number;
} | null;
startDate: string;
endDate: string;
isActive: boolean;
mpStatus?: string;
billingMonths?: number;
pendingPaymentType?: 'extension' | 'upgrade';
pendingPaymentPreferenceId?: string;
lastPaymentStatus?: string;
} | null;
payments: {
id: string;
subscriptionId: string;
amount: number;
paymentDate: string;
paymentMethod?: string;
status: string;
transactionId?: string;
}[];
};
export const getUserSubscriptionDetails = async (userId: string): Promise<UserSubscriptionDetails> => {
const res = await sysadminApi.post('/users/subscription-details', { userId });
return res.data;
};
export type ExtendUserSubscriptionPayload = {
userId: string;
mode: 'add_months' | 'set_end_date';
months?: 1 | 2;
endDate?: string;
reason?: string;
};
export type ExtendUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: string;
newEndDate: string;
paymentId: string;
transactionId?: string;
};
export const extendUserSubscription = async (data: ExtendUserSubscriptionPayload): Promise<ExtendUserSubscriptionResult> => {
const res = await sysadminApi.post('/users/extend-subscription', data);
return res.data;
};
export type FinalizeUserSubscriptionPayload = {
userId: string;
reason?: string;
};
export type FinalizeUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: string;
newEndDate: string;
};
export const finalizeUserSubscription = async (data: FinalizeUserSubscriptionPayload): Promise<FinalizeUserSubscriptionResult> => {
const res = await sysadminApi.post('/users/finalize-subscription', data);
return res.data;
};
export type Plan = {
id?: string;
_id?: string;
name: string;
description: string;
features: string[];
code: string;
price: number;
annualPrice: number;
limitOrganizations: number;
limitEmployees: number;
limitServices: number;
limitAppointments: number;
limitClients: number;
limitRepeats: number;
mailNotifications: boolean;
smsNotifications: boolean;
wapNotifications: boolean;
payments: boolean;
bot: boolean;
active: boolean;
dateLimit: boolean;
discount3Months?: number;
discount6Months?: number;
discount12Months?: number;
featured?: boolean;
};
export type UpdatePlanPayload = Partial<Omit<Plan, 'id' | '_id'>> & {
planId: string;
};
export const fetchPlans = async (): Promise<Plan[]> => {
const res = await sysadminApi.post('/plans/list', {});
return res.data;
};
export const updatePlan = async (data: UpdatePlanPayload) => {
const res = await sysadminApi.post('/plans/update', data);
return res.data;
};
// Wap Servers API Calls
export const fetchWapServers = async (filters: any) => {
const res = await sysadminApi.post('/wap/paginate', filters);
@@ -1,5 +1,5 @@
import { Outlet, NavLink } from 'react-router-dom';
import { Users, LayoutDashboard, LogOut, Server, Building2 } from 'lucide-react';
import { Users, LayoutDashboard, LogOut, Server, Building2, BadgeDollarSign } from 'lucide-react';
import { motion } from 'framer-motion';
export const DashboardLayout = () => {
@@ -30,6 +30,10 @@ export const DashboardLayout = () => {
<Building2 size={20} />
Organizaciones
</NavLink>
<NavLink to="/plans" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<BadgeDollarSign size={20} />
Planes
</NavLink>
<NavLink to="/wapservers" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<Server size={20} />
Servidores WAP
@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { X } from 'lucide-react';
import { updatePlan } from '../../api/sysadmin';
import type { Plan } from '../../api/sysadmin';
type EditPlanModalProps = {
plan: Plan;
onClose: () => void;
onSaved: () => void;
};
const numberFields: { key: keyof Plan; label: string }[] = [
{ key: 'price', label: 'Precio mensual' },
{ key: 'annualPrice', label: 'Precio anual' },
{ key: 'limitOrganizations', label: 'Límite de organizaciones' },
{ key: 'limitEmployees', label: 'Límite de empleados' },
{ key: 'limitServices', label: 'Límite de servicios' },
{ key: 'limitAppointments', label: 'Límite de turnos' },
{ key: 'limitClients', label: 'Límite de clientes' },
{ key: 'limitRepeats', label: 'Límite de repeticiones' },
{ key: 'discount3Months', label: 'Descuento 3 meses (%)' },
{ key: 'discount6Months', label: 'Descuento 6 meses (%)' },
{ key: 'discount12Months', label: 'Descuento 12 meses (%)' },
];
const limitFields = new Set<keyof Plan>([
'limitOrganizations',
'limitEmployees',
'limitServices',
'limitAppointments',
'limitClients',
'limitRepeats',
]);
const booleanFields: { key: keyof Plan; label: string }[] = [
{ key: 'active', label: 'Activo para contratación' },
{ key: 'featured', label: 'Destacado' },
{ key: 'mailNotifications', label: 'Notificaciones por email' },
{ key: 'smsNotifications', label: 'Notificaciones SMS' },
{ key: 'wapNotifications', label: 'Notificaciones WhatsApp' },
{ key: 'payments', label: 'Pagos habilitados' },
{ key: 'bot', label: 'Bot habilitado' },
{ key: 'dateLimit', label: 'Tiene límite de fecha' },
];
export const EditPlanModal = ({ plan, onClose, onSaved }: EditPlanModalProps) => {
const [formData, setFormData] = useState<Plan>({
...plan,
features: Array.isArray(plan.features) ? plan.features : [],
});
const [featuresText, setFeaturesText] = useState(formData.features.join('\n'));
const [loading, setLoading] = useState(false);
const planId = plan._id || plan.id || '';
const setField = (key: keyof Plan, value: string | number | boolean | string[]) => {
setFormData({ ...formData, [key]: value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await updatePlan({
planId,
name: formData.name,
description: formData.description,
code: formData.code,
price: Number(formData.price) || 0,
annualPrice: Number(formData.annualPrice) || 0,
limitOrganizations: Number(formData.limitOrganizations) || 0,
limitEmployees: Number(formData.limitEmployees) || 0,
limitServices: Number(formData.limitServices) || 0,
limitAppointments: Number(formData.limitAppointments) || 0,
limitClients: Number(formData.limitClients) || 0,
limitRepeats: Number(formData.limitRepeats) || 0,
discount3Months: Number(formData.discount3Months) || 0,
discount6Months: Number(formData.discount6Months) || 0,
discount12Months: Number(formData.discount12Months) || 0,
active: Boolean(formData.active),
featured: Boolean(formData.featured),
mailNotifications: Boolean(formData.mailNotifications),
smsNotifications: Boolean(formData.smsNotifications),
wapNotifications: Boolean(formData.wapNotifications),
payments: Boolean(formData.payments),
bot: Boolean(formData.bot),
dateLimit: Boolean(formData.dateLimit),
features: featuresText.split('\n').map(feature => feature.trim()).filter(Boolean),
});
onSaved();
onClose();
} catch (err: any) {
alert(err.response?.data?.message || err.message || 'Error guardando el plan');
}
setLoading(false);
};
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}>
<motion.div initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="glass-panel" style={{ width: '94%', maxWidth: '860px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}>
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
<X size={24} />
</button>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1.5rem' }}>Editar plan: {plan.name}</h2>
<form onSubmit={handleSubmit} style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '1rem' }}>
<div>
<label className="modal-label">Nombre</label>
<input className="input-glass" value={formData.name} onChange={e => setField('name', e.target.value)} required />
</div>
<div>
<label className="modal-label">Código</label>
<input className="input-glass" value={formData.code} onChange={e => setField('code', e.target.value)} required />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label className="modal-label">Descripción</label>
<textarea className="input-glass" rows={3} value={formData.description} onChange={e => setField('description', e.target.value)} required />
</div>
<h3 className="plan-modal-section">Precios, límites y descuentos</h3>
{numberFields.map(field => (
<div key={field.key}>
<label className="modal-label">{field.label}</label>
<input className="input-glass" type="number" min={limitFields.has(field.key) ? undefined : '0'} step="0.01" value={Number(formData[field.key] || 0)} onChange={e => setField(field.key, Number(e.target.value))} />
</div>
))}
<h3 className="plan-modal-section">Funciones y estado</h3>
{booleanFields.map(field => (
<label key={field.key} className="plan-toggle">
<input type="checkbox" checked={Boolean(formData[field.key])} onChange={e => setField(field.key, e.target.checked)} />
{field.label}
</label>
))}
<div style={{ gridColumn: '1 / -1' }}>
<label className="modal-label">Features visibles (una por línea)</label>
<textarea className="input-glass" rows={5} value={featuresText} onChange={e => setFeaturesText(e.target.value)} />
</div>
<div style={{ gridColumn: '1 / -1', marginTop: '1rem', display: 'flex', justifyContent: 'flex-end', gap: '1rem' }}>
<button type="button" className="btn-secondary" onClick={onClose}>Cancelar</button>
<button type="submit" className="btn-primary" disabled={loading || !planId}>{loading ? 'Guardando...' : 'Guardar cambios'}</button>
</div>
</form>
<style dangerouslySetInnerHTML={{__html: `
.modal-label { display: block; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.5rem; }
.plan-modal-section { grid-column: 1 / -1; margin: 1rem 0 0.25rem; color: var(--primary); font-size: 1.05rem; }
.plan-toggle { display: flex; align-items: center; gap: 0.6rem; color: var(--text-main); background: rgba(255,255,255,0.04); border: 1px solid var(--glass-border); border-radius: 10px; padding: 0.75rem; }
.plan-toggle input { width: 16px; height: 16px; }
@media (max-width: 768px) { form { grid-template-columns: 1fr !important; } }
`}} />
</motion.div>
</div>
);
};
@@ -0,0 +1,114 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { RefreshCw, X } from 'lucide-react';
import { recalculatePlanUsageCycle, type RecalculatePlanUsageCycleResult } from '../../api/sysadmin';
import { formatDateTime } from '../../utils/formatters';
export const RecalculationToolsModal = ({ initialUserId = '', onClose }: { initialUserId?: string, onClose: () => void }) => {
const [usageUserId, setUsageUserId] = useState(initialUserId);
const [usageResult, setUsageResult] = useState<RecalculatePlanUsageCycleResult | null>(null);
const [usageLoading, setUsageLoading] = useState(false);
const [usageError, setUsageError] = useState('');
const handleRecalculatePlanUsage = async () => {
const trimmedUserId = usageUserId.trim();
if (!trimmedUserId) {
setUsageError('Ingresá un userId para recalcular el uso del plan.');
return;
}
setUsageUserId(trimmedUserId);
setUsageLoading(true);
setUsageError('');
setUsageResult(null);
try {
const result = await recalculatePlanUsageCycle(trimmedUserId);
setUsageResult(result);
} catch (e: any) {
setUsageError(e.response?.data?.message || e.message || 'Error al recalcular el uso del plan');
} finally {
setUsageLoading(false);
}
};
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', padding: '1rem' }}>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="glass-panel"
style={{ width: '90%', maxWidth: '760px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}
>
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
<X size={24} />
</button>
<h2 style={{ fontSize: '1.5rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
<RefreshCw size={24} color="var(--primary)" />
Herramientas de recálculo
</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>Funciones exclusivas de sysadmin para reparar o reconstruir contadores.</p>
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem' }}>
<div style={{ marginBottom: '1rem' }}>
<h3 style={{ fontSize: '1.1rem', fontWeight: '700', marginBottom: '0.35rem' }}>Recalcular uso del plan</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>Recuenta las reservas del ciclo actual y los contadores stock del userId seleccionado.</p>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleRecalculatePlanUsage(); }} style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 300px' }}>
<label className="modal-label">User ID</label>
<input
type="text"
className="input-glass"
placeholder="Pegá o seleccioná un userId"
value={usageUserId}
onChange={(e) => setUsageUserId(e.target.value)}
/>
</div>
<button type="submit" className="btn-primary" disabled={usageLoading} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<RefreshCw size={18} /> {usageLoading ? 'Recalculando...' : 'Recalcular'}
</button>
</form>
{usageError && <div style={{ marginTop: '1rem', color: 'var(--danger)', fontSize: '0.9rem' }}>{usageError}</div>}
{usageResult && (
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div><strong>User ID</strong><br /><span className="long-id-value">{usageResult.userId}</span></div>
<div><strong>Subscription ID</strong><br /><span className="long-id-value">{usageResult.subscriptionId}</span></div>
<div><strong>Plan ID</strong><br /><span className="long-id-value">{usageResult.planId}</span></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '0.75rem' }}>
<div><strong>Cycle Start</strong><br /><span style={{ color: 'var(--text-muted)' }}>{formatDateTime(usageResult.cycleStart)}</span></div>
<div><strong>Cycle End</strong><br /><span style={{ color: 'var(--text-muted)' }}>{formatDateTime(usageResult.cycleEnd)}</span></div>
<div><strong>Organizations</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.organizationsCount}</span></div>
<div><strong>Collaborators</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.employeesCount}</span></div>
<div><strong>Services</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.servicesCount}</span></div>
<div><strong>Clients</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.clientsCount}</span></div>
<div><strong>Repeats</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.repeatsCount}</span></div>
<div><strong>Appointments</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.appointmentsCount}</span></div>
</div>
</div>
)}
</div>
<style dangerouslySetInnerHTML={{__html: `
.modal-label {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.long-id-value {
display: block;
color: var(--text-muted);
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.45;
}
`}} />
</motion.div>
</div>
);
};
@@ -0,0 +1,297 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { CreditCard, X } from 'lucide-react';
import { extendUserSubscription, finalizeUserSubscription, getUserSubscriptionDetails, type UserSubscriptionDetails } from '../../api/sysadmin';
import { formatDateTime } from '../../utils/formatters';
type Props = {
user: any;
onClose: () => void;
};
export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
const [details, setDetails] = useState<UserSubscriptionDetails | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [extensionDate, setExtensionDate] = useState('');
const [extensionLoading, setExtensionLoading] = useState(false);
const [extensionMessage, setExtensionMessage] = useState('');
const [extensionError, setExtensionError] = useState('');
const [finalizeLoading, setFinalizeLoading] = useState(false);
const formatAmount = (value?: number) => {
if (typeof value !== 'number') return '-';
return value.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' });
};
const loadDetails = async () => {
setLoading(true);
setError('');
try {
const result = await getUserSubscriptionDetails(user._id);
setDetails(result);
} catch (e: any) {
setError(e.response?.data?.message || e.message || 'Error cargando suscripción');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadDetails();
}, [user._id]);
const handleExtend = async (payload: { mode: 'add_months' | 'set_end_date'; months?: 1 | 2; endDate?: string }) => {
if (!details?.currentSubscription) return;
setExtensionLoading(true);
setExtensionError('');
setExtensionMessage('');
try {
const result = await extendUserSubscription({
userId: user._id,
reason: 'Ajuste manual excepcional desde sysadmin-cli',
...payload,
});
setExtensionMessage(`Vencimiento actualizado hasta ${formatDateTime(result.newEndDate)}. Se registró una bonificación $0.`);
await loadDetails();
} catch (e: any) {
setExtensionError(e.response?.data?.message || e.message || 'Error extendiendo suscripción');
} finally {
setExtensionLoading(false);
}
};
const handleFinalize = async () => {
if (!details?.currentSubscription) return;
const confirmed = window.confirm('Esto establecerá el vencimiento de la suscripción a ayer para forzar el comportamiento de plan vencido. No elimina datos ni cambia el plan directamente. ¿Continuar?');
if (!confirmed) return;
setFinalizeLoading(true);
setExtensionError('');
setExtensionMessage('');
try {
const result = await finalizeUserSubscription({
userId: user._id,
reason: 'Finalización manual desde sysadmin-cli para probar expiración de plan',
});
setExtensionMessage(`Período finalizado. El vencimiento quedó en ${formatDateTime(result.newEndDate)} para forzar la expiración en la próxima lectura.`);
await loadDetails();
} catch (e: any) {
setExtensionError(e.response?.data?.message || e.message || 'Error finalizando período');
} finally {
setFinalizeLoading(false);
}
};
const subscription = details?.currentSubscription;
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', padding: '1rem' }}>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="glass-panel"
style={{ width: '90%', maxWidth: '860px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}
>
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
<X size={24} />
</button>
<h2 style={{ fontSize: '1.5rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
<CreditCard size={24} color="var(--primary)" />
Suscripción del usuario
</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>
{user.firstName} {user.lastName} · {user.email}
</p>
{loading && <div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>Cargando suscripción...</div>}
{error && <div style={{ padding: '1rem', color: 'var(--danger)' }}>{error}</div>}
{!loading && !error && details && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<section className="subscription-card">
<h3 className="subscription-section-title">Suscripción actual</h3>
{!subscription ? (
<p style={{ color: 'var(--text-muted)' }}>No hay suscripción registrada para este usuario.</p>
) : (
<div className="subscription-grid">
<Info label="Subscription ID" value={subscription.id} long />
<Info label="User ID" value={details.userId} long />
<Info label="Plan" value={subscription.plan ? `${subscription.plan.name} (${subscription.plan.code})` : 'Plan no encontrado'} />
<Info label="Plan ID" value={subscription.plan?.id || '-'} long />
<Info label="Precio" value={formatAmount(subscription.plan?.price)} />
<Info label="Inicio" value={formatDateTime(subscription.startDate)} />
<Info label="Válida hasta" value={formatDateTime(subscription.endDate)} />
<Info label="Activa" value={subscription.isActive ? 'Sí' : 'No'} />
<Info label="MP status" value={subscription.mpStatus || '-'} />
<Info label="Meses facturados" value={subscription.billingMonths?.toString() || '-'} />
<Info label="Pago pendiente" value={subscription.pendingPaymentType || '-'} />
<Info label="Preference pendiente" value={subscription.pendingPaymentPreferenceId || '-'} long />
<Info label="Último pago" value={subscription.lastPaymentStatus || '-'} />
</div>
)}
</section>
{subscription && (
<section className="subscription-card">
<h3 className="subscription-section-title">Ajuste manual excepcional</h3>
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>
Extiende la vigencia sin pago y registra un pago de bonificación por $0 asociado a esta suscripción.
</p>
<div className="extension-actions">
<button className="btn btn-secondary" disabled={extensionLoading || finalizeLoading} onClick={() => handleExtend({ mode: 'add_months', months: 1 })}>
Extender 1 mes
</button>
<button className="btn btn-secondary" disabled={extensionLoading || finalizeLoading} onClick={() => handleExtend({ mode: 'add_months', months: 2 })}>
Extender 2 meses
</button>
<div className="extension-date-action">
<input
type="date"
value={extensionDate}
onChange={(event) => setExtensionDate(event.target.value)}
disabled={extensionLoading || finalizeLoading}
/>
<button
className="btn btn-primary"
disabled={extensionLoading || finalizeLoading || !extensionDate}
onClick={() => handleExtend({ mode: 'set_end_date', endDate: extensionDate })}
>
Establecer vencimiento
</button>
</div>
</div>
<div className="finalize-action">
<div>
<h4>Finalizar período</h4>
<p>Establece el vencimiento en ayer para forzar el comportamiento de suscripción vencida en la próxima lectura. No elimina datos ni cambia el plan directamente.</p>
</div>
<button className="btn btn-danger" disabled={extensionLoading || finalizeLoading} onClick={handleFinalize}>
Finalizar período
</button>
</div>
{(extensionLoading || finalizeLoading) && <p style={{ color: 'var(--text-muted)', marginTop: '0.75rem' }}>Aplicando ajuste...</p>}
{extensionMessage && <p style={{ color: 'var(--success)', marginTop: '0.75rem' }}>{extensionMessage}</p>}
{extensionError && <p style={{ color: 'var(--danger)', marginTop: '0.75rem' }}>{extensionError}</p>}
</section>
)}
<section className="subscription-card">
<h3 className="subscription-section-title">Pagos</h3>
{details.payments.length === 0 ? (
<p style={{ color: 'var(--text-muted)' }}>No hay pagos registrados para este usuario.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{details.payments.map((payment) => (
<div key={payment.id} className="payment-row">
<Info label="Payment ID" value={payment.id} long />
<Info label="Subscription ID" value={payment.subscriptionId} long />
<Info label="Monto" value={formatAmount(payment.amount)} />
<Info label="Fecha" value={formatDateTime(payment.paymentDate)} />
<Info label="Método" value={payment.paymentMethod || '-'} />
<Info label="Estado" value={payment.status} />
<Info label="Transaction ID" value={payment.transactionId || '-'} long />
</div>
))}
</div>
)}
</section>
</div>
)}
<style dangerouslySetInnerHTML={{__html: `
.subscription-card {
background: rgba(255,255,255,0.03);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 1.25rem;
}
.subscription-section-title {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 1rem;
}
.subscription-grid,
.payment-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.85rem;
}
.payment-row {
border-top: 1px solid var(--glass-border);
padding-top: 0.75rem;
}
.subscription-label {
display: block;
color: var(--text-muted);
font-size: 0.78rem;
margin-bottom: 0.25rem;
}
.subscription-value {
display: block;
color: var(--text-main);
font-weight: 600;
}
.subscription-value.long {
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.45;
}
.extension-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.extension-date-action {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.extension-date-action input {
background: rgba(255,255,255,0.06);
border: 1px solid var(--glass-border);
border-radius: 8px;
color: var(--text-main);
padding: 0.65rem 0.75rem;
}
.finalize-action {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--glass-border);
}
.finalize-action h4 {
color: var(--danger);
font-size: 1rem;
margin-bottom: 0.35rem;
}
.finalize-action p {
color: var(--text-muted);
margin: 0;
}
@media (max-width: 640px) {
.finalize-action {
align-items: stretch;
flex-direction: column;
}
}
`}} />
</motion.div>
</div>
);
};
const Info = ({ label, value, long = false }: { label: string; value: string; long?: boolean }) => (
<div>
<span className="subscription-label">{label}</span>
<span className={`subscription-value${long ? ' long' : ''}`}>{value}</span>
</div>
);
+60 -8
View File
@@ -1,5 +1,5 @@
import { motion } from 'framer-motion';
import { Users, Server, Shield } from 'lucide-react';
import { Building2, CreditCard, RefreshCw, Server, Shield, Tags, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
export const DashboardPage = () => {
@@ -32,23 +32,61 @@ export const DashboardPage = () => {
</div>
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Usuarios</h3>
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Administra perfiles, verifica cuentas, consulta organizaciones y realiza limpiezas seguras en la base de datos.</p>
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Administra perfiles, verifica cuentas, consulta organizaciones, suscripciones, pagos y herramientas de recálculo.</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1rem' }}>
<span className="dashboard-tag"><CreditCard size={14} /> Suscripciones</span>
<span className="dashboard-tag"><RefreshCw size={14} /> Recálculos</span>
</div>
</div>
</motion.div>
</Link>
{/* Card Companies */}
<Link to="/companies" style={{ display: 'block' }}>
<motion.div
whileHover={{ y: -5, scale: 1.02 }}
className="glass-panel"
style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}
>
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(16, 185, 129, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--success)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
<Building2 size={30} />
</div>
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Organizaciones</h3>
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Consulta organizaciones, revisa estado operativo y administra bloqueos desde el panel central.</p>
</div>
</motion.div>
</Link>
{/* Card Plans */}
<Link to="/plans" style={{ display: 'block' }}>
<motion.div
whileHover={{ y: -5, scale: 1.02 }}
className="glass-panel"
style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}
>
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(245, 158, 11, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--warning)', border: '1px solid rgba(245, 158, 11, 0.2)' }}>
<Tags size={30} />
</div>
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Planes</h3>
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Visualiza y edita precios, límites, descuentos, funciones y disponibilidad de los planes contratables.</p>
</div>
</motion.div>
</Link>
{/* Card Wap Servers */}
<div style={{ opacity: 0.6 }}>
<div className="glass-panel" style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', height: '100%' }}>
<Link to="/wapservers" style={{ display: 'block' }}>
<motion.div whileHover={{ y: -5, scale: 1.02 }} className="glass-panel" style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}>
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(139, 92, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent)', border: '1px solid rgba(139, 92, 246, 0.2)' }}>
<Server size={30} />
</div>
<div>
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem' }}>Servidores WAP</h3>
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>[Próximamente] Monitoreo y configuración de servidores de WhatsApp puente.</p>
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Servidores WAP</h3>
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Consulta servidores de WhatsApp puente y su estado operativo.</p>
</div>
</div>
</div>
</motion.div>
</Link>
{/* Card Config */}
<div style={{ opacity: 0.6 }}>
@@ -64,6 +102,20 @@ export const DashboardPage = () => {
</div>
</div>
<style dangerouslySetInnerHTML={{__html: `
.dashboard-tag {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 700;
border: 1px solid rgba(255, 255, 255, 0.08);
}
`}} />
</motion.div>
);
};
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { CheckCircle, Edit2, RefreshCw, Star, XCircle } from 'lucide-react';
import { fetchPlans } from '../api/sysadmin';
import type { Plan } from '../api/sysadmin';
import { EditPlanModal } from '../components/ui/EditPlanModal';
const formatMoney = (value: number) => new Intl.NumberFormat('es-AR', { style: 'currency', currency: 'ARS' }).format(value || 0);
const getFeatureTags = (plan: Plan) => [
{ label: 'Mail', enabled: plan.mailNotifications },
{ label: 'SMS', enabled: plan.smsNotifications },
{ label: 'WAP', enabled: plan.wapNotifications },
{ label: 'Pagos', enabled: plan.payments },
{ label: 'Bot', enabled: plan.bot },
{ label: 'Fecha límite', enabled: plan.dateLimit },
];
const getLimitTags = (plan: Plan) => [
{ label: 'Org', value: plan.limitOrganizations },
{ label: 'Emp', value: plan.limitEmployees },
{ label: 'Serv', value: plan.limitServices },
{ label: 'Turnos', value: plan.limitAppointments },
{ label: 'Clientes', value: plan.limitClients },
{ label: 'Rep', value: plan.limitRepeats },
];
const formatLimit = (value: number) => value === -1 ? '∞' : value;
export const PlansPage = () => {
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editingPlan, setEditingPlan] = useState<Plan | null>(null);
const [planIdFilter, setPlanIdFilter] = useState('');
const filteredPlans = plans.filter((plan) => {
const normalizedFilter = planIdFilter.trim().toLowerCase();
if (!normalizedFilter) return true;
return [plan.id, plan._id].some((planId) => planId?.toLowerCase().includes(normalizedFilter));
});
const loadPlans = async () => {
setLoading(true);
setError('');
try {
const data = await fetchPlans();
setPlans(data);
} catch (err: any) {
setError(err.response?.data?.message || err.message || 'Error cargando planes');
}
setLoading(false);
};
useEffect(() => {
loadPlans();
}, []);
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in plans-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
<div>
<h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Gestión de Planes</h1>
<p style={{ color: 'var(--text-muted)', marginTop: '0.35rem' }}>Visualizá y editá precios, límites y funciones contratables.</p>
</div>
<button className="btn-secondary" onClick={loadPlans} disabled={loading} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<RefreshCw size={18} /> Actualizar
</button>
</div>
<div className="glass-panel" style={{ padding: '1rem', marginBottom: '1rem', flexShrink: 0 }}>
<label style={{ display: 'block', color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: '0.5rem' }}>
Filtrar por ID de plan
</label>
<input
className="input-glass"
type="text"
placeholder="Pegá el id del plan"
value={planIdFilter}
onChange={(event) => setPlanIdFilter(event.target.value)}
/>
</div>
{error && (
<div className="glass-panel" style={{ padding: '1rem', marginBottom: '1rem', color: 'var(--danger)', borderColor: 'rgba(239,68,68,0.35)' }}>
{error}
</div>
)}
<div className="glass-panel" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1 }}>
<div style={{ overflow: 'auto', flex: 1 }} className="table-scroll-container">
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', minWidth: '920px' }}>
<thead>
<tr>
<th className="sticky-header">Plan</th>
<th className="sticky-header">Precio</th>
<th className="sticky-header">Límites</th>
<th className="sticky-header">Funciones</th>
<th className="sticky-header">Estado</th>
<th className="sticky-header" style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center' }}>Cargando planes...</td></tr>
) : filteredPlans.length === 0 ? (
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron planes</td></tr>
) : (
filteredPlans.map(plan => (
<tr key={plan._id || plan.id || plan.code} className="table-row-hover" style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<td style={{ padding: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontWeight: 700 }}>
{plan.name} {plan.featured && <Star size={16} color="var(--warning)" fill="var(--warning)" />}
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{plan.code}</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginTop: '0.35rem', maxWidth: '280px' }}>{plan.description}</div>
</td>
<td style={{ padding: '1rem' }}>
<div>{formatMoney(plan.price)} / mes</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{formatMoney(plan.annualPrice)} / año</div>
</td>
<td style={{ padding: '1rem' }}>
<div className="limit-tags">
{getLimitTags(plan).map((limit) => (
<span key={limit.label} className={`limit-tag ${limit.value === -1 ? 'unlimited' : ''}`}>
<small>{limit.label}</small>
<strong>{formatLimit(limit.value)}</strong>
</span>
))}
</div>
</td>
<td style={{ padding: '1rem' }}>
<div className="feature-tags">
{getFeatureTags(plan).map((feature) => (
<span key={feature.label} className={`feature-tag ${feature.enabled ? 'enabled' : 'disabled'}`}>
{feature.label}
</span>
))}
</div>
</td>
<td style={{ padding: '1rem' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35rem', color: plan.active ? 'var(--success)' : 'var(--warning)' }}>
{plan.active ? <CheckCircle size={18} /> : <XCircle size={18} />}
{plan.active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td style={{ padding: '1rem', textAlign: 'right' }}>
<button className="icon-btn" title="Editar plan" aria-label={`Editar ${plan.name}`} onClick={() => setEditingPlan(plan)}>
<Edit2 size={18} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<style dangerouslySetInnerHTML={{__html: `
.sticky-header { position: sticky; top: 0; z-index: 10; background: rgba(10, 10, 15, 0.95); backdrop-filter: blur(10px); border-bottom: 1px solid var(--glass-border); padding: 1rem; color: var(--text-muted); font-weight: 600; }
.table-row-hover:hover { background: rgba(255, 255, 255, 0.02); }
.limit-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; max-width: 300px; }
.limit-tag { display: inline-flex; align-items: center; gap: 0.3rem; border-radius: 999px; padding: 0.25rem 0.55rem; background: rgba(59, 130, 246, 0.12); border: 1px solid rgba(59, 130, 246, 0.24); color: #bfdbfe; }
.limit-tag small { color: #93c5fd; font-weight: 800; font-size: 0.7rem; text-transform: uppercase; }
.limit-tag strong { color: #f8fafc; font-size: 0.82rem; }
.limit-tag.unlimited { background: rgba(168, 85, 247, 0.16); border-color: rgba(168, 85, 247, 0.32); }
.limit-tag.unlimited small { color: #d8b4fe; }
.limit-tag.unlimited strong { color: #f5d0fe; font-size: 0.95rem; }
.feature-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; max-width: 260px; }
.feature-tag { display: inline-flex; align-items: center; border-radius: 999px; padding: 0.25rem 0.55rem; font-size: 0.75rem; font-weight: 700; border: 1px solid transparent; }
.feature-tag.enabled { background: rgba(34, 197, 94, 0.14); color: #86efac; border-color: rgba(34, 197, 94, 0.28); }
.feature-tag.disabled { background: rgba(148, 163, 184, 0.1); color: #94a3b8; border-color: rgba(148, 163, 184, 0.18); text-decoration: line-through; opacity: 0.75; }
@media (max-width: 768px) { .plans-page-container { height: auto !important; overflow: visible !important; } .table-scroll-container { overflow-x: auto; } }
`}} />
{editingPlan && <EditPlanModal plan={editingPlan} onClose={() => setEditingPlan(null)} onSaved={loadPlans} />}
</motion.div>
);
};
+114 -8
View File
@@ -1,15 +1,20 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Search, Edit2, Trash2, CheckCircle, XCircle, Building2, ChevronUp, ChevronDown, RotateCcw } from 'lucide-react';
import { Search, Edit2, Trash2, CheckCircle, XCircle, Building2, ChevronUp, ChevronDown, RotateCcw, RefreshCw, MoreVertical, CreditCard } from 'lucide-react';
import { fetchUsers, deleteUser, setVerifiedStatus } from '../api/sysadmin';
import { EditUserModal } from '../components/ui/EditUserModal';
import { OrganizationsModal } from '../components/ui/OrganizationsModal';
import { RecalculationToolsModal } from '../components/ui/RecalculationToolsModal';
import { SubscriptionDetailsModal } from '../components/ui/SubscriptionDetailsModal';
import { formatDate } from '../utils/formatters';
export const UsersPage = () => {
const [users, setUsers] = useState<any[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [recalculationUserId, setRecalculationUserId] = useState('');
const [openActionsUserId, setOpenActionsUserId] = useState<string | null>(null);
// Filters
const [filters, setFilters] = useState({
@@ -23,6 +28,8 @@ export const UsersPage = () => {
// Modals state
const [editingUser, setEditingUser] = useState<any>(null);
const [orgUser, setOrgUser] = useState<any>(null);
const [subscriptionUser, setSubscriptionUser] = useState<any>(null);
const [recalculationToolsOpen, setRecalculationToolsOpen] = useState(false);
const loadUsers = async () => {
setLoading(true);
@@ -46,6 +53,15 @@ export const UsersPage = () => {
loadUsers();
}, [page, filters.sortBy, filters.sortOrder, filters.isVerified]);
useEffect(() => {
if (!openActionsUserId) return;
const closeActionsMenu = () => setOpenActionsUserId(null);
document.addEventListener('click', closeActionsMenu);
return () => document.removeEventListener('click', closeActionsMenu);
}, [openActionsUserId]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPage(1);
@@ -107,10 +123,23 @@ export const UsersPage = () => {
}
};
const openRecalculationTools = (userId = '') => {
setRecalculationUserId(userId);
setRecalculationToolsOpen(true);
};
const runRowAction = (action: () => void) => {
setOpenActionsUserId(null);
action();
};
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in users-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
<h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Gestión de Usuarios</h1>
<button className="btn-secondary" onClick={() => openRecalculationTools()} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<RefreshCw size={18} /> Recalcular
</button>
</div>
{/* Filters Bar */}
@@ -179,9 +208,9 @@ export const UsersPage = () => {
</thead>
<tbody>
{loading ? (
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center' }}>Cargando...</td></tr>
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center' }}>Cargando...</td></tr>
) : users.length === 0 ? (
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron usuarios</td></tr>
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron usuarios</td></tr>
) : (
users.map(u => (
<tr key={u._id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="table-row-hover">
@@ -205,13 +234,43 @@ export const UsersPage = () => {
{u.verificationCode || '-'}
</td>
<td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{new Date(u.creationDate).toLocaleDateString()}
{formatDate(u.creationDate)}
</td>
<td style={{ padding: '1rem', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
<button className="icon-btn" title="Ver Organizaciones" onClick={() => setOrgUser(u)}><Building2 size={18} /></button>
<button className="icon-btn" title="Editar Perfil" onClick={() => setEditingUser(u)}><Edit2 size={18} /></button>
<button className="icon-btn danger" title="Eliminar" onClick={() => handleDelete(u)}><Trash2 size={18} /></button>
<div className="actions-menu-wrapper" onClick={(e) => e.stopPropagation()}>
<button
className="icon-btn"
title="Acciones"
aria-label={`Acciones para ${u.email}`}
aria-haspopup="menu"
aria-expanded={openActionsUserId === u._id}
onClick={() => setOpenActionsUserId(openActionsUserId === u._id ? null : u._id)}
>
<MoreVertical size={18} />
</button>
{openActionsUserId === u._id && (
<div className="actions-menu" role="menu">
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setOrgUser(u))}>
<Building2 size={16} /> Ver organizaciones
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setSubscriptionUser(u))}>
<CreditCard size={16} /> Ver suscripción
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => openRecalculationTools(u._id))}>
<RefreshCw size={16} /> Recalcular uso del plan
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleToggleVerified(u))}>
{u.verificated ? <XCircle size={16} /> : <CheckCircle size={16} />}
{u.verificated ? 'Marcar como no verificado' : 'Marcar como verificado'}
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setEditingUser(u))}>
<Edit2 size={16} /> Editar perfil
</button>
<button type="button" className="actions-menu-item danger" role="menuitem" onClick={() => runRowAction(() => handleDelete(u))}>
<Trash2 size={16} /> Eliminar
</button>
</div>
)}
</div>
</td>
</tr>
@@ -261,6 +320,51 @@ export const UsersPage = () => {
.icon-btn.danger:hover {
background: var(--danger);
}
.actions-menu-wrapper {
position: relative;
display: inline-flex;
justify-content: flex-end;
}
.actions-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 30;
min-width: 230px;
padding: 0.4rem;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
background: #111827;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(12px);
}
.actions-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.75rem;
border: 0;
border-radius: 8px;
background: transparent;
color: #f8fafc;
font-size: 0.9rem;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: all 0.2s;
}
.actions-menu-item:hover {
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
}
.actions-menu-item.danger {
color: #fca5a5;
}
.actions-menu-item.danger:hover {
background: rgba(239, 68, 68, 0.18);
color: #fecaca;
}
.btn-secondary {
background: rgba(255,255,255,0.05);
color: var(--text-main);
@@ -293,6 +397,8 @@ export const UsersPage = () => {
{/* Modals */}
{editingUser && <EditUserModal user={editingUser} onClose={() => setEditingUser(null)} onSaved={loadUsers} />}
{orgUser && <OrganizationsModal user={orgUser} onClose={() => setOrgUser(null)} />}
{subscriptionUser && <SubscriptionDetailsModal user={subscriptionUser} onClose={() => setSubscriptionUser(null)} />}
{recalculationToolsOpen && <RecalculationToolsModal initialUserId={recalculationUserId} onClose={() => setRecalculationToolsOpen(false)} />}
</motion.div>
);
};
+18
View File
@@ -0,0 +1,18 @@
import { format, isValid, parseISO } from 'date-fns';
import { es } from 'date-fns/locale';
const parseDate = (value?: string) => {
if (!value) return null;
const parsedDate = parseISO(value);
return isValid(parsedDate) ? parsedDate : null;
};
export const formatDate = (value?: string) => {
const parsedDate = parseDate(value);
return parsedDate ? format(parsedDate, 'dd MMM yyyy', { locale: es }) : value || '-';
};
export const formatDateTime = (value?: string) => {
const parsedDate = parseDate(value);
return parsedDate ? format(parsedDate, "dd MMM yyyy, HH:mm 'hs'", { locale: es }) : value || '-';
};
+2
View File
@@ -5,6 +5,7 @@ import wapRoutes from "./routes/wap.routes";
import usersRoutes from "./routes/users.routes";
import companiesRoutes from "./routes/companies.routes";
import servicesRoutes from "./routes/services.routes";
import plansRoutes from "./routes/plans.routes";
const app = express();
@@ -16,5 +17,6 @@ app.use("/wap", wapRoutes);
app.use("/users", usersRoutes);
app.use("/companies", companiesRoutes);
app.use("/services", servicesRoutes);
app.use("/plans", plansRoutes);
export default app;
@@ -0,0 +1,28 @@
import { Request, Response } from "express";
import { PlansService } from "../services/plans.service";
import { SysAdminUpdatePlanParams } from "../models/Plans.Model";
const plansService = new PlansService();
export class PlansController {
public listPlans = async (_req: Request, res: Response): Promise<void> => {
try {
const plans = await plansService.listPlans();
res.json(plans);
} catch (error: any) {
console.error("Error listing plans:", error?.response?.data || error);
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
public updatePlan = async (req: Request, res: Response): Promise<void> => {
try {
const data: SysAdminUpdatePlanParams = req.body;
const result = await plansService.updatePlan(data);
res.json(result);
} catch (error: any) {
console.error("Error updating plan:", error?.response?.data || error);
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
}
@@ -6,6 +6,10 @@ import {
SysAdminSetVerifiedParams,
SysAdminDeleteUserParams,
SysAdminOrganizationsStatusParams,
SysAdminRecalculatePlanUsageCycleParams,
SysAdminExtendUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionParams,
SysAdminUserSubscriptionDetailsParams,
} from "../models/Users.Model";
const usersService = new UsersService();
@@ -65,4 +69,48 @@ export class UsersController {
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
public recalculatePlanUsageCycle = async (req: Request, res: Response): Promise<void> => {
try {
const data: SysAdminRecalculatePlanUsageCycleParams = req.body;
const result = await usersService.recalculatePlanUsageCycle(data);
res.json(result);
} catch (error: any) {
console.error("Error recalculating plan usage cycle:", error?.response?.data || error);
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
public subscriptionDetails = async (req: Request, res: Response): Promise<void> => {
try {
const data: SysAdminUserSubscriptionDetailsParams = req.body;
const result = await usersService.getSubscriptionDetails(data);
res.json(result);
} catch (error: any) {
console.error("Error fetching subscription details:", error?.response?.data || error);
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
public extendSubscription = async (req: Request, res: Response): Promise<void> => {
try {
const data: SysAdminExtendUserSubscriptionParams = req.body;
const result = await usersService.extendSubscription(data);
res.json(result);
} catch (error: any) {
console.error("Error extending subscription:", error?.response?.data || error);
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
public finalizeSubscription = async (req: Request, res: Response): Promise<void> => {
try {
const data: SysAdminFinalizeUserSubscriptionParams = req.body;
const result = await usersService.finalizeSubscription(data);
res.json(result);
} catch (error: any) {
console.error("Error finalizing subscription:", error?.response?.data || error);
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
}
+31
View File
@@ -0,0 +1,31 @@
export type SysAdminPlan = {
id?: string;
_id?: string;
name: string;
description: string;
features: string[];
code: string;
price: number;
annualPrice: number;
limitOrganizations: number;
limitEmployees: number;
limitServices: number;
limitAppointments: number;
limitClients: number;
limitRepeats: number;
mailNotifications: boolean;
smsNotifications: boolean;
wapNotifications: boolean;
payments: boolean;
bot: boolean;
active: boolean;
dateLimit: boolean;
discount3Months?: number;
discount6Months?: number;
discount12Months?: number;
featured?: boolean;
};
export type SysAdminUpdatePlanParams = Partial<Omit<SysAdminPlan, "id" | "_id">> & {
planId: string;
};
+81
View File
@@ -52,3 +52,84 @@ export type SysAdminUserOrganizationsResult = {
clientIn: { companyId: string; companyName: string }[];
collaboratorIn: { companyId: string; companyName: string }[];
};
export type SysAdminRecalculatePlanUsageCycleParams = {
userId: string;
};
export type SysAdminRecalculatePlanUsageCycleResult = {
userId: string;
subscriptionId: string;
planId: string;
cycleStart: string;
cycleEnd: string;
organizationsCount: number;
employeesCount: number;
servicesCount: number;
clientsCount: number;
repeatsCount: number;
appointmentsCount: number;
};
export type SysAdminUserSubscriptionDetailsParams = {
userId: string;
};
export type SysAdminExtendUserSubscriptionParams = {
userId: string;
mode: "add_months" | "set_end_date";
months?: 1 | 2;
endDate?: string;
reason?: string;
};
export type SysAdminExtendUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: string;
newEndDate: string;
paymentId: string;
transactionId?: string;
};
export type SysAdminFinalizeUserSubscriptionParams = {
userId: string;
reason?: string;
};
export type SysAdminFinalizeUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: string;
newEndDate: string;
};
export type SysAdminUserSubscriptionDetailsResult = {
userId: string;
currentSubscription: {
id: string;
plan: {
id: string;
name: string;
code: string;
price: number;
} | null;
startDate: string;
endDate: string;
isActive: boolean;
mpStatus?: string;
billingMonths?: number;
pendingPaymentType?: "extension" | "upgrade";
pendingPaymentPreferenceId?: string;
lastPaymentStatus?: string;
} | null;
payments: {
id: string;
subscriptionId: string;
amount: number;
paymentDate: string;
paymentMethod?: string;
status: string;
transactionId?: string;
}[];
};
+10
View File
@@ -0,0 +1,10 @@
import { Router } from "express";
import { PlansController } from "../controllers/plans.controller";
const router = Router();
const plansController = new PlansController();
router.post("/list", plansController.listPlans);
router.post("/update", plansController.updatePlan);
export default router;
+4
View File
@@ -8,6 +8,10 @@ router.post("/paginate", usersController.paginateUsers);
router.post("/update", usersController.updateProfile);
router.post("/set-verified", usersController.setVerifiedStatus);
router.post("/organizations-status", usersController.organizationsStatus);
router.post("/recalculate-plan-usage-cycle", usersController.recalculatePlanUsageCycle);
router.post("/subscription-details", usersController.subscriptionDetails);
router.post("/extend-subscription", usersController.extendSubscription);
router.post("/finalize-subscription", usersController.finalizeSubscription);
router.post("/delete", usersController.deleteUser);
export default router;
+31
View File
@@ -0,0 +1,31 @@
import axios from "axios";
import { getSysAdminNonce } from "../helpers/GetSysAdminNonce";
import { getApiHost } from "../helpers/GetApiHost";
import { getPayload } from "../helpers/GetPayload";
import { SysAdminPlan, SysAdminUpdatePlanParams } from "../models/Plans.Model";
export class PlansService {
public async listPlans(): Promise<SysAdminPlan[]> {
const nonce = await getSysAdminNonce();
const postData = { payload: getPayload(nonce) };
const response = await axios.post(`${getApiHost()}/sysadmin/plans/list`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 5000,
});
return response.data;
}
public async updatePlan(data: SysAdminUpdatePlanParams): Promise<any> {
const nonce = await getSysAdminNonce();
const postData = { ...data, payload: getPayload(nonce) };
const response = await axios.post(`${getApiHost()}/sysadmin/plans/update`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 5000,
});
return response.data;
}
}
+64
View File
@@ -10,6 +10,14 @@ import {
SysAdminDeleteUserParams,
SysAdminOrganizationsStatusParams,
SysAdminUserOrganizationsResult,
SysAdminRecalculatePlanUsageCycleParams,
SysAdminRecalculatePlanUsageCycleResult,
SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult,
SysAdminFinalizeUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult,
} from "../models/Users.Model";
export class UsersService {
@@ -72,4 +80,60 @@ export class UsersService {
return response.data;
}
public async recalculatePlanUsageCycle(
data: SysAdminRecalculatePlanUsageCycleParams
): Promise<SysAdminRecalculatePlanUsageCycleResult> {
const nonce = await getSysAdminNonce();
const postData = { ...data, ...{ payload: getPayload(nonce) } };
const response = await axios.post(`${getApiHost()}/sysadmin/plan-usage-cycle/recalculate-user`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 10000,
});
return response.data;
}
public async getSubscriptionDetails(
data: SysAdminUserSubscriptionDetailsParams
): Promise<SysAdminUserSubscriptionDetailsResult> {
const nonce = await getSysAdminNonce();
const postData = { ...data, ...{ payload: getPayload(nonce) } };
const response = await axios.post(`${getApiHost()}/sysadmin/subscriptions/user-details`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 10000,
});
return response.data;
}
public async extendSubscription(
data: SysAdminExtendUserSubscriptionParams
): Promise<SysAdminExtendUserSubscriptionResult> {
const nonce = await getSysAdminNonce();
const postData = { ...data, ...{ payload: getPayload(nonce) } };
const response = await axios.post(`${getApiHost()}/sysadmin/subscriptions/extend-user`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 10000,
});
return response.data;
}
public async finalizeSubscription(
data: SysAdminFinalizeUserSubscriptionParams
): Promise<SysAdminFinalizeUserSubscriptionResult> {
const nonce = await getSysAdminNonce();
const postData = { ...data, ...{ payload: getPayload(nonce) } };
const response = await axios.post(`${getApiHost()}/sysadmin/subscriptions/finalize-user`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 10000,
});
return response.data;
}
}
+1
View File
@@ -12,3 +12,4 @@ NEXT_PUBLIC_DATAGRID_PAGE_SIZE = 10
NEXT_PUBLIC_SLOGAN = Tu tiempo en orden.
NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar
NEXT_PUBLIC_INFO_EMAIL = info@turnosxpress.com.ar
+1
View File
@@ -12,3 +12,4 @@ NEXT_PUBLIC_DATAGRID_PAGE_SIZE = 10
NEXT_PUBLIC_SLOGAN = Tu tiempo en orden.
NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar
NEXT_PUBLIC_INFO_EMAIL = info@turnosxpress.com.ar
+578 -545
View File
File diff suppressed because it is too large Load Diff
+24 -24
View File
@@ -9,44 +9,44 @@
"lint": "next lint"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^6.1.5",
"@mui/material": "^6.1.5",
"@mui/x-charts": "^7.23.2",
"@mui/x-date-pickers": "^7.22.2",
"@react-oauth/google": "^0.12.1",
"axios": "^1.7.7",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^6.5.0",
"@mui/material": "^6.5.0",
"@mui/x-charts": "^7.29.1",
"@mui/x-date-pickers": "^7.29.4",
"@react-oauth/google": "^0.13.5",
"axios": "^1.18.1",
"classnames": "^2.5.1",
"currency.js": "^2.0.4",
"dayjs": "^1.11.13",
"dompurify": "^3.2.7",
"driver.js": "^1.3.6",
"dayjs": "^1.11.21",
"dompurify": "^3.4.12",
"driver.js": "^1.7.0",
"leaflet": "^1.9.4",
"motion": "^11.12.0",
"next": "^15.5.7",
"pdfmake": "^0.2.20",
"motion": "^11.18.2",
"next": "^15.5.20",
"pdfmake": "^0.3.11",
"react": "^18.3.1",
"react-avatar": "^5.0.3",
"react-colorful": "^5.6.1",
"react-avatar": "^5.0.4",
"react-colorful": "^5.8.0",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-quill-new": "^3.6.0",
"react-quill-new": "^3.8.3",
"react-schedule-view": "^1.1.2",
"react-share": "^5.2.2",
"socket.io-client": "^4.8.1",
"yup": "^1.4.0",
"zustand": "^5.0.0"
"react-share": "^5.3.0",
"socket.io-client": "^4.8.3",
"yup": "^1.7.1",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/leaflet": "^1.9.15",
"@types/dompurify": "^3.2.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^20",
"@types/pdfmake": "^0.2.12",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "15.0.1",
"eslint-config-next": "15.5.20",
"typescript": "^5"
}
}
@@ -99,6 +99,7 @@ export type FindAppointmentsParams = {
clientId?: string;
start?: string;
dateDay?: string;
sessionUser?: string;
};
export type FindAppointmentsByUserParams = {
@@ -151,6 +152,7 @@ export interface AppointmentEventByClient {
serviceName: string;
serviceDescription: string;
collaboratorId: string;
collaboratorUserId: string;
collaboratorName: string;
collaboratorAvatar: string;
appointmentDate: string;
@@ -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;
};
@@ -117,6 +140,8 @@ export type ScheduleItem = {
from: string;
to: string;
disabled: boolean;
serviceScope?: "all" | "specific";
serviceIds?: string[];
};
export type FindSchedulesParams = {
+29
View File
@@ -48,6 +48,23 @@ export type GetSubscriptionInitPointParams = {
sessionUser: string;
};
export type VerifyPendingPlanPaymentParams = {
sessionUser: string;
};
export type VerifyPendingPlanPaymentResponse = {
status: "approved" | "pending" | "not_found" | "rejected" | "failed" | "cancelled";
approved: boolean;
message: string;
subscriptionId?: string;
paymentId?: string;
paymentType?: "new" | "extension" | "upgrade";
};
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
export type LastPlanPaymentType = "new" | "extension" | "upgrade";
export type DowngradeReason = "expired";
export interface GetSuscriptionInitPointResponse {
init_point: string;
}
@@ -61,6 +78,18 @@ export interface ISubscriptionInfo {
autoRenew?: boolean;
mpStatus?: string;
mpDateCreated?: Date;
pendingPaymentInitPoint?: string;
pendingPaymentPreferenceId?: string;
pendingPaymentType?: "extension" | "upgrade";
lastPaymentStatus?: LastPlanPaymentStatus;
lastPaymentPreferenceId?: string;
lastPaymentAt?: Date;
lastPaymentType?: LastPlanPaymentType;
downgradedFromPlanId?: string;
downgradedFromPlanName?: string;
downgradedFromPlanCode?: string;
downgradedAt?: Date;
downgradeReason?: DowngradeReason;
}
export const DefaultSubscriptionModel: ISubscriptionInfo = {
id: "",
@@ -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>
@@ -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(() => {
@@ -205,13 +205,13 @@ export default function ClientsGrid() {
<span className={pageStyle.bannedBadge}>Baneado</span>
)}
</div>
<p className={pageStyle.clientDetail}>
<div className={pageStyle.clientDetail}>
<div className={pageStyle.iconWrapper}>
<EmailOutlinedIcon sx={{ fontSize: 14 }} />
</div>
{client.email || "Sin email"}
</p>
<p className={pageStyle.clientPhone}>
</div>
<div className={pageStyle.clientPhone}>
<div className={pageStyle.iconWrapper}>
<PhoneOutlinedIcon sx={{ fontSize: 14 }} />
</div>
@@ -219,7 +219,7 @@ export default function ClientsGrid() {
? `${client.phoneCountryCode || ''} ${client.phoneAreaCode} - ${client.phoneNumber}`
: "Sin teléfono"
}
</p>
</div>
</div>
</div>
))}
@@ -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);
};
@@ -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;
}
@@ -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" }}>&nbsp;</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>
</>
);
}
@@ -10,8 +10,8 @@ import { useAlert } from "@store/Alert.Store";
import { useConfirmStore } from "@store/Confirm.Store";
import { useEffect, useState } from "react";
import MaterialAlert from "@mui/material/Alert";
import { ScheduleItem } from "@core/Models/Collaborators.model";
import { createSchedulesOverrides } from "../../../Collaborators.Service";
import { CollaboratorServiceItems, ScheduleItem } from "@core/Models/Collaborators.model";
import { createSchedulesOverrides, findCollaboratorsServices } from "../../../Collaborators.Service";
import { Grid2 } from "@mui/material";
import TextTime from "@core/app/components/TextTime/TextTime";
import TextDate from "@core/app/components/TextDate/TextDate";
@@ -51,11 +51,24 @@ export default function OverridesCreate() {
const [from, setFrom] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(8).minute(0));
const [to, setTo] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(12).minute(0));
const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]);
const [services, setServices] = useState<CollaboratorServiceItems[]>([]);
useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}, []);
useEffect(() => {
findCollaboratorsServices({
companyId: id,
employeeId: cid,
sessionUser: SessionInfo.userId,
})
.then((collaboratorServices) => {
setServices(collaboratorServices?.services.filter((service) => service.active) || []);
})
.catch((error) => alert.showError(error.format()));
}, [id, cid, SessionInfo.userId]);
useEffect(() => {
location.clear();
@@ -109,6 +122,8 @@ export default function OverridesCreate() {
from: schedule.from.format("HH:mm"),
to: schedule.to.format("HH:mm"),
disabled: schedule.data.disabled || false,
serviceScope: schedule.data.serviceScope || "all",
serviceIds: schedule.data.serviceScope === "specific" ? schedule.data.serviceIds || [] : [],
};
});
};
@@ -121,6 +136,8 @@ export default function OverridesCreate() {
from: from.format("HH:mm"),
to: to.format("HH:mm"),
disabled: false,
serviceScope: "all",
serviceIds: [],
},
};
@@ -174,6 +191,30 @@ export default function OverridesCreate() {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
};
const serviceScopeChangeHandler = (
from: string,
serviceScope: "all" | "specific",
serviceIds: string[]
) => {
setSchedules(
schedules.map((schedule) => {
if (schedule.data.from == from) {
return {
...schedule,
data: {
...schedule.data,
serviceScope,
serviceIds,
},
};
}
return schedule;
})
);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
};
return (
<>
<Location />
@@ -225,7 +266,11 @@ export default function OverridesCreate() {
from={schedule.data.from}
to={schedule.data.to}
disabled={schedule.data.disabled}
serviceScope={schedule.data.serviceScope}
serviceIds={schedule.data.serviceIds}
serviceOptions={services}
onCheckChange={checkChangeHandler}
onServiceScopeChange={serviceScopeChangeHandler}
onDelete={deleteScheduleHandler}
/>
))}
@@ -10,8 +10,9 @@ import { useAlert } from "@store/Alert.Store";
import { useConfirmStore } from "@store/Confirm.Store";
import { useEffect, useState } from "react";
import MaterialAlert from "@mui/material/Alert";
import { CollaboratorSchedulesView, ScheduleItem } from "@core/Models/Collaborators.model";
import { CollaboratorSchedulesView, CollaboratorServiceItems, ScheduleItem } from "@core/Models/Collaborators.model";
import {
findCollaboratorsServices,
findCollaboratorsSchedules,
updateCollaboratorsSchedules,
} from "../../Collaborators.Service";
@@ -51,6 +52,7 @@ export default function CollaboratorSchedules() {
const [from, setFrom] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(8).minute(0));
const [to, setTo] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(12).minute(0));
const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]);
const [services, setServices] = useState<CollaboratorServiceItems[]>([]);
useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -86,6 +88,18 @@ export default function CollaboratorSchedules() {
loadCollaboratorSchedules();
}, [weekDay]);
useEffect(() => {
findCollaboratorsServices({
companyId: id,
employeeId: cid,
sessionUser: SessionInfo.userId,
})
.then((collaboratorServices) => {
setServices(collaboratorServices?.services.filter((service) => service.active) || []);
})
.catch((error) => alert.showError(error.format()));
}, [id, cid, SessionInfo.userId]);
useEffect(() => {
if (eventHandler.eventType === EVENT_TYPES.SAVE) {
updateCollaboratorsSchedules({
@@ -158,6 +172,8 @@ export default function CollaboratorSchedules() {
from: schedule.from.format("HH:mm"),
to: schedule.to.format("HH:mm"),
disabled: schedule.data.disabled || false,
serviceScope: schedule.data.serviceScope || "all",
serviceIds: schedule.data.serviceScope === "specific" ? schedule.data.serviceIds || [] : [],
};
});
};
@@ -170,6 +186,8 @@ export default function CollaboratorSchedules() {
from: from.format("HH:mm"),
to: to.format("HH:mm"),
disabled: false,
serviceScope: "all",
serviceIds: [],
},
};
@@ -248,6 +266,30 @@ export default function CollaboratorSchedules() {
};
const serviceScopeChangeHandler = (
from: string,
serviceScope: "all" | "specific",
serviceIds: string[]
) => {
setSchedules(
schedules.map((schedule) => {
if (schedule.data.from == from) {
return {
...schedule,
data: {
...schedule.data,
serviceScope,
serviceIds,
},
};
}
return schedule;
})
);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
};
return (
@@ -291,7 +333,11 @@ export default function CollaboratorSchedules() {
from={schedule.data.from}
to={schedule.data.to}
disabled={schedule.data.disabled}
serviceScope={schedule.data.serviceScope}
serviceIds={schedule.data.serviceIds}
serviceOptions={services}
onCheckChange={checkChangeHandler}
onServiceScopeChange={serviceScopeChangeHandler}
onDelete={deleteScheduleHandler}
/>
))}
@@ -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>
);
}
@@ -120,6 +120,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [inviteRole, setInviteRole] = useState<"admin" | "colaborador">("colaborador");
const [isSearchingEmail, setIsSearchingEmail] = useState(false);
const [isUpgradeRequired, setIsUpgradeRequired] = useState(false);
const [inviteFlowNotice, setInviteFlowNotice] = useState("");
// State for Setup Collaborator Flow
const [collaboratorToSetup, setCollaboratorToSetup] = useState<any | null>(null);
@@ -128,6 +129,13 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [setupSelectedServices, setSetupSelectedServices] = useState<string[]>([]);
const [setupAvailableServices, setSetupAvailableServices] = useState<any[]>([]);
const getEmployeeUserId = (employee: any) => employee.userId?._id || employee.userId?.id || employee.userId;
const canManageEmployeeSchedules = (employee: any) => {
const isCurrentUser = String(getEmployeeUserId(employee)) === String(SessionInfo.userId);
return !employee.removed && (isCurrentUser || employee.guestOk === true);
};
const loadOrganizationContext = (org: any) => {
setCreatedCompanyId(org.id || org._id);
setOrgName(org.name || "");
@@ -219,11 +227,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
API.post<any[]>("employees/get-by-company", { companyId: org.id || org._id })
.then(empRes => {
if (empRes && empRes.length > 0) {
setAllEmployees(empRes);
const me = empRes.find((e: any) => {
const eUserId = e.userId?._id || e.userId?.id || e.userId;
return String(eUserId) === String(SessionInfo.userId);
});
const availableEmployees = empRes.filter(canManageEmployeeSchedules);
setAllEmployees(availableEmployees);
const me = availableEmployees.find((e: any) => String(getEmployeeUserId(e)) === String(SessionInfo.userId));
if (me) {
setEmployeeId(me.id || me._id || me.employeeId);
}
@@ -1256,7 +1262,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
for (let i = 0; i <= 6; i++) {
const isWorkingDay = workingDays.includes(i);
let schedules = [];
let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
if (isWorkingDay) {
if (hasBreak) {
schedules = [
@@ -1269,9 +1275,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
];
}
} else {
schedules = [
{ from: "00:00", to: "23:59", disabled: true }
];
schedules = [];
}
await API.post<any>("schedules/update", {
@@ -1368,14 +1372,14 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
onNext={() => {
if (doesProvideServices === null) return;
if (!doesProvideServices) {
// If they don't provide services, finish setup directly
window.location.href = "/landing/dashboard";
const query = createdCompanyId ? `&companyId=${createdCompanyId}` : "";
router.push(`/admin/assistant?flow=new-collaborator${query}`);
} else {
handleNext();
}
}}
disableNext={doesProvideServices === null}
nextLabel={doesProvideServices === false ? "Finalizar" : "Siguiente"}
nextLabel={doesProvideServices === false ? "Invitar colaborador" : "Siguiente"}
>
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
<div
@@ -1412,7 +1416,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
}}
>
<h4 style={{ margin: '0 0 5px 0', fontSize: '18px', fontWeight: doesProvideServices === false ? 'bold' : 'normal' }}>No, tengo un equipo</h4>
<p style={{ margin: 0, fontSize: '13px', color: 'rgba(255,255,255,0.7)' }}>Terminaremos aquí. Luego podrás invitar a tus colaboradores.</p>
<p style={{ margin: 0, fontSize: '13px', color: 'rgba(255,255,255,0.7)' }}>Te guiaremos para invitar al primer colaborador.</p>
</div>
</div>
</QuestionCard>
@@ -1724,7 +1728,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
for (let i = 0; i <= 6; i++) {
const isWorkingDay = workingDays.includes(i);
let schedules = [];
let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
if (isWorkingDay) {
if (hasBreak) {
schedules = [
@@ -1737,9 +1741,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
];
}
} else {
schedules = [
{ from: "00:00", to: "23:59", disabled: true }
];
schedules = [];
}
await API.post<any>("schedules/update", {
@@ -2016,7 +2018,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
return;
}
let schedules = [];
let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
if (hasBreak) {
schedules = [
{ from: openTime, to: breakStart, disabled: false },
@@ -2653,6 +2655,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const handleCreateCollaborator = async () => {
if (!inviteCollaborator || !createdCompanyId) return;
setIsCreating(true);
setInviteFlowNotice("");
try {
await API.post<any>("employees/create", {
companyId: createdCompanyId,
@@ -2661,13 +2664,21 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
hostOk: true,
sessionUser: SessionInfo.userId
});
handleActionSuccess();
handleNext();
} catch (error: any) {
const errorMessage = error.desc || error.message || "Error al invitar al colaborador";
if (errorMessage.toLowerCase().includes("limite") || errorMessage.toLowerCase().includes("límite")) {
const normalizedErrorMessage = errorMessage.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
if (normalizedErrorMessage.includes("limite")) {
setIsUpgradeRequired(true);
} else if (normalizedErrorMessage.includes("invitacion") && normalizedErrorMessage.includes("pendiente")) {
setInviteFlowNotice("Ya se envió una invitación para que este usuario forme parte de la compañía y todavía no respondió.");
setCurrentStepIndex(5);
} else if (normalizedErrorMessage.includes("ya pertenece")) {
setInviteFlowNotice("Este usuario ya pertenece a la compañía.");
setCurrentStepIndex(6);
} else {
alert(errorMessage);
setInviteFlowNotice(errorMessage);
setCurrentStepIndex(5);
}
} finally {
setIsCreating(false);
@@ -2752,6 +2763,109 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
{null}
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 4}
title="Invitación Enviada"
description="¿Cómo quieres continuar?"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
<div
onClick={() => {
setInviteEmail("");
setInviteCollaborator(null);
setInviteEmailError("");
setInviteRole("colaborador");
setIsUpgradeRequired(false);
setInviteFlowNotice("");
setCurrentStepIndex(1);
}}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Invitar otro colaborador</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Agrega otra persona a tu equipo.</div>
</div>
<div
onClick={() => {
const query = createdCompanyId ? `&companyId=${createdCompanyId}` : "";
router.push(`/admin/assistant?flow=update-schedule${query}`);
}}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Configurar mis horarios</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Define tus horarios personales de atención.</div>
</div>
<div
onClick={() => router.push("/admin/assistant")}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Terminar</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Volver a la home del asistente.</div>
</div>
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 5}
title="Invitación Pendiente"
description={inviteFlowNotice || "No pudimos enviar la invitación en este momento."}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
<div
onClick={() => {
setInviteEmail("");
setInviteCollaborator(null);
setInviteEmailError("");
setInviteRole("colaborador");
setIsUpgradeRequired(false);
setInviteFlowNotice("");
setCurrentStepIndex(1);
}}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Invitar otro colaborador</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Busca otra persona para sumar al equipo.</div>
</div>
<div
onClick={() => router.push("/admin/assistant")}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Volver al asistente</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Regresar a la home del asistente.</div>
</div>
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 6}
title="El usuario ya pertenece"
description={inviteFlowNotice || "Este usuario ya pertenece a la compañía."}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
<div
onClick={() => {
setInviteEmail("");
setInviteCollaborator(null);
setInviteEmailError("");
setInviteRole("colaborador");
setIsUpgradeRequired(false);
setInviteFlowNotice("");
setCurrentStepIndex(1);
}}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Invitar otro colaborador</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Busca otra persona para sumar al equipo.</div>
</div>
<div
onClick={() => router.push("/admin/assistant")}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Volver al asistente</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Regresar a la home del asistente.</div>
</div>
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 3 && isUpgradeRequired}
title="¡Haz crecer tu equipo!"
@@ -3202,6 +3316,24 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
return "Has configurado exitosamente tu negocio. Todo está listo para que empieces a recibir reservas y gestionar tu agenda como un profesional.";
};
const successButtonStyle: React.CSSProperties = {
padding: '16px 40px', fontSize: '18px', fontWeight: 'bold',
background: 'white', color: 'var(--wine-red)', border: 'none',
borderRadius: '30px', cursor: 'pointer',
boxShadow: '0 4px 15px rgba(0,0,0,0.3)',
transition: 'transform 0.2s, box-shadow 0.2s'
};
const handleSuccessButtonMouseOver = (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(255,255,255,0.3)';
};
const handleSuccessButtonMouseOut = (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0,0,0,0.3)';
};
return (
<div style={{
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
@@ -3240,21 +3372,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
window.location.href = "/landing/dashboard";
}
}}
style={{
padding: '16px 40px', fontSize: '18px', fontWeight: 'bold',
background: 'white', color: 'var(--wine-red)', border: 'none',
borderRadius: '30px', cursor: 'pointer',
boxShadow: '0 4px 15px rgba(0,0,0,0.3)',
transition: 'transform 0.2s, box-shadow 0.2s'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(255,255,255,0.3)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0,0,0,0.3)';
}}
style={successButtonStyle}
onMouseOver={handleSuccessButtonMouseOver}
onMouseOut={handleSuccessButtonMouseOut}
>
{action ? "Volver al Menú Principal" : "Ir a mi Panel de Control"}
</button>
@@ -12,6 +12,7 @@ interface ButtonProps {
style?: React.CSSProperties;
width?: ButtonWidth;
color?: ButtonColor;
disabled?: boolean;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
}
@@ -52,6 +53,7 @@ export default function Button(props: ButtonProps): React.ReactElement {
id={props.name}
value={props.text}
onClick={props.onClick}
disabled={props.disabled}
style={props.style}
/>
</>
@@ -18,6 +18,19 @@
gap: 15px;
}
.headerButton {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
width: 100%;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.title {
font-size: 20px;
font-weight: 700;
@@ -25,6 +38,22 @@
margin: 0;
}
.toggleIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 999px;
background: var(--white);
color: var(--green-darkestX1);
border: 1px solid var(--green-darkestX1);
font-size: 22px;
font-weight: 800;
line-height: 1;
}
.filters {
display: flex;
gap: 10px;

Some files were not shown because too many files have changed in this diff Show More