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
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.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_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
|
||||||
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
|
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
|
||||||
MP_CHECK_PAYMENT_MINUTES = 1
|
MP_CHECK_PAYMENT_MINUTES = 1
|
||||||
MP_WEBHOOK_URL = https://5747-186-132-168-62.ngrok-free.app/mercadopago/webhook
|
MP_WEBHOOK_URL = https://9300-186-132-200-176.ngrok-free.app/mercadopago/webhook
|
||||||
|
|
||||||
DEFAULT_BOT_ADMIN_PORT = 3005
|
DEFAULT_BOT_ADMIN_PORT = 3005
|
||||||
|
|
||||||
|
|||||||
Generated
+822
-488
File diff suppressed because it is too large
Load Diff
+25
-25
@@ -14,39 +14,39 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.13",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "4.17.17",
|
"@types/express": "^4.17.25",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.2",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/nodemailer": "^6.4.24",
|
||||||
"@types/socket.io": "^3.0.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"@types/swagger-ui-express": "^4.1.3",
|
"@types/socket.io": "^3.0.2",
|
||||||
"concurrently": "^8.2.0",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"csv-parse": "^5.4.0",
|
"concurrently": "^8.2.2",
|
||||||
|
"csv-parse": "^5.6.0",
|
||||||
"jest": "^30.4.2",
|
"jest": "^30.4.2",
|
||||||
"ts-jest": "^29.4.11",
|
"ts-jest": "^29.4.11",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "5.0.4"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/nodemailer": "^6.4.16",
|
"axios": "^1.18.1",
|
||||||
"axios": "^1.7.7",
|
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.6",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.21",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.6.1",
|
||||||
"express": "^4.21.1",
|
"express": "^4.22.2",
|
||||||
"google-auth-library": "^10.1.0",
|
"google-auth-library": "^10.9.0",
|
||||||
"joi": "^17.9.2",
|
"joi": "^17.13.4",
|
||||||
"jsonwebtoken": "^9.0.1",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mongoose": "^8.8.3",
|
"mongoose": "8.8.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.2",
|
||||||
"nodemailer": "^6.9.15",
|
"nodemailer": "^6.10.1",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.6",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.3",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tsoa": "^5.1.1"
|
"tsoa": "^5.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type FindAppointmentsParams = {
|
|||||||
clientId?: string;
|
clientId?: string;
|
||||||
start?: Date;
|
start?: Date;
|
||||||
dateDay?: Date;
|
dateDay?: Date;
|
||||||
|
sessionUser?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FindAppointmentsByUserParams = {
|
export type FindAppointmentsByUserParams = {
|
||||||
@@ -244,6 +245,7 @@ export interface AppointmentEventByClient {
|
|||||||
serviceName: string;
|
serviceName: string;
|
||||||
serviceDescription: string;
|
serviceDescription: string;
|
||||||
collaboratorId: string;
|
collaboratorId: string;
|
||||||
|
collaboratorUserId: string;
|
||||||
collaboratorName: string;
|
collaboratorName: string;
|
||||||
collaboratorAvatar: string;
|
collaboratorAvatar: string;
|
||||||
appointmentDate: string;
|
appointmentDate: string;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ import { DiscountType } from "../Discounts/Discounts.Interface";
|
|||||||
import Discounts from "../Discounts/Discounts";
|
import Discounts from "../Discounts/Discounts";
|
||||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||||
import Templates from "../Templates/Templates";
|
import Templates from "../Templates/Templates";
|
||||||
import { CollaboratorSchedulesView } from "../Schedules/Schedules.Interface";
|
import { CollaboratorSchedulesView, ScheduleItem } from "../Schedules/Schedules.Interface";
|
||||||
import ClientAccount from "../ClientAccounts/ClientAccount";
|
import ClientAccount from "../ClientAccounts/ClientAccount";
|
||||||
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
|
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
|
||||||
import DiscountsEmail from "../DiscountsEmail/DiscountsEmail";
|
import DiscountsEmail from "../DiscountsEmail/DiscountsEmail";
|
||||||
@@ -89,6 +89,14 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
this.Appointments = new AppointmentsAdapterMongoose();
|
this.Appointments = new AppointmentsAdapterMongoose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
|
||||||
|
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
public async setPaymentStatus(data: SetPaymentStatusParams): Promise<void> {
|
public async setPaymentStatus(data: SetPaymentStatusParams): Promise<void> {
|
||||||
const sessionUser = await UsersManager.users.findOne({
|
const sessionUser = await UsersManager.users.findOne({
|
||||||
_id: data.sessionUser,
|
_id: data.sessionUser,
|
||||||
@@ -305,12 +313,6 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
throw new Error("La compañia no existe");
|
throw new Error("La compañia no existe");
|
||||||
}
|
}
|
||||||
|
|
||||||
const canAdd = await MetricsList.canAddAppointment(companyCheck.ownerId);
|
|
||||||
|
|
||||||
if (!canAdd) {
|
|
||||||
throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceCheck = await ServicesManager.services.findOne({
|
const serviceCheck = await ServicesManager.services.findOne({
|
||||||
_id: data.serviceId,
|
_id: data.serviceId,
|
||||||
companyId: data.companyId,
|
companyId: data.companyId,
|
||||||
@@ -443,6 +445,18 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
|
|
||||||
data.comments = isNull<string>(data.comments, "");
|
data.comments = isNull<string>(data.comments, "");
|
||||||
|
|
||||||
|
const appointmentReserved = await MetricsList.reserveAppointment({
|
||||||
|
userId: String(companyCheck.ownerId),
|
||||||
|
companyId: data.companyId,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!appointmentReserved) {
|
||||||
|
throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let appointmentCreated = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const start = dayjs(data.start).clone();
|
const start = dayjs(data.start).clone();
|
||||||
const end = dayjs(data.start)
|
const end = dayjs(data.start)
|
||||||
@@ -467,6 +481,7 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
comments: data.comments,
|
comments: data.comments,
|
||||||
sessionUser: data.sessionUser,
|
sessionUser: data.sessionUser,
|
||||||
});
|
});
|
||||||
|
appointmentCreated = true;
|
||||||
|
|
||||||
if (data.repeatId) {
|
if (data.repeatId) {
|
||||||
const repeat = await RepeatsList.repeats.findOne({
|
const repeat = await RepeatsList.repeats.findOne({
|
||||||
@@ -489,8 +504,9 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await MetricsList.addAppointment({
|
await MetricsList.metrics.addAppointment({
|
||||||
userId: companyCheck.ownerId,
|
userId: companyCheck.ownerId,
|
||||||
|
companyId: data.companyId,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -559,6 +575,13 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
return newAppointment;
|
return newAppointment;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
|
if (!appointmentCreated) {
|
||||||
|
await MetricsList.releaseAppointment({
|
||||||
|
userId: String(companyCheck.ownerId),
|
||||||
|
companyId: data.companyId,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
throw new Error("Ha ocurrido un error al crear el turno.");
|
throw new Error("Ha ocurrido un error al crear el turno.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -920,6 +943,7 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
dateDay: dayjs(data.start).startOf("day").toDate(),
|
dateDay: dayjs(data.start).startOf("day").toDate(),
|
||||||
from: checkFrom,
|
from: checkFrom,
|
||||||
to: checkTo,
|
to: checkTo,
|
||||||
|
serviceId: data.serviceId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
//chequear que el colaborador este disponible.
|
//chequear que el colaborador este disponible.
|
||||||
@@ -929,6 +953,7 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
weekDay: weekDay,
|
weekDay: weekDay,
|
||||||
from: checkFrom,
|
from: checkFrom,
|
||||||
to: checkTo,
|
to: checkTo,
|
||||||
|
serviceId: data.serviceId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1037,6 +1062,9 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Historical read: Employee lookup includes removed employees (historical reads allow them)
|
||||||
|
// No snapshot fallback needed here as employee.calendarColor is used directly
|
||||||
|
|
||||||
const clientName = joinStrings([client.firstName, client.lastName], " ");
|
const clientName = joinStrings([client.firstName, client.lastName], " ");
|
||||||
const clientUserName = userClient
|
const clientUserName = userClient
|
||||||
? joinStrings([userClient.firstName, userClient.lastName], clientName)
|
? joinStrings([userClient.firstName, userClient.lastName], clientName)
|
||||||
@@ -1236,9 +1264,17 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
_id: String(employee.userId),
|
_id: String(employee.userId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!userEmployee) {
|
// Historical read snapshot fallback: use Employee snapshot if User is deleted
|
||||||
throw new Error("No se ha encontrado el usuario del colaborador");
|
const collaboratorFirstName = userEmployee
|
||||||
}
|
? isNull<string>(userEmployee.firstName, "")
|
||||||
|
: isNull<string>(employee.profileSnapshot?.firstName, "");
|
||||||
|
const collaboratorLastName = userEmployee
|
||||||
|
? isNull<string>(userEmployee.lastName, "")
|
||||||
|
: isNull<string>(employee.profileSnapshot?.lastName, "");
|
||||||
|
const collaboratorFullName = joinStrings([collaboratorFirstName, collaboratorLastName], " ");
|
||||||
|
const collaboratorAvatar = userEmployee
|
||||||
|
? getAvatar(employee.userId, userEmployee.avatar, collaboratorFullName)
|
||||||
|
: (employee.profileSnapshot?.avatar || collaboratorFullName);
|
||||||
|
|
||||||
const service = await ServiceList.services.findOne({
|
const service = await ServiceList.services.findOne({
|
||||||
_id: String(appointment.serviceId),
|
_id: String(appointment.serviceId),
|
||||||
@@ -1256,14 +1292,6 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
? getAvatar(client.userId, userClient.avatar, clientUserName)
|
? getAvatar(client.userId, userClient.avatar, clientUserName)
|
||||||
: clientUserName;
|
: clientUserName;
|
||||||
|
|
||||||
const employeeFirstName = isNull<string>(userEmployee.firstName, "");
|
|
||||||
const employeeLastName = isNull<string>(userEmployee.lastName, "");
|
|
||||||
const employeeFullName = joinStrings([employeeFirstName, employeeLastName], " ");
|
|
||||||
|
|
||||||
const employeeAvatar = userEmployee
|
|
||||||
? getAvatar(employee.userId, userEmployee.avatar, employeeFullName)
|
|
||||||
: clientUserName;
|
|
||||||
|
|
||||||
const discountData = await Discounts.getDiscountData(appointment.discountId, appointment.companyId);
|
const discountData = await Discounts.getDiscountData(appointment.discountId, appointment.companyId);
|
||||||
|
|
||||||
let ctaBalance = 0;
|
let ctaBalance = 0;
|
||||||
@@ -1285,8 +1313,9 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
serviceName: service.name,
|
serviceName: service.name,
|
||||||
serviceDescription: service.description,
|
serviceDescription: service.description,
|
||||||
collaboratorId: employee.id,
|
collaboratorId: employee.id,
|
||||||
collaboratorName: employeeFullName,
|
collaboratorUserId: String(employee.userId),
|
||||||
collaboratorAvatar: employeeAvatar,
|
collaboratorName: collaboratorFullName,
|
||||||
|
collaboratorAvatar: collaboratorAvatar,
|
||||||
appointmentDate: dayjs(appointment.start).toISOString(),
|
appointmentDate: dayjs(appointment.start).toISOString(),
|
||||||
startTime: appointment.startHour,
|
startTime: appointment.startHour,
|
||||||
endTime: appointment.endHour,
|
endTime: appointment.endHour,
|
||||||
@@ -1394,6 +1423,7 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
serviceName: service.name,
|
serviceName: service.name,
|
||||||
serviceDescription: service.description,
|
serviceDescription: service.description,
|
||||||
collaboratorId: employee.id,
|
collaboratorId: employee.id,
|
||||||
|
collaboratorUserId: String(employee.userId),
|
||||||
collaboratorName: employeeFullName,
|
collaboratorName: employeeFullName,
|
||||||
collaboratorAvatar: employeeAvatar,
|
collaboratorAvatar: employeeAvatar,
|
||||||
appointmentDate: dayjs(appointment.start).toISOString(),
|
appointmentDate: dayjs(appointment.start).toISOString(),
|
||||||
@@ -1503,6 +1533,7 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
serviceName: service.name,
|
serviceName: service.name,
|
||||||
serviceDescription: service.description,
|
serviceDescription: service.description,
|
||||||
collaboratorId: employee.id,
|
collaboratorId: employee.id,
|
||||||
|
collaboratorUserId: String(employee.userId),
|
||||||
collaboratorName: employeeFullName,
|
collaboratorName: employeeFullName,
|
||||||
collaboratorAvatar: employeeAvatar,
|
collaboratorAvatar: employeeAvatar,
|
||||||
appointmentDate: dayjs(appointment.start).toISOString(),
|
appointmentDate: dayjs(appointment.start).toISOString(),
|
||||||
@@ -1621,6 +1652,7 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
serviceName: service.name,
|
serviceName: service.name,
|
||||||
serviceDescription: service.description,
|
serviceDescription: service.description,
|
||||||
collaboratorId: String(employee.id || (employee as any)._id),
|
collaboratorId: String(employee.id || (employee as any)._id),
|
||||||
|
collaboratorUserId: String(employee.userId),
|
||||||
collaboratorName: employeeFullName,
|
collaboratorName: employeeFullName,
|
||||||
collaboratorAvatar: employeeAvatar,
|
collaboratorAvatar: employeeAvatar,
|
||||||
appointmentDate: dayjs(appointment.start).toISOString(),
|
appointmentDate: dayjs(appointment.start).toISOString(),
|
||||||
@@ -1738,6 +1770,7 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
serviceName: service.name,
|
serviceName: service.name,
|
||||||
serviceDescription: service.description,
|
serviceDescription: service.description,
|
||||||
collaboratorId: employee.id,
|
collaboratorId: employee.id,
|
||||||
|
collaboratorUserId: String(employee.userId),
|
||||||
collaboratorName: employeeFullName,
|
collaboratorName: employeeFullName,
|
||||||
collaboratorAvatar: employeeAvatar,
|
collaboratorAvatar: employeeAvatar,
|
||||||
appointmentDate: dayjs(appointment.start).toISOString(),
|
appointmentDate: dayjs(appointment.start).toISOString(),
|
||||||
@@ -1865,6 +1898,7 @@ public async findAppointmentsByCollaboratorPaginated(
|
|||||||
serviceName: service.name,
|
serviceName: service.name,
|
||||||
serviceDescription: service.description,
|
serviceDescription: service.description,
|
||||||
collaboratorId: String(employee.id || (employee as any)._id),
|
collaboratorId: String(employee.id || (employee as any)._id),
|
||||||
|
collaboratorUserId: String(employee.userId),
|
||||||
collaboratorName: employeeFullName,
|
collaboratorName: employeeFullName,
|
||||||
collaboratorAvatar: employeeAvatar,
|
collaboratorAvatar: employeeAvatar,
|
||||||
appointmentDate: dayjs(appointment.start).toISOString(),
|
appointmentDate: dayjs(appointment.start).toISOString(),
|
||||||
@@ -2271,6 +2305,10 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.isScheduleAvailableForService(schedule, data.serviceId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleDataFrom = schedule.from.split(":");
|
const scheduleDataFrom = schedule.from.split(":");
|
||||||
const scheduleDataTo = schedule.to.split(":");
|
const scheduleDataTo = schedule.to.split(":");
|
||||||
const hourFrom = parseInt(scheduleDataFrom[0]);
|
const hourFrom = parseInt(scheduleDataFrom[0]);
|
||||||
@@ -2743,7 +2781,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
|||||||
date: currentDate.toDate(),
|
date: currentDate.toDate(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasOverride && hasOverride.schedules.length > 0) {
|
if (
|
||||||
|
hasOverride &&
|
||||||
|
hasOverride.schedules.some(
|
||||||
|
(schedule) =>
|
||||||
|
!schedule.disabled &&
|
||||||
|
(!schedule.serviceScope ||
|
||||||
|
schedule.serviceScope === "all" ||
|
||||||
|
(schedule.serviceScope === "specific" &&
|
||||||
|
(schedule.serviceIds || []).includes(data.serviceId)))
|
||||||
|
)
|
||||||
|
) {
|
||||||
returnAvailableDates.push(currentDate.toDate());
|
returnAvailableDates.push(currentDate.toDate());
|
||||||
} else if (!hasOverride) {
|
} else if (!hasOverride) {
|
||||||
// Check if the current date is within any enabled schedule range
|
// Check if the current date is within any enabled schedule range
|
||||||
@@ -2766,7 +2814,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
|||||||
weekDay: currentDate.day(),
|
weekDay: currentDate.day(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (schedule && schedule.schedules.length > 0) {
|
if (
|
||||||
|
schedule &&
|
||||||
|
schedule.schedules.some(
|
||||||
|
(scheduleItem) =>
|
||||||
|
!scheduleItem.disabled &&
|
||||||
|
(!scheduleItem.serviceScope ||
|
||||||
|
scheduleItem.serviceScope === "all" ||
|
||||||
|
(scheduleItem.serviceScope === "specific" &&
|
||||||
|
(scheduleItem.serviceIds || []).includes(data.serviceId)))
|
||||||
|
)
|
||||||
|
) {
|
||||||
returnAvailableDates.push(currentDate.toDate());
|
returnAvailableDates.push(currentDate.toDate());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Appointments.getAppointmentEvent snapshot fallback
|
||||||
|
* when a removed employee's User account has been deleted.
|
||||||
|
*
|
||||||
|
* The fallback uses Employee.profileSnapshot fields instead of crashing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mock all external dependencies
|
||||||
|
jest.mock("../../Users/Users", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
users: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
getUserFullName: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Clients/Clients", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
clients: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
getClientFullName: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Companies/Companies", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
companies: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Employees/Employee", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
employees: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Services/Service", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
services: {
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../HeatMap/HeatMap", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
getHeatMap: jest.fn(),
|
||||||
|
addHeatMap: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Schedules/Schedules", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
find: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../SchedulesEnabled/SchedulesEnabled", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../SchedulesDisabled/SchedulesDisabled", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
find: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../SchedulesOverrides/SchedulesOverrides", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
find: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Metrics/Metrics", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
addAppointment: jest.fn(),
|
||||||
|
canAddAppointment: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Notifications/Notifications", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
NotificationsManager: {
|
||||||
|
sendSystemNotification: jest.fn(),
|
||||||
|
sendPushNotification: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../SystemNotifications/SystemNotification", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Repeats/Repeats", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
repeats: {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
existDisabledDate: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Discounts/Discounts", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
getDiscountData: jest.fn().mockResolvedValue({
|
||||||
|
name: "",
|
||||||
|
value: 0,
|
||||||
|
type: "PERCENTAGE",
|
||||||
|
code: "",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../helpers/check", () => ({
|
||||||
|
validatePermissionsByCompany: jest.fn(),
|
||||||
|
validateSessionUser: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Templates/Templates", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
sendEmail: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../ClientAccounts/ClientAccount", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
getAvailableAmount: jest.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../DiscountsEmail/DiscountsEmail", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../index", () => ({
|
||||||
|
io: {
|
||||||
|
to: jest.fn(() => ({ emit: jest.fn() })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AppointmentsList from "../Appointments";
|
||||||
|
import UsersManager from "../../Users/Users";
|
||||||
|
import EmployeesList from "../../Employees/Employee";
|
||||||
|
import ClientsManager from "../../Clients/Clients";
|
||||||
|
import ServiceList from "../../Services/Service";
|
||||||
|
|
||||||
|
describe("getAppointmentEvent — historical read snapshot fallback", () => {
|
||||||
|
let originalAdapter: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Save and replace adapter
|
||||||
|
originalAdapter = AppointmentsList.Appointments;
|
||||||
|
const mockAppointmentFindOne = jest.fn();
|
||||||
|
(AppointmentsList as any).Appointments = {
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: mockAppointmentFindOne,
|
||||||
|
AppointmentList: {
|
||||||
|
findOne: mockAppointmentFindOne,
|
||||||
|
find: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(AppointmentsList as any).Appointments = originalAdapter;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to Employee snapshot when User account is deleted", async () => {
|
||||||
|
const mockAppointment = {
|
||||||
|
_id: "appt-001",
|
||||||
|
id: "appt-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
clientId: "client-001",
|
||||||
|
employeeId: "emp-removed",
|
||||||
|
serviceId: "svc-001",
|
||||||
|
start: new Date("2026-07-20T10:00:00Z"),
|
||||||
|
startHour: 10,
|
||||||
|
endHour: 11,
|
||||||
|
dateDay: new Date("2026-07-20"),
|
||||||
|
price: 100,
|
||||||
|
length: 60,
|
||||||
|
payment: false,
|
||||||
|
comments: "",
|
||||||
|
discountId: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
_id: "client-001",
|
||||||
|
id: "client-001",
|
||||||
|
userId: "client-user-001",
|
||||||
|
firstName: "Client",
|
||||||
|
lastName: "Smith",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockClientUser = {
|
||||||
|
_id: "client-user-001",
|
||||||
|
id: "client-user-001",
|
||||||
|
firstName: "Client",
|
||||||
|
lastName: "Smith",
|
||||||
|
avatar: "client-avatar",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Employee exists (removed, with snapshot)
|
||||||
|
const mockRemovedEmployee = {
|
||||||
|
_id: "emp-removed",
|
||||||
|
id: "emp-removed",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "deleted-user-id",
|
||||||
|
calendarColor: "#ff0000",
|
||||||
|
removed: true,
|
||||||
|
profileSnapshot: {
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
email: "john@example.com",
|
||||||
|
avatar: "old-avatar-url",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock adapter.findOne for the appointment
|
||||||
|
const mockAppointmentFindOne = (AppointmentsList as any).Appointments.AppointmentList.findOne;
|
||||||
|
mockAppointmentFindOne.mockResolvedValue(mockAppointment);
|
||||||
|
|
||||||
|
// Mock lookups
|
||||||
|
(ClientsManager.clients.findOne as jest.Mock).mockResolvedValue(mockClient);
|
||||||
|
(ServiceList.services.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "svc-001",
|
||||||
|
name: "Haircut",
|
||||||
|
description: "Professional haircut",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call order for UsersManager.users.findOne (same mock as UserList):
|
||||||
|
// 1. Line 1217: UserList.users.findOne (NOT awaited, but consumes a value)
|
||||||
|
// 2. Line 1241: UsersManager.users.findOne for userClient
|
||||||
|
// 3. Line 1253: UsersManager.users.findOne for userEmployee
|
||||||
|
(UsersManager.users.findOne as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ _id: "session-user" }) // line 1217: not awaited, just needs truthy
|
||||||
|
.mockResolvedValueOnce(mockClientUser) // line 1241: client user
|
||||||
|
.mockResolvedValueOnce(null); // line 1253: employee user DELETED → triggers fallback
|
||||||
|
(EmployeesList.employees.findOne as jest.Mock).mockResolvedValue(mockRemovedEmployee);
|
||||||
|
(EmployeesList as any).findById = jest.fn().mockResolvedValue({
|
||||||
|
id: "emp-removed",
|
||||||
|
fullName: "John Doe",
|
||||||
|
avatar: "old-avatar-url",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await AppointmentsList.getAppointmentEvent({
|
||||||
|
appointmentId: "appt-001",
|
||||||
|
sessionUser: "session-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify snapshot fields are used for collaboratorName
|
||||||
|
expect(result.collaboratorName).toBe("John Doe");
|
||||||
|
|
||||||
|
// Verify snapshot avatar is used
|
||||||
|
expect(result.collaboratorAvatar).toBe("old-avatar-url");
|
||||||
|
|
||||||
|
// Verify collaboratorId comes from employee, not user
|
||||||
|
expect(result.collaboratorId).toBe("emp-removed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses live User data when User account still exists", async () => {
|
||||||
|
const mockAppointment = {
|
||||||
|
_id: "appt-002",
|
||||||
|
id: "appt-002",
|
||||||
|
companyId: "company-001",
|
||||||
|
clientId: "client-001",
|
||||||
|
employeeId: "emp-active",
|
||||||
|
serviceId: "svc-001",
|
||||||
|
start: new Date("2026-07-20T10:00:00Z"),
|
||||||
|
startHour: 10,
|
||||||
|
endHour: 11,
|
||||||
|
dateDay: new Date("2026-07-20"),
|
||||||
|
price: 100,
|
||||||
|
length: 60,
|
||||||
|
payment: false,
|
||||||
|
comments: "",
|
||||||
|
discountId: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
_id: "client-001",
|
||||||
|
id: "client-001",
|
||||||
|
userId: "client-user-001",
|
||||||
|
firstName: "Client",
|
||||||
|
lastName: "Smith",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockClientUser = {
|
||||||
|
_id: "client-user-001",
|
||||||
|
id: "client-user-001",
|
||||||
|
firstName: "Client",
|
||||||
|
lastName: "Smith",
|
||||||
|
avatar: "client-avatar",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockActiveEmployee = {
|
||||||
|
_id: "emp-active",
|
||||||
|
id: "emp-active",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "active-user-id",
|
||||||
|
calendarColor: "#00ff00",
|
||||||
|
removed: false,
|
||||||
|
profileSnapshot: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockActiveUser = {
|
||||||
|
_id: "active-user-id",
|
||||||
|
id: "active-user-id",
|
||||||
|
firstName: "Jane",
|
||||||
|
lastName: "Roe",
|
||||||
|
avatar: "live-avatar",
|
||||||
|
email: "jane@example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAppointmentFindOne = (AppointmentsList as any).Appointments.AppointmentList.findOne;
|
||||||
|
mockAppointmentFindOne.mockResolvedValue(mockAppointment);
|
||||||
|
|
||||||
|
(ClientsManager.clients.findOne as jest.Mock).mockResolvedValue(mockClient);
|
||||||
|
(ServiceList.services.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "svc-001",
|
||||||
|
name: "Haircut",
|
||||||
|
description: "Professional haircut",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call order for UsersManager.users.findOne (same mock as UserList):
|
||||||
|
// 1. Line 1217: UserList.users.findOne (NOT awaited, but consumes a value)
|
||||||
|
// 2. Line 1241: UsersManager.users.findOne for userClient
|
||||||
|
// 3. Line 1253: UsersManager.users.findOne for userEmployee
|
||||||
|
(UsersManager.users.findOne as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ _id: "session-user" }) // line 1217: not awaited
|
||||||
|
.mockResolvedValueOnce(mockClientUser) // line 1241: client user
|
||||||
|
.mockResolvedValueOnce(mockActiveUser); // line 1253: live user exists
|
||||||
|
(EmployeesList.employees.findOne as jest.Mock).mockResolvedValue(mockActiveEmployee);
|
||||||
|
(EmployeesList as any).findById = jest.fn().mockResolvedValue({
|
||||||
|
id: "emp-active",
|
||||||
|
fullName: "Jane Roe",
|
||||||
|
avatar: "live-avatar",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await AppointmentsList.getAppointmentEvent({
|
||||||
|
appointmentId: "appt-002",
|
||||||
|
sessionUser: "session-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live user data is used, not snapshot
|
||||||
|
expect(result.collaboratorName).toBe("Jane Roe");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CreateClientByUserParams,
|
CreateClientByUserParams,
|
||||||
PaginateClientsParams,
|
PaginateClientsParams,
|
||||||
PaginateClientsResults,
|
PaginateClientsResults,
|
||||||
|
DetachClientUserParams,
|
||||||
} from "./Clients.Interface";
|
} from "./Clients.Interface";
|
||||||
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
|
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
|
||||||
|
|
||||||
@@ -96,4 +97,8 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
|
|||||||
pages: Math.ceil(count / filters.limit),
|
pages: Math.ceil(count / filters.limit),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async detachUser(data: DetachClientUserParams): Promise<void> {
|
||||||
|
await this.clientList.updateOne({ _id: data.clientId }, { $unset: { userId: "" } }).exec();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export type FindClientsParams = {
|
|||||||
status?: boolean;
|
status?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DetachClientUserParams = {
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type FindClientByIdParams = {
|
export type FindClientByIdParams = {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -77,7 +81,7 @@ export type DeleteClientParams = {
|
|||||||
export interface IClient {
|
export interface IClient {
|
||||||
id?: string;
|
id?: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
userId: string;
|
userId?: string;
|
||||||
fromClientId: string;
|
fromClientId: string;
|
||||||
status: boolean;
|
status: boolean;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -116,6 +120,7 @@ export interface IClientsAdapter {
|
|||||||
find(filters: FindClientsParams): Promise<IClient[]>;
|
find(filters: FindClientsParams): Promise<IClient[]>;
|
||||||
findOne(filters: FindClientsParams): Promise<IClientDocument | null>;
|
findOne(filters: FindClientsParams): Promise<IClientDocument | null>;
|
||||||
paginate(filters: PaginateClientsParams): Promise<PaginateClientsResults>;
|
paginate(filters: PaginateClientsParams): Promise<PaginateClientsResults>;
|
||||||
|
detachUser(data: DetachClientUserParams): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IClientsManager {
|
export interface IClientsManager {
|
||||||
|
|||||||
@@ -195,7 +195,9 @@ class ClientManager implements IClientsManager {
|
|||||||
|
|
||||||
if (appointment) {
|
if (appointment) {
|
||||||
appointment.clientId = data.toClientId;
|
appointment.clientId = data.toClientId;
|
||||||
appointment.userId = clientTo.userId;
|
if (clientTo.userId) {
|
||||||
|
appointment.userId = clientTo.userId;
|
||||||
|
}
|
||||||
await appointment.save();
|
await appointment.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -536,7 +536,8 @@ class CompaniesManager implements ICompaniesManager {
|
|||||||
const employ = await EmployeesList.employees.findOne({
|
const employ = await EmployeesList.employees.findOne({
|
||||||
companyId: data.id,
|
companyId: data.id,
|
||||||
userId: String(data.sessionUser),
|
userId: String(data.sessionUser),
|
||||||
});
|
removed: { $ne: true },
|
||||||
|
} as any);
|
||||||
|
|
||||||
if (!employ) {
|
if (!employ) {
|
||||||
throw new Error("El usuario no participa de la organización");
|
throw new Error("El usuario no participa de la organización");
|
||||||
@@ -721,7 +722,8 @@ class CompaniesManager implements ICompaniesManager {
|
|||||||
//Organizaciones en las que participa como colaborador.
|
//Organizaciones en las que participa como colaborador.
|
||||||
const orgCollaborator = await EmployeesList.employees.find({
|
const orgCollaborator = await EmployeesList.employees.find({
|
||||||
userId: data.sessionUser,
|
userId: data.sessionUser,
|
||||||
});
|
removed: { $ne: true },
|
||||||
|
} as any);
|
||||||
|
|
||||||
for (const item of orgCollaborator) {
|
for (const item of orgCollaborator) {
|
||||||
if (item.companyId) {
|
if (item.companyId) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
UpdateEmployeeParams,
|
UpdateEmployeeParams,
|
||||||
DeleteEmployeesByCompany,
|
DeleteEmployeesByCompany,
|
||||||
DeleteEmployeeParams,
|
DeleteEmployeeParams,
|
||||||
|
RemoveCollaboratorParams,
|
||||||
} from "./Employees.Interface";
|
} from "./Employees.Interface";
|
||||||
import UsersManager from "../Users/Users";
|
import UsersManager from "../Users/Users";
|
||||||
import { ICompanyDocument } from "../Companies/Companies.Adapter.Mongoose";
|
import { ICompanyDocument } from "../Companies/Companies.Adapter.Mongoose";
|
||||||
@@ -26,7 +27,9 @@ import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model";
|
|||||||
import MetricsList from "../../Models/Metrics/Metrics";
|
import MetricsList from "../../Models/Metrics/Metrics";
|
||||||
import { NotificationsManager } from "../Notifications/Notifications";
|
import { NotificationsManager } from "../Notifications/Notifications";
|
||||||
import EmployeesServicesList from "../EmployeesServices/EmployeesServices";
|
import EmployeesServicesList from "../EmployeesServices/EmployeesServices";
|
||||||
|
import HeatMapList from "../HeatMap/HeatMap";
|
||||||
import SchedulesList from "../Schedules/Schedules";
|
import SchedulesList from "../Schedules/Schedules";
|
||||||
|
import AppointmentList from "../Appointments/Appointments";
|
||||||
import { IncompleteCollaboratorView } from "./Employees.Interface";
|
import { IncompleteCollaboratorView } from "./Employees.Interface";
|
||||||
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
|
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
|
||||||
|
|
||||||
@@ -51,7 +54,11 @@ class EmployeeManager implements IEmployeesManager {
|
|||||||
role: EmployeeRoles
|
role: EmployeeRoles
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
//Check if role exist in roles array.
|
//Check if role exist in roles array.
|
||||||
const check = await this.employees.findOne({ companyId: companyId, userId: employeeId });
|
const check = await this.employees.findOne({
|
||||||
|
companyId: companyId,
|
||||||
|
userId: employeeId,
|
||||||
|
removed: { $ne: true },
|
||||||
|
} as any);
|
||||||
|
|
||||||
if (!check) {
|
if (!check) {
|
||||||
return false;
|
return false;
|
||||||
@@ -112,8 +119,59 @@ class EmployeeManager implements IEmployeesManager {
|
|||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reactivation: if employee exists and is removed, reactivate instead of creating duplicate
|
||||||
|
if (employeeCheck && employeeCheck.removed === true) {
|
||||||
|
// Check quota before reactivation
|
||||||
|
const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId);
|
||||||
|
if (!canAdd) {
|
||||||
|
throw new Error(
|
||||||
|
"Ha alcanzado el limite de colaboradores permitidos de acuerdo a su plan."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removal metadata
|
||||||
|
employeeCheck.removed = false;
|
||||||
|
employeeCheck.removedAt = undefined;
|
||||||
|
employeeCheck.removedBy = undefined;
|
||||||
|
|
||||||
|
// Refresh profileSnapshot from current User
|
||||||
|
employeeCheck.profileSnapshot = {
|
||||||
|
firstName: userCheck.firstName || undefined,
|
||||||
|
lastName: userCheck.lastName || undefined,
|
||||||
|
email: userCheck.email || undefined,
|
||||||
|
avatar: userCheck.avatar || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset invitation state for full re-acceptance flow
|
||||||
|
employeeCheck.hostOk = false;
|
||||||
|
employeeCheck.guestOk = false;
|
||||||
|
|
||||||
|
await employeeCheck.save();
|
||||||
|
|
||||||
|
// Increment metrics
|
||||||
|
await MetricsList.addEmployee({
|
||||||
|
userId: companyCheck.ownerId,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send invitation notification
|
||||||
|
await NotificationsManager.sendSystemNotification({
|
||||||
|
userId: String(data.userId),
|
||||||
|
subject: "Su usuario ha sido vinculado a una organización",
|
||||||
|
message: `La compañía ${companyCheck.name} lo ha vinculado como colaborador. Es necesario que acepte la invitación para poder formar parte de esta organización.`,
|
||||||
|
type: NotificationType.EMPLOYEE,
|
||||||
|
code: String(employeeCheck._id),
|
||||||
|
});
|
||||||
|
|
||||||
|
return employeeCheck;
|
||||||
|
}
|
||||||
|
|
||||||
if (employeeCheck) {
|
if (employeeCheck) {
|
||||||
throw new Error("El usuario ya pertenece a la compañia");
|
if (!employeeCheck.guestOk) {
|
||||||
|
throw new Error("La invitación para este usuario ya está pendiente");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("El usuario ya pertenece a la compañía");
|
||||||
}
|
}
|
||||||
|
|
||||||
const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId);
|
const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId);
|
||||||
@@ -346,7 +404,7 @@ class EmployeeManager implements IEmployeesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async findByCompanyId(data: FindEmployeesParams): Promise<CompanyEmployeesView[]> {
|
public async findByCompanyId(data: FindEmployeesParams): Promise<CompanyEmployeesView[]> {
|
||||||
const employees = await this.employees.find(data);
|
const employees = await this.employees.find({ ...data, removed: { $ne: true } } as any);
|
||||||
|
|
||||||
const view = await Promise.all(
|
const view = await Promise.all(
|
||||||
employees.map(async (employee) => {
|
employees.map(async (employee) => {
|
||||||
@@ -422,7 +480,7 @@ class EmployeeManager implements IEmployeesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async textObjectFilter(data: FindEmployeesParams): Promise<TextObjectFilterResult[]> {
|
public async textObjectFilter(data: FindEmployeesParams): Promise<TextObjectFilterResult[]> {
|
||||||
const employees = await this.employees.find(data);
|
const employees = await this.employees.find({ ...data, removed: { $ne: true } } as any);
|
||||||
const formattedResult: TextObjectFilterResult[] = [];
|
const formattedResult: TextObjectFilterResult[] = [];
|
||||||
|
|
||||||
for (const colaborador of employees) {
|
for (const colaborador of employees) {
|
||||||
@@ -522,8 +580,151 @@ class EmployeeManager implements IEmployeesManager {
|
|||||||
}
|
}
|
||||||
return incompleteCollaborators;
|
return incompleteCollaborators;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async removeCollaboratorFromCompany(data: RemoveCollaboratorParams): Promise<void> {
|
||||||
|
// Validate session user
|
||||||
|
const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser });
|
||||||
|
if (!sessionUser) {
|
||||||
|
throw new Error(
|
||||||
|
"No se ha encontrado el usuario o no tiene permisos para realizar esta acción"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate company exists
|
||||||
|
const companyCheck = await CompaniesManager.companies.findOne({ _id: data.companyId });
|
||||||
|
if (!companyCheck) {
|
||||||
|
throw new Error("La compañia no existe");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate target employee exists
|
||||||
|
const employee = await this.employees.findOne({
|
||||||
|
_id: data.employeeId,
|
||||||
|
});
|
||||||
|
if (!employee) {
|
||||||
|
throw new Error("El colaborador no existe");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already removed
|
||||||
|
if (employee.removed) {
|
||||||
|
throw new Error("El colaborador ya fue eliminado de la organización");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate permission (admin or owner)
|
||||||
|
if (
|
||||||
|
!(await this.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))
|
||||||
|
) {
|
||||||
|
throw new Error(NoPermissionMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-removal prevention
|
||||||
|
if (String(employee.userId) === data.sessionUser) {
|
||||||
|
throw new Error("No puedes eliminarte a ti mismo de la organización");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner protection
|
||||||
|
if (employee.roles && employee.roles.includes(EmployeeRoles.OWNER)) {
|
||||||
|
throw new Error("No se puede eliminar al propietario de la organización");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query future appointments for this employee
|
||||||
|
const futureAppointments = await AppointmentList.Appointments.AppointmentList.find({
|
||||||
|
companyId: data.companyId,
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
start: { $gt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there are future appointments, require a replacement
|
||||||
|
if (futureAppointments && futureAppointments.length > 0) {
|
||||||
|
if (!data.replacementEmployeeId) {
|
||||||
|
throw new Error(
|
||||||
|
"El colaborador tiene turnos futuros asignados. Se requiere un empleado de reemplazo."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate replacement employee
|
||||||
|
const replacementEmployee = await this.employees.findOne({
|
||||||
|
_id: data.replacementEmployeeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!replacementEmployee) {
|
||||||
|
throw new Error("El empleado de reemplazo no existe");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(replacementEmployee.companyId) !== data.companyId) {
|
||||||
|
throw new Error("El empleado de reemplazo no pertenece a esta organización");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacementEmployee.removed) {
|
||||||
|
throw new Error("El empleado de reemplazo fue eliminado de la organización");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(replacementEmployee.userId) === data.sessionUser) {
|
||||||
|
throw new Error(
|
||||||
|
"No puedes designarte como reemplazo de ti mismo"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reassign future appointments
|
||||||
|
for (const appointment of futureAppointments) {
|
||||||
|
appointment.employeeId = data.replacementEmployeeId;
|
||||||
|
await appointment.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reassign active repeats
|
||||||
|
const RepeatsList = (await import("../Repeats/Repeats")).default;
|
||||||
|
const activeRepeats = await RepeatsList.repeats.find({
|
||||||
|
companyId: data.companyId,
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const repeat of activeRepeats) {
|
||||||
|
// Access the underlying Mongoose model to get save() capability
|
||||||
|
await RepeatsList.repeats.findOne({ _id: repeat.id }).then(async (doc) => {
|
||||||
|
if (doc) {
|
||||||
|
doc.employeeId = data.replacementEmployeeId!;
|
||||||
|
await doc.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot user profile
|
||||||
|
const targetUser = await UsersManager.users.findOne({ _id: employee.userId });
|
||||||
|
if (targetUser) {
|
||||||
|
employee.profileSnapshot = {
|
||||||
|
firstName: targetUser.firstName || undefined,
|
||||||
|
lastName: targetUser.lastName || undefined,
|
||||||
|
email: targetUser.email || undefined,
|
||||||
|
avatar: targetUser.avatar || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark employee as removed
|
||||||
|
employee.removed = true;
|
||||||
|
employee.removedAt = new Date();
|
||||||
|
employee.removedBy = data.sessionUser;
|
||||||
|
|
||||||
|
await employee.save();
|
||||||
|
|
||||||
|
// Cleanup: delete employee services and heatmap data
|
||||||
|
await EmployeesServicesList.deleteEmployeeServiceByEmployee({
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await HeatMapList.deleteHeatMapByEmployee({
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decrement metrics
|
||||||
|
await MetricsList.addEmployee({
|
||||||
|
userId: companyCheck.ownerId,
|
||||||
|
quantity: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { EmployeeManager };
|
||||||
|
|
||||||
const EmployeesList = new EmployeeManager();
|
const EmployeesList = new EmployeeManager();
|
||||||
|
|
||||||
export default EmployeesList;
|
export default EmployeesList;
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ export class EmployeesAdapterMongoose implements IEmployeesAdapter {
|
|||||||
limit: { type: Number, required: true, default: -1 },
|
limit: { type: Number, required: true, default: -1 },
|
||||||
hostOk: { type: Boolean, required: true, default: false },
|
hostOk: { type: Boolean, required: true, default: false },
|
||||||
guestOk: { type: Boolean, required: true, default: false },
|
guestOk: { type: Boolean, required: true, default: false },
|
||||||
|
removed: { type: Boolean, required: false, default: false },
|
||||||
|
removedAt: { type: Date, required: false },
|
||||||
|
removedBy: { type: Schema.Types.ObjectId, required: false, ref: "User" },
|
||||||
|
profileSnapshot: {
|
||||||
|
type: {
|
||||||
|
firstName: { type: String, required: false },
|
||||||
|
lastName: { type: String, required: false },
|
||||||
|
email: { type: String, required: false },
|
||||||
|
avatar: { type: String, required: false },
|
||||||
|
},
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
creationDate: { type: Date, required: true, default: Date.now },
|
creationDate: { type: Date, required: true, default: Date.now },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ export type DeleteEmployeeParams = {
|
|||||||
employeeId: string;
|
employeeId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RemoveCollaboratorParams = {
|
||||||
|
employeeId: string;
|
||||||
|
companyId: string;
|
||||||
|
replacementEmployeeId?: string;
|
||||||
|
sessionUser: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface IEmployee {
|
export interface IEmployee {
|
||||||
id?: string;
|
id?: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -77,6 +84,15 @@ export interface IEmployee {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
guestOk?: boolean;
|
guestOk?: boolean;
|
||||||
hostOk?: boolean;
|
hostOk?: boolean;
|
||||||
|
removed?: boolean;
|
||||||
|
removedAt?: Date;
|
||||||
|
removedBy?: string;
|
||||||
|
profileSnapshot?: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyEmployeesView {
|
export interface CompanyEmployeesView {
|
||||||
@@ -93,6 +109,14 @@ export interface CompanyEmployeesView {
|
|||||||
hostOk: boolean;
|
hostOk: boolean;
|
||||||
fullOk: boolean;
|
fullOk: boolean;
|
||||||
calendarColor: string;
|
calendarColor: string;
|
||||||
|
removed?: boolean;
|
||||||
|
removedAt?: Date;
|
||||||
|
profileSnapshot?: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IncompleteCollaboratorView {
|
export interface IncompleteCollaboratorView {
|
||||||
@@ -125,4 +149,5 @@ export interface IEmployeesManager {
|
|||||||
deleteEmployee(data: DeleteEmployeeParams): Promise<void>;
|
deleteEmployee(data: DeleteEmployeeParams): Promise<void>;
|
||||||
rejectGuest(data: ValidateEmployeeParams): Promise<void>;
|
rejectGuest(data: ValidateEmployeeParams): Promise<void>;
|
||||||
getIncompleteSetup(sessionUser: string): Promise<IncompleteCollaboratorView[]>;
|
getIncompleteSetup(sessionUser: string): Promise<IncompleteCollaboratorView[]>;
|
||||||
|
removeCollaboratorFromCompany(data: RemoveCollaboratorParams): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { EmployeeRoles } from "../Employees.Interface";
|
||||||
|
|
||||||
|
// Mock all external dependencies before importing the module under test
|
||||||
|
jest.mock("../../Users/Users", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
users: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
getUserFullName: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Companies/Companies", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
companies: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Metrics/Metrics", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
addEmployee: jest.fn(),
|
||||||
|
canAddEmployee: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Notifications/Notifications", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
NotificationsManager: {
|
||||||
|
sendSystemNotification: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../EmployeesServices/EmployeesServices", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
deleteEmployeeServiceByEmployee: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../HeatMap/HeatMap", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
deleteHeatMapByEmployee: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Repeats/Repeats", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
repeats: {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Schedules/Schedules", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Services/Service", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
services: {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../index", () => ({
|
||||||
|
io: {
|
||||||
|
to: jest.fn(() => ({ emit: jest.fn() })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import EmployeesList from "../Employee";
|
||||||
|
import UsersManager from "../../Users/Users";
|
||||||
|
|
||||||
|
describe("Query filtering — removed employees excluded", () => {
|
||||||
|
let originalAdapter: any;
|
||||||
|
let mockFind: jest.Mock;
|
||||||
|
let mockFindOne: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
originalAdapter = EmployeesList.employees;
|
||||||
|
|
||||||
|
mockFind = jest.fn();
|
||||||
|
mockFindOne = jest.fn();
|
||||||
|
|
||||||
|
(EmployeesList as any).employees = {
|
||||||
|
findOne: mockFindOne,
|
||||||
|
find: mockFind,
|
||||||
|
create: jest.fn(),
|
||||||
|
employeeList: {
|
||||||
|
findOne: mockFindOne,
|
||||||
|
find: mockFind,
|
||||||
|
create: jest.fn(),
|
||||||
|
deleteMany: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(EmployeesList as any).employees = originalAdapter;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- findByCompanyId excludes removed ---
|
||||||
|
|
||||||
|
describe("findByCompanyId", () => {
|
||||||
|
it("excludes removed employees from results", async () => {
|
||||||
|
const activeEmployee = {
|
||||||
|
_id: "emp-active",
|
||||||
|
id: "emp-active",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-active",
|
||||||
|
roles: [EmployeeRoles.EMPLOYEE],
|
||||||
|
guestOk: true,
|
||||||
|
hostOk: true,
|
||||||
|
calendarColor: "#ff9900",
|
||||||
|
removed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// findByCompanyId calls find with removed: { $ne: true }
|
||||||
|
// The adapter find should return only non-removed employees
|
||||||
|
mockFind.mockResolvedValue([activeEmployee]);
|
||||||
|
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "user-active",
|
||||||
|
firstName: "Active",
|
||||||
|
lastName: "User",
|
||||||
|
email: "active@example.com",
|
||||||
|
avatar: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await EmployeesList.findByCompanyId({
|
||||||
|
companyId: "company-001",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the query included the removed filter
|
||||||
|
expect(mockFind).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
companyId: "company-001",
|
||||||
|
removed: { $ne: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify only the active employee is in the result
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("emp-active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when all employees are removed", async () => {
|
||||||
|
// Adapter returns empty because filter excluded all removed
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await EmployeesList.findByCompanyId({
|
||||||
|
companyId: "company-001",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(mockFind).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
removed: { $ne: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- textObjectFilter excludes removed ---
|
||||||
|
|
||||||
|
describe("textObjectFilter", () => {
|
||||||
|
it("excludes removed employees from results", async () => {
|
||||||
|
const activeEmployee = {
|
||||||
|
_id: "emp-active",
|
||||||
|
id: "emp-active",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-active",
|
||||||
|
removed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only active employees returned after filter
|
||||||
|
mockFind.mockResolvedValue([activeEmployee]);
|
||||||
|
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "user-active",
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
avatar: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await EmployeesList.textObjectFilter({
|
||||||
|
companyId: "company-001",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFind).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
removed: { $ne: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe("John Doe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty when only removed employees exist", async () => {
|
||||||
|
mockFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await EmployeesList.textObjectFilter({
|
||||||
|
companyId: "company-001",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the removed filter was applied to the query
|
||||||
|
expect(mockFind).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
removed: { $ne: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { EmployeeRoles } from "../Employees.Interface";
|
||||||
|
|
||||||
|
// Mock all external dependencies before importing the module under test
|
||||||
|
jest.mock("../../Users/Users", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
users: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
getUserFullName: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Companies/Companies", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
companies: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Metrics/Metrics", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
addEmployee: jest.fn(),
|
||||||
|
canAddEmployee: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Notifications/Notifications", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
NotificationsManager: {
|
||||||
|
sendSystemNotification: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../EmployeesServices/EmployeesServices", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
deleteEmployeeServiceByEmployee: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../HeatMap/HeatMap", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
deleteHeatMapByEmployee: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Repeats/Repeats", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
repeats: {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../index", () => ({
|
||||||
|
io: {
|
||||||
|
to: jest.fn(() => ({ emit: jest.fn() })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import EmployeesList from "../Employee";
|
||||||
|
import UsersManager from "../../Users/Users";
|
||||||
|
import CompaniesManager from "../../Companies/Companies";
|
||||||
|
import MetricsList from "../../Metrics/Metrics";
|
||||||
|
|
||||||
|
describe("createEmployee — reactivation of removed collaborator", () => {
|
||||||
|
let originalAdapter: any;
|
||||||
|
let mockFindOne: jest.Mock;
|
||||||
|
let mockCreate: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
originalAdapter = EmployeesList.employees;
|
||||||
|
|
||||||
|
mockFindOne = jest.fn();
|
||||||
|
mockCreate = jest.fn();
|
||||||
|
|
||||||
|
(EmployeesList as any).employees = {
|
||||||
|
findOne: mockFindOne,
|
||||||
|
find: jest.fn(),
|
||||||
|
create: mockCreate,
|
||||||
|
employeeList: {
|
||||||
|
findOne: mockFindOne,
|
||||||
|
find: jest.fn(),
|
||||||
|
create: mockCreate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(EmployeesList as any).employees = originalAdapter;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call order in createEmployee:
|
||||||
|
* 1. UsersManager.users.findOne → session user
|
||||||
|
* 2. CompaniesManager.companies.findOne → company
|
||||||
|
* 3. checkPermission → checkRoleById → this.employees.findOne → admin employee record
|
||||||
|
* 4. UsersManager.users.findOne → user being invited
|
||||||
|
* 5. this.employees.findOne → existing employee lookup by (companyId, userId)
|
||||||
|
*/
|
||||||
|
it("reactivates a removed employee instead of creating a duplicate", async () => {
|
||||||
|
const adminEmployee = {
|
||||||
|
_id: "emp-admin",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "admin-user",
|
||||||
|
roles: [EmployeeRoles.ADMIN],
|
||||||
|
removed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const removedEmployee = {
|
||||||
|
_id: "emp-removed",
|
||||||
|
id: "emp-removed",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-002",
|
||||||
|
roles: [EmployeeRoles.EMPLOYEE],
|
||||||
|
removed: true,
|
||||||
|
removedAt: new Date(),
|
||||||
|
removedBy: "admin-user",
|
||||||
|
hostOk: true,
|
||||||
|
guestOk: true,
|
||||||
|
profileSnapshot: { firstName: "Old", lastName: "Name" },
|
||||||
|
save: jest.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
const userToInvite = {
|
||||||
|
_id: "user-002",
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
email: "john@example.com",
|
||||||
|
avatar: "new-avatar",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call 1: session user → Call 2: user being invited
|
||||||
|
(UsersManager.users.findOne as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ _id: "admin-user" })
|
||||||
|
.mockResolvedValueOnce(userToInvite);
|
||||||
|
|
||||||
|
// CompaniesManager.companies.findOne → company
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
name: "Test Company",
|
||||||
|
});
|
||||||
|
|
||||||
|
// this.employees.findOne:
|
||||||
|
// Call 1: checkRoleById → admin employee record
|
||||||
|
// Call 2: existing employee lookup → found + removed
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(adminEmployee)
|
||||||
|
.mockResolvedValueOnce(removedEmployee);
|
||||||
|
|
||||||
|
(MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await EmployeesList.createEmployee({
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-002",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify employee was reactivated
|
||||||
|
expect(removedEmployee.removed).toBe(false);
|
||||||
|
expect(removedEmployee.removedAt).toBeUndefined();
|
||||||
|
expect(removedEmployee.removedBy).toBeUndefined();
|
||||||
|
|
||||||
|
// Verify profileSnapshot was refreshed from current User
|
||||||
|
expect(removedEmployee.profileSnapshot).toEqual({
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
email: "john@example.com",
|
||||||
|
avatar: "new-avatar",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify invitation state was reset
|
||||||
|
expect(removedEmployee.hostOk).toBe(false);
|
||||||
|
expect(removedEmployee.guestOk).toBe(false);
|
||||||
|
|
||||||
|
// Verify save was called
|
||||||
|
expect(removedEmployee.save).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Verify metrics were incremented
|
||||||
|
expect(MetricsList.addEmployee).toHaveBeenCalledWith({
|
||||||
|
userId: "owner-user",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify no new employee was created
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Verify same employee returned
|
||||||
|
expect(result.id).toBe("emp-removed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws quota error when reactivation would exceed plan limit", async () => {
|
||||||
|
const adminEmployee = {
|
||||||
|
_id: "emp-admin",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "admin-user",
|
||||||
|
roles: [EmployeeRoles.ADMIN],
|
||||||
|
removed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const removedEmployee = {
|
||||||
|
_id: "emp-removed",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-002",
|
||||||
|
removed: true,
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call 1: session user → Call 2: user being invited
|
||||||
|
(UsersManager.users.findOne as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ _id: "admin-user" })
|
||||||
|
.mockResolvedValueOnce({ _id: "user-002" });
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
// this.employees.findOne: admin check → removed employee lookup
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(adminEmployee)
|
||||||
|
.mockResolvedValueOnce(removedEmployee);
|
||||||
|
|
||||||
|
// Quota is full
|
||||||
|
(MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmployeesList.createEmployee({
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-002",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("limite de colaboradores");
|
||||||
|
|
||||||
|
// Verify employee was NOT reactivated
|
||||||
|
expect(removedEmployee.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws 'El usuario no existe' when reinviting a removed employee whose User record was deleted", async () => {
|
||||||
|
// Scenario: Employee record exists and is removed, but the underlying User
|
||||||
|
// was deleted (e.g. admin cleanup). createEmployee must reject the invitation
|
||||||
|
// BEFORE attempting reactivation because User validation gates the entire flow.
|
||||||
|
const removedEmployee = {
|
||||||
|
_id: "emp-removed",
|
||||||
|
id: "emp-removed",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-deleted",
|
||||||
|
removed: true,
|
||||||
|
save: jest.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call 1: session user (owner, so checkPermission short-circuits)
|
||||||
|
(UsersManager.users.findOne as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ _id: "owner-user" })
|
||||||
|
.mockResolvedValueOnce(null); // invited user was DELETED
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
// checkPermission short-circuits for owner — no employees.findOne needed.
|
||||||
|
// But createEmployee still calls employees.findOne AFTER userCheck.
|
||||||
|
// Since userCheck is null, the method should throw before reaching employees.findOne.
|
||||||
|
await expect(
|
||||||
|
EmployeesList.createEmployee({
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-deleted",
|
||||||
|
sessionUser: "owner-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("El usuario no existe");
|
||||||
|
|
||||||
|
// Critical: reactivation must NOT have been attempted
|
||||||
|
expect(removedEmployee.save).not.toHaveBeenCalled();
|
||||||
|
expect(MetricsList.addEmployee).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves same employeeId across removal and reactivation", async () => {
|
||||||
|
const adminEmployee = {
|
||||||
|
_id: "emp-admin",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "admin-user",
|
||||||
|
roles: [EmployeeRoles.ADMIN],
|
||||||
|
removed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const removedEmployee = {
|
||||||
|
_id: "emp-original-id",
|
||||||
|
id: "emp-original-id",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-002",
|
||||||
|
removed: true,
|
||||||
|
save: jest.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
const userToInvite = {
|
||||||
|
_id: "user-002",
|
||||||
|
firstName: "Jane",
|
||||||
|
lastName: "Smith",
|
||||||
|
email: "jane@example.com",
|
||||||
|
avatar: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call 1: session user → Call 2: user being invited
|
||||||
|
(UsersManager.users.findOne as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ _id: "admin-user" })
|
||||||
|
.mockResolvedValueOnce(userToInvite);
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
// this.employees.findOne: admin check → removed employee lookup
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(adminEmployee)
|
||||||
|
.mockResolvedValueOnce(removedEmployee);
|
||||||
|
|
||||||
|
(MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await EmployeesList.createEmployee({
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-002",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same _id preserved
|
||||||
|
expect(result.id).toBe("emp-original-id");
|
||||||
|
|
||||||
|
// No new employee created
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
import { EmployeeRoles } from "../Employees.Interface";
|
||||||
|
|
||||||
|
// Mock all external dependencies before importing the module under test
|
||||||
|
jest.mock("../../Users/Users", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
users: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
getUserFullName: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Companies/Companies", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
companies: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Metrics/Metrics", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
addEmployee: jest.fn(),
|
||||||
|
canAddEmployee: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Notifications/Notifications", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
NotificationsManager: {
|
||||||
|
sendSystemNotification: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../EmployeesServices/EmployeesServices", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
deleteEmployeeServiceByEmployee: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../HeatMap/HeatMap", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
deleteHeatMapByEmployee: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Repeats/Repeats", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
repeats: {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Appointments/Appointments", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
Appointments: {
|
||||||
|
AppointmentList: {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../index", () => ({
|
||||||
|
io: {
|
||||||
|
to: jest.fn(() => ({ emit: jest.fn() })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import EmployeesList from "../Employee";
|
||||||
|
import UsersManager from "../../Users/Users";
|
||||||
|
import CompaniesManager from "../../Companies/Companies";
|
||||||
|
import MetricsList from "../../Metrics/Metrics";
|
||||||
|
import EmployeesServicesList from "../../EmployeesServices/EmployeesServices";
|
||||||
|
import HeatMapList from "../../HeatMap/HeatMap";
|
||||||
|
import AppointmentList from "../../Appointments/Appointments";
|
||||||
|
|
||||||
|
// Helper to build a mock employee document with save()
|
||||||
|
function buildEmployeeDoc(overrides: Record<string, any> = {}) {
|
||||||
|
const doc = {
|
||||||
|
_id: "emp-001",
|
||||||
|
id: "emp-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-002",
|
||||||
|
roles: [EmployeeRoles.EMPLOYEE],
|
||||||
|
calendarColor: "#ff9900",
|
||||||
|
hostOk: true,
|
||||||
|
guestOk: true,
|
||||||
|
removed: false,
|
||||||
|
removedAt: undefined,
|
||||||
|
removedBy: undefined,
|
||||||
|
profileSnapshot: undefined,
|
||||||
|
save: jest.fn().mockResolvedValue(true),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("removeCollaboratorFromCompany", () => {
|
||||||
|
let originalAdapter: any;
|
||||||
|
let mockFindOne: jest.Mock;
|
||||||
|
let mockAppointmentFind: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Save original adapter reference
|
||||||
|
originalAdapter = EmployeesList.employees;
|
||||||
|
|
||||||
|
// Create mock adapter
|
||||||
|
mockFindOne = jest.fn();
|
||||||
|
const mockFind = jest.fn();
|
||||||
|
const mockCreate = jest.fn();
|
||||||
|
const mockDeleteMany = jest.fn();
|
||||||
|
|
||||||
|
(EmployeesList as any).employees = {
|
||||||
|
findOne: mockFindOne,
|
||||||
|
find: mockFind,
|
||||||
|
create: mockCreate,
|
||||||
|
employeeList: {
|
||||||
|
findOne: mockFindOne,
|
||||||
|
find: mockFind,
|
||||||
|
deleteMany: mockDeleteMany,
|
||||||
|
create: mockCreate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockAppointmentFind = (AppointmentList.Appointments.AppointmentList.find as jest.Mock).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original adapter
|
||||||
|
(EmployeesList as any).employees = originalAdapter;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Session User Validation ---
|
||||||
|
|
||||||
|
it("throws when session user does not exist", async () => {
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
sessionUser: "nonexistent-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("No se ha encontrado el usuario");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Company Validation ---
|
||||||
|
|
||||||
|
it("throws when company does not exist", async () => {
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
companyId: "nonexistent-company",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("La compañia no existe");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Employee Not Found ---
|
||||||
|
|
||||||
|
it("throws when target employee does not exist", async () => {
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
mockFindOne.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-nonexistent",
|
||||||
|
companyId: "company-001",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("El colaborador no existe");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Already Removed ---
|
||||||
|
|
||||||
|
it("throws when employee is already removed", async () => {
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
mockFindOne.mockResolvedValueOnce(buildEmployeeDoc({ removed: true }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("El colaborador ya fue eliminado");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Permission Check ---
|
||||||
|
|
||||||
|
it("throws when non-admin attempts removal", async () => {
|
||||||
|
const regularEmployee = buildEmployeeDoc({
|
||||||
|
_id: "emp-regular",
|
||||||
|
userId: "regular-user",
|
||||||
|
roles: [EmployeeRoles.EMPLOYEE],
|
||||||
|
});
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "regular-user" });
|
||||||
|
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(buildEmployeeDoc({ userId: "target-user" })) // target
|
||||||
|
.mockResolvedValueOnce(regularEmployee); // session user's employee
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
sessionUser: "regular-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/No tienes permisos/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Self-Removal Prevention ---
|
||||||
|
|
||||||
|
it("throws when admin tries to remove themselves", async () => {
|
||||||
|
const adminEmployee = buildEmployeeDoc({
|
||||||
|
_id: "emp-admin",
|
||||||
|
userId: "admin-user",
|
||||||
|
roles: [EmployeeRoles.ADMIN],
|
||||||
|
});
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||||
|
|
||||||
|
// First findOne: target employee (the admin themselves)
|
||||||
|
// Second findOne: session user's employee record
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(adminEmployee)
|
||||||
|
.mockResolvedValueOnce(adminEmployee);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-admin",
|
||||||
|
companyId: "company-001",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("No puedes eliminarte a ti mismo");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Owner Protection ---
|
||||||
|
|
||||||
|
it("throws when admin tries to remove the owner", async () => {
|
||||||
|
const ownerEmployee = buildEmployeeDoc({
|
||||||
|
_id: "emp-owner",
|
||||||
|
userId: "owner-user",
|
||||||
|
roles: [EmployeeRoles.OWNER],
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminEmployee = buildEmployeeDoc({
|
||||||
|
_id: "emp-admin",
|
||||||
|
userId: "admin-user",
|
||||||
|
roles: [EmployeeRoles.ADMIN],
|
||||||
|
});
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||||
|
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(ownerEmployee)
|
||||||
|
.mockResolvedValueOnce(adminEmployee);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-owner",
|
||||||
|
companyId: "company-001",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("No se puede eliminar al propietario");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Happy Path: No Future Appointments ---
|
||||||
|
|
||||||
|
it("successfully removes employee with no future appointments", async () => {
|
||||||
|
const targetEmployee = buildEmployeeDoc({
|
||||||
|
userId: "target-user",
|
||||||
|
removed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminEmployee = buildEmployeeDoc({
|
||||||
|
userId: "admin-user",
|
||||||
|
roles: [EmployeeRoles.ADMIN],
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetUser = {
|
||||||
|
_id: "target-user",
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
email: "john@example.com",
|
||||||
|
avatar: "avatar-url",
|
||||||
|
};
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
// First call: session user validation
|
||||||
|
// Second call: target user lookup (for snapshot)
|
||||||
|
(UsersManager.users.findOne as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ _id: "admin-user" })
|
||||||
|
.mockResolvedValueOnce(targetUser);
|
||||||
|
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(targetEmployee) // target
|
||||||
|
.mockResolvedValueOnce(adminEmployee); // session user
|
||||||
|
|
||||||
|
mockAppointmentFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(targetEmployee.save).toHaveBeenCalled();
|
||||||
|
expect(targetEmployee.removed).toBe(true);
|
||||||
|
expect(targetEmployee.removedBy).toBe("admin-user");
|
||||||
|
expect(targetEmployee.removedAt).toBeDefined();
|
||||||
|
expect(targetEmployee.profileSnapshot).toEqual({
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
email: "john@example.com",
|
||||||
|
avatar: "avatar-url",
|
||||||
|
});
|
||||||
|
expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
HeatMapList.deleteHeatMapByEmployee
|
||||||
|
).toHaveBeenCalledWith({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
});
|
||||||
|
expect(MetricsList.addEmployee).toHaveBeenCalledWith({
|
||||||
|
userId: "owner-user",
|
||||||
|
quantity: -1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Owner Can Remove (via company.ownerId check) ---
|
||||||
|
|
||||||
|
it("owner can remove a collaborator", async () => {
|
||||||
|
const targetEmployee = buildEmployeeDoc({
|
||||||
|
userId: "target-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
(UsersManager.users.findOne as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ _id: "owner-user" }) // session user
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
_id: "target-user",
|
||||||
|
firstName: "Jane",
|
||||||
|
lastName: "Smith",
|
||||||
|
email: "jane@example.com",
|
||||||
|
avatar: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(targetEmployee)
|
||||||
|
.mockResolvedValueOnce(null); // owner has no employee record
|
||||||
|
|
||||||
|
mockAppointmentFind.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
sessionUser: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(targetEmployee.removed).toBe(true);
|
||||||
|
expect(targetEmployee.removedBy).toBe("owner-user");
|
||||||
|
expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
});
|
||||||
|
expect(HeatMapList.deleteHeatMapByEmployee).toHaveBeenCalledWith({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Admin with future appointments but no replacement → error ---
|
||||||
|
|
||||||
|
it("throws when employee has future appointments but no replacement provided", async () => {
|
||||||
|
const targetEmployee = buildEmployeeDoc({
|
||||||
|
userId: "target-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminEmployee = buildEmployeeDoc({
|
||||||
|
userId: "admin-user",
|
||||||
|
roles: [EmployeeRoles.ADMIN],
|
||||||
|
});
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||||
|
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(targetEmployee)
|
||||||
|
.mockResolvedValueOnce(adminEmployee);
|
||||||
|
|
||||||
|
// Simulate future appointments exist
|
||||||
|
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("turnos futuros asignados");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Replacement from different company → error ---
|
||||||
|
|
||||||
|
it("throws when replacement employee belongs to a different company", async () => {
|
||||||
|
const targetEmployee = buildEmployeeDoc({
|
||||||
|
userId: "target-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminEmployee = buildEmployeeDoc({
|
||||||
|
userId: "admin-user",
|
||||||
|
roles: [EmployeeRoles.ADMIN],
|
||||||
|
});
|
||||||
|
|
||||||
|
const replacementFromOtherCompany = buildEmployeeDoc({
|
||||||
|
_id: "emp-replacement",
|
||||||
|
companyId: "other-company",
|
||||||
|
userId: "replacement-user",
|
||||||
|
removed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||||
|
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(targetEmployee)
|
||||||
|
.mockResolvedValueOnce(adminEmployee)
|
||||||
|
.mockResolvedValueOnce(replacementFromOtherCompany);
|
||||||
|
|
||||||
|
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
replacementEmployeeId: "emp-replacement",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("no pertenece a esta organización");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Replacement is self → error ---
|
||||||
|
|
||||||
|
it("throws when admin tries to designate themselves as replacement", async () => {
|
||||||
|
const targetEmployee = buildEmployeeDoc({
|
||||||
|
userId: "target-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminEmployee = buildEmployeeDoc({
|
||||||
|
_id: "emp-admin",
|
||||||
|
userId: "admin-user",
|
||||||
|
roles: [EmployeeRoles.ADMIN],
|
||||||
|
});
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||||
|
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(targetEmployee)
|
||||||
|
.mockResolvedValueOnce(adminEmployee)
|
||||||
|
.mockResolvedValueOnce(adminEmployee); // replacement is the admin themselves
|
||||||
|
|
||||||
|
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
replacementEmployeeId: "emp-admin",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("designarte como reemplazo");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Replacement does not exist → error ---
|
||||||
|
|
||||||
|
it("throws when replacement employee does not exist", async () => {
|
||||||
|
const targetEmployee = buildEmployeeDoc({
|
||||||
|
userId: "target-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminEmployee = buildEmployeeDoc({
|
||||||
|
userId: "admin-user",
|
||||||
|
roles: [EmployeeRoles.ADMIN],
|
||||||
|
});
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
|
||||||
|
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(targetEmployee)
|
||||||
|
.mockResolvedValueOnce(adminEmployee)
|
||||||
|
.mockResolvedValueOnce(null); // replacement not found
|
||||||
|
|
||||||
|
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
replacementEmployeeId: "emp-nonexistent",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("empleado de reemplazo no existe");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Removal with replacement proceeds when future appointments exist ---
|
||||||
|
|
||||||
|
it("successfully removes employee with replacement when future appointments exist", async () => {
|
||||||
|
const targetEmployee = buildEmployeeDoc({
|
||||||
|
userId: "target-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminEmployee = buildEmployeeDoc({
|
||||||
|
userId: "admin-user",
|
||||||
|
roles: [EmployeeRoles.ADMIN],
|
||||||
|
});
|
||||||
|
|
||||||
|
const replacementEmployee = buildEmployeeDoc({
|
||||||
|
_id: "emp-replacement",
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "replacement-user",
|
||||||
|
removed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetUser = {
|
||||||
|
_id: "target-user",
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
email: "john@example.com",
|
||||||
|
avatar: "avatar-url",
|
||||||
|
};
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
(UsersManager.users.findOne as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ _id: "admin-user" })
|
||||||
|
.mockResolvedValueOnce(targetUser);
|
||||||
|
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(targetEmployee)
|
||||||
|
.mockResolvedValueOnce(adminEmployee)
|
||||||
|
.mockResolvedValueOnce(replacementEmployee);
|
||||||
|
|
||||||
|
// Future appointments exist
|
||||||
|
const mockAppointment = { employeeId: "emp-001", save: jest.fn().mockResolvedValue(true) };
|
||||||
|
mockAppointmentFind.mockResolvedValue([mockAppointment]);
|
||||||
|
|
||||||
|
await EmployeesList.removeCollaboratorFromCompany({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
replacementEmployeeId: "emp-replacement",
|
||||||
|
sessionUser: "admin-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(targetEmployee.removed).toBe(true);
|
||||||
|
expect(mockAppointment.employeeId).toBe("emp-replacement");
|
||||||
|
expect(mockAppointment.save).toHaveBeenCalled();
|
||||||
|
expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
});
|
||||||
|
expect(HeatMapList.deleteHeatMapByEmployee).toHaveBeenCalledWith({
|
||||||
|
employeeId: "emp-001",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -94,7 +94,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!employeeCheck) {
|
if (!employeeCheck) {
|
||||||
throw new Error("El empleado no existe o no pertenece a la 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({
|
const employeeServiceCheck = await this.employeesServices.findOne({
|
||||||
@@ -160,7 +164,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!employeeCheck) {
|
if (!employeeCheck) {
|
||||||
throw new Error("El empleado no existe o no pertenece a la 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);
|
await this.employeesServices.update(data);
|
||||||
@@ -253,7 +261,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!employeeCheck) {
|
if (!employeeCheck) {
|
||||||
throw new Error("El empleado no existe o no pertenece a la 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({
|
const userCheck = await UsersManager.users.findOne({
|
||||||
@@ -330,7 +342,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
|
|||||||
_id: data.employeeId,
|
_id: data.employeeId,
|
||||||
});
|
});
|
||||||
if (!employeeCheck) {
|
if (!employeeCheck) {
|
||||||
throw new Error("El empleado no existe o no pertenece a la 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({
|
const serviceCheck = await ServicesManager.services.findOne({
|
||||||
@@ -371,7 +387,11 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!employeeCheck) {
|
if (!employeeCheck) {
|
||||||
throw new Error("El empleado no existe o no pertenece a la 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({
|
const userCheck = await UsersManager.users.findOne({
|
||||||
@@ -416,6 +436,15 @@ class EmployeesServicesManager implements IEmployeesServicesManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const service of servicesByEmployee) {
|
for (const service of servicesByEmployee) {
|
||||||
|
const employeeDoc = await EmployeesList.employees.findOne({
|
||||||
|
_id: service.employeeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skip removed employees from public-facing results
|
||||||
|
if (!employeeDoc || employeeDoc.removed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const employeeData = await EmployeesList.findById({ id: service.employeeId });
|
const employeeData = await EmployeesList.findById({ id: service.employeeId });
|
||||||
|
|
||||||
returnData.push({
|
returnData.push({
|
||||||
|
|||||||
+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 = {
|
export type MetricsParams = {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
companyId?: string;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DeleteMetricsByCompany = {
|
export type DeleteMetricsByCompany = {
|
||||||
@@ -45,6 +47,8 @@ export interface IMetricsManager {
|
|||||||
addEmployee(data: MetricsParams): Promise<void>;
|
addEmployee(data: MetricsParams): Promise<void>;
|
||||||
addService(data: MetricsParams): Promise<void>;
|
addService(data: MetricsParams): Promise<void>;
|
||||||
addAppointment(data: MetricsParams): Promise<void>;
|
addAppointment(data: MetricsParams): Promise<void>;
|
||||||
|
reserveAppointment(data: MetricsParams): Promise<boolean>;
|
||||||
|
releaseAppointment(data: MetricsParams): Promise<void>;
|
||||||
addClient(data: MetricsParams): Promise<void>;
|
addClient(data: MetricsParams): Promise<void>;
|
||||||
canAddOrganization(userId: string): Promise<boolean>;
|
canAddOrganization(userId: string): Promise<boolean>;
|
||||||
canAddEmployee(userId: string): Promise<boolean>;
|
canAddEmployee(userId: string): Promise<boolean>;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import AppointmentList from "../Appointments/Appointments";
|
|||||||
import ClientsList from "../Clients/Clients";
|
import ClientsList from "../Clients/Clients";
|
||||||
import RepeatsList from "../Repeats/Repeats";
|
import RepeatsList from "../Repeats/Repeats";
|
||||||
import { isNull } from "../../helpers/IsNull";
|
import { isNull } from "../../helpers/IsNull";
|
||||||
|
import PlanUsageCycleList from "../PlanUsageCycle/PlanUsageCycle";
|
||||||
|
|
||||||
class MetricsManager implements IMetricsManager {
|
class MetricsManager implements IMetricsManager {
|
||||||
metrics: MetricsAdapterMongoose;
|
metrics: MetricsAdapterMongoose;
|
||||||
@@ -39,7 +40,8 @@ class MetricsManager implements IMetricsManager {
|
|||||||
for (const org of organizations) {
|
for (const org of organizations) {
|
||||||
const employees = await EmployeesList.employees.find({
|
const employees = await EmployeesList.employees.find({
|
||||||
companyId: String(org.id),
|
companyId: String(org.id),
|
||||||
});
|
removed: { $ne: true },
|
||||||
|
} as any);
|
||||||
employeesLength += employees.length;
|
employeesLength += employees.length;
|
||||||
|
|
||||||
const services = await ServiceList.services.find({
|
const services = await ServiceList.services.find({
|
||||||
@@ -231,17 +233,64 @@ class MetricsManager implements IMetricsManager {
|
|||||||
if (plan.limitAppointments < 0) {
|
if (plan.limitAppointments < 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const metrics = await this.getMetrics(userId);
|
const appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({ userId });
|
||||||
if (metrics.appointmentsCount >= plan.limitAppointments) {
|
if (appointmentsCount >= plan.limitAppointments) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addAppointment(data: MetricsParams): Promise<void> {
|
public async addAppointment(data: MetricsParams): Promise<void> {
|
||||||
|
if (data.userId) {
|
||||||
|
await PlanUsageCycleList.addAppointment({
|
||||||
|
userId: data.userId,
|
||||||
|
quantity: data.quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
await this.metrics.addAppointment(data);
|
await this.metrics.addAppointment(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async reserveAppointment(data: MetricsParams): Promise<boolean> {
|
||||||
|
if (!data.userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await PlanSubscriptionsList.findOne({
|
||||||
|
sessionUser: data.userId,
|
||||||
|
});
|
||||||
|
if (!subscription) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const plan = await PlansList.plans.findOne({
|
||||||
|
_id: subscription.planId,
|
||||||
|
});
|
||||||
|
if (!plan) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (plan.limitAppointments < 0) {
|
||||||
|
await PlanUsageCycleList.addAppointment({
|
||||||
|
userId: data.userId,
|
||||||
|
quantity: data.quantity,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await PlanUsageCycleList.reserveAppointment({
|
||||||
|
userId: data.userId,
|
||||||
|
quantity: data.quantity,
|
||||||
|
limit: plan.limitAppointments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async releaseAppointment(data: MetricsParams): Promise<void> {
|
||||||
|
if (data.userId) {
|
||||||
|
await PlanUsageCycleList.releaseAppointment({
|
||||||
|
userId: data.userId,
|
||||||
|
quantity: data.quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async addClient(data: MetricsParams): Promise<void> {
|
public async addClient(data: MetricsParams): Promise<void> {
|
||||||
await this.metrics.addClient(data);
|
await this.metrics.addClient(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Metrics.calculateMetrics — excludes removed employees from count.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("../../Companies/Companies", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
companies: {
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Employees/Employee", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
employees: {
|
||||||
|
find: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Services/Service", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
services: {
|
||||||
|
find: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Appointments/Appointments", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
countAppointmentsByMonth: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Clients/Clients", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
clients: {
|
||||||
|
find: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Repeats/Repeats", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
repeats: {
|
||||||
|
find: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Plans/Plans", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
plans: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../PlanUsageCycle/PlanUsageCycle", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
getAppointmentsCount: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import MetricsList from "../Metrics";
|
||||||
|
import CompaniesList from "../../Companies/Companies";
|
||||||
|
import EmployeesList from "../../Employees/Employee";
|
||||||
|
import ServiceList from "../../Services/Service";
|
||||||
|
import AppointmentList from "../../Appointments/Appointments";
|
||||||
|
import ClientsList from "../../Clients/Clients";
|
||||||
|
import RepeatsList from "../../Repeats/Repeats";
|
||||||
|
|
||||||
|
describe("calculateMetrics — excludes removed employees", () => {
|
||||||
|
let originalAdapter: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
originalAdapter = MetricsList.metrics;
|
||||||
|
|
||||||
|
const mockSave = jest.fn().mockResolvedValue(true);
|
||||||
|
const mockMetricsDoc = {
|
||||||
|
userId: "owner-user",
|
||||||
|
organizationsCount: 0,
|
||||||
|
employeesCount: 0,
|
||||||
|
servicesCount: 0,
|
||||||
|
appointmentsCount: 0,
|
||||||
|
clientsCount: 0,
|
||||||
|
repeatsCount: 0,
|
||||||
|
month: 6,
|
||||||
|
year: 2026,
|
||||||
|
save: mockSave,
|
||||||
|
};
|
||||||
|
|
||||||
|
(MetricsList as any).metrics = {
|
||||||
|
getMetrics: jest.fn().mockResolvedValue(mockMetricsDoc),
|
||||||
|
reset: jest.fn().mockResolvedValue(mockMetricsDoc),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(MetricsList as any).metrics = originalAdapter;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts only non-removed employees across companies", async () => {
|
||||||
|
// Two companies owned by the same user
|
||||||
|
(CompaniesList.companies.find as jest.Mock).mockResolvedValue([
|
||||||
|
{ _id: "company-001", id: "company-001" },
|
||||||
|
{ _id: "company-002", id: "company-002" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Company 001: 2 active + 1 removed → should count 2
|
||||||
|
// Company 002: 1 active → should count 1
|
||||||
|
// Total: 3 employees (not 4)
|
||||||
|
(EmployeesList.employees.find as jest.Mock)
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ _id: "emp-1", removed: false },
|
||||||
|
{ _id: "emp-2", removed: false },
|
||||||
|
{ _id: "emp-3", removed: true }, // filtered by query but just in case
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ _id: "emp-4", removed: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Services: 2 in company-001, 1 in company-002
|
||||||
|
(ServiceList.services.find as jest.Mock)
|
||||||
|
.mockResolvedValueOnce([{ _id: "svc-1" }, { _id: "svc-2" }])
|
||||||
|
.mockResolvedValueOnce([{ _id: "svc-3" }]);
|
||||||
|
|
||||||
|
// Appointments count per company
|
||||||
|
(AppointmentList.countAppointmentsByMonth as jest.Mock)
|
||||||
|
.mockResolvedValueOnce(5)
|
||||||
|
.mockResolvedValueOnce(3);
|
||||||
|
|
||||||
|
// Clients per company
|
||||||
|
(ClientsList.clients.find as jest.Mock)
|
||||||
|
.mockResolvedValueOnce([{ _id: "c-1" }])
|
||||||
|
.mockResolvedValueOnce([{ _id: "c-2" }, { _id: "c-3" }]);
|
||||||
|
|
||||||
|
// Repeats per company
|
||||||
|
(RepeatsList.repeats.find as jest.Mock)
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await MetricsList.calculateMetrics({ userId: "owner-user" });
|
||||||
|
|
||||||
|
// Verify employee find was called with removed filter for BOTH companies
|
||||||
|
expect(EmployeesList.employees.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
companyId: "company-001",
|
||||||
|
removed: { $ne: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(EmployeesList.employees.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
companyId: "company-002",
|
||||||
|
removed: { $ne: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the metrics document was saved
|
||||||
|
const metricsDoc = await (MetricsList as any).metrics.getMetrics();
|
||||||
|
expect(metricsDoc.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces zero employee count when all employees are removed", async () => {
|
||||||
|
(CompaniesList.companies.find as jest.Mock).mockResolvedValue([
|
||||||
|
{ _id: "company-001", id: "company-001" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// All employees in this company are removed
|
||||||
|
(EmployeesList.employees.find as jest.Mock).mockResolvedValue([]);
|
||||||
|
|
||||||
|
(ServiceList.services.find as jest.Mock).mockResolvedValue([]);
|
||||||
|
(AppointmentList.countAppointmentsByMonth as jest.Mock).mockResolvedValue(0);
|
||||||
|
(ClientsList.clients.find as jest.Mock).mockResolvedValue([]);
|
||||||
|
(RepeatsList.repeats.find as jest.Mock).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const mockSave = jest.fn().mockResolvedValue(true);
|
||||||
|
const mockMetricsDoc = {
|
||||||
|
userId: "owner-user",
|
||||||
|
organizationsCount: 0,
|
||||||
|
employeesCount: 5, // pre-existing count
|
||||||
|
servicesCount: 0,
|
||||||
|
appointmentsCount: 0,
|
||||||
|
clientsCount: 0,
|
||||||
|
repeatsCount: 0,
|
||||||
|
month: 6,
|
||||||
|
year: 2026,
|
||||||
|
save: mockSave,
|
||||||
|
};
|
||||||
|
|
||||||
|
(MetricsList as any).metrics = {
|
||||||
|
getMetrics: jest.fn().mockResolvedValue(mockMetricsDoc),
|
||||||
|
reset: jest.fn().mockResolvedValue(mockMetricsDoc),
|
||||||
|
};
|
||||||
|
|
||||||
|
await MetricsList.calculateMetrics({ userId: "owner-user" });
|
||||||
|
|
||||||
|
// employeesCount should be set to 0 (no non-removed employees)
|
||||||
|
expect(mockMetricsDoc.employeesCount).toBe(0);
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Document, Model, Schema, model } from "mongoose";
|
||||||
|
import {
|
||||||
|
CreatePlanPaymentParams,
|
||||||
|
FindPlanPaymentsParams,
|
||||||
|
IPlanPayment,
|
||||||
|
IPlanPaymentsAdapter,
|
||||||
|
} from "./PlanPayments.interface";
|
||||||
|
|
||||||
|
export interface IPlanPaymentDocument extends Omit<IPlanPayment, "id">, Document {}
|
||||||
|
|
||||||
|
export class PlanPaymentsAdapterMongoose implements IPlanPaymentsAdapter {
|
||||||
|
schema: Schema;
|
||||||
|
planPaymentList: Model<IPlanPaymentDocument>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.schema = new Schema({
|
||||||
|
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
|
||||||
|
subscriptionId: { type: Schema.Types.ObjectId, required: true, ref: "PlanSuscription" },
|
||||||
|
amount: { type: Number, required: true, default: 0 },
|
||||||
|
paymentDate: { type: Date, required: true, default: Date.now },
|
||||||
|
paymentMethod: { type: String, required: false },
|
||||||
|
status: { type: String, required: true },
|
||||||
|
transactionId: { type: String, required: false },
|
||||||
|
});
|
||||||
|
this.schema.index({ transactionId: 1 }, { unique: true, sparse: true });
|
||||||
|
|
||||||
|
this.planPaymentList = model<IPlanPaymentDocument>("PlanPayment", this.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
|
||||||
|
return this.planPaymentList.find(filters).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||||
|
return this.planPaymentList.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||||
|
if (!data.transactionId) {
|
||||||
|
return this.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.planPaymentList.findOneAndUpdate(
|
||||||
|
{ transactionId: data.transactionId },
|
||||||
|
{ $setOnInsert: data },
|
||||||
|
{ new: true, upsert: true, setDefaultsOnInsert: true }
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
export type FindPlanPaymentsParams = {
|
export type FindPlanPaymentsParams = {
|
||||||
_id?: string;
|
_id?: string;
|
||||||
|
userId?: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
transactionId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CreatePlanPaymentParams = Omit<IPlanPayment, "id">;
|
||||||
|
|
||||||
export interface IPlanPayment {
|
export interface IPlanPayment {
|
||||||
id?: string;
|
id?: string;
|
||||||
userId: string; // Referencia al ID del usuario
|
userId: string; // Referencia al ID del usuario
|
||||||
@@ -15,9 +20,13 @@ export interface IPlanPayment {
|
|||||||
|
|
||||||
export interface IPlanPaymentsAdapter {
|
export interface IPlanPaymentsAdapter {
|
||||||
find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
|
find(filters: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
|
||||||
|
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||||
|
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPlanPaymentsManager {
|
export interface IPlanPaymentsManager {
|
||||||
planPayments: IPlanPaymentsAdapter;
|
planPayments: IPlanPaymentsAdapter;
|
||||||
find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
|
find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]>;
|
||||||
|
create(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||||
|
createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { PlanPaymentsAdapterMongoose } from "./PlanPayments.Adapter.Mongoose";
|
||||||
|
import {
|
||||||
|
CreatePlanPaymentParams,
|
||||||
|
FindPlanPaymentsParams,
|
||||||
|
IPlanPayment,
|
||||||
|
IPlanPaymentsManager,
|
||||||
|
} from "./PlanPayments.interface";
|
||||||
|
|
||||||
|
class PlanPaymentsManager implements IPlanPaymentsManager {
|
||||||
|
planPayments: PlanPaymentsAdapterMongoose;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.planPayments = new PlanPaymentsAdapterMongoose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async find(data: FindPlanPaymentsParams): Promise<IPlanPayment[]> {
|
||||||
|
return this.planPayments.find(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||||
|
return this.planPayments.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createIfMissingByTransactionId(data: CreatePlanPaymentParams): Promise<IPlanPayment> {
|
||||||
|
return this.planPayments.createIfMissingByTransactionId(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlanPaymentsList = new PlanPaymentsManager();
|
||||||
|
|
||||||
|
export default PlanPaymentsList;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import {
|
import {
|
||||||
FindPlanSuscripcionsParams,
|
FindPlanSuscripcionsParams,
|
||||||
IPlanSuscriptionsAdapter,
|
IPlanSuscriptionsAdapter,
|
||||||
@@ -38,6 +37,26 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
|
|||||||
mpDateCreated: { type: Date, required: false },
|
mpDateCreated: { type: Date, required: false },
|
||||||
mpInitPoint: { type: String, required: false },
|
mpInitPoint: { type: String, required: false },
|
||||||
mpPreferenceId: { type: String, required: false },
|
mpPreferenceId: { type: String, required: false },
|
||||||
|
billingMonths: { type: Number, required: false },
|
||||||
|
pendingPaymentInitPoint: { type: String, required: false },
|
||||||
|
pendingPaymentPreferenceId: { type: String, required: false },
|
||||||
|
pendingPaymentBillingMonths: { type: Number, required: false },
|
||||||
|
pendingPaymentType: { type: String, required: false, enum: ["extension", "upgrade"] },
|
||||||
|
pendingPaymentPlanId: { type: Schema.Types.ObjectId, required: false, ref: "Plan" },
|
||||||
|
pendingPaymentCurrentPlanPrice: { type: Number, required: false },
|
||||||
|
pendingPaymentRequestedPlanPrice: { type: Number, required: false },
|
||||||
|
pendingPaymentProratedAmount: { type: Number, required: false },
|
||||||
|
pendingPaymentRemainingDays: { type: Number, required: false },
|
||||||
|
pendingPaymentPeriodEndDate: { type: Date, required: false },
|
||||||
|
lastPaymentStatus: { type: String, required: false, enum: ["rejected", "failed", "cancelled"] },
|
||||||
|
lastPaymentPreferenceId: { type: String, required: false },
|
||||||
|
lastPaymentAt: { type: Date, required: false },
|
||||||
|
lastPaymentType: { type: String, required: false, enum: ["new", "extension", "upgrade"] },
|
||||||
|
downgradedFromPlanId: { type: Schema.Types.ObjectId, required: false, ref: "Plan" },
|
||||||
|
downgradedFromPlanName: { type: String, required: false },
|
||||||
|
downgradedFromPlanCode: { type: String, required: false },
|
||||||
|
downgradedAt: { type: Date, required: false },
|
||||||
|
downgradeReason: { type: String, required: false, enum: ["expired"] },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
|
this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
|
||||||
@@ -54,20 +73,32 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
|
|||||||
public async findOne(
|
public async findOne(
|
||||||
filters: FindPlanSuscripcionsParams
|
filters: FindPlanSuscripcionsParams
|
||||||
): Promise<IPlanSuscriptionDocument | null> {
|
): Promise<IPlanSuscriptionDocument | null> {
|
||||||
let subscription = await this.planSuscriptionList
|
const searchCriteria: Record<string, unknown> = {};
|
||||||
.findOne({ userId: filters.sessionUser })
|
|
||||||
.exec();
|
if (filters.sessionUser) {
|
||||||
|
searchCriteria.userId = filters.sessionUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.mpPreferenceId) {
|
||||||
|
searchCriteria.mpPreferenceId = filters.mpPreferenceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.mpStatus) {
|
||||||
|
searchCriteria.mpStatus = filters.mpStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscription = await this.planSuscriptionList.findOne(searchCriteria).exec();
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateNow = dayjs(new Date());
|
const dateNow = new Date();
|
||||||
const dateEnd = dayjs(subscription.endDate);
|
const dateEnd = new Date(subscription.endDate);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (subscription.mpPreferenceId) {
|
if (subscription.mpPreferenceId) {
|
||||||
if (dateNow.isAfter(dateEnd)) {
|
if (dateNow > dateEnd) {
|
||||||
if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) {
|
if (subscription.mpStatus == MP_SUBS_STATUS.CANCELLED) {
|
||||||
subscription.mpPreferenceId = "";
|
subscription.mpPreferenceId = "";
|
||||||
subscription.isActive = false;
|
subscription.isActive = false;
|
||||||
@@ -77,17 +108,7 @@ export class PlanSuscriptionsAdapterMongoose implements IPlanSuscriptionsAdapter
|
|||||||
await subscription.save();
|
await subscription.save();
|
||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
// Without subscriptions, we wait for the webhook to update the status.
|
// Pending Checkout Preferences do not consume paid time; webhook decides the final status.
|
||||||
// If it's expired and not updated by webhook, we just cancel it.
|
|
||||||
if (subscription.mpStatus === MP_SUBS_STATUS.PENDING) {
|
|
||||||
subscription.mpPreferenceId = "";
|
|
||||||
subscription.isActive = false;
|
|
||||||
subscription.autoRenew = false;
|
|
||||||
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
|
||||||
subscription.mpInitPoint = "";
|
|
||||||
await subscription.save();
|
|
||||||
return subscription;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { IPlanSuscriptionDocument } from "./PlanSubscriptions.Adapter.Mongoose";
|
|||||||
|
|
||||||
export type FindPlanSuscripcionsParams = {
|
export type FindPlanSuscripcionsParams = {
|
||||||
sessionUser: string;
|
sessionUser: string;
|
||||||
|
mpPreferenceId?: string;
|
||||||
|
mpStatus?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreatePlanSuscriptionParams = {
|
export type CreatePlanSuscriptionParams = {
|
||||||
@@ -11,6 +13,7 @@ export type CreatePlanSuscriptionParams = {
|
|||||||
mpPlanId: string;
|
mpPlanId: string;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
|
isActive?: boolean;
|
||||||
autoRenew: boolean;
|
autoRenew: boolean;
|
||||||
mpPayerId?: number;
|
mpPayerId?: number;
|
||||||
mpPayerEmail?: string;
|
mpPayerEmail?: string;
|
||||||
@@ -18,6 +21,26 @@ export type CreatePlanSuscriptionParams = {
|
|||||||
mpDateCreated?: Date;
|
mpDateCreated?: Date;
|
||||||
mpInitPoint?: string;
|
mpInitPoint?: string;
|
||||||
mpPreferenceId?: string;
|
mpPreferenceId?: string;
|
||||||
|
billingMonths?: number;
|
||||||
|
pendingPaymentInitPoint?: string;
|
||||||
|
pendingPaymentPreferenceId?: string;
|
||||||
|
pendingPaymentBillingMonths?: number;
|
||||||
|
pendingPaymentType?: "extension" | "upgrade";
|
||||||
|
pendingPaymentPlanId?: string;
|
||||||
|
pendingPaymentCurrentPlanPrice?: number;
|
||||||
|
pendingPaymentRequestedPlanPrice?: number;
|
||||||
|
pendingPaymentProratedAmount?: number;
|
||||||
|
pendingPaymentRemainingDays?: number;
|
||||||
|
pendingPaymentPeriodEndDate?: Date;
|
||||||
|
lastPaymentStatus?: LastPlanPaymentStatus;
|
||||||
|
lastPaymentPreferenceId?: string;
|
||||||
|
lastPaymentAt?: Date;
|
||||||
|
lastPaymentType?: LastPlanPaymentType;
|
||||||
|
downgradedFromPlanId?: string;
|
||||||
|
downgradedFromPlanName?: string;
|
||||||
|
downgradedFromPlanCode?: string;
|
||||||
|
downgradedAt?: Date;
|
||||||
|
downgradeReason?: DowngradeReason;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CancellPlanSuscriptionParams = {
|
export type CancellPlanSuscriptionParams = {
|
||||||
@@ -47,6 +70,7 @@ export type MPPreApprovalResponse = {
|
|||||||
|
|
||||||
export type ToFreePlanParams = {
|
export type ToFreePlanParams = {
|
||||||
sessionUser: string;
|
sessionUser: string;
|
||||||
|
downgradeReason?: DowngradeReason;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToMpParams = {
|
export type ToMpParams = {
|
||||||
@@ -68,6 +92,23 @@ export type GetSubscriptionInitPointParams = {
|
|||||||
sessionUser: string;
|
sessionUser: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VerifyPendingPlanPaymentParams = {
|
||||||
|
sessionUser: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VerifyPendingPlanPaymentResponse = {
|
||||||
|
status: "approved" | "pending" | "not_found" | "rejected" | "failed" | "cancelled";
|
||||||
|
approved: boolean;
|
||||||
|
message: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
paymentId?: string;
|
||||||
|
paymentType?: "new" | "extension" | "upgrade";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
|
||||||
|
export type LastPlanPaymentType = "new" | "extension" | "upgrade";
|
||||||
|
export type DowngradeReason = "expired";
|
||||||
|
|
||||||
export interface GetSuscriptionInitPointResponse {
|
export interface GetSuscriptionInitPointResponse {
|
||||||
init_point: string;
|
init_point: string;
|
||||||
}
|
}
|
||||||
@@ -88,6 +129,26 @@ export interface IPlanSuscription {
|
|||||||
mpDateCreated: Date; // Fecha de creación de la suscripción en MercadoPago.
|
mpDateCreated: Date; // Fecha de creación de la suscripción en MercadoPago.
|
||||||
mpInitPoint: string; // URL de inicio de la suscripción en MercadoPago.
|
mpInitPoint: string; // URL de inicio de la suscripción en MercadoPago.
|
||||||
mpPreferenceId: string; // ID de la preferencia en MercadoPago.
|
mpPreferenceId: string; // ID de la preferencia en MercadoPago.
|
||||||
|
billingMonths?: number; // Cantidad de meses comprados para pagos únicos.
|
||||||
|
pendingPaymentInitPoint?: string; // URL de pago pendiente para extender el plan actual.
|
||||||
|
pendingPaymentPreferenceId?: string; // ID de preferencia pendiente para extender el plan actual.
|
||||||
|
pendingPaymentBillingMonths?: number; // Meses pendientes de acreditar al aprobarse la extensión.
|
||||||
|
pendingPaymentType?: "extension" | "upgrade";
|
||||||
|
pendingPaymentPlanId?: string;
|
||||||
|
pendingPaymentCurrentPlanPrice?: number;
|
||||||
|
pendingPaymentRequestedPlanPrice?: number;
|
||||||
|
pendingPaymentProratedAmount?: number;
|
||||||
|
pendingPaymentRemainingDays?: number;
|
||||||
|
pendingPaymentPeriodEndDate?: Date;
|
||||||
|
lastPaymentStatus?: LastPlanPaymentStatus;
|
||||||
|
lastPaymentPreferenceId?: string;
|
||||||
|
lastPaymentAt?: Date;
|
||||||
|
lastPaymentType?: LastPlanPaymentType;
|
||||||
|
downgradedFromPlanId?: string;
|
||||||
|
downgradedFromPlanName?: string;
|
||||||
|
downgradedFromPlanCode?: string;
|
||||||
|
downgradedAt?: Date;
|
||||||
|
downgradeReason?: DowngradeReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISubscriptionInfo {
|
export interface ISubscriptionInfo {
|
||||||
@@ -99,6 +160,18 @@ export interface ISubscriptionInfo {
|
|||||||
autoRenew: boolean;
|
autoRenew: boolean;
|
||||||
mpStatus: string;
|
mpStatus: string;
|
||||||
mpDateCreated: Date;
|
mpDateCreated: Date;
|
||||||
|
pendingPaymentInitPoint?: string;
|
||||||
|
pendingPaymentPreferenceId?: string;
|
||||||
|
pendingPaymentType?: "extension" | "upgrade";
|
||||||
|
lastPaymentStatus?: LastPlanPaymentStatus;
|
||||||
|
lastPaymentPreferenceId?: string;
|
||||||
|
lastPaymentAt?: Date;
|
||||||
|
lastPaymentType?: LastPlanPaymentType;
|
||||||
|
downgradedFromPlanId?: string;
|
||||||
|
downgradedFromPlanName?: string;
|
||||||
|
downgradedFromPlanCode?: string;
|
||||||
|
downgradedAt?: Date;
|
||||||
|
downgradeReason?: DowngradeReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPlanSuscriptionsAdapter {
|
export interface IPlanSuscriptionsAdapter {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const DEFAULT_BILLING_PERIOD_DAYS = 30;
|
||||||
|
|
||||||
|
export function calculateProratedUpgradeAmount(
|
||||||
|
currentPlanPrice: number,
|
||||||
|
requestedPlanPrice: number,
|
||||||
|
remainingDays: number,
|
||||||
|
billingPeriodDays = DEFAULT_BILLING_PERIOD_DAYS
|
||||||
|
): number {
|
||||||
|
const dailyDifference = (requestedPlanPrice - currentPlanPrice) / billingPeriodDays;
|
||||||
|
return Math.round(dailyDifference * remainingDays * 100) / 100;
|
||||||
|
}
|
||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import PlansList from "../../Models/Plans/Plans";
|
import PlansList from "../../Models/Plans/Plans";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
|
|
||||||
import "dayjs/locale/es";
|
import "dayjs/locale/es";
|
||||||
import { isNull } from "../../helpers/IsNull";
|
import { isNull } from "../../helpers/IsNull";
|
||||||
import { PlanFeatures } from "../Plans/Plans.interface";
|
import { PlanFeatures } from "../Plans/Plans.interface";
|
||||||
@@ -31,9 +30,11 @@ import { NotificationsManager } from "../Notifications/Notifications";
|
|||||||
import WapServerList from "../WapServer/WapServer";
|
import WapServerList from "../WapServer/WapServer";
|
||||||
import CompaniesList from "../Companies/Companies";
|
import CompaniesList from "../Companies/Companies";
|
||||||
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
|
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
|
||||||
|
import { calculateProratedUpgradeAmount } from "./PlanSubscriptions.pricing";
|
||||||
|
|
||||||
dayjs.locale("es");
|
dayjs.locale("es");
|
||||||
dayjs.extend(isSameOrAfter);
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
||||||
planSuscriptions: PlanSuscriptionsAdapterMongoose;
|
planSuscriptions: PlanSuscriptionsAdapterMongoose;
|
||||||
@@ -42,6 +43,24 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
this.planSuscriptions = new PlanSuscriptionsAdapterMongoose();
|
this.planSuscriptions = new PlanSuscriptionsAdapterMongoose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldDowngradeExpiredSubscription(subscription: IPlanSuscriptionDocument): boolean {
|
||||||
|
return subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED || subscription.mpStatus === MP_SUBS_STATUS.CANCELLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMercadoPagoBackUrl(returnStatus: "success" | "failure" | "pending"): string {
|
||||||
|
const backUrl = process.env.MP_BACK_URL || "";
|
||||||
|
if (!backUrl) return backUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(backUrl);
|
||||||
|
url.searchParams.set("mp_return", returnStatus);
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
const separator = backUrl.includes("?") ? "&" : "?";
|
||||||
|
return `${backUrl}${separator}mp_return=${returnStatus}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async getInitPoint(
|
public async getInitPoint(
|
||||||
data: GetSubscriptionInitPointParams
|
data: GetSubscriptionInitPointParams
|
||||||
): Promise<GetSuscriptionInitPointResponse> {
|
): Promise<GetSuscriptionInitPointResponse> {
|
||||||
@@ -135,8 +154,9 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
planId: data.planId,
|
planId: data.planId,
|
||||||
mpPlanId: data.planId,
|
mpPlanId: data.planId,
|
||||||
startDate: subscriptionStartDate.toDate(),
|
startDate: subscriptionStartDate.toDate(),
|
||||||
endDate: subscriptionStartDate.clone().add(data.months || 1, "months").toDate(),
|
endDate: subscriptionStartDate.clone().add(10, "years").toDate(),
|
||||||
autoRenew: true,
|
autoRenew: false,
|
||||||
|
mpStatus: "",
|
||||||
mpInitPoint: "/landing/dashboard",
|
mpInitPoint: "/landing/dashboard",
|
||||||
});
|
});
|
||||||
} else if (plan.price > 0) {
|
} else if (plan.price > 0) {
|
||||||
@@ -168,8 +188,16 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
|
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
const subscriptionStartDate = dayjs(new Date());
|
const subscriptionStartDate = dayjs(new Date());
|
||||||
subscription.endDate = subscriptionStartDate.clone().add(data.months || 1, "months").toDate();
|
subscription.startDate = subscriptionStartDate.toDate();
|
||||||
|
subscription.endDate = subscriptionStartDate.clone().add(10, "years").toDate();
|
||||||
|
subscription.autoRenew = false;
|
||||||
|
subscription.mpStatus = "";
|
||||||
subscription.mpInitPoint = dashboardUrl || "";
|
subscription.mpInitPoint = dashboardUrl || "";
|
||||||
|
subscription.downgradedFromPlanId = undefined;
|
||||||
|
subscription.downgradedFromPlanName = undefined;
|
||||||
|
subscription.downgradedFromPlanCode = undefined;
|
||||||
|
subscription.downgradedAt = undefined;
|
||||||
|
subscription.downgradeReason = undefined;
|
||||||
await subscription.save();
|
await subscription.save();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -182,6 +210,23 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
months: data.months || 1,
|
months: data.months || 1,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const isActivePaidSubscription =
|
||||||
|
actualPlan.price > 0 &&
|
||||||
|
(subscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
|
||||||
|
subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
|
||||||
|
dayjs(subscription.endDate).isAfter(new Date());
|
||||||
|
|
||||||
|
if (isActivePaidSubscription && actualPlan.id !== data.planId) {
|
||||||
|
if (plan.price <= actualPlan.price) {
|
||||||
|
throw new Error("No se puede cambiar a un plan igual o inferior desde esta ruta de actualización.");
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription = await this.toMP({
|
||||||
|
planId: data.planId,
|
||||||
|
sessionUser: data.sessionUser,
|
||||||
|
months: data.months || 1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
if (subscription.mpStatus != MP_SUBS_STATUS.CANCELLED && actualPlan.id !== data.planId) {
|
if (subscription.mpStatus != MP_SUBS_STATUS.CANCELLED && actualPlan.id !== data.planId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"No se puede cambiar el plan, ya que el usuario ya tiene una suscripción activa. Para cambiarlo primero debes cancelar tu plan actual."
|
"No se puede cambiar el plan, ya que el usuario ya tiene una suscripción activa. Para cambiarlo primero debes cancelar tu plan actual."
|
||||||
@@ -193,6 +238,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
sessionUser: data.sessionUser,
|
sessionUser: data.sessionUser,
|
||||||
months: data.months || 1,
|
months: data.months || 1,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,7 +273,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subscriptionStartDate = dayjs(new Date());
|
const preferenceDate = dayjs(new Date());
|
||||||
|
|
||||||
let discount = 0;
|
let discount = 0;
|
||||||
if (data.months === 3) discount = plan.discount3Months || 0;
|
if (data.months === 3) discount = plan.discount3Months || 0;
|
||||||
@@ -237,21 +283,74 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
const totalPrice = plan.price * data.months;
|
const totalPrice = plan.price * data.months;
|
||||||
const finalPrice = totalPrice - (totalPrice * (discount / 100));
|
const finalPrice = totalPrice - (totalPrice * (discount / 100));
|
||||||
|
|
||||||
|
const currentSubscription = await this.planSuscriptions.findOne({
|
||||||
|
sessionUser: data.sessionUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentPlan = currentSubscription
|
||||||
|
? await PlansList.plans.findOne({ _id: String(currentSubscription.planId) })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isUpgradePayment =
|
||||||
|
currentSubscription &&
|
||||||
|
currentPlan &&
|
||||||
|
String(currentSubscription.planId) !== String(data.planId) &&
|
||||||
|
currentPlan.price > 0 &&
|
||||||
|
plan.price > currentPlan.price &&
|
||||||
|
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
|
||||||
|
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
|
||||||
|
dayjs(currentSubscription.endDate).isAfter(preferenceDate);
|
||||||
|
|
||||||
|
const remainingDays = isUpgradePayment
|
||||||
|
? Math.max(0, Math.ceil((new Date(currentSubscription.endDate).getTime() - preferenceDate.toDate().getTime()) / DAY_MS))
|
||||||
|
: 0;
|
||||||
|
const proratedAmount = isUpgradePayment
|
||||||
|
? calculateProratedUpgradeAmount(currentPlan.price, plan.price, remainingDays)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (isUpgradePayment && remainingDays <= 0) {
|
||||||
|
throw new Error("No se puede actualizar el plan porque la suscripción actual no tiene días restantes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentSubscription?.pendingPaymentPreferenceId &&
|
||||||
|
currentSubscription.pendingPaymentInitPoint
|
||||||
|
) {
|
||||||
|
if (String(currentSubscription.pendingPaymentPlanId || currentSubscription.planId) === String(data.planId)) {
|
||||||
|
return currentSubscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Ya tenés un pago pendiente para tu plan. Verificalo o esperá a que se actualice antes de intentar otro cambio.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentSubscription &&
|
||||||
|
String(currentSubscription.planId) === String(data.planId) &&
|
||||||
|
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
|
||||||
|
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED) &&
|
||||||
|
currentSubscription.pendingPaymentPreferenceId &&
|
||||||
|
currentSubscription.pendingPaymentInitPoint
|
||||||
|
) {
|
||||||
|
return currentSubscription;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
"https://api.mercadopago.com/checkout/preferences",
|
"https://api.mercadopago.com/checkout/preferences",
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)",
|
title: isUpgradePayment
|
||||||
|
? "TurnosXpress.com.ar - Upgrade al plan " + plan.name
|
||||||
|
: "TurnosXpress.com.ar - Plan " + plan.name + " (" + data.months + " meses)",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
currency_id: "ARS",
|
currency_id: "ARS",
|
||||||
unit_price: finalPrice
|
unit_price: isUpgradePayment ? proratedAmount : finalPrice
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
back_urls: {
|
back_urls: {
|
||||||
success: process.env.MP_BACK_URL,
|
success: this.buildMercadoPagoBackUrl("success"),
|
||||||
failure: process.env.MP_BACK_URL,
|
failure: this.buildMercadoPagoBackUrl("failure"),
|
||||||
pending: process.env.MP_BACK_URL
|
pending: this.buildMercadoPagoBackUrl("pending")
|
||||||
},
|
},
|
||||||
auto_return: "approved",
|
auto_return: "approved",
|
||||||
payer: {
|
payer: {
|
||||||
@@ -270,20 +369,102 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
|
|
||||||
const subscriptionData = response.data;
|
const subscriptionData = response.data;
|
||||||
|
|
||||||
this.planSuscriptions.delete({ sessionUser: data.sessionUser });
|
if (isUpgradePayment && currentSubscription && currentPlan) {
|
||||||
|
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
|
||||||
|
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
|
||||||
|
currentSubscription.pendingPaymentBillingMonths = undefined;
|
||||||
|
currentSubscription.pendingPaymentType = "upgrade";
|
||||||
|
currentSubscription.pendingPaymentPlanId = data.planId;
|
||||||
|
currentSubscription.pendingPaymentCurrentPlanPrice = currentPlan.price;
|
||||||
|
currentSubscription.pendingPaymentRequestedPlanPrice = plan.price;
|
||||||
|
currentSubscription.pendingPaymentProratedAmount = proratedAmount;
|
||||||
|
currentSubscription.pendingPaymentRemainingDays = remainingDays;
|
||||||
|
currentSubscription.pendingPaymentPeriodEndDate = currentSubscription.endDate;
|
||||||
|
currentSubscription.lastPaymentStatus = undefined;
|
||||||
|
currentSubscription.lastPaymentPreferenceId = undefined;
|
||||||
|
currentSubscription.lastPaymentAt = undefined;
|
||||||
|
currentSubscription.lastPaymentType = undefined;
|
||||||
|
currentSubscription.downgradedFromPlanId = undefined;
|
||||||
|
currentSubscription.downgradedFromPlanName = undefined;
|
||||||
|
currentSubscription.downgradedFromPlanCode = undefined;
|
||||||
|
currentSubscription.downgradedAt = undefined;
|
||||||
|
currentSubscription.downgradeReason = undefined;
|
||||||
|
currentSubscription.mpInitPoint = subscriptionData.init_point;
|
||||||
|
await currentSubscription.save();
|
||||||
|
|
||||||
|
await NotificationsManager.sendSystemNotification({
|
||||||
|
userId: String(data.sessionUser),
|
||||||
|
subject: "Se ha creado una actualización de suscripción",
|
||||||
|
message: `Se ha creado una actualización al plan ${plan.name}.`,
|
||||||
|
type: NotificationType.BILLING,
|
||||||
|
code: String(currentSubscription._id)
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentSubscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentSubscription &&
|
||||||
|
String(currentSubscription.planId) === String(data.planId) &&
|
||||||
|
(currentSubscription.mpStatus === MP_SUBS_STATUS.AUTHORIZED ||
|
||||||
|
currentSubscription.mpStatus === MP_SUBS_STATUS.CANCELLED)
|
||||||
|
) {
|
||||||
|
currentSubscription.pendingPaymentInitPoint = subscriptionData.init_point;
|
||||||
|
currentSubscription.pendingPaymentPreferenceId = subscriptionData.id;
|
||||||
|
currentSubscription.pendingPaymentBillingMonths = data.months;
|
||||||
|
currentSubscription.pendingPaymentType = "extension";
|
||||||
|
currentSubscription.lastPaymentStatus = undefined;
|
||||||
|
currentSubscription.lastPaymentPreferenceId = undefined;
|
||||||
|
currentSubscription.lastPaymentAt = undefined;
|
||||||
|
currentSubscription.lastPaymentType = undefined;
|
||||||
|
currentSubscription.downgradedFromPlanId = undefined;
|
||||||
|
currentSubscription.downgradedFromPlanName = undefined;
|
||||||
|
currentSubscription.downgradedFromPlanCode = undefined;
|
||||||
|
currentSubscription.downgradedAt = undefined;
|
||||||
|
currentSubscription.downgradeReason = undefined;
|
||||||
|
currentSubscription.mpInitPoint = subscriptionData.init_point;
|
||||||
|
await currentSubscription.save();
|
||||||
|
|
||||||
|
await NotificationsManager.sendSystemNotification({
|
||||||
|
userId: String(data.sessionUser),
|
||||||
|
subject: "Se ha creado una nueva extensión de suscripción",
|
||||||
|
message: `Se ha creado una nueva extensión para el plan ${plan.name}.`,
|
||||||
|
type: NotificationType.BILLING,
|
||||||
|
code: String(currentSubscription._id)
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentSubscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.planSuscriptions.delete({ sessionUser: data.sessionUser });
|
||||||
|
|
||||||
const subscription = await this.planSuscriptions.create({
|
const subscription = await this.planSuscriptions.create({
|
||||||
userId: data.sessionUser,
|
userId: data.sessionUser,
|
||||||
planId: data.planId,
|
planId: data.planId,
|
||||||
mpPlanId: data.planId,
|
mpPlanId: data.planId,
|
||||||
startDate: subscriptionStartDate.toDate(),
|
startDate: preferenceDate.toDate(),
|
||||||
endDate: subscriptionStartDate.clone().add(data.months, "months").toDate(),
|
endDate: preferenceDate.toDate(),
|
||||||
|
isActive: false,
|
||||||
autoRenew: false,
|
autoRenew: false,
|
||||||
mpPayerEmail: sessionUser.mpPayerEmail,
|
mpPayerEmail: sessionUser.mpPayerEmail,
|
||||||
mpStatus: MP_SUBS_STATUS.PENDING,
|
mpStatus: MP_SUBS_STATUS.PENDING,
|
||||||
mpDateCreated: new Date(),
|
mpDateCreated: new Date(),
|
||||||
mpInitPoint: subscriptionData.init_point,
|
mpInitPoint: subscriptionData.init_point,
|
||||||
mpPreferenceId: subscriptionData.id,
|
mpPreferenceId: subscriptionData.id,
|
||||||
|
billingMonths: data.months,
|
||||||
|
pendingPaymentInitPoint: "",
|
||||||
|
pendingPaymentPreferenceId: "",
|
||||||
|
pendingPaymentBillingMonths: undefined,
|
||||||
|
pendingPaymentType: undefined,
|
||||||
|
lastPaymentStatus: undefined,
|
||||||
|
lastPaymentPreferenceId: undefined,
|
||||||
|
lastPaymentAt: undefined,
|
||||||
|
lastPaymentType: undefined,
|
||||||
|
downgradedFromPlanId: undefined,
|
||||||
|
downgradedFromPlanName: undefined,
|
||||||
|
downgradedFromPlanCode: undefined,
|
||||||
|
downgradedAt: undefined,
|
||||||
|
downgradeReason: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await NotificationsManager.sendSystemNotification({
|
await NotificationsManager.sendSystemNotification({
|
||||||
@@ -330,18 +511,29 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
// We just cancel it locally if not paid, or change to free plan if paid.
|
// We just cancel it locally if not paid, or change to free plan if paid.
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateNow = dayjs(new Date()).endOf("day");
|
const dateNow = new Date();
|
||||||
const dateEnd = dayjs(subscription.endDate);
|
const dateEnd = new Date(subscription.endDate);
|
||||||
|
|
||||||
if (dateNow.isSameOrAfter(dateEnd)) {
|
if (dateNow > dateEnd) {
|
||||||
this.toFreePlan({
|
await this.toFreePlan({
|
||||||
sessionUser: data.sessionUser,
|
sessionUser: data.sessionUser,
|
||||||
|
downgradeReason: "expired",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
||||||
subscription.mpInitPoint = "";
|
subscription.mpInitPoint = "";
|
||||||
subscription.mpPayerEmail = "";
|
subscription.mpPayerEmail = "";
|
||||||
subscription.mpPayerId = -1;
|
subscription.mpPayerId = -1;
|
||||||
|
subscription.pendingPaymentInitPoint = "";
|
||||||
|
subscription.pendingPaymentPreferenceId = "";
|
||||||
|
subscription.pendingPaymentBillingMonths = undefined;
|
||||||
|
subscription.pendingPaymentType = undefined;
|
||||||
|
subscription.pendingPaymentPlanId = undefined;
|
||||||
|
subscription.pendingPaymentCurrentPlanPrice = undefined;
|
||||||
|
subscription.pendingPaymentRequestedPlanPrice = undefined;
|
||||||
|
subscription.pendingPaymentProratedAmount = undefined;
|
||||||
|
subscription.pendingPaymentRemainingDays = undefined;
|
||||||
|
subscription.pendingPaymentPeriodEndDate = undefined;
|
||||||
await subscription.save();
|
await subscription.save();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -400,12 +592,43 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
|
|
||||||
//console.log("guardando el plan gratuito...");
|
//console.log("guardando el plan gratuito...");
|
||||||
//try {
|
//try {
|
||||||
|
const downgradedFromPlan = data.downgradeReason === "expired"
|
||||||
|
? await PlansList.plans.findOne({ _id: String(subscription.planId) })
|
||||||
|
: null;
|
||||||
|
|
||||||
console.log("cambiando al free plan:", freePlan.id);
|
console.log("cambiando al free plan:", freePlan.id);
|
||||||
subscription.planId = freePlan.id;
|
subscription.planId = freePlan.id;
|
||||||
subscription.mpStatus = "";
|
subscription.mpStatus = "";
|
||||||
subscription.isActive = true;
|
subscription.isActive = true;
|
||||||
|
subscription.autoRenew = false;
|
||||||
subscription.mpInitPoint = "";
|
subscription.mpInitPoint = "";
|
||||||
subscription.mpPreferenceId = "";
|
subscription.mpPreferenceId = "";
|
||||||
|
subscription.billingMonths = undefined;
|
||||||
|
subscription.pendingPaymentInitPoint = "";
|
||||||
|
subscription.pendingPaymentPreferenceId = "";
|
||||||
|
subscription.pendingPaymentBillingMonths = undefined;
|
||||||
|
subscription.pendingPaymentType = undefined;
|
||||||
|
subscription.pendingPaymentPlanId = undefined;
|
||||||
|
subscription.pendingPaymentCurrentPlanPrice = undefined;
|
||||||
|
subscription.pendingPaymentRequestedPlanPrice = undefined;
|
||||||
|
subscription.pendingPaymentProratedAmount = undefined;
|
||||||
|
subscription.pendingPaymentRemainingDays = undefined;
|
||||||
|
subscription.pendingPaymentPeriodEndDate = undefined;
|
||||||
|
subscription.startDate = new Date();
|
||||||
|
subscription.endDate = dayjs(new Date()).add(10, "years").toDate();
|
||||||
|
if (data.downgradeReason === "expired" && downgradedFromPlan) {
|
||||||
|
subscription.downgradedFromPlanId = String(downgradedFromPlan.id || downgradedFromPlan._id);
|
||||||
|
subscription.downgradedFromPlanName = downgradedFromPlan.name;
|
||||||
|
subscription.downgradedFromPlanCode = downgradedFromPlan.code;
|
||||||
|
subscription.downgradedAt = new Date();
|
||||||
|
subscription.downgradeReason = "expired";
|
||||||
|
} else {
|
||||||
|
subscription.downgradedFromPlanId = undefined;
|
||||||
|
subscription.downgradedFromPlanName = undefined;
|
||||||
|
subscription.downgradedFromPlanCode = undefined;
|
||||||
|
subscription.downgradedAt = undefined;
|
||||||
|
subscription.downgradeReason = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
await subscription.save();
|
await subscription.save();
|
||||||
// console.log("plan gratuito guardado:", freePlan.id);
|
// console.log("plan gratuito guardado:", freePlan.id);
|
||||||
@@ -430,6 +653,15 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
autoRenew: subscription.autoRenew,
|
autoRenew: subscription.autoRenew,
|
||||||
mpStatus: subscription.mpStatus,
|
mpStatus: subscription.mpStatus,
|
||||||
mpDateCreated: subscription.mpDateCreated,
|
mpDateCreated: subscription.mpDateCreated,
|
||||||
|
lastPaymentStatus: subscription.lastPaymentStatus,
|
||||||
|
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
|
||||||
|
lastPaymentAt: subscription.lastPaymentAt,
|
||||||
|
lastPaymentType: subscription.lastPaymentType,
|
||||||
|
downgradedFromPlanId: subscription.downgradedFromPlanId,
|
||||||
|
downgradedFromPlanName: subscription.downgradedFromPlanName,
|
||||||
|
downgradedFromPlanCode: subscription.downgradedFromPlanCode,
|
||||||
|
downgradedAt: subscription.downgradedAt,
|
||||||
|
downgradeReason: subscription.downgradeReason,
|
||||||
};
|
};
|
||||||
|
|
||||||
io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo);
|
io.to(`user:${String(subscription.userId)}`).emit("suscription_status_updated", susInfo);
|
||||||
@@ -446,15 +678,14 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateNow = dayjs(new Date()).endOf("day");
|
const dateNow = new Date();
|
||||||
const dateEnd = dayjs(subscription.endDate);
|
const dateEnd = new Date(subscription.endDate);
|
||||||
|
|
||||||
if (dateNow.isSameOrAfter(dateEnd)) {
|
if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
|
||||||
if (subscription.mpStatus === MP_SUBS_STATUS.CANCELLED) {
|
await this.toFreePlan({
|
||||||
this.toFreePlan({
|
sessionUser: data.sessionUser,
|
||||||
sessionUser: data.sessionUser,
|
downgradeReason: "expired",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.planSuscriptions.findOne(data);
|
return await this.planSuscriptions.findOne(data);
|
||||||
@@ -469,26 +700,30 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateNow = dayjs(new Date()).endOf("day");
|
const dateNow = new Date();
|
||||||
const dateEnd = dayjs(subscription.endDate);
|
const dateEnd = new Date(subscription.endDate);
|
||||||
|
|
||||||
if (dateNow.isSameOrAfter(dateEnd)) {
|
if (dateNow > dateEnd && this.shouldDowngradeExpiredSubscription(subscription)) {
|
||||||
//Esta condicion era la culpable de que todo el tiempo se queria aplicar el free plan.
|
await this.toFreePlan({
|
||||||
//Ya que la fecha de finalizacion queda guardada en la base de datos a pesar de que ya no se use.
|
sessionUser: data.sessionUser,
|
||||||
if (
|
downgradeReason: "expired",
|
||||||
subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED &&
|
});
|
||||||
subscription.mpStatus !== ""
|
return this.getSubscriptionByUser(data);
|
||||||
) {
|
|
||||||
this.toFreePlan({
|
|
||||||
sessionUser: data.sessionUser,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = await PlansList.plans.findOne({
|
let plan = await PlansList.findOne({
|
||||||
_id: subscription.planId,
|
_id: subscription.planId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isPendingPaidSubscription = subscription.mpStatus === MP_SUBS_STATUS.PENDING && plan.price > 0;
|
||||||
|
|
||||||
|
if (!isPendingPaidSubscription && (!subscription.isActive || subscription.mpStatus === MP_SUBS_STATUS.PENDING)) {
|
||||||
|
const freePlan = await PlansList.findOne({ price: 0 });
|
||||||
|
if (freePlan) {
|
||||||
|
plan = freePlan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
throw new Error("No se ha encontrado el plan");
|
throw new Error("No se ha encontrado el plan");
|
||||||
}
|
}
|
||||||
@@ -502,6 +737,18 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
autoRenew: subscription.autoRenew,
|
autoRenew: subscription.autoRenew,
|
||||||
mpStatus: subscription.mpStatus,
|
mpStatus: subscription.mpStatus,
|
||||||
mpDateCreated: subscription.mpDateCreated,
|
mpDateCreated: subscription.mpDateCreated,
|
||||||
|
pendingPaymentInitPoint: subscription.pendingPaymentInitPoint,
|
||||||
|
pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId,
|
||||||
|
pendingPaymentType: subscription.pendingPaymentType,
|
||||||
|
lastPaymentStatus: subscription.lastPaymentStatus,
|
||||||
|
lastPaymentPreferenceId: subscription.lastPaymentPreferenceId,
|
||||||
|
lastPaymentAt: subscription.lastPaymentAt,
|
||||||
|
lastPaymentType: subscription.lastPaymentType,
|
||||||
|
downgradedFromPlanId: subscription.downgradedFromPlanId,
|
||||||
|
downgradedFromPlanName: subscription.downgradedFromPlanName,
|
||||||
|
downgradedFromPlanCode: subscription.downgradedFromPlanCode,
|
||||||
|
downgradedAt: subscription.downgradedAt,
|
||||||
|
downgradeReason: subscription.downgradeReason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +761,10 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
}
|
}
|
||||||
const plan = subscriptionInfo.plan;
|
const plan = subscriptionInfo.plan;
|
||||||
|
|
||||||
|
if (!subscriptionInfo.isActive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
throw new Error("No se encontro el plan de la suscripcion");
|
throw new Error("No se encontro el plan de la suscripcion");
|
||||||
}
|
}
|
||||||
@@ -553,7 +804,7 @@ class PlanSuscriptionsManager implements IPlanSuscriptionsManager {
|
|||||||
if (!subscriptionInfo) {
|
if (!subscriptionInfo) {
|
||||||
throw new Error("No se encontro una suscripcion activa");
|
throw new Error("No se encontro una suscripcion activa");
|
||||||
}
|
}
|
||||||
return subscriptionInfo.isActive;
|
return subscriptionInfo.isActive && subscriptionInfo.mpStatus !== MP_SUBS_STATUS.PENDING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { calculateProratedUpgradeAmount } from "../PlanSubscriptions.pricing";
|
||||||
|
import PlanSubscriptionsList from "../PlanSubscriptons";
|
||||||
|
import { MP_SUBS_STATUS } from "../PlanSubscriptions.Adapter.Mongoose";
|
||||||
|
import PlansList from "../../Plans/Plans";
|
||||||
|
|
||||||
|
jest.mock("../../../index", () => ({
|
||||||
|
io: {
|
||||||
|
to: jest.fn(() => ({ emit: jest.fn() })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe("PlanSubscriptions Logic", () => {
|
describe("PlanSubscriptions Logic", () => {
|
||||||
it("should calculate correct endDate for 1, 3, 6, 12 months", () => {
|
it("should calculate correct endDate for 1, 3, 6, 12 months", () => {
|
||||||
@@ -40,4 +50,71 @@ describe("PlanSubscriptions Logic", () => {
|
|||||||
const final12 = total12 - (total12 * (30 / 100)); // 12000 - 3600 = 8400
|
const final12 = total12 - (total12 * (30 / 100)); // 12000 - 3600 = 8400
|
||||||
expect(final12).toBe(8400);
|
expect(final12).toBe(8400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should calculate only the prorated difference for paid plan upgrades", () => {
|
||||||
|
const currentPlanPrice = 1000;
|
||||||
|
const requestedPlanPrice = 2500;
|
||||||
|
const remainingDays = 15;
|
||||||
|
|
||||||
|
const proratedAmount = calculateProratedUpgradeAmount(
|
||||||
|
currentPlanPrice,
|
||||||
|
requestedPlanPrice,
|
||||||
|
remainingDays
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(proratedAmount).toBe(750);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep pending paid subscription plan instead of masking it as free", async () => {
|
||||||
|
const paidPlan = {
|
||||||
|
id: "paid-plan-id",
|
||||||
|
name: "Intermedio",
|
||||||
|
description: "",
|
||||||
|
features: [],
|
||||||
|
code: "intermediate",
|
||||||
|
price: 1000,
|
||||||
|
annualPrice: 0,
|
||||||
|
limitOrganizations: 1,
|
||||||
|
limitEmployees: 1,
|
||||||
|
limitServices: 1,
|
||||||
|
limitAppointments: 1,
|
||||||
|
limitClients: 1,
|
||||||
|
limitRepeats: 1,
|
||||||
|
mailNotifications: false,
|
||||||
|
smsNotifications: false,
|
||||||
|
wapNotifications: false,
|
||||||
|
bot: false,
|
||||||
|
active: true,
|
||||||
|
dateLimit: false,
|
||||||
|
payments: false,
|
||||||
|
};
|
||||||
|
const subscription = {
|
||||||
|
id: "subscription-id",
|
||||||
|
planId: paidPlan.id,
|
||||||
|
startDate: new Date(),
|
||||||
|
endDate: new Date(),
|
||||||
|
isActive: false,
|
||||||
|
autoRenew: false,
|
||||||
|
mpStatus: MP_SUBS_STATUS.PENDING,
|
||||||
|
mpDateCreated: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalPlanSuscriptions = PlanSubscriptionsList.planSuscriptions;
|
||||||
|
const findOneSpy = jest.spyOn(PlansList, "findOne").mockResolvedValue(paidPlan);
|
||||||
|
PlanSubscriptionsList.planSuscriptions = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(subscription),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: "user-id" });
|
||||||
|
|
||||||
|
expect(result?.plan).toEqual(paidPlan);
|
||||||
|
expect(result?.mpStatus).toBe(MP_SUBS_STATUS.PENDING);
|
||||||
|
expect(findOneSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(findOneSpy).toHaveBeenCalledWith({ _id: subscription.planId });
|
||||||
|
} finally {
|
||||||
|
PlanSubscriptionsList.planSuscriptions = originalPlanSuscriptions;
|
||||||
|
findOneSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Document, Model, Schema, model } from "mongoose";
|
||||||
|
import {
|
||||||
|
CreatePlanUsageCycleParams,
|
||||||
|
FindPlanUsageCycleParams,
|
||||||
|
IPlanUsageCycle,
|
||||||
|
IPlanUsageCycleAdapter,
|
||||||
|
UpdatePlanUsageCycleParams,
|
||||||
|
} from "./PlanUsageCycle.Interface";
|
||||||
|
|
||||||
|
export interface IPlanUsageCycleDocument extends Omit<IPlanUsageCycle, "id">, Document {}
|
||||||
|
|
||||||
|
export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
|
||||||
|
schema: Schema;
|
||||||
|
planUsageCycleList: Model<IPlanUsageCycleDocument>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.schema = new Schema({
|
||||||
|
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
|
||||||
|
subscriptionId: { type: Schema.Types.ObjectId, required: false, ref: "PlanSuscription" },
|
||||||
|
planId: { type: Schema.Types.ObjectId, required: true, ref: "Plan" },
|
||||||
|
cycleStart: { type: Date, required: true },
|
||||||
|
cycleEnd: { type: Date, required: true },
|
||||||
|
appointmentsCount: { type: Number, required: true, default: 0 },
|
||||||
|
creationDate: { type: Date, required: true, default: Date.now },
|
||||||
|
updateDate: { type: Date, required: true, default: Date.now },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.schema.index({ userId: 1, subscriptionId: 1, planId: 1, cycleStart: 1 }, { unique: true });
|
||||||
|
|
||||||
|
this.planUsageCycleList = model<IPlanUsageCycleDocument>("PlanUsageCycle", this.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findOne(filters: FindPlanUsageCycleParams): Promise<IPlanUsageCycleDocument | null> {
|
||||||
|
return await this.planUsageCycleList.findOne(filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: CreatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
|
||||||
|
return await this.planUsageCycleList.create({
|
||||||
|
...data,
|
||||||
|
appointmentsCount: data.appointmentsCount || 0,
|
||||||
|
updateDate: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
|
||||||
|
const now = new Date();
|
||||||
|
return await this.planUsageCycleList.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
userId: data.userId,
|
||||||
|
subscriptionId: data.subscriptionId,
|
||||||
|
planId: data.planId,
|
||||||
|
cycleStart: data.cycleStart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$setOnInsert: {
|
||||||
|
userId: data.userId,
|
||||||
|
subscriptionId: data.subscriptionId,
|
||||||
|
planId: data.planId,
|
||||||
|
cycleStart: data.cycleStart,
|
||||||
|
cycleEnd: data.cycleEnd,
|
||||||
|
creationDate: now,
|
||||||
|
},
|
||||||
|
$set: { updateDate: now },
|
||||||
|
},
|
||||||
|
{ new: true, upsert: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async incrementAppointments(
|
||||||
|
data: UpdatePlanUsageCycleParams & { quantity: number }
|
||||||
|
): Promise<IPlanUsageCycleDocument | null> {
|
||||||
|
const now = new Date();
|
||||||
|
return await this.planUsageCycleList.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
userId: data.userId,
|
||||||
|
subscriptionId: data.subscriptionId,
|
||||||
|
planId: data.planId,
|
||||||
|
cycleStart: data.cycleStart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$setOnInsert: {
|
||||||
|
userId: data.userId,
|
||||||
|
subscriptionId: data.subscriptionId,
|
||||||
|
planId: data.planId,
|
||||||
|
cycleStart: data.cycleStart,
|
||||||
|
cycleEnd: data.cycleEnd,
|
||||||
|
creationDate: now,
|
||||||
|
},
|
||||||
|
$inc: { appointmentsCount: data.quantity },
|
||||||
|
$set: { updateDate: now },
|
||||||
|
},
|
||||||
|
{ new: true, upsert: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async incrementAppointmentsIfWithinLimit(
|
||||||
|
data: UpdatePlanUsageCycleParams & { quantity: number; limit: number }
|
||||||
|
): Promise<IPlanUsageCycleDocument | null> {
|
||||||
|
const now = new Date();
|
||||||
|
return await this.planUsageCycleList.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
userId: data.userId,
|
||||||
|
subscriptionId: data.subscriptionId,
|
||||||
|
planId: data.planId,
|
||||||
|
cycleStart: data.cycleStart,
|
||||||
|
appointmentsCount: { $lte: data.limit - data.quantity },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$setOnInsert: {
|
||||||
|
userId: data.userId,
|
||||||
|
subscriptionId: data.subscriptionId,
|
||||||
|
planId: data.planId,
|
||||||
|
cycleStart: data.cycleStart,
|
||||||
|
cycleEnd: data.cycleEnd,
|
||||||
|
appointmentsCount: 0,
|
||||||
|
creationDate: now,
|
||||||
|
},
|
||||||
|
$inc: { appointmentsCount: data.quantity },
|
||||||
|
$set: { updateDate: now },
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setAppointmentsCount(
|
||||||
|
data: UpdatePlanUsageCycleParams & { appointmentsCount: number }
|
||||||
|
): Promise<IPlanUsageCycleDocument | null> {
|
||||||
|
const now = new Date();
|
||||||
|
return await this.planUsageCycleList.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
userId: data.userId,
|
||||||
|
subscriptionId: data.subscriptionId,
|
||||||
|
planId: data.planId,
|
||||||
|
cycleStart: data.cycleStart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$setOnInsert: {
|
||||||
|
userId: data.userId,
|
||||||
|
subscriptionId: data.subscriptionId,
|
||||||
|
planId: data.planId,
|
||||||
|
cycleStart: data.cycleStart,
|
||||||
|
cycleEnd: data.cycleEnd,
|
||||||
|
creationDate: now,
|
||||||
|
},
|
||||||
|
$set: {
|
||||||
|
appointmentsCount: Math.max(data.appointmentsCount, 0),
|
||||||
|
updateDate: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ new: true, upsert: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { IPlanUsageCycleDocument } from "./PlanUsageCycle.Adapter.Mongoose";
|
||||||
|
|
||||||
|
export interface IPlanUsageCycle {
|
||||||
|
id?: string;
|
||||||
|
userId: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
planId: string;
|
||||||
|
cycleStart: Date;
|
||||||
|
cycleEnd: Date;
|
||||||
|
appointmentsCount: number;
|
||||||
|
creationDate: Date;
|
||||||
|
updateDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FindPlanUsageCycleParams = {
|
||||||
|
userId?: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
planId?: string;
|
||||||
|
cycleStart?: Date;
|
||||||
|
cycleEnd?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatePlanUsageCycleParams = {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
planId: string;
|
||||||
|
cycleStart: Date;
|
||||||
|
cycleEnd: Date;
|
||||||
|
appointmentsCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CurrentPlanUsageCycleParams = {
|
||||||
|
userId: string;
|
||||||
|
now?: Date;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetPlanUsageCycleAppointmentsParams = CurrentPlanUsageCycleParams & {
|
||||||
|
appointmentsCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminRecalculatePlanUsageCycleParams = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminRecalculatePlanUsageCycleResult = {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
planId: string;
|
||||||
|
cycleStart: Date;
|
||||||
|
cycleEnd: Date;
|
||||||
|
organizationsCount: number;
|
||||||
|
employeesCount: number;
|
||||||
|
servicesCount: number;
|
||||||
|
clientsCount: number;
|
||||||
|
repeatsCount: number;
|
||||||
|
appointmentsCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdatePlanUsageCycleParams = {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
planId: string;
|
||||||
|
cycleStart: Date;
|
||||||
|
cycleEnd: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalculateCurrentCycleParams = {
|
||||||
|
subscriptionStartDate: Date;
|
||||||
|
now?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlanUsageCycleRange = {
|
||||||
|
cycleStart: Date;
|
||||||
|
cycleEnd: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IPlanUsageCycleAdapter {
|
||||||
|
findOne(filters: FindPlanUsageCycleParams): Promise<IPlanUsageCycleDocument | null>;
|
||||||
|
create(data: CreatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
|
||||||
|
getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
|
||||||
|
incrementAppointments(data: UpdatePlanUsageCycleParams & { quantity: number }): Promise<IPlanUsageCycleDocument | null>;
|
||||||
|
incrementAppointmentsIfWithinLimit(
|
||||||
|
data: UpdatePlanUsageCycleParams & { quantity: number; limit: number }
|
||||||
|
): Promise<IPlanUsageCycleDocument | null>;
|
||||||
|
setAppointmentsCount(
|
||||||
|
data: UpdatePlanUsageCycleParams & { appointmentsCount: number }
|
||||||
|
): Promise<IPlanUsageCycleDocument | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlanUsageCycleManager {
|
||||||
|
planUsageCycle: IPlanUsageCycleAdapter;
|
||||||
|
getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
|
||||||
|
addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void>;
|
||||||
|
reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<boolean>;
|
||||||
|
releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void>;
|
||||||
|
getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise<number>;
|
||||||
|
setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise<IPlanUsageCycleDocument | null>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import { CalculateCurrentCycleParams, PlanUsageCycleRange } from "./PlanUsageCycle.Interface";
|
||||||
|
|
||||||
|
export function calculateCurrentPlanUsageCycle(data: CalculateCurrentCycleParams): PlanUsageCycleRange {
|
||||||
|
const now = dayjs(data.now || new Date());
|
||||||
|
const anchor = dayjs(data.subscriptionStartDate);
|
||||||
|
|
||||||
|
let cycleStart = anchor.clone();
|
||||||
|
let cycleEnd = cycleStart.clone().add(1, "month");
|
||||||
|
|
||||||
|
while (!cycleEnd.isAfter(now)) {
|
||||||
|
cycleStart = cycleEnd;
|
||||||
|
cycleEnd = cycleStart.clone().add(1, "month");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cycleStart: cycleStart.toDate(),
|
||||||
|
cycleEnd: cycleEnd.toDate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { isNull } from "../../helpers/IsNull";
|
||||||
|
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
|
||||||
|
import {
|
||||||
|
CurrentPlanUsageCycleParams,
|
||||||
|
IPlanUsageCycleManager,
|
||||||
|
SetPlanUsageCycleAppointmentsParams,
|
||||||
|
UpdatePlanUsageCycleParams,
|
||||||
|
} from "./PlanUsageCycle.Interface";
|
||||||
|
import {
|
||||||
|
IPlanUsageCycleDocument,
|
||||||
|
PlanUsageCycleAdapterMongoose,
|
||||||
|
} from "./PlanUsageCycle.Adapter.Mongoose";
|
||||||
|
import { calculateCurrentPlanUsageCycle } from "./PlanUsageCycle.helpers";
|
||||||
|
|
||||||
|
export { calculateCurrentPlanUsageCycle } from "./PlanUsageCycle.helpers";
|
||||||
|
|
||||||
|
class PlanUsageCycleManager implements IPlanUsageCycleManager {
|
||||||
|
planUsageCycle: PlanUsageCycleAdapterMongoose;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.planUsageCycle = new PlanUsageCycleAdapterMongoose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCurrentCycleData(data: CurrentPlanUsageCycleParams): Promise<UpdatePlanUsageCycleParams> {
|
||||||
|
const subscription = await PlanSubscriptionsList.findOne({
|
||||||
|
sessionUser: data.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error("No se ha encontrado una suscripción activa");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cycleStart, cycleEnd } = calculateCurrentPlanUsageCycle({
|
||||||
|
subscriptionStartDate: subscription.startDate,
|
||||||
|
now: data.now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: data.userId,
|
||||||
|
subscriptionId: String(subscription.id),
|
||||||
|
planId: String(subscription.planId),
|
||||||
|
cycleStart,
|
||||||
|
cycleEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
|
||||||
|
return await this.planUsageCycle.getOrCreateCurrent(await this.getCurrentCycleData(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void> {
|
||||||
|
await this.planUsageCycle.incrementAppointments({
|
||||||
|
...(await this.getCurrentCycleData(data)),
|
||||||
|
quantity: isNull<number>(data.quantity, 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<boolean> {
|
||||||
|
if (typeof data.limit !== "number") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = isNull<number>(data.quantity, 1);
|
||||||
|
const usageCycleData = await this.getCurrentCycleData(data);
|
||||||
|
const usageCycle = await this.planUsageCycle.getOrCreateCurrent(usageCycleData);
|
||||||
|
|
||||||
|
if (usageCycle.appointmentsCount + quantity > data.limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedCycle = await this.planUsageCycle.incrementAppointmentsIfWithinLimit({
|
||||||
|
...usageCycleData,
|
||||||
|
quantity,
|
||||||
|
limit: data.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!updatedCycle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void> {
|
||||||
|
await this.addAppointment({
|
||||||
|
...data,
|
||||||
|
quantity: -Math.abs(isNull<number>(data.quantity, 1)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise<number> {
|
||||||
|
const usageCycle = await this.getCurrentCycle(data);
|
||||||
|
return usageCycle.appointmentsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise<IPlanUsageCycleDocument | null> {
|
||||||
|
return await this.planUsageCycle.setAppointmentsCount({
|
||||||
|
...(await this.getCurrentCycleData(data)),
|
||||||
|
appointmentsCount: data.appointmentsCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlanUsageCycleList = new PlanUsageCycleManager();
|
||||||
|
|
||||||
|
export default PlanUsageCycleList;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FindPlansParams, IPlansAdapter, IPlan } from "./Plans.interface";
|
import { FindPlansParams, IPlansAdapter, IPlan, SysAdminUpdatePlanParams } from "./Plans.interface";
|
||||||
import { Document, Model, Schema, model } from "mongoose";
|
import { Document, Model, Schema, model } from "mongoose";
|
||||||
|
|
||||||
export interface IPlanDocument extends Omit<IPlan, "id">, Document {}
|
export interface IPlanDocument extends Omit<IPlan, "id">, Document {}
|
||||||
@@ -45,4 +45,8 @@ export class PlansAdapterMongoose implements IPlansAdapter {
|
|||||||
public async findOne(filters: FindPlansParams): Promise<IPlanDocument | null> {
|
public async findOne(filters: FindPlansParams): Promise<IPlanDocument | null> {
|
||||||
return this.planList.findOne(filters).exec();
|
return this.planList.findOne(filters).exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void> {
|
||||||
|
await this.planList.updateOne({ _id: planId }, { $set: data }).exec();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,33 @@ export type FindPlansParams = {
|
|||||||
price?: number;
|
price?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SysAdminUpdatePlanParams = {
|
||||||
|
planId: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
features?: string[];
|
||||||
|
code?: string;
|
||||||
|
price?: number;
|
||||||
|
annualPrice?: number;
|
||||||
|
limitOrganizations?: number;
|
||||||
|
limitEmployees?: number;
|
||||||
|
limitServices?: number;
|
||||||
|
limitAppointments?: number;
|
||||||
|
limitClients?: number;
|
||||||
|
limitRepeats?: number;
|
||||||
|
mailNotifications?: boolean;
|
||||||
|
smsNotifications?: boolean;
|
||||||
|
wapNotifications?: boolean;
|
||||||
|
payments?: boolean;
|
||||||
|
bot?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
dateLimit?: boolean;
|
||||||
|
discount3Months?: number;
|
||||||
|
discount6Months?: number;
|
||||||
|
discount12Months?: number;
|
||||||
|
featured?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export interface IPlan {
|
export interface IPlan {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -44,10 +71,12 @@ export interface IPlan {
|
|||||||
export interface IPlansAdapter {
|
export interface IPlansAdapter {
|
||||||
find(filters: FindPlansParams): Promise<IPlan[]>;
|
find(filters: FindPlansParams): Promise<IPlan[]>;
|
||||||
findOne(filters: FindPlansParams): Promise<IPlan | null>;
|
findOne(filters: FindPlansParams): Promise<IPlan | null>;
|
||||||
|
updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPlansManager {
|
export interface IPlansManager {
|
||||||
plans: IPlansAdapter;
|
plans: IPlansAdapter;
|
||||||
find(data: FindPlansParams): Promise<IPlan[]>;
|
find(data: FindPlansParams): Promise<IPlan[]>;
|
||||||
findOne(data: FindPlansParams): Promise<IPlan>;
|
findOne(data: FindPlansParams): Promise<IPlan>;
|
||||||
|
sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isNull } from "../../helpers/IsNull";
|
import { isNull } from "../../helpers/IsNull";
|
||||||
import { PlansAdapterMongoose } from "./Plans.Adapter.Mongoose";
|
import { PlansAdapterMongoose } from "./Plans.Adapter.Mongoose";
|
||||||
import { FindPlansParams, IPlan, IPlansManager } from "./Plans.interface";
|
import { FindPlansParams, IPlan, IPlansManager, SysAdminUpdatePlanParams } from "./Plans.interface";
|
||||||
|
|
||||||
class PlansManager implements IPlansManager {
|
class PlansManager implements IPlansManager {
|
||||||
plans: PlansAdapterMongoose;
|
plans: PlansAdapterMongoose;
|
||||||
@@ -59,6 +59,12 @@ class PlansManager implements IPlansManager {
|
|||||||
|
|
||||||
return this.getOnePlan(plan);
|
return this.getOnePlan(plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise<void> {
|
||||||
|
const { planId, ...planData } = data;
|
||||||
|
await this.findOne({ _id: planId });
|
||||||
|
await this.plans.updateOne(planId, planData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlansList = new PlansManager();
|
const PlansList = new PlansManager();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export type ScheduleItem = {
|
|||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
serviceScope?: "all" | "specific";
|
||||||
|
serviceIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FindSchedulesParams = {
|
export type FindSchedulesParams = {
|
||||||
@@ -19,6 +21,7 @@ export type AvailableSchedulesParams = {
|
|||||||
weekDay: number;
|
weekDay: number;
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
|
serviceId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginateSchedulesParams = FindSchedulesParams & {
|
export type PaginateSchedulesParams = FindSchedulesParams & {
|
||||||
|
|||||||
@@ -173,6 +173,14 @@ class SchedulesManager implements ISchedulesManager {
|
|||||||
return await this.schedules.find(filters);
|
return await this.schedules.find(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
|
||||||
|
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
private async getScheduleByFrom(data: AvailableSchedulesParams): Promise<ScheduleItem | null> {
|
private async getScheduleByFrom(data: AvailableSchedulesParams): Promise<ScheduleItem | null> {
|
||||||
const schedule = await this.schedules.findOne({
|
const schedule = await this.schedules.findOne({
|
||||||
employeeId: data.employeeId,
|
employeeId: data.employeeId,
|
||||||
@@ -185,6 +193,9 @@ class SchedulesManager implements ISchedulesManager {
|
|||||||
if (horario.disabled) {
|
if (horario.disabled) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!this.isScheduleAvailableForService(horario, data.serviceId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (horario.from == data.from) {
|
if (horario.from == data.from) {
|
||||||
return horario;
|
return horario;
|
||||||
}
|
}
|
||||||
@@ -236,6 +247,7 @@ class SchedulesManager implements ISchedulesManager {
|
|||||||
weekDay: data.weekDay,
|
weekDay: data.weekDay,
|
||||||
from: scheduleFrom.to,
|
from: scheduleFrom.to,
|
||||||
to: data.to,
|
to: data.to,
|
||||||
|
serviceId: data.serviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!scheduleTo) {
|
if (!scheduleTo) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type AvailableSchedulesOverridesParams = {
|
|||||||
dateDay: Date;
|
dateDay: Date;
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
|
serviceId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginateSchedulesOverridesParams = FindSchedulesOverridesParams & {
|
export type PaginateSchedulesOverridesParams = FindSchedulesOverridesParams & {
|
||||||
|
|||||||
@@ -154,6 +154,14 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
|
|||||||
return await this.schedulesOverrides.findOne(filters);
|
return await this.schedulesOverrides.findOne(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
|
||||||
|
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
private async getScheduleByFrom(data: AvailableSchedulesOverridesParams): Promise<ScheduleItem | null> {
|
private async getScheduleByFrom(data: AvailableSchedulesOverridesParams): Promise<ScheduleItem | null> {
|
||||||
const exactDate = dayjs(data.dateDay).startOf("day").toDate();
|
const exactDate = dayjs(data.dateDay).startOf("day").toDate();
|
||||||
const schedule = await this.schedulesOverrides.findOne({
|
const schedule = await this.schedulesOverrides.findOne({
|
||||||
@@ -165,6 +173,7 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
|
|||||||
if (schedule) {
|
if (schedule) {
|
||||||
for (const horario of schedule.schedules) {
|
for (const horario of schedule.schedules) {
|
||||||
if (horario.disabled) continue;
|
if (horario.disabled) continue;
|
||||||
|
if (!this.isScheduleAvailableForService(horario, data.serviceId)) continue;
|
||||||
if (horario.from == data.from) return horario;
|
if (horario.from == data.from) return horario;
|
||||||
if (horario.from < data.from && horario.to > data.from) return horario;
|
if (horario.from < data.from && horario.to > data.from) return horario;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,73 @@ export type SysAdminOrganizationsStatusParams = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SysAdminUserSubscriptionDetailsParams = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminExtendUserSubscriptionMode = "add_months" | "set_end_date";
|
||||||
|
|
||||||
|
export type SysAdminExtendUserSubscriptionParams = {
|
||||||
|
userId: string;
|
||||||
|
mode: SysAdminExtendUserSubscriptionMode;
|
||||||
|
months?: 1 | 2;
|
||||||
|
endDate?: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminExtendUserSubscriptionResult = {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
previousEndDate: Date;
|
||||||
|
newEndDate: Date;
|
||||||
|
paymentId: string;
|
||||||
|
transactionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminFinalizeUserSubscriptionParams = {
|
||||||
|
userId: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminFinalizeUserSubscriptionResult = {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
previousEndDate: Date;
|
||||||
|
newEndDate: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminUserSubscriptionPayment = {
|
||||||
|
id: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
amount: number;
|
||||||
|
paymentDate: Date;
|
||||||
|
paymentMethod?: string;
|
||||||
|
status: string;
|
||||||
|
transactionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminUserSubscriptionDetailsResult = {
|
||||||
|
userId: string;
|
||||||
|
currentSubscription: {
|
||||||
|
id: string;
|
||||||
|
plan: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
price: number;
|
||||||
|
} | null;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
isActive: boolean;
|
||||||
|
mpStatus?: string;
|
||||||
|
billingMonths?: number;
|
||||||
|
pendingPaymentType?: "extension" | "upgrade";
|
||||||
|
pendingPaymentPreferenceId?: string;
|
||||||
|
lastPaymentStatus?: string;
|
||||||
|
} | null;
|
||||||
|
payments: SysAdminUserSubscriptionPayment[];
|
||||||
|
};
|
||||||
|
|
||||||
export type SysAdminUserOrganizationsResult = {
|
export type SysAdminUserOrganizationsResult = {
|
||||||
clientIn: { companyId: string; companyName: string }[];
|
clientIn: { companyId: string; companyName: string }[];
|
||||||
collaboratorIn: { companyId: string; companyName: string }[];
|
collaboratorIn: { companyId: string; companyName: string }[];
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ class UsersManager implements IUsersManager {
|
|||||||
|
|
||||||
if (sub) {
|
if (sub) {
|
||||||
if (sub.plan.price > 0) {
|
if (sub.plan.price > 0) {
|
||||||
throw new Error("Para darte de baja primero tenes que cancelar tu suscripción.");
|
throw new Error("No podés darte de baja mientras tengas un plan pago vigente. Podés eliminar tus organizaciones para borrar su contenido y seguir usando la cuenta hasta el vencimiento.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ class UsersManager implements IUsersManager {
|
|||||||
userId: data.sessionUser,
|
userId: data.sessionUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
//Eliminar los clientes creados con el usuario.
|
//Desvincular los clientes creados con el usuario sin borrar el historial de la organizacion.
|
||||||
const clients = await ClientsList.clients.find({
|
const clients = await ClientsList.clients.find({
|
||||||
userId: data.sessionUser,
|
userId: data.sessionUser,
|
||||||
});
|
});
|
||||||
@@ -294,21 +294,7 @@ class UsersManager implements IUsersManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Tengo que pasarlo como sesionUSer al duenio de la organizacion. para que pueda eliminar.
|
await ClientsList.clients.detachUser({ clientId: String(client.id) });
|
||||||
const orgClient = await CompaniesList.companies.findOne({
|
|
||||||
_id: String(client.companyId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!orgClient) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
//En este caso borra el cliente pero no chequea el saldo de la cuenta.
|
|
||||||
await ClientsList.deleteClient({
|
|
||||||
clientId: String(client.id),
|
|
||||||
checkCashOnAccount: false,
|
|
||||||
sessionUser: String(orgClient.ownerId),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//Eliminar las conversaciones
|
//Eliminar las conversaciones
|
||||||
@@ -630,7 +616,7 @@ class UsersManager implements IUsersManager {
|
|||||||
|
|
||||||
// Generate a JWT token using the payload and a secret word
|
// Generate a JWT token using the payload and a secret word
|
||||||
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
||||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
|
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a user object by merging the payload and the token
|
// Create a user object by merging the payload and the token
|
||||||
@@ -684,7 +670,7 @@ class UsersManager implements IUsersManager {
|
|||||||
// If the token expires in less than 5 minutes, generate a new one.
|
// If the token expires in less than 5 minutes, generate a new one.
|
||||||
if (decodedPayload.exp < nowInSeconds + fiveMinutesInSeconds) {
|
if (decodedPayload.exp < nowInSeconds + fiveMinutesInSeconds) {
|
||||||
finalToken = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
finalToken = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
||||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
|
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,7 +751,7 @@ class UsersManager implements IUsersManager {
|
|||||||
|
|
||||||
// Generate a JWT token using the payload and a secret word
|
// Generate a JWT token using the payload and a secret word
|
||||||
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
||||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
|
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a user object by merging the payload and the token
|
// Create a user object by merging the payload and the token
|
||||||
@@ -810,7 +796,7 @@ class UsersManager implements IUsersManager {
|
|||||||
|
|
||||||
// Generate a JWT token using the payload and a secret word
|
// Generate a JWT token using the payload and a secret word
|
||||||
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
||||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
|
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a user object by merging the payload and the token
|
// Create a user object by merging the payload and the token
|
||||||
@@ -862,7 +848,7 @@ class UsersManager implements IUsersManager {
|
|||||||
|
|
||||||
// Generate a JWT token using the payload and a secret word
|
// Generate a JWT token using the payload and a secret word
|
||||||
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
||||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
|
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a user object by merging the payload and the token
|
// Create a user object by merging the payload and the token
|
||||||
@@ -907,7 +893,7 @@ class UsersManager implements IUsersManager {
|
|||||||
|
|
||||||
// Generate a JWT token using the payload and a secret word
|
// Generate a JWT token using the payload and a secret word
|
||||||
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
const token = jwt.sign(payload, `${process.env.SECRET_WORD}`, {
|
||||||
expiresIn: `${process.env.TOKEN_SESSION_TIME}`,
|
expiresIn: `${process.env.TOKEN_SESSION_TIME}` as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a user object by merging the payload and the token
|
// Create a user object by merging the payload and the token
|
||||||
@@ -1279,14 +1265,7 @@ class UsersManager implements IUsersManager {
|
|||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (!client.id) continue;
|
if (!client.id) continue;
|
||||||
|
|
||||||
const orgClient = await CompaniesList.companies.findOne({ _id: String(client.companyId) });
|
await ClientsList.clients.detachUser({ clientId: String(client.id) });
|
||||||
if (!orgClient) continue;
|
|
||||||
|
|
||||||
await ClientsList.deleteClient({
|
|
||||||
clientId: String(client.id),
|
|
||||||
checkCashOnAccount: false,
|
|
||||||
sessionUser: String(orgClient.ownerId),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await MessageConversationList.deleteConversationsByUser({ userId: data.userId });
|
await MessageConversationList.deleteConversationsByUser({ userId: data.userId });
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
PaginatedAppointmentEventByClientResult,
|
PaginatedAppointmentEventByClientResult,
|
||||||
} from "../../Models/Appointments/Appointments.Interface";
|
} from "../../Models/Appointments/Appointments.Interface";
|
||||||
import { connect } from "mongoose";
|
import { connect } from "mongoose";
|
||||||
|
import { validatePermissionsByCompany } from "../../helpers/check";
|
||||||
|
|
||||||
export class AppointmentService {
|
export class AppointmentService {
|
||||||
public async createAppointment(data: CreateAppointmentParams): Promise<IAppointment> {
|
public async createAppointment(data: CreateAppointmentParams): Promise<IAppointment> {
|
||||||
@@ -63,6 +64,15 @@ export class AppointmentService {
|
|||||||
): Promise<AppointmentAdminByDateView> {
|
): Promise<AppointmentAdminByDateView> {
|
||||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
|
|
||||||
|
if (!data.companyId || !data.sessionUser) {
|
||||||
|
throw new Error("No se ha encontrado la organización o el usuario de sesión");
|
||||||
|
}
|
||||||
|
|
||||||
|
await validatePermissionsByCompany({
|
||||||
|
companyId: data.companyId,
|
||||||
|
sessionUser: data.sessionUser,
|
||||||
|
});
|
||||||
|
|
||||||
const appointments = await AppointmentList.findAppointmentsAdminByDate(data);
|
const appointments = await AppointmentList.findAppointmentsAdminByDate(data);
|
||||||
|
|
||||||
return appointments;
|
return appointments;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import CashFlowList from "../../Models/CashFlow/CashFlow";
|
|||||||
import CompaniesList from "../../Models/Companies/Companies";
|
import CompaniesList from "../../Models/Companies/Companies";
|
||||||
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
|
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
|
||||||
import MetricsList from "../../Models/Metrics/Metrics";
|
import MetricsList from "../../Models/Metrics/Metrics";
|
||||||
|
import PlanUsageCycleList from "../../Models/PlanUsageCycle/PlanUsageCycle";
|
||||||
|
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
public async getDashboardSummary(sessionUser: string): Promise<DashboardSummaryResult> {
|
public async getDashboardSummary(sessionUser: string): Promise<DashboardSummaryResult> {
|
||||||
@@ -198,11 +199,18 @@ export class DashboardService {
|
|||||||
const isOwner = String(ownerId) === String(params.sessionUser);
|
const isOwner = String(ownerId) === String(params.sessionUser);
|
||||||
|
|
||||||
// Fetch metrics for owner
|
// Fetch metrics for owner
|
||||||
const metrics = await MetricsList.getMetrics(String(ownerId));
|
const metricsDocument = await MetricsList.getMetrics(String(ownerId));
|
||||||
|
const metrics = metricsDocument.toObject ? metricsDocument.toObject() : { ...metricsDocument };
|
||||||
|
|
||||||
// Fetch subscription for owner
|
// Fetch subscription for owner
|
||||||
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: String(ownerId) });
|
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: String(ownerId) });
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
metrics.appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({
|
||||||
|
userId: String(ownerId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metrics,
|
metrics,
|
||||||
subscription,
|
subscription,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
UpdateEmployeeRolesParams,
|
UpdateEmployeeRolesParams,
|
||||||
UpdateEmployeeParams,
|
UpdateEmployeeParams,
|
||||||
IncompleteCollaboratorView,
|
IncompleteCollaboratorView,
|
||||||
|
RemoveCollaboratorParams,
|
||||||
} from "../../Models/Employees/Employees.Interface";
|
} from "../../Models/Employees/Employees.Interface";
|
||||||
import { authenticateMiddleware } from "../../middleware/authentication";
|
import { authenticateMiddleware } from "../../middleware/authentication";
|
||||||
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
|
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
|
||||||
@@ -448,3 +449,27 @@ export class GetIncompleteSetupController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Route("employees/remove-collaborator")
|
||||||
|
@Middlewares(authenticateMiddleware)
|
||||||
|
export class RemoveCollaboratorController extends Controller {
|
||||||
|
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||||
|
@SuccessResponse(200, "El colaborador fue removido con éxito")
|
||||||
|
@Post()
|
||||||
|
public async removeCollaborator(
|
||||||
|
@Body() requestBody: RemoveCollaboratorParams
|
||||||
|
): Promise<ApiVoidResult | ApiValidationError> {
|
||||||
|
try {
|
||||||
|
await new EmployeesService().removeCollaborator(requestBody);
|
||||||
|
this.setStatus(200);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Done",
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
const errorOccurred: Error = e as Error;
|
||||||
|
this.setStatus(500);
|
||||||
|
return new ApiValidationError(500, errorOccurred.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
UpdateEmployeeRolesParams,
|
UpdateEmployeeRolesParams,
|
||||||
UpdateEmployeeParams,
|
UpdateEmployeeParams,
|
||||||
IncompleteCollaboratorView,
|
IncompleteCollaboratorView,
|
||||||
|
RemoveCollaboratorParams,
|
||||||
} from "../../Models/Employees/Employees.Interface";
|
} from "../../Models/Employees/Employees.Interface";
|
||||||
import {
|
import {
|
||||||
CreateEmployeeServiceParams,
|
CreateEmployeeServiceParams,
|
||||||
@@ -162,4 +163,9 @@ export class EmployeesService {
|
|||||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
await EmployeesList.update(data);
|
await EmployeesList.update(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async removeCollaborator(data: RemoveCollaboratorParams): Promise<void> {
|
||||||
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
|
await EmployeesList.removeCollaboratorFromCompany(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,43 @@ import axios from "axios";
|
|||||||
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
|
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
|
||||||
import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
|
import { MP_SUBS_STATUS } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
|
||||||
import { connect } from "mongoose";
|
import { connect } from "mongoose";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments";
|
||||||
|
import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
|
||||||
|
import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
|
||||||
|
|
||||||
|
type MercadoPagoPaymentData = {
|
||||||
|
id: string | number;
|
||||||
|
status: string;
|
||||||
|
external_reference?: string;
|
||||||
|
preference_id?: string;
|
||||||
|
order?: { id?: string };
|
||||||
|
date_approved?: string;
|
||||||
|
payer?: { id?: number; email?: string };
|
||||||
|
transaction_amount?: number;
|
||||||
|
payment_method_id?: string;
|
||||||
|
payment_type_id?: string;
|
||||||
|
date_last_updated?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MercadoPagoMerchantOrder = {
|
||||||
|
id: string | number;
|
||||||
|
preference_id?: string;
|
||||||
|
external_reference?: string;
|
||||||
|
payments?: MercadoPagoPaymentData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MercadoPagoPaymentContext = {
|
||||||
|
preferenceId?: string;
|
||||||
|
externalReference?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MercadoPagoPaymentVerification = {
|
||||||
|
status: "approved" | "rejected" | "failed" | "cancelled" | "pending";
|
||||||
|
payment: MercadoPagoPaymentData | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"];
|
||||||
|
|
||||||
export class MercadoPagoWebhookService {
|
export class MercadoPagoWebhookService {
|
||||||
public async handleWebhook(body: any): Promise<void> {
|
public async handleWebhook(body: any): Promise<void> {
|
||||||
@@ -24,31 +61,20 @@ export class MercadoPagoWebhookService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const paymentData = paymentResponse.data;
|
const paymentData: MercadoPagoPaymentData = paymentResponse.data;
|
||||||
const status = paymentData.status; // approved, pending, rejected, etc.
|
const status = paymentData.status; // approved, pending, rejected, etc.
|
||||||
const userId = paymentData.external_reference; // We passed this when creating preference
|
const paymentContext = await this.resolvePaymentContext(paymentData);
|
||||||
|
const userId = paymentContext.externalReference || paymentData.external_reference; // We passed this when creating preference
|
||||||
if (userId && status === "approved") {
|
const preferenceId = paymentContext.preferenceId || paymentData.preference_id;
|
||||||
// Update subscription status in DB
|
const subscription = await this.findSubscriptionForPayment(userId, preferenceId);
|
||||||
const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
|
|
||||||
sessionUser: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
if (userId && preferenceId && status === "approved") {
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED;
|
await this.applyApprovedPayment(subscription, paymentData, userId, preferenceId);
|
||||||
subscription.isActive = true;
|
|
||||||
// Assuming endDate is correctly set for the months purchased when preference was created
|
|
||||||
await subscription.save();
|
|
||||||
}
|
}
|
||||||
} else if (userId && status === "rejected") {
|
} else if (userId && preferenceId && REJECTED_PAYMENT_STATUSES.includes(status)) {
|
||||||
const subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
|
if (subscription) {
|
||||||
sessionUser: userId,
|
await this.applyRejectedPayment(subscription, paymentData, userId, preferenceId);
|
||||||
});
|
|
||||||
|
|
||||||
if (subscription && subscription.mpStatus !== MP_SUBS_STATUS.AUTHORIZED) {
|
|
||||||
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
|
||||||
subscription.isActive = false;
|
|
||||||
await subscription.save();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -56,4 +82,320 @@ export class MercadoPagoWebhookService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async verifyPendingPlanPayment(sessionUser: string): Promise<VerifyPendingPlanPaymentResponse> {
|
||||||
|
const pendingSubscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
|
||||||
|
userId: sessionUser,
|
||||||
|
mpPreferenceId: { $exists: true, $ne: "" },
|
||||||
|
mpStatus: MP_SUBS_STATUS.PENDING,
|
||||||
|
}).exec();
|
||||||
|
|
||||||
|
const pendingExistingSubscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
|
||||||
|
userId: sessionUser,
|
||||||
|
pendingPaymentPreferenceId: { $exists: true, $ne: "" },
|
||||||
|
mpStatus: MP_SUBS_STATUS.AUTHORIZED,
|
||||||
|
}).exec();
|
||||||
|
|
||||||
|
const subscription = pendingSubscription || pendingExistingSubscription;
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return {
|
||||||
|
status: "not_found",
|
||||||
|
approved: false,
|
||||||
|
message: "No se encontró un pago pendiente para verificar.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferenceId = pendingSubscription
|
||||||
|
? subscription.mpPreferenceId
|
||||||
|
: subscription.pendingPaymentPreferenceId;
|
||||||
|
|
||||||
|
if (!preferenceId) {
|
||||||
|
return {
|
||||||
|
status: "not_found",
|
||||||
|
approved: false,
|
||||||
|
message: "No se encontró una preferencia de pago pendiente para verificar.",
|
||||||
|
subscriptionId: String(subscription._id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentVerification = await this.findPaymentForPreference(sessionUser, preferenceId);
|
||||||
|
|
||||||
|
if (!paymentVerification.payment || paymentVerification.status === "pending") {
|
||||||
|
return {
|
||||||
|
status: "pending",
|
||||||
|
approved: false,
|
||||||
|
message: "El pago todavía no figura confirmado.",
|
||||||
|
subscriptionId: String(subscription._id),
|
||||||
|
paymentType: this.getPaymentType(subscription, preferenceId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentType = this.getPaymentType(subscription, preferenceId);
|
||||||
|
const payment = paymentVerification.payment;
|
||||||
|
|
||||||
|
if (paymentVerification.status !== "approved") {
|
||||||
|
await this.applyRejectedPayment(subscription, payment, sessionUser, preferenceId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: paymentVerification.status,
|
||||||
|
approved: false,
|
||||||
|
message: "El pago fue rechazado. Tu plan actual no cambió y podés intentarlo nuevamente.",
|
||||||
|
subscriptionId: String(subscription._id),
|
||||||
|
paymentId: String(payment.id),
|
||||||
|
paymentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.applyApprovedPayment(subscription, payment, sessionUser, preferenceId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "approved",
|
||||||
|
approved: true,
|
||||||
|
message: "El pago fue aprobado y la suscripción fue actualizada.",
|
||||||
|
subscriptionId: String(subscription._id),
|
||||||
|
paymentId: String(payment.id),
|
||||||
|
paymentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findPaymentForPreference(sessionUser: string, preferenceId: string): Promise<MercadoPagoPaymentVerification> {
|
||||||
|
const merchantOrders = await this.searchMerchantOrdersByPreferenceId(preferenceId);
|
||||||
|
const merchantOrder = merchantOrders.find((order) => {
|
||||||
|
return !order.external_reference || order.external_reference === sessionUser;
|
||||||
|
});
|
||||||
|
const merchantOrderPayment = this.pickMostRelevantPayment(merchantOrder?.payments || []);
|
||||||
|
|
||||||
|
if (merchantOrderPayment) {
|
||||||
|
const payment = await this.getPaymentById(merchantOrderPayment.id);
|
||||||
|
return {
|
||||||
|
status: this.normalizePaymentStatus(payment.status),
|
||||||
|
payment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get("https://api.mercadopago.com/v1/payments/search", {
|
||||||
|
headers: this.getMercadoPagoHeaders(),
|
||||||
|
params: {
|
||||||
|
external_reference: sessionUser,
|
||||||
|
sort: "date_created",
|
||||||
|
criteria: "desc",
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payments: MercadoPagoPaymentData[] = response.data?.results || [];
|
||||||
|
const payment = this.pickMostRelevantPayment(payments.filter((payment) => payment.preference_id === preferenceId));
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
return { status: "pending", payment: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPayment = await this.getPaymentById(payment.id);
|
||||||
|
return {
|
||||||
|
status: this.normalizePaymentStatus(fullPayment.status),
|
||||||
|
payment: fullPayment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickMostRelevantPayment(payments: MercadoPagoPaymentData[]): MercadoPagoPaymentData | null {
|
||||||
|
return payments.find((payment) => payment.status === "approved")
|
||||||
|
|| payments.find((payment) => REJECTED_PAYMENT_STATUSES.includes(payment.status))
|
||||||
|
|| payments[0]
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePaymentStatus(status: string): MercadoPagoPaymentVerification["status"] {
|
||||||
|
if (status === "approved") return "approved";
|
||||||
|
if (status === "cancelled") return "cancelled";
|
||||||
|
if (status === "failed") return "failed";
|
||||||
|
if (status === "rejected") return "rejected";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolvePaymentContext(paymentData: MercadoPagoPaymentData): Promise<MercadoPagoPaymentContext> {
|
||||||
|
if (paymentData.preference_id) {
|
||||||
|
return {
|
||||||
|
preferenceId: paymentData.preference_id,
|
||||||
|
externalReference: paymentData.external_reference,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paymentData.order?.id) {
|
||||||
|
return {
|
||||||
|
externalReference: paymentData.external_reference,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const merchantOrder = await this.getMerchantOrderById(paymentData.order.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
preferenceId: merchantOrder?.preference_id,
|
||||||
|
externalReference: merchantOrder?.external_reference || paymentData.external_reference,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async searchMerchantOrdersByPreferenceId(preferenceId: string): Promise<MercadoPagoMerchantOrder[]> {
|
||||||
|
const response = await axios.get("https://api.mercadopago.com/merchant_orders/search", {
|
||||||
|
headers: this.getMercadoPagoHeaders(),
|
||||||
|
params: { preference_id: preferenceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data?.elements || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMerchantOrderById(merchantOrderId: string): Promise<MercadoPagoMerchantOrder | null> {
|
||||||
|
const response = await axios.get(`https://api.mercadopago.com/merchant_orders/${merchantOrderId}`, {
|
||||||
|
headers: this.getMercadoPagoHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPaymentById(paymentId: string | number): Promise<MercadoPagoPaymentData> {
|
||||||
|
const response = await axios.get(`https://api.mercadopago.com/v1/payments/${paymentId}`, {
|
||||||
|
headers: this.getMercadoPagoHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMercadoPagoHeaders(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findSubscriptionForPayment(userId?: string, preferenceId?: string): Promise<IPlanSuscriptionDocument | null> {
|
||||||
|
if (!userId || !preferenceId) return null;
|
||||||
|
|
||||||
|
let subscription = await PlanSubscriptionsList.planSuscriptions.findOne({
|
||||||
|
sessionUser: userId,
|
||||||
|
mpPreferenceId: preferenceId,
|
||||||
|
mpStatus: MP_SUBS_STATUS.PENDING,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
subscription = await PlanSubscriptionsList.planSuscriptions.planSuscriptionList.findOne({
|
||||||
|
userId,
|
||||||
|
pendingPaymentPreferenceId: preferenceId,
|
||||||
|
mpStatus: MP_SUBS_STATUS.AUTHORIZED,
|
||||||
|
}).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyApprovedPayment(
|
||||||
|
subscription: IPlanSuscriptionDocument,
|
||||||
|
paymentData: MercadoPagoPaymentData,
|
||||||
|
userId: string,
|
||||||
|
preferenceId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const approvedAt = paymentData.date_approved ? new Date(paymentData.date_approved) : new Date();
|
||||||
|
const billingMonths = subscription.pendingPaymentBillingMonths || subscription.billingMonths || 1;
|
||||||
|
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
|
||||||
|
const isUpgradePayment = isExtensionPayment && subscription.pendingPaymentType === "upgrade";
|
||||||
|
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
|
||||||
|
? subscription.endDate
|
||||||
|
: approvedAt;
|
||||||
|
|
||||||
|
if (isUpgradePayment && subscription.pendingPaymentPlanId) {
|
||||||
|
subscription.planId = subscription.pendingPaymentPlanId;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.mpStatus = MP_SUBS_STATUS.AUTHORIZED;
|
||||||
|
subscription.isActive = true;
|
||||||
|
subscription.autoRenew = false;
|
||||||
|
subscription.startDate = isExtensionPayment ? subscription.startDate : approvedAt;
|
||||||
|
subscription.endDate = isUpgradePayment
|
||||||
|
? subscription.endDate
|
||||||
|
: dayjs(periodStart).add(billingMonths, "months").toDate();
|
||||||
|
subscription.mpPayerId = paymentData.payer?.id || subscription.mpPayerId;
|
||||||
|
subscription.mpPayerEmail = paymentData.payer?.email || subscription.mpPayerEmail;
|
||||||
|
subscription.pendingPaymentInitPoint = "";
|
||||||
|
subscription.pendingPaymentPreferenceId = "";
|
||||||
|
subscription.pendingPaymentBillingMonths = undefined;
|
||||||
|
subscription.pendingPaymentType = undefined;
|
||||||
|
subscription.pendingPaymentPlanId = undefined;
|
||||||
|
subscription.pendingPaymentCurrentPlanPrice = undefined;
|
||||||
|
subscription.pendingPaymentRequestedPlanPrice = undefined;
|
||||||
|
subscription.pendingPaymentProratedAmount = undefined;
|
||||||
|
subscription.pendingPaymentRemainingDays = undefined;
|
||||||
|
subscription.pendingPaymentPeriodEndDate = undefined;
|
||||||
|
subscription.lastPaymentStatus = undefined;
|
||||||
|
subscription.lastPaymentPreferenceId = undefined;
|
||||||
|
subscription.lastPaymentAt = undefined;
|
||||||
|
subscription.lastPaymentType = undefined;
|
||||||
|
subscription.downgradedFromPlanId = undefined;
|
||||||
|
subscription.downgradedFromPlanName = undefined;
|
||||||
|
subscription.downgradedFromPlanCode = undefined;
|
||||||
|
subscription.downgradedAt = undefined;
|
||||||
|
subscription.downgradeReason = undefined;
|
||||||
|
await subscription.save();
|
||||||
|
|
||||||
|
await PlanPaymentsList.createIfMissingByTransactionId({
|
||||||
|
userId,
|
||||||
|
subscriptionId: String(subscription._id),
|
||||||
|
amount: paymentData.transaction_amount || 0,
|
||||||
|
paymentDate: approvedAt,
|
||||||
|
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
|
||||||
|
status: "completed",
|
||||||
|
transactionId: String(paymentData.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyRejectedPayment(
|
||||||
|
subscription: IPlanSuscriptionDocument,
|
||||||
|
paymentData: MercadoPagoPaymentData,
|
||||||
|
userId: string,
|
||||||
|
preferenceId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const isExtensionPayment = subscription.pendingPaymentPreferenceId === preferenceId;
|
||||||
|
const paymentStatus = this.normalizePaymentStatus(paymentData.status);
|
||||||
|
const paymentType = this.getPaymentType(subscription, preferenceId);
|
||||||
|
|
||||||
|
if (isExtensionPayment) {
|
||||||
|
subscription.pendingPaymentInitPoint = "";
|
||||||
|
subscription.pendingPaymentPreferenceId = "";
|
||||||
|
subscription.pendingPaymentBillingMonths = undefined;
|
||||||
|
subscription.pendingPaymentType = undefined;
|
||||||
|
subscription.pendingPaymentPlanId = undefined;
|
||||||
|
subscription.pendingPaymentCurrentPlanPrice = undefined;
|
||||||
|
subscription.pendingPaymentRequestedPlanPrice = undefined;
|
||||||
|
subscription.pendingPaymentProratedAmount = undefined;
|
||||||
|
subscription.pendingPaymentRemainingDays = undefined;
|
||||||
|
subscription.pendingPaymentPeriodEndDate = undefined;
|
||||||
|
} else {
|
||||||
|
subscription.mpStatus = MP_SUBS_STATUS.CANCELLED;
|
||||||
|
subscription.isActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentStatus !== "pending" && paymentStatus !== "approved") {
|
||||||
|
subscription.lastPaymentStatus = paymentStatus;
|
||||||
|
subscription.lastPaymentPreferenceId = preferenceId;
|
||||||
|
subscription.lastPaymentAt = paymentData.date_last_updated ? new Date(paymentData.date_last_updated) : new Date();
|
||||||
|
subscription.lastPaymentType = paymentType;
|
||||||
|
}
|
||||||
|
await subscription.save();
|
||||||
|
|
||||||
|
await PlanPaymentsList.createIfMissingByTransactionId({
|
||||||
|
userId,
|
||||||
|
subscriptionId: String(subscription._id),
|
||||||
|
amount: paymentData.transaction_amount || 0,
|
||||||
|
paymentDate: new Date(),
|
||||||
|
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
|
||||||
|
status: "failed",
|
||||||
|
transactionId: String(paymentData.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPaymentType(subscription: IPlanSuscriptionDocument, preferenceId: string): "new" | "extension" | "upgrade" {
|
||||||
|
if (subscription.pendingPaymentPreferenceId === preferenceId) {
|
||||||
|
return subscription.pendingPaymentType === "upgrade" ? "upgrade" : "extension";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "new";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { MercadoPagoWebhookService } from "../MercadoPagoWebhook.Service";
|
||||||
|
import PlanPaymentsList from "../../../Models/PlanPayments/PlanPayments";
|
||||||
|
|
||||||
|
jest.mock("../../../Models/PlanPayments/PlanPayments", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
createIfMissingByTransactionId: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../Models/PlanSubscriptions/PlanSubscriptons", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("MercadoPagoWebhookService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear pending upgrade metadata without changing the current plan or dates when payment is rejected", async () => {
|
||||||
|
const startDate = new Date("2026-07-01T00:00:00.000Z");
|
||||||
|
const endDate = new Date("2026-08-01T00:00:00.000Z");
|
||||||
|
const subscription = {
|
||||||
|
_id: "subscription-id",
|
||||||
|
planId: "current-plan-id",
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
pendingPaymentInitPoint: "https://checkout.example",
|
||||||
|
pendingPaymentPreferenceId: "preference-id",
|
||||||
|
pendingPaymentBillingMonths: undefined,
|
||||||
|
pendingPaymentType: "upgrade",
|
||||||
|
pendingPaymentPlanId: "requested-plan-id",
|
||||||
|
pendingPaymentCurrentPlanPrice: 1000,
|
||||||
|
pendingPaymentRequestedPlanPrice: 2000,
|
||||||
|
pendingPaymentProratedAmount: 500,
|
||||||
|
pendingPaymentRemainingDays: 15,
|
||||||
|
pendingPaymentPeriodEndDate: endDate,
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const service = new MercadoPagoWebhookService() as any;
|
||||||
|
|
||||||
|
await service.applyRejectedPayment(
|
||||||
|
subscription,
|
||||||
|
{
|
||||||
|
id: "payment-id",
|
||||||
|
status: "rejected",
|
||||||
|
transaction_amount: 500,
|
||||||
|
payment_method_id: "visa",
|
||||||
|
},
|
||||||
|
"user-id",
|
||||||
|
"preference-id"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(subscription.planId).toBe("current-plan-id");
|
||||||
|
expect(subscription.startDate).toBe(startDate);
|
||||||
|
expect(subscription.endDate).toBe(endDate);
|
||||||
|
expect(subscription.pendingPaymentInitPoint).toBe("");
|
||||||
|
expect(subscription.pendingPaymentPreferenceId).toBe("");
|
||||||
|
expect(subscription.pendingPaymentType).toBeUndefined();
|
||||||
|
expect(subscription.pendingPaymentPlanId).toBeUndefined();
|
||||||
|
expect(subscription.pendingPaymentProratedAmount).toBeUndefined();
|
||||||
|
expect(subscription.save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(PlanPaymentsList.createIfMissingByTransactionId).toHaveBeenCalledWith({
|
||||||
|
userId: "user-id",
|
||||||
|
subscriptionId: "subscription-id",
|
||||||
|
amount: 500,
|
||||||
|
paymentDate: expect.any(Date),
|
||||||
|
paymentMethod: "visa",
|
||||||
|
status: "failed",
|
||||||
|
transactionId: "payment-id",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,9 +7,12 @@ import {
|
|||||||
GetSubscriptionInitPointParams,
|
GetSubscriptionInitPointParams,
|
||||||
GetSuscriptionInitPointResponse,
|
GetSuscriptionInitPointResponse,
|
||||||
MPPreApprovalResponse,
|
MPPreApprovalResponse,
|
||||||
|
VerifyPendingPlanPaymentParams,
|
||||||
|
VerifyPendingPlanPaymentResponse,
|
||||||
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
|
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
|
||||||
import { authenticateMiddleware } from "../../middleware/authentication";
|
import { authenticateMiddleware } from "../../middleware/authentication";
|
||||||
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
|
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
|
||||||
|
import { rateLimiter } from "../../middleware/ratelimiter";
|
||||||
|
|
||||||
@Route("suscriptions/get-init-point")
|
@Route("suscriptions/get-init-point")
|
||||||
@Middlewares(authenticateMiddleware)
|
@Middlewares(authenticateMiddleware)
|
||||||
@@ -75,3 +78,24 @@ export class PlanSubscriptionsCancellController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Route("suscriptions/verify-pending-payment")
|
||||||
|
@Middlewares([rateLimiter, authenticateMiddleware])
|
||||||
|
export class PlanSubscriptionsVerifyPendingPaymentController extends Controller {
|
||||||
|
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||||
|
@SuccessResponse(200, "Done")
|
||||||
|
@Post()
|
||||||
|
public async verifyPendingPayment(
|
||||||
|
@Body() requestBody: VerifyPendingPlanPaymentParams
|
||||||
|
): Promise<VerifyPendingPlanPaymentResponse | ApiValidationError> {
|
||||||
|
try {
|
||||||
|
const result = await new PlanSubscriptionsService().verifyPendingPayment(requestBody);
|
||||||
|
this.setStatus(200);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const errorOccurred: Error = e as Error;
|
||||||
|
this.setStatus(500);
|
||||||
|
return new ApiValidationError(500, errorOccurred.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import {
|
|||||||
GetSubscriptionInitPointParams,
|
GetSubscriptionInitPointParams,
|
||||||
GetSuscriptionInitPointResponse,
|
GetSuscriptionInitPointResponse,
|
||||||
MPPreApprovalResponse,
|
MPPreApprovalResponse,
|
||||||
|
VerifyPendingPlanPaymentParams,
|
||||||
|
VerifyPendingPlanPaymentResponse,
|
||||||
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
|
} from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
|
||||||
import { connect } from "mongoose";
|
import { connect } from "mongoose";
|
||||||
|
import { validateSessionUser } from "../../helpers/check";
|
||||||
|
import { MercadoPagoWebhookService } from "../MercadoPago/MercadoPagoWebhook.Service";
|
||||||
|
|
||||||
export class PlanSubscriptionsService {
|
export class PlanSubscriptionsService {
|
||||||
public async getInitPoint(
|
public async getInitPoint(
|
||||||
@@ -27,4 +31,10 @@ export class PlanSubscriptionsService {
|
|||||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
await PlanSubscriptionsList.cancell(data);
|
await PlanSubscriptionsList.cancell(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async verifyPendingPayment(data: VerifyPendingPlanPaymentParams): Promise<VerifyPendingPlanPaymentResponse> {
|
||||||
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
|
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||||
|
return new MercadoPagoWebhookService().verifyPendingPlanPayment(data.sessionUser);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
|
||||||
|
import { ApiValidationError } from "../../../Models/Server.Error.model";
|
||||||
|
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
|
||||||
|
import {
|
||||||
|
SysAdminRecalculatePlanUsageCycleParams,
|
||||||
|
SysAdminRecalculatePlanUsageCycleResult,
|
||||||
|
} from "../../../Models/PlanUsageCycle/PlanUsageCycle.Interface";
|
||||||
|
import { SysAdminPlanUsageCycleService } from "./SysAdminPlanUsageCycle.Service";
|
||||||
|
|
||||||
|
@Route("sysadmin/plan-usage-cycle")
|
||||||
|
@Middlewares(sysAdminAuthMiddleware)
|
||||||
|
export class SysAdminPlanUsageCycleController extends Controller {
|
||||||
|
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||||
|
@SuccessResponse(200, "Done")
|
||||||
|
@Post("recalculate-user")
|
||||||
|
public async recalculateUserUsageCycle(
|
||||||
|
@Body() requestBody: SysAdminRecalculatePlanUsageCycleParams
|
||||||
|
): Promise<SysAdminRecalculatePlanUsageCycleResult | ApiValidationError> {
|
||||||
|
try {
|
||||||
|
const result = await new SysAdminPlanUsageCycleService().recalculateUserUsageCycle(requestBody);
|
||||||
|
this.setStatus(200);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const errorOccurred: Error = e as Error;
|
||||||
|
this.setStatus(500);
|
||||||
|
return new ApiValidationError(500, errorOccurred.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { connect } from "mongoose";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import AppointmentList from "../../../Models/Appointments/Appointments";
|
||||||
|
import CompaniesList from "../../../Models/Companies/Companies";
|
||||||
|
import MetricsList from "../../../Models/Metrics/Metrics";
|
||||||
|
import PlanUsageCycleList from "../../../Models/PlanUsageCycle/PlanUsageCycle";
|
||||||
|
import {
|
||||||
|
SysAdminRecalculatePlanUsageCycleParams,
|
||||||
|
SysAdminRecalculatePlanUsageCycleResult,
|
||||||
|
} from "../../../Models/PlanUsageCycle/PlanUsageCycle.Interface";
|
||||||
|
|
||||||
|
export class SysAdminPlanUsageCycleService {
|
||||||
|
public async recalculateUserUsageCycle(
|
||||||
|
params: SysAdminRecalculatePlanUsageCycleParams
|
||||||
|
): Promise<SysAdminRecalculatePlanUsageCycleResult> {
|
||||||
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
|
|
||||||
|
const usageCycle = await PlanUsageCycleList.getCurrentCycle({ userId: params.userId });
|
||||||
|
const companies = await CompaniesList.companies.find({ ownerId: params.userId });
|
||||||
|
const companyIds = companies.map((company) => String(company.id));
|
||||||
|
|
||||||
|
const appointmentsCount = companyIds.length === 0
|
||||||
|
? 0
|
||||||
|
: await AppointmentList.Appointments.AppointmentList.countDocuments({
|
||||||
|
companyId: { $in: companyIds },
|
||||||
|
creationDate: {
|
||||||
|
$gte: dayjs(usageCycle.cycleStart).toDate(),
|
||||||
|
$lt: dayjs(usageCycle.cycleEnd).toDate(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedCycle = await PlanUsageCycleList.setAppointmentsCount({
|
||||||
|
userId: params.userId,
|
||||||
|
appointmentsCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedCycle) {
|
||||||
|
throw new Error("No se pudo recalcular el uso del plan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await MetricsList.calculateMetrics({ userId: params.userId });
|
||||||
|
const metrics = await MetricsList.getMetrics(params.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: params.userId,
|
||||||
|
subscriptionId: String(updatedCycle.subscriptionId),
|
||||||
|
planId: String(updatedCycle.planId),
|
||||||
|
cycleStart: updatedCycle.cycleStart,
|
||||||
|
cycleEnd: updatedCycle.cycleEnd,
|
||||||
|
organizationsCount: metrics.organizationsCount,
|
||||||
|
employeesCount: metrics.employeesCount,
|
||||||
|
servicesCount: metrics.servicesCount,
|
||||||
|
clientsCount: metrics.clientsCount,
|
||||||
|
repeatsCount: metrics.repeatsCount || 0,
|
||||||
|
appointmentsCount: updatedCycle.appointmentsCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
|
||||||
|
|
||||||
|
import { ApiValidationError } from "../../../Models/Server.Error.model";
|
||||||
|
import { ApiVoidResult } from "../../../Models/Api.VoidResult.type";
|
||||||
|
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
|
||||||
|
import { IPlan, SysAdminUpdatePlanParams } from "../../../Models/Plans/Plans.interface";
|
||||||
|
import { SysAdminPlansService } from "./SysAdminPlans.Service";
|
||||||
|
|
||||||
|
@Route("sysadmin/plans")
|
||||||
|
@Middlewares(sysAdminAuthMiddleware)
|
||||||
|
export class SysAdminPlansController extends Controller {
|
||||||
|
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||||
|
@SuccessResponse(200, "Done")
|
||||||
|
@Post("list")
|
||||||
|
public async listPlans(): Promise<IPlan[] | ApiValidationError> {
|
||||||
|
try {
|
||||||
|
const plans = await new SysAdminPlansService().listPlans();
|
||||||
|
this.setStatus(200);
|
||||||
|
return plans;
|
||||||
|
} catch (e) {
|
||||||
|
const errorOccurred: Error = e as Error;
|
||||||
|
this.setStatus(500);
|
||||||
|
return new ApiValidationError(500, errorOccurred.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||||
|
@SuccessResponse(200, "El plan fue modificado con éxito")
|
||||||
|
@Post("update")
|
||||||
|
public async updatePlan(@Body() requestBody: SysAdminUpdatePlanParams): Promise<ApiVoidResult | ApiValidationError> {
|
||||||
|
try {
|
||||||
|
await new SysAdminPlansService().updatePlan(requestBody);
|
||||||
|
this.setStatus(200);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Done",
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
const errorOccurred: Error = e as Error;
|
||||||
|
this.setStatus(500);
|
||||||
|
return new ApiValidationError(500, errorOccurred.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import PlansList from "../../../Models/Plans/Plans";
|
||||||
|
import { IPlan, SysAdminUpdatePlanParams } from "../../../Models/Plans/Plans.interface";
|
||||||
|
import { connect } from "mongoose";
|
||||||
|
|
||||||
|
export class SysAdminPlansService {
|
||||||
|
public async listPlans(): Promise<IPlan[]> {
|
||||||
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
|
return await PlansList.find({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updatePlan(data: SysAdminUpdatePlanParams): Promise<void> {
|
||||||
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
|
await PlansList.sysAdminUpdatePlan(this.sanitizeUpdate(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeUpdate(data: SysAdminUpdatePlanParams): SysAdminUpdatePlanParams {
|
||||||
|
if (!data.planId) {
|
||||||
|
throw new Error("Plan id is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized: SysAdminUpdatePlanParams = { planId: data.planId };
|
||||||
|
const stringFields: (keyof SysAdminUpdatePlanParams)[] = ["name", "description", "code"];
|
||||||
|
const nonNegativeNumberFields: (keyof SysAdminUpdatePlanParams)[] = [
|
||||||
|
"price",
|
||||||
|
"annualPrice",
|
||||||
|
"discount3Months",
|
||||||
|
"discount6Months",
|
||||||
|
"discount12Months",
|
||||||
|
];
|
||||||
|
const limitFields: (keyof SysAdminUpdatePlanParams)[] = [
|
||||||
|
"limitOrganizations",
|
||||||
|
"limitEmployees",
|
||||||
|
"limitServices",
|
||||||
|
"limitAppointments",
|
||||||
|
"limitClients",
|
||||||
|
"limitRepeats",
|
||||||
|
];
|
||||||
|
const booleanFields: (keyof SysAdminUpdatePlanParams)[] = [
|
||||||
|
"mailNotifications",
|
||||||
|
"smsNotifications",
|
||||||
|
"wapNotifications",
|
||||||
|
"payments",
|
||||||
|
"bot",
|
||||||
|
"active",
|
||||||
|
"dateLimit",
|
||||||
|
"featured",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of stringFields) {
|
||||||
|
const value = data[field];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
sanitized[field] = value.trim() as never;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of nonNegativeNumberFields) {
|
||||||
|
const value = data[field];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
||||||
|
sanitized[field] = value as never;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of limitFields) {
|
||||||
|
const value = data[field];
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
sanitized[field] = value as never;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of booleanFields) {
|
||||||
|
const value = data[field];
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
sanitized[field] = value as never;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data.features)) {
|
||||||
|
sanitized.features = data.features.filter((feature) => typeof feature === "string").map((feature) => feature.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
|
||||||
|
import { ApiValidationError } from "../../../Models/Server.Error.model";
|
||||||
|
import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth";
|
||||||
|
import {
|
||||||
|
SysAdminExtendUserSubscriptionParams,
|
||||||
|
SysAdminExtendUserSubscriptionResult,
|
||||||
|
SysAdminFinalizeUserSubscriptionParams,
|
||||||
|
SysAdminFinalizeUserSubscriptionResult,
|
||||||
|
SysAdminUserSubscriptionDetailsParams,
|
||||||
|
SysAdminUserSubscriptionDetailsResult,
|
||||||
|
} from "../../../Models/Users/Users.Interface";
|
||||||
|
import { SysAdminSubscriptionsService } from "./SysAdminSubscriptions.Service";
|
||||||
|
|
||||||
|
@Route("sysadmin/subscriptions")
|
||||||
|
@Middlewares(sysAdminAuthMiddleware)
|
||||||
|
export class SysAdminSubscriptionsController extends Controller {
|
||||||
|
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||||
|
@SuccessResponse(200, "Done")
|
||||||
|
@Post("extend-user")
|
||||||
|
public async extendUserSubscription(
|
||||||
|
@Body() requestBody: SysAdminExtendUserSubscriptionParams
|
||||||
|
): Promise<SysAdminExtendUserSubscriptionResult | ApiValidationError> {
|
||||||
|
try {
|
||||||
|
const result = await new SysAdminSubscriptionsService().extendUserSubscription(requestBody);
|
||||||
|
this.setStatus(200);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const errorOccurred: Error = e as Error;
|
||||||
|
this.setStatus(500);
|
||||||
|
return new ApiValidationError(500, errorOccurred.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||||
|
@SuccessResponse(200, "Done")
|
||||||
|
@Post("finalize-user")
|
||||||
|
public async finalizeUserSubscription(
|
||||||
|
@Body() requestBody: SysAdminFinalizeUserSubscriptionParams
|
||||||
|
): Promise<SysAdminFinalizeUserSubscriptionResult | ApiValidationError> {
|
||||||
|
try {
|
||||||
|
const result = await new SysAdminSubscriptionsService().finalizeUserSubscription(requestBody);
|
||||||
|
this.setStatus(200);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const errorOccurred: Error = e as Error;
|
||||||
|
this.setStatus(500);
|
||||||
|
return new ApiValidationError(500, errorOccurred.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||||
|
@SuccessResponse(200, "Done")
|
||||||
|
@Post("user-details")
|
||||||
|
public async getUserSubscriptionDetails(
|
||||||
|
@Body() requestBody: SysAdminUserSubscriptionDetailsParams
|
||||||
|
): Promise<SysAdminUserSubscriptionDetailsResult | ApiValidationError> {
|
||||||
|
try {
|
||||||
|
const result = await new SysAdminSubscriptionsService().getUserSubscriptionDetails(requestBody);
|
||||||
|
this.setStatus(200);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const errorOccurred: Error = e as Error;
|
||||||
|
this.setStatus(500);
|
||||||
|
return new ApiValidationError(500, errorOccurred.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { connect, isValidObjectId } from "mongoose";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import PlanSubscriptionsList from "../../../Models/PlanSubscriptions/PlanSubscriptons";
|
||||||
|
import PlanPaymentsList from "../../../Models/PlanPayments/PlanPayments";
|
||||||
|
import PlansList from "../../../Models/Plans/Plans";
|
||||||
|
import {
|
||||||
|
SysAdminExtendUserSubscriptionParams,
|
||||||
|
SysAdminExtendUserSubscriptionResult,
|
||||||
|
SysAdminFinalizeUserSubscriptionParams,
|
||||||
|
SysAdminFinalizeUserSubscriptionResult,
|
||||||
|
SysAdminUserSubscriptionDetailsParams,
|
||||||
|
SysAdminUserSubscriptionDetailsResult,
|
||||||
|
} from "../../../Models/Users/Users.Interface";
|
||||||
|
|
||||||
|
export class SysAdminSubscriptionsService {
|
||||||
|
public async extendUserSubscription(
|
||||||
|
params: SysAdminExtendUserSubscriptionParams
|
||||||
|
): Promise<SysAdminExtendUserSubscriptionResult> {
|
||||||
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
|
|
||||||
|
if (!params.userId || !isValidObjectId(params.userId)) {
|
||||||
|
throw new Error("User id is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error("No subscription found for user");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const previousEndDate = subscription.endDate ? new Date(subscription.endDate) : now;
|
||||||
|
const baseDate = previousEndDate.getTime() > now.getTime() ? previousEndDate : now;
|
||||||
|
let newEndDate: Date;
|
||||||
|
|
||||||
|
if (params.mode === "add_months") {
|
||||||
|
if (params.months !== 1 && params.months !== 2) {
|
||||||
|
throw new Error("Months must be 1 or 2");
|
||||||
|
}
|
||||||
|
newEndDate = dayjs(baseDate).add(params.months, "month").toDate();
|
||||||
|
} else if (params.mode === "set_end_date") {
|
||||||
|
if (!params.endDate) {
|
||||||
|
throw new Error("End date is required");
|
||||||
|
}
|
||||||
|
const parsedEndDate = dayjs(params.endDate);
|
||||||
|
if (!parsedEndDate.isValid()) {
|
||||||
|
throw new Error("End date is invalid");
|
||||||
|
}
|
||||||
|
newEndDate = parsedEndDate.endOf("day").toDate();
|
||||||
|
if (newEndDate.getTime() <= now.getTime()) {
|
||||||
|
throw new Error("End date must be in the future");
|
||||||
|
}
|
||||||
|
if (newEndDate.getTime() <= baseDate.getTime()) {
|
||||||
|
throw new Error("End date must be after the current effective subscription end date");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid extension mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.endDate = newEndDate;
|
||||||
|
subscription.isActive = true;
|
||||||
|
if (subscription.mpStatus) {
|
||||||
|
subscription.mpStatus = "authorized";
|
||||||
|
}
|
||||||
|
await subscription.save();
|
||||||
|
|
||||||
|
const transactionId = [
|
||||||
|
"sysadmin_bonus",
|
||||||
|
String(subscription.id),
|
||||||
|
now.getTime(),
|
||||||
|
].join(":");
|
||||||
|
const payment = await PlanPaymentsList.createIfMissingByTransactionId({
|
||||||
|
userId: params.userId,
|
||||||
|
subscriptionId: String(subscription.id),
|
||||||
|
amount: 0,
|
||||||
|
paymentDate: now,
|
||||||
|
paymentMethod: "sysadmin_bonus",
|
||||||
|
status: "completed",
|
||||||
|
transactionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: params.userId,
|
||||||
|
subscriptionId: String(subscription.id),
|
||||||
|
previousEndDate,
|
||||||
|
newEndDate,
|
||||||
|
paymentId: String(payment.id),
|
||||||
|
transactionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async finalizeUserSubscription(
|
||||||
|
params: SysAdminFinalizeUserSubscriptionParams
|
||||||
|
): Promise<SysAdminFinalizeUserSubscriptionResult> {
|
||||||
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
|
|
||||||
|
if (!params.userId || !isValidObjectId(params.userId)) {
|
||||||
|
throw new Error("User id is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error("No subscription found for user");
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousEndDate = subscription.endDate ? new Date(subscription.endDate) : new Date();
|
||||||
|
const newEndDate = dayjs().subtract(1, "day").startOf("day").toDate();
|
||||||
|
const plan = await PlansList.plans.findOne({ _id: String(subscription.planId) });
|
||||||
|
|
||||||
|
subscription.endDate = newEndDate;
|
||||||
|
await subscription.save();
|
||||||
|
|
||||||
|
if (plan && plan.price > 0) {
|
||||||
|
await PlanSubscriptionsList.toFreePlan({
|
||||||
|
sessionUser: params.userId,
|
||||||
|
downgradeReason: "expired",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: params.userId,
|
||||||
|
subscriptionId: String(subscription.id),
|
||||||
|
previousEndDate,
|
||||||
|
newEndDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getUserSubscriptionDetails(
|
||||||
|
params: SysAdminUserSubscriptionDetailsParams
|
||||||
|
): Promise<SysAdminUserSubscriptionDetailsResult> {
|
||||||
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
|
|
||||||
|
const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId });
|
||||||
|
const payments = await PlanPaymentsList.find({ userId: params.userId });
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return {
|
||||||
|
userId: params.userId,
|
||||||
|
currentSubscription: null,
|
||||||
|
payments: payments.map((payment) => ({
|
||||||
|
id: String(payment.id),
|
||||||
|
subscriptionId: String(payment.subscriptionId),
|
||||||
|
amount: payment.amount,
|
||||||
|
paymentDate: payment.paymentDate,
|
||||||
|
paymentMethod: payment.paymentMethod,
|
||||||
|
status: payment.status,
|
||||||
|
transactionId: payment.transactionId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await PlansList.plans.findOne({ _id: String(subscription.planId) });
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: params.userId,
|
||||||
|
currentSubscription: {
|
||||||
|
id: String(subscription.id),
|
||||||
|
plan: plan
|
||||||
|
? {
|
||||||
|
id: String(plan.id),
|
||||||
|
name: plan.name,
|
||||||
|
code: plan.code,
|
||||||
|
price: plan.price,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
startDate: subscription.startDate,
|
||||||
|
endDate: subscription.endDate,
|
||||||
|
isActive: subscription.isActive,
|
||||||
|
mpStatus: subscription.mpStatus,
|
||||||
|
billingMonths: subscription.billingMonths,
|
||||||
|
pendingPaymentType: subscription.pendingPaymentType,
|
||||||
|
pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId,
|
||||||
|
lastPaymentStatus: subscription.lastPaymentStatus,
|
||||||
|
},
|
||||||
|
payments: payments.map((payment) => ({
|
||||||
|
id: String(payment.id),
|
||||||
|
subscriptionId: String(payment.subscriptionId),
|
||||||
|
amount: payment.amount,
|
||||||
|
paymentDate: payment.paymentDate,
|
||||||
|
paymentMethod: payment.paymentMethod,
|
||||||
|
status: payment.status,
|
||||||
|
transactionId: payment.transactionId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
export default function getAvatar(
|
export default function getAvatar(
|
||||||
userId: string,
|
userId: string | null | undefined,
|
||||||
avatar: string | null | undefined,
|
avatar: string | null | undefined,
|
||||||
fallback: string
|
fallback: string
|
||||||
): string {
|
): string {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { DashboardPage } from './pages/DashboardPage';
|
|||||||
import { UsersPage } from './pages/UsersPage';
|
import { UsersPage } from './pages/UsersPage';
|
||||||
import { WapServersPage } from './pages/WapServersPage';
|
import { WapServersPage } from './pages/WapServersPage';
|
||||||
import { CompaniesPage } from './pages/CompaniesPage';
|
import { CompaniesPage } from './pages/CompaniesPage';
|
||||||
|
import { PlansPage } from './pages/PlansPage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -13,6 +14,7 @@ function App() {
|
|||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="companies" element={<CompaniesPage />} />
|
<Route path="companies" element={<CompaniesPage />} />
|
||||||
|
<Route path="plans" element={<PlansPage />} />
|
||||||
<Route path="wapservers" element={<WapServersPage />} />
|
<Route path="wapservers" element={<WapServersPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -33,6 +33,141 @@ export const getOrganizationsStatus = async (userId: string) => {
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RecalculatePlanUsageCycleResult = {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
planId: string;
|
||||||
|
cycleStart: string;
|
||||||
|
cycleEnd: string;
|
||||||
|
organizationsCount: number;
|
||||||
|
employeesCount: number;
|
||||||
|
servicesCount: number;
|
||||||
|
clientsCount: number;
|
||||||
|
repeatsCount: number;
|
||||||
|
appointmentsCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recalculatePlanUsageCycle = async (userId: string): Promise<RecalculatePlanUsageCycleResult> => {
|
||||||
|
const res = await sysadminApi.post('/users/recalculate-plan-usage-cycle', { userId });
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserSubscriptionDetails = {
|
||||||
|
userId: string;
|
||||||
|
currentSubscription: {
|
||||||
|
id: string;
|
||||||
|
plan: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
price: number;
|
||||||
|
} | null;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
isActive: boolean;
|
||||||
|
mpStatus?: string;
|
||||||
|
billingMonths?: number;
|
||||||
|
pendingPaymentType?: 'extension' | 'upgrade';
|
||||||
|
pendingPaymentPreferenceId?: string;
|
||||||
|
lastPaymentStatus?: string;
|
||||||
|
} | null;
|
||||||
|
payments: {
|
||||||
|
id: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
amount: number;
|
||||||
|
paymentDate: string;
|
||||||
|
paymentMethod?: string;
|
||||||
|
status: string;
|
||||||
|
transactionId?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserSubscriptionDetails = async (userId: string): Promise<UserSubscriptionDetails> => {
|
||||||
|
const res = await sysadminApi.post('/users/subscription-details', { userId });
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExtendUserSubscriptionPayload = {
|
||||||
|
userId: string;
|
||||||
|
mode: 'add_months' | 'set_end_date';
|
||||||
|
months?: 1 | 2;
|
||||||
|
endDate?: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExtendUserSubscriptionResult = {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
previousEndDate: string;
|
||||||
|
newEndDate: string;
|
||||||
|
paymentId: string;
|
||||||
|
transactionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extendUserSubscription = async (data: ExtendUserSubscriptionPayload): Promise<ExtendUserSubscriptionResult> => {
|
||||||
|
const res = await sysadminApi.post('/users/extend-subscription', data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FinalizeUserSubscriptionPayload = {
|
||||||
|
userId: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FinalizeUserSubscriptionResult = {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
previousEndDate: string;
|
||||||
|
newEndDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const finalizeUserSubscription = async (data: FinalizeUserSubscriptionPayload): Promise<FinalizeUserSubscriptionResult> => {
|
||||||
|
const res = await sysadminApi.post('/users/finalize-subscription', data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Plan = {
|
||||||
|
id?: string;
|
||||||
|
_id?: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
features: string[];
|
||||||
|
code: string;
|
||||||
|
price: number;
|
||||||
|
annualPrice: number;
|
||||||
|
limitOrganizations: number;
|
||||||
|
limitEmployees: number;
|
||||||
|
limitServices: number;
|
||||||
|
limitAppointments: number;
|
||||||
|
limitClients: number;
|
||||||
|
limitRepeats: number;
|
||||||
|
mailNotifications: boolean;
|
||||||
|
smsNotifications: boolean;
|
||||||
|
wapNotifications: boolean;
|
||||||
|
payments: boolean;
|
||||||
|
bot: boolean;
|
||||||
|
active: boolean;
|
||||||
|
dateLimit: boolean;
|
||||||
|
discount3Months?: number;
|
||||||
|
discount6Months?: number;
|
||||||
|
discount12Months?: number;
|
||||||
|
featured?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdatePlanPayload = Partial<Omit<Plan, 'id' | '_id'>> & {
|
||||||
|
planId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchPlans = async (): Promise<Plan[]> => {
|
||||||
|
const res = await sysadminApi.post('/plans/list', {});
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updatePlan = async (data: UpdatePlanPayload) => {
|
||||||
|
const res = await sysadminApi.post('/plans/update', data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
// Wap Servers API Calls
|
// Wap Servers API Calls
|
||||||
export const fetchWapServers = async (filters: any) => {
|
export const fetchWapServers = async (filters: any) => {
|
||||||
const res = await sysadminApi.post('/wap/paginate', filters);
|
const res = await sysadminApi.post('/wap/paginate', filters);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Outlet, NavLink } from 'react-router-dom';
|
import { Outlet, NavLink } from 'react-router-dom';
|
||||||
import { Users, LayoutDashboard, LogOut, Server, Building2 } from 'lucide-react';
|
import { Users, LayoutDashboard, LogOut, Server, Building2, BadgeDollarSign } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
export const DashboardLayout = () => {
|
export const DashboardLayout = () => {
|
||||||
@@ -30,6 +30,10 @@ export const DashboardLayout = () => {
|
|||||||
<Building2 size={20} />
|
<Building2 size={20} />
|
||||||
Organizaciones
|
Organizaciones
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/plans" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
|
<BadgeDollarSign size={20} />
|
||||||
|
Planes
|
||||||
|
</NavLink>
|
||||||
<NavLink to="/wapservers" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
<NavLink to="/wapservers" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
<Server size={20} />
|
<Server size={20} />
|
||||||
Servidores WAP
|
Servidores WAP
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { updatePlan } from '../../api/sysadmin';
|
||||||
|
import type { Plan } from '../../api/sysadmin';
|
||||||
|
|
||||||
|
type EditPlanModalProps = {
|
||||||
|
plan: Plan;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const numberFields: { key: keyof Plan; label: string }[] = [
|
||||||
|
{ key: 'price', label: 'Precio mensual' },
|
||||||
|
{ key: 'annualPrice', label: 'Precio anual' },
|
||||||
|
{ key: 'limitOrganizations', label: 'Límite de organizaciones' },
|
||||||
|
{ key: 'limitEmployees', label: 'Límite de empleados' },
|
||||||
|
{ key: 'limitServices', label: 'Límite de servicios' },
|
||||||
|
{ key: 'limitAppointments', label: 'Límite de turnos' },
|
||||||
|
{ key: 'limitClients', label: 'Límite de clientes' },
|
||||||
|
{ key: 'limitRepeats', label: 'Límite de repeticiones' },
|
||||||
|
{ key: 'discount3Months', label: 'Descuento 3 meses (%)' },
|
||||||
|
{ key: 'discount6Months', label: 'Descuento 6 meses (%)' },
|
||||||
|
{ key: 'discount12Months', label: 'Descuento 12 meses (%)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const limitFields = new Set<keyof Plan>([
|
||||||
|
'limitOrganizations',
|
||||||
|
'limitEmployees',
|
||||||
|
'limitServices',
|
||||||
|
'limitAppointments',
|
||||||
|
'limitClients',
|
||||||
|
'limitRepeats',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const booleanFields: { key: keyof Plan; label: string }[] = [
|
||||||
|
{ key: 'active', label: 'Activo para contratación' },
|
||||||
|
{ key: 'featured', label: 'Destacado' },
|
||||||
|
{ key: 'mailNotifications', label: 'Notificaciones por email' },
|
||||||
|
{ key: 'smsNotifications', label: 'Notificaciones SMS' },
|
||||||
|
{ key: 'wapNotifications', label: 'Notificaciones WhatsApp' },
|
||||||
|
{ key: 'payments', label: 'Pagos habilitados' },
|
||||||
|
{ key: 'bot', label: 'Bot habilitado' },
|
||||||
|
{ key: 'dateLimit', label: 'Tiene límite de fecha' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EditPlanModal = ({ plan, onClose, onSaved }: EditPlanModalProps) => {
|
||||||
|
const [formData, setFormData] = useState<Plan>({
|
||||||
|
...plan,
|
||||||
|
features: Array.isArray(plan.features) ? plan.features : [],
|
||||||
|
});
|
||||||
|
const [featuresText, setFeaturesText] = useState(formData.features.join('\n'));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const planId = plan._id || plan.id || '';
|
||||||
|
|
||||||
|
const setField = (key: keyof Plan, value: string | number | boolean | string[]) => {
|
||||||
|
setFormData({ ...formData, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await updatePlan({
|
||||||
|
planId,
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description,
|
||||||
|
code: formData.code,
|
||||||
|
price: Number(formData.price) || 0,
|
||||||
|
annualPrice: Number(formData.annualPrice) || 0,
|
||||||
|
limitOrganizations: Number(formData.limitOrganizations) || 0,
|
||||||
|
limitEmployees: Number(formData.limitEmployees) || 0,
|
||||||
|
limitServices: Number(formData.limitServices) || 0,
|
||||||
|
limitAppointments: Number(formData.limitAppointments) || 0,
|
||||||
|
limitClients: Number(formData.limitClients) || 0,
|
||||||
|
limitRepeats: Number(formData.limitRepeats) || 0,
|
||||||
|
discount3Months: Number(formData.discount3Months) || 0,
|
||||||
|
discount6Months: Number(formData.discount6Months) || 0,
|
||||||
|
discount12Months: Number(formData.discount12Months) || 0,
|
||||||
|
active: Boolean(formData.active),
|
||||||
|
featured: Boolean(formData.featured),
|
||||||
|
mailNotifications: Boolean(formData.mailNotifications),
|
||||||
|
smsNotifications: Boolean(formData.smsNotifications),
|
||||||
|
wapNotifications: Boolean(formData.wapNotifications),
|
||||||
|
payments: Boolean(formData.payments),
|
||||||
|
bot: Boolean(formData.bot),
|
||||||
|
dateLimit: Boolean(formData.dateLimit),
|
||||||
|
features: featuresText.split('\n').map(feature => feature.trim()).filter(Boolean),
|
||||||
|
});
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.response?.data?.message || err.message || 'Error guardando el plan');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}>
|
||||||
|
<motion.div initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="glass-panel" style={{ width: '94%', maxWidth: '860px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}>
|
||||||
|
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1.5rem' }}>Editar plan: {plan.name}</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<label className="modal-label">Nombre</label>
|
||||||
|
<input className="input-glass" value={formData.name} onChange={e => setField('name', e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="modal-label">Código</label>
|
||||||
|
<input className="input-glass" value={formData.code} onChange={e => setField('code', e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label className="modal-label">Descripción</label>
|
||||||
|
<textarea className="input-glass" rows={3} value={formData.description} onChange={e => setField('description', e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="plan-modal-section">Precios, límites y descuentos</h3>
|
||||||
|
{numberFields.map(field => (
|
||||||
|
<div key={field.key}>
|
||||||
|
<label className="modal-label">{field.label}</label>
|
||||||
|
<input className="input-glass" type="number" min={limitFields.has(field.key) ? undefined : '0'} step="0.01" value={Number(formData[field.key] || 0)} onChange={e => setField(field.key, Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<h3 className="plan-modal-section">Funciones y estado</h3>
|
||||||
|
{booleanFields.map(field => (
|
||||||
|
<label key={field.key} className="plan-toggle">
|
||||||
|
<input type="checkbox" checked={Boolean(formData[field.key])} onChange={e => setField(field.key, e.target.checked)} />
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label className="modal-label">Features visibles (una por línea)</label>
|
||||||
|
<textarea className="input-glass" rows={5} value={featuresText} onChange={e => setFeaturesText(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ gridColumn: '1 / -1', marginTop: '1rem', display: 'flex', justifyContent: 'flex-end', gap: '1rem' }}>
|
||||||
|
<button type="button" className="btn-secondary" onClick={onClose}>Cancelar</button>
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading || !planId}>{loading ? 'Guardando...' : 'Guardar cambios'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style dangerouslySetInnerHTML={{__html: `
|
||||||
|
.modal-label { display: block; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.5rem; }
|
||||||
|
.plan-modal-section { grid-column: 1 / -1; margin: 1rem 0 0.25rem; color: var(--primary); font-size: 1.05rem; }
|
||||||
|
.plan-toggle { display: flex; align-items: center; gap: 0.6rem; color: var(--text-main); background: rgba(255,255,255,0.04); border: 1px solid var(--glass-border); border-radius: 10px; padding: 0.75rem; }
|
||||||
|
.plan-toggle input { width: 16px; height: 16px; }
|
||||||
|
@media (max-width: 768px) { form { grid-template-columns: 1fr !important; } }
|
||||||
|
`}} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { RefreshCw, X } from 'lucide-react';
|
||||||
|
import { recalculatePlanUsageCycle, type RecalculatePlanUsageCycleResult } from '../../api/sysadmin';
|
||||||
|
import { formatDateTime } from '../../utils/formatters';
|
||||||
|
|
||||||
|
export const RecalculationToolsModal = ({ initialUserId = '', onClose }: { initialUserId?: string, onClose: () => void }) => {
|
||||||
|
const [usageUserId, setUsageUserId] = useState(initialUserId);
|
||||||
|
const [usageResult, setUsageResult] = useState<RecalculatePlanUsageCycleResult | null>(null);
|
||||||
|
const [usageLoading, setUsageLoading] = useState(false);
|
||||||
|
const [usageError, setUsageError] = useState('');
|
||||||
|
|
||||||
|
const handleRecalculatePlanUsage = async () => {
|
||||||
|
const trimmedUserId = usageUserId.trim();
|
||||||
|
if (!trimmedUserId) {
|
||||||
|
setUsageError('Ingresá un userId para recalcular el uso del plan.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsageUserId(trimmedUserId);
|
||||||
|
setUsageLoading(true);
|
||||||
|
setUsageError('');
|
||||||
|
setUsageResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await recalculatePlanUsageCycle(trimmedUserId);
|
||||||
|
setUsageResult(result);
|
||||||
|
} catch (e: any) {
|
||||||
|
setUsageError(e.response?.data?.message || e.message || 'Error al recalcular el uso del plan');
|
||||||
|
} finally {
|
||||||
|
setUsageLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', padding: '1rem' }}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="glass-panel"
|
||||||
|
style={{ width: '90%', maxWidth: '760px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}
|
||||||
|
>
|
||||||
|
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<RefreshCw size={24} color="var(--primary)" />
|
||||||
|
Herramientas de recálculo
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>Funciones exclusivas de sysadmin para reparar o reconstruir contadores.</p>
|
||||||
|
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem' }}>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: '700', marginBottom: '0.35rem' }}>Recalcular uso del plan</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>Recuenta las reservas del ciclo actual y los contadores stock del userId seleccionado.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); handleRecalculatePlanUsage(); }} style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
|
<div style={{ flex: '1 1 300px' }}>
|
||||||
|
<label className="modal-label">User ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-glass"
|
||||||
|
placeholder="Pegá o seleccioná un userId"
|
||||||
|
value={usageUserId}
|
||||||
|
onChange={(e) => setUsageUserId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn-primary" disabled={usageLoading} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<RefreshCw size={18} /> {usageLoading ? 'Recalculando...' : 'Recalcular'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{usageError && <div style={{ marginTop: '1rem', color: 'var(--danger)', fontSize: '0.9rem' }}>{usageError}</div>}
|
||||||
|
|
||||||
|
{usageResult && (
|
||||||
|
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
<div><strong>User ID</strong><br /><span className="long-id-value">{usageResult.userId}</span></div>
|
||||||
|
<div><strong>Subscription ID</strong><br /><span className="long-id-value">{usageResult.subscriptionId}</span></div>
|
||||||
|
<div><strong>Plan ID</strong><br /><span className="long-id-value">{usageResult.planId}</span></div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '0.75rem' }}>
|
||||||
|
<div><strong>Cycle Start</strong><br /><span style={{ color: 'var(--text-muted)' }}>{formatDateTime(usageResult.cycleStart)}</span></div>
|
||||||
|
<div><strong>Cycle End</strong><br /><span style={{ color: 'var(--text-muted)' }}>{formatDateTime(usageResult.cycleEnd)}</span></div>
|
||||||
|
<div><strong>Organizations</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.organizationsCount}</span></div>
|
||||||
|
<div><strong>Collaborators</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.employeesCount}</span></div>
|
||||||
|
<div><strong>Services</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.servicesCount}</span></div>
|
||||||
|
<div><strong>Clients</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.clientsCount}</span></div>
|
||||||
|
<div><strong>Repeats</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.repeatsCount}</span></div>
|
||||||
|
<div><strong>Appointments</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.appointmentsCount}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style dangerouslySetInnerHTML={{__html: `
|
||||||
|
.modal-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.long-id-value {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
`}} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { CreditCard, X } from 'lucide-react';
|
||||||
|
import { extendUserSubscription, finalizeUserSubscription, getUserSubscriptionDetails, type UserSubscriptionDetails } from '../../api/sysadmin';
|
||||||
|
import { formatDateTime } from '../../utils/formatters';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: any;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
|
||||||
|
const [details, setDetails] = useState<UserSubscriptionDetails | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [extensionDate, setExtensionDate] = useState('');
|
||||||
|
const [extensionLoading, setExtensionLoading] = useState(false);
|
||||||
|
const [extensionMessage, setExtensionMessage] = useState('');
|
||||||
|
const [extensionError, setExtensionError] = useState('');
|
||||||
|
const [finalizeLoading, setFinalizeLoading] = useState(false);
|
||||||
|
|
||||||
|
const formatAmount = (value?: number) => {
|
||||||
|
if (typeof value !== 'number') return '-';
|
||||||
|
return value.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDetails = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await getUserSubscriptionDetails(user._id);
|
||||||
|
setDetails(result);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.response?.data?.message || e.message || 'Error cargando suscripción');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDetails();
|
||||||
|
}, [user._id]);
|
||||||
|
|
||||||
|
const handleExtend = async (payload: { mode: 'add_months' | 'set_end_date'; months?: 1 | 2; endDate?: string }) => {
|
||||||
|
if (!details?.currentSubscription) return;
|
||||||
|
|
||||||
|
setExtensionLoading(true);
|
||||||
|
setExtensionError('');
|
||||||
|
setExtensionMessage('');
|
||||||
|
try {
|
||||||
|
const result = await extendUserSubscription({
|
||||||
|
userId: user._id,
|
||||||
|
reason: 'Ajuste manual excepcional desde sysadmin-cli',
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
setExtensionMessage(`Vencimiento actualizado hasta ${formatDateTime(result.newEndDate)}. Se registró una bonificación $0.`);
|
||||||
|
await loadDetails();
|
||||||
|
} catch (e: any) {
|
||||||
|
setExtensionError(e.response?.data?.message || e.message || 'Error extendiendo suscripción');
|
||||||
|
} finally {
|
||||||
|
setExtensionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinalize = async () => {
|
||||||
|
if (!details?.currentSubscription) return;
|
||||||
|
|
||||||
|
const confirmed = window.confirm('Esto establecerá el vencimiento de la suscripción a ayer para forzar el comportamiento de plan vencido. No elimina datos ni cambia el plan directamente. ¿Continuar?');
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setFinalizeLoading(true);
|
||||||
|
setExtensionError('');
|
||||||
|
setExtensionMessage('');
|
||||||
|
try {
|
||||||
|
const result = await finalizeUserSubscription({
|
||||||
|
userId: user._id,
|
||||||
|
reason: 'Finalización manual desde sysadmin-cli para probar expiración de plan',
|
||||||
|
});
|
||||||
|
setExtensionMessage(`Período finalizado. El vencimiento quedó en ${formatDateTime(result.newEndDate)} para forzar la expiración en la próxima lectura.`);
|
||||||
|
await loadDetails();
|
||||||
|
} catch (e: any) {
|
||||||
|
setExtensionError(e.response?.data?.message || e.message || 'Error finalizando período');
|
||||||
|
} finally {
|
||||||
|
setFinalizeLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = details?.currentSubscription;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', padding: '1rem' }}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="glass-panel"
|
||||||
|
style={{ width: '90%', maxWidth: '860px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}
|
||||||
|
>
|
||||||
|
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<CreditCard size={24} color="var(--primary)" />
|
||||||
|
Suscripción del usuario
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>
|
||||||
|
{user.firstName} {user.lastName} · {user.email}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && <div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>Cargando suscripción...</div>}
|
||||||
|
{error && <div style={{ padding: '1rem', color: 'var(--danger)' }}>{error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && details && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
<section className="subscription-card">
|
||||||
|
<h3 className="subscription-section-title">Suscripción actual</h3>
|
||||||
|
{!subscription ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>No hay suscripción registrada para este usuario.</p>
|
||||||
|
) : (
|
||||||
|
<div className="subscription-grid">
|
||||||
|
<Info label="Subscription ID" value={subscription.id} long />
|
||||||
|
<Info label="User ID" value={details.userId} long />
|
||||||
|
<Info label="Plan" value={subscription.plan ? `${subscription.plan.name} (${subscription.plan.code})` : 'Plan no encontrado'} />
|
||||||
|
<Info label="Plan ID" value={subscription.plan?.id || '-'} long />
|
||||||
|
<Info label="Precio" value={formatAmount(subscription.plan?.price)} />
|
||||||
|
<Info label="Inicio" value={formatDateTime(subscription.startDate)} />
|
||||||
|
<Info label="Válida hasta" value={formatDateTime(subscription.endDate)} />
|
||||||
|
<Info label="Activa" value={subscription.isActive ? 'Sí' : 'No'} />
|
||||||
|
<Info label="MP status" value={subscription.mpStatus || '-'} />
|
||||||
|
<Info label="Meses facturados" value={subscription.billingMonths?.toString() || '-'} />
|
||||||
|
<Info label="Pago pendiente" value={subscription.pendingPaymentType || '-'} />
|
||||||
|
<Info label="Preference pendiente" value={subscription.pendingPaymentPreferenceId || '-'} long />
|
||||||
|
<Info label="Último pago" value={subscription.lastPaymentStatus || '-'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{subscription && (
|
||||||
|
<section className="subscription-card">
|
||||||
|
<h3 className="subscription-section-title">Ajuste manual excepcional</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||||
|
Extiende la vigencia sin pago y registra un pago de bonificación por $0 asociado a esta suscripción.
|
||||||
|
</p>
|
||||||
|
<div className="extension-actions">
|
||||||
|
<button className="btn btn-secondary" disabled={extensionLoading || finalizeLoading} onClick={() => handleExtend({ mode: 'add_months', months: 1 })}>
|
||||||
|
Extender 1 mes
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" disabled={extensionLoading || finalizeLoading} onClick={() => handleExtend({ mode: 'add_months', months: 2 })}>
|
||||||
|
Extender 2 meses
|
||||||
|
</button>
|
||||||
|
<div className="extension-date-action">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={extensionDate}
|
||||||
|
onChange={(event) => setExtensionDate(event.target.value)}
|
||||||
|
disabled={extensionLoading || finalizeLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={extensionLoading || finalizeLoading || !extensionDate}
|
||||||
|
onClick={() => handleExtend({ mode: 'set_end_date', endDate: extensionDate })}
|
||||||
|
>
|
||||||
|
Establecer vencimiento
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="finalize-action">
|
||||||
|
<div>
|
||||||
|
<h4>Finalizar período</h4>
|
||||||
|
<p>Establece el vencimiento en ayer para forzar el comportamiento de suscripción vencida en la próxima lectura. No elimina datos ni cambia el plan directamente.</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-danger" disabled={extensionLoading || finalizeLoading} onClick={handleFinalize}>
|
||||||
|
Finalizar período
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(extensionLoading || finalizeLoading) && <p style={{ color: 'var(--text-muted)', marginTop: '0.75rem' }}>Aplicando ajuste...</p>}
|
||||||
|
{extensionMessage && <p style={{ color: 'var(--success)', marginTop: '0.75rem' }}>{extensionMessage}</p>}
|
||||||
|
{extensionError && <p style={{ color: 'var(--danger)', marginTop: '0.75rem' }}>{extensionError}</p>}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="subscription-card">
|
||||||
|
<h3 className="subscription-section-title">Pagos</h3>
|
||||||
|
{details.payments.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>No hay pagos registrados para este usuario.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{details.payments.map((payment) => (
|
||||||
|
<div key={payment.id} className="payment-row">
|
||||||
|
<Info label="Payment ID" value={payment.id} long />
|
||||||
|
<Info label="Subscription ID" value={payment.subscriptionId} long />
|
||||||
|
<Info label="Monto" value={formatAmount(payment.amount)} />
|
||||||
|
<Info label="Fecha" value={formatDateTime(payment.paymentDate)} />
|
||||||
|
<Info label="Método" value={payment.paymentMethod || '-'} />
|
||||||
|
<Info label="Estado" value={payment.status} />
|
||||||
|
<Info label="Transaction ID" value={payment.transactionId || '-'} long />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style dangerouslySetInnerHTML={{__html: `
|
||||||
|
.subscription-card {
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.subscription-section-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.subscription-grid,
|
||||||
|
.payment-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
.payment-row {
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.subscription-label {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.subscription-value {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.subscription-value.long {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.extension-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.extension-date-action {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.extension-date-action input {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
}
|
||||||
|
.finalize-action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
.finalize-action h4 {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.finalize-action p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.finalize-action {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Info = ({ label, value, long = false }: { label: string; value: string; long?: boolean }) => (
|
||||||
|
<div>
|
||||||
|
<span className="subscription-label">{label}</span>
|
||||||
|
<span className={`subscription-value${long ? ' long' : ''}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Users, Server, Shield } from 'lucide-react';
|
import { Building2, CreditCard, RefreshCw, Server, Shield, Tags, Users } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
export const DashboardPage = () => {
|
export const DashboardPage = () => {
|
||||||
@@ -32,23 +32,61 @@ export const DashboardPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Usuarios</h3>
|
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Usuarios</h3>
|
||||||
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Administra perfiles, verifica cuentas, consulta organizaciones y realiza limpiezas seguras en la base de datos.</p>
|
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Administra perfiles, verifica cuentas, consulta organizaciones, suscripciones, pagos y herramientas de recálculo.</p>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1rem' }}>
|
||||||
|
<span className="dashboard-tag"><CreditCard size={14} /> Suscripciones</span>
|
||||||
|
<span className="dashboard-tag"><RefreshCw size={14} /> Recálculos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Card Companies */}
|
||||||
|
<Link to="/companies" style={{ display: 'block' }}>
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -5, scale: 1.02 }}
|
||||||
|
className="glass-panel"
|
||||||
|
style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(16, 185, 129, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--success)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||||
|
<Building2 size={30} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Organizaciones</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Consulta organizaciones, revisa estado operativo y administra bloqueos desde el panel central.</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Card Plans */}
|
||||||
|
<Link to="/plans" style={{ display: 'block' }}>
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -5, scale: 1.02 }}
|
||||||
|
className="glass-panel"
|
||||||
|
style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(245, 158, 11, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--warning)', border: '1px solid rgba(245, 158, 11, 0.2)' }}>
|
||||||
|
<Tags size={30} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Gestión de Planes</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Visualiza y edita precios, límites, descuentos, funciones y disponibilidad de los planes contratables.</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Card Wap Servers */}
|
{/* Card Wap Servers */}
|
||||||
<div style={{ opacity: 0.6 }}>
|
<Link to="/wapservers" style={{ display: 'block' }}>
|
||||||
<div className="glass-panel" style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', height: '100%' }}>
|
<motion.div whileHover={{ y: -5, scale: 1.02 }} className="glass-panel" style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', cursor: 'pointer', height: '100%' }}>
|
||||||
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(139, 92, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent)', border: '1px solid rgba(139, 92, 246, 0.2)' }}>
|
<div style={{ width: '60px', height: '60px', borderRadius: '16px', background: 'rgba(139, 92, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent)', border: '1px solid rgba(139, 92, 246, 0.2)' }}>
|
||||||
<Server size={30} />
|
<Server size={30} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem' }}>Servidores WAP</h3>
|
<h3 style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem', color: 'var(--text-main)' }}>Servidores WAP</h3>
|
||||||
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>[Próximamente] Monitoreo y configuración de servidores de WhatsApp puente.</p>
|
<p style={{ color: 'var(--text-muted)', lineHeight: '1.5' }}>Consulta servidores de WhatsApp puente y su estado operativo.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
{/* Card Config */}
|
{/* Card Config */}
|
||||||
<div style={{ opacity: 0.6 }}>
|
<div style={{ opacity: 0.6 }}>
|
||||||
@@ -64,6 +102,20 @@ export const DashboardPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<style dangerouslySetInnerHTML={{__html: `
|
||||||
|
.dashboard-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
`}} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 React, { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Search, Edit2, Trash2, CheckCircle, XCircle, Building2, ChevronUp, ChevronDown, RotateCcw } from 'lucide-react';
|
import { Search, Edit2, Trash2, CheckCircle, XCircle, Building2, ChevronUp, ChevronDown, RotateCcw, RefreshCw, MoreVertical, CreditCard } from 'lucide-react';
|
||||||
import { fetchUsers, deleteUser, setVerifiedStatus } from '../api/sysadmin';
|
import { fetchUsers, deleteUser, setVerifiedStatus } from '../api/sysadmin';
|
||||||
import { EditUserModal } from '../components/ui/EditUserModal';
|
import { EditUserModal } from '../components/ui/EditUserModal';
|
||||||
import { OrganizationsModal } from '../components/ui/OrganizationsModal';
|
import { OrganizationsModal } from '../components/ui/OrganizationsModal';
|
||||||
|
import { RecalculationToolsModal } from '../components/ui/RecalculationToolsModal';
|
||||||
|
import { SubscriptionDetailsModal } from '../components/ui/SubscriptionDetailsModal';
|
||||||
|
import { formatDate } from '../utils/formatters';
|
||||||
|
|
||||||
export const UsersPage = () => {
|
export const UsersPage = () => {
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [recalculationUserId, setRecalculationUserId] = useState('');
|
||||||
|
const [openActionsUserId, setOpenActionsUserId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
@@ -23,6 +28,8 @@ export const UsersPage = () => {
|
|||||||
// Modals state
|
// Modals state
|
||||||
const [editingUser, setEditingUser] = useState<any>(null);
|
const [editingUser, setEditingUser] = useState<any>(null);
|
||||||
const [orgUser, setOrgUser] = useState<any>(null);
|
const [orgUser, setOrgUser] = useState<any>(null);
|
||||||
|
const [subscriptionUser, setSubscriptionUser] = useState<any>(null);
|
||||||
|
const [recalculationToolsOpen, setRecalculationToolsOpen] = useState(false);
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -46,6 +53,15 @@ export const UsersPage = () => {
|
|||||||
loadUsers();
|
loadUsers();
|
||||||
}, [page, filters.sortBy, filters.sortOrder, filters.isVerified]);
|
}, [page, filters.sortBy, filters.sortOrder, filters.isVerified]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openActionsUserId) return;
|
||||||
|
|
||||||
|
const closeActionsMenu = () => setOpenActionsUserId(null);
|
||||||
|
document.addEventListener('click', closeActionsMenu);
|
||||||
|
|
||||||
|
return () => document.removeEventListener('click', closeActionsMenu);
|
||||||
|
}, [openActionsUserId]);
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -107,10 +123,23 @@ export const UsersPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openRecalculationTools = (userId = '') => {
|
||||||
|
setRecalculationUserId(userId);
|
||||||
|
setRecalculationToolsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runRowAction = (action: () => void) => {
|
||||||
|
setOpenActionsUserId(null);
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in users-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in users-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
|
||||||
<h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Gestión de Usuarios</h1>
|
<h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Gestión de Usuarios</h1>
|
||||||
|
<button className="btn-secondary" onClick={() => openRecalculationTools()} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<RefreshCw size={18} /> Recalcular
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters Bar */}
|
{/* Filters Bar */}
|
||||||
@@ -179,9 +208,9 @@ export const UsersPage = () => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center' }}>Cargando...</td></tr>
|
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center' }}>Cargando...</td></tr>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron usuarios</td></tr>
|
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron usuarios</td></tr>
|
||||||
) : (
|
) : (
|
||||||
users.map(u => (
|
users.map(u => (
|
||||||
<tr key={u._id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="table-row-hover">
|
<tr key={u._id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="table-row-hover">
|
||||||
@@ -205,13 +234,43 @@ export const UsersPage = () => {
|
|||||||
{u.verificationCode || '-'}
|
{u.verificationCode || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
<td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
||||||
{new Date(u.creationDate).toLocaleDateString()}
|
{formatDate(u.creationDate)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'right' }}>
|
<td style={{ padding: '1rem', textAlign: 'right' }}>
|
||||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
|
<div className="actions-menu-wrapper" onClick={(e) => e.stopPropagation()}>
|
||||||
<button className="icon-btn" title="Ver Organizaciones" onClick={() => setOrgUser(u)}><Building2 size={18} /></button>
|
<button
|
||||||
<button className="icon-btn" title="Editar Perfil" onClick={() => setEditingUser(u)}><Edit2 size={18} /></button>
|
className="icon-btn"
|
||||||
<button className="icon-btn danger" title="Eliminar" onClick={() => handleDelete(u)}><Trash2 size={18} /></button>
|
title="Acciones"
|
||||||
|
aria-label={`Acciones para ${u.email}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={openActionsUserId === u._id}
|
||||||
|
onClick={() => setOpenActionsUserId(openActionsUserId === u._id ? null : u._id)}
|
||||||
|
>
|
||||||
|
<MoreVertical size={18} />
|
||||||
|
</button>
|
||||||
|
{openActionsUserId === u._id && (
|
||||||
|
<div className="actions-menu" role="menu">
|
||||||
|
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setOrgUser(u))}>
|
||||||
|
<Building2 size={16} /> Ver organizaciones
|
||||||
|
</button>
|
||||||
|
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setSubscriptionUser(u))}>
|
||||||
|
<CreditCard size={16} /> Ver suscripción
|
||||||
|
</button>
|
||||||
|
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => openRecalculationTools(u._id))}>
|
||||||
|
<RefreshCw size={16} /> Recalcular uso del plan
|
||||||
|
</button>
|
||||||
|
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleToggleVerified(u))}>
|
||||||
|
{u.verificated ? <XCircle size={16} /> : <CheckCircle size={16} />}
|
||||||
|
{u.verificated ? 'Marcar como no verificado' : 'Marcar como verificado'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setEditingUser(u))}>
|
||||||
|
<Edit2 size={16} /> Editar perfil
|
||||||
|
</button>
|
||||||
|
<button type="button" className="actions-menu-item danger" role="menuitem" onClick={() => runRowAction(() => handleDelete(u))}>
|
||||||
|
<Trash2 size={16} /> Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -261,6 +320,51 @@ export const UsersPage = () => {
|
|||||||
.icon-btn.danger:hover {
|
.icon-btn.danger:hover {
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
}
|
}
|
||||||
|
.actions-menu-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.actions-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 30;
|
||||||
|
min-width: 230px;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #111827;
|
||||||
|
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.45);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.actions-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.actions-menu-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.actions-menu-item.danger {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.actions-menu-item.danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.18);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: rgba(255,255,255,0.05);
|
background: rgba(255,255,255,0.05);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
@@ -293,6 +397,8 @@ export const UsersPage = () => {
|
|||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
{editingUser && <EditUserModal user={editingUser} onClose={() => setEditingUser(null)} onSaved={loadUsers} />}
|
{editingUser && <EditUserModal user={editingUser} onClose={() => setEditingUser(null)} onSaved={loadUsers} />}
|
||||||
{orgUser && <OrganizationsModal user={orgUser} onClose={() => setOrgUser(null)} />}
|
{orgUser && <OrganizationsModal user={orgUser} onClose={() => setOrgUser(null)} />}
|
||||||
|
{subscriptionUser && <SubscriptionDetailsModal user={subscriptionUser} onClose={() => setSubscriptionUser(null)} />}
|
||||||
|
{recalculationToolsOpen && <RecalculationToolsModal initialUserId={recalculationUserId} onClose={() => setRecalculationToolsOpen(false)} />}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 usersRoutes from "./routes/users.routes";
|
||||||
import companiesRoutes from "./routes/companies.routes";
|
import companiesRoutes from "./routes/companies.routes";
|
||||||
import servicesRoutes from "./routes/services.routes";
|
import servicesRoutes from "./routes/services.routes";
|
||||||
|
import plansRoutes from "./routes/plans.routes";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -16,5 +17,6 @@ app.use("/wap", wapRoutes);
|
|||||||
app.use("/users", usersRoutes);
|
app.use("/users", usersRoutes);
|
||||||
app.use("/companies", companiesRoutes);
|
app.use("/companies", companiesRoutes);
|
||||||
app.use("/services", servicesRoutes);
|
app.use("/services", servicesRoutes);
|
||||||
|
app.use("/plans", plansRoutes);
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { PlansService } from "../services/plans.service";
|
||||||
|
import { SysAdminUpdatePlanParams } from "../models/Plans.Model";
|
||||||
|
|
||||||
|
const plansService = new PlansService();
|
||||||
|
|
||||||
|
export class PlansController {
|
||||||
|
public listPlans = async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const plans = await plansService.listPlans();
|
||||||
|
res.json(plans);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error listing plans:", error?.response?.data || error);
|
||||||
|
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public updatePlan = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const data: SysAdminUpdatePlanParams = req.body;
|
||||||
|
const result = await plansService.updatePlan(data);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error updating plan:", error?.response?.data || error);
|
||||||
|
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
SysAdminSetVerifiedParams,
|
SysAdminSetVerifiedParams,
|
||||||
SysAdminDeleteUserParams,
|
SysAdminDeleteUserParams,
|
||||||
SysAdminOrganizationsStatusParams,
|
SysAdminOrganizationsStatusParams,
|
||||||
|
SysAdminRecalculatePlanUsageCycleParams,
|
||||||
|
SysAdminExtendUserSubscriptionParams,
|
||||||
|
SysAdminFinalizeUserSubscriptionParams,
|
||||||
|
SysAdminUserSubscriptionDetailsParams,
|
||||||
} from "../models/Users.Model";
|
} from "../models/Users.Model";
|
||||||
|
|
||||||
const usersService = new UsersService();
|
const usersService = new UsersService();
|
||||||
@@ -65,4 +69,48 @@ export class UsersController {
|
|||||||
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public recalculatePlanUsageCycle = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const data: SysAdminRecalculatePlanUsageCycleParams = req.body;
|
||||||
|
const result = await usersService.recalculatePlanUsageCycle(data);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error recalculating plan usage cycle:", error?.response?.data || error);
|
||||||
|
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public subscriptionDetails = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const data: SysAdminUserSubscriptionDetailsParams = req.body;
|
||||||
|
const result = await usersService.getSubscriptionDetails(data);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error fetching subscription details:", error?.response?.data || error);
|
||||||
|
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public extendSubscription = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const data: SysAdminExtendUserSubscriptionParams = req.body;
|
||||||
|
const result = await usersService.extendSubscription(data);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error extending subscription:", error?.response?.data || error);
|
||||||
|
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public finalizeSubscription = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const data: SysAdminFinalizeUserSubscriptionParams = req.body;
|
||||||
|
const result = await usersService.finalizeSubscription(data);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error finalizing subscription:", error?.response?.data || error);
|
||||||
|
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }[];
|
clientIn: { companyId: string; companyName: string }[];
|
||||||
collaboratorIn: { companyId: string; companyName: string }[];
|
collaboratorIn: { companyId: string; companyName: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SysAdminRecalculatePlanUsageCycleParams = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminRecalculatePlanUsageCycleResult = {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
planId: string;
|
||||||
|
cycleStart: string;
|
||||||
|
cycleEnd: string;
|
||||||
|
organizationsCount: number;
|
||||||
|
employeesCount: number;
|
||||||
|
servicesCount: number;
|
||||||
|
clientsCount: number;
|
||||||
|
repeatsCount: number;
|
||||||
|
appointmentsCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminUserSubscriptionDetailsParams = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminExtendUserSubscriptionParams = {
|
||||||
|
userId: string;
|
||||||
|
mode: "add_months" | "set_end_date";
|
||||||
|
months?: 1 | 2;
|
||||||
|
endDate?: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminExtendUserSubscriptionResult = {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
previousEndDate: string;
|
||||||
|
newEndDate: string;
|
||||||
|
paymentId: string;
|
||||||
|
transactionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminFinalizeUserSubscriptionParams = {
|
||||||
|
userId: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminFinalizeUserSubscriptionResult = {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
previousEndDate: string;
|
||||||
|
newEndDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminUserSubscriptionDetailsResult = {
|
||||||
|
userId: string;
|
||||||
|
currentSubscription: {
|
||||||
|
id: string;
|
||||||
|
plan: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
price: number;
|
||||||
|
} | null;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
isActive: boolean;
|
||||||
|
mpStatus?: string;
|
||||||
|
billingMonths?: number;
|
||||||
|
pendingPaymentType?: "extension" | "upgrade";
|
||||||
|
pendingPaymentPreferenceId?: string;
|
||||||
|
lastPaymentStatus?: string;
|
||||||
|
} | null;
|
||||||
|
payments: {
|
||||||
|
id: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
amount: number;
|
||||||
|
paymentDate: string;
|
||||||
|
paymentMethod?: string;
|
||||||
|
status: string;
|
||||||
|
transactionId?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -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("/update", usersController.updateProfile);
|
||||||
router.post("/set-verified", usersController.setVerifiedStatus);
|
router.post("/set-verified", usersController.setVerifiedStatus);
|
||||||
router.post("/organizations-status", usersController.organizationsStatus);
|
router.post("/organizations-status", usersController.organizationsStatus);
|
||||||
|
router.post("/recalculate-plan-usage-cycle", usersController.recalculatePlanUsageCycle);
|
||||||
|
router.post("/subscription-details", usersController.subscriptionDetails);
|
||||||
|
router.post("/extend-subscription", usersController.extendSubscription);
|
||||||
|
router.post("/finalize-subscription", usersController.finalizeSubscription);
|
||||||
router.post("/delete", usersController.deleteUser);
|
router.post("/delete", usersController.deleteUser);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -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,
|
SysAdminDeleteUserParams,
|
||||||
SysAdminOrganizationsStatusParams,
|
SysAdminOrganizationsStatusParams,
|
||||||
SysAdminUserOrganizationsResult,
|
SysAdminUserOrganizationsResult,
|
||||||
|
SysAdminRecalculatePlanUsageCycleParams,
|
||||||
|
SysAdminRecalculatePlanUsageCycleResult,
|
||||||
|
SysAdminExtendUserSubscriptionParams,
|
||||||
|
SysAdminExtendUserSubscriptionResult,
|
||||||
|
SysAdminFinalizeUserSubscriptionParams,
|
||||||
|
SysAdminFinalizeUserSubscriptionResult,
|
||||||
|
SysAdminUserSubscriptionDetailsParams,
|
||||||
|
SysAdminUserSubscriptionDetailsResult,
|
||||||
} from "../models/Users.Model";
|
} from "../models/Users.Model";
|
||||||
|
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
@@ -72,4 +80,60 @@ export class UsersService {
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async recalculatePlanUsageCycle(
|
||||||
|
data: SysAdminRecalculatePlanUsageCycleParams
|
||||||
|
): Promise<SysAdminRecalculatePlanUsageCycleResult> {
|
||||||
|
const nonce = await getSysAdminNonce();
|
||||||
|
const postData = { ...data, ...{ payload: getPayload(nonce) } };
|
||||||
|
|
||||||
|
const response = await axios.post(`${getApiHost()}/sysadmin/plan-usage-cycle/recalculate-user`, postData, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSubscriptionDetails(
|
||||||
|
data: SysAdminUserSubscriptionDetailsParams
|
||||||
|
): Promise<SysAdminUserSubscriptionDetailsResult> {
|
||||||
|
const nonce = await getSysAdminNonce();
|
||||||
|
const postData = { ...data, ...{ payload: getPayload(nonce) } };
|
||||||
|
|
||||||
|
const response = await axios.post(`${getApiHost()}/sysadmin/subscriptions/user-details`, postData, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async extendSubscription(
|
||||||
|
data: SysAdminExtendUserSubscriptionParams
|
||||||
|
): Promise<SysAdminExtendUserSubscriptionResult> {
|
||||||
|
const nonce = await getSysAdminNonce();
|
||||||
|
const postData = { ...data, ...{ payload: getPayload(nonce) } };
|
||||||
|
|
||||||
|
const response = await axios.post(`${getApiHost()}/sysadmin/subscriptions/extend-user`, postData, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async finalizeSubscription(
|
||||||
|
data: SysAdminFinalizeUserSubscriptionParams
|
||||||
|
): Promise<SysAdminFinalizeUserSubscriptionResult> {
|
||||||
|
const nonce = await getSysAdminNonce();
|
||||||
|
const postData = { ...data, ...{ payload: getPayload(nonce) } };
|
||||||
|
|
||||||
|
const response = await axios.post(`${getApiHost()}/sysadmin/subscriptions/finalize-user`, postData, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ NEXT_PUBLIC_DATAGRID_PAGE_SIZE = 10
|
|||||||
NEXT_PUBLIC_SLOGAN = Tu tiempo en orden.
|
NEXT_PUBLIC_SLOGAN = Tu tiempo en orden.
|
||||||
|
|
||||||
NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar
|
NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar
|
||||||
|
NEXT_PUBLIC_INFO_EMAIL = info@turnosxpress.com.ar
|
||||||
@@ -12,3 +12,4 @@ NEXT_PUBLIC_DATAGRID_PAGE_SIZE = 10
|
|||||||
NEXT_PUBLIC_SLOGAN = Tu tiempo en orden.
|
NEXT_PUBLIC_SLOGAN = Tu tiempo en orden.
|
||||||
|
|
||||||
NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar
|
NEXT_PUBLIC_BASE_URL = https://turnosxpress.com.ar
|
||||||
|
NEXT_PUBLIC_INFO_EMAIL = info@turnosxpress.com.ar
|
||||||
Generated
+578
-545
File diff suppressed because it is too large
Load Diff
+24
-24
@@ -9,44 +9,44 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^6.1.5",
|
"@mui/icons-material": "^6.5.0",
|
||||||
"@mui/material": "^6.1.5",
|
"@mui/material": "^6.5.0",
|
||||||
"@mui/x-charts": "^7.23.2",
|
"@mui/x-charts": "^7.29.1",
|
||||||
"@mui/x-date-pickers": "^7.22.2",
|
"@mui/x-date-pickers": "^7.29.4",
|
||||||
"@react-oauth/google": "^0.12.1",
|
"@react-oauth/google": "^0.13.5",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.18.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"currency.js": "^2.0.4",
|
"currency.js": "^2.0.4",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.21",
|
||||||
"dompurify": "^3.2.7",
|
"dompurify": "^3.4.12",
|
||||||
"driver.js": "^1.3.6",
|
"driver.js": "^1.7.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"motion": "^11.12.0",
|
"motion": "^11.18.2",
|
||||||
"next": "^15.5.7",
|
"next": "^15.5.20",
|
||||||
"pdfmake": "^0.2.20",
|
"pdfmake": "^0.3.11",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-avatar": "^5.0.3",
|
"react-avatar": "^5.0.4",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.8.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-quill-new": "^3.6.0",
|
"react-quill-new": "^3.8.3",
|
||||||
"react-schedule-view": "^1.1.2",
|
"react-schedule-view": "^1.1.2",
|
||||||
"react-share": "^5.2.2",
|
"react-share": "^5.3.0",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.3",
|
||||||
"yup": "^1.4.0",
|
"yup": "^1.7.1",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@types/leaflet": "^1.9.15",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pdfmake": "^0.2.12",
|
"@types/pdfmake": "^0.2.12",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "15.0.1",
|
"eslint-config-next": "15.5.20",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export type FindAppointmentsParams = {
|
|||||||
clientId?: string;
|
clientId?: string;
|
||||||
start?: string;
|
start?: string;
|
||||||
dateDay?: string;
|
dateDay?: string;
|
||||||
|
sessionUser?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FindAppointmentsByUserParams = {
|
export type FindAppointmentsByUserParams = {
|
||||||
@@ -151,6 +152,7 @@ export interface AppointmentEventByClient {
|
|||||||
serviceName: string;
|
serviceName: string;
|
||||||
serviceDescription: string;
|
serviceDescription: string;
|
||||||
collaboratorId: string;
|
collaboratorId: string;
|
||||||
|
collaboratorUserId: string;
|
||||||
collaboratorName: string;
|
collaboratorName: string;
|
||||||
collaboratorAvatar: string;
|
collaboratorAvatar: string;
|
||||||
appointmentDate: string;
|
appointmentDate: string;
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ export interface IEmployee {
|
|||||||
roles?: EmployeeRoles[];
|
roles?: EmployeeRoles[];
|
||||||
guestOk?: boolean;
|
guestOk?: boolean;
|
||||||
hostOk?: boolean;
|
hostOk?: boolean;
|
||||||
|
removed?: boolean;
|
||||||
|
removedAt?: Date;
|
||||||
|
profileSnapshot?: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyEmployeesView {
|
export interface CompanyEmployeesView {
|
||||||
@@ -38,6 +46,14 @@ export interface CompanyEmployeesView {
|
|||||||
hostOk: boolean;
|
hostOk: boolean;
|
||||||
fullOk: boolean;
|
fullOk: boolean;
|
||||||
calendarColor: string;
|
calendarColor: string;
|
||||||
|
removed?: boolean;
|
||||||
|
removedAt?: Date;
|
||||||
|
profileSnapshot?: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateEmployeeParams = {
|
export type CreateEmployeeParams = {
|
||||||
@@ -66,6 +82,13 @@ export type ValidateEmployeeParams = {
|
|||||||
sessionUser: string;
|
sessionUser: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RemoveCollaboratorParams = {
|
||||||
|
employeeId: string;
|
||||||
|
companyId: string;
|
||||||
|
replacementEmployeeId?: string;
|
||||||
|
sessionUser: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type FindEmployeesByIdParams = {
|
export type FindEmployeesByIdParams = {
|
||||||
id?: string;
|
id?: string;
|
||||||
};
|
};
|
||||||
@@ -117,6 +140,8 @@ export type ScheduleItem = {
|
|||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
serviceScope?: "all" | "specific";
|
||||||
|
serviceIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FindSchedulesParams = {
|
export type FindSchedulesParams = {
|
||||||
|
|||||||
@@ -48,6 +48,23 @@ export type GetSubscriptionInitPointParams = {
|
|||||||
sessionUser: string;
|
sessionUser: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VerifyPendingPlanPaymentParams = {
|
||||||
|
sessionUser: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VerifyPendingPlanPaymentResponse = {
|
||||||
|
status: "approved" | "pending" | "not_found" | "rejected" | "failed" | "cancelled";
|
||||||
|
approved: boolean;
|
||||||
|
message: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
paymentId?: string;
|
||||||
|
paymentType?: "new" | "extension" | "upgrade";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LastPlanPaymentStatus = "rejected" | "failed" | "cancelled";
|
||||||
|
export type LastPlanPaymentType = "new" | "extension" | "upgrade";
|
||||||
|
export type DowngradeReason = "expired";
|
||||||
|
|
||||||
export interface GetSuscriptionInitPointResponse {
|
export interface GetSuscriptionInitPointResponse {
|
||||||
init_point: string;
|
init_point: string;
|
||||||
}
|
}
|
||||||
@@ -61,6 +78,18 @@ export interface ISubscriptionInfo {
|
|||||||
autoRenew?: boolean;
|
autoRenew?: boolean;
|
||||||
mpStatus?: string;
|
mpStatus?: string;
|
||||||
mpDateCreated?: Date;
|
mpDateCreated?: Date;
|
||||||
|
pendingPaymentInitPoint?: string;
|
||||||
|
pendingPaymentPreferenceId?: string;
|
||||||
|
pendingPaymentType?: "extension" | "upgrade";
|
||||||
|
lastPaymentStatus?: LastPlanPaymentStatus;
|
||||||
|
lastPaymentPreferenceId?: string;
|
||||||
|
lastPaymentAt?: Date;
|
||||||
|
lastPaymentType?: LastPlanPaymentType;
|
||||||
|
downgradedFromPlanId?: string;
|
||||||
|
downgradedFromPlanName?: string;
|
||||||
|
downgradedFromPlanCode?: string;
|
||||||
|
downgradedAt?: Date;
|
||||||
|
downgradeReason?: DowngradeReason;
|
||||||
}
|
}
|
||||||
export const DefaultSubscriptionModel: ISubscriptionInfo = {
|
export const DefaultSubscriptionModel: ISubscriptionInfo = {
|
||||||
id: "",
|
id: "",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export default function ClientDashboardLayout({
|
|||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
|
|
||||||
const [client, setClient] = useState<OrganizationClientView | null>(null);
|
const [client, setClient] = useState<OrganizationClientView | null>(null);
|
||||||
|
const [isCheckingAccess, setIsCheckingAccess] = useState(true);
|
||||||
const [clientName, setClientName] = useState<string>("");
|
const [clientName, setClientName] = useState<string>("");
|
||||||
const [, setClientEmail] = useState<string>("");
|
const [, setClientEmail] = useState<string>("");
|
||||||
const [clientAvatar, setClientAvatar] = useState<string>("");
|
const [clientAvatar, setClientAvatar] = useState<string>("");
|
||||||
@@ -106,6 +107,7 @@ export default function ClientDashboardLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||||
|
setIsCheckingAccess(true);
|
||||||
|
|
||||||
findClientById({
|
findClientById({
|
||||||
id: clientId,
|
id: clientId,
|
||||||
@@ -119,9 +121,15 @@ export default function ClientDashboardLayout({
|
|||||||
setClientAvatar(client.avatar);
|
setClientAvatar(client.avatar);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (error?.desc?.includes("No tienes permisos") || error?.desc?.includes("no tiene permisos")) {
|
||||||
|
setClient(null);
|
||||||
|
goTo("/admin/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert.showError(error.format());
|
alert.showError(error.format());
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
setIsCheckingAccess(false);
|
||||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -131,7 +139,8 @@ export default function ClientDashboardLayout({
|
|||||||
<Content>
|
<Content>
|
||||||
<Suspense fallback={<SuspenseLoading />}>
|
<Suspense fallback={<SuspenseLoading />}>
|
||||||
<Secure>
|
<Secure>
|
||||||
<SectionContainer style={{ top: "70px" }}>
|
{isCheckingAccess && <SuspenseLoading />}
|
||||||
|
{client && <SectionContainer style={{ top: "70px" }}>
|
||||||
<div className={style.clientProfileHeader}>
|
<div className={style.clientProfileHeader}>
|
||||||
<Avatar
|
<Avatar
|
||||||
name="userProfileAvatar"
|
name="userProfileAvatar"
|
||||||
@@ -195,7 +204,7 @@ export default function ClientDashboardLayout({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Menu />
|
<Menu />
|
||||||
</SectionContainer>
|
</SectionContainer>}
|
||||||
</Secure>
|
</Secure>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
+6
-1
@@ -193,8 +193,8 @@ export default function OrganizationProfile() {
|
|||||||
const filter = useFilterStore();
|
const filter = useFilterStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const appointmentsView = useAppointmentsViewStore();
|
|
||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
|
const appointmentsView = useAppointmentsViewStore();
|
||||||
|
|
||||||
const [windowWidth] = useWindowSize();
|
const [windowWidth] = useWindowSize();
|
||||||
|
|
||||||
@@ -317,6 +317,7 @@ export default function OrganizationProfile() {
|
|||||||
employeeId: appointmentsView.collaboratorId
|
employeeId: appointmentsView.collaboratorId
|
||||||
? appointmentsView.collaboratorId
|
? appointmentsView.collaboratorId
|
||||||
: collaboratorId,
|
: collaboratorId,
|
||||||
|
sessionUser: SessionInfo.userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredFilter = Object.fromEntries(
|
const filteredFilter = Object.fromEntries(
|
||||||
@@ -338,6 +339,10 @@ export default function OrganizationProfile() {
|
|||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (error?.desc?.includes("No tienes permisos") || error?.desc?.includes("no tiene permisos")) {
|
||||||
|
goTo("/admin/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert.showError(error.format());
|
alert.showError(error.format());
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -205,13 +205,13 @@ export default function ClientsGrid() {
|
|||||||
<span className={pageStyle.bannedBadge}>Baneado</span>
|
<span className={pageStyle.bannedBadge}>Baneado</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className={pageStyle.clientDetail}>
|
<div className={pageStyle.clientDetail}>
|
||||||
<div className={pageStyle.iconWrapper}>
|
<div className={pageStyle.iconWrapper}>
|
||||||
<EmailOutlinedIcon sx={{ fontSize: 14 }} />
|
<EmailOutlinedIcon sx={{ fontSize: 14 }} />
|
||||||
</div>
|
</div>
|
||||||
{client.email || "Sin email"}
|
{client.email || "Sin email"}
|
||||||
</p>
|
</div>
|
||||||
<p className={pageStyle.clientPhone}>
|
<div className={pageStyle.clientPhone}>
|
||||||
<div className={pageStyle.iconWrapper}>
|
<div className={pageStyle.iconWrapper}>
|
||||||
<PhoneOutlinedIcon sx={{ fontSize: 14 }} />
|
<PhoneOutlinedIcon sx={{ fontSize: 14 }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +219,7 @@ export default function ClientsGrid() {
|
|||||||
? `${client.phoneCountryCode || ''} ${client.phoneAreaCode} - ${client.phoneNumber}`
|
? `${client.phoneCountryCode || ''} ${client.phoneAreaCode} - ${client.phoneNumber}`
|
||||||
: "Sin teléfono"
|
: "Sin teléfono"
|
||||||
}
|
}
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+5
@@ -14,6 +14,7 @@ import {
|
|||||||
CollaboratorSchedulesView,
|
CollaboratorSchedulesView,
|
||||||
UpdateScheduleParams,
|
UpdateScheduleParams,
|
||||||
UpdateEmployeeParams,
|
UpdateEmployeeParams,
|
||||||
|
RemoveCollaboratorParams,
|
||||||
} from "@models/Collaborators.model";
|
} from "@models/Collaborators.model";
|
||||||
import { CollaboratorView, FindCollaboratorByEmailParams } from "@models/User.model";
|
import { CollaboratorView, FindCollaboratorByEmailParams } from "@models/User.model";
|
||||||
import { ApiVoidResult } from "@models/Api.VoidResult.type";
|
import { ApiVoidResult } from "@models/Api.VoidResult.type";
|
||||||
@@ -128,3 +129,7 @@ export const updateSchedulesOverrides = async (data: UpdateSchedulesOverridesPar
|
|||||||
export const deleteSchedulesOverrides = async (data: DeleteSchedulesOverridesParams): Promise<ApiVoidResult> => {
|
export const deleteSchedulesOverrides = async (data: DeleteSchedulesOverridesParams): Promise<ApiVoidResult> => {
|
||||||
return ApiRequest.post<ApiVoidResult>("schedules-overrides/delete", data);
|
return ApiRequest.post<ApiVoidResult>("schedules-overrides/delete", data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const removeCollaborator = async (data: RemoveCollaboratorParams): Promise<ApiVoidResult> => {
|
||||||
|
return ApiRequest.post<ApiVoidResult>("employees/remove-collaborator", data);
|
||||||
|
};
|
||||||
|
|||||||
+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";
|
"use client";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import Location from "@components/Location/Location";
|
import Location from "@components/Location/Location";
|
||||||
import { useLocationStore } from "@store/Location.Store";
|
import { useLocationStore } from "@store/Location.Store";
|
||||||
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
|
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
|
||||||
import { useSessionStore } from "@store/Sesion.Store";
|
import { useSessionStore } from "@store/Sesion.Store";
|
||||||
import { useAlert } from "@store/Alert.Store";
|
import { useAlert } from "@store/Alert.Store";
|
||||||
|
import { useConfirmStore } from "@store/Confirm.Store";
|
||||||
import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined";
|
import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined";
|
||||||
import LocalPoliceOutlinedIcon from "@mui/icons-material/LocalPoliceOutlined";
|
import LocalPoliceOutlinedIcon from "@mui/icons-material/LocalPoliceOutlined";
|
||||||
import MaterialAlert from "@mui/material/Alert";
|
import MaterialAlert from "@mui/material/Alert";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import MuiButton from "@mui/material/Button";
|
||||||
|
import Radio from "@mui/material/Radio";
|
||||||
|
import RadioGroup from "@mui/material/RadioGroup";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import FormControl from "@mui/material/FormControl";
|
||||||
|
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
||||||
|
import { ThemeProvider } from "@emotion/react";
|
||||||
import { PopoverPicker } from "@core/app/components/ColorPicker/ColorPicker";
|
import { PopoverPicker } from "@core/app/components/ColorPicker/ColorPicker";
|
||||||
import { findCollaboratorsById, updateCollaborator } from "../../Collaborators.Service";
|
import {
|
||||||
|
findCollaboratorsById,
|
||||||
|
updateCollaborator,
|
||||||
|
loadCollaboratorsByCompanyId,
|
||||||
|
removeCollaborator,
|
||||||
|
} from "../../Collaborators.Service";
|
||||||
import { ApiError } from "@models/Server.Error.model";
|
import { ApiError } from "@models/Server.Error.model";
|
||||||
import { UpdateEmployeeParams } from "@models/Collaborators.model";
|
import { UpdateEmployeeParams, CompanyEmployeesView, EmployeeRoles } from "@models/Collaborators.model";
|
||||||
|
import { useNavigation } from "@hooks/goto";
|
||||||
|
import style from "./edit.module.css";
|
||||||
|
import PersonOffOutlinedIcon from "@mui/icons-material/PersonOffOutlined";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,9 +49,19 @@ export default function OrganizationEdit() {
|
|||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
|
const confirm = useConfirmStore();
|
||||||
|
const { goTo } = useNavigation();
|
||||||
|
|
||||||
const [collaboratorName, setCollaboratorName] = useState<string>("");
|
const [collaboratorName, setCollaboratorName] = useState<string>("");
|
||||||
|
const [collaboratorUserId, setCollaboratorUserId] = useState<string>("");
|
||||||
const [collaboratorColor, setCollaboratorColor] = useState<string>("");
|
const [collaboratorColor, setCollaboratorColor] = useState<string>("");
|
||||||
|
const [collaborators, setCollaborators] = useState<CompanyEmployeesView[]>([]);
|
||||||
|
const [isOwner, setIsOwner] = useState<boolean>(false);
|
||||||
|
const [replacementDialogOpen, setReplacementDialogOpen] = useState<boolean>(false);
|
||||||
|
const [selectedReplacement, setSelectedReplacement] = useState<string>("");
|
||||||
|
const [removalCollapsed, setRemovalCollapsed] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const toggleRemoval = useCallback(() => setRemovalCollapsed((prev) => !prev), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
@@ -58,6 +90,7 @@ export default function OrganizationEdit() {
|
|||||||
|
|
||||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||||
loadCollaborator();
|
loadCollaborator();
|
||||||
|
loadCollaborators();
|
||||||
}, [collaboratorName]);
|
}, [collaboratorName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -83,6 +116,21 @@ export default function OrganizationEdit() {
|
|||||||
}
|
}
|
||||||
}, [eventHandler.eventType]);
|
}, [eventHandler.eventType]);
|
||||||
|
|
||||||
|
const loadCollaborators = () => {
|
||||||
|
loadCollaboratorsByCompanyId({ companyId: id })
|
||||||
|
.then((list) => {
|
||||||
|
setCollaborators(list);
|
||||||
|
const current = list.find((c) => c.id === cid);
|
||||||
|
setIsOwner(
|
||||||
|
current !== undefined &&
|
||||||
|
current.roles.includes(EmployeeRoles.OWNER)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Non-critical — removal button still works, server validates
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadCollaborator = () => {
|
const loadCollaborator = () => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||||
findCollaboratorsById({ id: cid })
|
findCollaboratorsById({ id: cid })
|
||||||
@@ -92,6 +140,7 @@ export default function OrganizationEdit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCollaboratorName(collaborator.fullName);
|
setCollaboratorName(collaborator.fullName);
|
||||||
|
setCollaboratorUserId(collaborator.userId);
|
||||||
setCollaboratorColor(collaborator.calendarColor);
|
setCollaboratorColor(collaborator.calendarColor);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -102,6 +151,65 @@ export default function OrganizationEdit() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveClick = () => {
|
||||||
|
if (isOwner) {
|
||||||
|
alert.showError("No se puede eliminar al propietario de la organización");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(SessionInfo.userId) === collaboratorUserId) {
|
||||||
|
alert.showError("No puedes eliminarte a ti mismo de la organización");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = collaborators.filter(
|
||||||
|
(c) => c.id !== cid
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
setReplacementDialogOpen(true);
|
||||||
|
setSelectedReplacement("");
|
||||||
|
} else {
|
||||||
|
confirm.show(
|
||||||
|
"¿Estás seguro de que deseas remover este colaborador de la organización?",
|
||||||
|
handleConfirmRemove,
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmReplace = () => {
|
||||||
|
setReplacementDialogOpen(false);
|
||||||
|
confirm.show(
|
||||||
|
"¿Estás seguro de que deseas remover este colaborador de la organización? Los turnos futuros serán transferidos al colaborador seleccionado.",
|
||||||
|
handleConfirmRemove,
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmRemove = () => {
|
||||||
|
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||||
|
|
||||||
|
removeCollaborator({
|
||||||
|
employeeId: cid,
|
||||||
|
companyId: id,
|
||||||
|
sessionUser: SessionInfo.userId,
|
||||||
|
...(selectedReplacement
|
||||||
|
? { replacementEmployeeId: selectedReplacement }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
alert.showSuccess("Colaborador removido correctamente");
|
||||||
|
goTo("/admin/org/profile/" + id + "/collaborators");
|
||||||
|
})
|
||||||
|
.catch((error: ApiError) => {
|
||||||
|
alert.showError(error.format());
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Location />
|
<Location />
|
||||||
@@ -119,7 +227,124 @@ export default function OrganizationEdit() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ marginBottom: "10px" }}> </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 { useConfirmStore } from "@store/Confirm.Store";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import MaterialAlert from "@mui/material/Alert";
|
import MaterialAlert from "@mui/material/Alert";
|
||||||
import { ScheduleItem } from "@core/Models/Collaborators.model";
|
import { CollaboratorServiceItems, ScheduleItem } from "@core/Models/Collaborators.model";
|
||||||
import { createSchedulesOverrides } from "../../../Collaborators.Service";
|
import { createSchedulesOverrides, findCollaboratorsServices } from "../../../Collaborators.Service";
|
||||||
import { Grid2 } from "@mui/material";
|
import { Grid2 } from "@mui/material";
|
||||||
import TextTime from "@core/app/components/TextTime/TextTime";
|
import TextTime from "@core/app/components/TextTime/TextTime";
|
||||||
import TextDate from "@core/app/components/TextDate/TextDate";
|
import TextDate from "@core/app/components/TextDate/TextDate";
|
||||||
@@ -51,11 +51,24 @@ export default function OverridesCreate() {
|
|||||||
const [from, setFrom] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(8).minute(0));
|
const [from, setFrom] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(8).minute(0));
|
||||||
const [to, setTo] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(12).minute(0));
|
const [to, setTo] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(12).minute(0));
|
||||||
const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]);
|
const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]);
|
||||||
|
const [services, setServices] = useState<CollaboratorServiceItems[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
findCollaboratorsServices({
|
||||||
|
companyId: id,
|
||||||
|
employeeId: cid,
|
||||||
|
sessionUser: SessionInfo.userId,
|
||||||
|
})
|
||||||
|
.then((collaboratorServices) => {
|
||||||
|
setServices(collaboratorServices?.services.filter((service) => service.active) || []);
|
||||||
|
})
|
||||||
|
.catch((error) => alert.showError(error.format()));
|
||||||
|
}, [id, cid, SessionInfo.userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
@@ -109,6 +122,8 @@ export default function OverridesCreate() {
|
|||||||
from: schedule.from.format("HH:mm"),
|
from: schedule.from.format("HH:mm"),
|
||||||
to: schedule.to.format("HH:mm"),
|
to: schedule.to.format("HH:mm"),
|
||||||
disabled: schedule.data.disabled || false,
|
disabled: schedule.data.disabled || false,
|
||||||
|
serviceScope: schedule.data.serviceScope || "all",
|
||||||
|
serviceIds: schedule.data.serviceScope === "specific" ? schedule.data.serviceIds || [] : [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -121,6 +136,8 @@ export default function OverridesCreate() {
|
|||||||
from: from.format("HH:mm"),
|
from: from.format("HH:mm"),
|
||||||
to: to.format("HH:mm"),
|
to: to.format("HH:mm"),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
serviceScope: "all",
|
||||||
|
serviceIds: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +191,30 @@ export default function OverridesCreate() {
|
|||||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const serviceScopeChangeHandler = (
|
||||||
|
from: string,
|
||||||
|
serviceScope: "all" | "specific",
|
||||||
|
serviceIds: string[]
|
||||||
|
) => {
|
||||||
|
setSchedules(
|
||||||
|
schedules.map((schedule) => {
|
||||||
|
if (schedule.data.from == from) {
|
||||||
|
return {
|
||||||
|
...schedule,
|
||||||
|
data: {
|
||||||
|
...schedule.data,
|
||||||
|
serviceScope,
|
||||||
|
serviceIds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Location />
|
<Location />
|
||||||
@@ -225,7 +266,11 @@ export default function OverridesCreate() {
|
|||||||
from={schedule.data.from}
|
from={schedule.data.from}
|
||||||
to={schedule.data.to}
|
to={schedule.data.to}
|
||||||
disabled={schedule.data.disabled}
|
disabled={schedule.data.disabled}
|
||||||
|
serviceScope={schedule.data.serviceScope}
|
||||||
|
serviceIds={schedule.data.serviceIds}
|
||||||
|
serviceOptions={services}
|
||||||
onCheckChange={checkChangeHandler}
|
onCheckChange={checkChangeHandler}
|
||||||
|
onServiceScopeChange={serviceScopeChangeHandler}
|
||||||
onDelete={deleteScheduleHandler}
|
onDelete={deleteScheduleHandler}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+47
-1
@@ -10,8 +10,9 @@ import { useAlert } from "@store/Alert.Store";
|
|||||||
import { useConfirmStore } from "@store/Confirm.Store";
|
import { useConfirmStore } from "@store/Confirm.Store";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import MaterialAlert from "@mui/material/Alert";
|
import MaterialAlert from "@mui/material/Alert";
|
||||||
import { CollaboratorSchedulesView, ScheduleItem } from "@core/Models/Collaborators.model";
|
import { CollaboratorSchedulesView, CollaboratorServiceItems, ScheduleItem } from "@core/Models/Collaborators.model";
|
||||||
import {
|
import {
|
||||||
|
findCollaboratorsServices,
|
||||||
findCollaboratorsSchedules,
|
findCollaboratorsSchedules,
|
||||||
updateCollaboratorsSchedules,
|
updateCollaboratorsSchedules,
|
||||||
} from "../../Collaborators.Service";
|
} from "../../Collaborators.Service";
|
||||||
@@ -51,6 +52,7 @@ export default function CollaboratorSchedules() {
|
|||||||
const [from, setFrom] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(8).minute(0));
|
const [from, setFrom] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(8).minute(0));
|
||||||
const [to, setTo] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(12).minute(0));
|
const [to, setTo] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(12).minute(0));
|
||||||
const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]);
|
const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]);
|
||||||
|
const [services, setServices] = useState<CollaboratorServiceItems[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
@@ -86,6 +88,18 @@ export default function CollaboratorSchedules() {
|
|||||||
loadCollaboratorSchedules();
|
loadCollaboratorSchedules();
|
||||||
}, [weekDay]);
|
}, [weekDay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
findCollaboratorsServices({
|
||||||
|
companyId: id,
|
||||||
|
employeeId: cid,
|
||||||
|
sessionUser: SessionInfo.userId,
|
||||||
|
})
|
||||||
|
.then((collaboratorServices) => {
|
||||||
|
setServices(collaboratorServices?.services.filter((service) => service.active) || []);
|
||||||
|
})
|
||||||
|
.catch((error) => alert.showError(error.format()));
|
||||||
|
}, [id, cid, SessionInfo.userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (eventHandler.eventType === EVENT_TYPES.SAVE) {
|
if (eventHandler.eventType === EVENT_TYPES.SAVE) {
|
||||||
updateCollaboratorsSchedules({
|
updateCollaboratorsSchedules({
|
||||||
@@ -158,6 +172,8 @@ export default function CollaboratorSchedules() {
|
|||||||
from: schedule.from.format("HH:mm"),
|
from: schedule.from.format("HH:mm"),
|
||||||
to: schedule.to.format("HH:mm"),
|
to: schedule.to.format("HH:mm"),
|
||||||
disabled: schedule.data.disabled || false,
|
disabled: schedule.data.disabled || false,
|
||||||
|
serviceScope: schedule.data.serviceScope || "all",
|
||||||
|
serviceIds: schedule.data.serviceScope === "specific" ? schedule.data.serviceIds || [] : [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -170,6 +186,8 @@ export default function CollaboratorSchedules() {
|
|||||||
from: from.format("HH:mm"),
|
from: from.format("HH:mm"),
|
||||||
to: to.format("HH:mm"),
|
to: to.format("HH:mm"),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
serviceScope: "all",
|
||||||
|
serviceIds: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -248,6 +266,30 @@ export default function CollaboratorSchedules() {
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const serviceScopeChangeHandler = (
|
||||||
|
from: string,
|
||||||
|
serviceScope: "all" | "specific",
|
||||||
|
serviceIds: string[]
|
||||||
|
) => {
|
||||||
|
setSchedules(
|
||||||
|
schedules.map((schedule) => {
|
||||||
|
if (schedule.data.from == from) {
|
||||||
|
return {
|
||||||
|
...schedule,
|
||||||
|
data: {
|
||||||
|
...schedule.data,
|
||||||
|
serviceScope,
|
||||||
|
serviceIds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -291,7 +333,11 @@ export default function CollaboratorSchedules() {
|
|||||||
from={schedule.data.from}
|
from={schedule.data.from}
|
||||||
to={schedule.data.to}
|
to={schedule.data.to}
|
||||||
disabled={schedule.data.disabled}
|
disabled={schedule.data.disabled}
|
||||||
|
serviceScope={schedule.data.serviceScope}
|
||||||
|
serviceIds={schedule.data.serviceIds}
|
||||||
|
serviceOptions={services}
|
||||||
onCheckChange={checkChangeHandler}
|
onCheckChange={checkChangeHandler}
|
||||||
|
onServiceScopeChange={serviceScopeChangeHandler}
|
||||||
onDelete={deleteScheduleHandler}
|
onDelete={deleteScheduleHandler}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Menu from "@components/Menu/Menu";
|
|||||||
import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store";
|
import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store";
|
||||||
import Content from "@core/app/components/Content/Content";
|
import Content from "@core/app/components/Content/Content";
|
||||||
import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading";
|
import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading";
|
||||||
|
import { useNavigation } from "@core/app/hooks/goto";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,24 +19,33 @@ type UrlData = {
|
|||||||
|
|
||||||
export default function OrgLayout({ children }: { children: React.ReactNode }) {
|
export default function OrgLayout({ children }: { children: React.ReactNode }) {
|
||||||
const [organization, setOrganization] = useState<MyOranizationsView | undefined>();
|
const [organization, setOrganization] = useState<MyOranizationsView | undefined>();
|
||||||
|
const [isCheckingAccess, setIsCheckingAccess] = useState(true);
|
||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const organizationId = data.id;
|
const organizationId = data.id;
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const organizationHeader = useOrganizationHeaderStore();
|
const organizationHeader = useOrganizationHeaderStore();
|
||||||
|
const { goTo } = useNavigation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!SessionInfo.userId) {
|
if (!SessionInfo.userId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadOrganizationsById({ id: organizationId, sessionUser: SessionInfo.userId }).then(
|
setIsCheckingAccess(true);
|
||||||
(organization) => {
|
loadOrganizationsById({ id: organizationId, sessionUser: SessionInfo.userId })
|
||||||
|
.then((organization) => {
|
||||||
setOrganization(organization);
|
setOrganization(organization);
|
||||||
organizationHeader.setHeaderColor(organization.headerColor);
|
organizationHeader.setHeaderColor(organization.headerColor);
|
||||||
organizationHeader.setHeaderFontColor(organization.headerFontColor);
|
organizationHeader.setHeaderFontColor(organization.headerFontColor);
|
||||||
organizationHeader.setHeaderFontShadowColor(organization.headerFontShadowColor);
|
organizationHeader.setHeaderFontShadowColor(organization.headerFontShadowColor);
|
||||||
}
|
})
|
||||||
);
|
.catch(() => {
|
||||||
|
setOrganization(undefined);
|
||||||
|
goTo("/admin/access-denied");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsCheckingAccess(false);
|
||||||
|
});
|
||||||
}, [SessionInfo.userId]);
|
}, [SessionInfo.userId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,9 +53,14 @@ export default function OrgLayout({ children }: { children: React.ReactNode }) {
|
|||||||
<Content>
|
<Content>
|
||||||
<Suspense fallback={<SuspenseLoading />}>
|
<Suspense fallback={<SuspenseLoading />}>
|
||||||
<Secure>
|
<Secure>
|
||||||
|
{isCheckingAccess && <SuspenseLoading />}
|
||||||
{organization && <OrganizationHeader data={organization} />}
|
{organization && <OrganizationHeader data={organization} />}
|
||||||
<OrganizationContent>{children}</OrganizationContent>
|
{organization && (
|
||||||
<Menu />
|
<>
|
||||||
|
<OrganizationContent>{children}</OrganizationContent>
|
||||||
|
<Menu />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Secure>
|
</Secure>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
.page {
|
||||||
|
align-items: center;
|
||||||
|
background: radial-gradient(circle at top, rgba(255, 47, 125, 0.24), transparent 30%), linear-gradient(160deg, #3b0076 0%, #4b0097 48%, #8b2cff 100%);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
padding: 48px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: 0 28px 70px rgba(20, 0, 50, 0.28);
|
||||||
|
max-width: 720px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent {
|
||||||
|
background: linear-gradient(90deg, #ff2f7d, #ff6b35, #7c3aed);
|
||||||
|
height: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 44px 42px 38px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconWrap {
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(145deg, rgba(255, 47, 125, 0.12), rgba(124, 58, 237, 0.12));
|
||||||
|
border: 1px solid rgba(255, 47, 125, 0.2);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||||
|
color: #ff2f7d;
|
||||||
|
display: flex;
|
||||||
|
height: 76px;
|
||||||
|
justify-content: center;
|
||||||
|
width: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
background: rgba(255, 47, 125, 0.1);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #b91c5c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 7px 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #2b174f;
|
||||||
|
font-size: clamp(28px, 4vw, 38px);
|
||||||
|
line-height: 1.05;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryAction,
|
||||||
|
.secondaryAction {
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
font-weight: 900;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 0 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryAction {
|
||||||
|
background: linear-gradient(135deg, #ff2f7d, #7c3aed);
|
||||||
|
box-shadow: 0 14px 28px rgba(124, 58, 237, 0.28);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryAction {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(124, 58, 237, 0.18);
|
||||||
|
color: #4b148c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryAction:hover,
|
||||||
|
.secondaryAction:hover {
|
||||||
|
box-shadow: 0 18px 34px rgba(43, 23, 79, 0.18);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.content {
|
||||||
|
padding: 34px 24px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions,
|
||||||
|
.primaryAction,
|
||||||
|
.secondaryAction {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||||
|
import HomeOutlinedIcon from "@mui/icons-material/HomeOutlined";
|
||||||
|
import GroupsOutlinedIcon from "@mui/icons-material/GroupsOutlined";
|
||||||
|
import style from "./access-denied.module.css";
|
||||||
|
|
||||||
|
export default function AdminAccessDeniedPage() {
|
||||||
|
return (
|
||||||
|
<div className={style.page}>
|
||||||
|
<div className={style.card}>
|
||||||
|
<div className={style.accent} />
|
||||||
|
<div className={style.content}>
|
||||||
|
<div className={style.iconWrap}>
|
||||||
|
<ShieldOutlinedIcon sx={{ fontSize: 42 }} />
|
||||||
|
</div>
|
||||||
|
<span className={style.eyebrow}>Acceso restringido</span>
|
||||||
|
<h1 className={style.title}>No tenés permisos para acceder</h1>
|
||||||
|
<p className={style.description}>
|
||||||
|
Esta sección pertenece a una organización en la que ya no tenés permisos activos.
|
||||||
|
Si creés que se trata de un error, comunicate con un administrador de la organización.
|
||||||
|
</p>
|
||||||
|
<div className={style.actions}>
|
||||||
|
<Link className={style.primaryAction} href="/admin/org">
|
||||||
|
<GroupsOutlinedIcon sx={{ fontSize: 19, mr: 0.75 }} />
|
||||||
|
Ir a Mis organizaciones
|
||||||
|
</Link>
|
||||||
|
<Link className={style.secondaryAction} href="/landing/dashboard">
|
||||||
|
<HomeOutlinedIcon sx={{ fontSize: 19, mr: 0.75 }} />
|
||||||
|
Volver al dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -120,6 +120,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
const [inviteRole, setInviteRole] = useState<"admin" | "colaborador">("colaborador");
|
const [inviteRole, setInviteRole] = useState<"admin" | "colaborador">("colaborador");
|
||||||
const [isSearchingEmail, setIsSearchingEmail] = useState(false);
|
const [isSearchingEmail, setIsSearchingEmail] = useState(false);
|
||||||
const [isUpgradeRequired, setIsUpgradeRequired] = useState(false);
|
const [isUpgradeRequired, setIsUpgradeRequired] = useState(false);
|
||||||
|
const [inviteFlowNotice, setInviteFlowNotice] = useState("");
|
||||||
|
|
||||||
// State for Setup Collaborator Flow
|
// State for Setup Collaborator Flow
|
||||||
const [collaboratorToSetup, setCollaboratorToSetup] = useState<any | null>(null);
|
const [collaboratorToSetup, setCollaboratorToSetup] = useState<any | null>(null);
|
||||||
@@ -128,6 +129,13 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
const [setupSelectedServices, setSetupSelectedServices] = useState<string[]>([]);
|
const [setupSelectedServices, setSetupSelectedServices] = useState<string[]>([]);
|
||||||
const [setupAvailableServices, setSetupAvailableServices] = useState<any[]>([]);
|
const [setupAvailableServices, setSetupAvailableServices] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const getEmployeeUserId = (employee: any) => employee.userId?._id || employee.userId?.id || employee.userId;
|
||||||
|
|
||||||
|
const canManageEmployeeSchedules = (employee: any) => {
|
||||||
|
const isCurrentUser = String(getEmployeeUserId(employee)) === String(SessionInfo.userId);
|
||||||
|
return !employee.removed && (isCurrentUser || employee.guestOk === true);
|
||||||
|
};
|
||||||
|
|
||||||
const loadOrganizationContext = (org: any) => {
|
const loadOrganizationContext = (org: any) => {
|
||||||
setCreatedCompanyId(org.id || org._id);
|
setCreatedCompanyId(org.id || org._id);
|
||||||
setOrgName(org.name || "");
|
setOrgName(org.name || "");
|
||||||
@@ -219,11 +227,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
API.post<any[]>("employees/get-by-company", { companyId: org.id || org._id })
|
API.post<any[]>("employees/get-by-company", { companyId: org.id || org._id })
|
||||||
.then(empRes => {
|
.then(empRes => {
|
||||||
if (empRes && empRes.length > 0) {
|
if (empRes && empRes.length > 0) {
|
||||||
setAllEmployees(empRes);
|
const availableEmployees = empRes.filter(canManageEmployeeSchedules);
|
||||||
const me = empRes.find((e: any) => {
|
setAllEmployees(availableEmployees);
|
||||||
const eUserId = e.userId?._id || e.userId?.id || e.userId;
|
const me = availableEmployees.find((e: any) => String(getEmployeeUserId(e)) === String(SessionInfo.userId));
|
||||||
return String(eUserId) === String(SessionInfo.userId);
|
|
||||||
});
|
|
||||||
if (me) {
|
if (me) {
|
||||||
setEmployeeId(me.id || me._id || me.employeeId);
|
setEmployeeId(me.id || me._id || me.employeeId);
|
||||||
}
|
}
|
||||||
@@ -1256,7 +1262,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
for (let i = 0; i <= 6; i++) {
|
for (let i = 0; i <= 6; i++) {
|
||||||
const isWorkingDay = workingDays.includes(i);
|
const isWorkingDay = workingDays.includes(i);
|
||||||
|
|
||||||
let schedules = [];
|
let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
|
||||||
if (isWorkingDay) {
|
if (isWorkingDay) {
|
||||||
if (hasBreak) {
|
if (hasBreak) {
|
||||||
schedules = [
|
schedules = [
|
||||||
@@ -1269,9 +1275,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
schedules = [
|
schedules = [];
|
||||||
{ from: "00:00", to: "23:59", disabled: true }
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await API.post<any>("schedules/update", {
|
await API.post<any>("schedules/update", {
|
||||||
@@ -1368,14 +1372,14 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
onNext={() => {
|
onNext={() => {
|
||||||
if (doesProvideServices === null) return;
|
if (doesProvideServices === null) return;
|
||||||
if (!doesProvideServices) {
|
if (!doesProvideServices) {
|
||||||
// If they don't provide services, finish setup directly
|
const query = createdCompanyId ? `&companyId=${createdCompanyId}` : "";
|
||||||
window.location.href = "/landing/dashboard";
|
router.push(`/admin/assistant?flow=new-collaborator${query}`);
|
||||||
} else {
|
} else {
|
||||||
handleNext();
|
handleNext();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disableNext={doesProvideServices === null}
|
disableNext={doesProvideServices === null}
|
||||||
nextLabel={doesProvideServices === false ? "Finalizar" : "Siguiente"}
|
nextLabel={doesProvideServices === false ? "Invitar colaborador" : "Siguiente"}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
|
||||||
<div
|
<div
|
||||||
@@ -1412,7 +1416,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h4 style={{ margin: '0 0 5px 0', fontSize: '18px', fontWeight: doesProvideServices === false ? 'bold' : 'normal' }}>No, tengo un equipo</h4>
|
<h4 style={{ margin: '0 0 5px 0', fontSize: '18px', fontWeight: doesProvideServices === false ? 'bold' : 'normal' }}>No, tengo un equipo</h4>
|
||||||
<p style={{ margin: 0, fontSize: '13px', color: 'rgba(255,255,255,0.7)' }}>Terminaremos aquí. Luego podrás invitar a tus colaboradores.</p>
|
<p style={{ margin: 0, fontSize: '13px', color: 'rgba(255,255,255,0.7)' }}>Te guiaremos para invitar al primer colaborador.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</QuestionCard>
|
</QuestionCard>
|
||||||
@@ -1724,7 +1728,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
for (let i = 0; i <= 6; i++) {
|
for (let i = 0; i <= 6; i++) {
|
||||||
const isWorkingDay = workingDays.includes(i);
|
const isWorkingDay = workingDays.includes(i);
|
||||||
|
|
||||||
let schedules = [];
|
let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
|
||||||
if (isWorkingDay) {
|
if (isWorkingDay) {
|
||||||
if (hasBreak) {
|
if (hasBreak) {
|
||||||
schedules = [
|
schedules = [
|
||||||
@@ -1737,9 +1741,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
schedules = [
|
schedules = [];
|
||||||
{ from: "00:00", to: "23:59", disabled: true }
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await API.post<any>("schedules/update", {
|
await API.post<any>("schedules/update", {
|
||||||
@@ -2016,7 +2018,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let schedules = [];
|
let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
|
||||||
if (hasBreak) {
|
if (hasBreak) {
|
||||||
schedules = [
|
schedules = [
|
||||||
{ from: openTime, to: breakStart, disabled: false },
|
{ from: openTime, to: breakStart, disabled: false },
|
||||||
@@ -2653,6 +2655,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
const handleCreateCollaborator = async () => {
|
const handleCreateCollaborator = async () => {
|
||||||
if (!inviteCollaborator || !createdCompanyId) return;
|
if (!inviteCollaborator || !createdCompanyId) return;
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
|
setInviteFlowNotice("");
|
||||||
try {
|
try {
|
||||||
await API.post<any>("employees/create", {
|
await API.post<any>("employees/create", {
|
||||||
companyId: createdCompanyId,
|
companyId: createdCompanyId,
|
||||||
@@ -2661,13 +2664,21 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
hostOk: true,
|
hostOk: true,
|
||||||
sessionUser: SessionInfo.userId
|
sessionUser: SessionInfo.userId
|
||||||
});
|
});
|
||||||
handleActionSuccess();
|
handleNext();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMessage = error.desc || error.message || "Error al invitar al colaborador";
|
const errorMessage = error.desc || error.message || "Error al invitar al colaborador";
|
||||||
if (errorMessage.toLowerCase().includes("limite") || errorMessage.toLowerCase().includes("límite")) {
|
const normalizedErrorMessage = errorMessage.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||||
|
if (normalizedErrorMessage.includes("limite")) {
|
||||||
setIsUpgradeRequired(true);
|
setIsUpgradeRequired(true);
|
||||||
|
} else if (normalizedErrorMessage.includes("invitacion") && normalizedErrorMessage.includes("pendiente")) {
|
||||||
|
setInviteFlowNotice("Ya se envió una invitación para que este usuario forme parte de la compañía y todavía no respondió.");
|
||||||
|
setCurrentStepIndex(5);
|
||||||
|
} else if (normalizedErrorMessage.includes("ya pertenece")) {
|
||||||
|
setInviteFlowNotice("Este usuario ya pertenece a la compañía.");
|
||||||
|
setCurrentStepIndex(6);
|
||||||
} else {
|
} else {
|
||||||
alert(errorMessage);
|
setInviteFlowNotice(errorMessage);
|
||||||
|
setCurrentStepIndex(5);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
@@ -2752,6 +2763,109 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
{null}
|
{null}
|
||||||
</QuestionCard>
|
</QuestionCard>
|
||||||
|
|
||||||
|
<QuestionCard
|
||||||
|
isActive={currentStepIndex === 4}
|
||||||
|
title="Invitación Enviada"
|
||||||
|
description="¿Cómo quieres continuar?"
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setInviteEmail("");
|
||||||
|
setInviteCollaborator(null);
|
||||||
|
setInviteEmailError("");
|
||||||
|
setInviteRole("colaborador");
|
||||||
|
setIsUpgradeRequired(false);
|
||||||
|
setInviteFlowNotice("");
|
||||||
|
setCurrentStepIndex(1);
|
||||||
|
}}
|
||||||
|
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>Invitar otro colaborador</div>
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Agrega otra persona a tu equipo.</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
const query = createdCompanyId ? `&companyId=${createdCompanyId}` : "";
|
||||||
|
router.push(`/admin/assistant?flow=update-schedule${query}`);
|
||||||
|
}}
|
||||||
|
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>Configurar mis horarios</div>
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Define tus horarios personales de atención.</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/admin/assistant")}
|
||||||
|
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>Terminar</div>
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Volver a la home del asistente.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</QuestionCard>
|
||||||
|
|
||||||
|
<QuestionCard
|
||||||
|
isActive={currentStepIndex === 5}
|
||||||
|
title="Invitación Pendiente"
|
||||||
|
description={inviteFlowNotice || "No pudimos enviar la invitación en este momento."}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setInviteEmail("");
|
||||||
|
setInviteCollaborator(null);
|
||||||
|
setInviteEmailError("");
|
||||||
|
setInviteRole("colaborador");
|
||||||
|
setIsUpgradeRequired(false);
|
||||||
|
setInviteFlowNotice("");
|
||||||
|
setCurrentStepIndex(1);
|
||||||
|
}}
|
||||||
|
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>Invitar otro colaborador</div>
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Busca otra persona para sumar al equipo.</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/admin/assistant")}
|
||||||
|
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>Volver al asistente</div>
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Regresar a la home del asistente.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</QuestionCard>
|
||||||
|
|
||||||
|
<QuestionCard
|
||||||
|
isActive={currentStepIndex === 6}
|
||||||
|
title="El usuario ya pertenece"
|
||||||
|
description={inviteFlowNotice || "Este usuario ya pertenece a la compañía."}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setInviteEmail("");
|
||||||
|
setInviteCollaborator(null);
|
||||||
|
setInviteEmailError("");
|
||||||
|
setInviteRole("colaborador");
|
||||||
|
setIsUpgradeRequired(false);
|
||||||
|
setInviteFlowNotice("");
|
||||||
|
setCurrentStepIndex(1);
|
||||||
|
}}
|
||||||
|
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>Invitar otro colaborador</div>
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Busca otra persona para sumar al equipo.</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/admin/assistant")}
|
||||||
|
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>Volver al asistente</div>
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Regresar a la home del asistente.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</QuestionCard>
|
||||||
|
|
||||||
<QuestionCard
|
<QuestionCard
|
||||||
isActive={currentStepIndex === 3 && isUpgradeRequired}
|
isActive={currentStepIndex === 3 && isUpgradeRequired}
|
||||||
title="¡Haz crecer tu equipo!"
|
title="¡Haz crecer tu equipo!"
|
||||||
@@ -3202,6 +3316,24 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
return "Has configurado exitosamente tu negocio. Todo está listo para que empieces a recibir reservas y gestionar tu agenda como un profesional.";
|
return "Has configurado exitosamente tu negocio. Todo está listo para que empieces a recibir reservas y gestionar tu agenda como un profesional.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const successButtonStyle: React.CSSProperties = {
|
||||||
|
padding: '16px 40px', fontSize: '18px', fontWeight: 'bold',
|
||||||
|
background: 'white', color: 'var(--wine-red)', border: 'none',
|
||||||
|
borderRadius: '30px', cursor: 'pointer',
|
||||||
|
boxShadow: '0 4px 15px rgba(0,0,0,0.3)',
|
||||||
|
transition: 'transform 0.2s, box-shadow 0.2s'
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuccessButtonMouseOver = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 20px rgba(255,255,255,0.3)';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuccessButtonMouseOut = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0,0,0,0.3)';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||||
@@ -3240,21 +3372,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
window.location.href = "/landing/dashboard";
|
window.location.href = "/landing/dashboard";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={successButtonStyle}
|
||||||
padding: '16px 40px', fontSize: '18px', fontWeight: 'bold',
|
onMouseOver={handleSuccessButtonMouseOver}
|
||||||
background: 'white', color: 'var(--wine-red)', border: 'none',
|
onMouseOut={handleSuccessButtonMouseOut}
|
||||||
borderRadius: '30px', cursor: 'pointer',
|
|
||||||
boxShadow: '0 4px 15px rgba(0,0,0,0.3)',
|
|
||||||
transition: 'transform 0.2s, box-shadow 0.2s'
|
|
||||||
}}
|
|
||||||
onMouseOver={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(255,255,255,0.3)';
|
|
||||||
}}
|
|
||||||
onMouseOut={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0,0,0,0.3)';
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{action ? "Volver al Menú Principal" : "Ir a mi Panel de Control"}
|
{action ? "Volver al Menú Principal" : "Ir a mi Panel de Control"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface ButtonProps {
|
|||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
width?: ButtonWidth;
|
width?: ButtonWidth;
|
||||||
color?: ButtonColor;
|
color?: ButtonColor;
|
||||||
|
disabled?: boolean;
|
||||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export default function Button(props: ButtonProps): React.ReactElement {
|
|||||||
id={props.name}
|
id={props.name}
|
||||||
value={props.text}
|
value={props.text}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
|
disabled={props.disabled}
|
||||||
style={props.style}
|
style={props.style}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -18,6 +18,19 @@
|
|||||||
gap: 15px;
|
gap: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerButton {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -25,6 +38,22 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggleIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--green-darkestX1);
|
||||||
|
border: 1px solid var(--green-darkestX1);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user