Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
1770a6f
added written review field to rating submission and modified ui to su…
Henrp Mar 26, 2026
3c65d96
Add written review field to rating submission
Henrp Mar 26, 2026
e4bb2d1
fix
Henrp Mar 26, 2026
c8a8c84
fix
Henrp Mar 26, 2026
8c33dd7
removed class selection
Henrp Mar 27, 2026
0df15bf
added review to progress bar calculation
Henrp Mar 31, 2026
ee66dcf
modify UserFeedbackModel when user has less than 3 reviews (Previosul…
sambai-1 Apr 13, 2026
c9e6ef6
add frontend for ratings (changed from single ratings to having ratin…
sambai-1 Apr 13, 2026
2dc2ade
update old review model to soft delete
sambai-1 Apr 13, 2026
321fe8f
update backend reviews (from single text -> reviewTitle and reviewCon…
sambai-1 Apr 13, 2026
4e4083a
add GET_CLASS_REVIEWS and GET_CLASS_RATINGS as addional queries rathe…
sambai-1 Apr 13, 2026
410607e
add reviewTitle and reviewContent to your ratings
sambai-1 Apr 13, 2026
400d36d
remove classRating into classReviwes specifically for the wirtten rev…
sambai-1 Apr 13, 2026
36c0977
add reviewerGrade
sambai-1 Apr 13, 2026
6b1e967
revert ratings config to require 3 ratings
sambai-1 Apr 13, 2026
f063fa7
change rating unlock to 0 for testing
sambai-1 Apr 13, 2026
d26c504
update top half of ratings
sambai-1 Apr 14, 2026
ff01902
add general ratings, has not implimented sorting
sambai-1 Apr 14, 2026
eaaa374
add filter selection and fix spacing
sambai-1 Apr 14, 2026
f5df282
ui fix for many things. aligns with final design now
Henrp Apr 16, 2026
eb80c49
format
Henrp Apr 16, 2026
b68ea9f
helpful button backend & minor ui fixes
Henrp Apr 16, 2026
0cd0e31
format
Henrp Apr 16, 2026
58dbf88
fixes
Henrp Apr 16, 2026
b588290
gap to space-1
Henrp Apr 16, 2026
53e8a38
please be respectful in your reviews
Henrp Apr 16, 2026
af0f9e5
fixes
Henrp Apr 17, 2026
8dde893
format
Henrp Apr 17, 2026
3c40f9f
fixes
Henrp Apr 18, 2026
6d5c8f0
format
Henrp Apr 18, 2026
6f03a19
revert to original overview layout & replaced overview with ratings …
Henrp Apr 21, 2026
315213d
format
Henrp Apr 21, 2026
f513126
color change
Henrp Apr 22, 2026
f611ba0
gap fix for UserRatingSumamry
Henrp Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 323 additions & 3 deletions apps/backend/src/modules/rating/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
CourseModel,
RatingModel,
RatingType,
ReviewModel,
SectionModel,
} from "@repo/common/models";
import { METRIC_MAPPINGS, REQUIRED_METRICS } from "@repo/shared";
Expand Down Expand Up @@ -493,7 +494,50 @@ export const getUserRatings = async (context: RequestContext) => {
count: 0,
classes: [],
};
return formatUserRatings(userRatings[0]);
const formattedUserRatings = formatUserRatings(userRatings[0]);
const activeReviews = await ReviewModel.find({
createdBy: context.user._id,
$or: [{ valid: true }, { valid: { $exists: false } }],
})
.select(
"subject courseNumber reviewTitle reviewContent reviewerGrade updatedAt"
)
.sort({ updatedAt: -1 })
.lean();

const reviewByCourse = new Map<
string,
{
reviewTitle?: string | null;
reviewContent?: string | null;
reviewerGrade?: string | null;
}
>();
activeReviews.forEach((review) => {
const key = `${review.subject}|${review.courseNumber}`;
if (!reviewByCourse.has(key)) {
reviewByCourse.set(key, {
reviewTitle: review.reviewTitle ?? null,
reviewContent: review.reviewContent ?? null,
reviewerGrade: (review.reviewerGrade as string | null) ?? "n/a",
});
}
});

return {
...formattedUserRatings,
classes: formattedUserRatings.classes.map((ratedClass) => {
const review = reviewByCourse.get(
`${ratedClass.subject}|${ratedClass.courseNumber}`
);
return {
...ratedClass,
reviewTitle: review?.reviewTitle ?? null,
reviewContent: review?.reviewContent ?? null,
reviewerGrade: review?.reviewerGrade ?? "n/a",
};
}),
};
};

const filterAggregatedMetrics = (
Expand Down Expand Up @@ -934,7 +978,10 @@ export const createRatings = async (
subject: string,
courseNumber: string,
classNumber: string,
metrics: MetricInput[]
metrics: MetricInput[],
reviewTitle?: string | null,
reviewContent?: string | null,
reviewerGrade?: string | null
) => {
if (!context.user._id) {
throw new GraphQLError("Unauthorized", {
Expand Down Expand Up @@ -1011,7 +1058,77 @@ export const createRatings = async (
]);
}

// Step 2: Create all new ratings and increment their aggregated counts
// Step 2: Soft-delete and create review snapshots
// Update or create the review for this user/course.
if (
reviewTitle !== undefined ||
reviewContent !== undefined ||
reviewerGrade !== undefined
) {
const normalizedTitle = (reviewTitle ?? "").trim();
const normalizedContent = (reviewContent ?? "").trim();
const normalizedReviewerGrade =
(reviewerGrade ?? "n/a").trim() || "n/a";
const hasReviewPayload =
normalizedTitle.length > 0 || normalizedContent.length > 0;

const existingReview = await ReviewModel.findOne({
createdBy: context.user._id,
courseId,
$or: [{ valid: true }, { valid: { $exists: false } }],
}).session(session);

if (!hasReviewPayload) {
if (existingReview) {
await ReviewModel.updateOne(
{ _id: existingReview._id },
{ $set: { valid: false } },
{ session }
);
}
} else if (existingReview) {
await ReviewModel.updateOne(
{ _id: existingReview._id },
{
$set: {
reviewTitle: normalizedTitle,
reviewContent: normalizedContent,
reviewerGrade: normalizedReviewerGrade,
classId,
subject,
courseNumber,
semester,
year,
classNumber,
valid: true,
},
},
{ session }
);
} else {
await ReviewModel.create(
[
{
createdBy: context.user._id,
courseId,
reviewTitle: normalizedTitle,
reviewContent: normalizedContent,
reviewerGrade: normalizedReviewerGrade,
classId,
subject,
courseNumber,
semester,
year,
classNumber,
valid: true,
},
],
{ session }
);
}
}

// Step 3: Create all new ratings and increment their aggregated counts
for (const metric of metrics) {
await Promise.all([
RatingModel.create(
Expand Down Expand Up @@ -1085,6 +1202,15 @@ export const deleteRatings = async (
const session = await connection.startSession();
try {
await session.withTransaction(async () => {
// Delete reviews for all affected courseIds
await ReviewModel.deleteMany(
{
createdBy: context.user._id,
courseId: { $in: Array.from(affectedCourseIds) },
},
{ session }
);

// Delete all ratings and decrement their aggregated counts
for (const existingRating of existingRatings) {
await Promise.all([
Expand Down Expand Up @@ -1119,6 +1245,42 @@ const anonymizeUserId = (userId: string): string => {
return createHash("sha256").update(userId).digest("hex").slice(0, 16);
};

export const voteReviewHelpful = async (
context: RequestContext,
reviewId: string
): Promise<number> => {
if (!context.user._id) {
throw new GraphQLError("Unauthorized", {
extensions: { code: "UNAUTHENTICATED" },
});
}

const review = await ReviewModel.findById(reviewId);
if (!review) {
throw new GraphQLError("Review not found", {
extensions: { code: "NOT_FOUND" },
});
}

const userId = context.user._id;
const alreadyVoted =
(review.helpfulVoters as string[] | undefined)?.includes(userId) ?? false;

if (alreadyVoted) {
await ReviewModel.updateOne(
{ _id: review._id },
{ $pull: { helpfulVoters: userId }, $inc: { helpfulCount: -1 } }
);
return Math.max(0, ((review.helpfulCount as number | undefined) ?? 0) - 1);
} else {
await ReviewModel.updateOne(
{ _id: review._id },
{ $addToSet: { helpfulVoters: userId }, $inc: { helpfulCount: 1 } }
);
return ((review.helpfulCount as number | undefined) ?? 0) + 1;
}
};

export const getAllRatings = async () => {
const ratings = await RatingModel.find({}).lean();
const shadowBannedCourseIds = await getShadowBannedCourseIds();
Expand Down Expand Up @@ -1155,3 +1317,161 @@ export const getAllRatings = async () => {
: new Date().toISOString(),
}));
};

export const getClassRatings = async (
subject: string,
courseNumber: string
) => {
const ratings = await RatingModel.find({ subject, courseNumber }).lean();
const sections = await SectionModel.find({ subject, courseNumber })
.select("semester year classNumber number meetings")
.lean();
const shadowBannedCourseIds = await getShadowBannedCourseIds();
const crossListedShadowBanEnabled = isShadowBanCrossListedEnabled();

const visibleRatings = ratings.filter((rating) => {
if (isSubjectShadowBanned(rating.subject)) {
return false;
}

if (
crossListedShadowBanEnabled &&
typeof rating.courseId === "string" &&
shadowBannedCourseIds.has(rating.courseId)
) {
return false;
}

return true;
});
const activeReviews = await ReviewModel.find({
subject,
courseNumber,
$or: [{ valid: true }, { valid: { $exists: false } }],
})
.sort({ updatedAt: -1 })
.lean();

const reviewByUser = new Map<
string,
{
reviewId: string;
reviewTitle?: string | null;
reviewContent?: string | null;
reviewerGrade?: string | null;
helpfulCount: number;
}
>();
activeReviews.forEach((review) => {
if (reviewByUser.has(review.createdBy)) return;
reviewByUser.set(review.createdBy, {
reviewId: review._id?.toString() ?? "",
reviewTitle: review.reviewTitle ?? null,
reviewContent: review.reviewContent ?? null,
reviewerGrade: (review.reviewerGrade as string | null) ?? "n/a",
helpfulCount: (review.helpfulCount as number | undefined) ?? 0,
});
});

const instructorNamesByClassKey = new Map<string, Set<string>>();
sections.forEach((section) => {
const classNumber = section.classNumber ?? section.number;
if (!classNumber) return;
const classKey = `${section.semester}|${section.year}|${classNumber}`;

const names = instructorNamesByClassKey.get(classKey) ?? new Set<string>();
section.meetings?.forEach((meeting) => {
meeting.instructors?.forEach((instructor) => {
if (
instructor.role === "PI" &&
instructor.givenName &&
instructor.familyName
) {
names.add(`${instructor.givenName} ${instructor.familyName}`);
}
});
});
instructorNamesByClassKey.set(classKey, names);
});

type UserClassEntry = {
subject: string;
courseNumber: string;
semester: Semester;
year: number;
classNumber: string;
professorName?: string | null;
metrics: { metricName: MetricName; value: number }[];
reviewTitle?: string | null;
reviewContent?: string | null;
reviewerGrade?: string | null;
lastUpdated: string;
reviewId?: string | null;
helpfulCount: number;
};

const userClassesById = new Map<string, Map<string, UserClassEntry>>();
visibleRatings.forEach((rating) => {
const userId = rating.createdBy;
const classKey = `${rating.semester}|${rating.year}|${rating.classNumber}`;
const userClasses = userClassesById.get(userId) ?? new Map();
const existingClass = userClasses.get(classKey);
const timestamp =
"updatedAt" in rating && rating.updatedAt instanceof Date
? rating.updatedAt
: "createdAt" in rating && rating.createdAt instanceof Date
? rating.createdAt
: new Date(0);

if (!existingClass) {
const review = reviewByUser.get(userId);
userClasses.set(classKey, {
subject: rating.subject,
courseNumber: rating.courseNumber,
semester: rating.semester as Semester,
year: rating.year,
classNumber: rating.classNumber,
professorName:
Array.from(instructorNamesByClassKey.get(classKey) ?? []).join(
", "
) || null,
metrics: [
{
metricName: rating.metricName as MetricName,
value: rating.value,
},
],
reviewTitle: review?.reviewTitle ?? null,
reviewContent: review?.reviewContent ?? null,
reviewerGrade: review?.reviewerGrade ?? "n/a",
lastUpdated: timestamp.toISOString(),
reviewId: review?.reviewId ?? null,
helpfulCount: review?.helpfulCount ?? 0,
});
userClassesById.set(userId, userClasses);
return;
}

existingClass.metrics.push({
metricName: rating.metricName as MetricName,
value: rating.value,
});
if (timestamp.toISOString() > existingClass.lastUpdated) {
existingClass.lastUpdated = timestamp.toISOString();
}
});

const users = Array.from(userClassesById.entries()).map(
([userId, classes]) => ({
anonymousUserId: anonymizeUserId(userId),
classes: Array.from(classes.values()),
})
);

return {
subject,
courseNumber,
count: users.length,
users,
};
};
8 changes: 8 additions & 0 deletions apps/backend/src/modules/rating/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const formatUserRatings = (ratings: UserRatings): UserRatings => {
value: userMetric.value,
})),

reviewTitle: (userClass as UserClass & { reviewTitle?: string | null })
.reviewTitle,
reviewContent: (userClass as UserClass & { reviewContent?: string | null })
.reviewContent,
lastUpdated: userClass.lastUpdated?.toString(),
})),
};
Expand Down Expand Up @@ -91,6 +95,10 @@ export const formatUserClassRatings = (ratings: UserClass): UserClass => {
metricName: metric.metricName as MetricName,
value: metric.value,
})),
reviewTitle: (ratings as UserClass & { reviewTitle?: string | null })
.reviewTitle,
reviewContent: (ratings as UserClass & { reviewContent?: string | null })
.reviewContent,
lastUpdated: ratings.lastUpdated,
};
};
Expand Down
Loading