297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
import dayjs from "dayjs";
|
|
import { Types } from "mongoose";
|
|
import { isNull } from "../../helpers/IsNull";
|
|
import { joinStrings } from "../../helpers/String";
|
|
import AppointmentList from "../Appointments/Appointments";
|
|
import CompaniesManager from "../Companies/Companies";
|
|
import EmployeesList from "../Employees/Employee";
|
|
import ServicesManager from "../Services/Service";
|
|
import UsersManager from "../Users/Users";
|
|
import { RatingsAdapterMongoose } from "./Ratings.Adapter.Mongoose";
|
|
import {
|
|
CountRatingsByUserParams,
|
|
CountRatingsByUserResult,
|
|
CreateRatingParams,
|
|
IRating,
|
|
IRatingsManager,
|
|
PendingRatingItem,
|
|
PendingRatingsByUserParams,
|
|
ProfessionalRatingSummaryByUserParams,
|
|
RATING_COMMENT_MAX_LENGTH,
|
|
RatingTargetSummaryParams,
|
|
RatingTargetSummaryResult,
|
|
RatingTargetReviewsParams,
|
|
RatingTargetReviewsResult,
|
|
RatingTargetType,
|
|
} from "./Ratings.Interface";
|
|
|
|
const PENDING_RATINGS_APPOINTMENT_LIMIT = 50;
|
|
const TARGET_REVIEWS_LIMIT = 20;
|
|
|
|
class RatingsManager implements IRatingsManager {
|
|
ratings: RatingsAdapterMongoose;
|
|
|
|
constructor() {
|
|
this.ratings = new RatingsAdapterMongoose();
|
|
}
|
|
|
|
public async createRating(data: CreateRatingParams): Promise<IRating> {
|
|
await this.validateSessionUser(data.sessionUser);
|
|
this.validateScore(data.score);
|
|
this.validateTargetType(data.targetType);
|
|
const comment = isNull<string>(data.comment, "").trim();
|
|
this.validateComment(comment);
|
|
|
|
const appointment = await AppointmentList.Appointments.AppointmentList.findOne({
|
|
_id: data.appointmentId,
|
|
userId: data.sessionUser,
|
|
});
|
|
|
|
if (!appointment) {
|
|
throw new Error("No se encontró un turno válido para calificar.");
|
|
}
|
|
|
|
if (!appointment.present || !dayjs(appointment.dateDay).isBefore(dayjs().startOf("day"))) {
|
|
throw new Error("Solo puedes calificar turnos pasados a los que asististe.");
|
|
}
|
|
|
|
const expectedTargetId = this.getTargetId({
|
|
targetType: data.targetType,
|
|
companyId: String(appointment.companyId),
|
|
serviceId: String(appointment.serviceId),
|
|
employeeId: String(appointment.employeeId),
|
|
});
|
|
|
|
if (String(data.targetId) !== expectedTargetId) {
|
|
throw new Error("La calificación no corresponde al turno seleccionado.");
|
|
}
|
|
|
|
const existingRating = await this.ratings.findOne({
|
|
userId: data.sessionUser,
|
|
targetType: data.targetType,
|
|
targetId: data.targetId,
|
|
});
|
|
|
|
if (existingRating) {
|
|
throw new Error("Ya calificaste este item.");
|
|
}
|
|
|
|
try {
|
|
return await this.ratings.create({
|
|
appointmentId: data.appointmentId,
|
|
userId: data.sessionUser,
|
|
companyId: String(appointment.companyId),
|
|
serviceId: String(appointment.serviceId),
|
|
employeeId: String(appointment.employeeId),
|
|
targetType: data.targetType,
|
|
targetId: data.targetId,
|
|
score: data.score,
|
|
comment,
|
|
});
|
|
} catch (error) {
|
|
if (this.isDuplicateRatingError(error)) {
|
|
throw new Error("Ya calificaste este item.");
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
public async pendingByUser(data: PendingRatingsByUserParams): Promise<PendingRatingItem[]> {
|
|
await this.validateSessionUser(data.sessionUser);
|
|
|
|
const appointments = await AppointmentList.Appointments.AppointmentList.find({
|
|
userId: data.sessionUser,
|
|
present: true,
|
|
dateDay: { $lt: dayjs().startOf("day").toDate() },
|
|
})
|
|
.sort({ start: -1 })
|
|
.limit(PENDING_RATINGS_APPOINTMENT_LIMIT);
|
|
|
|
const pendingItems: PendingRatingItem[] = [];
|
|
const seenTargets = new Set<string>();
|
|
const candidateTargetIds = Array.from(
|
|
new Set(
|
|
appointments.flatMap((appointment) => [
|
|
String(appointment.companyId),
|
|
String(appointment.serviceId),
|
|
String(appointment.employeeId),
|
|
])
|
|
)
|
|
);
|
|
const candidateTargetObjectIds = this.toObjectIds(candidateTargetIds);
|
|
const existingRatings = await this.ratings.find({
|
|
userId: data.sessionUser,
|
|
targetId: { $in: candidateTargetIds },
|
|
});
|
|
const ratedTargets = new Set(
|
|
existingRatings.map((rating) => `${rating.targetType}:${String(rating.targetId)}`)
|
|
);
|
|
const companies = await CompaniesManager.companies.find({ _id: { $in: candidateTargetObjectIds } } as any);
|
|
const services = await ServicesManager.services.find({ _id: { $in: candidateTargetObjectIds } } as any);
|
|
const employees = await EmployeesList.employees.find({ _id: { $in: candidateTargetObjectIds } } as any);
|
|
const employeeUserIds = Array.from(new Set(employees.map((employee) => String(employee.userId))));
|
|
const employeeUsers = await UsersManager.users.find({ _id: { $in: this.toObjectIds(employeeUserIds) } } as any);
|
|
|
|
const companiesById = new Map(companies.map((company) => [String(company.id), company]));
|
|
const servicesById = new Map(services.map((service) => [String(service.id), service]));
|
|
const employeesById = new Map(employees.map((employee) => [String(employee.id), employee]));
|
|
const employeeUsersById = new Map(employeeUsers.map((user) => [String(user.id), user]));
|
|
|
|
for (const appointment of appointments) {
|
|
const company = companiesById.get(String(appointment.companyId));
|
|
const service = servicesById.get(String(appointment.serviceId));
|
|
const employee = employeesById.get(String(appointment.employeeId));
|
|
|
|
if (!company || !service || !employee) {
|
|
continue;
|
|
}
|
|
|
|
const userEmployee = employeeUsersById.get(String(employee.userId));
|
|
const employeeName = userEmployee
|
|
? joinStrings([isNull<string>(userEmployee.firstName, ""), isNull<string>(userEmployee.lastName, "")], " ")
|
|
: "Profesional";
|
|
|
|
const candidates = [
|
|
{ targetType: RatingTargetType.COMPANY, targetId: String(appointment.companyId), targetName: company.name },
|
|
{ targetType: RatingTargetType.SERVICE, targetId: String(appointment.serviceId), targetName: service.name },
|
|
{ targetType: RatingTargetType.EMPLOYEE, targetId: String(appointment.employeeId), targetName: employeeName },
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const seenKey = `${candidate.targetType}:${candidate.targetId}`;
|
|
if (seenTargets.has(seenKey)) {
|
|
continue;
|
|
}
|
|
|
|
if (ratedTargets.has(seenKey)) {
|
|
seenTargets.add(seenKey);
|
|
continue;
|
|
}
|
|
|
|
pendingItems.push({
|
|
appointmentId: String(appointment.id),
|
|
appointmentDate: dayjs(appointment.start).toISOString(),
|
|
targetType: candidate.targetType,
|
|
targetId: candidate.targetId,
|
|
targetName: candidate.targetName,
|
|
companyId: String(appointment.companyId),
|
|
serviceId: String(appointment.serviceId),
|
|
employeeId: String(appointment.employeeId),
|
|
serviceName: service.name,
|
|
});
|
|
|
|
seenTargets.add(seenKey);
|
|
}
|
|
}
|
|
|
|
return pendingItems;
|
|
}
|
|
|
|
public async countByUser(data: CountRatingsByUserParams): Promise<CountRatingsByUserResult> {
|
|
await this.validateSessionUser(data.sessionUser);
|
|
|
|
return {
|
|
count: await this.ratings.count({ userId: data.sessionUser }),
|
|
};
|
|
}
|
|
|
|
public async professionalSummaryByUser(data: ProfessionalRatingSummaryByUserParams): Promise<RatingTargetSummaryResult> {
|
|
await this.validateSessionUser(data.sessionUser);
|
|
|
|
const employees = await EmployeesList.employees.find({
|
|
userId: data.sessionUser,
|
|
removed: { $ne: true },
|
|
} as any);
|
|
const employeeIds = employees.map((employee) => String(employee.id));
|
|
|
|
return this.ratings.summaryByTargets({
|
|
targetType: RatingTargetType.EMPLOYEE,
|
|
targetIds: employeeIds,
|
|
});
|
|
}
|
|
|
|
public async summaryByTarget(data: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult> {
|
|
this.validateTargetType(data.targetType);
|
|
|
|
if (!Types.ObjectId.isValid(data.targetId)) {
|
|
throw new Error("No se ha proporcionado un objetivo válido.");
|
|
}
|
|
|
|
return this.ratings.summaryByTarget(data);
|
|
}
|
|
|
|
public async reviewsByTarget(data: RatingTargetReviewsParams): Promise<RatingTargetReviewsResult> {
|
|
this.validateTargetType(data.targetType);
|
|
|
|
if (!Types.ObjectId.isValid(data.targetId)) {
|
|
throw new Error("No se ha proporcionado un objetivo válido.");
|
|
}
|
|
|
|
const pageSize = Math.min(Math.max(data.pageSize || data.limit || TARGET_REVIEWS_LIMIT, 1), TARGET_REVIEWS_LIMIT);
|
|
const page = Math.max(data.page || 1, 1);
|
|
|
|
return this.ratings.reviewsByTarget({
|
|
...data,
|
|
page,
|
|
pageSize,
|
|
limit: pageSize,
|
|
});
|
|
}
|
|
|
|
private async validateSessionUser(sessionUser: string): Promise<void> {
|
|
const user = await UsersManager.users.findOne({ _id: sessionUser });
|
|
|
|
if (!user) {
|
|
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
|
|
}
|
|
}
|
|
|
|
private toObjectIds(ids: string[]): Types.ObjectId[] {
|
|
return ids.filter((id) => Types.ObjectId.isValid(id)).map((id) => new Types.ObjectId(id));
|
|
}
|
|
|
|
private validateScore(score: number): void {
|
|
if (!Number.isInteger(score) || score < 1 || score > 5) {
|
|
throw new Error("La calificación debe estar entre 1 y 5.");
|
|
}
|
|
}
|
|
|
|
private validateTargetType(targetType: RatingTargetType): void {
|
|
if (!Object.values(RatingTargetType).includes(targetType)) {
|
|
throw new Error("El tipo de calificación no es válido.");
|
|
}
|
|
}
|
|
|
|
private validateComment(comment: string): void {
|
|
if (comment.length > RATING_COMMENT_MAX_LENGTH) {
|
|
throw new Error(`El comentario no puede superar los ${RATING_COMMENT_MAX_LENGTH} caracteres.`);
|
|
}
|
|
}
|
|
|
|
private isDuplicateRatingError(error: unknown): boolean {
|
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === 11000);
|
|
}
|
|
|
|
private getTargetId(data: {
|
|
targetType: RatingTargetType;
|
|
companyId: string;
|
|
serviceId: string;
|
|
employeeId: string;
|
|
}): string {
|
|
if (data.targetType === RatingTargetType.COMPANY) {
|
|
return data.companyId;
|
|
}
|
|
|
|
if (data.targetType === RatingTargetType.SERVICE) {
|
|
return data.serviceId;
|
|
}
|
|
|
|
return data.employeeId;
|
|
}
|
|
}
|
|
|
|
const RatingsList = new RatingsManager();
|
|
|
|
export default RatingsList;
|