feat: implement rating summary service methods and integrate rating display components into the organization header and pending ratings page

This commit is contained in:
2026-07-22 12:13:50 -03:00
parent 383a5bc6b4
commit da3c3abb52
27 changed files with 1334 additions and 104 deletions
@@ -1,10 +1,33 @@
import { Document, Model, Schema, Types, model, models } from "mongoose";
import { FindRatingsParams, IRating, IRatingsAdapter, RATING_COMMENT_MAX_LENGTH, RatingTargetType } from "./Ratings.Interface";
import getAvatar from "../../helpers/getAvatar";
import { joinStrings } from "../../helpers/String";
import {
FindRatingsParams,
IRating,
IRatingsAdapter,
RATING_COMMENT_MAX_LENGTH,
RatingTargetSummaryParams,
RatingTargetSummaryResult,
RatingTargetReviewItem,
RatingTargetReviewsParams,
RatingTargetType,
} from "./Ratings.Interface";
export interface IRatingDocument extends Omit<IRating, "id">, Document {
_id: Types.ObjectId;
}
type RatingReviewDocument = IRatingDocument & {
userId?: {
_id: Types.ObjectId;
firstName?: string;
lastName?: string;
avatar?: string;
};
};
const GENERIC_REVIEW_AUTHOR = "Cliente verificado";
export class RatingsAdapterMongoose implements IRatingsAdapter {
schema: Schema;
ratingsList: Model<IRatingDocument>;
@@ -44,6 +67,97 @@ export class RatingsAdapterMongoose implements IRatingsAdapter {
return rating ? this.mapRating(rating) : null;
}
public async count(filters: FindRatingsParams): Promise<number> {
return this.ratingsList.countDocuments(filters).exec();
}
public async summaryByTarget(filters: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult> {
const [summary] = await this.ratingsList
.aggregate<{ averageScore: number; totalCount: number }>([
{
$match: {
targetType: filters.targetType,
targetId: new Types.ObjectId(filters.targetId),
},
},
{
$group: {
_id: null,
averageScore: { $avg: "$score" },
totalCount: { $sum: 1 },
},
},
])
.exec();
return {
averageScore: summary?.averageScore || 0,
totalCount: summary?.totalCount || 0,
};
}
public async summaryByTargets(filters: { targetType: RatingTargetType; targetIds: string[] }): Promise<RatingTargetSummaryResult> {
const targetIds = filters.targetIds.filter((targetId) => Types.ObjectId.isValid(targetId));
if (targetIds.length === 0) {
return { averageScore: 0, totalCount: 0 };
}
const [summary] = await this.ratingsList
.aggregate<{ averageScore: number; totalCount: number }>([
{
$match: {
targetType: filters.targetType,
targetId: { $in: targetIds.map((targetId) => new Types.ObjectId(targetId)) },
},
},
{
$group: {
_id: null,
averageScore: { $avg: "$score" },
totalCount: { $sum: 1 },
},
},
])
.exec();
return {
averageScore: summary?.averageScore || 0,
totalCount: summary?.totalCount || 0,
};
}
public async reviewsByTarget(filters: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]> {
const ratings = await this.ratingsList
.find({
targetType: filters.targetType,
targetId: new Types.ObjectId(filters.targetId),
comment: { $regex: /\S/ },
})
.sort({ createdAt: -1 })
.limit(filters.limit || 20)
.select("score comment createdAt userId")
.populate("userId", "firstName lastName avatar")
.exec();
return (ratings as unknown as RatingReviewDocument[]).map((rating) => {
const user = rating.userId;
const displayName = user ? joinStrings([user.firstName, user.lastName], " ") : "";
const authorName = displayName || GENERIC_REVIEW_AUTHOR;
return {
id: rating._id.toString(),
score: rating.score,
comment: rating.comment || "",
createdAt: rating.createdAt,
author: {
displayName: authorName,
avatarUrl: user ? getAvatar(user._id.toString(), user.avatar, "") : "",
},
};
});
}
private mapRating(rating: IRatingDocument): IRating {
return {
id: rating._id.toString(),
@@ -30,6 +30,43 @@ export type PendingRatingsByUserParams = {
sessionUser: string;
};
export type CountRatingsByUserParams = {
sessionUser: string;
};
export type CountRatingsByUserResult = {
count: number;
};
export type ProfessionalRatingSummaryByUserParams = {
sessionUser: string;
};
export type RatingTargetSummaryParams = {
targetType: RatingTargetType;
targetId: string;
};
export type RatingTargetSummaryResult = {
averageScore: number;
totalCount: number;
};
export type RatingTargetReviewsParams = RatingTargetSummaryParams & {
limit?: number;
};
export type RatingTargetReviewItem = {
id: string;
score: number;
comment: string;
createdAt: Date;
author: {
displayName: string;
avatarUrl: string;
};
};
export interface IRating {
id?: string;
appointmentId: string;
@@ -61,10 +98,18 @@ export interface IRatingsAdapter {
create(data: Omit<IRating, "id" | "createdAt" | "updatedAt">): Promise<IRating>;
find(filters: FindRatingsParams): Promise<IRating[]>;
findOne(filters: FindRatingsParams): Promise<IRating | null>;
count(filters: FindRatingsParams): Promise<number>;
summaryByTarget(filters: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult>;
summaryByTargets(filters: { targetType: RatingTargetType; targetIds: string[] }): Promise<RatingTargetSummaryResult>;
reviewsByTarget(filters: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]>;
}
export interface IRatingsManager {
ratings: IRatingsAdapter;
createRating(data: CreateRatingParams): Promise<IRating>;
pendingByUser(data: PendingRatingsByUserParams): Promise<PendingRatingItem[]>;
countByUser(data: CountRatingsByUserParams): Promise<CountRatingsByUserResult>;
professionalSummaryByUser(data: ProfessionalRatingSummaryByUserParams): Promise<RatingTargetSummaryResult>;
summaryByTarget(data: RatingTargetSummaryParams): Promise<RatingTargetSummaryResult>;
reviewsByTarget(data: RatingTargetReviewsParams): Promise<RatingTargetReviewItem[]>;
}
+54
View File
@@ -9,16 +9,24 @@ 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,
RatingTargetReviewItem,
RatingTargetReviewsParams,
RatingTargetType,
} from "./Ratings.Interface";
const PENDING_RATINGS_APPOINTMENT_LIMIT = 50;
const TARGET_REVIEWS_LIMIT = 20;
class RatingsManager implements IRatingsManager {
ratings: RatingsAdapterMongoose;
@@ -180,6 +188,52 @@ class RatingsManager implements IRatingsManager {
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<RatingTargetReviewItem[]> {
this.validateTargetType(data.targetType);
if (!Types.ObjectId.isValid(data.targetId)) {
throw new Error("No se ha proporcionado un objetivo válido.");
}
return this.ratings.reviewsByTarget({
...data,
limit: Math.min(Math.max(data.limit || TARGET_REVIEWS_LIMIT, 1), TARGET_REVIEWS_LIMIT),
});
}
private async validateSessionUser(sessionUser: string): Promise<void> {
const user = await UsersManager.users.findOne({ _id: sessionUser });