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 *.njsproj
*.sln *.sln
*.sw? *.sw?
# AI tooling
.atl/
+1
View File
@@ -1,2 +1,3 @@
# turnosxpress # 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_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
MP_CHECK_PAYMENT_MINUTES = 1 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 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": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.13", "@types/cors": "^2.8.19",
"@types/express": "4.17.17", "@types/express": "^4.17.25",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.2", "@types/jsonwebtoken": "^9.0.10",
"@types/sanitize-html": "^2.16.0", "@types/nodemailer": "^6.4.24",
"@types/socket.io": "^3.0.1", "@types/sanitize-html": "^2.16.1",
"@types/swagger-ui-express": "^4.1.3", "@types/socket.io": "^3.0.2",
"concurrently": "^8.2.0", "@types/swagger-ui-express": "^4.1.8",
"csv-parse": "^5.4.0", "concurrently": "^8.2.2",
"csv-parse": "^5.6.0",
"jest": "^30.4.2", "jest": "^30.4.2",
"ts-jest": "^29.4.11", "ts-jest": "^29.4.11",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"typescript": "5.0.4" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"@types/nodemailer": "^6.4.16", "axios": "^1.18.1",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.6",
"dayjs": "^1.11.13", "dayjs": "^1.11.21",
"dotenv": "^16.0.3", "dotenv": "^16.6.1",
"express": "^4.21.1", "express": "^4.22.2",
"google-auth-library": "^10.1.0", "google-auth-library": "^10.9.0",
"joi": "^17.9.2", "joi": "^17.13.4",
"jsonwebtoken": "^9.0.1", "jsonwebtoken": "^9.0.3",
"mongoose": "^8.8.3", "mongoose": "8.8.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.2",
"nodemailer": "^6.9.15", "nodemailer": "^6.10.1",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.6",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"socket.io": "^4.8.1", "socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.1",
"tsoa": "^5.1.1" "tsoa": "^5.1.1"
} }
} }
@@ -12,6 +12,7 @@ export type FindAppointmentsParams = {
clientId?: string; clientId?: string;
start?: Date; start?: Date;
dateDay?: Date; dateDay?: Date;
sessionUser?: string;
}; };
export type FindAppointmentsByUserParams = { export type FindAppointmentsByUserParams = {
@@ -244,6 +245,7 @@ export interface AppointmentEventByClient {
serviceName: string; serviceName: string;
serviceDescription: string; serviceDescription: string;
collaboratorId: string; collaboratorId: string;
collaboratorUserId: string;
collaboratorName: string; collaboratorName: string;
collaboratorAvatar: string; collaboratorAvatar: string;
appointmentDate: string; appointmentDate: string;
+81 -23
View File
@@ -70,7 +70,7 @@ import { DiscountType } from "../Discounts/Discounts.Interface";
import Discounts from "../Discounts/Discounts"; import Discounts from "../Discounts/Discounts";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check"; import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import Templates from "../Templates/Templates"; import Templates from "../Templates/Templates";
import { CollaboratorSchedulesView } from "../Schedules/Schedules.Interface"; import { CollaboratorSchedulesView, ScheduleItem } from "../Schedules/Schedules.Interface";
import ClientAccount from "../ClientAccounts/ClientAccount"; import ClientAccount from "../ClientAccounts/ClientAccount";
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled"; import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import DiscountsEmail from "../DiscountsEmail/DiscountsEmail"; import DiscountsEmail from "../DiscountsEmail/DiscountsEmail";
@@ -89,6 +89,14 @@ class AppointmentManager implements IAppointmentsManager {
this.Appointments = new AppointmentsAdapterMongoose(); 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> { public async setPaymentStatus(data: SetPaymentStatusParams): Promise<void> {
const sessionUser = await UsersManager.users.findOne({ const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser, _id: data.sessionUser,
@@ -305,12 +313,6 @@ class AppointmentManager implements IAppointmentsManager {
throw new Error("La compañia no existe"); 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({ const serviceCheck = await ServicesManager.services.findOne({
_id: data.serviceId, _id: data.serviceId,
companyId: data.companyId, companyId: data.companyId,
@@ -443,6 +445,18 @@ class AppointmentManager implements IAppointmentsManager {
data.comments = isNull<string>(data.comments, ""); 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 { try {
const start = dayjs(data.start).clone(); const start = dayjs(data.start).clone();
const end = dayjs(data.start) const end = dayjs(data.start)
@@ -467,6 +481,7 @@ class AppointmentManager implements IAppointmentsManager {
comments: data.comments, comments: data.comments,
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
}); });
appointmentCreated = true;
if (data.repeatId) { if (data.repeatId) {
const repeat = await RepeatsList.repeats.findOne({ const repeat = await RepeatsList.repeats.findOne({
@@ -489,8 +504,9 @@ class AppointmentManager implements IAppointmentsManager {
} }
} }
await MetricsList.addAppointment({ await MetricsList.metrics.addAppointment({
userId: companyCheck.ownerId, userId: companyCheck.ownerId,
companyId: data.companyId,
quantity: 1, quantity: 1,
}); });
@@ -559,6 +575,13 @@ class AppointmentManager implements IAppointmentsManager {
return newAppointment; return newAppointment;
} catch (err) { } catch (err) {
console.log(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."); 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(), dateDay: dayjs(data.start).startOf("day").toDate(),
from: checkFrom, from: checkFrom,
to: checkTo, to: checkTo,
serviceId: data.serviceId,
}); });
} else { } else {
//chequear que el colaborador este disponible. //chequear que el colaborador este disponible.
@@ -929,6 +953,7 @@ class AppointmentManager implements IAppointmentsManager {
weekDay: weekDay, weekDay: weekDay,
from: checkFrom, from: checkFrom,
to: checkTo, to: checkTo,
serviceId: data.serviceId,
}); });
} }
@@ -1037,6 +1062,9 @@ class AppointmentManager implements IAppointmentsManager {
continue; 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 clientName = joinStrings([client.firstName, client.lastName], " ");
const clientUserName = userClient const clientUserName = userClient
? joinStrings([userClient.firstName, userClient.lastName], clientName) ? joinStrings([userClient.firstName, userClient.lastName], clientName)
@@ -1236,9 +1264,17 @@ class AppointmentManager implements IAppointmentsManager {
_id: String(employee.userId), _id: String(employee.userId),
}); });
if (!userEmployee) { // Historical read snapshot fallback: use Employee snapshot if User is deleted
throw new Error("No se ha encontrado el usuario del colaborador"); 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({ const service = await ServiceList.services.findOne({
_id: String(appointment.serviceId), _id: String(appointment.serviceId),
@@ -1256,14 +1292,6 @@ class AppointmentManager implements IAppointmentsManager {
? getAvatar(client.userId, userClient.avatar, clientUserName) ? getAvatar(client.userId, userClient.avatar, clientUserName)
: 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); const discountData = await Discounts.getDiscountData(appointment.discountId, appointment.companyId);
let ctaBalance = 0; let ctaBalance = 0;
@@ -1285,8 +1313,9 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name, serviceName: service.name,
serviceDescription: service.description, serviceDescription: service.description,
collaboratorId: employee.id, collaboratorId: employee.id,
collaboratorName: employeeFullName, collaboratorUserId: String(employee.userId),
collaboratorAvatar: employeeAvatar, collaboratorName: collaboratorFullName,
collaboratorAvatar: collaboratorAvatar,
appointmentDate: dayjs(appointment.start).toISOString(), appointmentDate: dayjs(appointment.start).toISOString(),
startTime: appointment.startHour, startTime: appointment.startHour,
endTime: appointment.endHour, endTime: appointment.endHour,
@@ -1394,6 +1423,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name, serviceName: service.name,
serviceDescription: service.description, serviceDescription: service.description,
collaboratorId: employee.id, collaboratorId: employee.id,
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName, collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar, collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(), appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1503,6 +1533,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name, serviceName: service.name,
serviceDescription: service.description, serviceDescription: service.description,
collaboratorId: employee.id, collaboratorId: employee.id,
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName, collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar, collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(), appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1621,6 +1652,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name, serviceName: service.name,
serviceDescription: service.description, serviceDescription: service.description,
collaboratorId: String(employee.id || (employee as any)._id), collaboratorId: String(employee.id || (employee as any)._id),
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName, collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar, collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(), appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1738,6 +1770,7 @@ class AppointmentManager implements IAppointmentsManager {
serviceName: service.name, serviceName: service.name,
serviceDescription: service.description, serviceDescription: service.description,
collaboratorId: employee.id, collaboratorId: employee.id,
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName, collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar, collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(), appointmentDate: dayjs(appointment.start).toISOString(),
@@ -1865,6 +1898,7 @@ public async findAppointmentsByCollaboratorPaginated(
serviceName: service.name, serviceName: service.name,
serviceDescription: service.description, serviceDescription: service.description,
collaboratorId: String(employee.id || (employee as any)._id), collaboratorId: String(employee.id || (employee as any)._id),
collaboratorUserId: String(employee.userId),
collaboratorName: employeeFullName, collaboratorName: employeeFullName,
collaboratorAvatar: employeeAvatar, collaboratorAvatar: employeeAvatar,
appointmentDate: dayjs(appointment.start).toISOString(), appointmentDate: dayjs(appointment.start).toISOString(),
@@ -2271,6 +2305,10 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
continue; continue;
} }
if (!this.isScheduleAvailableForService(schedule, data.serviceId)) {
continue;
}
const scheduleDataFrom = schedule.from.split(":"); const scheduleDataFrom = schedule.from.split(":");
const scheduleDataTo = schedule.to.split(":"); const scheduleDataTo = schedule.to.split(":");
const hourFrom = parseInt(scheduleDataFrom[0]); const hourFrom = parseInt(scheduleDataFrom[0]);
@@ -2743,7 +2781,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
date: currentDate.toDate(), 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()); returnAvailableDates.push(currentDate.toDate());
} else if (!hasOverride) { } else if (!hasOverride) {
// Check if the current date is within any enabled schedule range // 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(), 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()); 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, CreateClientByUserParams,
PaginateClientsParams, PaginateClientsParams,
PaginateClientsResults, PaginateClientsResults,
DetachClientUserParams,
} from "./Clients.Interface"; } from "./Clients.Interface";
import { Document, FilterQuery, Model, Schema, model } from "mongoose"; import { Document, FilterQuery, Model, Schema, model } from "mongoose";
@@ -96,4 +97,8 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
pages: Math.ceil(count / filters.limit), 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; status?: boolean;
}; };
export type DetachClientUserParams = {
clientId: string;
};
export type FindClientByIdParams = { export type FindClientByIdParams = {
id: string; id: string;
companyId: string; companyId: string;
@@ -77,7 +81,7 @@ export type DeleteClientParams = {
export interface IClient { export interface IClient {
id?: string; id?: string;
companyId: string; companyId: string;
userId: string; userId?: string;
fromClientId: string; fromClientId: string;
status: boolean; status: boolean;
firstName: string; firstName: string;
@@ -116,6 +120,7 @@ export interface IClientsAdapter {
find(filters: FindClientsParams): Promise<IClient[]>; find(filters: FindClientsParams): Promise<IClient[]>;
findOne(filters: FindClientsParams): Promise<IClientDocument | null>; findOne(filters: FindClientsParams): Promise<IClientDocument | null>;
paginate(filters: PaginateClientsParams): Promise<PaginateClientsResults>; paginate(filters: PaginateClientsParams): Promise<PaginateClientsResults>;
detachUser(data: DetachClientUserParams): Promise<void>;
} }
export interface IClientsManager { export interface IClientsManager {
+2
View File
@@ -195,7 +195,9 @@ class ClientManager implements IClientsManager {
if (appointment) { if (appointment) {
appointment.clientId = data.toClientId; appointment.clientId = data.toClientId;
if (clientTo.userId) {
appointment.userId = clientTo.userId; appointment.userId = clientTo.userId;
}
await appointment.save(); await appointment.save();
} }
} }
+4 -2
View File
@@ -536,7 +536,8 @@ class CompaniesManager implements ICompaniesManager {
const employ = await EmployeesList.employees.findOne({ const employ = await EmployeesList.employees.findOne({
companyId: data.id, companyId: data.id,
userId: String(data.sessionUser), userId: String(data.sessionUser),
}); removed: { $ne: true },
} as any);
if (!employ) { if (!employ) {
throw new Error("El usuario no participa de la organización"); 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. //Organizaciones en las que participa como colaborador.
const orgCollaborator = await EmployeesList.employees.find({ const orgCollaborator = await EmployeesList.employees.find({
userId: data.sessionUser, userId: data.sessionUser,
}); removed: { $ne: true },
} as any);
for (const item of orgCollaborator) { for (const item of orgCollaborator) {
if (item.companyId) { if (item.companyId) {
+205 -4
View File
@@ -14,6 +14,7 @@ import {
UpdateEmployeeParams, UpdateEmployeeParams,
DeleteEmployeesByCompany, DeleteEmployeesByCompany,
DeleteEmployeeParams, DeleteEmployeeParams,
RemoveCollaboratorParams,
} from "./Employees.Interface"; } from "./Employees.Interface";
import UsersManager from "../Users/Users"; import UsersManager from "../Users/Users";
import { ICompanyDocument } from "../Companies/Companies.Adapter.Mongoose"; import { ICompanyDocument } from "../Companies/Companies.Adapter.Mongoose";
@@ -26,7 +27,9 @@ import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model";
import MetricsList from "../../Models/Metrics/Metrics"; import MetricsList from "../../Models/Metrics/Metrics";
import { NotificationsManager } from "../Notifications/Notifications"; import { NotificationsManager } from "../Notifications/Notifications";
import EmployeesServicesList from "../EmployeesServices/EmployeesServices"; import EmployeesServicesList from "../EmployeesServices/EmployeesServices";
import HeatMapList from "../HeatMap/HeatMap";
import SchedulesList from "../Schedules/Schedules"; import SchedulesList from "../Schedules/Schedules";
import AppointmentList from "../Appointments/Appointments";
import { IncompleteCollaboratorView } from "./Employees.Interface"; import { IncompleteCollaboratorView } from "./Employees.Interface";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
@@ -51,7 +54,11 @@ class EmployeeManager implements IEmployeesManager {
role: EmployeeRoles role: EmployeeRoles
): Promise<boolean> { ): Promise<boolean> {
//Check if role exist in roles array. //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) { if (!check) {
return false; return false;
@@ -112,8 +119,59 @@ class EmployeeManager implements IEmployeesManager {
userId: data.userId, 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) { 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); const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId);
@@ -346,7 +404,7 @@ class EmployeeManager implements IEmployeesManager {
} }
public async findByCompanyId(data: FindEmployeesParams): Promise<CompanyEmployeesView[]> { 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( const view = await Promise.all(
employees.map(async (employee) => { employees.map(async (employee) => {
@@ -422,7 +480,7 @@ class EmployeeManager implements IEmployeesManager {
} }
public async textObjectFilter(data: FindEmployeesParams): Promise<TextObjectFilterResult[]> { 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[] = []; const formattedResult: TextObjectFilterResult[] = [];
for (const colaborador of employees) { for (const colaborador of employees) {
@@ -522,8 +580,151 @@ class EmployeeManager implements IEmployeesManager {
} }
return incompleteCollaborators; 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(); const EmployeesList = new EmployeeManager();
export default EmployeesList; export default EmployeesList;
@@ -23,6 +23,18 @@ export class EmployeesAdapterMongoose implements IEmployeesAdapter {
limit: { type: Number, required: true, default: -1 }, limit: { type: Number, required: true, default: -1 },
hostOk: { type: Boolean, required: true, default: false }, hostOk: { type: Boolean, required: true, default: false },
guestOk: { 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 }, creationDate: { type: Date, required: true, default: Date.now },
}); });
@@ -68,6 +68,13 @@ export type DeleteEmployeeParams = {
employeeId: string; employeeId: string;
}; };
export type RemoveCollaboratorParams = {
employeeId: string;
companyId: string;
replacementEmployeeId?: string;
sessionUser: string;
};
export interface IEmployee { export interface IEmployee {
id?: string; id?: string;
companyId: string; companyId: string;
@@ -77,6 +84,15 @@ export interface IEmployee {
limit?: number; limit?: number;
guestOk?: boolean; guestOk?: boolean;
hostOk?: boolean; hostOk?: boolean;
removed?: boolean;
removedAt?: Date;
removedBy?: string;
profileSnapshot?: {
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
};
} }
export interface CompanyEmployeesView { export interface CompanyEmployeesView {
@@ -93,6 +109,14 @@ export interface CompanyEmployeesView {
hostOk: boolean; hostOk: boolean;
fullOk: boolean; fullOk: boolean;
calendarColor: string; calendarColor: string;
removed?: boolean;
removedAt?: Date;
profileSnapshot?: {
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
};
} }
export interface IncompleteCollaboratorView { export interface IncompleteCollaboratorView {
@@ -125,4 +149,5 @@ export interface IEmployeesManager {
deleteEmployee(data: DeleteEmployeeParams): Promise<void>; deleteEmployee(data: DeleteEmployeeParams): Promise<void>;
rejectGuest(data: ValidateEmployeeParams): Promise<void>; rejectGuest(data: ValidateEmployeeParams): Promise<void>;
getIncompleteSetup(sessionUser: string): Promise<IncompleteCollaboratorView[]>; 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) { 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({ const employeeServiceCheck = await this.employeesServices.findOne({
@@ -160,7 +164,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
}); });
if (!employeeCheck) { 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); await this.employeesServices.update(data);
@@ -253,7 +261,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
}); });
if (!employeeCheck) { 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({ const userCheck = await UsersManager.users.findOne({
@@ -330,7 +342,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
_id: data.employeeId, _id: data.employeeId,
}); });
if (!employeeCheck) { 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({ const serviceCheck = await ServicesManager.services.findOne({
@@ -371,7 +387,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
}); });
if (!employeeCheck) { 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({ const userCheck = await UsersManager.users.findOne({
@@ -416,6 +436,15 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
}); });
for (const service of servicesByEmployee) { 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 }); const employeeData = await EmployeesList.findById({ id: service.employeeId });
returnData.push({ 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 = { export type MetricsParams = {
userId?: string; userId?: string;
companyId?: string;
quantity?: number; quantity?: number;
limit?: number;
}; };
export type DeleteMetricsByCompany = { export type DeleteMetricsByCompany = {
@@ -45,6 +47,8 @@ export interface IMetricsManager {
addEmployee(data: MetricsParams): Promise<void>; addEmployee(data: MetricsParams): Promise<void>;
addService(data: MetricsParams): Promise<void>; addService(data: MetricsParams): Promise<void>;
addAppointment(data: MetricsParams): Promise<void>; addAppointment(data: MetricsParams): Promise<void>;
reserveAppointment(data: MetricsParams): Promise<boolean>;
releaseAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>; addClient(data: MetricsParams): Promise<void>;
canAddOrganization(userId: string): Promise<boolean>; canAddOrganization(userId: string): Promise<boolean>;
canAddEmployee(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 ClientsList from "../Clients/Clients";
import RepeatsList from "../Repeats/Repeats"; import RepeatsList from "../Repeats/Repeats";
import { isNull } from "../../helpers/IsNull"; import { isNull } from "../../helpers/IsNull";
import PlanUsageCycleList from "../PlanUsageCycle/PlanUsageCycle";
class MetricsManager implements IMetricsManager { class MetricsManager implements IMetricsManager {
metrics: MetricsAdapterMongoose; metrics: MetricsAdapterMongoose;
@@ -39,7 +40,8 @@ class MetricsManager implements IMetricsManager {
for (const org of organizations) { for (const org of organizations) {
const employees = await EmployeesList.employees.find({ const employees = await EmployeesList.employees.find({
companyId: String(org.id), companyId: String(org.id),
}); removed: { $ne: true },
} as any);
employeesLength += employees.length; employeesLength += employees.length;
const services = await ServiceList.services.find({ const services = await ServiceList.services.find({
@@ -231,17 +233,64 @@ class MetricsManager implements IMetricsManager {
if (plan.limitAppointments < 0) { if (plan.limitAppointments < 0) {
return true; return true;
} }
const metrics = await this.getMetrics(userId); const appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({ userId });
if (metrics.appointmentsCount >= plan.limitAppointments) { if (appointmentsCount >= plan.limitAppointments) {
return false; return false;
} }
return true; return true;
} }
public async addAppointment(data: MetricsParams): Promise<void> { public async addAppointment(data: MetricsParams): Promise<void> {
if (data.userId) {
await PlanUsageCycleList.addAppointment({
userId: data.userId,
quantity: data.quantity,
});
}
await this.metrics.addAppointment(data); 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> { public async addClient(data: MetricsParams): Promise<void> {
await this.metrics.addClient(data); 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 = { export type FindPlanPaymentsParams = {
_id?: string; _id?: string;
userId?: string;
subscriptionId?: string;
transactionId?: string;
}; };
export type CreatePlanPaymentParams = Omit<IPlanPayment, "id">;
export interface IPlanPayment { export interface IPlanPayment {
id?: string; id?: string;
userId: string; // Referencia al ID del usuario userId: string; // Referencia al ID del usuario
@@ -15,9 +20,13 @@ export interface IPlanPayment {
export interface IPlanPaymentsAdapter { export interface IPlanPaymentsAdapter {
find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>; find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
} }
export interface IPlanPaymentsManager { export interface IPlanPaymentsManager {
planPayments: IPlanPaymentsAdapter; planPayments: IPlanPaymentsAdapter;
find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>; 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 { import {
FindPlanSuscripcionsParams, FindPlanSuscripcionsParams,
IPlanSuscriptionsAdapter, IPlanSuscriptionsAdapter,
@@ -38,6 +37,26 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
mpDateCreated: { type: Date, required: false }, mpDateCreated: { type: Date, required: false },
mpInitPoint: { type: String, required: false }, mpInitPoint: { type: String, required: false },
mpPreferenceId: { 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); this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
@@ -54,20 +73,32 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
public async findOne( public async findOne(
filters: FindPlanSuscripcionsParams filters: FindPlanSuscripcionsParams
): Promise<IPlanSuscriptionDocument | null> { ): Promise<IPlanSuscriptionDocument | null> {
let subscription = await this.planSuscriptionList const searchCriteria: Record<string, unknown> = {};
.findOne({ userId: filters.sessionUser })
.exec(); 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) { if (!subscription) {
return null; return null;
} }
const dateNow = dayjs(new Date()); const dateNow = new Date();
const dateEnd = dayjs(subscription.endDate); const dateEnd = new Date(subscription.endDate);
try { try {
if (subscription.mpPreferenceId) { if (subscription.mpPreferenceId) {
if (dateNow.isAfter(dateEnd)) { if (dateNow > dateEnd) {
if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) { if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) {
subscription.mpPreferenceId = ""; subscription.mpPreferenceId = "";
subscription.isActive = false; subscription.isActive = false;
@@ -77,17 +108,7 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
await subscription.save(); await subscription.save();
return subscription; return subscription;
} }
// Without subscriptions, we wait for the webhook to update the status. // Pending Checkout Preferences do not consume paid time; webhook decides the final 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;
}
} }
} }
} catch (e) { } catch (e) {
@@ -3,6 +3,8 @@ import { IPlanSuscriptionDocument } from "./PlanSubscriptions.Adapter.Mongoose";
export type FindPlanSuscripcionsParams = { export type FindPlanSuscripcionsParams = {
sessionUser: string; sessionUser: string;
mpPreferenceId?: string;
mpStatus?: string;
}; };
export type CreatePlanSuscriptionParams = { export type CreatePlanSuscriptionParams = {
@@ -11,6 +13,7 @@ export type CreatePlanSuscriptionParams = {
mpPlanId: string; mpPlanId: string;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
isActive?: boolean;
autoRenew: boolean; autoRenew: boolean;
mpPayerId?: number; mpPayerId?: number;
mpPayerEmail?: string; mpPayerEmail?: string;
@@ -18,6 +21,26 @@ export type CreatePlanSuscriptionParams = {
mpDateCreated?: Date; mpDateCreated?: Date;
mpInitPoint?: string; mpInitPoint?: string;
mpPreferenceId?: 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 = { export type CancellPlanSuscriptionParams = {
@@ -47,6 +70,7 @@ export type MPPreApprovalResponse = {
export type ToFreePlanParams = { export type ToFreePlanParams = {
sessionUser: string; sessionUser: string;
downgradeReason?: DowngradeReason;
}; };
export type ToMpParams = { export type ToMpParams = {
@@ -68,6 +92,23 @@ export type GetSubscriptionInitPointParams = {
sessionUser: string; 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 { export interface GetSuscriptionInitPointResponse {
init_point: string; init_point: string;
} }
@@ -88,6 +129,26 @@ export interface IPlanSuscription {
mpDateCreated: Date; // Fecha de creación de la suscripción en MercadoPago. mpDateCreated: Date; // Fecha de creación de la suscripción en MercadoPago.
mpInitPoint: string; // URL de inicio 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. 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 { export interface ISubscriptionInfo {
@@ -99,6 +160,18 @@ export interface ISubscriptionInfo {
autoRenew: boolean; autoRenew: boolean;
mpStatus: string; mpStatus: string;
mpDateCreated: Date; 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 { 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 axios from "axios";
import PlansList from "../../Models/Plans/Plans"; import PlansList from "../../Models/Plans/Plans";
import dayjs from "dayjs"; import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import "dayjs/locale/es"; import "dayjs/locale/es";
import { isNull } from "../../helpers/IsNull"; import { isNull } from "../../helpers/IsNull";
import { PlanFeatures } from "../Plans/Plans.interface"; import { PlanFeatures } from "../Plans/Plans.interface";
@@ -31,9 +30,11 @@ import { NotificationsManager } from "../Notifications/Notifications";
import WapServerList from "../WapServer/WapServer"; import WapServerList from "../WapServer/WapServer";
import CompaniesList from "../Companies/Companies"; import CompaniesList from "../Companies/Companies";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing";
dayjs.locale("es"); dayjs.locale("es");
dayjs.extend(isSameOrAfter);
const DAY_MS = 24 * 60 * 60 * 1000;
class PlanSuscriptionsManager implements IPlanSuscriptionsManager { class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
planSuscriptions: PlanSuscriptionsAdapterMongoose; planSuscriptions: PlanSuscriptionsAdapterMongoose;
@@ -42,6 +43,24 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
this.planSuscriptions = new PlanSuscriptionsAdapterMongoose(); 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( public async getInitPoint(
data: GetSubscriptionInitPointParams data: GetSubscriptionInitPointParams
): Promise<GetSuscriptionInitPointResponse> { ): Promise<GetSuscriptionInitPointResponse> {
@@ -135,8 +154,9 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
planId: data.planId, planId: data.planId,
mpPlanId: data.planId, mpPlanId: data.planId,
startDate: subscriptionStartDate.toDate(), startDate: subscriptionStartDate.toDate(),
endDate: subscriptionStartDate.clone().add(data.months || 1, "months").toDate(), endDate: subscriptionStartDate.clone().add(10, "years").toDate(),
autoRenew: true, autoRenew: false,
mpStatus: "",
mpInitPoint: "/landing/dashboard", mpInitPoint: "/landing/dashboard",
}); });
} else if (plan.price > 0) { } else if (plan.price > 0) {
@@ -168,8 +188,16 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
if (subscription) { if (subscription) {
const subscriptionStartDate = dayjs(new Date()); 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.mpInitPoint = dashboardUrl || "";
subscription.downgradedFromPlanId = undefined;
subscription.downgradedFromPlanName = undefined;
subscription.downgradedFromPlanCode = undefined;
subscription.downgradedAt = undefined;
subscription.downgradeReason = undefined;
await subscription.save(); await subscription.save();
} }
} else { } else {
@@ -182,6 +210,23 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
months: data.months || 1, months: data.months || 1,
}); });
} else { } 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) { if (subscription.mpStatus != MP_SUBS_STATUS.CANCELLED && actualPlan.id !== data.planId) {
throw new Error( 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." "No se puede cambiar el plan, ya que el usuario ya tiene una suscripción activa. Para cambiarlo primero debes cancelar tu plan actual."
@@ -196,6 +241,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
} }
} }
} }
}
if (!subscription) { if (!subscription) {
throw new Error("No fue posible crear la suscripción"); throw new Error("No fue posible crear la suscripción");
@@ -227,7 +273,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
} }
try { try {
const subscriptionStartDate = dayjs(new Date()); const preferenceDate = dayjs(new Date());
let discount = 0; let discount = 0;
if (data.months === 3) discount = plan.discount3Months || 0; if (data.months === 3) discount = plan.discount3Months || 0;
@@ -237,21 +283,74 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const totalPrice = plan.price * data.months; const totalPrice = plan.price * data.months;
const finalPrice = totalPrice - (totalPrice * (discount / 100)); 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( const response = await axios.post(
"https://api.mercadopago.com/checkout/preferences", "https://api.mercadopago.com/checkout/preferences",
{ {
items: [ 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, quantity: 1,
currency_id: "ARS", currency_id: "ARS",
unit_price: finalPrice unit_price: isUpgradePayment ? proratedAmount : finalPrice
} }
], ],
back_urls: { back_urls: {
success: process.env.MP_BACK_URL, success: this.buildMercadoPagoBackUrl("success"),
failure: process.env.MP_BACK_URL, failure: this.buildMercadoPagoBackUrl("failure"),
pending: process.env.MP_BACK_URL pending: this.buildMercadoPagoBackUrl("pending")
}, },
auto_return: "approved", auto_return: "approved",
payer: { payer: {
@@ -270,20 +369,102 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
const subscriptionData = response.data; 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({ const subscription = await this.planSuscriptions.create({
userId: data.sessionUser, userId: data.sessionUser,
planId: data.planId, planId: data.planId,
mpPlanId: data.planId, mpPlanId: data.planId,
startDate: subscriptionStartDate.toDate(), startDate: preferenceDate.toDate(),
endDate: subscriptionStartDate.clone().add(data.months, "months").toDate(), endDate: preferenceDate.toDate(),
isActive: false,
autoRenew: false, autoRenew: false,
mpPayerEmail: sessionUser.mpPayerEmail, mpPayerEmail: sessionUser.mpPayerEmail,
mpStatus: MP_SUBS_STATUS.PENDING, mpStatus: MP_SUBS_STATUS.PENDING,
mpDateCreated: new Date(), mpDateCreated: new Date(),
mpInitPoint: subscriptionData.init_point, mpInitPoint: subscriptionData.init_point,
mpPreferenceId: subscriptionData.id, 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({ 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. // We just cancel it locally if not paid, or change to free plan if paid.
} }
const dateNow = dayjs(new Date()).endOf("day"); const dateNow = new Date();
const dateEnd = dayjs(subscription.endDate); const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) { if (dateNow > dateEnd) {
this.toFreePlan({ await this.toFreePlan({
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
downgradeReason: "expired",
}); });
} else { } else {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED; subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.mpInitPoint = ""; subscription.mpInitPoint = "";
subscription.mpPayerEmail = ""; subscription.mpPayerEmail = "";
subscription.mpPayerId = -1; 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(); await subscription.save();
} }
} catch (error) { } catch (error) {
@@ -400,12 +592,43 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
//console.log("guardando el plan gratuito..."); //console.log("guardando el plan gratuito...");
//try { //try {
const downgradedFromPlan = data.downgradeReason === "expired"
? await PlansList.plans.findOne({ _id: String(subscription.planId) })
: null;
console.log("cambiando al free plan:", freePlan.id); console.log("cambiando al free plan:", freePlan.id);
subscription.planId = freePlan.id; subscription.planId = freePlan.id;
subscription.mpStatus = ""; subscription.mpStatus = "";
subscription.isActive = true; subscription.isActive = true;
subscription.autoRenew = false;
subscription.mpInitPoint = ""; subscription.mpInitPoint = "";
subscription.mpPreferenceId = ""; 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(); await subscription.save();
// console.log("plan gratuito guardado:", freePlan.id); // console.log("plan gratuito guardado:", freePlan.id);
@@ -430,6 +653,15 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
autoRenew: subscription.autoRenew, autoRenew: subscription.autoRenew,
mpStatus: subscription.mpStatus, mpStatus: subscription.mpStatus,
mpDateCreated: subscription.mpDateCreated, 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); io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo);
@@ -446,16 +678,15 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null; return null;
} }
const dateNow = dayjs(new Date()).endOf("day"); const dateNow = new Date();
const dateEnd = dayjs(subscription.endDate); const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) { if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
if (subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) { await this.toFreePlan({
this.toFreePlan({
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
downgradeReason: "expired",
}); });
} }
}
return await this.planSuscriptions.findOne(data); return await this.planSuscriptions.findOne(data);
} }
@@ -469,26 +700,30 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
return null; return null;
} }
const dateNow = dayjs(new Date()).endOf("day"); const dateNow = new Date();
const dateEnd = dayjs(subscription.endDate); const dateEnd = new Date(subscription.endDate);
if (dateNow.isSameOrAfter(dateEnd)) { if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
//Esta condicion era la culpable de que todo el tiempo se queria aplicar el free plan. await this.toFreePlan({
//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, sessionUser: data.sessionUser,
downgradeReason: "expired",
}); });
} return this.getSubscriptionByUser(data);
} }
const plan = await PlansList.plans.findOne({ let plan = await PlansList.findOne({
_id: subscription.planId, _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) { if (!plan) {
throw new Error("No se ha encontrado el plan"); throw new Error("No se ha encontrado el plan");
} }
@@ -502,6 +737,18 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
autoRenew: subscription.autoRenew, autoRenew: subscription.autoRenew,
mpStatus: subscription.mpStatus, mpStatus: subscription.mpStatus,
mpDateCreated: subscription.mpDateCreated, 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; const plan = subscriptionInfo.plan;
if (!subscriptionInfo.isActive) {
return false;
}
if (!plan) { if (!plan) {
throw new Error("No se encontro el plan de la suscripcion"); throw new Error("No se encontro el plan de la suscripcion");
} }
@@ -553,7 +804,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
if (!subscriptionInfo) { if (!subscriptionInfo) {
throw new Error("No se encontro una suscripcion activa"); 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 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", () => { describe("PlanSubscriptions Logic", () => {
it("should calculate correct endDate for 1, 3, 6, 12 months", () => { 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 const final12 = total12 - (total12 * (30 / 100)); // 12000 - 3600 = 8400
expect(final12).toBe(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"; import { Document, Model, Schema, model } from "mongoose";
export interface IPlanDocument extends Omit<IPlan, "id">, Document {} 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> { public async findOne(filters: FindPlansParams): Promise<IPlanDocument | null> {
return this.planList.findOne(filters).exec(); 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; 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 { export interface IPlan {
id?: string; id?: string;
name: string; name: string;
@@ -44,10 +71,12 @@ export interface IPlan {
export interface IPlansAdapter { export interface IPlansAdapter {
find(filters: FindPlansParams): Promise<IPlan[]>; find(filters: FindPlansParams): Promise<IPlan[]>;
findOne(filters: FindPlansParams): Promise<IPlan | null>; findOne(filters: FindPlansParams): Promise<IPlan | null>;
updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void>;
} }
export interface IPlansManager { export interface IPlansManager {
plans: IPlansAdapter; plans: IPlansAdapter;
find(data: FindPlansParams): Promise<IPlan[]>; find(data: FindPlansParams): Promise<IPlan[]>;
findOne(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 { isNull } from "../../helpers/IsNull";
import { PlansAdapterMongoose } from "./Plans.Adapter.Mongoose"; 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 { class PlansManager implements IPlansManager {
plans: PlansAdapterMongoose; plans: PlansAdapterMongoose;
@@ -59,6 +59,12 @@ class PlansManager implements IPlansManager {
return this.getOnePlan(plan); 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(); const PlansList = new PlansManager();
@@ -4,6 +4,8 @@ export type ScheduleItem = {
from: string; from: string;
to: string; to: string;
disabled: boolean; disabled: boolean;
serviceScope?: "all" | "specific";
serviceIds?: string[];
}; };
export type FindSchedulesParams = { export type FindSchedulesParams = {
@@ -19,6 +21,7 @@ export type AvailableSchedulesParams = {
weekDay: number; weekDay: number;
from: string; from: string;
to: string; to: string;
serviceId?: string;
}; };
export type PaginateSchedulesParams = FindSchedulesParams & { export type PaginateSchedulesParams = FindSchedulesParams & {
+12
View File
@@ -173,6 +173,14 @@ class SchedulesManager implements ISchedulesManager {
return await this.schedules.find(filters); 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> { private async getScheduleByFrom(data: AvailableSchedulesParams): Promise<ScheduleItem | null> {
const schedule = await this.schedules.findOne({ const schedule = await this.schedules.findOne({
employeeId: data.employeeId, employeeId: data.employeeId,
@@ -185,6 +193,9 @@ class SchedulesManager implements ISchedulesManager {
if (horario.disabled) { if (horario.disabled) {
continue; continue;
} }
if (!this.isScheduleAvailableForService(horario, data.serviceId)) {
continue;
}
if (horario.from == data.from) { if (horario.from == data.from) {
return horario; return horario;
} }
@@ -236,6 +247,7 @@ class SchedulesManager implements ISchedulesManager {
weekDay: data.weekDay, weekDay: data.weekDay,
from: scheduleFrom.to, from: scheduleFrom.to,
to: data.to, to: data.to,
serviceId: data.serviceId,
}); });
if (!scheduleTo) { if (!scheduleTo) {
@@ -14,6 +14,7 @@ export type AvailableSchedulesOverridesParams = {
dateDay: Date; dateDay: Date;
from: string; from: string;
to: string; to: string;
serviceId?: string;
}; };
export type PaginateSchedulesOverridesParams = FindSchedulesOverridesParams & { export type PaginateSchedulesOverridesParams = FindSchedulesOverridesParams & {
@@ -154,6 +154,14 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
return await this.schedulesOverrides.findOne(filters); 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> { private async getScheduleByFrom(data: AvailableSchedulesOverridesParams): Promise<ScheduleItem | null> {
const exactDate = dayjs(data.dateDay).startOf("day").toDate(); const exactDate = dayjs(data.dateDay).startOf("day").toDate();
const schedule = await this.schedulesOverrides.findOne({ const schedule = await this.schedulesOverrides.findOne({
@@ -165,6 +173,7 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
if (schedule) { if (schedule) {
for (const horario of schedule.schedules) { for (const horario of schedule.schedules) {
if (horario.disabled) continue; if (horario.disabled) continue;
if (!this.isScheduleAvailableForService(horario, data.serviceId)) continue;
if (horario.from == data.from) return horario; if (horario.from == data.from) return horario;
if (horario.from < data.from && horario.to > data.from) return horario; if (horario.from < data.from && horario.to > data.from) return horario;
} }
@@ -205,6 +205,73 @@ export type SysAdminOrganizationsStatusParams = {
userId: string; 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 = { export type SysAdminUserOrganizationsResult = {
clientIn: { companyId: string; companyName: string }[]; clientIn: { companyId: string; companyName: string }[];
collaboratorIn: { 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) {
if (sub.plan.price > 0) { 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, 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({ const clients = await ClientsList.clients.find({
userId: data.sessionUser, userId: data.sessionUser,
}); });
@@ -294,21 +294,7 @@ class UsersManager implements IUsersManager {
continue; continue;
} }
//Tengo que pasarlo como sesionUSer al duenio de la organizacion. para que pueda eliminar. await ClientsList.clients.detachUser({ clientId: String(client.id) });
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),
});
} }
//Eliminar las conversaciones //Eliminar las conversaciones
@@ -630,7 +616,7 @@ class UsersManager implements IUsersManager {
// Generate a JWT token using the payload and a secret word // Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.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 // 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 the token expires in less than 5 minutes, generate a new one.
if (decodedPayload.exp < nowInSeconds + fiveMinutesInSeconds) { if (decodedPayload.exp < nowInSeconds + fiveMinutesInSeconds) {
finalToken = jwt.sign(payload, `${process.env.SECRET_WORD}`, { 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 // Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.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 // 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 // Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.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 // 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 // Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.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 // 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 // Generate a JWT token using the payload and a secret word
const token = jwt.sign(payload, `${process.env.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 // Create a user object by merging the payload and the token
@@ -1279,14 +1265,7 @@ class UsersManager implements IUsersManager {
for (const client of clients) { for (const client of clients) {
if (!client.id) continue; if (!client.id) continue;
const orgClient = await CompaniesList.companies.findOne({ _id: String(client.companyId) }); await ClientsList.clients.detachUser({ clientId: String(client.id) });
if (!orgClient) continue;
await ClientsList.deleteClient({
clientId: String(client.id),
checkCashOnAccount: false,
sessionUser: String(orgClient.ownerId),
});
} }
await MessageConversationList.deleteConversationsByUser({ userId: data.userId }); await MessageConversationList.deleteConversationsByUser({ userId: data.userId });
@@ -25,6 +25,7 @@ import {
PaginatedAppointmentEventByClientResult, PaginatedAppointmentEventByClientResult,
} from "../../Models/Appointments/Appointments.Interface"; } from "../../Models/Appointments/Appointments.Interface";
import { connect } from "mongoose"; import { connect } from "mongoose";
import { validatePermissionsByCompany } from "../../helpers/check";
export class AppointmentService { export class AppointmentService {
public async createAppointment(data: CreateAppointmentParams): Promise<IAppointment> { public async createAppointment(data: CreateAppointmentParams): Promise<IAppointment> {
@@ -63,6 +64,15 @@ export class AppointmentService {
): Promise<AppointmentAdminByDateView> { ): Promise<AppointmentAdminByDateView> {
await connect(`${process.env.DATABASE_CONNECTION}`); 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); const appointments = await AppointmentList.findAppointmentsAdminByDate(data);
return appointments; return appointments;
@@ -10,6 +10,7 @@ import CashFlowList from "../../Models/CashFlow/CashFlow";
import CompaniesList from "../../Models/Companies/Companies"; import CompaniesList from "../../Models/Companies/Companies";
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons"; import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
import MetricsList from "../../Models/Metrics/Metrics"; import MetricsList from "../../Models/Metrics/Metrics";
import PlanUsageCycleList from "../../Models/PlanUsageCycle/PlanUsageCycle";
export class DashboardService { export class DashboardService {
public async getDashboardSummary(sessionUser: string): Promise<DashboardSummaryResult> { public async getDashboardSummary(sessionUser: string): Promise<DashboardSummaryResult> {
@@ -198,11 +199,18 @@ export class DashboardService {
const isOwner = String(ownerId) === String(params.sessionUser); const isOwner = String(ownerId) === String(params.sessionUser);
// Fetch metrics for owner // 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 // Fetch subscription for owner
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: String(ownerId) }); const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: String(ownerId) });
if (subscription) {
metrics.appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({
userId: String(ownerId),
});
}
return { return {
metrics, metrics,
subscription, subscription,
@@ -13,6 +13,7 @@ import {
UpdateEmployeeRolesParams, UpdateEmployeeRolesParams,
UpdateEmployeeParams, UpdateEmployeeParams,
IncompleteCollaboratorView, IncompleteCollaboratorView,
RemoveCollaboratorParams,
} from "../../Models/Employees/Employees.Interface"; } from "../../Models/Employees/Employees.Interface";
import { authenticateMiddleware } from "../../middleware/authentication"; import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; 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, UpdateEmployeeRolesParams,
UpdateEmployeeParams, UpdateEmployeeParams,
IncompleteCollaboratorView, IncompleteCollaboratorView,
RemoveCollaboratorParams,
} from "../../Models/Employees/Employees.Interface"; } from "../../Models/Employees/Employees.Interface";
import { import {
CreateEmployeeServiceParams, CreateEmployeeServiceParams,
@@ -162,4 +163,9 @@ export class EmployeesService {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
await EmployeesList.update(data); 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 PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose"; import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
import { connect } from "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 { export class MercadoPagoWebhookService {
public async handleWebhook(body: any): Promise<void> { 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 status = paymentData.status; // approved, pending, rejected, etc.
const userId = paymentData.external_reference; // We passed this when creating preference const paymentContext = await this.resolvePaymentContext(paymentData);
const userId = paymentContext.externalReference || paymentData.external_reference; // We passed this when creating preference
if (userId && status === "approved") { const preferenceId = paymentContext.preferenceId || paymentData.preference_id;
// Update subscription status in DB const subscription = await this.findSubscriptionForPayment(userId, preferenceId);
const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
sessionUser: userId,
});
if (userId && preferenceId && status === "approved") {
if (subscription) { if (subscription) {
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED; await this.applyApprovedPayment(subscription, paymentData, userId, preferenceId);
subscription.isActive = true;
// Assuming endDate is correctly set for the months purchased when preference was created
await subscription.save();
} }
} else if (userId && status === "rejected") { } else if (userId && preferenceId && REJECTED_PAYMENT_STATUSES.includes(status)) {
const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({ if (subscription) {
sessionUser: userId, await this.applyRejectedPayment(subscription, paymentData, userId, preferenceId);
});
if (subscription && subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED) {
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
subscription.isActive = false;
await subscription.save();
} }
} }
} catch (error) { } 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, GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse, GetSuscriptionInitPointResponse,
MPPreApprovalResponse, MPPreApprovalResponse,
VerifyPendingPlanPaymentParams,
VerifyPendingPlanPaymentResponse,
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface"; } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import { authenticateMiddleware } from "../../middleware/authentication"; import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
import { rateLimiter } from "../../middleware/ratelimiter";
@Route("suscriptions/get-init-point") @Route("suscriptions/get-init-point")
@Middlewares(authenticateMiddleware) @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, GetSubscriptionInitPointParams,
GetSuscriptionInitPointResponse, GetSuscriptionInitPointResponse,
MPPreApprovalResponse, MPPreApprovalResponse,
VerifyPendingPlanPaymentParams,
VerifyPendingPlanPaymentResponse,
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface"; } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import { connect } from "mongoose"; import { connect } from "mongoose";
import { validateSessionUser } from "../../helpers/check";
import { MercadoPagoWebhookService } from "../MercadoPago/MercadoPagoWebhook.Service";
export class PlanSubscriptionsService { export class PlanSubscriptionsService {
public async getInitPoint( public async getInitPoint(
@@ -27,4 +31,10 @@ export class PlanSubscriptionsService {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
await PlanSubscriptionsList.cancell(data); 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"; import path from "path";
export default function getAvatar( export default function getAvatar(
userId: string, userId: string | null | undefined,
avatar: string | null | undefined, avatar: string | null | undefined,
fallback: string fallback: string
): string { ): string {
+2
View File
@@ -4,6 +4,7 @@ import { DashboardPage } from './pages/DashboardPage';
import { UsersPage } from './pages/UsersPage'; import { UsersPage } from './pages/UsersPage';
import { WapServersPage } from './pages/WapServersPage'; import { WapServersPage } from './pages/WapServersPage';
import { CompaniesPage } from './pages/CompaniesPage'; import { CompaniesPage } from './pages/CompaniesPage';
import { PlansPage } from './pages/PlansPage';
function App() { function App() {
return ( return (
@@ -13,6 +14,7 @@ function App() {
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="users" element={<UsersPage />} /> <Route path="users" element={<UsersPage />} />
<Route path="companies" element={<CompaniesPage />} /> <Route path="companies" element={<CompaniesPage />} />
<Route path="plans" element={<PlansPage />} />
<Route path="wapservers" element={<WapServersPage />} /> <Route path="wapservers" element={<WapServersPage />} />
</Route> </Route>
</Routes> </Routes>
+135
View File
@@ -33,6 +33,141 @@ export const getOrganizationsStatus = async (userId: string) => {
return res.data; 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 // Wap Servers API Calls
export const fetchWapServers = async (filters: any) => { export const fetchWapServers = async (filters: any) => {
const res = await sysadminApi.post('/wap/paginate', filters); const res = await sysadminApi.post('/wap/paginate', filters);
@@ -1,5 +1,5 @@
import { Outlet, NavLink } from 'react-router-dom'; 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'; import { motion } from 'framer-motion';
export const DashboardLayout = () => { export const DashboardLayout = () => {
@@ -30,6 +30,10 @@ export const DashboardLayout = () => {
<Building2 size={20} /> <Building2 size={20} />
Organizaciones Organizaciones
</NavLink> </NavLink>
<NavLink to="/plans" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<BadgeDollarSign size={20} />
Planes
</NavLink>
<NavLink to="/wapservers" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}> <NavLink to="/wapservers" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<Server size={20} /> <Server size={20} />
Servidores WAP 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 { 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'; import { Link } from 'react-router-dom';
export const DashboardPage = () => { export const DashboardPage = () => {
@@ -32,23 +32,61 @@ export const DashboardPage = () => {
</div> </div>
<div> <div>
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Usuarios</h3> <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> </div>
</motion.div> </motion.div>
</Link> </Link>
{/* Card Wap Servers */} {/* Card Wap Servers */}
<div style={{ opacity: 0.6 }}> <Link to="/wapservers" style={{ display: 'block' }}>
<div className="glass-panel" style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', height: '100%' }}> <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)' }}> <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} /> <Server size={30} />
</div> </div>
<div> <div>
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem' }}>Servidores WAP</h3> <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' }}>[Próximamente] Monitoreo y configuración de servidores de WhatsApp puente.</p> <p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Consulta servidores de WhatsApp puente y su estado operativo.</p>
</div>
</div>
</div> </div>
</motion.div>
</Link>
{/* Card Config */} {/* Card Config */}
<div style={{ opacity: 0.6 }}> <div style={{ opacity: 0.6 }}>
@@ -64,6 +102,20 @@ export const DashboardPage = () => {
</div> </div>
</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> </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 React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; 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 { fetchUsers, deleteUser, setVerifiedStatus } from '../api/sysadmin';
import { EditUserModal } from '../components/ui/EditUserModal'; import { EditUserModal } from '../components/ui/EditUserModal';
import { OrganizationsModal } from '../components/ui/OrganizationsModal'; 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 = () => { export const UsersPage = () => {
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [recalculationUserId, setRecalculationUserId] = useState('');
const [openActionsUserId, setOpenActionsUserId] = useState<string | null>(null);
// Filters // Filters
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
@@ -23,6 +28,8 @@ export const UsersPage = () => {
// Modals state // Modals state
const [editingUser, setEditingUser] = useState<any>(null); const [editingUser, setEditingUser] = useState<any>(null);
const [orgUser, setOrgUser] = useState<any>(null); const [orgUser, setOrgUser] = useState<any>(null);
const [subscriptionUser, setSubscriptionUser] = useState<any>(null);
const [recalculationToolsOpen, setRecalculationToolsOpen] = useState(false);
const loadUsers = async () => { const loadUsers = async () => {
setLoading(true); setLoading(true);
@@ -46,6 +53,15 @@ export const UsersPage = () => {
loadUsers(); loadUsers();
}, [page, filters.sortBy, filters.sortOrder, filters.isVerified]); }, [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) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setPage(1); setPage(1);
@@ -107,10 +123,23 @@ export const UsersPage = () => {
} }
}; };
const openRecalculationTools = (userId = '') => {
setRecalculationUserId(userId);
setRecalculationToolsOpen(true);
};
const runRowAction = (action: () => void) => {
setOpenActionsUserId(null);
action();
};
return ( 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' }}> <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 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
<h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Gestión de Usuarios</h1> <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> </div>
{/* Filters Bar */} {/* Filters Bar */}
@@ -179,9 +208,9 @@ export const UsersPage = () => {
</thead> </thead>
<tbody> <tbody>
{loading ? ( {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 ? ( ) : 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 => ( 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"> <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 || '-'} {u.verificationCode || '-'}
</td> </td>
<td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}> <td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{new Date(u.creationDate).toLocaleDateString()} {formatDate(u.creationDate)}
</td> </td>
<td style={{ padding: '1rem', textAlign: 'right' }}> <td style={{ padding: '1rem', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}> <div className="actions-menu-wrapper" onClick={(e) => e.stopPropagation()}>
<button className="icon-btn" title="Ver Organizaciones" onClick={() => setOrgUser(u)}><Building2 size={18} /></button> <button
<button className="icon-btn" title="Editar Perfil" onClick={() => setEditingUser(u)}><Edit2 size={18} /></button> className="icon-btn"
<button className="icon-btn danger" title="Eliminar" onClick={() => handleDelete(u)}><Trash2 size={18} /></button> 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> </div>
</td> </td>
</tr> </tr>
@@ -261,6 +320,51 @@ export const UsersPage = () => {
.icon-btn.danger:hover { .icon-btn.danger:hover {
background: var(--danger); 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 { .btn-secondary {
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.05);
color: var(--text-main); color: var(--text-main);
@@ -293,6 +397,8 @@ export const UsersPage = () => {
{/* Modals */} {/* Modals */}
{editingUser && <EditUserModal user={editingUser} onClose={() => setEditingUser(null)} onSaved={loadUsers} />} {editingUser && <EditUserModal user={editingUser} onClose={() => setEditingUser(null)} onSaved={loadUsers} />}
{orgUser && <OrganizationsModal user={orgUser} onClose={() => setOrgUser(null)} />} {orgUser && <OrganizationsModal user={orgUser} onClose={() => setOrgUser(null)} />}
{subscriptionUser && <SubscriptionDetailsModal user={subscriptionUser} onClose={() => setSubscriptionUser(null)} />}
{recalculationToolsOpen && <RecalculationToolsModal initialUserId={recalculationUserId} onClose={() => setRecalculationToolsOpen(false)} />}
</motion.div> </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 usersRoutes from "./routes/users.routes";
import companiesRoutes from "./routes/companies.routes"; import companiesRoutes from "./routes/companies.routes";
import servicesRoutes from "./routes/services.routes"; import servicesRoutes from "./routes/services.routes";
import plansRoutes from "./routes/plans.routes";
const app = express(); const app = express();
@@ -16,5 +17,6 @@ app.use("/wap", wapRoutes);
app.use("/users", usersRoutes); app.use("/users", usersRoutes);
app.use("/companies", companiesRoutes); app.use("/companies", companiesRoutes);
app.use("/services", servicesRoutes); app.use("/services", servicesRoutes);
app.use("/plans", plansRoutes);
export default app; 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, SysAdminSetVerifiedParams,
SysAdminDeleteUserParams, SysAdminDeleteUserParams,
SysAdminOrganizationsStatusParams, SysAdminOrganizationsStatusParams,
SysAdminRecalculatePlanUsageCycleParams,
SysAdminExtendUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionParams,
SysAdminUserSubscriptionDetailsParams,
} from "../models/Users.Model"; } from "../models/Users.Model";
const usersService = new UsersService(); const usersService = new UsersService();
@@ -65,4 +69,48 @@ export class UsersController {
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" }); 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 }[]; clientIn: { companyId: string; companyName: string }[];
collaboratorIn: { 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("/update", usersController.updateProfile);
router.post("/set-verified", usersController.setVerifiedStatus); router.post("/set-verified", usersController.setVerifiedStatus);
router.post("/organizations-status", usersController.organizationsStatus); 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); router.post("/delete", usersController.deleteUser);
export default router; 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, SysAdminDeleteUserParams,
SysAdminOrganizationsStatusParams, SysAdminOrganizationsStatusParams,
SysAdminUserOrganizationsResult, SysAdminUserOrganizationsResult,
SysAdminRecalculatePlanUsageCycleParams,
SysAdminRecalculatePlanUsageCycleResult,
SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult,
SysAdminFinalizeUserSubscriptionParams,
SysAdminFinalizeUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult,
} from "../models/Users.Model"; } from "../models/Users.Model";
export class UsersService { export class UsersService {
@@ -72,4 +80,60 @@ export class UsersService {
return response.data; 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_SLOGAN = Tu tiempo en orden.
NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar 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_SLOGAN = Tu tiempo en orden.
NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar 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" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.13.3", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^6.1.5", "@mui/icons-material": "^6.5.0",
"@mui/material": "^6.1.5", "@mui/material": "^6.5.0",
"@mui/x-charts": "^7.23.2", "@mui/x-charts": "^7.29.1",
"@mui/x-date-pickers": "^7.22.2", "@mui/x-date-pickers": "^7.29.4",
"@react-oauth/google": "^0.12.1", "@react-oauth/google": "^0.13.5",
"axios": "^1.7.7", "axios": "^1.18.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"currency.js": "^2.0.4", "currency.js": "^2.0.4",
"dayjs": "^1.11.13", "dayjs": "^1.11.21",
"dompurify": "^3.2.7", "dompurify": "^3.4.12",
"driver.js": "^1.3.6", "driver.js": "^1.7.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"motion": "^11.12.0", "motion": "^11.18.2",
"next": "^15.5.7", "next": "^15.5.20",
"pdfmake": "^0.2.20", "pdfmake": "^0.3.11",
"react": "^18.3.1", "react": "^18.3.1",
"react-avatar": "^5.0.3", "react-avatar": "^5.0.4",
"react-colorful": "^5.6.1", "react-colorful": "^5.8.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-leaflet": "^4.2.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-schedule-view": "^1.1.2",
"react-share": "^5.2.2", "react-share": "^5.3.0",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.3",
"yup": "^1.4.0", "yup": "^1.7.1",
"zustand": "^5.0.0" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.2.0",
"@types/leaflet": "^1.9.15", "@types/leaflet": "^1.9.21",
"@types/node": "^20", "@types/node": "^20",
"@types/pdfmake": "^0.2.12", "@types/pdfmake": "^0.2.12",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "15.0.1", "eslint-config-next": "15.5.20",
"typescript": "^5" "typescript": "^5"
} }
} }
@@ -99,6 +99,7 @@ export type FindAppointmentsParams = {
clientId?: string; clientId?: string;
start?: string; start?: string;
dateDay?: string; dateDay?: string;
sessionUser?: string;
}; };
export type FindAppointmentsByUserParams = { export type FindAppointmentsByUserParams = {
@@ -151,6 +152,7 @@ export interface AppointmentEventByClient {
serviceName: string; serviceName: string;
serviceDescription: string; serviceDescription: string;
collaboratorId: string; collaboratorId: string;
collaboratorUserId: string;
collaboratorName: string; collaboratorName: string;
collaboratorAvatar: string; collaboratorAvatar: string;
appointmentDate: string; appointmentDate: string;
@@ -22,6 +22,14 @@ export interface IEmployee {
roles?: EmployeeRoles[]; roles?: EmployeeRoles[];
guestOk?: boolean; guestOk?: boolean;
hostOk?: boolean; hostOk?: boolean;
removed?: boolean;
removedAt?: Date;
profileSnapshot?: {
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
};
} }
export interface CompanyEmployeesView { export interface CompanyEmployeesView {
@@ -38,6 +46,14 @@ export interface CompanyEmployeesView {
hostOk: boolean; hostOk: boolean;
fullOk: boolean; fullOk: boolean;
calendarColor: string; calendarColor: string;
removed?: boolean;
removedAt?: Date;
profileSnapshot?: {
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
};
} }
export type CreateEmployeeParams = { export type CreateEmployeeParams = {
@@ -66,6 +82,13 @@ export type ValidateEmployeeParams = {
sessionUser: string; sessionUser: string;
}; };
export type RemoveCollaboratorParams = {
employeeId: string;
companyId: string;
replacementEmployeeId?: string;
sessionUser: string;
};
export type FindEmployeesByIdParams = { export type FindEmployeesByIdParams = {
id?: string; id?: string;
}; };
@@ -117,6 +140,8 @@ export type ScheduleItem = {
from: string; from: string;
to: string; to: string;
disabled: boolean; disabled: boolean;
serviceScope?: "all" | "specific";
serviceIds?: string[];
}; };
export type FindSchedulesParams = { export type FindSchedulesParams = {
+29
View File
@@ -48,6 +48,23 @@ export type GetSubscriptionInitPointParams = {
sessionUser: string; 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 { export interface GetSuscriptionInitPointResponse {
init_point: string; init_point: string;
} }
@@ -61,6 +78,18 @@ export interface ISubscriptionInfo {
autoRenew?: boolean; autoRenew?: boolean;
mpStatus?: string; mpStatus?: string;
mpDateCreated?: Date; 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 = { export const DefaultSubscriptionModel: ISubscriptionInfo = {
id: "", id: "",
@@ -52,6 +52,7 @@ export default function ClientDashboardLayout({
const { goTo } = useNavigation(); const { goTo } = useNavigation();
const [client, setClient] = useState<OrganizationClientView | null>(null); const [client, setClient] = useState<OrganizationClientView | null>(null);
const [isCheckingAccess, setIsCheckingAccess] = useState(true);
const [clientName, setClientName] = useState<string>(""); const [clientName, setClientName] = useState<string>("");
const [, setClientEmail] = useState<string>(""); const [, setClientEmail] = useState<string>("");
const [clientAvatar, setClientAvatar] = useState<string>(""); const [clientAvatar, setClientAvatar] = useState<string>("");
@@ -106,6 +107,7 @@ export default function ClientDashboardLayout({
} }
eventHandler.setEventType(EVENT_TYPES.LOADING); eventHandler.setEventType(EVENT_TYPES.LOADING);
setIsCheckingAccess(true);
findClientById({ findClientById({
id: clientId, id: clientId,
@@ -119,9 +121,15 @@ export default function ClientDashboardLayout({
setClientAvatar(client.avatar); setClientAvatar(client.avatar);
}) })
.catch((error) => { .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()); alert.showError(error.format());
}) })
.finally(() => { .finally(() => {
setIsCheckingAccess(false);
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
}); });
}; };
@@ -131,7 +139,8 @@ export default function ClientDashboardLayout({
<Content> <Content>
<Suspense fallback={<SuspenseLoading />}> <Suspense fallback={<SuspenseLoading />}>
<Secure> <Secure>
<SectionContainer style={{ top: "70px" }}> {isCheckingAccess && <SuspenseLoading />}
{client && <SectionContainer style={{ top: "70px" }}>
<div className={style.clientProfileHeader}> <div className={style.clientProfileHeader}>
<Avatar <Avatar
name="userProfileAvatar" name="userProfileAvatar"
@@ -195,7 +204,7 @@ export default function ClientDashboardLayout({
</div> </div>
</div> </div>
<Menu /> <Menu />
</SectionContainer> </SectionContainer>}
</Secure> </Secure>
</Suspense> </Suspense>
</Content> </Content>
@@ -193,8 +193,8 @@ export default function OrganizationProfile() {
const filter = useFilterStore(); const filter = useFilterStore();
const alert = useAlert(); const alert = useAlert();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const appointmentsView = useAppointmentsViewStore();
const { goTo } = useNavigation(); const { goTo } = useNavigation();
const appointmentsView = useAppointmentsViewStore();
const [windowWidth] = useWindowSize(); const [windowWidth] = useWindowSize();
@@ -317,6 +317,7 @@ export default function OrganizationProfile() {
employeeId: appointmentsView.collaboratorId employeeId: appointmentsView.collaboratorId
? appointmentsView.collaboratorId ? appointmentsView.collaboratorId
: collaboratorId, : collaboratorId,
sessionUser: SessionInfo.userId,
}; };
const filteredFilter = Object.fromEntries( const filteredFilter = Object.fromEntries(
@@ -338,6 +339,10 @@ export default function OrganizationProfile() {
]); ]);
}) })
.catch((error) => { .catch((error) => {
if (error?.desc?.includes("No tienes permisos") || error?.desc?.includes("no tiene permisos")) {
goTo("/admin/access-denied");
return;
}
alert.showError(error.format()); alert.showError(error.format());
}) })
.finally(() => { .finally(() => {
@@ -205,13 +205,13 @@ export default function ClientsGrid() {
<span className={pageStyle.bannedBadge}>Baneado</span> <span className={pageStyle.bannedBadge}>Baneado</span>
)} )}
</div> </div>
<p className={pageStyle.clientDetail}> <div className={pageStyle.clientDetail}>
<div className={pageStyle.iconWrapper}> <div className={pageStyle.iconWrapper}>
<EmailOutlinedIcon sx={{ fontSize: 14 }} /> <EmailOutlinedIcon sx={{ fontSize: 14 }} />
</div> </div>
{client.email || "Sin email"} {client.email || "Sin email"}
</p> </div>
<p className={pageStyle.clientPhone}> <div className={pageStyle.clientPhone}>
<div className={pageStyle.iconWrapper}> <div className={pageStyle.iconWrapper}>
<PhoneOutlinedIcon sx={{ fontSize: 14 }} /> <PhoneOutlinedIcon sx={{ fontSize: 14 }} />
</div> </div>
@@ -219,7 +219,7 @@ export default function ClientsGrid() {
? `${client.phoneCountryCode || ''} ${client.phoneAreaCode} - ${client.phoneNumber}` ? `${client.phoneCountryCode || ''} ${client.phoneAreaCode} - ${client.phoneNumber}`
: "Sin teléfono" : "Sin teléfono"
} }
</p> </div>
</div> </div>
</div> </div>
))} ))}
@@ -14,6 +14,7 @@ import {
CollaboratorSchedulesView, CollaboratorSchedulesView,
UpdateScheduleParams, UpdateScheduleParams,
UpdateEmployeeParams, UpdateEmployeeParams,
RemoveCollaboratorParams,
} from "@models/Collaborators.model"; } from "@models/Collaborators.model";
import { CollaboratorView, FindCollaboratorByEmailParams } from "@models/User.model"; import { CollaboratorView, FindCollaboratorByEmailParams } from "@models/User.model";
import { ApiVoidResult } from "@models/Api.VoidResult.type"; 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> => { export const deleteSchedulesOverrides = async (data: DeleteSchedulesOverridesParams): Promise<ApiVoidResult> => {
return ApiRequest.post<ApiVoidResult>("schedules-overrides/delete", data); 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"; "use client";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import Location from "@components/Location/Location"; import Location from "@components/Location/Location";
import { useLocationStore } from "@store/Location.Store"; import { useLocationStore } from "@store/Location.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store"; import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
import { useSessionStore } from "@store/Sesion.Store"; import { useSessionStore } from "@store/Sesion.Store";
import { useAlert } from "@store/Alert.Store"; import { useAlert } from "@store/Alert.Store";
import { useConfirmStore } from "@store/Confirm.Store";
import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined"; import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined";
import LocalPoliceOutlinedIcon from "@mui/icons-material/LocalPoliceOutlined"; import LocalPoliceOutlinedIcon from "@mui/icons-material/LocalPoliceOutlined";
import MaterialAlert from "@mui/material/Alert"; 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 { 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 { 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 = { type UrlData = {
id: string; id: string;
@@ -27,9 +49,19 @@ export default function OrganizationEdit() {
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const alert = useAlert(); const alert = useAlert();
const confirm = useConfirmStore();
const { goTo } = useNavigation();
const [collaboratorName, setCollaboratorName] = useState<string>(""); const [collaboratorName, setCollaboratorName] = useState<string>("");
const [collaboratorUserId, setCollaboratorUserId] = useState<string>("");
const [collaboratorColor, setCollaboratorColor] = 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(() => { useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -58,6 +90,7 @@ export default function OrganizationEdit() {
eventHandler.setEventType(EVENT_TYPES.LOADING); eventHandler.setEventType(EVENT_TYPES.LOADING);
loadCollaborator(); loadCollaborator();
loadCollaborators();
}, [collaboratorName]); }, [collaboratorName]);
useEffect(() => { useEffect(() => {
@@ -83,6 +116,21 @@ export default function OrganizationEdit() {
} }
}, [eventHandler.eventType]); }, [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 = () => { const loadCollaborator = () => {
eventHandler.setEventType(EVENT_TYPES.LOADING); eventHandler.setEventType(EVENT_TYPES.LOADING);
findCollaboratorsById({ id: cid }) findCollaboratorsById({ id: cid })
@@ -92,6 +140,7 @@ export default function OrganizationEdit() {
} }
setCollaboratorName(collaborator.fullName); setCollaboratorName(collaborator.fullName);
setCollaboratorUserId(collaborator.userId);
setCollaboratorColor(collaborator.calendarColor); setCollaboratorColor(collaborator.calendarColor);
}) })
.catch((error) => { .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 ( return (
<> <>
<Location /> <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 { useConfirmStore } from "@store/Confirm.Store";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import MaterialAlert from "@mui/material/Alert"; import MaterialAlert from "@mui/material/Alert";
import { ScheduleItem } from "@core/Models/Collaborators.model"; import { CollaboratorServiceItems, ScheduleItem } from "@core/Models/Collaborators.model";
import { createSchedulesOverrides } from "../../../Collaborators.Service"; import { createSchedulesOverrides, findCollaboratorsServices } from "../../../Collaborators.Service";
import { Grid2 } from "@mui/material"; import { Grid2 } from "@mui/material";
import TextTime from "@core/app/components/TextTime/TextTime"; import TextTime from "@core/app/components/TextTime/TextTime";
import TextDate from "@core/app/components/TextDate/TextDate"; 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 [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 [to, setTo] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(12).minute(0));
const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]); const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]);
const [services, setServices] = useState<CollaboratorServiceItems[]>([]);
useEffect(() => { useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); 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(() => { useEffect(() => {
location.clear(); location.clear();
@@ -109,6 +122,8 @@ export default function OverridesCreate() {
from: schedule.from.format("HH:mm"), from: schedule.from.format("HH:mm"),
to: schedule.to.format("HH:mm"), to: schedule.to.format("HH:mm"),
disabled: schedule.data.disabled || false, 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"), from: from.format("HH:mm"),
to: to.format("HH:mm"), to: to.format("HH:mm"),
disabled: false, disabled: false,
serviceScope: "all",
serviceIds: [],
}, },
}; };
@@ -174,6 +191,30 @@ export default function OverridesCreate() {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); 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 ( return (
<> <>
<Location /> <Location />
@@ -225,7 +266,11 @@ export default function OverridesCreate() {
from={schedule.data.from} from={schedule.data.from}
to={schedule.data.to} to={schedule.data.to}
disabled={schedule.data.disabled} disabled={schedule.data.disabled}
serviceScope={schedule.data.serviceScope}
serviceIds={schedule.data.serviceIds}
serviceOptions={services}
onCheckChange={checkChangeHandler} onCheckChange={checkChangeHandler}
onServiceScopeChange={serviceScopeChangeHandler}
onDelete={deleteScheduleHandler} onDelete={deleteScheduleHandler}
/> />
))} ))}
@@ -10,8 +10,9 @@ import { useAlert } from "@store/Alert.Store";
import { useConfirmStore } from "@store/Confirm.Store"; import { useConfirmStore } from "@store/Confirm.Store";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import MaterialAlert from "@mui/material/Alert"; import MaterialAlert from "@mui/material/Alert";
import { CollaboratorSchedulesView, ScheduleItem } from "@core/Models/Collaborators.model"; import { CollaboratorSchedulesView, CollaboratorServiceItems, ScheduleItem } from "@core/Models/Collaborators.model";
import { import {
findCollaboratorsServices,
findCollaboratorsSchedules, findCollaboratorsSchedules,
updateCollaboratorsSchedules, updateCollaboratorsSchedules,
} from "../../Collaborators.Service"; } 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 [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 [to, setTo] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(12).minute(0));
const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]); const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]);
const [services, setServices] = useState<CollaboratorServiceItems[]>([]);
useEffect(() => { useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -86,6 +88,18 @@ export default function CollaboratorSchedules() {
loadCollaboratorSchedules(); loadCollaboratorSchedules();
}, [weekDay]); }, [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(() => { useEffect(() => {
if (eventHandler.eventType === EVENT_TYPES.SAVE) { if (eventHandler.eventType === EVENT_TYPES.SAVE) {
updateCollaboratorsSchedules({ updateCollaboratorsSchedules({
@@ -158,6 +172,8 @@ export default function CollaboratorSchedules() {
from: schedule.from.format("HH:mm"), from: schedule.from.format("HH:mm"),
to: schedule.to.format("HH:mm"), to: schedule.to.format("HH:mm"),
disabled: schedule.data.disabled || false, 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"), from: from.format("HH:mm"),
to: to.format("HH:mm"), to: to.format("HH:mm"),
disabled: false, 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 ( return (
@@ -291,7 +333,11 @@ export default function CollaboratorSchedules() {
from={schedule.data.from} from={schedule.data.from}
to={schedule.data.to} to={schedule.data.to}
disabled={schedule.data.disabled} disabled={schedule.data.disabled}
serviceScope={schedule.data.serviceScope}
serviceIds={schedule.data.serviceIds}
serviceOptions={services}
onCheckChange={checkChangeHandler} onCheckChange={checkChangeHandler}
onServiceScopeChange={serviceScopeChangeHandler}
onDelete={deleteScheduleHandler} onDelete={deleteScheduleHandler}
/> />
))} ))}
@@ -11,6 +11,7 @@ import Menu from "@components/Menu/Menu";
import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store"; import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store";
import Content from "@core/app/components/Content/Content"; import Content from "@core/app/components/Content/Content";
import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading"; import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading";
import { useNavigation } from "@core/app/hooks/goto";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -18,24 +19,33 @@ type UrlData = {
export default function OrgLayout({ children }: { children: React.ReactNode }) { export default function OrgLayout({ children }: { children: React.ReactNode }) {
const [organization, setOrganization] = useState<MyOranizationsView | undefined>(); const [organization, setOrganization] = useState<MyOranizationsView | undefined>();
const [isCheckingAccess, setIsCheckingAccess] = useState(true);
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const organizationId = data.id; const organizationId = data.id;
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const organizationHeader = useOrganizationHeaderStore(); const organizationHeader = useOrganizationHeaderStore();
const { goTo } = useNavigation();
useEffect(() => { useEffect(() => {
if (!SessionInfo.userId) { if (!SessionInfo.userId) {
return; return;
} }
loadOrganizationsById({ id: organizationId, sessionUser: SessionInfo.userId }).then( setIsCheckingAccess(true);
(organization) => { loadOrganizationsById({ id: organizationId, sessionUser: SessionInfo.userId })
.then((organization) => {
setOrganization(organization); setOrganization(organization);
organizationHeader.setHeaderColor(organization.headerColor); organizationHeader.setHeaderColor(organization.headerColor);
organizationHeader.setHeaderFontColor(organization.headerFontColor); organizationHeader.setHeaderFontColor(organization.headerFontColor);
organizationHeader.setHeaderFontShadowColor(organization.headerFontShadowColor); organizationHeader.setHeaderFontShadowColor(organization.headerFontShadowColor);
} })
); .catch(() => {
setOrganization(undefined);
goTo("/admin/access-denied");
})
.finally(() => {
setIsCheckingAccess(false);
});
}, [SessionInfo.userId]); }, [SessionInfo.userId]);
return ( return (
@@ -43,9 +53,14 @@ export default function OrgLayout({ children }: { children: React.ReactNode }) {
<Content> <Content>
<Suspense fallback={<SuspenseLoading />}> <Suspense fallback={<SuspenseLoading />}>
<Secure> <Secure>
{isCheckingAccess && <SuspenseLoading />}
{organization && <OrganizationHeader data={organization} />} {organization && <OrganizationHeader data={organization} />}
{organization && (
<>
<OrganizationContent>{children}</OrganizationContent> <OrganizationContent>{children}</OrganizationContent>
<Menu /> <Menu />
</>
)}
</Secure> </Secure>
</Suspense> </Suspense>
</Content> </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 [inviteRole, setInviteRole] = useState<"admin" | "colaborador">("colaborador");
const [isSearchingEmail, setIsSearchingEmail] = useState(false); const [isSearchingEmail, setIsSearchingEmail] = useState(false);
const [isUpgradeRequired, setIsUpgradeRequired] = useState(false); const [isUpgradeRequired, setIsUpgradeRequired] = useState(false);
const [inviteFlowNotice, setInviteFlowNotice] = useState("");
// State for Setup Collaborator Flow // State for Setup Collaborator Flow
const [collaboratorToSetup, setCollaboratorToSetup] = useState<any | null>(null); 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 [setupSelectedServices, setSetupSelectedServices] = useState<string[]>([]);
const [setupAvailableServices, setSetupAvailableServices] = useState<any[]>([]); 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) => { const loadOrganizationContext = (org: any) => {
setCreatedCompanyId(org.id || org._id); setCreatedCompanyId(org.id || org._id);
setOrgName(org.name || ""); 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 }) API.post<any[]>("employees/get-by-company", { companyId: org.id || org._id })
.then(empRes => { .then(empRes => {
if (empRes && empRes.length > 0) { if (empRes && empRes.length > 0) {
setAllEmployees(empRes); const availableEmployees = empRes.filter(canManageEmployeeSchedules);
const me = empRes.find((e: any) => { setAllEmployees(availableEmployees);
const eUserId = e.userId?._id || e.userId?.id || e.userId; const me = availableEmployees.find((e: any) => String(getEmployeeUserId(e)) === String(SessionInfo.userId));
return String(eUserId) === String(SessionInfo.userId);
});
if (me) { if (me) {
setEmployeeId(me.id || me._id || me.employeeId); 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++) { for (let i = 0; i <= 6; i++) {
const isWorkingDay = workingDays.includes(i); const isWorkingDay = workingDays.includes(i);
let schedules = []; let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
if (isWorkingDay) { if (isWorkingDay) {
if (hasBreak) { if (hasBreak) {
schedules = [ schedules = [
@@ -1269,9 +1275,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
]; ];
} }
} else { } else {
schedules = [ schedules = [];
{ from: "00:00", to: "23:59", disabled: true }
];
} }
await API.post<any>("schedules/update", { await API.post<any>("schedules/update", {
@@ -1368,14 +1372,14 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
onNext={() => { onNext={() => {
if (doesProvideServices === null) return; if (doesProvideServices === null) return;
if (!doesProvideServices) { if (!doesProvideServices) {
// If they don't provide services, finish setup directly const query = createdCompanyId ? `&companyId=${createdCompanyId}` : "";
window.location.href = "/landing/dashboard"; router.push(`/admin/assistant?flow=new-collaborator${query}`);
} else { } else {
handleNext(); handleNext();
} }
}} }}
disableNext={doesProvideServices === null} 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 style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
<div <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> <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>
</div> </div>
</QuestionCard> </QuestionCard>
@@ -1724,7 +1728,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
for (let i = 0; i <= 6; i++) { for (let i = 0; i <= 6; i++) {
const isWorkingDay = workingDays.includes(i); const isWorkingDay = workingDays.includes(i);
let schedules = []; let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
if (isWorkingDay) { if (isWorkingDay) {
if (hasBreak) { if (hasBreak) {
schedules = [ schedules = [
@@ -1737,9 +1741,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
]; ];
} }
} else { } else {
schedules = [ schedules = [];
{ from: "00:00", to: "23:59", disabled: true }
];
} }
await API.post<any>("schedules/update", { await API.post<any>("schedules/update", {
@@ -2016,7 +2018,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
return; return;
} }
let schedules = []; let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
if (hasBreak) { if (hasBreak) {
schedules = [ schedules = [
{ from: openTime, to: breakStart, disabled: false }, { from: openTime, to: breakStart, disabled: false },
@@ -2653,6 +2655,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const handleCreateCollaborator = async () => { const handleCreateCollaborator = async () => {
if (!inviteCollaborator || !createdCompanyId) return; if (!inviteCollaborator || !createdCompanyId) return;
setIsCreating(true); setIsCreating(true);
setInviteFlowNotice("");
try { try {
await API.post<any>("employees/create", { await API.post<any>("employees/create", {
companyId: createdCompanyId, companyId: createdCompanyId,
@@ -2661,13 +2664,21 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
hostOk: true, hostOk: true,
sessionUser: SessionInfo.userId sessionUser: SessionInfo.userId
}); });
handleActionSuccess(); handleNext();
} catch (error: any) { } catch (error: any) {
const errorMessage = error.desc || error.message || "Error al invitar al colaborador"; 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); 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 { } else {
alert(errorMessage); setInviteFlowNotice(errorMessage);
setCurrentStepIndex(5);
} }
} finally { } finally {
setIsCreating(false); setIsCreating(false);
@@ -2752,6 +2763,109 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
{null} {null}
</QuestionCard> </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 <QuestionCard
isActive={currentStepIndex === 3 && isUpgradeRequired} isActive={currentStepIndex === 3 && isUpgradeRequired}
title="¡Haz crecer tu equipo!" 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."; 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 ( return (
<div style={{ <div style={{
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', 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"; window.location.href = "/landing/dashboard";
} }
}} }}
style={{ style={successButtonStyle}
padding: '16px 40px', fontSize: '18px', fontWeight: 'bold', onMouseOver={handleSuccessButtonMouseOver}
background: 'white', color: 'var(--wine-red)', border: 'none', onMouseOut={handleSuccessButtonMouseOut}
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)';
}}
> >
{action ? "Volver al Menú Principal" : "Ir a mi Panel de Control"} {action ? "Volver al Menú Principal" : "Ir a mi Panel de Control"}
</button> </button>
@@ -12,6 +12,7 @@ interface ButtonProps {
style?: React.CSSProperties; style?: React.CSSProperties;
width?: ButtonWidth; width?: ButtonWidth;
color?: ButtonColor; color?: ButtonColor;
disabled?: boolean;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void; onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
} }
@@ -52,6 +53,7 @@ export default function Button(props: ButtonProps): React.ReactElement {
id={props.name} id={props.name}
value={props.text} value={props.text}
onClick={props.onClick} onClick={props.onClick}
disabled={props.disabled}
style={props.style} style={props.style}
/> />
</> </>
@@ -18,6 +18,19 @@
gap: 15px; 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 { .title {
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
@@ -25,6 +38,22 @@
margin: 0; 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 { .filters {
display: flex; display: flex;
gap: 10px; gap: 10px;

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