feat: implement backend and frontend support for managing reservation period extensions and summaries

This commit is contained in:
2026-07-28 17:10:20 -03:00
parent 68d98e8b40
commit ece3879c8d
9 changed files with 697 additions and 13 deletions
+5 -1
View File
@@ -682,7 +682,11 @@ class CompaniesManager implements ICompaniesManager {
continue;
}
const isAdmin = await EmployeesList.checkPermission(companyDoc, employ.id, EmployeeRoles.ADMIN);
const isAdmin = await EmployeesList.checkPermission(
companyDoc,
data.sessionUser,
EmployeeRoles.ADMIN
);
returnData.push({
id: isNull<string>(company.id, ""),
@@ -49,6 +49,49 @@ export type EnableScheduleParams = {
sessionUser: string;
};
export type ReservationPeriodScope = "employee" | "all";
export type ReservationPeriodDurationUnit = "week" | "month";
export type ReservationPeriodDuration = {
unit: ReservationPeriodDurationUnit;
value: number;
};
export type ReservationPeriodsSummaryParams = {
companyId: string;
sessionUser: string;
};
export type ExtendReservationPeriodsParams = {
companyId: string;
scope: ReservationPeriodScope;
employeeId?: string;
duration?: ReservationPeriodDuration;
weeks?: number;
sessionUser: string;
};
export type ReservationPeriodEmployeeSummary = {
employeeId: string;
employeeFullName: string;
employeeEmail: string;
employeeAvatar: string;
employeeUserId: string;
isOpen: boolean;
currentOpenUntil: Date | null;
daysRemaining: number | null;
};
export type ReservationPeriodsSummary = {
companyId: string;
status: "missing" | "expired" | "near-ending" | "open";
worstDaysRemaining: number | null;
currentOpenUntil: Date | null;
nearEndingThresholdDays: number;
employees: ReservationPeriodEmployeeSummary[];
};
export type DeleteSchedulesEnabledByCompanyParams = {
companyId: string;
};
@@ -94,4 +137,6 @@ export interface ISchedulesEnabledManager {
exists(data: CheckSchedulesEnabledParams): Promise<boolean>;
deleteSchedulesEnabledByCompany(data: DeleteSchedulesEnabledByCompanyParams): Promise<void>;
deleteSchedulesEnabledByEmployee(data: DeleteSchedulesEnabledByEmployeeParams): Promise<void>;
summarizeReservationPeriods(data: ReservationPeriodsSummaryParams): Promise<ReservationPeriodsSummary>;
extendReservationPeriods(data: ExtendReservationPeriodsParams): Promise<ReservationPeriodsSummary>;
}
@@ -9,6 +9,10 @@ import {
FindSchedulesEnabledParams,
ISchedulesEnabled,
ISchedulesEnabledManager,
ReservationPeriodEmployeeSummary,
ReservationPeriodsSummary,
ReservationPeriodsSummaryParams,
ExtendReservationPeriodsParams,
SchedulesEnabledView,
} from "./SchedulesEnabled.Interface";
import { EmployeeRoles } from "../Employees/Employees.Interface";
@@ -22,9 +26,19 @@ import getAvatar from "../../helpers/getAvatar";
import { NotificationsManager } from "../Notifications/Notifications";
import dayjs from "dayjs";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
dayjs.locale("es");
const NEAR_ENDING_THRESHOLD_DAYS = 14;
const ALLOWED_RESERVATION_PERIOD_DURATIONS = [
{ unit: "week", value: 1 },
{ unit: "week", value: 2 },
{ unit: "week", value: 3 },
{ unit: "month", value: 1 },
{ unit: "month", value: 2 },
] as const;
class SchedulesEnabledManager implements ISchedulesEnabledManager {
schedulesEnabled: SchedulesEnabledAdapterMongoose;
@@ -233,7 +247,7 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
if (data.employeeId) {
employeeFilter = {
...employeeFilter,
employeeId: data.employeeId,
_id: data.employeeId,
};
}
const employees = await EmployeesList.employees.find(employeeFilter);
@@ -297,6 +311,137 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
await this.schedulesEnabled.delete(String(data.id));
}
public async summarizeReservationPeriods(
data: ReservationPeriodsSummaryParams
): Promise<ReservationPeriodsSummary> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
return this.buildReservationPeriodsSummary(data.companyId);
}
public async extendReservationPeriods(
data: ExtendReservationPeriodsParams
): Promise<ReservationPeriodsSummary> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
const duration = data.duration || (data.weeks ? { unit: "week" as const, value: Number(data.weeks) } : null);
if (!duration || !ALLOWED_RESERVATION_PERIOD_DURATIONS.some(option => option.unit === duration.unit && option.value === Number(duration.value))) {
throw new Error("El período seleccionado no es válido");
}
const employees = await EmployeesList.employees.find({
companyId: data.companyId,
removed: { $ne: true },
} as any);
let targetEmployees = employees;
if (data.scope === "employee") {
if (!data.employeeId) {
throw new Error("Debe seleccionar un colaborador");
}
targetEmployees = employees.filter(employee => String(employee.id) === String(data.employeeId));
if (targetEmployees.length === 0) {
throw new Error("El colaborador no existe o no pertenece a la organización");
}
} else if (data.scope !== "all") {
throw new Error("El alcance seleccionado no es válido");
}
if (targetEmployees.length === 0) {
throw new Error("No hay colaboradores disponibles para extender el período de reservas");
}
const today = dayjs().startOf("day");
const summary = await this.buildReservationPeriodsSummary(data.companyId);
const operations = targetEmployees.map(employee => {
const employeeSummary = summary.employees.find(item => String(item.employeeId) === String(employee.id));
const startDate = employeeSummary?.currentOpenUntil && dayjs(employeeSummary.currentOpenUntil).isAfter(today)
? dayjs(employeeSummary.currentOpenUntil).add(1, "day").startOf("day")
: today;
const endDate = startDate.add(Number(duration.value), duration.unit).subtract(1, "day").endOf("day");
return {
updateOne: {
filter: {
employeeId: employee.id,
companyId: data.companyId,
startDate: startDate.toDate(),
endDate: endDate.toDate(),
},
update: {
$setOnInsert: {
employeeId: employee.id,
companyId: data.companyId,
startDate: startDate.toDate(),
endDate: endDate.toDate(),
},
},
upsert: true,
},
};
});
await this.schedulesEnabled.schedulesEnabledList.bulkWrite(operations, { ordered: true });
return this.buildReservationPeriodsSummary(data.companyId);
}
private async buildReservationPeriodsSummary(companyId: string): Promise<ReservationPeriodsSummary> {
const today = dayjs().startOf("day");
const employees = await EmployeesList.findByCompanyId({ companyId });
const employeeSummaries: ReservationPeriodEmployeeSummary[] = [];
for (const employee of employees) {
const latestPeriod = await this.schedulesEnabled.schedulesEnabledList
.findOne({ companyId, employeeId: employee.id })
.sort({ endDate: -1 })
.exec();
const currentOpenUntil = latestPeriod ? latestPeriod.endDate : null;
const daysRemaining = currentOpenUntil
? dayjs(currentOpenUntil).endOf("day").diff(today, "day")
: null;
employeeSummaries.push({
employeeId: employee.id,
employeeFullName: employee.fullName,
employeeEmail: employee.email,
employeeAvatar: employee.avatar,
employeeUserId: employee.userId,
isOpen: daysRemaining !== null && daysRemaining >= 0,
currentOpenUntil,
daysRemaining,
});
}
const currentOpenUntilDates = employeeSummaries
.map(employee => employee.currentOpenUntil)
.filter((date): date is Date => Boolean(date));
const remainingValues = employeeSummaries
.map(employee => employee.daysRemaining)
.filter((days): days is number => days !== null);
const hasMissing = employeeSummaries.some(employee => employee.currentOpenUntil === null);
const hasExpired = employeeSummaries.some(employee => employee.daysRemaining !== null && employee.daysRemaining < 0);
const hasNearEnding = employeeSummaries.some(employee => employee.daysRemaining !== null && employee.daysRemaining <= NEAR_ENDING_THRESHOLD_DAYS);
return {
companyId,
status: hasMissing ? "missing" : hasExpired ? "expired" : hasNearEnding ? "near-ending" : "open",
worstDaysRemaining: remainingValues.length > 0 ? Math.min(...remainingValues) : null,
currentOpenUntil: currentOpenUntilDates.length > 0
? currentOpenUntilDates.reduce((max, date) => dayjs(date).isAfter(max) ? date : max, currentOpenUntilDates[0])
: null,
nearEndingThresholdDays: NEAR_ENDING_THRESHOLD_DAYS,
employees: employeeSummaries,
};
}
}
const SchedulesEnabledList = new SchedulesEnabledManager();
@@ -5,8 +5,11 @@ import {
CreateSchedulesEnabledParams,
DeleteSchedulesEnabledParams,
EnableScheduleParams,
ExtendReservationPeriodsParams,
FindSchedulesEnabledParams,
ISchedulesEnabled,
ReservationPeriodsSummary,
ReservationPeriodsSummaryParams,
SchedulesEnabledView,
} from "../../Models/SchedulesEnabled/SchedulesEnabled.Interface";
import { authenticateMiddleware } from "../../middleware/authentication";
@@ -150,3 +153,45 @@ export class DisableSchedulesEnabledController extends Controller {
}
}
}
@Route("schedules-enabled/reservation-periods/summary")
@Middlewares(authenticateMiddleware)
export class ReservationPeriodsSummaryController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async summarizeReservationPeriods(
@Body() requestBody: ReservationPeriodsSummaryParams
): Promise<ReservationPeriodsSummary | ApiValidationError> {
try {
const summary = await new SchedulesEnabledService().summarizeReservationPeriods(requestBody);
this.setStatus(200);
return summary;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("schedules-enabled/reservation-periods/extend")
@Middlewares(authenticateMiddleware)
export class ExtendReservationPeriodsController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async extendReservationPeriods(
@Body() requestBody: ExtendReservationPeriodsParams
): Promise<ReservationPeriodsSummary | ApiValidationError> {
try {
const summary = await new SchedulesEnabledService().extendReservationPeriods(requestBody);
this.setStatus(200);
return summary;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -7,6 +7,9 @@ import {
FindSchedulesEnabledParams,
SchedulesEnabledView,
EnableScheduleParams,
ReservationPeriodsSummaryParams,
ReservationPeriodsSummary,
ExtendReservationPeriodsParams,
} from "../../Models/SchedulesEnabled/SchedulesEnabled.Interface";
export class SchedulesEnabledService {
@@ -52,4 +55,18 @@ export class SchedulesEnabledService {
return schedulesEnabled;
}
public async summarizeReservationPeriods(
data: ReservationPeriodsSummaryParams
): Promise<ReservationPeriodsSummary> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return SchedulesEnabledList.summarizeReservationPeriods(data);
}
public async extendReservationPeriods(
data: ExtendReservationPeriodsParams
): Promise<ReservationPeriodsSummary> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return SchedulesEnabledList.extendReservationPeriods(data);
}
}