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 { await this.validateSessionUser(data.sessionUser); this.validateScore(data.score); this.validateTargetType(data.targetType); const comment = isNull(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 { 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(); 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(userEmployee.firstName, ""), isNull(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 { await this.validateSessionUser(data.sessionUser); return { count: await this.ratings.count({ userId: data.sessionUser }), }; } public async professionalSummaryByUser(data: ProfessionalRatingSummaryByUserParams): Promise { 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 { 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 { 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 { 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;