Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40090cdec5 | |||
| a9afb13d15 | |||
| b01149a19e | |||
| f017f7e9aa | |||
| 10ec32139c | |||
| 74d6ee6706 | |||
| af6c87acb6 | |||
| 78ee86ac48 | |||
| 0f76ef3118 | |||
| 4f3b56784e | |||
| 1c8c47cf76 | |||
| 8d74ae95c8 | |||
| f6e2bb8372 | |||
| 81616c7c1c | |||
| 8d9a03cd23 | |||
| d6a4aab52b |
@@ -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
|
||||
@@ -34,3 +34,6 @@ server/build
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# AI tooling
|
||||
.atl/
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ MP_SUBSCRIPTION_FIND_URL = https://api.mercadopago.com/preapproval/
|
||||
MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
|
||||
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
|
||||
MP_CHECK_PAYMENT_MINUTES = 1
|
||||
MP_WEBHOOK_URL = https://5747-186-132-168-62.ngrok-free.app/mercadopago/webhook
|
||||
MP_WEBHOOK_URL = https://9300-186-132-200-176.ngrok-free.app/mercadopago/webhook
|
||||
|
||||
DEFAULT_BOT_ADMIN_PORT = 3005
|
||||
|
||||
|
||||
Generated
+822
-488
File diff suppressed because it is too large
Load Diff
+25
-25
@@ -14,39 +14,39 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/cors": "^2.8.13",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/socket.io": "^3.0.1",
|
||||
"@types/swagger-ui-express": "^4.1.3",
|
||||
"concurrently": "^8.2.0",
|
||||
"csv-parse": "^5.4.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/nodemailer": "^6.4.24",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/socket.io": "^3.0.2",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"concurrently": "^8.2.2",
|
||||
"csv-parse": "^5.6.0",
|
||||
"jest": "^30.4.2",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "5.0.4"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.18.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.21.1",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"joi": "^17.9.2",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"mongoose": "^8.8.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.15",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"cors": "^2.8.6",
|
||||
"dayjs": "^1.11.21",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^4.22.2",
|
||||
"google-auth-library": "^10.9.0",
|
||||
"joi": "^17.13.4",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mongoose": "8.8.3",
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"nodemailer": "^6.10.1",
|
||||
"sanitize-html": "^2.17.6",
|
||||
"sharp": "^0.33.5",
|
||||
"socket.io": "^4.8.1",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tsoa": "^5.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type FindAppointmentsParams = {
|
||||
clientId?: string;
|
||||
start?: Date;
|
||||
dateDay?: Date;
|
||||
sessionUser?: string;
|
||||
};
|
||||
|
||||
export type FindAppointmentsByUserParams = {
|
||||
@@ -244,6 +245,7 @@ export interface AppointmentEventByClient {
|
||||
serviceName: string;
|
||||
serviceDescription: string;
|
||||
collaboratorId: string;
|
||||
collaboratorUserId: string;
|
||||
collaboratorName: string;
|
||||
collaboratorAvatar: string;
|
||||
appointmentDate: string;
|
||||
|
||||
@@ -70,7 +70,7 @@ import { DiscountType } from "../Discounts/Discounts.Interface";
|
||||
import Discounts from "../Discounts/Discounts";
|
||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||
import Templates from "../Templates/Templates";
|
||||
import { CollaboratorSchedulesView } from "../Schedules/Schedules.Interface";
|
||||
import { CollaboratorSchedulesView, ScheduleItem } from "../Schedules/Schedules.Interface";
|
||||
import ClientAccount from "../ClientAccounts/ClientAccount";
|
||||
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
|
||||
import DiscountsEmail from "../DiscountsEmail/DiscountsEmail";
|
||||
@@ -89,6 +89,14 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
this.Appointments = new AppointmentsAdapterMongoose();
|
||||
}
|
||||
|
||||
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
|
||||
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
|
||||
}
|
||||
|
||||
public async setPaymentStatus(data: SetPaymentStatusParams): Promise<void> {
|
||||
const sessionUser = await UsersManager.users.findOne({
|
||||
_id: data.sessionUser,
|
||||
@@ -305,12 +313,6 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
throw new Error("La compañia no existe");
|
||||
}
|
||||
|
||||
const canAdd = await MetricsList.canAddAppointment(companyCheck.ownerId);
|
||||
|
||||
if (!canAdd) {
|
||||
throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan.");
|
||||
}
|
||||
|
||||
const serviceCheck = await ServicesManager.services.findOne({
|
||||
_id: data.serviceId,
|
||||
companyId: data.companyId,
|
||||
@@ -443,6 +445,18 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
|
||||
data.comments = isNull<string>(data.comments, "");
|
||||
|
||||
const appointmentReserved = await MetricsList.reserveAppointment({
|
||||
userId: String(companyCheck.ownerId),
|
||||
companyId: data.companyId,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
if (!appointmentReserved) {
|
||||
throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan.");
|
||||
}
|
||||
|
||||
let appointmentCreated = false;
|
||||
|
||||
try {
|
||||
const start = dayjs(data.start).clone();
|
||||
const end = dayjs(data.start)
|
||||
@@ -467,6 +481,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
comments: data.comments,
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
appointmentCreated = true;
|
||||
|
||||
if (data.repeatId) {
|
||||
const repeat = await RepeatsList.repeats.findOne({
|
||||
@@ -489,8 +504,9 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
}
|
||||
}
|
||||
|
||||
await MetricsList.addAppointment({
|
||||
await MetricsList.metrics.addAppointment({
|
||||
userId: companyCheck.ownerId,
|
||||
companyId: data.companyId,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
@@ -559,6 +575,13 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
return newAppointment;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
if (!appointmentCreated) {
|
||||
await MetricsList.releaseAppointment({
|
||||
userId: String(companyCheck.ownerId),
|
||||
companyId: data.companyId,
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
throw new Error("Ha ocurrido un error al crear el turno.");
|
||||
}
|
||||
}
|
||||
@@ -920,6 +943,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
dateDay: dayjs(data.start).startOf("day").toDate(),
|
||||
from: checkFrom,
|
||||
to: checkTo,
|
||||
serviceId: data.serviceId,
|
||||
});
|
||||
} else {
|
||||
//chequear que el colaborador este disponible.
|
||||
@@ -929,6 +953,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
weekDay: weekDay,
|
||||
from: checkFrom,
|
||||
to: checkTo,
|
||||
serviceId: data.serviceId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1037,6 +1062,9 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Historical read: Employee lookup includes removed employees (historical reads allow them)
|
||||
// No snapshot fallback needed here as employee.calendarColor is used directly
|
||||
|
||||
const clientName = joinStrings([client.firstName, client.lastName], " ");
|
||||
const clientUserName = userClient
|
||||
? joinStrings([userClient.firstName, userClient.lastName], clientName)
|
||||
@@ -1236,9 +1264,17 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
_id: String(employee.userId),
|
||||
});
|
||||
|
||||
if (!userEmployee) {
|
||||
throw new Error("No se ha encontrado el usuario del colaborador");
|
||||
}
|
||||
// Historical read snapshot fallback: use Employee snapshot if User is deleted
|
||||
const collaboratorFirstName = userEmployee
|
||||
? isNull<string>(userEmployee.firstName, "")
|
||||
: isNull<string>(employee.profileSnapshot?.firstName, "");
|
||||
const collaboratorLastName = userEmployee
|
||||
? isNull<string>(userEmployee.lastName, "")
|
||||
: isNull<string>(employee.profileSnapshot?.lastName, "");
|
||||
const collaboratorFullName = joinStrings([collaboratorFirstName, collaboratorLastName], " ");
|
||||
const collaboratorAvatar = userEmployee
|
||||
? getAvatar(employee.userId, userEmployee.avatar, collaboratorFullName)
|
||||
: (employee.profileSnapshot?.avatar || collaboratorFullName);
|
||||
|
||||
const service = await ServiceList.services.findOne({
|
||||
_id: String(appointment.serviceId),
|
||||
@@ -1256,14 +1292,6 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
? getAvatar(client.userId, userClient.avatar, clientUserName)
|
||||
: clientUserName;
|
||||
|
||||
const employeeFirstName = isNull<string>(userEmployee.firstName, "");
|
||||
const employeeLastName = isNull<string>(userEmployee.lastName, "");
|
||||
const employeeFullName = joinStrings([employeeFirstName, employeeLastName], " ");
|
||||
|
||||
const employeeAvatar = userEmployee
|
||||
? getAvatar(employee.userId, userEmployee.avatar, employeeFullName)
|
||||
: clientUserName;
|
||||
|
||||
const discountData = await Discounts.getDiscountData(appointment.discountId, appointment.companyId);
|
||||
|
||||
let ctaBalance = 0;
|
||||
@@ -1285,8 +1313,9 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
serviceName: service.name,
|
||||
serviceDescription: service.description,
|
||||
collaboratorId: employee.id,
|
||||
collaboratorName: employeeFullName,
|
||||
collaboratorAvatar: employeeAvatar,
|
||||
collaboratorUserId: String(employee.userId),
|
||||
collaboratorName: collaboratorFullName,
|
||||
collaboratorAvatar: collaboratorAvatar,
|
||||
appointmentDate: dayjs(appointment.start).toISOString(),
|
||||
startTime: appointment.startHour,
|
||||
endTime: appointment.endHour,
|
||||
@@ -1394,6 +1423,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
serviceName: service.name,
|
||||
serviceDescription: service.description,
|
||||
collaboratorId: employee.id,
|
||||
collaboratorUserId: String(employee.userId),
|
||||
collaboratorName: employeeFullName,
|
||||
collaboratorAvatar: employeeAvatar,
|
||||
appointmentDate: dayjs(appointment.start).toISOString(),
|
||||
@@ -1503,6 +1533,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
serviceName: service.name,
|
||||
serviceDescription: service.description,
|
||||
collaboratorId: employee.id,
|
||||
collaboratorUserId: String(employee.userId),
|
||||
collaboratorName: employeeFullName,
|
||||
collaboratorAvatar: employeeAvatar,
|
||||
appointmentDate: dayjs(appointment.start).toISOString(),
|
||||
@@ -1621,6 +1652,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
serviceName: service.name,
|
||||
serviceDescription: service.description,
|
||||
collaboratorId: String(employee.id || (employee as any)._id),
|
||||
collaboratorUserId: String(employee.userId),
|
||||
collaboratorName: employeeFullName,
|
||||
collaboratorAvatar: employeeAvatar,
|
||||
appointmentDate: dayjs(appointment.start).toISOString(),
|
||||
@@ -1738,6 +1770,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
serviceName: service.name,
|
||||
serviceDescription: service.description,
|
||||
collaboratorId: employee.id,
|
||||
collaboratorUserId: String(employee.userId),
|
||||
collaboratorName: employeeFullName,
|
||||
collaboratorAvatar: employeeAvatar,
|
||||
appointmentDate: dayjs(appointment.start).toISOString(),
|
||||
@@ -1865,6 +1898,7 @@ public async findAppointmentsByCollaboratorPaginated(
|
||||
serviceName: service.name,
|
||||
serviceDescription: service.description,
|
||||
collaboratorId: String(employee.id || (employee as any)._id),
|
||||
collaboratorUserId: String(employee.userId),
|
||||
collaboratorName: employeeFullName,
|
||||
collaboratorAvatar: employeeAvatar,
|
||||
appointmentDate: dayjs(appointment.start).toISOString(),
|
||||
@@ -2271,6 +2305,10 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.isScheduleAvailableForService(schedule, data.serviceId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheduleDataFrom = schedule.from.split(":");
|
||||
const scheduleDataTo = schedule.to.split(":");
|
||||
const hourFrom = parseInt(scheduleDataFrom[0]);
|
||||
@@ -2743,7 +2781,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
date: currentDate.toDate(),
|
||||
});
|
||||
|
||||
if (hasOverride && hasOverride.schedules.length > 0) {
|
||||
if (
|
||||
hasOverride &&
|
||||
hasOverride.schedules.some(
|
||||
(schedule) =>
|
||||
!schedule.disabled &&
|
||||
(!schedule.serviceScope ||
|
||||
schedule.serviceScope === "all" ||
|
||||
(schedule.serviceScope === "specific" &&
|
||||
(schedule.serviceIds || []).includes(data.serviceId)))
|
||||
)
|
||||
) {
|
||||
returnAvailableDates.push(currentDate.toDate());
|
||||
} else if (!hasOverride) {
|
||||
// Check if the current date is within any enabled schedule range
|
||||
@@ -2766,7 +2814,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
weekDay: currentDate.day(),
|
||||
});
|
||||
|
||||
if (schedule && schedule.schedules.length > 0) {
|
||||
if (
|
||||
schedule &&
|
||||
schedule.schedules.some(
|
||||
(scheduleItem) =>
|
||||
!scheduleItem.disabled &&
|
||||
(!scheduleItem.serviceScope ||
|
||||
scheduleItem.serviceScope === "all" ||
|
||||
(scheduleItem.serviceScope === "specific" &&
|
||||
(scheduleItem.serviceIds || []).includes(data.serviceId)))
|
||||
)
|
||||
) {
|
||||
returnAvailableDates.push(currentDate.toDate());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Tests for Appointments.getAppointmentEvent snapshot fallback
|
||||
* when a removed employee's User account has been deleted.
|
||||
*
|
||||
* The fallback uses Employee.profileSnapshot fields instead of crashing.
|
||||
*/
|
||||
|
||||
// Mock all external dependencies
|
||||
jest.mock("../../Users/Users", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
users: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
getUserFullName: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Clients/Clients", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
clients: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
getClientFullName: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Companies/Companies", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
companies: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Employees/Employee", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
employees: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Services/Service", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
services: {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../HeatMap/HeatMap", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getHeatMap: jest.fn(),
|
||||
addHeatMap: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Schedules/Schedules", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
find: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../SchedulesEnabled/SchedulesEnabled", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../SchedulesDisabled/SchedulesDisabled", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
find: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../SchedulesOverrides/SchedulesOverrides", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
find: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Metrics/Metrics", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
addAppointment: jest.fn(),
|
||||
canAddAppointment: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Notifications/Notifications", () => ({
|
||||
__esModule: true,
|
||||
NotificationsManager: {
|
||||
sendSystemNotification: jest.fn(),
|
||||
sendPushNotification: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../SystemNotifications/SystemNotification", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Repeats/Repeats", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
repeats: {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
existDisabledDate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Discounts/Discounts", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getDiscountData: jest.fn().mockResolvedValue({
|
||||
name: "",
|
||||
value: 0,
|
||||
type: "PERCENTAGE",
|
||||
code: "",
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../helpers/check", () => ({
|
||||
validatePermissionsByCompany: jest.fn(),
|
||||
validateSessionUser: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../Templates/Templates", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
sendEmail: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../ClientAccounts/ClientAccount", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getAvailableAmount: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../DiscountsEmail/DiscountsEmail", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../index", () => ({
|
||||
io: {
|
||||
to: jest.fn(() => ({ emit: jest.fn() })),
|
||||
},
|
||||
}));
|
||||
|
||||
import AppointmentsList from "../Appointments";
|
||||
import UsersManager from "../../Users/Users";
|
||||
import EmployeesList from "../../Employees/Employee";
|
||||
import ClientsManager from "../../Clients/Clients";
|
||||
import ServiceList from "../../Services/Service";
|
||||
|
||||
describe("getAppointmentEvent — historical read snapshot fallback", () => {
|
||||
let originalAdapter: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Save and replace adapter
|
||||
originalAdapter = AppointmentsList.Appointments;
|
||||
const mockAppointmentFindOne = jest.fn();
|
||||
(AppointmentsList as any).Appointments = {
|
||||
find: jest.fn(),
|
||||
findOne: mockAppointmentFindOne,
|
||||
AppointmentList: {
|
||||
findOne: mockAppointmentFindOne,
|
||||
find: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(AppointmentsList as any).Appointments = originalAdapter;
|
||||
});
|
||||
|
||||
it("falls back to Employee snapshot when User account is deleted", async () => {
|
||||
const mockAppointment = {
|
||||
_id: "appt-001",
|
||||
id: "appt-001",
|
||||
companyId: "company-001",
|
||||
clientId: "client-001",
|
||||
employeeId: "emp-removed",
|
||||
serviceId: "svc-001",
|
||||
start: new Date("2026-07-20T10:00:00Z"),
|
||||
startHour: 10,
|
||||
endHour: 11,
|
||||
dateDay: new Date("2026-07-20"),
|
||||
price: 100,
|
||||
length: 60,
|
||||
payment: false,
|
||||
comments: "",
|
||||
discountId: undefined,
|
||||
};
|
||||
|
||||
const mockClient = {
|
||||
_id: "client-001",
|
||||
id: "client-001",
|
||||
userId: "client-user-001",
|
||||
firstName: "Client",
|
||||
lastName: "Smith",
|
||||
};
|
||||
|
||||
const mockClientUser = {
|
||||
_id: "client-user-001",
|
||||
id: "client-user-001",
|
||||
firstName: "Client",
|
||||
lastName: "Smith",
|
||||
avatar: "client-avatar",
|
||||
};
|
||||
|
||||
// Employee exists (removed, with snapshot)
|
||||
const mockRemovedEmployee = {
|
||||
_id: "emp-removed",
|
||||
id: "emp-removed",
|
||||
companyId: "company-001",
|
||||
userId: "deleted-user-id",
|
||||
calendarColor: "#ff0000",
|
||||
removed: true,
|
||||
profileSnapshot: {
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: "john@example.com",
|
||||
avatar: "old-avatar-url",
|
||||
},
|
||||
};
|
||||
|
||||
// Mock adapter.findOne for the appointment
|
||||
const mockAppointmentFindOne = (AppointmentsList as any).Appointments.AppointmentList.findOne;
|
||||
mockAppointmentFindOne.mockResolvedValue(mockAppointment);
|
||||
|
||||
// Mock lookups
|
||||
(ClientsManager.clients.findOne as jest.Mock).mockResolvedValue(mockClient);
|
||||
(ServiceList.services.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "svc-001",
|
||||
name: "Haircut",
|
||||
description: "Professional haircut",
|
||||
});
|
||||
|
||||
// Call order for UsersManager.users.findOne (same mock as UserList):
|
||||
// 1. Line 1217: UserList.users.findOne (NOT awaited, but consumes a value)
|
||||
// 2. Line 1241: UsersManager.users.findOne for userClient
|
||||
// 3. Line 1253: UsersManager.users.findOne for userEmployee
|
||||
(UsersManager.users.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce({ _id: "session-user" }) // line 1217: not awaited, just needs truthy
|
||||
.mockResolvedValueOnce(mockClientUser) // line 1241: client user
|
||||
.mockResolvedValueOnce(null); // line 1253: employee user DELETED → triggers fallback
|
||||
(EmployeesList.employees.findOne as jest.Mock).mockResolvedValue(mockRemovedEmployee);
|
||||
(EmployeesList as any).findById = jest.fn().mockResolvedValue({
|
||||
id: "emp-removed",
|
||||
fullName: "John Doe",
|
||||
avatar: "old-avatar-url",
|
||||
});
|
||||
|
||||
const result = await AppointmentsList.getAppointmentEvent({
|
||||
appointmentId: "appt-001",
|
||||
sessionUser: "session-user",
|
||||
});
|
||||
|
||||
// Verify snapshot fields are used for collaboratorName
|
||||
expect(result.collaboratorName).toBe("John Doe");
|
||||
|
||||
// Verify snapshot avatar is used
|
||||
expect(result.collaboratorAvatar).toBe("old-avatar-url");
|
||||
|
||||
// Verify collaboratorId comes from employee, not user
|
||||
expect(result.collaboratorId).toBe("emp-removed");
|
||||
});
|
||||
|
||||
it("uses live User data when User account still exists", async () => {
|
||||
const mockAppointment = {
|
||||
_id: "appt-002",
|
||||
id: "appt-002",
|
||||
companyId: "company-001",
|
||||
clientId: "client-001",
|
||||
employeeId: "emp-active",
|
||||
serviceId: "svc-001",
|
||||
start: new Date("2026-07-20T10:00:00Z"),
|
||||
startHour: 10,
|
||||
endHour: 11,
|
||||
dateDay: new Date("2026-07-20"),
|
||||
price: 100,
|
||||
length: 60,
|
||||
payment: false,
|
||||
comments: "",
|
||||
discountId: undefined,
|
||||
};
|
||||
|
||||
const mockClient = {
|
||||
_id: "client-001",
|
||||
id: "client-001",
|
||||
userId: "client-user-001",
|
||||
firstName: "Client",
|
||||
lastName: "Smith",
|
||||
};
|
||||
|
||||
const mockClientUser = {
|
||||
_id: "client-user-001",
|
||||
id: "client-user-001",
|
||||
firstName: "Client",
|
||||
lastName: "Smith",
|
||||
avatar: "client-avatar",
|
||||
};
|
||||
|
||||
const mockActiveEmployee = {
|
||||
_id: "emp-active",
|
||||
id: "emp-active",
|
||||
companyId: "company-001",
|
||||
userId: "active-user-id",
|
||||
calendarColor: "#00ff00",
|
||||
removed: false,
|
||||
profileSnapshot: undefined,
|
||||
};
|
||||
|
||||
const mockActiveUser = {
|
||||
_id: "active-user-id",
|
||||
id: "active-user-id",
|
||||
firstName: "Jane",
|
||||
lastName: "Roe",
|
||||
avatar: "live-avatar",
|
||||
email: "jane@example.com",
|
||||
};
|
||||
|
||||
const mockAppointmentFindOne = (AppointmentsList as any).Appointments.AppointmentList.findOne;
|
||||
mockAppointmentFindOne.mockResolvedValue(mockAppointment);
|
||||
|
||||
(ClientsManager.clients.findOne as jest.Mock).mockResolvedValue(mockClient);
|
||||
(ServiceList.services.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "svc-001",
|
||||
name: "Haircut",
|
||||
description: "Professional haircut",
|
||||
});
|
||||
|
||||
// Call order for UsersManager.users.findOne (same mock as UserList):
|
||||
// 1. Line 1217: UserList.users.findOne (NOT awaited, but consumes a value)
|
||||
// 2. Line 1241: UsersManager.users.findOne for userClient
|
||||
// 3. Line 1253: UsersManager.users.findOne for userEmployee
|
||||
(UsersManager.users.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce({ _id: "session-user" }) // line 1217: not awaited
|
||||
.mockResolvedValueOnce(mockClientUser) // line 1241: client user
|
||||
.mockResolvedValueOnce(mockActiveUser); // line 1253: live user exists
|
||||
(EmployeesList.employees.findOne as jest.Mock).mockResolvedValue(mockActiveEmployee);
|
||||
(EmployeesList as any).findById = jest.fn().mockResolvedValue({
|
||||
id: "emp-active",
|
||||
fullName: "Jane Roe",
|
||||
avatar: "live-avatar",
|
||||
});
|
||||
|
||||
const result = await AppointmentsList.getAppointmentEvent({
|
||||
appointmentId: "appt-002",
|
||||
sessionUser: "session-user",
|
||||
});
|
||||
|
||||
// Live user data is used, not snapshot
|
||||
expect(result.collaboratorName).toBe("Jane Roe");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CreateClientByUserParams,
|
||||
PaginateClientsParams,
|
||||
PaginateClientsResults,
|
||||
DetachClientUserParams,
|
||||
} from "./Clients.Interface";
|
||||
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
|
||||
|
||||
@@ -96,4 +97,8 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
|
||||
pages: Math.ceil(count / filters.limit),
|
||||
};
|
||||
}
|
||||
|
||||
public async detachUser(data: DetachClientUserParams): Promise<void> {
|
||||
await this.clientList.updateOne({ _id: data.clientId }, { $unset: { userId: "" } }).exec();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ export type FindClientsParams = {
|
||||
status?: boolean;
|
||||
};
|
||||
|
||||
export type DetachClientUserParams = {
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
export type FindClientByIdParams = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -77,7 +81,7 @@ export type DeleteClientParams = {
|
||||
export interface IClient {
|
||||
id?: string;
|
||||
companyId: string;
|
||||
userId: string;
|
||||
userId?: string;
|
||||
fromClientId: string;
|
||||
status: boolean;
|
||||
firstName: string;
|
||||
@@ -116,6 +120,7 @@ export interface IClientsAdapter {
|
||||
find(filters: FindClientsParams): Promise<IClient[]>;
|
||||
findOne(filters: FindClientsParams): Promise<IClientDocument | null>;
|
||||
paginate(filters: PaginateClientsParams): Promise<PaginateClientsResults>;
|
||||
detachUser(data: DetachClientUserParams): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IClientsManager {
|
||||
|
||||
@@ -195,7 +195,9 @@ class ClientManager implements IClientsManager {
|
||||
|
||||
if (appointment) {
|
||||
appointment.clientId = data.toClientId;
|
||||
appointment.userId = clientTo.userId;
|
||||
if (clientTo.userId) {
|
||||
appointment.userId = clientTo.userId;
|
||||
}
|
||||
await appointment.save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,7 +536,8 @@ class CompaniesManager implements ICompaniesManager {
|
||||
const employ = await EmployeesList.employees.findOne({
|
||||
companyId: data.id,
|
||||
userId: String(data.sessionUser),
|
||||
});
|
||||
removed: { $ne: true },
|
||||
} as any);
|
||||
|
||||
if (!employ) {
|
||||
throw new Error("El usuario no participa de la organización");
|
||||
@@ -721,7 +722,8 @@ class CompaniesManager implements ICompaniesManager {
|
||||
//Organizaciones en las que participa como colaborador.
|
||||
const orgCollaborator = await EmployeesList.employees.find({
|
||||
userId: data.sessionUser,
|
||||
});
|
||||
removed: { $ne: true },
|
||||
} as any);
|
||||
|
||||
for (const item of orgCollaborator) {
|
||||
if (item.companyId) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
UpdateEmployeeParams,
|
||||
DeleteEmployeesByCompany,
|
||||
DeleteEmployeeParams,
|
||||
RemoveCollaboratorParams,
|
||||
} from "./Employees.Interface";
|
||||
import UsersManager from "../Users/Users";
|
||||
import { ICompanyDocument } from "../Companies/Companies.Adapter.Mongoose";
|
||||
@@ -26,7 +27,9 @@ import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model";
|
||||
import MetricsList from "../../Models/Metrics/Metrics";
|
||||
import { NotificationsManager } from "../Notifications/Notifications";
|
||||
import EmployeesServicesList from "../EmployeesServices/EmployeesServices";
|
||||
import HeatMapList from "../HeatMap/HeatMap";
|
||||
import SchedulesList from "../Schedules/Schedules";
|
||||
import AppointmentList from "../Appointments/Appointments";
|
||||
import { IncompleteCollaboratorView } from "./Employees.Interface";
|
||||
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
|
||||
|
||||
@@ -51,7 +54,11 @@ class EmployeeManager implements IEmployeesManager {
|
||||
role: EmployeeRoles
|
||||
): Promise<boolean> {
|
||||
//Check if role exist in roles array.
|
||||
const check = await this.employees.findOne({ companyId: companyId, userId: employeeId });
|
||||
const check = await this.employees.findOne({
|
||||
companyId: companyId,
|
||||
userId: employeeId,
|
||||
removed: { $ne: true },
|
||||
} as any);
|
||||
|
||||
if (!check) {
|
||||
return false;
|
||||
@@ -112,8 +119,59 @@ class EmployeeManager implements IEmployeesManager {
|
||||
userId: data.userId,
|
||||
});
|
||||
|
||||
// Reactivation: if employee exists and is removed, reactivate instead of creating duplicate
|
||||
if (employeeCheck && employeeCheck.removed === true) {
|
||||
// Check quota before reactivation
|
||||
const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId);
|
||||
if (!canAdd) {
|
||||
throw new Error(
|
||||
"Ha alcanzado el limite de colaboradores permitidos de acuerdo a su plan."
|
||||
);
|
||||
}
|
||||
|
||||
// Clear removal metadata
|
||||
employeeCheck.removed = false;
|
||||
employeeCheck.removedAt = undefined;
|
||||
employeeCheck.removedBy = undefined;
|
||||
|
||||
// Refresh profileSnapshot from current User
|
||||
employeeCheck.profileSnapshot = {
|
||||
firstName: userCheck.firstName || undefined,
|
||||
lastName: userCheck.lastName || undefined,
|
||||
email: userCheck.email || undefined,
|
||||
avatar: userCheck.avatar || undefined,
|
||||
};
|
||||
|
||||
// Reset invitation state for full re-acceptance flow
|
||||
employeeCheck.hostOk = false;
|
||||
employeeCheck.guestOk = false;
|
||||
|
||||
await employeeCheck.save();
|
||||
|
||||
// Increment metrics
|
||||
await MetricsList.addEmployee({
|
||||
userId: companyCheck.ownerId,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
// Send invitation notification
|
||||
await NotificationsManager.sendSystemNotification({
|
||||
userId: String(data.userId),
|
||||
subject: "Su usuario ha sido vinculado a una organización",
|
||||
message: `La compañía ${companyCheck.name} lo ha vinculado como colaborador. Es necesario que acepte la invitación para poder formar parte de esta organización.`,
|
||||
type: NotificationType.EMPLOYEE,
|
||||
code: String(employeeCheck._id),
|
||||
});
|
||||
|
||||
return employeeCheck;
|
||||
}
|
||||
|
||||
if (employeeCheck) {
|
||||
throw new Error("El usuario ya pertenece a la compañia");
|
||||
if (!employeeCheck.guestOk) {
|
||||
throw new Error("La invitación para este usuario ya está pendiente");
|
||||
}
|
||||
|
||||
throw new Error("El usuario ya pertenece a la compañía");
|
||||
}
|
||||
|
||||
const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId);
|
||||
@@ -346,7 +404,7 @@ class EmployeeManager implements IEmployeesManager {
|
||||
}
|
||||
|
||||
public async findByCompanyId(data: FindEmployeesParams): Promise<CompanyEmployeesView[]> {
|
||||
const employees = await this.employees.find(data);
|
||||
const employees = await this.employees.find({ ...data, removed: { $ne: true } } as any);
|
||||
|
||||
const view = await Promise.all(
|
||||
employees.map(async (employee) => {
|
||||
@@ -422,7 +480,7 @@ class EmployeeManager implements IEmployeesManager {
|
||||
}
|
||||
|
||||
public async textObjectFilter(data: FindEmployeesParams): Promise<TextObjectFilterResult[]> {
|
||||
const employees = await this.employees.find(data);
|
||||
const employees = await this.employees.find({ ...data, removed: { $ne: true } } as any);
|
||||
const formattedResult: TextObjectFilterResult[] = [];
|
||||
|
||||
for (const colaborador of employees) {
|
||||
@@ -522,8 +580,151 @@ class EmployeeManager implements IEmployeesManager {
|
||||
}
|
||||
return incompleteCollaborators;
|
||||
}
|
||||
|
||||
public async removeCollaboratorFromCompany(data: RemoveCollaboratorParams): Promise<void> {
|
||||
// Validate session user
|
||||
const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser });
|
||||
if (!sessionUser) {
|
||||
throw new Error(
|
||||
"No se ha encontrado el usuario o no tiene permisos para realizar esta acción"
|
||||
);
|
||||
}
|
||||
|
||||
// Validate company exists
|
||||
const companyCheck = await CompaniesManager.companies.findOne({ _id: data.companyId });
|
||||
if (!companyCheck) {
|
||||
throw new Error("La compañia no existe");
|
||||
}
|
||||
|
||||
// Validate target employee exists
|
||||
const employee = await this.employees.findOne({
|
||||
_id: data.employeeId,
|
||||
});
|
||||
if (!employee) {
|
||||
throw new Error("El colaborador no existe");
|
||||
}
|
||||
|
||||
// Check if already removed
|
||||
if (employee.removed) {
|
||||
throw new Error("El colaborador ya fue eliminado de la organización");
|
||||
}
|
||||
|
||||
// Validate permission (admin or owner)
|
||||
if (
|
||||
!(await this.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))
|
||||
) {
|
||||
throw new Error(NoPermissionMessage());
|
||||
}
|
||||
|
||||
// Self-removal prevention
|
||||
if (String(employee.userId) === data.sessionUser) {
|
||||
throw new Error("No puedes eliminarte a ti mismo de la organización");
|
||||
}
|
||||
|
||||
// Owner protection
|
||||
if (employee.roles && employee.roles.includes(EmployeeRoles.OWNER)) {
|
||||
throw new Error("No se puede eliminar al propietario de la organización");
|
||||
}
|
||||
|
||||
// Query future appointments for this employee
|
||||
const futureAppointments = await AppointmentList.Appointments.AppointmentList.find({
|
||||
companyId: data.companyId,
|
||||
employeeId: data.employeeId,
|
||||
start: { $gt: new Date() },
|
||||
});
|
||||
|
||||
// If there are future appointments, require a replacement
|
||||
if (futureAppointments && futureAppointments.length > 0) {
|
||||
if (!data.replacementEmployeeId) {
|
||||
throw new Error(
|
||||
"El colaborador tiene turnos futuros asignados. Se requiere un empleado de reemplazo."
|
||||
);
|
||||
}
|
||||
|
||||
// Validate replacement employee
|
||||
const replacementEmployee = await this.employees.findOne({
|
||||
_id: data.replacementEmployeeId,
|
||||
});
|
||||
|
||||
if (!replacementEmployee) {
|
||||
throw new Error("El empleado de reemplazo no existe");
|
||||
}
|
||||
|
||||
if (String(replacementEmployee.companyId) !== data.companyId) {
|
||||
throw new Error("El empleado de reemplazo no pertenece a esta organización");
|
||||
}
|
||||
|
||||
if (replacementEmployee.removed) {
|
||||
throw new Error("El empleado de reemplazo fue eliminado de la organización");
|
||||
}
|
||||
|
||||
if (String(replacementEmployee.userId) === data.sessionUser) {
|
||||
throw new Error(
|
||||
"No puedes designarte como reemplazo de ti mismo"
|
||||
);
|
||||
}
|
||||
|
||||
// Reassign future appointments
|
||||
for (const appointment of futureAppointments) {
|
||||
appointment.employeeId = data.replacementEmployeeId;
|
||||
await appointment.save();
|
||||
}
|
||||
|
||||
// Reassign active repeats
|
||||
const RepeatsList = (await import("../Repeats/Repeats")).default;
|
||||
const activeRepeats = await RepeatsList.repeats.find({
|
||||
companyId: data.companyId,
|
||||
employeeId: data.employeeId,
|
||||
});
|
||||
|
||||
for (const repeat of activeRepeats) {
|
||||
// Access the underlying Mongoose model to get save() capability
|
||||
await RepeatsList.repeats.findOne({ _id: repeat.id }).then(async (doc) => {
|
||||
if (doc) {
|
||||
doc.employeeId = data.replacementEmployeeId!;
|
||||
await doc.save();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot user profile
|
||||
const targetUser = await UsersManager.users.findOne({ _id: employee.userId });
|
||||
if (targetUser) {
|
||||
employee.profileSnapshot = {
|
||||
firstName: targetUser.firstName || undefined,
|
||||
lastName: targetUser.lastName || undefined,
|
||||
email: targetUser.email || undefined,
|
||||
avatar: targetUser.avatar || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Mark employee as removed
|
||||
employee.removed = true;
|
||||
employee.removedAt = new Date();
|
||||
employee.removedBy = data.sessionUser;
|
||||
|
||||
await employee.save();
|
||||
|
||||
// Cleanup: delete employee services and heatmap data
|
||||
await EmployeesServicesList.deleteEmployeeServiceByEmployee({
|
||||
employeeId: data.employeeId,
|
||||
});
|
||||
|
||||
await HeatMapList.deleteHeatMapByEmployee({
|
||||
employeeId: data.employeeId,
|
||||
});
|
||||
|
||||
// Decrement metrics
|
||||
await MetricsList.addEmployee({
|
||||
userId: companyCheck.ownerId,
|
||||
quantity: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { EmployeeManager };
|
||||
|
||||
const EmployeesList = new EmployeeManager();
|
||||
|
||||
export default EmployeesList;
|
||||
|
||||
@@ -23,6 +23,18 @@ export class EmployeesAdapterMongoose implements IEmployeesAdapter {
|
||||
limit: { type: Number, required: true, default: -1 },
|
||||
hostOk: { type: Boolean, required: true, default: false },
|
||||
guestOk: { type: Boolean, required: true, default: false },
|
||||
removed: { type: Boolean, required: false, default: false },
|
||||
removedAt: { type: Date, required: false },
|
||||
removedBy: { type: Schema.Types.ObjectId, required: false, ref: "User" },
|
||||
profileSnapshot: {
|
||||
type: {
|
||||
firstName: { type: String, required: false },
|
||||
lastName: { type: String, required: false },
|
||||
email: { type: String, required: false },
|
||||
avatar: { type: String, required: false },
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
creationDate: { type: Date, required: true, default: Date.now },
|
||||
});
|
||||
|
||||
|
||||
@@ -68,6 +68,13 @@ export type DeleteEmployeeParams = {
|
||||
employeeId: string;
|
||||
};
|
||||
|
||||
export type RemoveCollaboratorParams = {
|
||||
employeeId: string;
|
||||
companyId: string;
|
||||
replacementEmployeeId?: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export interface IEmployee {
|
||||
id?: string;
|
||||
companyId: string;
|
||||
@@ -77,6 +84,15 @@ export interface IEmployee {
|
||||
limit?: number;
|
||||
guestOk?: boolean;
|
||||
hostOk?: boolean;
|
||||
removed?: boolean;
|
||||
removedAt?: Date;
|
||||
removedBy?: string;
|
||||
profileSnapshot?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CompanyEmployeesView {
|
||||
@@ -93,6 +109,14 @@ export interface CompanyEmployeesView {
|
||||
hostOk: boolean;
|
||||
fullOk: boolean;
|
||||
calendarColor: string;
|
||||
removed?: boolean;
|
||||
removedAt?: Date;
|
||||
profileSnapshot?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IncompleteCollaboratorView {
|
||||
@@ -125,4 +149,5 @@ export interface IEmployeesManager {
|
||||
deleteEmployee(data: DeleteEmployeeParams): Promise<void>;
|
||||
rejectGuest(data: ValidateEmployeeParams): Promise<void>;
|
||||
getIncompleteSetup(sessionUser: string): Promise<IncompleteCollaboratorView[]>;
|
||||
removeCollaboratorFromCompany(data: RemoveCollaboratorParams): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { EmployeeRoles } from "../Employees.Interface";
|
||||
|
||||
// Mock all external dependencies before importing the module under test
|
||||
jest.mock("../../Users/Users", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
users: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
getUserFullName: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Companies/Companies", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
companies: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Metrics/Metrics", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
addEmployee: jest.fn(),
|
||||
canAddEmployee: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Notifications/Notifications", () => ({
|
||||
__esModule: true,
|
||||
NotificationsManager: {
|
||||
sendSystemNotification: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../EmployeesServices/EmployeesServices", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
deleteEmployeeServiceByEmployee: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../HeatMap/HeatMap", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
deleteHeatMapByEmployee: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Repeats/Repeats", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
repeats: {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Schedules/Schedules", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Services/Service", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
services: {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../index", () => ({
|
||||
io: {
|
||||
to: jest.fn(() => ({ emit: jest.fn() })),
|
||||
},
|
||||
}));
|
||||
|
||||
import EmployeesList from "../Employee";
|
||||
import UsersManager from "../../Users/Users";
|
||||
|
||||
describe("Query filtering — removed employees excluded", () => {
|
||||
let originalAdapter: any;
|
||||
let mockFind: jest.Mock;
|
||||
let mockFindOne: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
originalAdapter = EmployeesList.employees;
|
||||
|
||||
mockFind = jest.fn();
|
||||
mockFindOne = jest.fn();
|
||||
|
||||
(EmployeesList as any).employees = {
|
||||
findOne: mockFindOne,
|
||||
find: mockFind,
|
||||
create: jest.fn(),
|
||||
employeeList: {
|
||||
findOne: mockFindOne,
|
||||
find: mockFind,
|
||||
create: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(EmployeesList as any).employees = originalAdapter;
|
||||
});
|
||||
|
||||
// --- findByCompanyId excludes removed ---
|
||||
|
||||
describe("findByCompanyId", () => {
|
||||
it("excludes removed employees from results", async () => {
|
||||
const activeEmployee = {
|
||||
_id: "emp-active",
|
||||
id: "emp-active",
|
||||
companyId: "company-001",
|
||||
userId: "user-active",
|
||||
roles: [EmployeeRoles.EMPLOYEE],
|
||||
guestOk: true,
|
||||
hostOk: true,
|
||||
calendarColor: "#ff9900",
|
||||
removed: false,
|
||||
};
|
||||
|
||||
// findByCompanyId calls find with removed: { $ne: true }
|
||||
// The adapter find should return only non-removed employees
|
||||
mockFind.mockResolvedValue([activeEmployee]);
|
||||
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "user-active",
|
||||
firstName: "Active",
|
||||
lastName: "User",
|
||||
email: "active@example.com",
|
||||
avatar: "",
|
||||
});
|
||||
|
||||
const result = await EmployeesList.findByCompanyId({
|
||||
companyId: "company-001",
|
||||
});
|
||||
|
||||
// Verify the query included the removed filter
|
||||
expect(mockFind).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-001",
|
||||
removed: { $ne: true },
|
||||
})
|
||||
);
|
||||
|
||||
// Verify only the active employee is in the result
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("emp-active");
|
||||
});
|
||||
|
||||
it("returns empty array when all employees are removed", async () => {
|
||||
// Adapter returns empty because filter excluded all removed
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const result = await EmployeesList.findByCompanyId({
|
||||
companyId: "company-001",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockFind).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
removed: { $ne: true },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- textObjectFilter excludes removed ---
|
||||
|
||||
describe("textObjectFilter", () => {
|
||||
it("excludes removed employees from results", async () => {
|
||||
const activeEmployee = {
|
||||
_id: "emp-active",
|
||||
id: "emp-active",
|
||||
companyId: "company-001",
|
||||
userId: "user-active",
|
||||
removed: false,
|
||||
};
|
||||
|
||||
// Only active employees returned after filter
|
||||
mockFind.mockResolvedValue([activeEmployee]);
|
||||
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "user-active",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
avatar: "",
|
||||
});
|
||||
|
||||
const result = await EmployeesList.textObjectFilter({
|
||||
companyId: "company-001",
|
||||
});
|
||||
|
||||
expect(mockFind).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
removed: { $ne: true },
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("John Doe");
|
||||
});
|
||||
|
||||
it("returns empty when only removed employees exist", async () => {
|
||||
mockFind.mockResolvedValue([]);
|
||||
|
||||
const result = await EmployeesList.textObjectFilter({
|
||||
companyId: "company-001",
|
||||
});
|
||||
|
||||
// Verify the removed filter was applied to the query
|
||||
expect(mockFind).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
removed: { $ne: true },
|
||||
})
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import { EmployeeRoles } from "../Employees.Interface";
|
||||
|
||||
// Mock all external dependencies before importing the module under test
|
||||
jest.mock("../../Users/Users", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
users: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
getUserFullName: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Companies/Companies", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
companies: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Metrics/Metrics", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
addEmployee: jest.fn(),
|
||||
canAddEmployee: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Notifications/Notifications", () => ({
|
||||
__esModule: true,
|
||||
NotificationsManager: {
|
||||
sendSystemNotification: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../EmployeesServices/EmployeesServices", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
deleteEmployeeServiceByEmployee: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../HeatMap/HeatMap", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
deleteHeatMapByEmployee: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Repeats/Repeats", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
repeats: {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../index", () => ({
|
||||
io: {
|
||||
to: jest.fn(() => ({ emit: jest.fn() })),
|
||||
},
|
||||
}));
|
||||
|
||||
import EmployeesList from "../Employee";
|
||||
import UsersManager from "../../Users/Users";
|
||||
import CompaniesManager from "../../Companies/Companies";
|
||||
import MetricsList from "../../Metrics/Metrics";
|
||||
|
||||
describe("createEmployee — reactivation of removed collaborator", () => {
|
||||
let originalAdapter: any;
|
||||
let mockFindOne: jest.Mock;
|
||||
let mockCreate: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
originalAdapter = EmployeesList.employees;
|
||||
|
||||
mockFindOne = jest.fn();
|
||||
mockCreate = jest.fn();
|
||||
|
||||
(EmployeesList as any).employees = {
|
||||
findOne: mockFindOne,
|
||||
find: jest.fn(),
|
||||
create: mockCreate,
|
||||
employeeList: {
|
||||
findOne: mockFindOne,
|
||||
find: jest.fn(),
|
||||
create: mockCreate,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(EmployeesList as any).employees = originalAdapter;
|
||||
});
|
||||
|
||||
/**
|
||||
* Call order in createEmployee:
|
||||
* 1. UsersManager.users.findOne → session user
|
||||
* 2. CompaniesManager.companies.findOne → company
|
||||
* 3. checkPermission → checkRoleById → this.employees.findOne → admin employee record
|
||||
* 4. UsersManager.users.findOne → user being invited
|
||||
* 5. this.employees.findOne → existing employee lookup by (companyId, userId)
|
||||
*/
|
||||
it("reactivates a removed employee instead of creating a duplicate", async () => {
|
||||
const adminEmployee = {
|
||||
_id: "emp-admin",
|
||||
companyId: "company-001",
|
||||
userId: "admin-user",
|
||||
roles: [EmployeeRoles.ADMIN],
|
||||
removed: false,
|
||||
};
|
||||
|
||||
const removedEmployee = {
|
||||
_id: "emp-removed",
|
||||
id: "emp-removed",
|
||||
companyId: "company-001",
|
||||
userId: "user-002",
|
||||
roles: [EmployeeRoles.EMPLOYEE],
|
||||
removed: true,
|
||||
removedAt: new Date(),
|
||||
removedBy: "admin-user",
|
||||
hostOk: true,
|
||||
guestOk: true,
|
||||
profileSnapshot: { firstName: "Old", lastName: "Name" },
|
||||
save: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
const userToInvite = {
|
||||
_id: "user-002",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: "john@example.com",
|
||||
avatar: "new-avatar",
|
||||
};
|
||||
|
||||
// Call 1: session user → Call 2: user being invited
|
||||
(UsersManager.users.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce({ _id: "admin-user" })
|
||||
.mockResolvedValueOnce(userToInvite);
|
||||
|
||||
// CompaniesManager.companies.findOne → company
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
name: "Test Company",
|
||||
});
|
||||
|
||||
// this.employees.findOne:
|
||||
// Call 1: checkRoleById → admin employee record
|
||||
// Call 2: existing employee lookup → found + removed
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(adminEmployee)
|
||||
.mockResolvedValueOnce(removedEmployee);
|
||||
|
||||
(MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const result = await EmployeesList.createEmployee({
|
||||
companyId: "company-001",
|
||||
userId: "user-002",
|
||||
sessionUser: "admin-user",
|
||||
});
|
||||
|
||||
// Verify employee was reactivated
|
||||
expect(removedEmployee.removed).toBe(false);
|
||||
expect(removedEmployee.removedAt).toBeUndefined();
|
||||
expect(removedEmployee.removedBy).toBeUndefined();
|
||||
|
||||
// Verify profileSnapshot was refreshed from current User
|
||||
expect(removedEmployee.profileSnapshot).toEqual({
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: "john@example.com",
|
||||
avatar: "new-avatar",
|
||||
});
|
||||
|
||||
// Verify invitation state was reset
|
||||
expect(removedEmployee.hostOk).toBe(false);
|
||||
expect(removedEmployee.guestOk).toBe(false);
|
||||
|
||||
// Verify save was called
|
||||
expect(removedEmployee.save).toHaveBeenCalled();
|
||||
|
||||
// Verify metrics were incremented
|
||||
expect(MetricsList.addEmployee).toHaveBeenCalledWith({
|
||||
userId: "owner-user",
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
// Verify no new employee was created
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
|
||||
// Verify same employee returned
|
||||
expect(result.id).toBe("emp-removed");
|
||||
});
|
||||
|
||||
it("throws quota error when reactivation would exceed plan limit", async () => {
|
||||
const adminEmployee = {
|
||||
_id: "emp-admin",
|
||||
companyId: "company-001",
|
||||
userId: "admin-user",
|
||||
roles: [EmployeeRoles.ADMIN],
|
||||
removed: false,
|
||||
};
|
||||
|
||||
const removedEmployee = {
|
||||
_id: "emp-removed",
|
||||
companyId: "company-001",
|
||||
userId: "user-002",
|
||||
removed: true,
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
// Call 1: session user → Call 2: user being invited
|
||||
(UsersManager.users.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce({ _id: "admin-user" })
|
||||
.mockResolvedValueOnce({ _id: "user-002" });
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
// this.employees.findOne: admin check → removed employee lookup
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(adminEmployee)
|
||||
.mockResolvedValueOnce(removedEmployee);
|
||||
|
||||
// Quota is full
|
||||
(MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
EmployeesList.createEmployee({
|
||||
companyId: "company-001",
|
||||
userId: "user-002",
|
||||
sessionUser: "admin-user",
|
||||
})
|
||||
).rejects.toThrow("limite de colaboradores");
|
||||
|
||||
// Verify employee was NOT reactivated
|
||||
expect(removedEmployee.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws 'El usuario no existe' when reinviting a removed employee whose User record was deleted", async () => {
|
||||
// Scenario: Employee record exists and is removed, but the underlying User
|
||||
// was deleted (e.g. admin cleanup). createEmployee must reject the invitation
|
||||
// BEFORE attempting reactivation because User validation gates the entire flow.
|
||||
const removedEmployee = {
|
||||
_id: "emp-removed",
|
||||
id: "emp-removed",
|
||||
companyId: "company-001",
|
||||
userId: "user-deleted",
|
||||
removed: true,
|
||||
save: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
// Call 1: session user (owner, so checkPermission short-circuits)
|
||||
(UsersManager.users.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce({ _id: "owner-user" })
|
||||
.mockResolvedValueOnce(null); // invited user was DELETED
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
// checkPermission short-circuits for owner — no employees.findOne needed.
|
||||
// But createEmployee still calls employees.findOne AFTER userCheck.
|
||||
// Since userCheck is null, the method should throw before reaching employees.findOne.
|
||||
await expect(
|
||||
EmployeesList.createEmployee({
|
||||
companyId: "company-001",
|
||||
userId: "user-deleted",
|
||||
sessionUser: "owner-user",
|
||||
})
|
||||
).rejects.toThrow("El usuario no existe");
|
||||
|
||||
// Critical: reactivation must NOT have been attempted
|
||||
expect(removedEmployee.save).not.toHaveBeenCalled();
|
||||
expect(MetricsList.addEmployee).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves same employeeId across removal and reactivation", async () => {
|
||||
const adminEmployee = {
|
||||
_id: "emp-admin",
|
||||
companyId: "company-001",
|
||||
userId: "admin-user",
|
||||
roles: [EmployeeRoles.ADMIN],
|
||||
removed: false,
|
||||
};
|
||||
|
||||
const removedEmployee = {
|
||||
_id: "emp-original-id",
|
||||
id: "emp-original-id",
|
||||
companyId: "company-001",
|
||||
userId: "user-002",
|
||||
removed: true,
|
||||
save: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
const userToInvite = {
|
||||
_id: "user-002",
|
||||
firstName: "Jane",
|
||||
lastName: "Smith",
|
||||
email: "jane@example.com",
|
||||
avatar: "",
|
||||
};
|
||||
|
||||
// Call 1: session user → Call 2: user being invited
|
||||
(UsersManager.users.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce({ _id: "admin-user" })
|
||||
.mockResolvedValueOnce(userToInvite);
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
// this.employees.findOne: admin check → removed employee lookup
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(adminEmployee)
|
||||
.mockResolvedValueOnce(removedEmployee);
|
||||
|
||||
(MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const result = await EmployeesList.createEmployee({
|
||||
companyId: "company-001",
|
||||
userId: "user-002",
|
||||
sessionUser: "admin-user",
|
||||
});
|
||||
|
||||
// Same _id preserved
|
||||
expect(result.id).toBe("emp-original-id");
|
||||
|
||||
// No new employee created
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,632 @@
|
||||
import { EmployeeRoles } from "../Employees.Interface";
|
||||
|
||||
// Mock all external dependencies before importing the module under test
|
||||
jest.mock("../../Users/Users", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
users: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
getUserFullName: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Companies/Companies", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
companies: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Metrics/Metrics", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
addEmployee: jest.fn(),
|
||||
canAddEmployee: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Notifications/Notifications", () => ({
|
||||
__esModule: true,
|
||||
NotificationsManager: {
|
||||
sendSystemNotification: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../EmployeesServices/EmployeesServices", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
deleteEmployeeServiceByEmployee: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../HeatMap/HeatMap", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
deleteHeatMapByEmployee: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Repeats/Repeats", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
repeats: {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Appointments/Appointments", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
Appointments: {
|
||||
AppointmentList: {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../index", () => ({
|
||||
io: {
|
||||
to: jest.fn(() => ({ emit: jest.fn() })),
|
||||
},
|
||||
}));
|
||||
|
||||
import EmployeesList from "../Employee";
|
||||
import UsersManager from "../../Users/Users";
|
||||
import CompaniesManager from "../../Companies/Companies";
|
||||
import MetricsList from "../../Metrics/Metrics";
|
||||
import EmployeesServicesList from "../../EmployeesServices/EmployeesServices";
|
||||
import HeatMapList from "../../HeatMap/HeatMap";
|
||||
import AppointmentList from "../../Appointments/Appointments";
|
||||
|
||||
// Helper to build a mock employee document with save()
|
||||
function buildEmployeeDoc(overrides: Record<string, any> = {}) {
|
||||
const doc = {
|
||||
_id: "emp-001",
|
||||
id: "emp-001",
|
||||
companyId: "company-001",
|
||||
userId: "user-002",
|
||||
roles: [EmployeeRoles.EMPLOYEE],
|
||||
calendarColor: "#ff9900",
|
||||
hostOk: true,
|
||||
guestOk: true,
|
||||
removed: false,
|
||||
removedAt: undefined,
|
||||
removedBy: undefined,
|
||||
profileSnapshot: undefined,
|
||||
save: jest.fn().mockResolvedValue(true),
|
||||
...overrides,
|
||||
};
|
||||
return doc;
|
||||
}
|
||||
|
||||
describe("removeCollaboratorFromCompany", () => {
|
||||
let originalAdapter: any;
|
||||
let mockFindOne: jest.Mock;
|
||||
let mockAppointmentFind: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Save original adapter reference
|
||||
originalAdapter = EmployeesList.employees;
|
||||
|
||||
// Create mock adapter
|
||||
mockFindOne = jest.fn();
|
||||
const mockFind = jest.fn();
|
||||
const mockCreate = jest.fn();
|
||||
const mockDeleteMany = jest.fn();
|
||||
|
||||
(EmployeesList as any).employees = {
|
||||
findOne: mockFindOne,
|
||||
find: mockFind,
|
||||
create: mockCreate,
|
||||
employeeList: {
|
||||
findOne: mockFindOne,
|
||||
find: mockFind,
|
||||
deleteMany: mockDeleteMany,
|
||||
create: mockCreate,
|
||||
},
|
||||
};
|
||||
|
||||
mockAppointmentFind = (AppointmentList.Appointments.AppointmentList.find as jest.Mock).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original adapter
|
||||
(EmployeesList as any).employees = originalAdapter;
|
||||
});
|
||||
|
||||
// --- Session User Validation ---
|
||||
|
||||
it("throws when session user does not exist", async () => {
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-001",
|
||||
companyId: "company-001",
|
||||
sessionUser: "nonexistent-user",
|
||||
})
|
||||
).rejects.toThrow("No se ha encontrado el usuario");
|
||||
});
|
||||
|
||||
// --- Company Validation ---
|
||||
|
||||
it("throws when company does not exist", async () => {
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-001",
|
||||
companyId: "nonexistent-company",
|
||||
sessionUser: "admin-user",
|
||||
})
|
||||
).rejects.toThrow("La compañia no existe");
|
||||
});
|
||||
|
||||
// --- Employee Not Found ---
|
||||
|
||||
it("throws when target employee does not exist", async () => {
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
mockFindOne.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-nonexistent",
|
||||
companyId: "company-001",
|
||||
sessionUser: "admin-user",
|
||||
})
|
||||
).rejects.toThrow("El colaborador no existe");
|
||||
});
|
||||
|
||||
// --- Already Removed ---
|
||||
|
||||
it("throws when employee is already removed", async () => {
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
mockFindOne.mockResolvedValueOnce(buildEmployeeDoc({ removed: true }));
|
||||
|
||||
await expect(
|
||||
EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-001",
|
||||
companyId: "company-001",
|
||||
sessionUser: "admin-user",
|
||||
})
|
||||
).rejects.toThrow("El colaborador ya fue eliminado");
|
||||
});
|
||||
|
||||
// --- Permission Check ---
|
||||
|
||||
it("throws when non-admin attempts removal", async () => {
|
||||
const regularEmployee = buildEmployeeDoc({
|
||||
_id: "emp-regular",
|
||||
userId: "regular-user",
|
||||
roles: [EmployeeRoles.EMPLOYEE],
|
||||
});
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "regular-user" });
|
||||
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(buildEmployeeDoc({ userId: "target-user" })) // target
|
||||
.mockResolvedValueOnce(regularEmployee); // session user's employee
|
||||
|
||||
await expect(
|
||||
EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-001",
|
||||
companyId: "company-001",
|
||||
sessionUser: "regular-user",
|
||||
})
|
||||
).rejects.toThrow(/No tienes permisos/);
|
||||
});
|
||||
|
||||
// --- Self-Removal Prevention ---
|
||||
|
||||
it("throws when admin tries to remove themselves", async () => {
|
||||
const adminEmployee = buildEmployeeDoc({
|
||||
_id: "emp-admin",
|
||||
userId: "admin-user",
|
||||
roles: [EmployeeRoles.ADMIN],
|
||||
});
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||
|
||||
// First findOne: target employee (the admin themselves)
|
||||
// Second findOne: session user's employee record
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(adminEmployee)
|
||||
.mockResolvedValueOnce(adminEmployee);
|
||||
|
||||
await expect(
|
||||
EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-admin",
|
||||
companyId: "company-001",
|
||||
sessionUser: "admin-user",
|
||||
})
|
||||
).rejects.toThrow("No puedes eliminarte a ti mismo");
|
||||
});
|
||||
|
||||
// --- Owner Protection ---
|
||||
|
||||
it("throws when admin tries to remove the owner", async () => {
|
||||
const ownerEmployee = buildEmployeeDoc({
|
||||
_id: "emp-owner",
|
||||
userId: "owner-user",
|
||||
roles: [EmployeeRoles.OWNER],
|
||||
});
|
||||
|
||||
const adminEmployee = buildEmployeeDoc({
|
||||
_id: "emp-admin",
|
||||
userId: "admin-user",
|
||||
roles: [EmployeeRoles.ADMIN],
|
||||
});
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(ownerEmployee)
|
||||
.mockResolvedValueOnce(adminEmployee);
|
||||
|
||||
await expect(
|
||||
EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-owner",
|
||||
companyId: "company-001",
|
||||
sessionUser: "admin-user",
|
||||
})
|
||||
).rejects.toThrow("No se puede eliminar al propietario");
|
||||
});
|
||||
|
||||
// --- Happy Path: No Future Appointments ---
|
||||
|
||||
it("successfully removes employee with no future appointments", async () => {
|
||||
const targetEmployee = buildEmployeeDoc({
|
||||
userId: "target-user",
|
||||
removed: false,
|
||||
});
|
||||
|
||||
const adminEmployee = buildEmployeeDoc({
|
||||
userId: "admin-user",
|
||||
roles: [EmployeeRoles.ADMIN],
|
||||
});
|
||||
|
||||
const targetUser = {
|
||||
_id: "target-user",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: "john@example.com",
|
||||
avatar: "avatar-url",
|
||||
};
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
// First call: session user validation
|
||||
// Second call: target user lookup (for snapshot)
|
||||
(UsersManager.users.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce({ _id: "admin-user" })
|
||||
.mockResolvedValueOnce(targetUser);
|
||||
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(targetEmployee) // target
|
||||
.mockResolvedValueOnce(adminEmployee); // session user
|
||||
|
||||
mockAppointmentFind.mockResolvedValue([]);
|
||||
|
||||
await EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-001",
|
||||
companyId: "company-001",
|
||||
sessionUser: "admin-user",
|
||||
});
|
||||
|
||||
expect(targetEmployee.save).toHaveBeenCalled();
|
||||
expect(targetEmployee.removed).toBe(true);
|
||||
expect(targetEmployee.removedBy).toBe("admin-user");
|
||||
expect(targetEmployee.removedAt).toBeDefined();
|
||||
expect(targetEmployee.profileSnapshot).toEqual({
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: "john@example.com",
|
||||
avatar: "avatar-url",
|
||||
});
|
||||
expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({
|
||||
employeeId: "emp-001",
|
||||
});
|
||||
expect(
|
||||
HeatMapList.deleteHeatMapByEmployee
|
||||
).toHaveBeenCalledWith({
|
||||
employeeId: "emp-001",
|
||||
});
|
||||
expect(MetricsList.addEmployee).toHaveBeenCalledWith({
|
||||
userId: "owner-user",
|
||||
quantity: -1,
|
||||
});
|
||||
});
|
||||
|
||||
// --- Owner Can Remove (via company.ownerId check) ---
|
||||
|
||||
it("owner can remove a collaborator", async () => {
|
||||
const targetEmployee = buildEmployeeDoc({
|
||||
userId: "target-user",
|
||||
});
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
(UsersManager.users.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce({ _id: "owner-user" }) // session user
|
||||
.mockResolvedValueOnce({
|
||||
_id: "target-user",
|
||||
firstName: "Jane",
|
||||
lastName: "Smith",
|
||||
email: "jane@example.com",
|
||||
avatar: "",
|
||||
});
|
||||
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(targetEmployee)
|
||||
.mockResolvedValueOnce(null); // owner has no employee record
|
||||
|
||||
mockAppointmentFind.mockResolvedValue([]);
|
||||
|
||||
await EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-001",
|
||||
companyId: "company-001",
|
||||
sessionUser: "owner-user",
|
||||
});
|
||||
|
||||
expect(targetEmployee.removed).toBe(true);
|
||||
expect(targetEmployee.removedBy).toBe("owner-user");
|
||||
expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({
|
||||
employeeId: "emp-001",
|
||||
});
|
||||
expect(HeatMapList.deleteHeatMapByEmployee).toHaveBeenCalledWith({
|
||||
employeeId: "emp-001",
|
||||
});
|
||||
});
|
||||
|
||||
// --- Admin with future appointments but no replacement → error ---
|
||||
|
||||
it("throws when employee has future appointments but no replacement provided", async () => {
|
||||
const targetEmployee = buildEmployeeDoc({
|
||||
userId: "target-user",
|
||||
});
|
||||
|
||||
const adminEmployee = buildEmployeeDoc({
|
||||
userId: "admin-user",
|
||||
roles: [EmployeeRoles.ADMIN],
|
||||
});
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(targetEmployee)
|
||||
.mockResolvedValueOnce(adminEmployee);
|
||||
|
||||
// Simulate future appointments exist
|
||||
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
|
||||
|
||||
await expect(
|
||||
EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-001",
|
||||
companyId: "company-001",
|
||||
sessionUser: "admin-user",
|
||||
})
|
||||
).rejects.toThrow("turnos futuros asignados");
|
||||
});
|
||||
|
||||
// --- Replacement from different company → error ---
|
||||
|
||||
it("throws when replacement employee belongs to a different company", async () => {
|
||||
const targetEmployee = buildEmployeeDoc({
|
||||
userId: "target-user",
|
||||
});
|
||||
|
||||
const adminEmployee = buildEmployeeDoc({
|
||||
userId: "admin-user",
|
||||
roles: [EmployeeRoles.ADMIN],
|
||||
});
|
||||
|
||||
const replacementFromOtherCompany = buildEmployeeDoc({
|
||||
_id: "emp-replacement",
|
||||
companyId: "other-company",
|
||||
userId: "replacement-user",
|
||||
removed: false,
|
||||
});
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(targetEmployee)
|
||||
.mockResolvedValueOnce(adminEmployee)
|
||||
.mockResolvedValueOnce(replacementFromOtherCompany);
|
||||
|
||||
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
|
||||
|
||||
await expect(
|
||||
EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-001",
|
||||
companyId: "company-001",
|
||||
replacementEmployeeId: "emp-replacement",
|
||||
sessionUser: "admin-user",
|
||||
})
|
||||
).rejects.toThrow("no pertenece a esta organización");
|
||||
});
|
||||
|
||||
// --- Replacement is self → error ---
|
||||
|
||||
it("throws when admin tries to designate themselves as replacement", async () => {
|
||||
const targetEmployee = buildEmployeeDoc({
|
||||
userId: "target-user",
|
||||
});
|
||||
|
||||
const adminEmployee = buildEmployeeDoc({
|
||||
_id: "emp-admin",
|
||||
userId: "admin-user",
|
||||
roles: [EmployeeRoles.ADMIN],
|
||||
});
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(targetEmployee)
|
||||
.mockResolvedValueOnce(adminEmployee)
|
||||
.mockResolvedValueOnce(adminEmployee); // replacement is the admin themselves
|
||||
|
||||
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
|
||||
|
||||
await expect(
|
||||
EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-001",
|
||||
companyId: "company-001",
|
||||
replacementEmployeeId: "emp-admin",
|
||||
sessionUser: "admin-user",
|
||||
})
|
||||
).rejects.toThrow("designarte como reemplazo");
|
||||
});
|
||||
|
||||
// --- Replacement does not exist → error ---
|
||||
|
||||
it("throws when replacement employee does not exist", async () => {
|
||||
const targetEmployee = buildEmployeeDoc({
|
||||
userId: "target-user",
|
||||
});
|
||||
|
||||
const adminEmployee = buildEmployeeDoc({
|
||||
userId: "admin-user",
|
||||
roles: [EmployeeRoles.ADMIN],
|
||||
});
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(targetEmployee)
|
||||
.mockResolvedValueOnce(adminEmployee)
|
||||
.mockResolvedValueOnce(null); // replacement not found
|
||||
|
||||
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
|
||||
|
||||
await expect(
|
||||
EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-001",
|
||||
companyId: "company-001",
|
||||
replacementEmployeeId: "emp-nonexistent",
|
||||
sessionUser: "admin-user",
|
||||
})
|
||||
).rejects.toThrow("empleado de reemplazo no existe");
|
||||
});
|
||||
|
||||
// --- Removal with replacement proceeds when future appointments exist ---
|
||||
|
||||
it("successfully removes employee with replacement when future appointments exist", async () => {
|
||||
const targetEmployee = buildEmployeeDoc({
|
||||
userId: "target-user",
|
||||
});
|
||||
|
||||
const adminEmployee = buildEmployeeDoc({
|
||||
userId: "admin-user",
|
||||
roles: [EmployeeRoles.ADMIN],
|
||||
});
|
||||
|
||||
const replacementEmployee = buildEmployeeDoc({
|
||||
_id: "emp-replacement",
|
||||
companyId: "company-001",
|
||||
userId: "replacement-user",
|
||||
removed: false,
|
||||
});
|
||||
|
||||
const targetUser = {
|
||||
_id: "target-user",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: "john@example.com",
|
||||
avatar: "avatar-url",
|
||||
};
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
});
|
||||
|
||||
(UsersManager.users.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce({ _id: "admin-user" })
|
||||
.mockResolvedValueOnce(targetUser);
|
||||
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(targetEmployee)
|
||||
.mockResolvedValueOnce(adminEmployee)
|
||||
.mockResolvedValueOnce(replacementEmployee);
|
||||
|
||||
// Future appointments exist
|
||||
const mockAppointment = { employeeId: "emp-001", save: jest.fn().mockResolvedValue(true) };
|
||||
mockAppointmentFind.mockResolvedValue([mockAppointment]);
|
||||
|
||||
await EmployeesList.removeCollaboratorFromCompany({
|
||||
employeeId: "emp-001",
|
||||
companyId: "company-001",
|
||||
replacementEmployeeId: "emp-replacement",
|
||||
sessionUser: "admin-user",
|
||||
});
|
||||
|
||||
expect(targetEmployee.removed).toBe(true);
|
||||
expect(mockAppointment.employeeId).toBe("emp-replacement");
|
||||
expect(mockAppointment.save).toHaveBeenCalled();
|
||||
expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({
|
||||
employeeId: "emp-001",
|
||||
});
|
||||
expect(HeatMapList.deleteHeatMapByEmployee).toHaveBeenCalledWith({
|
||||
employeeId: "emp-001",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
|
||||
});
|
||||
|
||||
if (!employeeCheck) {
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
}
|
||||
|
||||
if (employeeCheck.removed) {
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
}
|
||||
|
||||
const employeeServiceCheck = await this.employeesServices.findOne({
|
||||
@@ -160,7 +164,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
|
||||
});
|
||||
|
||||
if (!employeeCheck) {
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
}
|
||||
|
||||
if (employeeCheck.removed) {
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
}
|
||||
|
||||
await this.employeesServices.update(data);
|
||||
@@ -253,7 +261,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
|
||||
});
|
||||
|
||||
if (!employeeCheck) {
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
}
|
||||
|
||||
if (employeeCheck.removed) {
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
}
|
||||
|
||||
const userCheck = await UsersManager.users.findOne({
|
||||
@@ -330,7 +342,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
|
||||
_id: data.employeeId,
|
||||
});
|
||||
if (!employeeCheck) {
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
}
|
||||
|
||||
if (employeeCheck.removed) {
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
}
|
||||
|
||||
const serviceCheck = await ServicesManager.services.findOne({
|
||||
@@ -371,7 +387,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
|
||||
});
|
||||
|
||||
if (!employeeCheck) {
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
}
|
||||
|
||||
if (employeeCheck.removed) {
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
}
|
||||
|
||||
const userCheck = await UsersManager.users.findOne({
|
||||
@@ -416,6 +436,15 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
|
||||
});
|
||||
|
||||
for (const service of servicesByEmployee) {
|
||||
const employeeDoc = await EmployeesList.employees.findOne({
|
||||
_id: service.employeeId,
|
||||
});
|
||||
|
||||
// Skip removed employees from public-facing results
|
||||
if (!employeeDoc || employeeDoc.removed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const employeeData = await EmployeesList.findById({ id: service.employeeId });
|
||||
|
||||
returnData.push({
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
// Mock dependencies
|
||||
jest.mock("../../Users/Users", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
users: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
getUserFullName: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Companies/Companies", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
companies: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Services/Service", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
services: {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Notifications/Notifications", () => ({
|
||||
__esModule: true,
|
||||
NotificationsManager: {
|
||||
sendSystemNotification: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Employees/Employee", () => {
|
||||
const mockFindOne = jest.fn();
|
||||
const mockFind = jest.fn();
|
||||
const mockFindById = jest.fn();
|
||||
return {
|
||||
__esModule: true,
|
||||
default: {
|
||||
employees: {
|
||||
findOne: mockFindOne,
|
||||
find: mockFind,
|
||||
employeeList: {
|
||||
findOne: mockFindOne,
|
||||
find: mockFind,
|
||||
},
|
||||
},
|
||||
findById: mockFindById,
|
||||
checkPermission: jest.fn().mockResolvedValue(true),
|
||||
_mocks: { findOne: mockFindOne, find: mockFind, findById: mockFindById },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import EmployeesServicesList from "../EmployeesServices";
|
||||
import EmployeesList from "../../Employees/Employee";
|
||||
|
||||
describe("EmployeesServices.findByService — removed employee exclusion", () => {
|
||||
let mockEmployeesFindOne: jest.Mock;
|
||||
let mockEmployeesFindById: jest.Mock;
|
||||
let originalEmployeesServicesAdapter: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockEmployeesFindOne = (EmployeesList as any)._mocks.findOne;
|
||||
mockEmployeesFindById = (EmployeesList as any)._mocks.findById;
|
||||
|
||||
// Save and replace the employeesServices adapter
|
||||
originalEmployeesServicesAdapter = EmployeesServicesList.employeesServices;
|
||||
|
||||
const mockESFindOne = jest.fn();
|
||||
const mockESFind = jest.fn();
|
||||
(EmployeesServicesList as any).employeesServices = {
|
||||
findOne: mockESFindOne,
|
||||
find: mockESFind,
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
update: jest.fn(),
|
||||
paginate: jest.fn(),
|
||||
employeesServicesList: {
|
||||
findOne: mockESFindOne,
|
||||
find: mockESFind,
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(EmployeesServicesList as any).employeesServices = originalEmployeesServicesAdapter;
|
||||
});
|
||||
|
||||
it("excludes removed employees from findByService results", async () => {
|
||||
// Service assignments include one for an active employee and one for a removed employee
|
||||
const activeServiceAssignment = {
|
||||
_id: "es-001",
|
||||
employeeId: "emp-active",
|
||||
serviceId: "svc-001",
|
||||
companyId: "company-001",
|
||||
};
|
||||
|
||||
const removedServiceAssignment = {
|
||||
_id: "es-002",
|
||||
employeeId: "emp-removed",
|
||||
serviceId: "svc-001",
|
||||
companyId: "company-001",
|
||||
};
|
||||
|
||||
const activeEmployeeDoc = {
|
||||
_id: "emp-active",
|
||||
id: "emp-active",
|
||||
companyId: "company-001",
|
||||
userId: "user-active",
|
||||
removed: false,
|
||||
};
|
||||
|
||||
const removedEmployeeDoc = {
|
||||
_id: "emp-removed",
|
||||
id: "emp-removed",
|
||||
companyId: "company-001",
|
||||
userId: "user-removed",
|
||||
removed: true,
|
||||
};
|
||||
|
||||
// employeesServices.find returns both assignments
|
||||
const mockESFind = (EmployeesServicesList as any).employeesServices.find;
|
||||
mockESFind.mockResolvedValue([activeServiceAssignment, removedServiceAssignment]);
|
||||
|
||||
// EmployeesList.employees.findOne returns the employee docs
|
||||
mockEmployeesFindOne
|
||||
.mockResolvedValueOnce(activeEmployeeDoc)
|
||||
.mockResolvedValueOnce(removedEmployeeDoc);
|
||||
|
||||
// EmployeesList.findById for the active employee
|
||||
mockEmployeesFindById.mockResolvedValue({
|
||||
id: "emp-active",
|
||||
fullName: "Active User",
|
||||
avatar: "",
|
||||
});
|
||||
|
||||
const result = await EmployeesServicesList.findByService({
|
||||
serviceId: "svc-001",
|
||||
});
|
||||
|
||||
// Only the active employee should appear in results
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("emp-active");
|
||||
expect(result[0].name).toBe("Active User");
|
||||
});
|
||||
|
||||
it("returns empty when all assigned employees are removed", async () => {
|
||||
const removedServiceAssignment = {
|
||||
_id: "es-001",
|
||||
employeeId: "emp-removed",
|
||||
serviceId: "svc-001",
|
||||
companyId: "company-001",
|
||||
};
|
||||
|
||||
const removedEmployeeDoc = {
|
||||
_id: "emp-removed",
|
||||
id: "emp-removed",
|
||||
companyId: "company-001",
|
||||
userId: "user-removed",
|
||||
removed: true,
|
||||
};
|
||||
|
||||
const mockESFind = (EmployeesServicesList as any).employeesServices.find;
|
||||
mockESFind.mockResolvedValue([removedServiceAssignment]);
|
||||
|
||||
mockEmployeesFindOne.mockResolvedValueOnce(removedEmployeeDoc);
|
||||
|
||||
const result = await EmployeesServicesList.findByService({
|
||||
serviceId: "svc-001",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toHaveLength(0);
|
||||
// Prove the guard stopped processing: employee was found but removed,
|
||||
// so findById should NEVER have been called for enrichment.
|
||||
expect((EmployeesList as any)._mocks.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("excludes employees whose Employee document is null (hard-deleted User)", async () => {
|
||||
// Scenario: service assignment exists but the Employee record was hard-deleted
|
||||
// (e.g. cascade cleanup). findByService must skip gracefully.
|
||||
const activeServiceAssignment = {
|
||||
_id: "es-001",
|
||||
employeeId: "emp-active",
|
||||
serviceId: "svc-001",
|
||||
companyId: "company-001",
|
||||
};
|
||||
|
||||
const orphanServiceAssignment = {
|
||||
_id: "es-002",
|
||||
employeeId: "emp-orphan",
|
||||
serviceId: "svc-001",
|
||||
companyId: "company-001",
|
||||
};
|
||||
|
||||
const activeEmployeeDoc = {
|
||||
_id: "emp-active",
|
||||
id: "emp-active",
|
||||
companyId: "company-001",
|
||||
userId: "user-active",
|
||||
removed: false,
|
||||
};
|
||||
|
||||
const mockESFind = (EmployeesServicesList as any).employeesServices.find;
|
||||
mockESFind.mockResolvedValue([activeServiceAssignment, orphanServiceAssignment]);
|
||||
|
||||
// First findOne: active employee → found; second: orphan → null (deleted)
|
||||
mockEmployeesFindOne
|
||||
.mockResolvedValueOnce(activeEmployeeDoc)
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
mockEmployeesFindById.mockResolvedValue({
|
||||
id: "emp-active",
|
||||
fullName: "Active User",
|
||||
avatar: "",
|
||||
});
|
||||
|
||||
const result = await EmployeesServicesList.findByService({
|
||||
serviceId: "svc-001",
|
||||
});
|
||||
|
||||
// Only the active employee should appear
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("emp-active");
|
||||
// findById was called once for the active employee only
|
||||
expect(mockEmployeesFindById).toHaveBeenCalledTimes(1);
|
||||
expect(mockEmployeesFindById).toHaveBeenCalledWith({ id: "emp-active" });
|
||||
});
|
||||
|
||||
it("does not call findById for removed employees, only for active ones", async () => {
|
||||
// Proves the continue guard prevents enrichment calls for removed employees.
|
||||
const activeAssignment = {
|
||||
_id: "es-001",
|
||||
employeeId: "emp-active",
|
||||
serviceId: "svc-001",
|
||||
companyId: "company-001",
|
||||
};
|
||||
|
||||
const removedAssignment = {
|
||||
_id: "es-002",
|
||||
employeeId: "emp-removed",
|
||||
serviceId: "svc-001",
|
||||
companyId: "company-001",
|
||||
};
|
||||
|
||||
const activeDoc = {
|
||||
_id: "emp-active",
|
||||
id: "emp-active",
|
||||
removed: false,
|
||||
};
|
||||
|
||||
const removedDoc = {
|
||||
_id: "emp-removed",
|
||||
id: "emp-removed",
|
||||
removed: true,
|
||||
};
|
||||
|
||||
const mockESFind = (EmployeesServicesList as any).employeesServices.find;
|
||||
mockESFind.mockResolvedValue([activeAssignment, removedAssignment]);
|
||||
|
||||
mockEmployeesFindOne
|
||||
.mockResolvedValueOnce(activeDoc)
|
||||
.mockResolvedValueOnce(removedDoc);
|
||||
|
||||
mockEmployeesFindById.mockResolvedValue({
|
||||
id: "emp-active",
|
||||
fullName: "Active User",
|
||||
avatar: "",
|
||||
});
|
||||
|
||||
const result = await EmployeesServicesList.findByService({
|
||||
serviceId: "svc-001",
|
||||
});
|
||||
|
||||
// findById called ONCE (active only), never for the removed employee
|
||||
expect(mockEmployeesFindById).toHaveBeenCalledTimes(1);
|
||||
expect(mockEmployeesFindById).toHaveBeenCalledWith({ id: "emp-active" });
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public Booking Path — Limitation Documentation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The actual public booking flow for collaborator resolution is:
|
||||
//
|
||||
// Frontend (Appointment.tsx)
|
||||
// → POST /employees/employees-by-service
|
||||
// → FindEmployeesByServiceController.findEmployeesByService()
|
||||
// → new EmployeesService().findByService(requestBody) [thin wrapper: connect() + delegate]
|
||||
// → EmployeesServicesList.findByService(data) [model — tested above]
|
||||
//
|
||||
// The model layer (EmployeesServicesManager.findByService) is the GATEKEEPER
|
||||
// that applies the `removed` guard. The controller and service classes are thin
|
||||
// pass-through wrappers with no additional filtering logic.
|
||||
//
|
||||
// Full-path integration testing (controller → service → model → DB) is not
|
||||
// practically unit-testable because:
|
||||
// 1. The service layer calls mongoose.connect() which requires a real DB
|
||||
// 2. The controller instantiates the service class internally
|
||||
// 3. 5+ collections would need seeding (Employees, EmployeesServices, Users, Companies)
|
||||
//
|
||||
// The model-level tests above provide the STRONGEST feasible coverage because:
|
||||
// - They exercise the exact `removed` guard logic (line 444 of EmployeesServices.ts)
|
||||
// - They prove findById enrichment is skipped for removed/null employees
|
||||
// - They prove the correct filter is applied to service assignment queries
|
||||
// - The controller/service layers add no filtering — they are pure delegation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -2,7 +2,9 @@ import { IMetricsDocument } from "./Metrics.Adapter.Mongoose";
|
||||
|
||||
export type MetricsParams = {
|
||||
userId?: string;
|
||||
companyId?: string;
|
||||
quantity?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type DeleteMetricsByCompany = {
|
||||
@@ -45,6 +47,8 @@ export interface IMetricsManager {
|
||||
addEmployee(data: MetricsParams): Promise<void>;
|
||||
addService(data: MetricsParams): Promise<void>;
|
||||
addAppointment(data: MetricsParams): Promise<void>;
|
||||
reserveAppointment(data: MetricsParams): Promise<boolean>;
|
||||
releaseAppointment(data: MetricsParams): Promise<void>;
|
||||
addClient(data: MetricsParams): Promise<void>;
|
||||
canAddOrganization(userId: string): Promise<boolean>;
|
||||
canAddEmployee(userId: string): Promise<boolean>;
|
||||
|
||||
@@ -14,6 +14,7 @@ import AppointmentList from "../Appointments/Appointments";
|
||||
import ClientsList from "../Clients/Clients";
|
||||
import RepeatsList from "../Repeats/Repeats";
|
||||
import { isNull } from "../../helpers/IsNull";
|
||||
import PlanUsageCycleList from "../PlanUsageCycle/PlanUsageCycle";
|
||||
|
||||
class MetricsManager implements IMetricsManager {
|
||||
metrics: MetricsAdapterMongoose;
|
||||
@@ -39,7 +40,8 @@ class MetricsManager implements IMetricsManager {
|
||||
for (const org of organizations) {
|
||||
const employees = await EmployeesList.employees.find({
|
||||
companyId: String(org.id),
|
||||
});
|
||||
removed: { $ne: true },
|
||||
} as any);
|
||||
employeesLength += employees.length;
|
||||
|
||||
const services = await ServiceList.services.find({
|
||||
@@ -231,17 +233,64 @@ class MetricsManager implements IMetricsManager {
|
||||
if (plan.limitAppointments < 0) {
|
||||
return true;
|
||||
}
|
||||
const metrics = await this.getMetrics(userId);
|
||||
if (metrics.appointmentsCount >= plan.limitAppointments) {
|
||||
const appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({ userId });
|
||||
if (appointmentsCount >= plan.limitAppointments) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async addAppointment(data: MetricsParams): Promise<void> {
|
||||
if (data.userId) {
|
||||
await PlanUsageCycleList.addAppointment({
|
||||
userId: data.userId,
|
||||
quantity: data.quantity,
|
||||
});
|
||||
}
|
||||
await this.metrics.addAppointment(data);
|
||||
}
|
||||
|
||||
public async reserveAppointment(data: MetricsParams): Promise<boolean> {
|
||||
if (!data.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subscription = await PlanSubscriptionsList.findOne({
|
||||
sessionUser: data.userId,
|
||||
});
|
||||
if (!subscription) {
|
||||
return false;
|
||||
}
|
||||
const plan = await PlansList.plans.findOne({
|
||||
_id: subscription.planId,
|
||||
});
|
||||
if (!plan) {
|
||||
return false;
|
||||
}
|
||||
if (plan.limitAppointments < 0) {
|
||||
await PlanUsageCycleList.addAppointment({
|
||||
userId: data.userId,
|
||||
quantity: data.quantity,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return await PlanUsageCycleList.reserveAppointment({
|
||||
userId: data.userId,
|
||||
quantity: data.quantity,
|
||||
limit: plan.limitAppointments,
|
||||
});
|
||||
}
|
||||
|
||||
public async releaseAppointment(data: MetricsParams): Promise<void> {
|
||||
if (data.userId) {
|
||||
await PlanUsageCycleList.releaseAppointment({
|
||||
userId: data.userId,
|
||||
quantity: data.quantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async addClient(data: MetricsParams): Promise<void> {
|
||||
await this.metrics.addClient(data);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Tests for Metrics.calculateMetrics — excludes removed employees from count.
|
||||
*/
|
||||
|
||||
jest.mock("../../Companies/Companies", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
companies: {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Employees/Employee", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
employees: {
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Services/Service", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
services: {
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Appointments/Appointments", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
countAppointmentsByMonth: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Clients/Clients", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
clients: {
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Repeats/Repeats", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
repeats: {
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Plans/Plans", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
plans: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../PlanUsageCycle/PlanUsageCycle", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getAppointmentsCount: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import MetricsList from "../Metrics";
|
||||
import CompaniesList from "../../Companies/Companies";
|
||||
import EmployeesList from "../../Employees/Employee";
|
||||
import ServiceList from "../../Services/Service";
|
||||
import AppointmentList from "../../Appointments/Appointments";
|
||||
import ClientsList from "../../Clients/Clients";
|
||||
import RepeatsList from "../../Repeats/Repeats";
|
||||
|
||||
describe("calculateMetrics — excludes removed employees", () => {
|
||||
let originalAdapter: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
originalAdapter = MetricsList.metrics;
|
||||
|
||||
const mockSave = jest.fn().mockResolvedValue(true);
|
||||
const mockMetricsDoc = {
|
||||
userId: "owner-user",
|
||||
organizationsCount: 0,
|
||||
employeesCount: 0,
|
||||
servicesCount: 0,
|
||||
appointmentsCount: 0,
|
||||
clientsCount: 0,
|
||||
repeatsCount: 0,
|
||||
month: 6,
|
||||
year: 2026,
|
||||
save: mockSave,
|
||||
};
|
||||
|
||||
(MetricsList as any).metrics = {
|
||||
getMetrics: jest.fn().mockResolvedValue(mockMetricsDoc),
|
||||
reset: jest.fn().mockResolvedValue(mockMetricsDoc),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(MetricsList as any).metrics = originalAdapter;
|
||||
});
|
||||
|
||||
it("counts only non-removed employees across companies", async () => {
|
||||
// Two companies owned by the same user
|
||||
(CompaniesList.companies.find as jest.Mock).mockResolvedValue([
|
||||
{ _id: "company-001", id: "company-001" },
|
||||
{ _id: "company-002", id: "company-002" },
|
||||
]);
|
||||
|
||||
// Company 001: 2 active + 1 removed → should count 2
|
||||
// Company 002: 1 active → should count 1
|
||||
// Total: 3 employees (not 4)
|
||||
(EmployeesList.employees.find as jest.Mock)
|
||||
.mockResolvedValueOnce([
|
||||
{ _id: "emp-1", removed: false },
|
||||
{ _id: "emp-2", removed: false },
|
||||
{ _id: "emp-3", removed: true }, // filtered by query but just in case
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ _id: "emp-4", removed: false },
|
||||
]);
|
||||
|
||||
// Services: 2 in company-001, 1 in company-002
|
||||
(ServiceList.services.find as jest.Mock)
|
||||
.mockResolvedValueOnce([{ _id: "svc-1" }, { _id: "svc-2" }])
|
||||
.mockResolvedValueOnce([{ _id: "svc-3" }]);
|
||||
|
||||
// Appointments count per company
|
||||
(AppointmentList.countAppointmentsByMonth as jest.Mock)
|
||||
.mockResolvedValueOnce(5)
|
||||
.mockResolvedValueOnce(3);
|
||||
|
||||
// Clients per company
|
||||
(ClientsList.clients.find as jest.Mock)
|
||||
.mockResolvedValueOnce([{ _id: "c-1" }])
|
||||
.mockResolvedValueOnce([{ _id: "c-2" }, { _id: "c-3" }]);
|
||||
|
||||
// Repeats per company
|
||||
(RepeatsList.repeats.find as jest.Mock)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await MetricsList.calculateMetrics({ userId: "owner-user" });
|
||||
|
||||
// Verify employee find was called with removed filter for BOTH companies
|
||||
expect(EmployeesList.employees.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-001",
|
||||
removed: { $ne: true },
|
||||
})
|
||||
);
|
||||
expect(EmployeesList.employees.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-002",
|
||||
removed: { $ne: true },
|
||||
})
|
||||
);
|
||||
|
||||
// Verify the metrics document was saved
|
||||
const metricsDoc = await (MetricsList as any).metrics.getMetrics();
|
||||
expect(metricsDoc.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("produces zero employee count when all employees are removed", async () => {
|
||||
(CompaniesList.companies.find as jest.Mock).mockResolvedValue([
|
||||
{ _id: "company-001", id: "company-001" },
|
||||
]);
|
||||
|
||||
// All employees in this company are removed
|
||||
(EmployeesList.employees.find as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
(ServiceList.services.find as jest.Mock).mockResolvedValue([]);
|
||||
(AppointmentList.countAppointmentsByMonth as jest.Mock).mockResolvedValue(0);
|
||||
(ClientsList.clients.find as jest.Mock).mockResolvedValue([]);
|
||||
(RepeatsList.repeats.find as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const mockSave = jest.fn().mockResolvedValue(true);
|
||||
const mockMetricsDoc = {
|
||||
userId: "owner-user",
|
||||
organizationsCount: 0,
|
||||
employeesCount: 5, // pre-existing count
|
||||
servicesCount: 0,
|
||||
appointmentsCount: 0,
|
||||
clientsCount: 0,
|
||||
repeatsCount: 0,
|
||||
month: 6,
|
||||
year: 2026,
|
||||
save: mockSave,
|
||||
};
|
||||
|
||||
(MetricsList as any).metrics = {
|
||||
getMetrics: jest.fn().mockResolvedValue(mockMetricsDoc),
|
||||
reset: jest.fn().mockResolvedValue(mockMetricsDoc),
|
||||
};
|
||||
|
||||
await MetricsList.calculateMetrics({ userId: "owner-user" });
|
||||
|
||||
// employeesCount should be set to 0 (no non-removed employees)
|
||||
expect(mockMetricsDoc.employeesCount).toBe(0);
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Document, Model, Schema, model } from "mongoose";
|
||||
import {
|
||||
CreatePlanPaymentParams,
|
||||
FindPlanPaymentsParams,
|
||||
IPlanPayment,
|
||||
IPlanPaymentsAdapter,
|
||||
} from "./PlanPayments.interface";
|
||||
|
||||
export interface IPlanPaymentDocument extends Omit<IPlanPayment, "id">, Document {}
|
||||
|
||||
export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
|
||||
schema: Schema;
|
||||
planPaymentList: Model<IPlanPaymentDocument>;
|
||||
|
||||
constructor() {
|
||||
this.schema = new Schema({
|
||||
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
|
||||
subscriptionId: { type: Schema.Types.ObjectId, required: true, ref: "PlanSuscription" },
|
||||
amount: { type: Number, required: true, default: 0 },
|
||||
paymentDate: { type: Date, required: true, default: Date.now },
|
||||
paymentMethod: { type: String, required: false },
|
||||
status: { type: String, required: true },
|
||||
transactionId: { type: String, required: false },
|
||||
});
|
||||
this.schema.index({ transactionId: 1 }, { unique: true, sparse: true });
|
||||
|
||||
this.planPaymentList = model<IPlanPaymentDocument>("PlanPayment", this.schema);
|
||||
}
|
||||
|
||||
public async find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
|
||||
return this.planPaymentList.find(filters).exec();
|
||||
}
|
||||
|
||||
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||
return this.planPaymentList.create(data);
|
||||
}
|
||||
|
||||
public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||
if (!data.transactionId) {
|
||||
return this.create(data);
|
||||
}
|
||||
|
||||
return this.planPaymentList.findOneAndUpdate(
|
||||
{ transactionId: data.transactionId },
|
||||
{ $setOnInsert: data },
|
||||
{ new: true, upsert: true, setDefaultsOnInsert: true }
|
||||
).exec();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
export type FindPlanPaymentsParams = {
|
||||
_id?: string;
|
||||
userId?: string;
|
||||
subscriptionId?: string;
|
||||
transactionId?: string;
|
||||
};
|
||||
|
||||
export type CreatePlanPaymentParams = Omit<IPlanPayment, "id">;
|
||||
|
||||
export interface IPlanPayment {
|
||||
id?: string;
|
||||
userId: string; // Referencia al ID del usuario
|
||||
@@ -15,9 +20,13 @@ export interface IPlanPayment {
|
||||
|
||||
export interface IPlanPaymentsAdapter {
|
||||
find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
|
||||
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||
}
|
||||
|
||||
export interface IPlanPaymentsManager {
|
||||
planPayments: IPlanPaymentsAdapter;
|
||||
find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
|
||||
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { PlanPaymentsAdapterMongoose } from "./PlanPayments.Adapter.Mongoose";
|
||||
import {
|
||||
CreatePlanPaymentParams,
|
||||
FindPlanPaymentsParams,
|
||||
IPlanPayment,
|
||||
IPlanPaymentsManager,
|
||||
} from "./PlanPayments.interface";
|
||||
|
||||
class PlanPaymentsManager implements IPlanPaymentsManager {
|
||||
planPayments: PlanPaymentsAdapterMongoose;
|
||||
|
||||
constructor() {
|
||||
this.planPayments = new PlanPaymentsAdapterMongoose();
|
||||
}
|
||||
|
||||
public async find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
|
||||
return this.planPayments.find(data);
|
||||
}
|
||||
|
||||
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||
return this.planPayments.create(data);
|
||||
}
|
||||
|
||||
public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||
return this.planPayments.createIfMissingByTransactionId(data);
|
||||
}
|
||||
}
|
||||
|
||||
const PlanPaymentsList = new PlanPaymentsManager();
|
||||
|
||||
export default PlanPaymentsList;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
FindPlanSuscripcionsParams,
|
||||
IPlanSuscriptionsAdapter,
|
||||
@@ -38,6 +37,26 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
|
||||
mpDateCreated: { type: Date, required: false },
|
||||
mpInitPoint: { type: String, required: false },
|
||||
mpPreferenceId: { type: String, required: false },
|
||||
billingMonths: { type: Number, required: false },
|
||||
pendingPaymentInitPoint: { type: String, required: false },
|
||||
pendingPaymentPreferenceId: { type: String, required: false },
|
||||
pendingPaymentBillingMonths: { type: Number, required: false },
|
||||
pendingPaymentType: { type: String, required: false, enum: ["extension", "upgrade"] },
|
||||
pendingPaymentPlanId: { type: Schema.Types.ObjectId, required: false, ref: "Plan" },
|
||||
pendingPaymentCurrentPlanPrice: { type: Number, required: false },
|
||||
pendingPaymentRequestedPlanPrice: { type: Number, required: false },
|
||||
pendingPaymentProratedAmount: { type: Number, required: false },
|
||||
pendingPaymentRemainingDays: { type: Number, required: false },
|
||||
pendingPaymentPeriodEndDate: { type: Date, required: false },
|
||||
lastPaymentStatus: { type: String, required: false, enum: ["rejected", "failed", "cancelled"] },
|
||||
lastPaymentPreferenceId: { type: String, required: false },
|
||||
lastPaymentAt: { type: Date, required: false },
|
||||
lastPaymentType: { type: String, required: false, enum: ["new", "extension", "upgrade"] },
|
||||
downgradedFromPlanId: { type: Schema.Types.ObjectId, required: false, ref: "Plan" },
|
||||
downgradedFromPlanName: { type: String, required: false },
|
||||
downgradedFromPlanCode: { type: String, required: false },
|
||||
downgradedAt: { type: Date, required: false },
|
||||
downgradeReason: { type: String, required: false, enum: ["expired"] },
|
||||
});
|
||||
|
||||
this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
|
||||
@@ -54,20 +73,32 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
|
||||
public async findOne(
|
||||
filters: FindPlanSuscripcionsParams
|
||||
): Promise<IPlanSuscriptionDocument | null> {
|
||||
let subscription = await this.planSuscriptionList
|
||||
.findOne({ userId: filters.sessionUser })
|
||||
.exec();
|
||||
const searchCriteria: Record<string, unknown> = {};
|
||||
|
||||
if (filters.sessionUser) {
|
||||
searchCriteria.userId = filters.sessionUser;
|
||||
}
|
||||
|
||||
if (filters.mpPreferenceId) {
|
||||
searchCriteria.mpPreferenceId = filters.mpPreferenceId;
|
||||
}
|
||||
|
||||
if (filters.mpStatus) {
|
||||
searchCriteria.mpStatus = filters.mpStatus;
|
||||
}
|
||||
|
||||
let subscription = await this.planSuscriptionList.findOne(searchCriteria).exec();
|
||||
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateNow = dayjs(new Date());
|
||||
const dateEnd = dayjs(subscription.endDate);
|
||||
const dateNow = new Date();
|
||||
const dateEnd = new Date(subscription.endDate);
|
||||
|
||||
try {
|
||||
if (subscription.mpPreferenceId) {
|
||||
if (dateNow.isAfter(dateEnd)) {
|
||||
if (dateNow > dateEnd) {
|
||||
if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) {
|
||||
subscription.mpPreferenceId = "";
|
||||
subscription.isActive = false;
|
||||
@@ -77,17 +108,7 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
|
||||
await subscription.save();
|
||||
return subscription;
|
||||
}
|
||||
// Without subscriptions, we wait for the webhook to update the status.
|
||||
// If it's expired and not updated by webhook, we just cancel it.
|
||||
if (subscription.mpStatus === MP_SUBS_STATUS.PENDING) {
|
||||
subscription.mpPreferenceId = "";
|
||||
subscription.isActive = false;
|
||||
subscription.autoRenew = false;
|
||||
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
||||
subscription.mpInitPoint = "";
|
||||
await subscription.save();
|
||||
return subscription;
|
||||
}
|
||||
// Pending Checkout Preferences do not consume paid time; webhook decides the final status.
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { IPlanSuscriptionDocument } from "./PlanSubscriptions.Adapter.Mongoose";
|
||||
|
||||
export type FindPlanSuscripcionsParams = {
|
||||
sessionUser: string;
|
||||
mpPreferenceId?: string;
|
||||
mpStatus?: string;
|
||||
};
|
||||
|
||||
export type CreatePlanSuscriptionParams = {
|
||||
@@ -11,6 +13,7 @@ export type CreatePlanSuscriptionParams = {
|
||||
mpPlanId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isActive?: boolean;
|
||||
autoRenew: boolean;
|
||||
mpPayerId?: number;
|
||||
mpPayerEmail?: string;
|
||||
@@ -18,6 +21,26 @@ export type CreatePlanSuscriptionParams = {
|
||||
mpDateCreated?: Date;
|
||||
mpInitPoint?: string;
|
||||
mpPreferenceId?: string;
|
||||
billingMonths?: number;
|
||||
pendingPaymentInitPoint?: string;
|
||||
pendingPaymentPreferenceId?: string;
|
||||
pendingPaymentBillingMonths?: number;
|
||||
pendingPaymentType?: "extension" | "upgrade";
|
||||
pendingPaymentPlanId?: string;
|
||||
pendingPaymentCurrentPlanPrice?: number;
|
||||
pendingPaymentRequestedPlanPrice?: number;
|
||||
pendingPaymentProratedAmount?: number;
|
||||
pendingPaymentRemainingDays?: number;
|
||||
pendingPaymentPeriodEndDate?: Date;
|
||||
lastPaymentStatus?: LastPlanPaymentStatus;
|
||||
lastPaymentPreferenceId?: string;
|
||||
lastPaymentAt?: Date;
|
||||
lastPaymentType?: LastPlanPaymentType;
|
||||
downgradedFromPlanId?: string;
|
||||
downgradedFromPlanName?: string;
|
||||
downgradedFromPlanCode?: string;
|
||||
downgradedAt?: Date;
|
||||
downgradeReason?: DowngradeReason;
|
||||
};
|
||||
|
||||
export type CancellPlanSuscriptionParams = {
|
||||
@@ -47,6 +70,7 @@ export type MPPreApprovalResponse = {
|
||||
|
||||
export type ToFreePlanParams = {
|
||||
sessionUser: string;
|
||||
downgradeReason?: DowngradeReason;
|
||||
};
|
||||
|
||||
export type ToMpParams = {
|
||||
@@ -68,6 +92,23 @@ export type GetSubscriptionInitPointParams = {
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type VerifyPendingPlanPaymentParams = {
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type VerifyPendingPlanPaymentResponse = {
|
||||
status: "approved" | "pending" | "not_found" | "rejected" | "failed" | "cancelled";
|
||||
approved: boolean;
|
||||
message: string;
|
||||
subscriptionId?: string;
|
||||
paymentId?: string;
|
||||
paymentType?: "new" | "extension" | "upgrade";
|
||||
};
|
||||
|
||||
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
|
||||
export type LastPlanPaymentType = "new" | "extension" | "upgrade";
|
||||
export type DowngradeReason = "expired";
|
||||
|
||||
export interface GetSuscriptionInitPointResponse {
|
||||
init_point: string;
|
||||
}
|
||||
@@ -88,6 +129,26 @@ export interface IPlanSuscription {
|
||||
mpDateCreated: Date; // Fecha de creación de la suscripción en MercadoPago.
|
||||
mpInitPoint: string; // URL de inicio de la suscripción en MercadoPago.
|
||||
mpPreferenceId: string; // ID de la preferencia en MercadoPago.
|
||||
billingMonths?: number; // Cantidad de meses comprados para pagos únicos.
|
||||
pendingPaymentInitPoint?: string; // URL de pago pendiente para extender el plan actual.
|
||||
pendingPaymentPreferenceId?: string; // ID de preferencia pendiente para extender el plan actual.
|
||||
pendingPaymentBillingMonths?: number; // Meses pendientes de acreditar al aprobarse la extensión.
|
||||
pendingPaymentType?: "extension" | "upgrade";
|
||||
pendingPaymentPlanId?: string;
|
||||
pendingPaymentCurrentPlanPrice?: number;
|
||||
pendingPaymentRequestedPlanPrice?: number;
|
||||
pendingPaymentProratedAmount?: number;
|
||||
pendingPaymentRemainingDays?: number;
|
||||
pendingPaymentPeriodEndDate?: Date;
|
||||
lastPaymentStatus?: LastPlanPaymentStatus;
|
||||
lastPaymentPreferenceId?: string;
|
||||
lastPaymentAt?: Date;
|
||||
lastPaymentType?: LastPlanPaymentType;
|
||||
downgradedFromPlanId?: string;
|
||||
downgradedFromPlanName?: string;
|
||||
downgradedFromPlanCode?: string;
|
||||
downgradedAt?: Date;
|
||||
downgradeReason?: DowngradeReason;
|
||||
}
|
||||
|
||||
export interface ISubscriptionInfo {
|
||||
@@ -99,6 +160,18 @@ export interface ISubscriptionInfo {
|
||||
autoRenew: boolean;
|
||||
mpStatus: string;
|
||||
mpDateCreated: Date;
|
||||
pendingPaymentInitPoint?: string;
|
||||
pendingPaymentPreferenceId?: string;
|
||||
pendingPaymentType?: "extension" | "upgrade";
|
||||
lastPaymentStatus?: LastPlanPaymentStatus;
|
||||
lastPaymentPreferenceId?: string;
|
||||
lastPaymentAt?: Date;
|
||||
lastPaymentType?: LastPlanPaymentType;
|
||||
downgradedFromPlanId?: string;
|
||||
downgradedFromPlanName?: string;
|
||||
downgradedFromPlanCode?: string;
|
||||
downgradedAt?: Date;
|
||||
downgradeReason?: DowngradeReason;
|
||||
}
|
||||
|
||||
export interface IPlanSuscriptionsAdapter {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const DEFAULT_BILLING_PERIOD_DAYS = 30;
|
||||
|
||||
export function calculateProratedUpgradeAmount(
|
||||
currentPlanPrice: number,
|
||||
requestedPlanPrice: number,
|
||||
remainingDays: number,
|
||||
billingPeriodDays = DEFAULT_BILLING_PERIOD_DAYS
|
||||
): number {
|
||||
const dailyDifference = (requestedPlanPrice - currentPlanPrice) / billingPeriodDays;
|
||||
return Math.round(dailyDifference * remainingDays * 100) / 100;
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import axios from "axios";
|
||||
import PlansList from "../../Models/Plans/Plans";
|
||||
import dayjs from "dayjs";
|
||||
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
|
||||
import "dayjs/locale/es";
|
||||
import { isNull } from "../../helpers/IsNull";
|
||||
import { PlanFeatures } from "../Plans/Plans.interface";
|
||||
@@ -31,9 +30,11 @@ import { NotificationsManager } from "../Notifications/Notifications";
|
||||
import WapServerList from "../WapServer/WapServer";
|
||||
import CompaniesList from "../Companies/Companies";
|
||||
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
|
||||
import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing";
|
||||
|
||||
dayjs.locale("es");
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
planSuscriptions: PlanSuscriptionsAdapterMongoose;
|
||||
@@ -42,6 +43,24 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
this.planSuscriptions = new PlanSuscriptionsAdapterMongoose();
|
||||
}
|
||||
|
||||
private shouldDowngradeExpiredSubscription(subscription: IPlanSuscriptionDocument): boolean {
|
||||
return subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || subscription.mpStatus === MP_SUBS_STATUS.CANCELLED;
|
||||
}
|
||||
|
||||
private buildMercadoPagoBackUrl(returnStatus: "success" | "failure" | "pending"): string {
|
||||
const backUrl = process.env.MP_BACK_URL || "";
|
||||
if (!backUrl) return backUrl;
|
||||
|
||||
try {
|
||||
const url = new URL(backUrl);
|
||||
url.searchParams.set("mp_return", returnStatus);
|
||||
return url.toString();
|
||||
} catch {
|
||||
const separator = backUrl.includes("?") ? "&" : "?";
|
||||
return `${backUrl}${separator}mp_return=${returnStatus}`;
|
||||
}
|
||||
}
|
||||
|
||||
public async getInitPoint(
|
||||
data: GetSubscriptionInitPointParams
|
||||
): Promise<GetSuscriptionInitPointResponse> {
|
||||
@@ -135,8 +154,9 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
planId: data.planId,
|
||||
mpPlanId: data.planId,
|
||||
startDate: subscriptionStartDate.toDate(),
|
||||
endDate: subscriptionStartDate.clone().add(data.months || 1, "months").toDate(),
|
||||
autoRenew: true,
|
||||
endDate: subscriptionStartDate.clone().add(10, "years").toDate(),
|
||||
autoRenew: false,
|
||||
mpStatus: "",
|
||||
mpInitPoint: "/landing/dashboard",
|
||||
});
|
||||
} else if (plan.price > 0) {
|
||||
@@ -168,8 +188,16 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
|
||||
if (subscription) {
|
||||
const subscriptionStartDate = dayjs(new Date());
|
||||
subscription.endDate = subscriptionStartDate.clone().add(data.months || 1, "months").toDate();
|
||||
subscription.startDate = subscriptionStartDate.toDate();
|
||||
subscription.endDate = subscriptionStartDate.clone().add(10, "years").toDate();
|
||||
subscription.autoRenew = false;
|
||||
subscription.mpStatus = "";
|
||||
subscription.mpInitPoint = dashboardUrl || "";
|
||||
subscription.downgradedFromPlanId = undefined;
|
||||
subscription.downgradedFromPlanName = undefined;
|
||||
subscription.downgradedFromPlanCode = undefined;
|
||||
subscription.downgradedAt = undefined;
|
||||
subscription.downgradeReason = undefined;
|
||||
await subscription.save();
|
||||
}
|
||||
} else {
|
||||
@@ -182,6 +210,23 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
months: data.months || 1,
|
||||
});
|
||||
} else {
|
||||
const isActivePaidSubscription =
|
||||
actualPlan.price > 0 &&
|
||||
(subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
|
||||
subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
|
||||
dayjs(subscription.endDate).isAfter(new Date());
|
||||
|
||||
if (isActivePaidSubscription && actualPlan.id !== data.planId) {
|
||||
if (plan.price <= actualPlan.price) {
|
||||
throw new Error("No se puede cambiar a un plan igual o inferior desde esta ruta de actualización.");
|
||||
}
|
||||
|
||||
subscription = await this.toMP({
|
||||
planId: data.planId,
|
||||
sessionUser: data.sessionUser,
|
||||
months: data.months || 1,
|
||||
});
|
||||
} else {
|
||||
if (subscription.mpStatus != MP_SUBS_STATUS.CANCELLED && actualPlan.id !== data.planId) {
|
||||
throw new Error(
|
||||
"No se puede cambiar el plan, ya que el usuario ya tiene una suscripción activa. Para cambiarlo primero debes cancelar tu plan actual."
|
||||
@@ -193,6 +238,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
sessionUser: data.sessionUser,
|
||||
months: data.months || 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,7 +273,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const subscriptionStartDate = dayjs(new Date());
|
||||
const preferenceDate = dayjs(new Date());
|
||||
|
||||
let discount = 0;
|
||||
if (data.months === 3) discount = plan.discount3Months || 0;
|
||||
@@ -237,21 +283,74 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
const totalPrice = plan.price * data.months;
|
||||
const finalPrice = totalPrice - (totalPrice * (discount / 100));
|
||||
|
||||
const currentSubscription = await this.planSuscriptions.findOne({
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
|
||||
const currentPlan = currentSubscription
|
||||
? await PlansList.plans.findOne({ _id: String(currentSubscription.planId) })
|
||||
: null;
|
||||
|
||||
const isUpgradePayment =
|
||||
currentSubscription &&
|
||||
currentPlan &&
|
||||
String(currentSubscription.planId) !== String(data.planId) &&
|
||||
currentPlan.price > 0 &&
|
||||
plan.price > currentPlan.price &&
|
||||
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
|
||||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
|
||||
dayjs(currentSubscription.endDate).isAfter(preferenceDate);
|
||||
|
||||
const remainingDays = isUpgradePayment
|
||||
? Math.max(0, Math.ceil((new Date(currentSubscription.endDate).getTime() - preferenceDate.toDate().getTime()) / DAY_MS))
|
||||
: 0;
|
||||
const proratedAmount = isUpgradePayment
|
||||
? calculateProratedUpgradeAmount(currentPlan.price, plan.price, remainingDays)
|
||||
: 0;
|
||||
|
||||
if (isUpgradePayment && remainingDays <= 0) {
|
||||
throw new Error("No se puede actualizar el plan porque la suscripción actual no tiene días restantes.");
|
||||
}
|
||||
|
||||
if (
|
||||
currentSubscription?.pendingPaymentPreferenceId &&
|
||||
currentSubscription.pendingPaymentInitPoint
|
||||
) {
|
||||
if (String(currentSubscription.pendingPaymentPlanId || currentSubscription.planId) === String(data.planId)) {
|
||||
return currentSubscription;
|
||||
}
|
||||
|
||||
throw new Error("Ya tenés un pago pendiente para tu plan. Verificalo o esperá a que se actualice antes de intentar otro cambio.");
|
||||
}
|
||||
|
||||
if (
|
||||
currentSubscription &&
|
||||
String(currentSubscription.planId) === String(data.planId) &&
|
||||
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
|
||||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
|
||||
currentSubscription.pendingPaymentPreferenceId &&
|
||||
currentSubscription.pendingPaymentInitPoint
|
||||
) {
|
||||
return currentSubscription;
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
"https://api.mercadopago.com/checkout/preferences",
|
||||
{
|
||||
items: [
|
||||
{
|
||||
title: "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)",
|
||||
title: isUpgradePayment
|
||||
? "TurnosXpress.com.ar - Upgrade al plan " + plan.name
|
||||
: "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)",
|
||||
quantity: 1,
|
||||
currency_id: "ARS",
|
||||
unit_price: finalPrice
|
||||
unit_price: isUpgradePayment ? proratedAmount : finalPrice
|
||||
}
|
||||
],
|
||||
back_urls: {
|
||||
success: process.env.MP_BACK_URL,
|
||||
failure: process.env.MP_BACK_URL,
|
||||
pending: process.env.MP_BACK_URL
|
||||
success: this.buildMercadoPagoBackUrl("success"),
|
||||
failure: this.buildMercadoPagoBackUrl("failure"),
|
||||
pending: this.buildMercadoPagoBackUrl("pending")
|
||||
},
|
||||
auto_return: "approved",
|
||||
payer: {
|
||||
@@ -270,20 +369,102 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
|
||||
const subscriptionData = response.data;
|
||||
|
||||
this.planSuscriptions.delete({ sessionUser: data.sessionUser });
|
||||
if (isUpgradePayment && currentSubscription && currentPlan) {
|
||||
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
|
||||
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
|
||||
currentSubscription.pendingPaymentBillingMonths = undefined;
|
||||
currentSubscription.pendingPaymentType = "upgrade";
|
||||
currentSubscription.pendingPaymentPlanId = data.planId;
|
||||
currentSubscription.pendingPaymentCurrentPlanPrice = currentPlan.price;
|
||||
currentSubscription.pendingPaymentRequestedPlanPrice = plan.price;
|
||||
currentSubscription.pendingPaymentProratedAmount = proratedAmount;
|
||||
currentSubscription.pendingPaymentRemainingDays = remainingDays;
|
||||
currentSubscription.pendingPaymentPeriodEndDate = currentSubscription.endDate;
|
||||
currentSubscription.lastPaymentStatus = undefined;
|
||||
currentSubscription.lastPaymentPreferenceId = undefined;
|
||||
currentSubscription.lastPaymentAt = undefined;
|
||||
currentSubscription.lastPaymentType = undefined;
|
||||
currentSubscription.downgradedFromPlanId = undefined;
|
||||
currentSubscription.downgradedFromPlanName = undefined;
|
||||
currentSubscription.downgradedFromPlanCode = undefined;
|
||||
currentSubscription.downgradedAt = undefined;
|
||||
currentSubscription.downgradeReason = undefined;
|
||||
currentSubscription.mpInitPoint = subscriptionData.init_point;
|
||||
await currentSubscription.save();
|
||||
|
||||
await NotificationsManager.sendSystemNotification({
|
||||
userId: String(data.sessionUser),
|
||||
subject: "Se ha creado una actualización de suscripción",
|
||||
message: `Se ha creado una actualización al plan ${plan.name}.`,
|
||||
type: NotificationType.BILLING,
|
||||
code: String(currentSubscription._id)
|
||||
});
|
||||
|
||||
return currentSubscription;
|
||||
}
|
||||
|
||||
if (
|
||||
currentSubscription &&
|
||||
String(currentSubscription.planId) === String(data.planId) &&
|
||||
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
|
||||
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED)
|
||||
) {
|
||||
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
|
||||
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
|
||||
currentSubscription.pendingPaymentBillingMonths = data.months;
|
||||
currentSubscription.pendingPaymentType = "extension";
|
||||
currentSubscription.lastPaymentStatus = undefined;
|
||||
currentSubscription.lastPaymentPreferenceId = undefined;
|
||||
currentSubscription.lastPaymentAt = undefined;
|
||||
currentSubscription.lastPaymentType = undefined;
|
||||
currentSubscription.downgradedFromPlanId = undefined;
|
||||
currentSubscription.downgradedFromPlanName = undefined;
|
||||
currentSubscription.downgradedFromPlanCode = undefined;
|
||||
currentSubscription.downgradedAt = undefined;
|
||||
currentSubscription.downgradeReason = undefined;
|
||||
currentSubscription.mpInitPoint = subscriptionData.init_point;
|
||||
await currentSubscription.save();
|
||||
|
||||
await NotificationsManager.sendSystemNotification({
|
||||
userId: String(data.sessionUser),
|
||||
subject: "Se ha creado una nueva extensión de suscripción",
|
||||
message: `Se ha creado una nueva extensión para el plan ${plan.name}.`,
|
||||
type: NotificationType.BILLING,
|
||||
code: String(currentSubscription._id)
|
||||
});
|
||||
|
||||
return currentSubscription;
|
||||
}
|
||||
|
||||
await this.planSuscriptions.delete({ sessionUser: data.sessionUser });
|
||||
|
||||
const subscription = await this.planSuscriptions.create({
|
||||
userId: data.sessionUser,
|
||||
planId: data.planId,
|
||||
mpPlanId: data.planId,
|
||||
startDate: subscriptionStartDate.toDate(),
|
||||
endDate: subscriptionStartDate.clone().add(data.months, "months").toDate(),
|
||||
startDate: preferenceDate.toDate(),
|
||||
endDate: preferenceDate.toDate(),
|
||||
isActive: false,
|
||||
autoRenew: false,
|
||||
mpPayerEmail: sessionUser.mpPayerEmail,
|
||||
mpStatus: MP_SUBS_STATUS.PENDING,
|
||||
mpDateCreated: new Date(),
|
||||
mpInitPoint: subscriptionData.init_point,
|
||||
mpPreferenceId: subscriptionData.id,
|
||||
billingMonths: data.months,
|
||||
pendingPaymentInitPoint: "",
|
||||
pendingPaymentPreferenceId: "",
|
||||
pendingPaymentBillingMonths: undefined,
|
||||
pendingPaymentType: undefined,
|
||||
lastPaymentStatus: undefined,
|
||||
lastPaymentPreferenceId: undefined,
|
||||
lastPaymentAt: undefined,
|
||||
lastPaymentType: undefined,
|
||||
downgradedFromPlanId: undefined,
|
||||
downgradedFromPlanName: undefined,
|
||||
downgradedFromPlanCode: undefined,
|
||||
downgradedAt: undefined,
|
||||
downgradeReason: undefined,
|
||||
});
|
||||
|
||||
await NotificationsManager.sendSystemNotification({
|
||||
@@ -330,18 +511,29 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
// We just cancel it locally if not paid, or change to free plan if paid.
|
||||
}
|
||||
|
||||
const dateNow = dayjs(new Date()).endOf("day");
|
||||
const dateEnd = dayjs(subscription.endDate);
|
||||
const dateNow = new Date();
|
||||
const dateEnd = new Date(subscription.endDate);
|
||||
|
||||
if (dateNow.isSameOrAfter(dateEnd)) {
|
||||
this.toFreePlan({
|
||||
if (dateNow > dateEnd) {
|
||||
await this.toFreePlan({
|
||||
sessionUser: data.sessionUser,
|
||||
downgradeReason: "expired",
|
||||
});
|
||||
} else {
|
||||
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
||||
subscription.mpInitPoint = "";
|
||||
subscription.mpPayerEmail = "";
|
||||
subscription.mpPayerId = -1;
|
||||
subscription.pendingPaymentInitPoint = "";
|
||||
subscription.pendingPaymentPreferenceId = "";
|
||||
subscription.pendingPaymentBillingMonths = undefined;
|
||||
subscription.pendingPaymentType = undefined;
|
||||
subscription.pendingPaymentPlanId = undefined;
|
||||
subscription.pendingPaymentCurrentPlanPrice = undefined;
|
||||
subscription.pendingPaymentRequestedPlanPrice = undefined;
|
||||
subscription.pendingPaymentProratedAmount = undefined;
|
||||
subscription.pendingPaymentRemainingDays = undefined;
|
||||
subscription.pendingPaymentPeriodEndDate = undefined;
|
||||
await subscription.save();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -400,12 +592,43 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
|
||||
//console.log("guardando el plan gratuito...");
|
||||
//try {
|
||||
const downgradedFromPlan = data.downgradeReason === "expired"
|
||||
? await PlansList.plans.findOne({ _id: String(subscription.planId) })
|
||||
: null;
|
||||
|
||||
console.log("cambiando al free plan:", freePlan.id);
|
||||
subscription.planId = freePlan.id;
|
||||
subscription.mpStatus = "";
|
||||
subscription.isActive = true;
|
||||
subscription.autoRenew = false;
|
||||
subscription.mpInitPoint = "";
|
||||
subscription.mpPreferenceId = "";
|
||||
subscription.billingMonths = undefined;
|
||||
subscription.pendingPaymentInitPoint = "";
|
||||
subscription.pendingPaymentPreferenceId = "";
|
||||
subscription.pendingPaymentBillingMonths = undefined;
|
||||
subscription.pendingPaymentType = undefined;
|
||||
subscription.pendingPaymentPlanId = undefined;
|
||||
subscription.pendingPaymentCurrentPlanPrice = undefined;
|
||||
subscription.pendingPaymentRequestedPlanPrice = undefined;
|
||||
subscription.pendingPaymentProratedAmount = undefined;
|
||||
subscription.pendingPaymentRemainingDays = undefined;
|
||||
subscription.pendingPaymentPeriodEndDate = undefined;
|
||||
subscription.startDate = new Date();
|
||||
subscription.endDate = dayjs(new Date()).add(10, "years").toDate();
|
||||
if (data.downgradeReason === "expired" && downgradedFromPlan) {
|
||||
subscription.downgradedFromPlanId = String(downgradedFromPlan.id || downgradedFromPlan._id);
|
||||
subscription.downgradedFromPlanName = downgradedFromPlan.name;
|
||||
subscription.downgradedFromPlanCode = downgradedFromPlan.code;
|
||||
subscription.downgradedAt = new Date();
|
||||
subscription.downgradeReason = "expired";
|
||||
} else {
|
||||
subscription.downgradedFromPlanId = undefined;
|
||||
subscription.downgradedFromPlanName = undefined;
|
||||
subscription.downgradedFromPlanCode = undefined;
|
||||
subscription.downgradedAt = undefined;
|
||||
subscription.downgradeReason = undefined;
|
||||
}
|
||||
|
||||
await subscription.save();
|
||||
// console.log("plan gratuito guardado:", freePlan.id);
|
||||
@@ -430,6 +653,15 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
autoRenew: subscription.autoRenew,
|
||||
mpStatus: subscription.mpStatus,
|
||||
mpDateCreated: subscription.mpDateCreated,
|
||||
lastPaymentStatus: subscription.lastPaymentStatus,
|
||||
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
|
||||
lastPaymentAt: subscription.lastPaymentAt,
|
||||
lastPaymentType: subscription.lastPaymentType,
|
||||
downgradedFromPlanId: subscription.downgradedFromPlanId,
|
||||
downgradedFromPlanName: subscription.downgradedFromPlanName,
|
||||
downgradedFromPlanCode: subscription.downgradedFromPlanCode,
|
||||
downgradedAt: subscription.downgradedAt,
|
||||
downgradeReason: subscription.downgradeReason,
|
||||
};
|
||||
|
||||
io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo);
|
||||
@@ -446,15 +678,14 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateNow = dayjs(new Date()).endOf("day");
|
||||
const dateEnd = dayjs(subscription.endDate);
|
||||
const dateNow = new Date();
|
||||
const dateEnd = new Date(subscription.endDate);
|
||||
|
||||
if (dateNow.isSameOrAfter(dateEnd)) {
|
||||
if (subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) {
|
||||
this.toFreePlan({
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
}
|
||||
if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
|
||||
await this.toFreePlan({
|
||||
sessionUser: data.sessionUser,
|
||||
downgradeReason: "expired",
|
||||
});
|
||||
}
|
||||
|
||||
return await this.planSuscriptions.findOne(data);
|
||||
@@ -469,26 +700,30 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateNow = dayjs(new Date()).endOf("day");
|
||||
const dateEnd = dayjs(subscription.endDate);
|
||||
const dateNow = new Date();
|
||||
const dateEnd = new Date(subscription.endDate);
|
||||
|
||||
if (dateNow.isSameOrAfter(dateEnd)) {
|
||||
//Esta condicion era la culpable de que todo el tiempo se queria aplicar el free plan.
|
||||
//Ya que la fecha de finalizacion queda guardada en la base de datos a pesar de que ya no se use.
|
||||
if (
|
||||
subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED &&
|
||||
subscription.mpStatus !== ""
|
||||
) {
|
||||
this.toFreePlan({
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
}
|
||||
if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
|
||||
await this.toFreePlan({
|
||||
sessionUser: data.sessionUser,
|
||||
downgradeReason: "expired",
|
||||
});
|
||||
return this.getSubscriptionByUser(data);
|
||||
}
|
||||
|
||||
const plan = await PlansList.plans.findOne({
|
||||
let plan = await PlansList.findOne({
|
||||
_id: subscription.planId,
|
||||
});
|
||||
|
||||
const isPendingPaidSubscription = subscription.mpStatus === MP_SUBS_STATUS.PENDING && plan.price > 0;
|
||||
|
||||
if (!isPendingPaidSubscription && (!subscription.isActive || subscription.mpStatus === MP_SUBS_STATUS.PENDING)) {
|
||||
const freePlan = await PlansList.findOne({ price: 0 });
|
||||
if (freePlan) {
|
||||
plan = freePlan;
|
||||
}
|
||||
}
|
||||
|
||||
if (!plan) {
|
||||
throw new Error("No se ha encontrado el plan");
|
||||
}
|
||||
@@ -502,6 +737,18 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
autoRenew: subscription.autoRenew,
|
||||
mpStatus: subscription.mpStatus,
|
||||
mpDateCreated: subscription.mpDateCreated,
|
||||
pendingPaymentInitPoint: subscription.pendingPaymentInitPoint,
|
||||
pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId,
|
||||
pendingPaymentType: subscription.pendingPaymentType,
|
||||
lastPaymentStatus: subscription.lastPaymentStatus,
|
||||
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
|
||||
lastPaymentAt: subscription.lastPaymentAt,
|
||||
lastPaymentType: subscription.lastPaymentType,
|
||||
downgradedFromPlanId: subscription.downgradedFromPlanId,
|
||||
downgradedFromPlanName: subscription.downgradedFromPlanName,
|
||||
downgradedFromPlanCode: subscription.downgradedFromPlanCode,
|
||||
downgradedAt: subscription.downgradedAt,
|
||||
downgradeReason: subscription.downgradeReason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -514,6 +761,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
}
|
||||
const plan = subscriptionInfo.plan;
|
||||
|
||||
if (!subscriptionInfo.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!plan) {
|
||||
throw new Error("No se encontro el plan de la suscripcion");
|
||||
}
|
||||
@@ -553,7 +804,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||
if (!subscriptionInfo) {
|
||||
throw new Error("No se encontro una suscripcion activa");
|
||||
}
|
||||
return subscriptionInfo.isActive;
|
||||
return subscriptionInfo.isActive && subscriptionInfo.mpStatus !== MP_SUBS_STATUS.PENDING;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import dayjs from "dayjs";
|
||||
import { calculateProratedUpgradeAmount } from "../PlanSubscriptions.pricing";
|
||||
import PlanSubscriptionsList from "../PlanSubscriptons";
|
||||
import { MP_SUBS_STATUS } from "../PlanSubscriptions.Adapter.Mongoose";
|
||||
import PlansList from "../../Plans/Plans";
|
||||
|
||||
jest.mock("../../../index", () => ({
|
||||
io: {
|
||||
to: jest.fn(() => ({ emit: jest.fn() })),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("PlanSubscriptions Logic", () => {
|
||||
it("should calculate correct endDate for 1, 3, 6, 12 months", () => {
|
||||
@@ -40,4 +50,71 @@ describe("PlanSubscriptions Logic", () => {
|
||||
const final12 = total12 - (total12 * (30 / 100)); // 12000 - 3600 = 8400
|
||||
expect(final12).toBe(8400);
|
||||
});
|
||||
|
||||
it("should calculate only the prorated difference for paid plan upgrades", () => {
|
||||
const currentPlanPrice = 1000;
|
||||
const requestedPlanPrice = 2500;
|
||||
const remainingDays = 15;
|
||||
|
||||
const proratedAmount = calculateProratedUpgradeAmount(
|
||||
currentPlanPrice,
|
||||
requestedPlanPrice,
|
||||
remainingDays
|
||||
);
|
||||
|
||||
expect(proratedAmount).toBe(750);
|
||||
});
|
||||
|
||||
it("should keep pending paid subscription plan instead of masking it as free", async () => {
|
||||
const paidPlan = {
|
||||
id: "paid-plan-id",
|
||||
name: "Intermedio",
|
||||
description: "",
|
||||
features: [],
|
||||
code: "intermediate",
|
||||
price: 1000,
|
||||
annualPrice: 0,
|
||||
limitOrganizations: 1,
|
||||
limitEmployees: 1,
|
||||
limitServices: 1,
|
||||
limitAppointments: 1,
|
||||
limitClients: 1,
|
||||
limitRepeats: 1,
|
||||
mailNotifications: false,
|
||||
smsNotifications: false,
|
||||
wapNotifications: false,
|
||||
bot: false,
|
||||
active: true,
|
||||
dateLimit: false,
|
||||
payments: false,
|
||||
};
|
||||
const subscription = {
|
||||
id: "subscription-id",
|
||||
planId: paidPlan.id,
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
isActive: false,
|
||||
autoRenew: false,
|
||||
mpStatus: MP_SUBS_STATUS.PENDING,
|
||||
mpDateCreated: new Date(),
|
||||
};
|
||||
|
||||
const originalPlanSuscriptions = PlanSubscriptionsList.planSuscriptions;
|
||||
const findOneSpy = jest.spyOn(PlansList, "findOne").mockResolvedValue(paidPlan);
|
||||
PlanSubscriptionsList.planSuscriptions = {
|
||||
findOne: jest.fn().mockResolvedValue(subscription),
|
||||
} as any;
|
||||
|
||||
try {
|
||||
const result = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: "user-id" });
|
||||
|
||||
expect(result?.plan).toEqual(paidPlan);
|
||||
expect(result?.mpStatus).toBe(MP_SUBS_STATUS.PENDING);
|
||||
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
||||
expect(findOneSpy).toHaveBeenCalledWith({ _id: subscription.planId });
|
||||
} finally {
|
||||
PlanSubscriptionsList.planSuscriptions = originalPlanSuscriptions;
|
||||
findOneSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Document, Model, Schema, model } from "mongoose";
|
||||
import {
|
||||
CreatePlanUsageCycleParams,
|
||||
FindPlanUsageCycleParams,
|
||||
IPlanUsageCycle,
|
||||
IPlanUsageCycleAdapter,
|
||||
UpdatePlanUsageCycleParams,
|
||||
} from "./PlanUsageCycle.Interface";
|
||||
|
||||
export interface IPlanUsageCycleDocument extends Omit<IPlanUsageCycle, "id">, Document {}
|
||||
|
||||
export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
|
||||
schema: Schema;
|
||||
planUsageCycleList: Model<IPlanUsageCycleDocument>;
|
||||
|
||||
constructor() {
|
||||
this.schema = new Schema({
|
||||
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
|
||||
subscriptionId: { type: Schema.Types.ObjectId, required: false, ref: "PlanSuscription" },
|
||||
planId: { type: Schema.Types.ObjectId, required: true, ref: "Plan" },
|
||||
cycleStart: { type: Date, required: true },
|
||||
cycleEnd: { type: Date, required: true },
|
||||
appointmentsCount: { type: Number, required: true, default: 0 },
|
||||
creationDate: { type: Date, required: true, default: Date.now },
|
||||
updateDate: { type: Date, required: true, default: Date.now },
|
||||
});
|
||||
|
||||
this.schema.index({ userId: 1, subscriptionId: 1, planId: 1, cycleStart: 1 }, { unique: true });
|
||||
|
||||
this.planUsageCycleList = model<IPlanUsageCycleDocument>("PlanUsageCycle", this.schema);
|
||||
}
|
||||
|
||||
public async findOne(filters: FindPlanUsageCycleParams): Promise<IPlanUsageCycleDocument | null> {
|
||||
return await this.planUsageCycleList.findOne(filters);
|
||||
}
|
||||
|
||||
public async create(data: CreatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
|
||||
return await this.planUsageCycleList.create({
|
||||
...data,
|
||||
appointmentsCount: data.appointmentsCount || 0,
|
||||
updateDate: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
public async getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
|
||||
const now = new Date();
|
||||
return await this.planUsageCycleList.findOneAndUpdate(
|
||||
{
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
},
|
||||
{
|
||||
$setOnInsert: {
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
cycleEnd: data.cycleEnd,
|
||||
creationDate: now,
|
||||
},
|
||||
$set: { updateDate: now },
|
||||
},
|
||||
{ new: true, upsert: true }
|
||||
);
|
||||
}
|
||||
|
||||
public async incrementAppointments(
|
||||
data: UpdatePlanUsageCycleParams & { quantity: number }
|
||||
): Promise<IPlanUsageCycleDocument | null> {
|
||||
const now = new Date();
|
||||
return await this.planUsageCycleList.findOneAndUpdate(
|
||||
{
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
},
|
||||
{
|
||||
$setOnInsert: {
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
cycleEnd: data.cycleEnd,
|
||||
creationDate: now,
|
||||
},
|
||||
$inc: { appointmentsCount: data.quantity },
|
||||
$set: { updateDate: now },
|
||||
},
|
||||
{ new: true, upsert: true }
|
||||
);
|
||||
}
|
||||
|
||||
public async incrementAppointmentsIfWithinLimit(
|
||||
data: UpdatePlanUsageCycleParams & { quantity: number; limit: number }
|
||||
): Promise<IPlanUsageCycleDocument | null> {
|
||||
const now = new Date();
|
||||
return await this.planUsageCycleList.findOneAndUpdate(
|
||||
{
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
appointmentsCount: { $lte: data.limit - data.quantity },
|
||||
},
|
||||
{
|
||||
$setOnInsert: {
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
cycleEnd: data.cycleEnd,
|
||||
appointmentsCount: 0,
|
||||
creationDate: now,
|
||||
},
|
||||
$inc: { appointmentsCount: data.quantity },
|
||||
$set: { updateDate: now },
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
}
|
||||
|
||||
public async setAppointmentsCount(
|
||||
data: UpdatePlanUsageCycleParams & { appointmentsCount: number }
|
||||
): Promise<IPlanUsageCycleDocument | null> {
|
||||
const now = new Date();
|
||||
return await this.planUsageCycleList.findOneAndUpdate(
|
||||
{
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
},
|
||||
{
|
||||
$setOnInsert: {
|
||||
userId: data.userId,
|
||||
subscriptionId: data.subscriptionId,
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
cycleEnd: data.cycleEnd,
|
||||
creationDate: now,
|
||||
},
|
||||
$set: {
|
||||
appointmentsCount: Math.max(data.appointmentsCount, 0),
|
||||
updateDate: now,
|
||||
},
|
||||
},
|
||||
{ new: true, upsert: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { IPlanUsageCycleDocument } from "./PlanUsageCycle.Adapter.Mongoose";
|
||||
|
||||
export interface IPlanUsageCycle {
|
||||
id?: string;
|
||||
userId: string;
|
||||
subscriptionId?: string;
|
||||
planId: string;
|
||||
cycleStart: Date;
|
||||
cycleEnd: Date;
|
||||
appointmentsCount: number;
|
||||
creationDate: Date;
|
||||
updateDate: Date;
|
||||
}
|
||||
|
||||
export type FindPlanUsageCycleParams = {
|
||||
userId?: string;
|
||||
subscriptionId?: string;
|
||||
planId?: string;
|
||||
cycleStart?: Date;
|
||||
cycleEnd?: Date;
|
||||
};
|
||||
|
||||
export type CreatePlanUsageCycleParams = {
|
||||
userId: string;
|
||||
subscriptionId?: string;
|
||||
planId: string;
|
||||
cycleStart: Date;
|
||||
cycleEnd: Date;
|
||||
appointmentsCount?: number;
|
||||
};
|
||||
|
||||
export type CurrentPlanUsageCycleParams = {
|
||||
userId: string;
|
||||
now?: Date;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type SetPlanUsageCycleAppointmentsParams = CurrentPlanUsageCycleParams & {
|
||||
appointmentsCount: number;
|
||||
};
|
||||
|
||||
export type SysAdminRecalculatePlanUsageCycleParams = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type SysAdminRecalculatePlanUsageCycleResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
planId: string;
|
||||
cycleStart: Date;
|
||||
cycleEnd: Date;
|
||||
organizationsCount: number;
|
||||
employeesCount: number;
|
||||
servicesCount: number;
|
||||
clientsCount: number;
|
||||
repeatsCount: number;
|
||||
appointmentsCount: number;
|
||||
};
|
||||
|
||||
export type UpdatePlanUsageCycleParams = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
planId: string;
|
||||
cycleStart: Date;
|
||||
cycleEnd: Date;
|
||||
};
|
||||
|
||||
export type CalculateCurrentCycleParams = {
|
||||
subscriptionStartDate: Date;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
export type PlanUsageCycleRange = {
|
||||
cycleStart: Date;
|
||||
cycleEnd: Date;
|
||||
};
|
||||
|
||||
export interface IPlanUsageCycleAdapter {
|
||||
findOne(filters: FindPlanUsageCycleParams): Promise<IPlanUsageCycleDocument | null>;
|
||||
create(data: CreatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
|
||||
getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
|
||||
incrementAppointments(data: UpdatePlanUsageCycleParams & { quantity: number }): Promise<IPlanUsageCycleDocument | null>;
|
||||
incrementAppointmentsIfWithinLimit(
|
||||
data: UpdatePlanUsageCycleParams & { quantity: number; limit: number }
|
||||
): Promise<IPlanUsageCycleDocument | null>;
|
||||
setAppointmentsCount(
|
||||
data: UpdatePlanUsageCycleParams & { appointmentsCount: number }
|
||||
): Promise<IPlanUsageCycleDocument | null>;
|
||||
}
|
||||
|
||||
export interface IPlanUsageCycleManager {
|
||||
planUsageCycle: IPlanUsageCycleAdapter;
|
||||
getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
|
||||
addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void>;
|
||||
reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<boolean>;
|
||||
releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void>;
|
||||
getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise<number>;
|
||||
setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise<IPlanUsageCycleDocument | null>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import dayjs from "dayjs";
|
||||
import { CalculateCurrentCycleParams, PlanUsageCycleRange } from "./PlanUsageCycle.Interface";
|
||||
|
||||
export function calculateCurrentPlanUsageCycle(data: CalculateCurrentCycleParams): PlanUsageCycleRange {
|
||||
const now = dayjs(data.now || new Date());
|
||||
const anchor = dayjs(data.subscriptionStartDate);
|
||||
|
||||
let cycleStart = anchor.clone();
|
||||
let cycleEnd = cycleStart.clone().add(1, "month");
|
||||
|
||||
while (!cycleEnd.isAfter(now)) {
|
||||
cycleStart = cycleEnd;
|
||||
cycleEnd = cycleStart.clone().add(1, "month");
|
||||
}
|
||||
|
||||
return {
|
||||
cycleStart: cycleStart.toDate(),
|
||||
cycleEnd: cycleEnd.toDate(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { isNull } from "../../helpers/IsNull";
|
||||
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
|
||||
import {
|
||||
CurrentPlanUsageCycleParams,
|
||||
IPlanUsageCycleManager,
|
||||
SetPlanUsageCycleAppointmentsParams,
|
||||
UpdatePlanUsageCycleParams,
|
||||
} from "./PlanUsageCycle.Interface";
|
||||
import {
|
||||
IPlanUsageCycleDocument,
|
||||
PlanUsageCycleAdapterMongoose,
|
||||
} from "./PlanUsageCycle.Adapter.Mongoose";
|
||||
import { calculateCurrentPlanUsageCycle } from "./PlanUsageCycle.helpers";
|
||||
|
||||
export { calculateCurrentPlanUsageCycle } from "./PlanUsageCycle.helpers";
|
||||
|
||||
class PlanUsageCycleManager implements IPlanUsageCycleManager {
|
||||
planUsageCycle: PlanUsageCycleAdapterMongoose;
|
||||
|
||||
constructor() {
|
||||
this.planUsageCycle = new PlanUsageCycleAdapterMongoose();
|
||||
}
|
||||
|
||||
private async getCurrentCycleData(data: CurrentPlanUsageCycleParams): Promise<UpdatePlanUsageCycleParams> {
|
||||
const subscription = await PlanSubscriptionsList.findOne({
|
||||
sessionUser: data.userId,
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error("No se ha encontrado una suscripción activa");
|
||||
}
|
||||
|
||||
const { cycleStart, cycleEnd } = calculateCurrentPlanUsageCycle({
|
||||
subscriptionStartDate: subscription.startDate,
|
||||
now: data.now,
|
||||
});
|
||||
|
||||
return {
|
||||
userId: data.userId,
|
||||
subscriptionId: String(subscription.id),
|
||||
planId: String(subscription.planId),
|
||||
cycleStart,
|
||||
cycleEnd,
|
||||
};
|
||||
}
|
||||
|
||||
public async getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
|
||||
return await this.planUsageCycle.getOrCreateCurrent(await this.getCurrentCycleData(data));
|
||||
}
|
||||
|
||||
public async addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void> {
|
||||
await this.planUsageCycle.incrementAppointments({
|
||||
...(await this.getCurrentCycleData(data)),
|
||||
quantity: isNull<number>(data.quantity, 1),
|
||||
});
|
||||
}
|
||||
|
||||
public async reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<boolean> {
|
||||
if (typeof data.limit !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const quantity = isNull<number>(data.quantity, 1);
|
||||
const usageCycleData = await this.getCurrentCycleData(data);
|
||||
const usageCycle = await this.planUsageCycle.getOrCreateCurrent(usageCycleData);
|
||||
|
||||
if (usageCycle.appointmentsCount + quantity > data.limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updatedCycle = await this.planUsageCycle.incrementAppointmentsIfWithinLimit({
|
||||
...usageCycleData,
|
||||
quantity,
|
||||
limit: data.limit,
|
||||
});
|
||||
|
||||
return !!updatedCycle;
|
||||
}
|
||||
|
||||
public async releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void> {
|
||||
await this.addAppointment({
|
||||
...data,
|
||||
quantity: -Math.abs(isNull<number>(data.quantity, 1)),
|
||||
});
|
||||
}
|
||||
|
||||
public async getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise<number> {
|
||||
const usageCycle = await this.getCurrentCycle(data);
|
||||
return usageCycle.appointmentsCount;
|
||||
}
|
||||
|
||||
public async setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise<IPlanUsageCycleDocument | null> {
|
||||
return await this.planUsageCycle.setAppointmentsCount({
|
||||
...(await this.getCurrentCycleData(data)),
|
||||
appointmentsCount: data.appointmentsCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const PlanUsageCycleList = new PlanUsageCycleManager();
|
||||
|
||||
export default PlanUsageCycleList;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FindPlansParams, IPlansAdapter, IPlan } from "./Plans.interface";
|
||||
import { FindPlansParams, IPlansAdapter, IPlan, SysAdminUpdatePlanParams } from "./Plans.interface";
|
||||
import { Document, Model, Schema, model } from "mongoose";
|
||||
|
||||
export interface IPlanDocument extends Omit<IPlan, "id">, Document {}
|
||||
@@ -45,4 +45,8 @@ export class PlansAdapterMongoose implements IPlansAdapter {
|
||||
public async findOne(filters: FindPlansParams): Promise<IPlanDocument | null> {
|
||||
return this.planList.findOne(filters).exec();
|
||||
}
|
||||
|
||||
public async updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void> {
|
||||
await this.planList.updateOne({ _id: planId }, { $set: data }).exec();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,33 @@ export type FindPlansParams = {
|
||||
price?: number;
|
||||
};
|
||||
|
||||
export type SysAdminUpdatePlanParams = {
|
||||
planId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
features?: string[];
|
||||
code?: string;
|
||||
price?: number;
|
||||
annualPrice?: number;
|
||||
limitOrganizations?: number;
|
||||
limitEmployees?: number;
|
||||
limitServices?: number;
|
||||
limitAppointments?: number;
|
||||
limitClients?: number;
|
||||
limitRepeats?: number;
|
||||
mailNotifications?: boolean;
|
||||
smsNotifications?: boolean;
|
||||
wapNotifications?: boolean;
|
||||
payments?: boolean;
|
||||
bot?: boolean;
|
||||
active?: boolean;
|
||||
dateLimit?: boolean;
|
||||
discount3Months?: number;
|
||||
discount6Months?: number;
|
||||
discount12Months?: number;
|
||||
featured?: boolean;
|
||||
};
|
||||
|
||||
export interface IPlan {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -44,10 +71,12 @@ export interface IPlan {
|
||||
export interface IPlansAdapter {
|
||||
find(filters: FindPlansParams): Promise<IPlan[]>;
|
||||
findOne(filters: FindPlansParams): Promise<IPlan | null>;
|
||||
updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IPlansManager {
|
||||
plans: IPlansAdapter;
|
||||
find(data: FindPlansParams): Promise<IPlan[]>;
|
||||
findOne(data: FindPlansParams): Promise<IPlan>;
|
||||
sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isNull } from "../../helpers/IsNull";
|
||||
import { PlansAdapterMongoose } from "./Plans.Adapter.Mongoose";
|
||||
import { FindPlansParams, IPlan, IPlansManager } from "./Plans.interface";
|
||||
import { FindPlansParams, IPlan, IPlansManager, SysAdminUpdatePlanParams } from "./Plans.interface";
|
||||
|
||||
class PlansManager implements IPlansManager {
|
||||
plans: PlansAdapterMongoose;
|
||||
@@ -59,6 +59,12 @@ class PlansManager implements IPlansManager {
|
||||
|
||||
return this.getOnePlan(plan);
|
||||
}
|
||||
|
||||
public async sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise<void> {
|
||||
const { planId, ...planData } = data;
|
||||
await this.findOne({ _id: planId });
|
||||
await this.plans.updateOne(planId, planData);
|
||||
}
|
||||
}
|
||||
|
||||
const PlansList = new PlansManager();
|
||||
|
||||
@@ -4,6 +4,8 @@ export type ScheduleItem = {
|
||||
from: string;
|
||||
to: string;
|
||||
disabled: boolean;
|
||||
serviceScope?: "all" | "specific";
|
||||
serviceIds?: string[];
|
||||
};
|
||||
|
||||
export type FindSchedulesParams = {
|
||||
@@ -19,6 +21,7 @@ export type AvailableSchedulesParams = {
|
||||
weekDay: number;
|
||||
from: string;
|
||||
to: string;
|
||||
serviceId?: string;
|
||||
};
|
||||
|
||||
export type PaginateSchedulesParams = FindSchedulesParams & {
|
||||
|
||||
@@ -173,6 +173,14 @@ class SchedulesManager implements ISchedulesManager {
|
||||
return await this.schedules.find(filters);
|
||||
}
|
||||
|
||||
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
|
||||
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
|
||||
}
|
||||
|
||||
private async getScheduleByFrom(data: AvailableSchedulesParams): Promise<ScheduleItem | null> {
|
||||
const schedule = await this.schedules.findOne({
|
||||
employeeId: data.employeeId,
|
||||
@@ -185,6 +193,9 @@ class SchedulesManager implements ISchedulesManager {
|
||||
if (horario.disabled) {
|
||||
continue;
|
||||
}
|
||||
if (!this.isScheduleAvailableForService(horario, data.serviceId)) {
|
||||
continue;
|
||||
}
|
||||
if (horario.from == data.from) {
|
||||
return horario;
|
||||
}
|
||||
@@ -236,6 +247,7 @@ class SchedulesManager implements ISchedulesManager {
|
||||
weekDay: data.weekDay,
|
||||
from: scheduleFrom.to,
|
||||
to: data.to,
|
||||
serviceId: data.serviceId,
|
||||
});
|
||||
|
||||
if (!scheduleTo) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export type AvailableSchedulesOverridesParams = {
|
||||
dateDay: Date;
|
||||
from: string;
|
||||
to: string;
|
||||
serviceId?: string;
|
||||
};
|
||||
|
||||
export type PaginateSchedulesOverridesParams = FindSchedulesOverridesParams & {
|
||||
|
||||
@@ -154,6 +154,14 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
|
||||
return await this.schedulesOverrides.findOne(filters);
|
||||
}
|
||||
|
||||
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
|
||||
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
|
||||
}
|
||||
|
||||
private async getScheduleByFrom(data: AvailableSchedulesOverridesParams): Promise<ScheduleItem | null> {
|
||||
const exactDate = dayjs(data.dateDay).startOf("day").toDate();
|
||||
const schedule = await this.schedulesOverrides.findOne({
|
||||
@@ -165,6 +173,7 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
|
||||
if (schedule) {
|
||||
for (const horario of schedule.schedules) {
|
||||
if (horario.disabled) continue;
|
||||
if (!this.isScheduleAvailableForService(horario, data.serviceId)) continue;
|
||||
if (horario.from == data.from) return horario;
|
||||
if (horario.from < data.from && horario.to > data.from) return horario;
|
||||
}
|
||||
|
||||
@@ -205,6 +205,73 @@ export type SysAdminOrganizationsStatusParams = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type SysAdminUserSubscriptionDetailsParams = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type SysAdminExtendUserSubscriptionMode = "add_months" | "set_end_date";
|
||||
|
||||
export type SysAdminExtendUserSubscriptionParams = {
|
||||
userId: string;
|
||||
mode: SysAdminExtendUserSubscriptionMode;
|
||||
months?: 1 | 2;
|
||||
endDate?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type SysAdminExtendUserSubscriptionResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
previousEndDate: Date;
|
||||
newEndDate: Date;
|
||||
paymentId: string;
|
||||
transactionId?: string;
|
||||
};
|
||||
|
||||
export type SysAdminFinalizeUserSubscriptionParams = {
|
||||
userId: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type SysAdminFinalizeUserSubscriptionResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
previousEndDate: Date;
|
||||
newEndDate: Date;
|
||||
};
|
||||
|
||||
export type SysAdminUserSubscriptionPayment = {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
amount: number;
|
||||
paymentDate: Date;
|
||||
paymentMethod?: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
};
|
||||
|
||||
export type SysAdminUserSubscriptionDetailsResult = {
|
||||
userId: string;
|
||||
currentSubscription: {
|
||||
id: string;
|
||||
plan: {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
price: number;
|
||||
} | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isActive: boolean;
|
||||
mpStatus?: string;
|
||||
billingMonths?: number;
|
||||
pendingPaymentType?: "extension" | "upgrade";
|
||||
pendingPaymentPreferenceId?: string;
|
||||
lastPaymentStatus?: string;
|
||||
} | null;
|
||||
payments: SysAdminUserSubscriptionPayment[];
|
||||
};
|
||||
|
||||
export type SysAdminUserOrganizationsResult = {
|
||||
clientIn: { companyId: string; companyName: string }[];
|
||||
collaboratorIn: { companyId: string; companyName: string }[];
|
||||
|
||||
@@ -197,7 +197,7 @@ class UsersManager implements IUsersManager {
|
||||
|
||||
if (sub) {
|
||||
if (sub.plan.price > 0) {
|
||||
throw new Error("Para darte de baja primero tenes que cancelar tu suscripción.");
|
||||
throw new Error("No podés darte de baja mientras tengas un plan pago vigente. Podés eliminar tus organizaciones para borrar su contenido y seguir usando la cuenta hasta el vencimiento.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ class UsersManager implements IUsersManager {
|
||||
userId: data.sessionUser,
|
||||
});
|
||||
|
||||
//Eliminar los clientes creados con el usuario.
|
||||
//Desvincular los clientes creados con el usuario sin borrar el historial de la organizacion.
|
||||
const clients = await ClientsList.clients.find({
|
||||
userId: data.sessionUser,
|
||||
});
|
||||
@@ -294,21 +294,7 @@ class UsersManager implements IUsersManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
//Tengo que pasarlo como sesionUSer al duenio de la organizacion. para que pueda eliminar.
|
||||
const orgClient = await CompaniesList.companies.findOne({
|
||||
_id: String(client.companyId),
|
||||
});
|
||||
|
||||
if (!orgClient) {
|
||||
continue;
|
||||
}
|
||||
|
||||
//En este caso borra el cliente pero no chequea el saldo de la cuenta.
|
||||
await ClientsList.deleteClient({
|
||||
clientId: String(client.id),
|
||||
checkCashOnAccount: false,
|
||||
sessionUser: String(orgClient.ownerId),
|
||||
});
|
||||
await ClientsList.clients.detachUser({ clientId: String(client.id) });
|
||||
}
|
||||
|
||||
//Eliminar las conversaciones
|
||||
@@ -630,7 +616,7 @@ class UsersManager implements IUsersManager {
|
||||
|
||||
// Generate a JWT token using the payload and a secret word
|
||||
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
|
||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
|
||||
});
|
||||
|
||||
// Create a user object by merging the payload and the token
|
||||
@@ -684,7 +670,7 @@ class UsersManager implements IUsersManager {
|
||||
// If the token expires in less than 5 minutes, generate a new one.
|
||||
if (decodedPayload.exp < nowInSeconds + fiveMinutesInSeconds) {
|
||||
finalToken = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
|
||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -765,7 +751,7 @@ class UsersManager implements IUsersManager {
|
||||
|
||||
// Generate a JWT token using the payload and a secret word
|
||||
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
|
||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
|
||||
});
|
||||
|
||||
// Create a user object by merging the payload and the token
|
||||
@@ -810,7 +796,7 @@ class UsersManager implements IUsersManager {
|
||||
|
||||
// Generate a JWT token using the payload and a secret word
|
||||
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
|
||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
|
||||
});
|
||||
|
||||
// Create a user object by merging the payload and the token
|
||||
@@ -862,7 +848,7 @@ class UsersManager implements IUsersManager {
|
||||
|
||||
// Generate a JWT token using the payload and a secret word
|
||||
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
|
||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
|
||||
});
|
||||
|
||||
// Create a user object by merging the payload and the token
|
||||
@@ -907,7 +893,7 @@ class UsersManager implements IUsersManager {
|
||||
|
||||
// Generate a JWT token using the payload and a secret word
|
||||
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
|
||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
|
||||
});
|
||||
|
||||
// Create a user object by merging the payload and the token
|
||||
@@ -1279,14 +1265,7 @@ class UsersManager implements IUsersManager {
|
||||
for (const client of clients) {
|
||||
if (!client.id) continue;
|
||||
|
||||
const orgClient = await CompaniesList.companies.findOne({ _id: String(client.companyId) });
|
||||
if (!orgClient) continue;
|
||||
|
||||
await ClientsList.deleteClient({
|
||||
clientId: String(client.id),
|
||||
checkCashOnAccount: false,
|
||||
sessionUser: String(orgClient.ownerId),
|
||||
});
|
||||
await ClientsList.clients.detachUser({ clientId: String(client.id) });
|
||||
}
|
||||
|
||||
await MessageConversationList.deleteConversationsByUser({ userId: data.userId });
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
PaginatedAppointmentEventByClientResult,
|
||||
} from "../../Models/Appointments/Appointments.Interface";
|
||||
import { connect } from "mongoose";
|
||||
import { validatePermissionsByCompany } from "../../helpers/check";
|
||||
|
||||
export class AppointmentService {
|
||||
public async createAppointment(data: CreateAppointmentParams): Promise<IAppointment> {
|
||||
@@ -63,6 +64,15 @@ export class AppointmentService {
|
||||
): Promise<AppointmentAdminByDateView> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
|
||||
if (!data.companyId || !data.sessionUser) {
|
||||
throw new Error("No se ha encontrado la organización o el usuario de sesión");
|
||||
}
|
||||
|
||||
await validatePermissionsByCompany({
|
||||
companyId: data.companyId,
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
|
||||
const appointments = await AppointmentList.findAppointmentsAdminByDate(data);
|
||||
|
||||
return appointments;
|
||||
|
||||
@@ -10,6 +10,7 @@ import CashFlowList from "../../Models/CashFlow/CashFlow";
|
||||
import CompaniesList from "../../Models/Companies/Companies";
|
||||
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
|
||||
import MetricsList from "../../Models/Metrics/Metrics";
|
||||
import PlanUsageCycleList from "../../Models/PlanUsageCycle/PlanUsageCycle";
|
||||
|
||||
export class DashboardService {
|
||||
public async getDashboardSummary(sessionUser: string): Promise<DashboardSummaryResult> {
|
||||
@@ -198,11 +199,18 @@ export class DashboardService {
|
||||
const isOwner = String(ownerId) === String(params.sessionUser);
|
||||
|
||||
// Fetch metrics for owner
|
||||
const metrics = await MetricsList.getMetrics(String(ownerId));
|
||||
const metricsDocument = await MetricsList.getMetrics(String(ownerId));
|
||||
const metrics = metricsDocument.toObject ? metricsDocument.toObject() : { ...metricsDocument };
|
||||
|
||||
// Fetch subscription for owner
|
||||
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: String(ownerId) });
|
||||
|
||||
if (subscription) {
|
||||
metrics.appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({
|
||||
userId: String(ownerId),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
metrics,
|
||||
subscription,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
UpdateEmployeeRolesParams,
|
||||
UpdateEmployeeParams,
|
||||
IncompleteCollaboratorView,
|
||||
RemoveCollaboratorParams,
|
||||
} from "../../Models/Employees/Employees.Interface";
|
||||
import { authenticateMiddleware } from "../../middleware/authentication";
|
||||
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
|
||||
@@ -448,3 +449,27 @@ export class GetIncompleteSetupController extends Controller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Route("employees/remove-collaborator")
|
||||
@Middlewares(authenticateMiddleware)
|
||||
export class RemoveCollaboratorController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "El colaborador fue removido con éxito")
|
||||
@Post()
|
||||
public async removeCollaborator(
|
||||
@Body() requestBody: RemoveCollaboratorParams
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new EmployeesService().removeCollaborator(requestBody);
|
||||
this.setStatus(200);
|
||||
return {
|
||||
success: true,
|
||||
message: "Done",
|
||||
};
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
UpdateEmployeeRolesParams,
|
||||
UpdateEmployeeParams,
|
||||
IncompleteCollaboratorView,
|
||||
RemoveCollaboratorParams,
|
||||
} from "../../Models/Employees/Employees.Interface";
|
||||
import {
|
||||
CreateEmployeeServiceParams,
|
||||
@@ -162,4 +163,9 @@ export class EmployeesService {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
await EmployeesList.update(data);
|
||||
}
|
||||
|
||||
public async removeCollaborator(data: RemoveCollaboratorParams): Promise<void> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
await EmployeesList.removeCollaboratorFromCompany(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,43 @@ import axios from "axios";
|
||||
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
|
||||
import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
|
||||
import { connect } from "mongoose";
|
||||
import dayjs from "dayjs";
|
||||
import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments";
|
||||
import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
|
||||
import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
|
||||
|
||||
type MercadoPagoPaymentData = {
|
||||
id: string | number;
|
||||
status: string;
|
||||
external_reference?: string;
|
||||
preference_id?: string;
|
||||
order?: { id?: string };
|
||||
date_approved?: string;
|
||||
payer?: { id?: number; email?: string };
|
||||
transaction_amount?: number;
|
||||
payment_method_id?: string;
|
||||
payment_type_id?: string;
|
||||
date_last_updated?: string;
|
||||
};
|
||||
|
||||
type MercadoPagoMerchantOrder = {
|
||||
id: string | number;
|
||||
preference_id?: string;
|
||||
external_reference?: string;
|
||||
payments?: MercadoPagoPaymentData[];
|
||||
};
|
||||
|
||||
type MercadoPagoPaymentContext = {
|
||||
preferenceId?: string;
|
||||
externalReference?: string;
|
||||
};
|
||||
|
||||
type MercadoPagoPaymentVerification = {
|
||||
status: "approved" | "rejected" | "failed" | "cancelled" | "pending";
|
||||
payment: MercadoPagoPaymentData | null;
|
||||
};
|
||||
|
||||
const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"];
|
||||
|
||||
export class MercadoPagoWebhookService {
|
||||
public async handleWebhook(body: any): Promise<void> {
|
||||
@@ -24,31 +61,20 @@ export class MercadoPagoWebhookService {
|
||||
}
|
||||
);
|
||||
|
||||
const paymentData = paymentResponse.data;
|
||||
const paymentData: MercadoPagoPaymentData = paymentResponse.data;
|
||||
const status = paymentData.status; // approved, pending, rejected, etc.
|
||||
const userId = paymentData.external_reference; // We passed this when creating preference
|
||||
|
||||
if (userId && status === "approved") {
|
||||
// Update subscription status in DB
|
||||
const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
|
||||
sessionUser: userId,
|
||||
});
|
||||
const paymentContext = await this.resolvePaymentContext(paymentData);
|
||||
const userId = paymentContext.externalReference || paymentData.external_reference; // We passed this when creating preference
|
||||
const preferenceId = paymentContext.preferenceId || paymentData.preference_id;
|
||||
const subscription = await this.findSubscriptionForPayment(userId, preferenceId);
|
||||
|
||||
if (userId && preferenceId && status === "approved") {
|
||||
if (subscription) {
|
||||
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED;
|
||||
subscription.isActive = true;
|
||||
// Assuming endDate is correctly set for the months purchased when preference was created
|
||||
await subscription.save();
|
||||
await this.applyApprovedPayment(subscription, paymentData, userId, preferenceId);
|
||||
}
|
||||
} else if (userId && status === "rejected") {
|
||||
const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
|
||||
sessionUser: userId,
|
||||
});
|
||||
|
||||
if (subscription && subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED) {
|
||||
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
||||
subscription.isActive = false;
|
||||
await subscription.save();
|
||||
} else if (userId && preferenceId && REJECTED_PAYMENT_STATUSES.includes(status)) {
|
||||
if (subscription) {
|
||||
await this.applyRejectedPayment(subscription, paymentData, userId, preferenceId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -56,4 +82,320 @@ export class MercadoPagoWebhookService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async verifyPendingPlanPayment(sessionUser: string): Promise<VerifyPendingPlanPaymentResponse> {
|
||||
const pendingSubscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
|
||||
userId: sessionUser,
|
||||
mpPreferenceId: { $exists: true, $ne: "" },
|
||||
mpStatus: MP_SUBS_STATUS.PENDING,
|
||||
}).exec();
|
||||
|
||||
const pendingExistingSubscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
|
||||
userId: sessionUser,
|
||||
pendingPaymentPreferenceId: { $exists: true, $ne: "" },
|
||||
mpStatus: MP_SUBS_STATUS.AUTHORIZED,
|
||||
}).exec();
|
||||
|
||||
const subscription = pendingSubscription || pendingExistingSubscription;
|
||||
|
||||
if (!subscription) {
|
||||
return {
|
||||
status: "not_found",
|
||||
approved: false,
|
||||
message: "No se encontró un pago pendiente para verificar.",
|
||||
};
|
||||
}
|
||||
|
||||
const preferenceId = pendingSubscription
|
||||
? subscription.mpPreferenceId
|
||||
: subscription.pendingPaymentPreferenceId;
|
||||
|
||||
if (!preferenceId) {
|
||||
return {
|
||||
status: "not_found",
|
||||
approved: false,
|
||||
message: "No se encontró una preferencia de pago pendiente para verificar.",
|
||||
subscriptionId: String(subscription._id),
|
||||
};
|
||||
}
|
||||
|
||||
const paymentVerification = await this.findPaymentForPreference(sessionUser, preferenceId);
|
||||
|
||||
if (!paymentVerification.payment || paymentVerification.status === "pending") {
|
||||
return {
|
||||
status: "pending",
|
||||
approved: false,
|
||||
message: "El pago todavía no figura confirmado.",
|
||||
subscriptionId: String(subscription._id),
|
||||
paymentType: this.getPaymentType(subscription, preferenceId),
|
||||
};
|
||||
}
|
||||
|
||||
const paymentType = this.getPaymentType(subscription, preferenceId);
|
||||
const payment = paymentVerification.payment;
|
||||
|
||||
if (paymentVerification.status !== "approved") {
|
||||
await this.applyRejectedPayment(subscription, payment, sessionUser, preferenceId);
|
||||
|
||||
return {
|
||||
status: paymentVerification.status,
|
||||
approved: false,
|
||||
message: "El pago fue rechazado. Tu plan actual no cambió y podés intentarlo nuevamente.",
|
||||
subscriptionId: String(subscription._id),
|
||||
paymentId: String(payment.id),
|
||||
paymentType,
|
||||
};
|
||||
}
|
||||
|
||||
await this.applyApprovedPayment(subscription, payment, sessionUser, preferenceId);
|
||||
|
||||
return {
|
||||
status: "approved",
|
||||
approved: true,
|
||||
message: "El pago fue aprobado y la suscripción fue actualizada.",
|
||||
subscriptionId: String(subscription._id),
|
||||
paymentId: String(payment.id),
|
||||
paymentType,
|
||||
};
|
||||
}
|
||||
|
||||
private async findPaymentForPreference(sessionUser: string, preferenceId: string): Promise<MercadoPagoPaymentVerification> {
|
||||
const merchantOrders = await this.searchMerchantOrdersByPreferenceId(preferenceId);
|
||||
const merchantOrder = merchantOrders.find((order) => {
|
||||
return !order.external_reference || order.external_reference === sessionUser;
|
||||
});
|
||||
const merchantOrderPayment = this.pickMostRelevantPayment(merchantOrder?.payments || []);
|
||||
|
||||
if (merchantOrderPayment) {
|
||||
const payment = await this.getPaymentById(merchantOrderPayment.id);
|
||||
return {
|
||||
status: this.normalizePaymentStatus(payment.status),
|
||||
payment,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await axios.get("https://api.mercadopago.com/v1/payments/search", {
|
||||
headers: this.getMercadoPagoHeaders(),
|
||||
params: {
|
||||
external_reference: sessionUser,
|
||||
sort: "date_created",
|
||||
criteria: "desc",
|
||||
limit: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const payments: MercadoPagoPaymentData[] = response.data?.results || [];
|
||||
const payment = this.pickMostRelevantPayment(payments.filter((payment) => payment.preference_id === preferenceId));
|
||||
|
||||
if (!payment) {
|
||||
return { status: "pending", payment: null };
|
||||
}
|
||||
|
||||
const fullPayment = await this.getPaymentById(payment.id);
|
||||
return {
|
||||
status: this.normalizePaymentStatus(fullPayment.status),
|
||||
payment: fullPayment,
|
||||
};
|
||||
}
|
||||
|
||||
private pickMostRelevantPayment(payments: MercadoPagoPaymentData[]): MercadoPagoPaymentData | null {
|
||||
return payments.find((payment) => payment.status === "approved")
|
||||
|| payments.find((payment) => REJECTED_PAYMENT_STATUSES.includes(payment.status))
|
||||
|| payments[0]
|
||||
|| null;
|
||||
}
|
||||
|
||||
private normalizePaymentStatus(status: string): MercadoPagoPaymentVerification["status"] {
|
||||
if (status === "approved") return "approved";
|
||||
if (status === "cancelled") return "cancelled";
|
||||
if (status === "failed") return "failed";
|
||||
if (status === "rejected") return "rejected";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
private async resolvePaymentContext(paymentData: MercadoPagoPaymentData): Promise<MercadoPagoPaymentContext> {
|
||||
if (paymentData.preference_id) {
|
||||
return {
|
||||
preferenceId: paymentData.preference_id,
|
||||
externalReference: paymentData.external_reference,
|
||||
};
|
||||
}
|
||||
|
||||
if (!paymentData.order?.id) {
|
||||
return {
|
||||
externalReference: paymentData.external_reference,
|
||||
};
|
||||
}
|
||||
|
||||
const merchantOrder = await this.getMerchantOrderById(paymentData.order.id);
|
||||
|
||||
return {
|
||||
preferenceId: merchantOrder?.preference_id,
|
||||
externalReference: merchantOrder?.external_reference || paymentData.external_reference,
|
||||
};
|
||||
}
|
||||
|
||||
private async searchMerchantOrdersByPreferenceId(preferenceId: string): Promise<MercadoPagoMerchantOrder[]> {
|
||||
const response = await axios.get("https://api.mercadopago.com/merchant_orders/search", {
|
||||
headers: this.getMercadoPagoHeaders(),
|
||||
params: { preference_id: preferenceId },
|
||||
});
|
||||
|
||||
return response.data?.elements || [];
|
||||
}
|
||||
|
||||
private async getMerchantOrderById(merchantOrderId: string): Promise<MercadoPagoMerchantOrder | null> {
|
||||
const response = await axios.get(`https://api.mercadopago.com/merchant_orders/${merchantOrderId}`, {
|
||||
headers: this.getMercadoPagoHeaders(),
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
}
|
||||
|
||||
private async getPaymentById(paymentId: string | number): Promise<MercadoPagoPaymentData> {
|
||||
const response = await axios.get(`https://api.mercadopago.com/v1/payments/${paymentId}`, {
|
||||
headers: this.getMercadoPagoHeaders(),
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private getMercadoPagoHeaders(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
private async findSubscriptionForPayment(userId?: string, preferenceId?: string): Promise<IPlanSuscriptionDocument | null> {
|
||||
if (!userId || !preferenceId) return null;
|
||||
|
||||
let subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
|
||||
sessionUser: userId,
|
||||
mpPreferenceId: preferenceId,
|
||||
mpStatus: MP_SUBS_STATUS.PENDING,
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
subscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
|
||||
userId,
|
||||
pendingPaymentPreferenceId: preferenceId,
|
||||
mpStatus: MP_SUBS_STATUS.AUTHORIZED,
|
||||
}).exec();
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
private async applyApprovedPayment(
|
||||
subscription: IPlanSuscriptionDocument,
|
||||
paymentData: MercadoPagoPaymentData,
|
||||
userId: string,
|
||||
preferenceId: string
|
||||
): Promise<void> {
|
||||
const approvedAt = paymentData.date_approved ? new Date(paymentData.date_approved) : new Date();
|
||||
const billingMonths = subscription.pendingPaymentBillingMonths || subscription.billingMonths || 1;
|
||||
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
|
||||
const isUpgradePayment = isExtensionPayment && subscription.pendingPaymentType === "upgrade";
|
||||
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
|
||||
? subscription.endDate
|
||||
: approvedAt;
|
||||
|
||||
if (isUpgradePayment && subscription.pendingPaymentPlanId) {
|
||||
subscription.planId = subscription.pendingPaymentPlanId;
|
||||
}
|
||||
|
||||
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED;
|
||||
subscription.isActive = true;
|
||||
subscription.autoRenew = false;
|
||||
subscription.startDate = isExtensionPayment ? subscription.startDate : approvedAt;
|
||||
subscription.endDate = isUpgradePayment
|
||||
? subscription.endDate
|
||||
: dayjs(periodStart).add(billingMonths, "months").toDate();
|
||||
subscription.mpPayerId = paymentData.payer?.id || subscription.mpPayerId;
|
||||
subscription.mpPayerEmail = paymentData.payer?.email || subscription.mpPayerEmail;
|
||||
subscription.pendingPaymentInitPoint = "";
|
||||
subscription.pendingPaymentPreferenceId = "";
|
||||
subscription.pendingPaymentBillingMonths = undefined;
|
||||
subscription.pendingPaymentType = undefined;
|
||||
subscription.pendingPaymentPlanId = undefined;
|
||||
subscription.pendingPaymentCurrentPlanPrice = undefined;
|
||||
subscription.pendingPaymentRequestedPlanPrice = undefined;
|
||||
subscription.pendingPaymentProratedAmount = undefined;
|
||||
subscription.pendingPaymentRemainingDays = undefined;
|
||||
subscription.pendingPaymentPeriodEndDate = undefined;
|
||||
subscription.lastPaymentStatus = undefined;
|
||||
subscription.lastPaymentPreferenceId = undefined;
|
||||
subscription.lastPaymentAt = undefined;
|
||||
subscription.lastPaymentType = undefined;
|
||||
subscription.downgradedFromPlanId = undefined;
|
||||
subscription.downgradedFromPlanName = undefined;
|
||||
subscription.downgradedFromPlanCode = undefined;
|
||||
subscription.downgradedAt = undefined;
|
||||
subscription.downgradeReason = undefined;
|
||||
await subscription.save();
|
||||
|
||||
await PlanPaymentsList.createIfMissingByTransactionId({
|
||||
userId,
|
||||
subscriptionId: String(subscription._id),
|
||||
amount: paymentData.transaction_amount || 0,
|
||||
paymentDate: approvedAt,
|
||||
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
|
||||
status: "completed",
|
||||
transactionId: String(paymentData.id),
|
||||
});
|
||||
}
|
||||
|
||||
private async applyRejectedPayment(
|
||||
subscription: IPlanSuscriptionDocument,
|
||||
paymentData: MercadoPagoPaymentData,
|
||||
userId: string,
|
||||
preferenceId: string
|
||||
): Promise<void> {
|
||||
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
|
||||
const paymentStatus = this.normalizePaymentStatus(paymentData.status);
|
||||
const paymentType = this.getPaymentType(subscription, preferenceId);
|
||||
|
||||
if (isExtensionPayment) {
|
||||
subscription.pendingPaymentInitPoint = "";
|
||||
subscription.pendingPaymentPreferenceId = "";
|
||||
subscription.pendingPaymentBillingMonths = undefined;
|
||||
subscription.pendingPaymentType = undefined;
|
||||
subscription.pendingPaymentPlanId = undefined;
|
||||
subscription.pendingPaymentCurrentPlanPrice = undefined;
|
||||
subscription.pendingPaymentRequestedPlanPrice = undefined;
|
||||
subscription.pendingPaymentProratedAmount = undefined;
|
||||
subscription.pendingPaymentRemainingDays = undefined;
|
||||
subscription.pendingPaymentPeriodEndDate = undefined;
|
||||
} else {
|
||||
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
||||
subscription.isActive = false;
|
||||
}
|
||||
|
||||
if (paymentStatus !== "pending" && paymentStatus !== "approved") {
|
||||
subscription.lastPaymentStatus = paymentStatus;
|
||||
subscription.lastPaymentPreferenceId = preferenceId;
|
||||
subscription.lastPaymentAt = paymentData.date_last_updated ? new Date(paymentData.date_last_updated) : new Date();
|
||||
subscription.lastPaymentType = paymentType;
|
||||
}
|
||||
await subscription.save();
|
||||
|
||||
await PlanPaymentsList.createIfMissingByTransactionId({
|
||||
userId,
|
||||
subscriptionId: String(subscription._id),
|
||||
amount: paymentData.transaction_amount || 0,
|
||||
paymentDate: new Date(),
|
||||
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
|
||||
status: "failed",
|
||||
transactionId: String(paymentData.id),
|
||||
});
|
||||
}
|
||||
|
||||
private getPaymentType(subscription: IPlanSuscriptionDocument, preferenceId: string): "new" | "extension" | "upgrade" {
|
||||
if (subscription.pendingPaymentPreferenceId === preferenceId) {
|
||||
return subscription.pendingPaymentType === "upgrade" ? "upgrade" : "extension";
|
||||
}
|
||||
|
||||
return "new";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MercadoPagoWebhookService } from "../MercadoPagoWebhook.Service";
|
||||
import PlanPaymentsList from "../../../Models/PlanPayments/PlanPayments";
|
||||
|
||||
jest.mock("../../../Models/PlanPayments/PlanPayments", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
createIfMissingByTransactionId: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../Models/PlanSubscriptions/PlanSubscriptons", () => ({
|
||||
__esModule: true,
|
||||
default: {},
|
||||
}));
|
||||
|
||||
describe("MercadoPagoWebhookService", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should clear pending upgrade metadata without changing the current plan or dates when payment is rejected", async () => {
|
||||
const startDate = new Date("2026-07-01T00:00:00.000Z");
|
||||
const endDate = new Date("2026-08-01T00:00:00.000Z");
|
||||
const subscription = {
|
||||
_id: "subscription-id",
|
||||
planId: "current-plan-id",
|
||||
startDate,
|
||||
endDate,
|
||||
pendingPaymentInitPoint: "https://checkout.example",
|
||||
pendingPaymentPreferenceId: "preference-id",
|
||||
pendingPaymentBillingMonths: undefined,
|
||||
pendingPaymentType: "upgrade",
|
||||
pendingPaymentPlanId: "requested-plan-id",
|
||||
pendingPaymentCurrentPlanPrice: 1000,
|
||||
pendingPaymentRequestedPlanPrice: 2000,
|
||||
pendingPaymentProratedAmount: 500,
|
||||
pendingPaymentRemainingDays: 15,
|
||||
pendingPaymentPeriodEndDate: endDate,
|
||||
save: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new MercadoPagoWebhookService() as any;
|
||||
|
||||
await service.applyRejectedPayment(
|
||||
subscription,
|
||||
{
|
||||
id: "payment-id",
|
||||
status: "rejected",
|
||||
transaction_amount: 500,
|
||||
payment_method_id: "visa",
|
||||
},
|
||||
"user-id",
|
||||
"preference-id"
|
||||
);
|
||||
|
||||
expect(subscription.planId).toBe("current-plan-id");
|
||||
expect(subscription.startDate).toBe(startDate);
|
||||
expect(subscription.endDate).toBe(endDate);
|
||||
expect(subscription.pendingPaymentInitPoint).toBe("");
|
||||
expect(subscription.pendingPaymentPreferenceId).toBe("");
|
||||
expect(subscription.pendingPaymentType).toBeUndefined();
|
||||
expect(subscription.pendingPaymentPlanId).toBeUndefined();
|
||||
expect(subscription.pendingPaymentProratedAmount).toBeUndefined();
|
||||
expect(subscription.save).toHaveBeenCalledTimes(1);
|
||||
expect(PlanPaymentsList.createIfMissingByTransactionId).toHaveBeenCalledWith({
|
||||
userId: "user-id",
|
||||
subscriptionId: "subscription-id",
|
||||
amount: 500,
|
||||
paymentDate: expect.any(Date),
|
||||
paymentMethod: "visa",
|
||||
status: "failed",
|
||||
transactionId: "payment-id",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
GetSubscriptionInitPointParams,
|
||||
GetSuscriptionInitPointResponse,
|
||||
MPPreApprovalResponse,
|
||||
VerifyPendingPlanPaymentParams,
|
||||
VerifyPendingPlanPaymentResponse,
|
||||
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
|
||||
import { authenticateMiddleware } from "../../middleware/authentication";
|
||||
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
|
||||
import { rateLimiter } from "../../middleware/ratelimiter";
|
||||
|
||||
@Route("suscriptions/get-init-point")
|
||||
@Middlewares(authenticateMiddleware)
|
||||
@@ -75,3 +78,24 @@ export class PlanSubscriptionsCancellController extends Controller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Route("suscriptions/verify-pending-payment")
|
||||
@Middlewares([rateLimiter, authenticateMiddleware])
|
||||
export class PlanSubscriptionsVerifyPendingPaymentController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async verifyPendingPayment(
|
||||
@Body() requestBody: VerifyPendingPlanPaymentParams
|
||||
): Promise<VerifyPendingPlanPaymentResponse | ApiValidationError> {
|
||||
try {
|
||||
const result = await new PlanSubscriptionsService().verifyPendingPayment(requestBody);
|
||||
this.setStatus(200);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ import {
|
||||
GetSubscriptionInitPointParams,
|
||||
GetSuscriptionInitPointResponse,
|
||||
MPPreApprovalResponse,
|
||||
VerifyPendingPlanPaymentParams,
|
||||
VerifyPendingPlanPaymentResponse,
|
||||
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
|
||||
import { connect } from "mongoose";
|
||||
import { validateSessionUser } from "../../helpers/check";
|
||||
import { MercadoPagoWebhookService } from "../MercadoPago/MercadoPagoWebhook.Service";
|
||||
|
||||
export class PlanSubscriptionsService {
|
||||
public async getInitPoint(
|
||||
@@ -27,4 +31,10 @@ export class PlanSubscriptionsService {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
await PlanSubscriptionsList.cancell(data);
|
||||
}
|
||||
|
||||
public async verifyPendingPayment(data: VerifyPendingPlanPaymentParams): Promise<VerifyPendingPlanPaymentResponse> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
return new MercadoPagoWebhookService().verifyPendingPlanPayment(data.sessionUser);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
|
||||
import { ApiValidationError } from "../../../Models/Server.Error.model";
|
||||
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
|
||||
import {
|
||||
SysAdminRecalculatePlanUsageCycleParams,
|
||||
SysAdminRecalculatePlanUsageCycleResult,
|
||||
} from "../../../Models/PlanUsageCycle/PlanUsageCycle.Interface";
|
||||
import { SysAdminPlanUsageCycleService } from "./SysAdminPlanUsageCycle.Service";
|
||||
|
||||
@Route("sysadmin/plan-usage-cycle")
|
||||
@Middlewares(sysAdminAuthMiddleware)
|
||||
export class SysAdminPlanUsageCycleController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post("recalculate-user")
|
||||
public async recalculateUserUsageCycle(
|
||||
@Body() requestBody: SysAdminRecalculatePlanUsageCycleParams
|
||||
): Promise<SysAdminRecalculatePlanUsageCycleResult | ApiValidationError> {
|
||||
try {
|
||||
const result = await new SysAdminPlanUsageCycleService().recalculateUserUsageCycle(requestBody);
|
||||
this.setStatus(200);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { connect } from "mongoose";
|
||||
import dayjs from "dayjs";
|
||||
import AppointmentList from "../../../Models/Appointments/Appointments";
|
||||
import CompaniesList from "../../../Models/Companies/Companies";
|
||||
import MetricsList from "../../../Models/Metrics/Metrics";
|
||||
import PlanUsageCycleList from "../../../Models/PlanUsageCycle/PlanUsageCycle";
|
||||
import {
|
||||
SysAdminRecalculatePlanUsageCycleParams,
|
||||
SysAdminRecalculatePlanUsageCycleResult,
|
||||
} from "../../../Models/PlanUsageCycle/PlanUsageCycle.Interface";
|
||||
|
||||
export class SysAdminPlanUsageCycleService {
|
||||
public async recalculateUserUsageCycle(
|
||||
params: SysAdminRecalculatePlanUsageCycleParams
|
||||
): Promise<SysAdminRecalculatePlanUsageCycleResult> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
|
||||
const usageCycle = await PlanUsageCycleList.getCurrentCycle({ userId: params.userId });
|
||||
const companies = await CompaniesList.companies.find({ ownerId: params.userId });
|
||||
const companyIds = companies.map((company) => String(company.id));
|
||||
|
||||
const appointmentsCount = companyIds.length === 0
|
||||
? 0
|
||||
: await AppointmentList.Appointments.AppointmentList.countDocuments({
|
||||
companyId: { $in: companyIds },
|
||||
creationDate: {
|
||||
$gte: dayjs(usageCycle.cycleStart).toDate(),
|
||||
$lt: dayjs(usageCycle.cycleEnd).toDate(),
|
||||
},
|
||||
});
|
||||
|
||||
const updatedCycle = await PlanUsageCycleList.setAppointmentsCount({
|
||||
userId: params.userId,
|
||||
appointmentsCount,
|
||||
});
|
||||
|
||||
if (!updatedCycle) {
|
||||
throw new Error("No se pudo recalcular el uso del plan.");
|
||||
}
|
||||
|
||||
await MetricsList.calculateMetrics({ userId: params.userId });
|
||||
const metrics = await MetricsList.getMetrics(params.userId);
|
||||
|
||||
return {
|
||||
userId: params.userId,
|
||||
subscriptionId: String(updatedCycle.subscriptionId),
|
||||
planId: String(updatedCycle.planId),
|
||||
cycleStart: updatedCycle.cycleStart,
|
||||
cycleEnd: updatedCycle.cycleEnd,
|
||||
organizationsCount: metrics.organizationsCount,
|
||||
employeesCount: metrics.employeesCount,
|
||||
servicesCount: metrics.servicesCount,
|
||||
clientsCount: metrics.clientsCount,
|
||||
repeatsCount: metrics.repeatsCount || 0,
|
||||
appointmentsCount: updatedCycle.appointmentsCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
|
||||
|
||||
import { ApiValidationError } from "../../../Models/Server.Error.model";
|
||||
import { ApiVoidResult } from "../../../Models/Api.VoidResult.type";
|
||||
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
|
||||
import { IPlan, SysAdminUpdatePlanParams } from "../../../Models/Plans/Plans.interface";
|
||||
import { SysAdminPlansService } from "./SysAdminPlans.Service";
|
||||
|
||||
@Route("sysadmin/plans")
|
||||
@Middlewares(sysAdminAuthMiddleware)
|
||||
export class SysAdminPlansController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post("list")
|
||||
public async listPlans(): Promise<IPlan[] | ApiValidationError> {
|
||||
try {
|
||||
const plans = await new SysAdminPlansService().listPlans();
|
||||
this.setStatus(200);
|
||||
return plans;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "El plan fue modificado con éxito")
|
||||
@Post("update")
|
||||
public async updatePlan(@Body() requestBody: SysAdminUpdatePlanParams): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new SysAdminPlansService().updatePlan(requestBody);
|
||||
this.setStatus(200);
|
||||
return {
|
||||
success: true,
|
||||
message: "Done",
|
||||
};
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import PlansList from "../../../Models/Plans/Plans";
|
||||
import { IPlan, SysAdminUpdatePlanParams } from "../../../Models/Plans/Plans.interface";
|
||||
import { connect } from "mongoose";
|
||||
|
||||
export class SysAdminPlansService {
|
||||
public async listPlans(): Promise<IPlan[]> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
return await PlansList.find({});
|
||||
}
|
||||
|
||||
public async updatePlan(data: SysAdminUpdatePlanParams): Promise<void> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
await PlansList.sysAdminUpdatePlan(this.sanitizeUpdate(data));
|
||||
}
|
||||
|
||||
private sanitizeUpdate(data: SysAdminUpdatePlanParams): SysAdminUpdatePlanParams {
|
||||
if (!data.planId) {
|
||||
throw new Error("Plan id is required");
|
||||
}
|
||||
|
||||
const sanitized: SysAdminUpdatePlanParams = { planId: data.planId };
|
||||
const stringFields: (keyof SysAdminUpdatePlanParams)[] = ["name", "description", "code"];
|
||||
const nonNegativeNumberFields: (keyof SysAdminUpdatePlanParams)[] = [
|
||||
"price",
|
||||
"annualPrice",
|
||||
"discount3Months",
|
||||
"discount6Months",
|
||||
"discount12Months",
|
||||
];
|
||||
const limitFields: (keyof SysAdminUpdatePlanParams)[] = [
|
||||
"limitOrganizations",
|
||||
"limitEmployees",
|
||||
"limitServices",
|
||||
"limitAppointments",
|
||||
"limitClients",
|
||||
"limitRepeats",
|
||||
];
|
||||
const booleanFields: (keyof SysAdminUpdatePlanParams)[] = [
|
||||
"mailNotifications",
|
||||
"smsNotifications",
|
||||
"wapNotifications",
|
||||
"payments",
|
||||
"bot",
|
||||
"active",
|
||||
"dateLimit",
|
||||
"featured",
|
||||
];
|
||||
|
||||
for (const field of stringFields) {
|
||||
const value = data[field];
|
||||
if (typeof value === "string") {
|
||||
sanitized[field] = value.trim() as never;
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of nonNegativeNumberFields) {
|
||||
const value = data[field];
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
||||
sanitized[field] = value as never;
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of limitFields) {
|
||||
const value = data[field];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
sanitized[field] = value as never;
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of booleanFields) {
|
||||
const value = data[field];
|
||||
if (typeof value === "boolean") {
|
||||
sanitized[field] = value as never;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(data.features)) {
|
||||
sanitized.features = data.features.filter((feature) => typeof feature === "string").map((feature) => feature.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
|
||||
import { ApiValidationError } from "../../../Models/Server.Error.model";
|
||||
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
|
||||
import {
|
||||
SysAdminExtendUserSubscriptionParams,
|
||||
SysAdminExtendUserSubscriptionResult,
|
||||
SysAdminFinalizeUserSubscriptionParams,
|
||||
SysAdminFinalizeUserSubscriptionResult,
|
||||
SysAdminUserSubscriptionDetailsParams,
|
||||
SysAdminUserSubscriptionDetailsResult,
|
||||
} from "../../../Models/Users/Users.Interface";
|
||||
import { SysAdminSubscriptionsService } from "./SysAdminSubscriptions.Service";
|
||||
|
||||
@Route("sysadmin/subscriptions")
|
||||
@Middlewares(sysAdminAuthMiddleware)
|
||||
export class SysAdminSubscriptionsController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post("extend-user")
|
||||
public async extendUserSubscription(
|
||||
@Body() requestBody: SysAdminExtendUserSubscriptionParams
|
||||
): Promise<SysAdminExtendUserSubscriptionResult | ApiValidationError> {
|
||||
try {
|
||||
const result = await new SysAdminSubscriptionsService().extendUserSubscription(requestBody);
|
||||
this.setStatus(200);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post("finalize-user")
|
||||
public async finalizeUserSubscription(
|
||||
@Body() requestBody: SysAdminFinalizeUserSubscriptionParams
|
||||
): Promise<SysAdminFinalizeUserSubscriptionResult | ApiValidationError> {
|
||||
try {
|
||||
const result = await new SysAdminSubscriptionsService().finalizeUserSubscription(requestBody);
|
||||
this.setStatus(200);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post("user-details")
|
||||
public async getUserSubscriptionDetails(
|
||||
@Body() requestBody: SysAdminUserSubscriptionDetailsParams
|
||||
): Promise<SysAdminUserSubscriptionDetailsResult | ApiValidationError> {
|
||||
try {
|
||||
const result = await new SysAdminSubscriptionsService().getUserSubscriptionDetails(requestBody);
|
||||
this.setStatus(200);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { connect, isValidObjectId } from "mongoose";
|
||||
import dayjs from "dayjs";
|
||||
import PlanSubscriptionsList from "../../../Models/PlanSubscriptions/PlanSubscriptons";
|
||||
import PlanPaymentsList from "../../../Models/PlanPayments/PlanPayments";
|
||||
import PlansList from "../../../Models/Plans/Plans";
|
||||
import {
|
||||
SysAdminExtendUserSubscriptionParams,
|
||||
SysAdminExtendUserSubscriptionResult,
|
||||
SysAdminFinalizeUserSubscriptionParams,
|
||||
SysAdminFinalizeUserSubscriptionResult,
|
||||
SysAdminUserSubscriptionDetailsParams,
|
||||
SysAdminUserSubscriptionDetailsResult,
|
||||
} from "../../../Models/Users/Users.Interface";
|
||||
|
||||
export class SysAdminSubscriptionsService {
|
||||
public async extendUserSubscription(
|
||||
params: SysAdminExtendUserSubscriptionParams
|
||||
): Promise<SysAdminExtendUserSubscriptionResult> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
|
||||
if (!params.userId || !isValidObjectId(params.userId)) {
|
||||
throw new Error("User id is required");
|
||||
}
|
||||
|
||||
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
|
||||
if (!subscription) {
|
||||
throw new Error("No subscription found for user");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const previousEndDate = subscription.endDate ? new Date(subscription.endDate) : now;
|
||||
const baseDate = previousEndDate.getTime() > now.getTime() ? previousEndDate : now;
|
||||
let newEndDate: Date;
|
||||
|
||||
if (params.mode === "add_months") {
|
||||
if (params.months !== 1 && params.months !== 2) {
|
||||
throw new Error("Months must be 1 or 2");
|
||||
}
|
||||
newEndDate = dayjs(baseDate).add(params.months, "month").toDate();
|
||||
} else if (params.mode === "set_end_date") {
|
||||
if (!params.endDate) {
|
||||
throw new Error("End date is required");
|
||||
}
|
||||
const parsedEndDate = dayjs(params.endDate);
|
||||
if (!parsedEndDate.isValid()) {
|
||||
throw new Error("End date is invalid");
|
||||
}
|
||||
newEndDate = parsedEndDate.endOf("day").toDate();
|
||||
if (newEndDate.getTime() <= now.getTime()) {
|
||||
throw new Error("End date must be in the future");
|
||||
}
|
||||
if (newEndDate.getTime() <= baseDate.getTime()) {
|
||||
throw new Error("End date must be after the current effective subscription end date");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Invalid extension mode");
|
||||
}
|
||||
|
||||
subscription.endDate = newEndDate;
|
||||
subscription.isActive = true;
|
||||
if (subscription.mpStatus) {
|
||||
subscription.mpStatus = "authorized";
|
||||
}
|
||||
await subscription.save();
|
||||
|
||||
const transactionId = [
|
||||
"sysadmin_bonus",
|
||||
String(subscription.id),
|
||||
now.getTime(),
|
||||
].join(":");
|
||||
const payment = await PlanPaymentsList.createIfMissingByTransactionId({
|
||||
userId: params.userId,
|
||||
subscriptionId: String(subscription.id),
|
||||
amount: 0,
|
||||
paymentDate: now,
|
||||
paymentMethod: "sysadmin_bonus",
|
||||
status: "completed",
|
||||
transactionId,
|
||||
});
|
||||
|
||||
return {
|
||||
userId: params.userId,
|
||||
subscriptionId: String(subscription.id),
|
||||
previousEndDate,
|
||||
newEndDate,
|
||||
paymentId: String(payment.id),
|
||||
transactionId,
|
||||
};
|
||||
}
|
||||
|
||||
public async finalizeUserSubscription(
|
||||
params: SysAdminFinalizeUserSubscriptionParams
|
||||
): Promise<SysAdminFinalizeUserSubscriptionResult> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
|
||||
if (!params.userId || !isValidObjectId(params.userId)) {
|
||||
throw new Error("User id is required");
|
||||
}
|
||||
|
||||
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
|
||||
if (!subscription) {
|
||||
throw new Error("No subscription found for user");
|
||||
}
|
||||
|
||||
const previousEndDate = subscription.endDate ? new Date(subscription.endDate) : new Date();
|
||||
const newEndDate = dayjs().subtract(1, "day").startOf("day").toDate();
|
||||
const plan = await PlansList.plans.findOne({ _id: String(subscription.planId) });
|
||||
|
||||
subscription.endDate = newEndDate;
|
||||
await subscription.save();
|
||||
|
||||
if (plan && plan.price > 0) {
|
||||
await PlanSubscriptionsList.toFreePlan({
|
||||
sessionUser: params.userId,
|
||||
downgradeReason: "expired",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
userId: params.userId,
|
||||
subscriptionId: String(subscription.id),
|
||||
previousEndDate,
|
||||
newEndDate,
|
||||
};
|
||||
}
|
||||
|
||||
public async getUserSubscriptionDetails(
|
||||
params: SysAdminUserSubscriptionDetailsParams
|
||||
): Promise<SysAdminUserSubscriptionDetailsResult> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
|
||||
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
|
||||
const payments = await PlanPaymentsList.find({ userId: params.userId });
|
||||
|
||||
if (!subscription) {
|
||||
return {
|
||||
userId: params.userId,
|
||||
currentSubscription: null,
|
||||
payments: payments.map((payment) => ({
|
||||
id: String(payment.id),
|
||||
subscriptionId: String(payment.subscriptionId),
|
||||
amount: payment.amount,
|
||||
paymentDate: payment.paymentDate,
|
||||
paymentMethod: payment.paymentMethod,
|
||||
status: payment.status,
|
||||
transactionId: payment.transactionId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const plan = await PlansList.plans.findOne({ _id: String(subscription.planId) });
|
||||
|
||||
return {
|
||||
userId: params.userId,
|
||||
currentSubscription: {
|
||||
id: String(subscription.id),
|
||||
plan: plan
|
||||
? {
|
||||
id: String(plan.id),
|
||||
name: plan.name,
|
||||
code: plan.code,
|
||||
price: plan.price,
|
||||
}
|
||||
: null,
|
||||
startDate: subscription.startDate,
|
||||
endDate: subscription.endDate,
|
||||
isActive: subscription.isActive,
|
||||
mpStatus: subscription.mpStatus,
|
||||
billingMonths: subscription.billingMonths,
|
||||
pendingPaymentType: subscription.pendingPaymentType,
|
||||
pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId,
|
||||
lastPaymentStatus: subscription.lastPaymentStatus,
|
||||
},
|
||||
payments: payments.map((payment) => ({
|
||||
id: String(payment.id),
|
||||
subscriptionId: String(payment.subscriptionId),
|
||||
amount: payment.amount,
|
||||
paymentDate: payment.paymentDate,
|
||||
paymentMethod: payment.paymentMethod,
|
||||
status: payment.status,
|
||||
transactionId: payment.transactionId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export default function getAvatar(
|
||||
userId: string,
|
||||
userId: string | null | undefined,
|
||||
avatar: string | null | undefined,
|
||||
fallback: string
|
||||
): string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DashboardPage } from './pages/DashboardPage';
|
||||
import { UsersPage } from './pages/UsersPage';
|
||||
import { WapServersPage } from './pages/WapServersPage';
|
||||
import { CompaniesPage } from './pages/CompaniesPage';
|
||||
import { PlansPage } from './pages/PlansPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -13,6 +14,7 @@ function App() {
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="companies" element={<CompaniesPage />} />
|
||||
<Route path="plans" element={<PlansPage />} />
|
||||
<Route path="wapservers" element={<WapServersPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -33,6 +33,141 @@ export const getOrganizationsStatus = async (userId: string) => {
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export type RecalculatePlanUsageCycleResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
planId: string;
|
||||
cycleStart: string;
|
||||
cycleEnd: string;
|
||||
organizationsCount: number;
|
||||
employeesCount: number;
|
||||
servicesCount: number;
|
||||
clientsCount: number;
|
||||
repeatsCount: number;
|
||||
appointmentsCount: number;
|
||||
};
|
||||
|
||||
export const recalculatePlanUsageCycle = async (userId: string): Promise<RecalculatePlanUsageCycleResult> => {
|
||||
const res = await sysadminApi.post('/users/recalculate-plan-usage-cycle', { userId });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export type UserSubscriptionDetails = {
|
||||
userId: string;
|
||||
currentSubscription: {
|
||||
id: string;
|
||||
plan: {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
price: number;
|
||||
} | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
isActive: boolean;
|
||||
mpStatus?: string;
|
||||
billingMonths?: number;
|
||||
pendingPaymentType?: 'extension' | 'upgrade';
|
||||
pendingPaymentPreferenceId?: string;
|
||||
lastPaymentStatus?: string;
|
||||
} | null;
|
||||
payments: {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
amount: number;
|
||||
paymentDate: string;
|
||||
paymentMethod?: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const getUserSubscriptionDetails = async (userId: string): Promise<UserSubscriptionDetails> => {
|
||||
const res = await sysadminApi.post('/users/subscription-details', { userId });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export type ExtendUserSubscriptionPayload = {
|
||||
userId: string;
|
||||
mode: 'add_months' | 'set_end_date';
|
||||
months?: 1 | 2;
|
||||
endDate?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ExtendUserSubscriptionResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
previousEndDate: string;
|
||||
newEndDate: string;
|
||||
paymentId: string;
|
||||
transactionId?: string;
|
||||
};
|
||||
|
||||
export const extendUserSubscription = async (data: ExtendUserSubscriptionPayload): Promise<ExtendUserSubscriptionResult> => {
|
||||
const res = await sysadminApi.post('/users/extend-subscription', data);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export type FinalizeUserSubscriptionPayload = {
|
||||
userId: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type FinalizeUserSubscriptionResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
previousEndDate: string;
|
||||
newEndDate: string;
|
||||
};
|
||||
|
||||
export const finalizeUserSubscription = async (data: FinalizeUserSubscriptionPayload): Promise<FinalizeUserSubscriptionResult> => {
|
||||
const res = await sysadminApi.post('/users/finalize-subscription', data);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export type Plan = {
|
||||
id?: string;
|
||||
_id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
code: string;
|
||||
price: number;
|
||||
annualPrice: number;
|
||||
limitOrganizations: number;
|
||||
limitEmployees: number;
|
||||
limitServices: number;
|
||||
limitAppointments: number;
|
||||
limitClients: number;
|
||||
limitRepeats: number;
|
||||
mailNotifications: boolean;
|
||||
smsNotifications: boolean;
|
||||
wapNotifications: boolean;
|
||||
payments: boolean;
|
||||
bot: boolean;
|
||||
active: boolean;
|
||||
dateLimit: boolean;
|
||||
discount3Months?: number;
|
||||
discount6Months?: number;
|
||||
discount12Months?: number;
|
||||
featured?: boolean;
|
||||
};
|
||||
|
||||
export type UpdatePlanPayload = Partial<Omit<Plan, 'id' | '_id'>> & {
|
||||
planId: string;
|
||||
};
|
||||
|
||||
export const fetchPlans = async (): Promise<Plan[]> => {
|
||||
const res = await sysadminApi.post('/plans/list', {});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const updatePlan = async (data: UpdatePlanPayload) => {
|
||||
const res = await sysadminApi.post('/plans/update', data);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
// Wap Servers API Calls
|
||||
export const fetchWapServers = async (filters: any) => {
|
||||
const res = await sysadminApi.post('/wap/paginate', filters);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Outlet, NavLink } from 'react-router-dom';
|
||||
import { Users, LayoutDashboard, LogOut, Server, Building2 } from 'lucide-react';
|
||||
import { Users, LayoutDashboard, LogOut, Server, Building2, BadgeDollarSign } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export const DashboardLayout = () => {
|
||||
@@ -30,6 +30,10 @@ export const DashboardLayout = () => {
|
||||
<Building2 size={20} />
|
||||
Organizaciones
|
||||
</NavLink>
|
||||
<NavLink to="/plans" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<BadgeDollarSign size={20} />
|
||||
Planes
|
||||
</NavLink>
|
||||
<NavLink to="/wapservers" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<Server size={20} />
|
||||
Servidores WAP
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
import { updatePlan } from '../../api/sysadmin';
|
||||
import type { Plan } from '../../api/sysadmin';
|
||||
|
||||
type EditPlanModalProps = {
|
||||
plan: Plan;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
};
|
||||
|
||||
const numberFields: { key: keyof Plan; label: string }[] = [
|
||||
{ key: 'price', label: 'Precio mensual' },
|
||||
{ key: 'annualPrice', label: 'Precio anual' },
|
||||
{ key: 'limitOrganizations', label: 'Límite de organizaciones' },
|
||||
{ key: 'limitEmployees', label: 'Límite de empleados' },
|
||||
{ key: 'limitServices', label: 'Límite de servicios' },
|
||||
{ key: 'limitAppointments', label: 'Límite de turnos' },
|
||||
{ key: 'limitClients', label: 'Límite de clientes' },
|
||||
{ key: 'limitRepeats', label: 'Límite de repeticiones' },
|
||||
{ key: 'discount3Months', label: 'Descuento 3 meses (%)' },
|
||||
{ key: 'discount6Months', label: 'Descuento 6 meses (%)' },
|
||||
{ key: 'discount12Months', label: 'Descuento 12 meses (%)' },
|
||||
];
|
||||
|
||||
const limitFields = new Set<keyof Plan>([
|
||||
'limitOrganizations',
|
||||
'limitEmployees',
|
||||
'limitServices',
|
||||
'limitAppointments',
|
||||
'limitClients',
|
||||
'limitRepeats',
|
||||
]);
|
||||
|
||||
const booleanFields: { key: keyof Plan; label: string }[] = [
|
||||
{ key: 'active', label: 'Activo para contratación' },
|
||||
{ key: 'featured', label: 'Destacado' },
|
||||
{ key: 'mailNotifications', label: 'Notificaciones por email' },
|
||||
{ key: 'smsNotifications', label: 'Notificaciones SMS' },
|
||||
{ key: 'wapNotifications', label: 'Notificaciones WhatsApp' },
|
||||
{ key: 'payments', label: 'Pagos habilitados' },
|
||||
{ key: 'bot', label: 'Bot habilitado' },
|
||||
{ key: 'dateLimit', label: 'Tiene límite de fecha' },
|
||||
];
|
||||
|
||||
export const EditPlanModal = ({ plan, onClose, onSaved }: EditPlanModalProps) => {
|
||||
const [formData, setFormData] = useState<Plan>({
|
||||
...plan,
|
||||
features: Array.isArray(plan.features) ? plan.features : [],
|
||||
});
|
||||
const [featuresText, setFeaturesText] = useState(formData.features.join('\n'));
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const planId = plan._id || plan.id || '';
|
||||
|
||||
const setField = (key: keyof Plan, value: string | number | boolean | string[]) => {
|
||||
setFormData({ ...formData, [key]: value });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await updatePlan({
|
||||
planId,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
code: formData.code,
|
||||
price: Number(formData.price) || 0,
|
||||
annualPrice: Number(formData.annualPrice) || 0,
|
||||
limitOrganizations: Number(formData.limitOrganizations) || 0,
|
||||
limitEmployees: Number(formData.limitEmployees) || 0,
|
||||
limitServices: Number(formData.limitServices) || 0,
|
||||
limitAppointments: Number(formData.limitAppointments) || 0,
|
||||
limitClients: Number(formData.limitClients) || 0,
|
||||
limitRepeats: Number(formData.limitRepeats) || 0,
|
||||
discount3Months: Number(formData.discount3Months) || 0,
|
||||
discount6Months: Number(formData.discount6Months) || 0,
|
||||
discount12Months: Number(formData.discount12Months) || 0,
|
||||
active: Boolean(formData.active),
|
||||
featured: Boolean(formData.featured),
|
||||
mailNotifications: Boolean(formData.mailNotifications),
|
||||
smsNotifications: Boolean(formData.smsNotifications),
|
||||
wapNotifications: Boolean(formData.wapNotifications),
|
||||
payments: Boolean(formData.payments),
|
||||
bot: Boolean(formData.bot),
|
||||
dateLimit: Boolean(formData.dateLimit),
|
||||
features: featuresText.split('\n').map(feature => feature.trim()).filter(Boolean),
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.message || err.message || 'Error guardando el plan');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}>
|
||||
<motion.div initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="glass-panel" style={{ width: '94%', maxWidth: '860px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}>
|
||||
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1.5rem' }}>Editar plan: {plan.name}</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '1rem' }}>
|
||||
<div>
|
||||
<label className="modal-label">Nombre</label>
|
||||
<input className="input-glass" value={formData.name} onChange={e => setField('name', e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="modal-label">Código</label>
|
||||
<input className="input-glass" value={formData.code} onChange={e => setField('code', e.target.value)} required />
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="modal-label">Descripción</label>
|
||||
<textarea className="input-glass" rows={3} value={formData.description} onChange={e => setField('description', e.target.value)} required />
|
||||
</div>
|
||||
|
||||
<h3 className="plan-modal-section">Precios, límites y descuentos</h3>
|
||||
{numberFields.map(field => (
|
||||
<div key={field.key}>
|
||||
<label className="modal-label">{field.label}</label>
|
||||
<input className="input-glass" type="number" min={limitFields.has(field.key) ? undefined : '0'} step="0.01" value={Number(formData[field.key] || 0)} onChange={e => setField(field.key, Number(e.target.value))} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h3 className="plan-modal-section">Funciones y estado</h3>
|
||||
{booleanFields.map(field => (
|
||||
<label key={field.key} className="plan-toggle">
|
||||
<input type="checkbox" checked={Boolean(formData[field.key])} onChange={e => setField(field.key, e.target.checked)} />
|
||||
{field.label}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="modal-label">Features visibles (una por línea)</label>
|
||||
<textarea className="input-glass" rows={5} value={featuresText} onChange={e => setFeaturesText(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div style={{ gridColumn: '1 / -1', marginTop: '1rem', display: 'flex', justifyContent: 'flex-end', gap: '1rem' }}>
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Cancelar</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading || !planId}>{loading ? 'Guardando...' : 'Guardar cambios'}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style dangerouslySetInnerHTML={{__html: `
|
||||
.modal-label { display: block; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.5rem; }
|
||||
.plan-modal-section { grid-column: 1 / -1; margin: 1rem 0 0.25rem; color: var(--primary); font-size: 1.05rem; }
|
||||
.plan-toggle { display: flex; align-items: center; gap: 0.6rem; color: var(--text-main); background: rgba(255,255,255,0.04); border: 1px solid var(--glass-border); border-radius: 10px; padding: 0.75rem; }
|
||||
.plan-toggle input { width: 16px; height: 16px; }
|
||||
@media (max-width: 768px) { form { grid-template-columns: 1fr !important; } }
|
||||
`}} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import { recalculatePlanUsageCycle, type RecalculatePlanUsageCycleResult } from '../../api/sysadmin';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
export const RecalculationToolsModal = ({ initialUserId = '', onClose }: { initialUserId?: string, onClose: () => void }) => {
|
||||
const [usageUserId, setUsageUserId] = useState(initialUserId);
|
||||
const [usageResult, setUsageResult] = useState<RecalculatePlanUsageCycleResult | null>(null);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [usageError, setUsageError] = useState('');
|
||||
|
||||
const handleRecalculatePlanUsage = async () => {
|
||||
const trimmedUserId = usageUserId.trim();
|
||||
if (!trimmedUserId) {
|
||||
setUsageError('Ingresá un userId para recalcular el uso del plan.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUsageUserId(trimmedUserId);
|
||||
setUsageLoading(true);
|
||||
setUsageError('');
|
||||
setUsageResult(null);
|
||||
|
||||
try {
|
||||
const result = await recalculatePlanUsageCycle(trimmedUserId);
|
||||
setUsageResult(result);
|
||||
} catch (e: any) {
|
||||
setUsageError(e.response?.data?.message || e.message || 'Error al recalcular el uso del plan');
|
||||
} finally {
|
||||
setUsageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', padding: '1rem' }}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="glass-panel"
|
||||
style={{ width: '90%', maxWidth: '760px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}
|
||||
>
|
||||
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<RefreshCw size={24} color="var(--primary)" />
|
||||
Herramientas de recálculo
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>Funciones exclusivas de sysadmin para reparar o reconstruir contadores.</p>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem' }}>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<h3 style={{ fontSize: '1.1rem', fontWeight: '700', marginBottom: '0.35rem' }}>Recalcular uso del plan</h3>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>Recuenta las reservas del ciclo actual y los contadores stock del userId seleccionado.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleRecalculatePlanUsage(); }} style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: '1 1 300px' }}>
|
||||
<label className="modal-label">User ID</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-glass"
|
||||
placeholder="Pegá o seleccioná un userId"
|
||||
value={usageUserId}
|
||||
onChange={(e) => setUsageUserId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary" disabled={usageLoading} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<RefreshCw size={18} /> {usageLoading ? 'Recalculando...' : 'Recalcular'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{usageError && <div style={{ marginTop: '1rem', color: 'var(--danger)', fontSize: '0.9rem' }}>{usageError}</div>}
|
||||
|
||||
{usageResult && (
|
||||
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div><strong>User ID</strong><br /><span className="long-id-value">{usageResult.userId}</span></div>
|
||||
<div><strong>Subscription ID</strong><br /><span className="long-id-value">{usageResult.subscriptionId}</span></div>
|
||||
<div><strong>Plan ID</strong><br /><span className="long-id-value">{usageResult.planId}</span></div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '0.75rem' }}>
|
||||
<div><strong>Cycle Start</strong><br /><span style={{ color: 'var(--text-muted)' }}>{formatDateTime(usageResult.cycleStart)}</span></div>
|
||||
<div><strong>Cycle End</strong><br /><span style={{ color: 'var(--text-muted)' }}>{formatDateTime(usageResult.cycleEnd)}</span></div>
|
||||
<div><strong>Organizations</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.organizationsCount}</span></div>
|
||||
<div><strong>Collaborators</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.employeesCount}</span></div>
|
||||
<div><strong>Services</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.servicesCount}</span></div>
|
||||
<div><strong>Clients</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.clientsCount}</span></div>
|
||||
<div><strong>Repeats</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.repeatsCount}</span></div>
|
||||
<div><strong>Appointments</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.appointmentsCount}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style dangerouslySetInnerHTML={{__html: `
|
||||
.modal-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.long-id-value {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
`}} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CreditCard, X } from 'lucide-react';
|
||||
import { extendUserSubscription, finalizeUserSubscription, getUserSubscriptionDetails, type UserSubscriptionDetails } from '../../api/sysadmin';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
type Props = {
|
||||
user: any;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
|
||||
const [details, setDetails] = useState<UserSubscriptionDetails | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [extensionDate, setExtensionDate] = useState('');
|
||||
const [extensionLoading, setExtensionLoading] = useState(false);
|
||||
const [extensionMessage, setExtensionMessage] = useState('');
|
||||
const [extensionError, setExtensionError] = useState('');
|
||||
const [finalizeLoading, setFinalizeLoading] = useState(false);
|
||||
|
||||
const formatAmount = (value?: number) => {
|
||||
if (typeof value !== 'number') return '-';
|
||||
return value.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' });
|
||||
};
|
||||
|
||||
const loadDetails = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await getUserSubscriptionDetails(user._id);
|
||||
setDetails(result);
|
||||
} catch (e: any) {
|
||||
setError(e.response?.data?.message || e.message || 'Error cargando suscripción');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDetails();
|
||||
}, [user._id]);
|
||||
|
||||
const handleExtend = async (payload: { mode: 'add_months' | 'set_end_date'; months?: 1 | 2; endDate?: string }) => {
|
||||
if (!details?.currentSubscription) return;
|
||||
|
||||
setExtensionLoading(true);
|
||||
setExtensionError('');
|
||||
setExtensionMessage('');
|
||||
try {
|
||||
const result = await extendUserSubscription({
|
||||
userId: user._id,
|
||||
reason: 'Ajuste manual excepcional desde sysadmin-cli',
|
||||
...payload,
|
||||
});
|
||||
setExtensionMessage(`Vencimiento actualizado hasta ${formatDateTime(result.newEndDate)}. Se registró una bonificación $0.`);
|
||||
await loadDetails();
|
||||
} catch (e: any) {
|
||||
setExtensionError(e.response?.data?.message || e.message || 'Error extendiendo suscripción');
|
||||
} finally {
|
||||
setExtensionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (!details?.currentSubscription) return;
|
||||
|
||||
const confirmed = window.confirm('Esto establecerá el vencimiento de la suscripción a ayer para forzar el comportamiento de plan vencido. No elimina datos ni cambia el plan directamente. ¿Continuar?');
|
||||
if (!confirmed) return;
|
||||
|
||||
setFinalizeLoading(true);
|
||||
setExtensionError('');
|
||||
setExtensionMessage('');
|
||||
try {
|
||||
const result = await finalizeUserSubscription({
|
||||
userId: user._id,
|
||||
reason: 'Finalización manual desde sysadmin-cli para probar expiración de plan',
|
||||
});
|
||||
setExtensionMessage(`Período finalizado. El vencimiento quedó en ${formatDateTime(result.newEndDate)} para forzar la expiración en la próxima lectura.`);
|
||||
await loadDetails();
|
||||
} catch (e: any) {
|
||||
setExtensionError(e.response?.data?.message || e.message || 'Error finalizando período');
|
||||
} finally {
|
||||
setFinalizeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const subscription = details?.currentSubscription;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', padding: '1rem' }}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="glass-panel"
|
||||
style={{ width: '90%', maxWidth: '860px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}
|
||||
>
|
||||
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<CreditCard size={24} color="var(--primary)" />
|
||||
Suscripción del usuario
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>
|
||||
{user.firstName} {user.lastName} · {user.email}
|
||||
</p>
|
||||
|
||||
{loading && <div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>Cargando suscripción...</div>}
|
||||
{error && <div style={{ padding: '1rem', color: 'var(--danger)' }}>{error}</div>}
|
||||
|
||||
{!loading && !error && details && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<section className="subscription-card">
|
||||
<h3 className="subscription-section-title">Suscripción actual</h3>
|
||||
{!subscription ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>No hay suscripción registrada para este usuario.</p>
|
||||
) : (
|
||||
<div className="subscription-grid">
|
||||
<Info label="Subscription ID" value={subscription.id} long />
|
||||
<Info label="User ID" value={details.userId} long />
|
||||
<Info label="Plan" value={subscription.plan ? `${subscription.plan.name} (${subscription.plan.code})` : 'Plan no encontrado'} />
|
||||
<Info label="Plan ID" value={subscription.plan?.id || '-'} long />
|
||||
<Info label="Precio" value={formatAmount(subscription.plan?.price)} />
|
||||
<Info label="Inicio" value={formatDateTime(subscription.startDate)} />
|
||||
<Info label="Válida hasta" value={formatDateTime(subscription.endDate)} />
|
||||
<Info label="Activa" value={subscription.isActive ? 'Sí' : 'No'} />
|
||||
<Info label="MP status" value={subscription.mpStatus || '-'} />
|
||||
<Info label="Meses facturados" value={subscription.billingMonths?.toString() || '-'} />
|
||||
<Info label="Pago pendiente" value={subscription.pendingPaymentType || '-'} />
|
||||
<Info label="Preference pendiente" value={subscription.pendingPaymentPreferenceId || '-'} long />
|
||||
<Info label="Último pago" value={subscription.lastPaymentStatus || '-'} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{subscription && (
|
||||
<section className="subscription-card">
|
||||
<h3 className="subscription-section-title">Ajuste manual excepcional</h3>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
Extiende la vigencia sin pago y registra un pago de bonificación por $0 asociado a esta suscripción.
|
||||
</p>
|
||||
<div className="extension-actions">
|
||||
<button className="btn btn-secondary" disabled={extensionLoading || finalizeLoading} onClick={() => handleExtend({ mode: 'add_months', months: 1 })}>
|
||||
Extender 1 mes
|
||||
</button>
|
||||
<button className="btn btn-secondary" disabled={extensionLoading || finalizeLoading} onClick={() => handleExtend({ mode: 'add_months', months: 2 })}>
|
||||
Extender 2 meses
|
||||
</button>
|
||||
<div className="extension-date-action">
|
||||
<input
|
||||
type="date"
|
||||
value={extensionDate}
|
||||
onChange={(event) => setExtensionDate(event.target.value)}
|
||||
disabled={extensionLoading || finalizeLoading}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={extensionLoading || finalizeLoading || !extensionDate}
|
||||
onClick={() => handleExtend({ mode: 'set_end_date', endDate: extensionDate })}
|
||||
>
|
||||
Establecer vencimiento
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="finalize-action">
|
||||
<div>
|
||||
<h4>Finalizar período</h4>
|
||||
<p>Establece el vencimiento en ayer para forzar el comportamiento de suscripción vencida en la próxima lectura. No elimina datos ni cambia el plan directamente.</p>
|
||||
</div>
|
||||
<button className="btn btn-danger" disabled={extensionLoading || finalizeLoading} onClick={handleFinalize}>
|
||||
Finalizar período
|
||||
</button>
|
||||
</div>
|
||||
{(extensionLoading || finalizeLoading) && <p style={{ color: 'var(--text-muted)', marginTop: '0.75rem' }}>Aplicando ajuste...</p>}
|
||||
{extensionMessage && <p style={{ color: 'var(--success)', marginTop: '0.75rem' }}>{extensionMessage}</p>}
|
||||
{extensionError && <p style={{ color: 'var(--danger)', marginTop: '0.75rem' }}>{extensionError}</p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="subscription-card">
|
||||
<h3 className="subscription-section-title">Pagos</h3>
|
||||
{details.payments.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>No hay pagos registrados para este usuario.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{details.payments.map((payment) => (
|
||||
<div key={payment.id} className="payment-row">
|
||||
<Info label="Payment ID" value={payment.id} long />
|
||||
<Info label="Subscription ID" value={payment.subscriptionId} long />
|
||||
<Info label="Monto" value={formatAmount(payment.amount)} />
|
||||
<Info label="Fecha" value={formatDateTime(payment.paymentDate)} />
|
||||
<Info label="Método" value={payment.paymentMethod || '-'} />
|
||||
<Info label="Estado" value={payment.status} />
|
||||
<Info label="Transaction ID" value={payment.transactionId || '-'} long />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style dangerouslySetInnerHTML={{__html: `
|
||||
.subscription-card {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.subscription-section-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.subscription-grid,
|
||||
.payment-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.payment-row {
|
||||
border-top: 1px solid var(--glass-border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
.subscription-label {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.subscription-value {
|
||||
display: block;
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
}
|
||||
.subscription-value.long {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.extension-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
.extension-date-action {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
.extension-date-action input {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-main);
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
.finalize-action {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
.finalize-action h4 {
|
||||
color: var(--danger);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.finalize-action p {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.finalize-action {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`}} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Info = ({ label, value, long = false }: { label: string; value: string; long?: boolean }) => (
|
||||
<div>
|
||||
<span className="subscription-label">{label}</span>
|
||||
<span className={`subscription-value${long ? ' long' : ''}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Users, Server, Shield } from 'lucide-react';
|
||||
import { Building2, CreditCard, RefreshCw, Server, Shield, Tags, Users } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export const DashboardPage = () => {
|
||||
@@ -32,23 +32,61 @@ export const DashboardPage = () => {
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Usuarios</h3>
|
||||
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Administra perfiles, verifica cuentas, consulta organizaciones y realiza limpiezas seguras en la base de datos.</p>
|
||||
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Administra perfiles, verifica cuentas, consulta organizaciones, suscripciones, pagos y herramientas de recálculo.</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<span className="dashboard-tag"><CreditCard size={14} /> Suscripciones</span>
|
||||
<span className="dashboard-tag"><RefreshCw size={14} /> Recálculos</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
{/* Card Companies */}
|
||||
<Link to="/companies" style={{ display: 'block' }}>
|
||||
<motion.div
|
||||
whileHover={{ y: -5, scale: 1.02 }}
|
||||
className="glass-panel"
|
||||
style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}
|
||||
>
|
||||
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(16, 185, 129, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--success)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||
<Building2 size={30} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Organizaciones</h3>
|
||||
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Consulta organizaciones, revisa estado operativo y administra bloqueos desde el panel central.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
{/* Card Plans */}
|
||||
<Link to="/plans" style={{ display: 'block' }}>
|
||||
<motion.div
|
||||
whileHover={{ y: -5, scale: 1.02 }}
|
||||
className="glass-panel"
|
||||
style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}
|
||||
>
|
||||
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(245, 158, 11, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--warning)', border: '1px solid rgba(245, 158, 11, 0.2)' }}>
|
||||
<Tags size={30} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Planes</h3>
|
||||
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Visualiza y edita precios, límites, descuentos, funciones y disponibilidad de los planes contratables.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
{/* Card Wap Servers */}
|
||||
<div style={{ opacity: 0.6 }}>
|
||||
<div className="glass-panel" style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', height: '100%' }}>
|
||||
<Link to="/wapservers" style={{ display: 'block' }}>
|
||||
<motion.div whileHover={{ y: -5, scale: 1.02 }} className="glass-panel" style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}>
|
||||
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(139, 92, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent)', border: '1px solid rgba(139, 92, 246, 0.2)' }}>
|
||||
<Server size={30} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem' }}>Servidores WAP</h3>
|
||||
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>[Próximamente] Monitoreo y configuración de servidores de WhatsApp puente.</p>
|
||||
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Servidores WAP</h3>
|
||||
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Consulta servidores de WhatsApp puente y su estado operativo.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
{/* Card Config */}
|
||||
<div style={{ opacity: 0.6 }}>
|
||||
@@ -64,6 +102,20 @@ export const DashboardPage = () => {
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<style dangerouslySetInnerHTML={{__html: `
|
||||
.dashboard-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
`}} />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,20 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Search, Edit2, Trash2, CheckCircle, XCircle, Building2, ChevronUp, ChevronDown, RotateCcw } from 'lucide-react';
|
||||
import { Search, Edit2, Trash2, CheckCircle, XCircle, Building2, ChevronUp, ChevronDown, RotateCcw, RefreshCw, MoreVertical, CreditCard } from 'lucide-react';
|
||||
import { fetchUsers, deleteUser, setVerifiedStatus } from '../api/sysadmin';
|
||||
import { EditUserModal } from '../components/ui/EditUserModal';
|
||||
import { OrganizationsModal } from '../components/ui/OrganizationsModal';
|
||||
import { RecalculationToolsModal } from '../components/ui/RecalculationToolsModal';
|
||||
import { SubscriptionDetailsModal } from '../components/ui/SubscriptionDetailsModal';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
|
||||
export const UsersPage = () => {
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [recalculationUserId, setRecalculationUserId] = useState('');
|
||||
const [openActionsUserId, setOpenActionsUserId] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [filters, setFilters] = useState({
|
||||
@@ -23,6 +28,8 @@ export const UsersPage = () => {
|
||||
// Modals state
|
||||
const [editingUser, setEditingUser] = useState<any>(null);
|
||||
const [orgUser, setOrgUser] = useState<any>(null);
|
||||
const [subscriptionUser, setSubscriptionUser] = useState<any>(null);
|
||||
const [recalculationToolsOpen, setRecalculationToolsOpen] = useState(false);
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
@@ -46,6 +53,15 @@ export const UsersPage = () => {
|
||||
loadUsers();
|
||||
}, [page, filters.sortBy, filters.sortOrder, filters.isVerified]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openActionsUserId) return;
|
||||
|
||||
const closeActionsMenu = () => setOpenActionsUserId(null);
|
||||
document.addEventListener('click', closeActionsMenu);
|
||||
|
||||
return () => document.removeEventListener('click', closeActionsMenu);
|
||||
}, [openActionsUserId]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
@@ -107,10 +123,23 @@ export const UsersPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openRecalculationTools = (userId = '') => {
|
||||
setRecalculationUserId(userId);
|
||||
setRecalculationToolsOpen(true);
|
||||
};
|
||||
|
||||
const runRowAction = (action: () => void) => {
|
||||
setOpenActionsUserId(null);
|
||||
action();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in users-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
|
||||
<h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Gestión de Usuarios</h1>
|
||||
<button className="btn-secondary" onClick={() => openRecalculationTools()} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<RefreshCw size={18} /> Recalcular
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters Bar */}
|
||||
@@ -179,9 +208,9 @@ export const UsersPage = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center' }}>Cargando...</td></tr>
|
||||
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center' }}>Cargando...</td></tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron usuarios</td></tr>
|
||||
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron usuarios</td></tr>
|
||||
) : (
|
||||
users.map(u => (
|
||||
<tr key={u._id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="table-row-hover">
|
||||
@@ -205,13 +234,43 @@ export const UsersPage = () => {
|
||||
{u.verificationCode || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
||||
{new Date(u.creationDate).toLocaleDateString()}
|
||||
{formatDate(u.creationDate)}
|
||||
</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
|
||||
<button className="icon-btn" title="Ver Organizaciones" onClick={() => setOrgUser(u)}><Building2 size={18} /></button>
|
||||
<button className="icon-btn" title="Editar Perfil" onClick={() => setEditingUser(u)}><Edit2 size={18} /></button>
|
||||
<button className="icon-btn danger" title="Eliminar" onClick={() => handleDelete(u)}><Trash2 size={18} /></button>
|
||||
<div className="actions-menu-wrapper" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="Acciones"
|
||||
aria-label={`Acciones para ${u.email}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={openActionsUserId === u._id}
|
||||
onClick={() => setOpenActionsUserId(openActionsUserId === u._id ? null : u._id)}
|
||||
>
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
{openActionsUserId === u._id && (
|
||||
<div className="actions-menu" role="menu">
|
||||
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setOrgUser(u))}>
|
||||
<Building2 size={16} /> Ver organizaciones
|
||||
</button>
|
||||
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setSubscriptionUser(u))}>
|
||||
<CreditCard size={16} /> Ver suscripción
|
||||
</button>
|
||||
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => openRecalculationTools(u._id))}>
|
||||
<RefreshCw size={16} /> Recalcular uso del plan
|
||||
</button>
|
||||
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleToggleVerified(u))}>
|
||||
{u.verificated ? <XCircle size={16} /> : <CheckCircle size={16} />}
|
||||
{u.verificated ? 'Marcar como no verificado' : 'Marcar como verificado'}
|
||||
</button>
|
||||
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setEditingUser(u))}>
|
||||
<Edit2 size={16} /> Editar perfil
|
||||
</button>
|
||||
<button type="button" className="actions-menu-item danger" role="menuitem" onClick={() => runRowAction(() => handleDelete(u))}>
|
||||
<Trash2 size={16} /> Eliminar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -261,6 +320,51 @@ export const UsersPage = () => {
|
||||
.icon-btn.danger:hover {
|
||||
background: var(--danger);
|
||||
}
|
||||
.actions-menu-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.actions-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
min-width: 230px;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 12px;
|
||||
background: #111827;
|
||||
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.actions-menu-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #f8fafc;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.actions-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ffffff;
|
||||
}
|
||||
.actions-menu-item.danger {
|
||||
color: #fca5a5;
|
||||
}
|
||||
.actions-menu-item.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
color: #fecaca;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--text-main);
|
||||
@@ -293,6 +397,8 @@ export const UsersPage = () => {
|
||||
{/* Modals */}
|
||||
{editingUser && <EditUserModal user={editingUser} onClose={() => setEditingUser(null)} onSaved={loadUsers} />}
|
||||
{orgUser && <OrganizationsModal user={orgUser} onClose={() => setOrgUser(null)} />}
|
||||
{subscriptionUser && <SubscriptionDetailsModal user={subscriptionUser} onClose={() => setSubscriptionUser(null)} />}
|
||||
{recalculationToolsOpen && <RecalculationToolsModal initialUserId={recalculationUserId} onClose={() => setRecalculationToolsOpen(false)} />}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 || '-';
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import wapRoutes from "./routes/wap.routes";
|
||||
import usersRoutes from "./routes/users.routes";
|
||||
import companiesRoutes from "./routes/companies.routes";
|
||||
import servicesRoutes from "./routes/services.routes";
|
||||
import plansRoutes from "./routes/plans.routes";
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -16,5 +17,6 @@ app.use("/wap", wapRoutes);
|
||||
app.use("/users", usersRoutes);
|
||||
app.use("/companies", companiesRoutes);
|
||||
app.use("/services", servicesRoutes);
|
||||
app.use("/plans", plansRoutes);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Request, Response } from "express";
|
||||
import { PlansService } from "../services/plans.service";
|
||||
import { SysAdminUpdatePlanParams } from "../models/Plans.Model";
|
||||
|
||||
const plansService = new PlansService();
|
||||
|
||||
export class PlansController {
|
||||
public listPlans = async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const plans = await plansService.listPlans();
|
||||
res.json(plans);
|
||||
} catch (error: any) {
|
||||
console.error("Error listing plans:", error?.response?.data || error);
|
||||
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||
}
|
||||
};
|
||||
|
||||
public updatePlan = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const data: SysAdminUpdatePlanParams = req.body;
|
||||
const result = await plansService.updatePlan(data);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error("Error updating plan:", error?.response?.data || error);
|
||||
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
SysAdminSetVerifiedParams,
|
||||
SysAdminDeleteUserParams,
|
||||
SysAdminOrganizationsStatusParams,
|
||||
SysAdminRecalculatePlanUsageCycleParams,
|
||||
SysAdminExtendUserSubscriptionParams,
|
||||
SysAdminFinalizeUserSubscriptionParams,
|
||||
SysAdminUserSubscriptionDetailsParams,
|
||||
} from "../models/Users.Model";
|
||||
|
||||
const usersService = new UsersService();
|
||||
@@ -65,4 +69,48 @@ export class UsersController {
|
||||
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||
}
|
||||
};
|
||||
|
||||
public recalculatePlanUsageCycle = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const data: SysAdminRecalculatePlanUsageCycleParams = req.body;
|
||||
const result = await usersService.recalculatePlanUsageCycle(data);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error("Error recalculating plan usage cycle:", error?.response?.data || error);
|
||||
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||
}
|
||||
};
|
||||
|
||||
public subscriptionDetails = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const data: SysAdminUserSubscriptionDetailsParams = req.body;
|
||||
const result = await usersService.getSubscriptionDetails(data);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching subscription details:", error?.response?.data || error);
|
||||
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||
}
|
||||
};
|
||||
|
||||
public extendSubscription = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const data: SysAdminExtendUserSubscriptionParams = req.body;
|
||||
const result = await usersService.extendSubscription(data);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error("Error extending subscription:", error?.response?.data || error);
|
||||
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||
}
|
||||
};
|
||||
|
||||
public finalizeSubscription = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const data: SysAdminFinalizeUserSubscriptionParams = req.body;
|
||||
const result = await usersService.finalizeSubscription(data);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error("Error finalizing subscription:", error?.response?.data || error);
|
||||
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -52,3 +52,84 @@ export type SysAdminUserOrganizationsResult = {
|
||||
clientIn: { companyId: string; companyName: string }[];
|
||||
collaboratorIn: { companyId: string; companyName: string }[];
|
||||
};
|
||||
|
||||
export type SysAdminRecalculatePlanUsageCycleParams = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type SysAdminRecalculatePlanUsageCycleResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
planId: string;
|
||||
cycleStart: string;
|
||||
cycleEnd: string;
|
||||
organizationsCount: number;
|
||||
employeesCount: number;
|
||||
servicesCount: number;
|
||||
clientsCount: number;
|
||||
repeatsCount: number;
|
||||
appointmentsCount: number;
|
||||
};
|
||||
|
||||
export type SysAdminUserSubscriptionDetailsParams = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type SysAdminExtendUserSubscriptionParams = {
|
||||
userId: string;
|
||||
mode: "add_months" | "set_end_date";
|
||||
months?: 1 | 2;
|
||||
endDate?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type SysAdminExtendUserSubscriptionResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
previousEndDate: string;
|
||||
newEndDate: string;
|
||||
paymentId: string;
|
||||
transactionId?: string;
|
||||
};
|
||||
|
||||
export type SysAdminFinalizeUserSubscriptionParams = {
|
||||
userId: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type SysAdminFinalizeUserSubscriptionResult = {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
previousEndDate: string;
|
||||
newEndDate: string;
|
||||
};
|
||||
|
||||
export type SysAdminUserSubscriptionDetailsResult = {
|
||||
userId: string;
|
||||
currentSubscription: {
|
||||
id: string;
|
||||
plan: {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
price: number;
|
||||
} | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
isActive: boolean;
|
||||
mpStatus?: string;
|
||||
billingMonths?: number;
|
||||
pendingPaymentType?: "extension" | "upgrade";
|
||||
pendingPaymentPreferenceId?: string;
|
||||
lastPaymentStatus?: string;
|
||||
} | null;
|
||||
payments: {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
amount: number;
|
||||
paymentDate: string;
|
||||
paymentMethod?: string;
|
||||
status: string;
|
||||
transactionId?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -8,6 +8,10 @@ router.post("/paginate", usersController.paginateUsers);
|
||||
router.post("/update", usersController.updateProfile);
|
||||
router.post("/set-verified", usersController.setVerifiedStatus);
|
||||
router.post("/organizations-status", usersController.organizationsStatus);
|
||||
router.post("/recalculate-plan-usage-cycle", usersController.recalculatePlanUsageCycle);
|
||||
router.post("/subscription-details", usersController.subscriptionDetails);
|
||||
router.post("/extend-subscription", usersController.extendSubscription);
|
||||
router.post("/finalize-subscription", usersController.finalizeSubscription);
|
||||
router.post("/delete", usersController.deleteUser);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,14 @@ import {
|
||||
SysAdminDeleteUserParams,
|
||||
SysAdminOrganizationsStatusParams,
|
||||
SysAdminUserOrganizationsResult,
|
||||
SysAdminRecalculatePlanUsageCycleParams,
|
||||
SysAdminRecalculatePlanUsageCycleResult,
|
||||
SysAdminExtendUserSubscriptionParams,
|
||||
SysAdminExtendUserSubscriptionResult,
|
||||
SysAdminFinalizeUserSubscriptionParams,
|
||||
SysAdminFinalizeUserSubscriptionResult,
|
||||
SysAdminUserSubscriptionDetailsParams,
|
||||
SysAdminUserSubscriptionDetailsResult,
|
||||
} from "../models/Users.Model";
|
||||
|
||||
export class UsersService {
|
||||
@@ -72,4 +80,60 @@ export class UsersService {
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async recalculatePlanUsageCycle(
|
||||
data: SysAdminRecalculatePlanUsageCycleParams
|
||||
): Promise<SysAdminRecalculatePlanUsageCycleResult> {
|
||||
const nonce = await getSysAdminNonce();
|
||||
const postData = { ...data, ...{ payload: getPayload(nonce) } };
|
||||
|
||||
const response = await axios.post(`${getApiHost()}/sysadmin/plan-usage-cycle/recalculate-user`, postData, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async getSubscriptionDetails(
|
||||
data: SysAdminUserSubscriptionDetailsParams
|
||||
): Promise<SysAdminUserSubscriptionDetailsResult> {
|
||||
const nonce = await getSysAdminNonce();
|
||||
const postData = { ...data, ...{ payload: getPayload(nonce) } };
|
||||
|
||||
const response = await axios.post(`${getApiHost()}/sysadmin/subscriptions/user-details`, postData, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async extendSubscription(
|
||||
data: SysAdminExtendUserSubscriptionParams
|
||||
): Promise<SysAdminExtendUserSubscriptionResult> {
|
||||
const nonce = await getSysAdminNonce();
|
||||
const postData = { ...data, ...{ payload: getPayload(nonce) } };
|
||||
|
||||
const response = await axios.post(`${getApiHost()}/sysadmin/subscriptions/extend-user`, postData, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async finalizeSubscription(
|
||||
data: SysAdminFinalizeUserSubscriptionParams
|
||||
): Promise<SysAdminFinalizeUserSubscriptionResult> {
|
||||
const nonce = await getSysAdminNonce();
|
||||
const postData = { ...data, ...{ payload: getPayload(nonce) } };
|
||||
|
||||
const response = await axios.post(`${getApiHost()}/sysadmin/subscriptions/finalize-user`, postData, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -11,4 +11,5 @@ NEXT_PUBLIC_DATAGRID_PAGE_SIZE = 10
|
||||
|
||||
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
|
||||
+2
-1
@@ -11,4 +11,5 @@ NEXT_PUBLIC_DATAGRID_PAGE_SIZE = 10
|
||||
|
||||
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
|
||||
Generated
+578
-545
File diff suppressed because it is too large
Load Diff
+24
-24
@@ -9,44 +9,44 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^6.1.5",
|
||||
"@mui/material": "^6.1.5",
|
||||
"@mui/x-charts": "^7.23.2",
|
||||
"@mui/x-date-pickers": "^7.22.2",
|
||||
"@react-oauth/google": "^0.12.1",
|
||||
"axios": "^1.7.7",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^6.5.0",
|
||||
"@mui/material": "^6.5.0",
|
||||
"@mui/x-charts": "^7.29.1",
|
||||
"@mui/x-date-pickers": "^7.29.4",
|
||||
"@react-oauth/google": "^0.13.5",
|
||||
"axios": "^1.18.1",
|
||||
"classnames": "^2.5.1",
|
||||
"currency.js": "^2.0.4",
|
||||
"dayjs": "^1.11.13",
|
||||
"dompurify": "^3.2.7",
|
||||
"driver.js": "^1.3.6",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.12",
|
||||
"driver.js": "^1.7.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"motion": "^11.12.0",
|
||||
"next": "^15.5.7",
|
||||
"pdfmake": "^0.2.20",
|
||||
"motion": "^11.18.2",
|
||||
"next": "^15.5.20",
|
||||
"pdfmake": "^0.3.11",
|
||||
"react": "^18.3.1",
|
||||
"react-avatar": "^5.0.3",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-avatar": "^5.0.4",
|
||||
"react-colorful": "^5.8.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-quill-new": "^3.6.0",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"react-schedule-view": "^1.1.2",
|
||||
"react-share": "^5.2.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"yup": "^1.4.0",
|
||||
"zustand": "^5.0.0"
|
||||
"react-share": "^5.3.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"yup": "^1.7.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/leaflet": "^1.9.15",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^20",
|
||||
"@types/pdfmake": "^0.2.12",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "15.0.1",
|
||||
"eslint-config-next": "15.5.20",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ export type FindAppointmentsParams = {
|
||||
clientId?: string;
|
||||
start?: string;
|
||||
dateDay?: string;
|
||||
sessionUser?: string;
|
||||
};
|
||||
|
||||
export type FindAppointmentsByUserParams = {
|
||||
@@ -151,6 +152,7 @@ export interface AppointmentEventByClient {
|
||||
serviceName: string;
|
||||
serviceDescription: string;
|
||||
collaboratorId: string;
|
||||
collaboratorUserId: string;
|
||||
collaboratorName: string;
|
||||
collaboratorAvatar: string;
|
||||
appointmentDate: string;
|
||||
|
||||
@@ -22,6 +22,14 @@ export interface IEmployee {
|
||||
roles?: EmployeeRoles[];
|
||||
guestOk?: boolean;
|
||||
hostOk?: boolean;
|
||||
removed?: boolean;
|
||||
removedAt?: Date;
|
||||
profileSnapshot?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CompanyEmployeesView {
|
||||
@@ -38,6 +46,14 @@ export interface CompanyEmployeesView {
|
||||
hostOk: boolean;
|
||||
fullOk: boolean;
|
||||
calendarColor: string;
|
||||
removed?: boolean;
|
||||
removedAt?: Date;
|
||||
profileSnapshot?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type CreateEmployeeParams = {
|
||||
@@ -66,6 +82,13 @@ export type ValidateEmployeeParams = {
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type RemoveCollaboratorParams = {
|
||||
employeeId: string;
|
||||
companyId: string;
|
||||
replacementEmployeeId?: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type FindEmployeesByIdParams = {
|
||||
id?: string;
|
||||
};
|
||||
@@ -117,6 +140,8 @@ export type ScheduleItem = {
|
||||
from: string;
|
||||
to: string;
|
||||
disabled: boolean;
|
||||
serviceScope?: "all" | "specific";
|
||||
serviceIds?: string[];
|
||||
};
|
||||
|
||||
export type FindSchedulesParams = {
|
||||
|
||||
@@ -48,6 +48,23 @@ export type GetSubscriptionInitPointParams = {
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type VerifyPendingPlanPaymentParams = {
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type VerifyPendingPlanPaymentResponse = {
|
||||
status: "approved" | "pending" | "not_found" | "rejected" | "failed" | "cancelled";
|
||||
approved: boolean;
|
||||
message: string;
|
||||
subscriptionId?: string;
|
||||
paymentId?: string;
|
||||
paymentType?: "new" | "extension" | "upgrade";
|
||||
};
|
||||
|
||||
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
|
||||
export type LastPlanPaymentType = "new" | "extension" | "upgrade";
|
||||
export type DowngradeReason = "expired";
|
||||
|
||||
export interface GetSuscriptionInitPointResponse {
|
||||
init_point: string;
|
||||
}
|
||||
@@ -61,6 +78,18 @@ export interface ISubscriptionInfo {
|
||||
autoRenew?: boolean;
|
||||
mpStatus?: string;
|
||||
mpDateCreated?: Date;
|
||||
pendingPaymentInitPoint?: string;
|
||||
pendingPaymentPreferenceId?: string;
|
||||
pendingPaymentType?: "extension" | "upgrade";
|
||||
lastPaymentStatus?: LastPlanPaymentStatus;
|
||||
lastPaymentPreferenceId?: string;
|
||||
lastPaymentAt?: Date;
|
||||
lastPaymentType?: LastPlanPaymentType;
|
||||
downgradedFromPlanId?: string;
|
||||
downgradedFromPlanName?: string;
|
||||
downgradedFromPlanCode?: string;
|
||||
downgradedAt?: Date;
|
||||
downgradeReason?: DowngradeReason;
|
||||
}
|
||||
export const DefaultSubscriptionModel: ISubscriptionInfo = {
|
||||
id: "",
|
||||
|
||||
@@ -52,6 +52,7 @@ export default function ClientDashboardLayout({
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
const [client, setClient] = useState<OrganizationClientView | null>(null);
|
||||
const [isCheckingAccess, setIsCheckingAccess] = useState(true);
|
||||
const [clientName, setClientName] = useState<string>("");
|
||||
const [, setClientEmail] = useState<string>("");
|
||||
const [clientAvatar, setClientAvatar] = useState<string>("");
|
||||
@@ -106,6 +107,7 @@ export default function ClientDashboardLayout({
|
||||
}
|
||||
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
setIsCheckingAccess(true);
|
||||
|
||||
findClientById({
|
||||
id: clientId,
|
||||
@@ -119,9 +121,15 @@ export default function ClientDashboardLayout({
|
||||
setClientAvatar(client.avatar);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error?.desc?.includes("No tienes permisos") || error?.desc?.includes("no tiene permisos")) {
|
||||
setClient(null);
|
||||
goTo("/admin/access-denied");
|
||||
return;
|
||||
}
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
setIsCheckingAccess(false);
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
};
|
||||
@@ -131,7 +139,8 @@ export default function ClientDashboardLayout({
|
||||
<Content>
|
||||
<Suspense fallback={<SuspenseLoading />}>
|
||||
<Secure>
|
||||
<SectionContainer style={{ top: "70px" }}>
|
||||
{isCheckingAccess && <SuspenseLoading />}
|
||||
{client && <SectionContainer style={{ top: "70px" }}>
|
||||
<div className={style.clientProfileHeader}>
|
||||
<Avatar
|
||||
name="userProfileAvatar"
|
||||
@@ -195,7 +204,7 @@ export default function ClientDashboardLayout({
|
||||
</div>
|
||||
</div>
|
||||
<Menu />
|
||||
</SectionContainer>
|
||||
</SectionContainer>}
|
||||
</Secure>
|
||||
</Suspense>
|
||||
</Content>
|
||||
|
||||
+6
-1
@@ -193,8 +193,8 @@ export default function OrganizationProfile() {
|
||||
const filter = useFilterStore();
|
||||
const alert = useAlert();
|
||||
const SessionInfo = useSessionStore();
|
||||
const appointmentsView = useAppointmentsViewStore();
|
||||
const { goTo } = useNavigation();
|
||||
const appointmentsView = useAppointmentsViewStore();
|
||||
|
||||
const [windowWidth] = useWindowSize();
|
||||
|
||||
@@ -317,6 +317,7 @@ export default function OrganizationProfile() {
|
||||
employeeId: appointmentsView.collaboratorId
|
||||
? appointmentsView.collaboratorId
|
||||
: collaboratorId,
|
||||
sessionUser: SessionInfo.userId,
|
||||
};
|
||||
|
||||
const filteredFilter = Object.fromEntries(
|
||||
@@ -338,6 +339,10 @@ export default function OrganizationProfile() {
|
||||
]);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error?.desc?.includes("No tienes permisos") || error?.desc?.includes("no tiene permisos")) {
|
||||
goTo("/admin/access-denied");
|
||||
return;
|
||||
}
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -205,13 +205,13 @@ export default function ClientsGrid() {
|
||||
<span className={pageStyle.bannedBadge}>Baneado</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={pageStyle.clientDetail}>
|
||||
<div className={pageStyle.clientDetail}>
|
||||
<div className={pageStyle.iconWrapper}>
|
||||
<EmailOutlinedIcon sx={{ fontSize: 14 }} />
|
||||
</div>
|
||||
{client.email || "Sin email"}
|
||||
</p>
|
||||
<p className={pageStyle.clientPhone}>
|
||||
</div>
|
||||
<div className={pageStyle.clientPhone}>
|
||||
<div className={pageStyle.iconWrapper}>
|
||||
<PhoneOutlinedIcon sx={{ fontSize: 14 }} />
|
||||
</div>
|
||||
@@ -219,7 +219,7 @@ export default function ClientsGrid() {
|
||||
? `${client.phoneCountryCode || ''} ${client.phoneAreaCode} - ${client.phoneNumber}`
|
||||
: "Sin teléfono"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+5
@@ -14,6 +14,7 @@ import {
|
||||
CollaboratorSchedulesView,
|
||||
UpdateScheduleParams,
|
||||
UpdateEmployeeParams,
|
||||
RemoveCollaboratorParams,
|
||||
} from "@models/Collaborators.model";
|
||||
import { CollaboratorView, FindCollaboratorByEmailParams } from "@models/User.model";
|
||||
import { ApiVoidResult } from "@models/Api.VoidResult.type";
|
||||
@@ -128,3 +129,7 @@ export const updateSchedulesOverrides = async (data: UpdateSchedulesOverridesPar
|
||||
export const deleteSchedulesOverrides = async (data: DeleteSchedulesOverridesParams): Promise<ApiVoidResult> => {
|
||||
return ApiRequest.post<ApiVoidResult>("schedules-overrides/delete", data);
|
||||
};
|
||||
|
||||
export const removeCollaborator = async (data: RemoveCollaboratorParams): Promise<ApiVoidResult> => {
|
||||
return ApiRequest.post<ApiVoidResult>("employees/remove-collaborator", data);
|
||||
};
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
/* ── Removal card (collapsible) ─────────────────────────────── */
|
||||
|
||||
.removalCard {
|
||||
margin-top: 32px;
|
||||
padding: 0;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 42, 127, 0.06) 0%,
|
||||
rgba(255, 74, 42, 0.04) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 74, 42, 0.18);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.removalCard::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--wine-red) 0%,
|
||||
var(--red) 100%
|
||||
);
|
||||
border-radius: 16px 16px 0 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Toggle button (always visible header) ──────────────────── */
|
||||
|
||||
.removalToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 20px 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: "Kanit", serif;
|
||||
text-align: left;
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
|
||||
.removalToggle:hover {
|
||||
background: rgba(255, 42, 127, 0.03);
|
||||
}
|
||||
|
||||
.removalToggleLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.removalToggleRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.removalToggleText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Icon circle ────────────────────────────────────────────── */
|
||||
|
||||
.removalIconCircle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 42, 127, 0.12) 0%,
|
||||
rgba(255, 74, 42, 0.10) 100%
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--wine-red);
|
||||
}
|
||||
|
||||
/* ── Typography ─────────────────────────────────────────────── */
|
||||
|
||||
.removalTitle {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--black);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.removalSubtitle {
|
||||
font-size: 13px;
|
||||
color: var(--black-light);
|
||||
opacity: 0.7;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.removalExpandLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--wine-red);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.removalChevron {
|
||||
font-size: 22px;
|
||||
color: var(--wine-red);
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
/* ── Expanded body ──────────────────────────────────────────── */
|
||||
|
||||
.removalBody {
|
||||
padding: 0 24px 24px 24px;
|
||||
animation: removalSlideIn 0.22s ease;
|
||||
}
|
||||
|
||||
@keyframes removalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.removalDivider {
|
||||
height: 1px;
|
||||
background: rgba(255, 74, 42, 0.12);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ── Description & list (expanded content) ──────────────────── */
|
||||
|
||||
.removalDescription {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 15px;
|
||||
color: var(--black-light);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.removalList {
|
||||
margin: 0 0 22px 0;
|
||||
padding: 0 0 0 22px;
|
||||
font-size: 14.5px;
|
||||
color: var(--black-light);
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.removalList li {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.removalList li::marker {
|
||||
color: var(--wine-red);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ── Action row ─────────────────────────────────────────────── */
|
||||
|
||||
.removalActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 11px 28px;
|
||||
border: none;
|
||||
border-radius: 100px;
|
||||
font-family: "Kanit", serif;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.22s ease,
|
||||
box-shadow 0.22s ease,
|
||||
transform 0.15s ease;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 42, 127, 0.10) 0%,
|
||||
rgba(255, 74, 42, 0.08) 100%
|
||||
);
|
||||
color: var(--red);
|
||||
border: 1.5px solid rgba(255, 74, 42, 0.30);
|
||||
box-shadow: 0 1px 3px rgba(255, 42, 127, 0.06);
|
||||
}
|
||||
|
||||
.removeButton:hover {
|
||||
background: var(--red);
|
||||
color: var(--white);
|
||||
border-color: var(--red);
|
||||
box-shadow: 0 4px 14px rgba(255, 74, 42, 0.30);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.removeButton:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 6px rgba(255, 74, 42, 0.20);
|
||||
}
|
||||
|
||||
.removeButtonIcon {
|
||||
font-size: 18px;
|
||||
transition: color 0.22s ease;
|
||||
}
|
||||
+229
-4
@@ -1,18 +1,40 @@
|
||||
"use client";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Location from "@components/Location/Location";
|
||||
import { useLocationStore } from "@store/Location.Store";
|
||||
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
|
||||
import { useSessionStore } from "@store/Sesion.Store";
|
||||
import { useAlert } from "@store/Alert.Store";
|
||||
import { useConfirmStore } from "@store/Confirm.Store";
|
||||
import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined";
|
||||
import LocalPoliceOutlinedIcon from "@mui/icons-material/LocalPoliceOutlined";
|
||||
import MaterialAlert from "@mui/material/Alert";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import MuiButton from "@mui/material/Button";
|
||||
import Radio from "@mui/material/Radio";
|
||||
import RadioGroup from "@mui/material/RadioGroup";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
||||
import { ThemeProvider } from "@emotion/react";
|
||||
import { PopoverPicker } from "@core/app/components/ColorPicker/ColorPicker";
|
||||
import { findCollaboratorsById, updateCollaborator } from "../../Collaborators.Service";
|
||||
import {
|
||||
findCollaboratorsById,
|
||||
updateCollaborator,
|
||||
loadCollaboratorsByCompanyId,
|
||||
removeCollaborator,
|
||||
} from "../../Collaborators.Service";
|
||||
import { ApiError } from "@models/Server.Error.model";
|
||||
import { UpdateEmployeeParams } from "@models/Collaborators.model";
|
||||
import { UpdateEmployeeParams, CompanyEmployeesView, EmployeeRoles } from "@models/Collaborators.model";
|
||||
import { useNavigation } from "@hooks/goto";
|
||||
import style from "./edit.module.css";
|
||||
import PersonOffOutlinedIcon from "@mui/icons-material/PersonOffOutlined";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -27,9 +49,19 @@ export default function OrganizationEdit() {
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirmStore();
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
const [collaboratorName, setCollaboratorName] = useState<string>("");
|
||||
const [collaboratorUserId, setCollaboratorUserId] = useState<string>("");
|
||||
const [collaboratorColor, setCollaboratorColor] = useState<string>("");
|
||||
const [collaborators, setCollaborators] = useState<CompanyEmployeesView[]>([]);
|
||||
const [isOwner, setIsOwner] = useState<boolean>(false);
|
||||
const [replacementDialogOpen, setReplacementDialogOpen] = useState<boolean>(false);
|
||||
const [selectedReplacement, setSelectedReplacement] = useState<string>("");
|
||||
const [removalCollapsed, setRemovalCollapsed] = useState<boolean>(true);
|
||||
|
||||
const toggleRemoval = useCallback(() => setRemovalCollapsed((prev) => !prev), []);
|
||||
|
||||
useEffect(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
@@ -58,6 +90,7 @@ export default function OrganizationEdit() {
|
||||
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
loadCollaborator();
|
||||
loadCollaborators();
|
||||
}, [collaboratorName]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -83,6 +116,21 @@ export default function OrganizationEdit() {
|
||||
}
|
||||
}, [eventHandler.eventType]);
|
||||
|
||||
const loadCollaborators = () => {
|
||||
loadCollaboratorsByCompanyId({ companyId: id })
|
||||
.then((list) => {
|
||||
setCollaborators(list);
|
||||
const current = list.find((c) => c.id === cid);
|
||||
setIsOwner(
|
||||
current !== undefined &&
|
||||
current.roles.includes(EmployeeRoles.OWNER)
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-critical — removal button still works, server validates
|
||||
});
|
||||
};
|
||||
|
||||
const loadCollaborator = () => {
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
findCollaboratorsById({ id: cid })
|
||||
@@ -92,6 +140,7 @@ export default function OrganizationEdit() {
|
||||
}
|
||||
|
||||
setCollaboratorName(collaborator.fullName);
|
||||
setCollaboratorUserId(collaborator.userId);
|
||||
setCollaboratorColor(collaborator.calendarColor);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -102,6 +151,65 @@ export default function OrganizationEdit() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveClick = () => {
|
||||
if (isOwner) {
|
||||
alert.showError("No se puede eliminar al propietario de la organización");
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(SessionInfo.userId) === collaboratorUserId) {
|
||||
alert.showError("No puedes eliminarte a ti mismo de la organización");
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = collaborators.filter(
|
||||
(c) => c.id !== cid
|
||||
);
|
||||
|
||||
if (candidates.length > 0) {
|
||||
setReplacementDialogOpen(true);
|
||||
setSelectedReplacement("");
|
||||
} else {
|
||||
confirm.show(
|
||||
"¿Estás seguro de que deseas remover este colaborador de la organización?",
|
||||
handleConfirmRemove,
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReplace = () => {
|
||||
setReplacementDialogOpen(false);
|
||||
confirm.show(
|
||||
"¿Estás seguro de que deseas remover este colaborador de la organización? Los turnos futuros serán transferidos al colaborador seleccionado.",
|
||||
handleConfirmRemove,
|
||||
() => {}
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfirmRemove = () => {
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
removeCollaborator({
|
||||
employeeId: cid,
|
||||
companyId: id,
|
||||
sessionUser: SessionInfo.userId,
|
||||
...(selectedReplacement
|
||||
? { replacementEmployeeId: selectedReplacement }
|
||||
: {}),
|
||||
})
|
||||
.then(() => {
|
||||
alert.showSuccess("Colaborador removido correctamente");
|
||||
goTo("/admin/org/profile/" + id + "/collaborators");
|
||||
})
|
||||
.catch((error: ApiError) => {
|
||||
alert.showError(error.format());
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Location />
|
||||
@@ -119,7 +227,124 @@ export default function OrganizationEdit() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ marginBottom: "10px" }}> </div>
|
||||
{!isOwner && (
|
||||
<div className={style.removalCard}>
|
||||
<button
|
||||
type="button"
|
||||
className={style.removalToggle}
|
||||
onClick={toggleRemoval}
|
||||
aria-expanded={!removalCollapsed}
|
||||
aria-controls="removal-details"
|
||||
>
|
||||
<div className={style.removalToggleLeft}>
|
||||
<div className={style.removalIconCircle}>
|
||||
<PersonOffOutlinedIcon fontSize="medium" />
|
||||
</div>
|
||||
<div className={style.removalToggleText}>
|
||||
<h3 className={style.removalTitle}>
|
||||
Remover colaborador
|
||||
</h3>
|
||||
{!removalCollapsed && (
|
||||
<span className={style.removalSubtitle}>
|
||||
Acción irreversible — revisá los efectos antes de continuar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.removalToggleRight}>
|
||||
{removalCollapsed ? (
|
||||
<>
|
||||
<span className={style.removalExpandLabel}>
|
||||
Mostrar opciones de remoción
|
||||
</span>
|
||||
<ExpandMoreIcon className={style.removalChevron} />
|
||||
</>
|
||||
) : (
|
||||
<ExpandLessIcon className={style.removalChevron} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!removalCollapsed && (
|
||||
<div id="removal-details" className={style.removalBody}>
|
||||
<div className={style.removalDivider} />
|
||||
|
||||
<p className={style.removalDescription}>
|
||||
Al remover este colaborador de la organización:
|
||||
</p>
|
||||
|
||||
<ul className={style.removalList}>
|
||||
<li>Dejará de pertenecer a la organización.</li>
|
||||
<li>
|
||||
Los turnos futuros y repeticiones activas serán
|
||||
transferidos a otro colaborador.
|
||||
</li>
|
||||
<li>El historial de turnos permanecerá preservado.</li>
|
||||
<li>
|
||||
Ya no recibirá nuevas reservas ni turnos asignados.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className={style.removalActions}>
|
||||
<button
|
||||
className={style.removeButton}
|
||||
type="button"
|
||||
onClick={handleRemoveClick}
|
||||
>
|
||||
<PersonOffOutlinedIcon className={style.removeButtonIcon} />
|
||||
Remover colaborador
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<Dialog
|
||||
open={replacementDialogOpen}
|
||||
onClose={() => setReplacementDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Seleccionar reemplazo</DialogTitle>
|
||||
<DialogContent>
|
||||
<p style={{ marginBottom: "16px", color: "#666" }}>
|
||||
Este colaborador tiene turnos futuros asignados.
|
||||
Seleccione un colaborador de reemplazo para transferir los turnos.
|
||||
</p>
|
||||
<FormControl component="fieldset">
|
||||
<RadioGroup
|
||||
value={selectedReplacement}
|
||||
onChange={(e) => setSelectedReplacement(e.target.value)}
|
||||
>
|
||||
{collaborators
|
||||
.filter((c) => c.id !== cid)
|
||||
.map((collaborator) => (
|
||||
<FormControlLabel
|
||||
key={collaborator.id}
|
||||
value={collaborator.id}
|
||||
control={<Radio />}
|
||||
label={`${collaborator.fullName} (${collaborator.email})`}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MuiButton onClick={() => setReplacementDialogOpen(false)}>
|
||||
Cancelar
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
onClick={handleConfirmReplace}
|
||||
disabled={!selectedReplacement}
|
||||
color="success"
|
||||
variant="contained"
|
||||
autoFocus
|
||||
>
|
||||
Aceptar
|
||||
</MuiButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+47
-2
@@ -10,8 +10,8 @@ import { useAlert } from "@store/Alert.Store";
|
||||
import { useConfirmStore } from "@store/Confirm.Store";
|
||||
import { useEffect, useState } from "react";
|
||||
import MaterialAlert from "@mui/material/Alert";
|
||||
import { ScheduleItem } from "@core/Models/Collaborators.model";
|
||||
import { createSchedulesOverrides } from "../../../Collaborators.Service";
|
||||
import { CollaboratorServiceItems, ScheduleItem } from "@core/Models/Collaborators.model";
|
||||
import { createSchedulesOverrides, findCollaboratorsServices } from "../../../Collaborators.Service";
|
||||
import { Grid2 } from "@mui/material";
|
||||
import TextTime from "@core/app/components/TextTime/TextTime";
|
||||
import TextDate from "@core/app/components/TextDate/TextDate";
|
||||
@@ -51,11 +51,24 @@ export default function OverridesCreate() {
|
||||
const [from, setFrom] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(8).minute(0));
|
||||
const [to, setTo] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(12).minute(0));
|
||||
const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]);
|
||||
const [services, setServices] = useState<CollaboratorServiceItems[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
findCollaboratorsServices({
|
||||
companyId: id,
|
||||
employeeId: cid,
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then((collaboratorServices) => {
|
||||
setServices(collaboratorServices?.services.filter((service) => service.active) || []);
|
||||
})
|
||||
.catch((error) => alert.showError(error.format()));
|
||||
}, [id, cid, SessionInfo.userId]);
|
||||
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
@@ -109,6 +122,8 @@ export default function OverridesCreate() {
|
||||
from: schedule.from.format("HH:mm"),
|
||||
to: schedule.to.format("HH:mm"),
|
||||
disabled: schedule.data.disabled || false,
|
||||
serviceScope: schedule.data.serviceScope || "all",
|
||||
serviceIds: schedule.data.serviceScope === "specific" ? schedule.data.serviceIds || [] : [],
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -121,6 +136,8 @@ export default function OverridesCreate() {
|
||||
from: from.format("HH:mm"),
|
||||
to: to.format("HH:mm"),
|
||||
disabled: false,
|
||||
serviceScope: "all",
|
||||
serviceIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -174,6 +191,30 @@ export default function OverridesCreate() {
|
||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||
};
|
||||
|
||||
const serviceScopeChangeHandler = (
|
||||
from: string,
|
||||
serviceScope: "all" | "specific",
|
||||
serviceIds: string[]
|
||||
) => {
|
||||
setSchedules(
|
||||
schedules.map((schedule) => {
|
||||
if (schedule.data.from == from) {
|
||||
return {
|
||||
...schedule,
|
||||
data: {
|
||||
...schedule.data,
|
||||
serviceScope,
|
||||
serviceIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return schedule;
|
||||
})
|
||||
);
|
||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Location />
|
||||
@@ -225,7 +266,11 @@ export default function OverridesCreate() {
|
||||
from={schedule.data.from}
|
||||
to={schedule.data.to}
|
||||
disabled={schedule.data.disabled}
|
||||
serviceScope={schedule.data.serviceScope}
|
||||
serviceIds={schedule.data.serviceIds}
|
||||
serviceOptions={services}
|
||||
onCheckChange={checkChangeHandler}
|
||||
onServiceScopeChange={serviceScopeChangeHandler}
|
||||
onDelete={deleteScheduleHandler}
|
||||
/>
|
||||
))}
|
||||
|
||||
+47
-1
@@ -10,8 +10,9 @@ import { useAlert } from "@store/Alert.Store";
|
||||
import { useConfirmStore } from "@store/Confirm.Store";
|
||||
import { useEffect, useState } from "react";
|
||||
import MaterialAlert from "@mui/material/Alert";
|
||||
import { CollaboratorSchedulesView, ScheduleItem } from "@core/Models/Collaborators.model";
|
||||
import { CollaboratorSchedulesView, CollaboratorServiceItems, ScheduleItem } from "@core/Models/Collaborators.model";
|
||||
import {
|
||||
findCollaboratorsServices,
|
||||
findCollaboratorsSchedules,
|
||||
updateCollaboratorsSchedules,
|
||||
} from "../../Collaborators.Service";
|
||||
@@ -51,6 +52,7 @@ export default function CollaboratorSchedules() {
|
||||
const [from, setFrom] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(8).minute(0));
|
||||
const [to, setTo] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(12).minute(0));
|
||||
const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]);
|
||||
const [services, setServices] = useState<CollaboratorServiceItems[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
@@ -86,6 +88,18 @@ export default function CollaboratorSchedules() {
|
||||
loadCollaboratorSchedules();
|
||||
}, [weekDay]);
|
||||
|
||||
useEffect(() => {
|
||||
findCollaboratorsServices({
|
||||
companyId: id,
|
||||
employeeId: cid,
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then((collaboratorServices) => {
|
||||
setServices(collaboratorServices?.services.filter((service) => service.active) || []);
|
||||
})
|
||||
.catch((error) => alert.showError(error.format()));
|
||||
}, [id, cid, SessionInfo.userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventHandler.eventType === EVENT_TYPES.SAVE) {
|
||||
updateCollaboratorsSchedules({
|
||||
@@ -158,6 +172,8 @@ export default function CollaboratorSchedules() {
|
||||
from: schedule.from.format("HH:mm"),
|
||||
to: schedule.to.format("HH:mm"),
|
||||
disabled: schedule.data.disabled || false,
|
||||
serviceScope: schedule.data.serviceScope || "all",
|
||||
serviceIds: schedule.data.serviceScope === "specific" ? schedule.data.serviceIds || [] : [],
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -170,6 +186,8 @@ export default function CollaboratorSchedules() {
|
||||
from: from.format("HH:mm"),
|
||||
to: to.format("HH:mm"),
|
||||
disabled: false,
|
||||
serviceScope: "all",
|
||||
serviceIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -248,6 +266,30 @@ export default function CollaboratorSchedules() {
|
||||
|
||||
};
|
||||
|
||||
const serviceScopeChangeHandler = (
|
||||
from: string,
|
||||
serviceScope: "all" | "specific",
|
||||
serviceIds: string[]
|
||||
) => {
|
||||
setSchedules(
|
||||
schedules.map((schedule) => {
|
||||
if (schedule.data.from == from) {
|
||||
return {
|
||||
...schedule,
|
||||
data: {
|
||||
...schedule.data,
|
||||
serviceScope,
|
||||
serviceIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return schedule;
|
||||
})
|
||||
);
|
||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
@@ -291,7 +333,11 @@ export default function CollaboratorSchedules() {
|
||||
from={schedule.data.from}
|
||||
to={schedule.data.to}
|
||||
disabled={schedule.data.disabled}
|
||||
serviceScope={schedule.data.serviceScope}
|
||||
serviceIds={schedule.data.serviceIds}
|
||||
serviceOptions={services}
|
||||
onCheckChange={checkChangeHandler}
|
||||
onServiceScopeChange={serviceScopeChangeHandler}
|
||||
onDelete={deleteScheduleHandler}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -11,6 +11,7 @@ import Menu from "@components/Menu/Menu";
|
||||
import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store";
|
||||
import Content from "@core/app/components/Content/Content";
|
||||
import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -18,24 +19,33 @@ type UrlData = {
|
||||
|
||||
export default function OrgLayout({ children }: { children: React.ReactNode }) {
|
||||
const [organization, setOrganization] = useState<MyOranizationsView | undefined>();
|
||||
const [isCheckingAccess, setIsCheckingAccess] = useState(true);
|
||||
const data = useParams<UrlData>();
|
||||
const organizationId = data.id;
|
||||
const SessionInfo = useSessionStore();
|
||||
const organizationHeader = useOrganizationHeaderStore();
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!SessionInfo.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadOrganizationsById({ id: organizationId, sessionUser: SessionInfo.userId }).then(
|
||||
(organization) => {
|
||||
setIsCheckingAccess(true);
|
||||
loadOrganizationsById({ id: organizationId, sessionUser: SessionInfo.userId })
|
||||
.then((organization) => {
|
||||
setOrganization(organization);
|
||||
organizationHeader.setHeaderColor(organization.headerColor);
|
||||
organizationHeader.setHeaderFontColor(organization.headerFontColor);
|
||||
organizationHeader.setHeaderFontShadowColor(organization.headerFontShadowColor);
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setOrganization(undefined);
|
||||
goTo("/admin/access-denied");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsCheckingAccess(false);
|
||||
});
|
||||
}, [SessionInfo.userId]);
|
||||
|
||||
return (
|
||||
@@ -43,9 +53,14 @@ export default function OrgLayout({ children }: { children: React.ReactNode }) {
|
||||
<Content>
|
||||
<Suspense fallback={<SuspenseLoading />}>
|
||||
<Secure>
|
||||
{isCheckingAccess && <SuspenseLoading />}
|
||||
{organization && <OrganizationHeader data={organization} />}
|
||||
<OrganizationContent>{children}</OrganizationContent>
|
||||
<Menu />
|
||||
{organization && (
|
||||
<>
|
||||
<OrganizationContent>{children}</OrganizationContent>
|
||||
<Menu />
|
||||
</>
|
||||
)}
|
||||
</Secure>
|
||||
</Suspense>
|
||||
</Content>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
.page {
|
||||
align-items: center;
|
||||
background: radial-gradient(circle at top, rgba(255, 47, 125, 0.24), transparent 30%), linear-gradient(160deg, #3b0076 0%, #4b0097 48%, #8b2cff 100%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 48px 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 28px 70px rgba(20, 0, 50, 0.28);
|
||||
max-width: 720px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.accent {
|
||||
background: linear-gradient(90deg, #ff2f7d, #ff6b35, #7c3aed);
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.content {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 44px 42px 38px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.iconWrap {
|
||||
align-items: center;
|
||||
background: linear-gradient(145deg, rgba(255, 47, 125, 0.12), rgba(124, 58, 237, 0.12));
|
||||
border: 1px solid rgba(255, 47, 125, 0.2);
|
||||
border-radius: 24px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
color: #ff2f7d;
|
||||
display: flex;
|
||||
height: 76px;
|
||||
justify-content: center;
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
background: rgba(255, 47, 125, 0.1);
|
||||
border-radius: 999px;
|
||||
color: #b91c5c;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 7px 13px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #2b174f;
|
||||
font-size: clamp(28px, 4vw, 38px);
|
||||
line-height: 1.05;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #4b5563;
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
font-weight: 900;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
padding: 0 20px;
|
||||
text-decoration: none;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
background: linear-gradient(135deg, #ff2f7d, #7c3aed);
|
||||
box-shadow: 0 14px 28px rgba(124, 58, 237, 0.28);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(124, 58, 237, 0.18);
|
||||
color: #4b148c;
|
||||
}
|
||||
|
||||
.primaryAction:hover,
|
||||
.secondaryAction:hover {
|
||||
box-shadow: 0 18px 34px rgba(43, 23, 79, 0.18);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.content {
|
||||
padding: 34px 24px 30px;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import HomeOutlinedIcon from "@mui/icons-material/HomeOutlined";
|
||||
import GroupsOutlinedIcon from "@mui/icons-material/GroupsOutlined";
|
||||
import style from "./access-denied.module.css";
|
||||
|
||||
export default function AdminAccessDeniedPage() {
|
||||
return (
|
||||
<div className={style.page}>
|
||||
<div className={style.card}>
|
||||
<div className={style.accent} />
|
||||
<div className={style.content}>
|
||||
<div className={style.iconWrap}>
|
||||
<ShieldOutlinedIcon sx={{ fontSize: 42 }} />
|
||||
</div>
|
||||
<span className={style.eyebrow}>Acceso restringido</span>
|
||||
<h1 className={style.title}>No tenés permisos para acceder</h1>
|
||||
<p className={style.description}>
|
||||
Esta sección pertenece a una organización en la que ya no tenés permisos activos.
|
||||
Si creés que se trata de un error, comunicate con un administrador de la organización.
|
||||
</p>
|
||||
<div className={style.actions}>
|
||||
<Link className={style.primaryAction} href="/admin/org">
|
||||
<GroupsOutlinedIcon sx={{ fontSize: 19, mr: 0.75 }} />
|
||||
Ir a Mis organizaciones
|
||||
</Link>
|
||||
<Link className={style.secondaryAction} href="/landing/dashboard">
|
||||
<HomeOutlinedIcon sx={{ fontSize: 19, mr: 0.75 }} />
|
||||
Volver al dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -120,6 +120,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
const [inviteRole, setInviteRole] = useState<"admin" | "colaborador">("colaborador");
|
||||
const [isSearchingEmail, setIsSearchingEmail] = useState(false);
|
||||
const [isUpgradeRequired, setIsUpgradeRequired] = useState(false);
|
||||
const [inviteFlowNotice, setInviteFlowNotice] = useState("");
|
||||
|
||||
// State for Setup Collaborator Flow
|
||||
const [collaboratorToSetup, setCollaboratorToSetup] = useState<any | null>(null);
|
||||
@@ -128,6 +129,13 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
const [setupSelectedServices, setSetupSelectedServices] = useState<string[]>([]);
|
||||
const [setupAvailableServices, setSetupAvailableServices] = useState<any[]>([]);
|
||||
|
||||
const getEmployeeUserId = (employee: any) => employee.userId?._id || employee.userId?.id || employee.userId;
|
||||
|
||||
const canManageEmployeeSchedules = (employee: any) => {
|
||||
const isCurrentUser = String(getEmployeeUserId(employee)) === String(SessionInfo.userId);
|
||||
return !employee.removed && (isCurrentUser || employee.guestOk === true);
|
||||
};
|
||||
|
||||
const loadOrganizationContext = (org: any) => {
|
||||
setCreatedCompanyId(org.id || org._id);
|
||||
setOrgName(org.name || "");
|
||||
@@ -219,11 +227,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
API.post<any[]>("employees/get-by-company", { companyId: org.id || org._id })
|
||||
.then(empRes => {
|
||||
if (empRes && empRes.length > 0) {
|
||||
setAllEmployees(empRes);
|
||||
const me = empRes.find((e: any) => {
|
||||
const eUserId = e.userId?._id || e.userId?.id || e.userId;
|
||||
return String(eUserId) === String(SessionInfo.userId);
|
||||
});
|
||||
const availableEmployees = empRes.filter(canManageEmployeeSchedules);
|
||||
setAllEmployees(availableEmployees);
|
||||
const me = availableEmployees.find((e: any) => String(getEmployeeUserId(e)) === String(SessionInfo.userId));
|
||||
if (me) {
|
||||
setEmployeeId(me.id || me._id || me.employeeId);
|
||||
}
|
||||
@@ -1256,7 +1262,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
for (let i = 0; i <= 6; i++) {
|
||||
const isWorkingDay = workingDays.includes(i);
|
||||
|
||||
let schedules = [];
|
||||
let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
|
||||
if (isWorkingDay) {
|
||||
if (hasBreak) {
|
||||
schedules = [
|
||||
@@ -1269,9 +1275,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
];
|
||||
}
|
||||
} else {
|
||||
schedules = [
|
||||
{ from: "00:00", to: "23:59", disabled: true }
|
||||
];
|
||||
schedules = [];
|
||||
}
|
||||
|
||||
await API.post<any>("schedules/update", {
|
||||
@@ -1368,14 +1372,14 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
onNext={() => {
|
||||
if (doesProvideServices === null) return;
|
||||
if (!doesProvideServices) {
|
||||
// If they don't provide services, finish setup directly
|
||||
window.location.href = "/landing/dashboard";
|
||||
const query = createdCompanyId ? `&companyId=${createdCompanyId}` : "";
|
||||
router.push(`/admin/assistant?flow=new-collaborator${query}`);
|
||||
} else {
|
||||
handleNext();
|
||||
}
|
||||
}}
|
||||
disableNext={doesProvideServices === null}
|
||||
nextLabel={doesProvideServices === false ? "Finalizar" : "Siguiente"}
|
||||
nextLabel={doesProvideServices === false ? "Invitar colaborador" : "Siguiente"}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
|
||||
<div
|
||||
@@ -1412,7 +1416,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: '0 0 5px 0', fontSize: '18px', fontWeight: doesProvideServices === false ? 'bold' : 'normal' }}>No, tengo un equipo</h4>
|
||||
<p style={{ margin: 0, fontSize: '13px', color: 'rgba(255,255,255,0.7)' }}>Terminaremos aquí. Luego podrás invitar a tus colaboradores.</p>
|
||||
<p style={{ margin: 0, fontSize: '13px', color: 'rgba(255,255,255,0.7)' }}>Te guiaremos para invitar al primer colaborador.</p>
|
||||
</div>
|
||||
</div>
|
||||
</QuestionCard>
|
||||
@@ -1724,7 +1728,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
for (let i = 0; i <= 6; i++) {
|
||||
const isWorkingDay = workingDays.includes(i);
|
||||
|
||||
let schedules = [];
|
||||
let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
|
||||
if (isWorkingDay) {
|
||||
if (hasBreak) {
|
||||
schedules = [
|
||||
@@ -1737,9 +1741,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
];
|
||||
}
|
||||
} else {
|
||||
schedules = [
|
||||
{ from: "00:00", to: "23:59", disabled: true }
|
||||
];
|
||||
schedules = [];
|
||||
}
|
||||
|
||||
await API.post<any>("schedules/update", {
|
||||
@@ -2016,7 +2018,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
return;
|
||||
}
|
||||
|
||||
let schedules = [];
|
||||
let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
|
||||
if (hasBreak) {
|
||||
schedules = [
|
||||
{ from: openTime, to: breakStart, disabled: false },
|
||||
@@ -2653,6 +2655,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
const handleCreateCollaborator = async () => {
|
||||
if (!inviteCollaborator || !createdCompanyId) return;
|
||||
setIsCreating(true);
|
||||
setInviteFlowNotice("");
|
||||
try {
|
||||
await API.post<any>("employees/create", {
|
||||
companyId: createdCompanyId,
|
||||
@@ -2661,13 +2664,21 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
hostOk: true,
|
||||
sessionUser: SessionInfo.userId
|
||||
});
|
||||
handleActionSuccess();
|
||||
handleNext();
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.desc || error.message || "Error al invitar al colaborador";
|
||||
if (errorMessage.toLowerCase().includes("limite") || errorMessage.toLowerCase().includes("límite")) {
|
||||
const normalizedErrorMessage = errorMessage.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||
if (normalizedErrorMessage.includes("limite")) {
|
||||
setIsUpgradeRequired(true);
|
||||
} else if (normalizedErrorMessage.includes("invitacion") && normalizedErrorMessage.includes("pendiente")) {
|
||||
setInviteFlowNotice("Ya se envió una invitación para que este usuario forme parte de la compañía y todavía no respondió.");
|
||||
setCurrentStepIndex(5);
|
||||
} else if (normalizedErrorMessage.includes("ya pertenece")) {
|
||||
setInviteFlowNotice("Este usuario ya pertenece a la compañía.");
|
||||
setCurrentStepIndex(6);
|
||||
} else {
|
||||
alert(errorMessage);
|
||||
setInviteFlowNotice(errorMessage);
|
||||
setCurrentStepIndex(5);
|
||||
}
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
@@ -2752,6 +2763,109 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
{null}
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 4}
|
||||
title="Invitación Enviada"
|
||||
description="¿Cómo quieres continuar?"
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
|
||||
<div
|
||||
onClick={() => {
|
||||
setInviteEmail("");
|
||||
setInviteCollaborator(null);
|
||||
setInviteEmailError("");
|
||||
setInviteRole("colaborador");
|
||||
setIsUpgradeRequired(false);
|
||||
setInviteFlowNotice("");
|
||||
setCurrentStepIndex(1);
|
||||
}}
|
||||
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>Invitar otro colaborador</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Agrega otra persona a tu equipo.</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
const query = createdCompanyId ? `&companyId=${createdCompanyId}` : "";
|
||||
router.push(`/admin/assistant?flow=update-schedule${query}`);
|
||||
}}
|
||||
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>Configurar mis horarios</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Define tus horarios personales de atención.</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => router.push("/admin/assistant")}
|
||||
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>Terminar</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Volver a la home del asistente.</div>
|
||||
</div>
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 5}
|
||||
title="Invitación Pendiente"
|
||||
description={inviteFlowNotice || "No pudimos enviar la invitación en este momento."}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
|
||||
<div
|
||||
onClick={() => {
|
||||
setInviteEmail("");
|
||||
setInviteCollaborator(null);
|
||||
setInviteEmailError("");
|
||||
setInviteRole("colaborador");
|
||||
setIsUpgradeRequired(false);
|
||||
setInviteFlowNotice("");
|
||||
setCurrentStepIndex(1);
|
||||
}}
|
||||
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>Invitar otro colaborador</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Busca otra persona para sumar al equipo.</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => router.push("/admin/assistant")}
|
||||
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>Volver al asistente</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Regresar a la home del asistente.</div>
|
||||
</div>
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 6}
|
||||
title="El usuario ya pertenece"
|
||||
description={inviteFlowNotice || "Este usuario ya pertenece a la compañía."}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
|
||||
<div
|
||||
onClick={() => {
|
||||
setInviteEmail("");
|
||||
setInviteCollaborator(null);
|
||||
setInviteEmailError("");
|
||||
setInviteRole("colaborador");
|
||||
setIsUpgradeRequired(false);
|
||||
setInviteFlowNotice("");
|
||||
setCurrentStepIndex(1);
|
||||
}}
|
||||
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>Invitar otro colaborador</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Busca otra persona para sumar al equipo.</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => router.push("/admin/assistant")}
|
||||
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>Volver al asistente</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Regresar a la home del asistente.</div>
|
||||
</div>
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 3 && isUpgradeRequired}
|
||||
title="¡Haz crecer tu equipo!"
|
||||
@@ -3202,6 +3316,24 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
return "Has configurado exitosamente tu negocio. Todo está listo para que empieces a recibir reservas y gestionar tu agenda como un profesional.";
|
||||
};
|
||||
|
||||
const successButtonStyle: React.CSSProperties = {
|
||||
padding: '16px 40px', fontSize: '18px', fontWeight: 'bold',
|
||||
background: 'white', color: 'var(--wine-red)', border: 'none',
|
||||
borderRadius: '30px', cursor: 'pointer',
|
||||
boxShadow: '0 4px 15px rgba(0,0,0,0.3)',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s'
|
||||
};
|
||||
|
||||
const handleSuccessButtonMouseOver = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(255,255,255,0.3)';
|
||||
};
|
||||
|
||||
const handleSuccessButtonMouseOut = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0,0,0,0.3)';
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||
@@ -3232,7 +3364,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
{getSuccessMessage()}
|
||||
</p>
|
||||
|
||||
<button
|
||||
<button
|
||||
onClick={() => {
|
||||
if (action) {
|
||||
router.push("/admin/assistant");
|
||||
@@ -3240,21 +3372,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
window.location.href = "/landing/dashboard";
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '16px 40px', fontSize: '18px', fontWeight: 'bold',
|
||||
background: 'white', color: 'var(--wine-red)', border: 'none',
|
||||
borderRadius: '30px', cursor: 'pointer',
|
||||
boxShadow: '0 4px 15px rgba(0,0,0,0.3)',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s'
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(255,255,255,0.3)';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0,0,0,0.3)';
|
||||
}}
|
||||
style={successButtonStyle}
|
||||
onMouseOver={handleSuccessButtonMouseOver}
|
||||
onMouseOut={handleSuccessButtonMouseOut}
|
||||
>
|
||||
{action ? "Volver al Menú Principal" : "Ir a mi Panel de Control"}
|
||||
</button>
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ButtonProps {
|
||||
style?: React.CSSProperties;
|
||||
width?: ButtonWidth;
|
||||
color?: ButtonColor;
|
||||
disabled?: boolean;
|
||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ export default function Button(props: ButtonProps): React.ReactElement {
|
||||
id={props.name}
|
||||
value={props.text}
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
style={props.style}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -18,6 +18,19 @@
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.headerButton {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
@@ -25,6 +38,22 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toggleIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
background: var(--white);
|
||||
color: var(--green-darkestX1);
|
||||
border: 1px solid var(--green-darkestX1);
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user