feat: implement full-stack ratings system for appointments with user submission and pending ratings dashboard integration
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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 {
|
||||
CreateRatingParams,
|
||||
IRating,
|
||||
IRatingsManager,
|
||||
PendingRatingItem,
|
||||
PendingRatingsByUserParams,
|
||||
RATING_COMMENT_MAX_LENGTH,
|
||||
RatingTargetType,
|
||||
} from "./Ratings.Interface";
|
||||
|
||||
const PENDING_RATINGS_APPOINTMENT_LIMIT = 50;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user