Skip to content

Commit

Permalink
feat: properly sort coursecards based on sort arguments
Browse files Browse the repository at this point in the history
  • Loading branch information
ap-1 committed Feb 4, 2025
1 parent 2a9e98d commit ea45ab9
Showing 1 changed file with 181 additions and 8 deletions.
189 changes: 181 additions & 8 deletions apps/backend/src/controllers/courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
} from "~/util";
import { RequestHandler } from "express";
import db, { Prisma } from "@cmucourses/db";
import { SortOption, SortType, type Sort } from "~/../../apps/frontend/src/app/sorts";
import { initialState } from "~/../../apps/frontend/src/app/user";

const projection = { _id: false, __v: false };
const MAX_LIMIT = 10;
Expand Down Expand Up @@ -96,6 +98,11 @@ export interface GetFilteredCourses {
levels?: string;
session?: SingleOrArray<string>;
fces?: BoolLiteral;
sort?: SingleOrArray<string>;
numSemesters?: string;
spring?: BoolLiteral;
summer?: BoolLiteral;
fall?: BoolLiteral;
};
}

Expand All @@ -115,12 +122,10 @@ export const getFilteredCourses: RequestHandler<
const pipeline: Prisma.InputJsonValue[] = [];

const matchStage: Record<string, unknown> = {};
const sortKeys: [string, unknown][] = [];
const addedFields: Record<string, unknown> = {};

if (req.query.keywords !== undefined) {
matchStage.$text = { $search: req.query.keywords };
sortKeys.push(["score", { $meta: "textScore" }]);
addedFields.relevance = { $meta: "textScore" };
}

Expand Down Expand Up @@ -196,13 +201,181 @@ export const getFilteredCourses: RequestHandler<
pipeline.push({ $addFields: addedFields as Prisma.InputJsonValue });
pipeline.push({ $project: projection as Prisma.InputJsonValue });

if (sortKeys.length > 0) {
const sortOptions: Record<string, unknown> = {};
for (const [key, option] of sortKeys) {
sortOptions[key] = option;
}
if (req.query.sort !== undefined) {
const numSemesters = parseOptionalInt(req.query.numSemesters, initialState.fceAggregation.numSemesters);
const spring = !req.query.spring ? initialState.fceAggregation.counted.spring : fromBoolLiteral(req.query.spring);
const summer = !req.query.summer ? initialState.fceAggregation.counted.summer : fromBoolLiteral(req.query.summer);
const fall = !req.query.fall ? initialState.fceAggregation.counted.fall : fromBoolLiteral(req.query.fall);

// Add a $lookup stage to join the fces collection
pipeline.push({
$lookup: {
from: "fces",
localField: "courseID",
foreignField: "courseID",
as: "fces",
},
});

pipeline.push({ $sort: sortOptions as Prisma.InputJsonValue });
// Unwind the fces array to de-normalize the data
pipeline.push({
$unwind: {
path: "$fces",
preserveNullAndEmptyArrays: true,
},
});

// Filter FCEs based on the counted settings
pipeline.push({
$match: {
$expr: {
$or: [
{ $and: [{ $eq: ["$fces.semester", "spring"] }, { $eq: [spring, true] }] },
{ $and: [{ $eq: ["$fces.semester", "summer"] }, { $eq: [summer, true] }] },
{ $and: [{ $eq: ["$fces.semester", "fall"] }, { $eq: [fall, true] }] },
],
},
},
});

// TODO: These take a long time to run and then return a 304 with no results
// So only the seasons from fceAggregation.counted are respected for now

// Sort FCEs by year and semester to find the latest ones
// pipeline.push({
// $addFields: {
// numericYear: { $toInt: "$fces.year" },
// numericSemester: {
// $switch: {
// branches: [
// { case: { $eq: ["$fces.semester", "fall"] }, then: 3 },
// { case: { $eq: ["$fces.semester", "summer"] }, then: 2 },
// { case: { $eq: ["$fces.semester", "spring"] }, then: 1 },
// ],
// default: 0,
// },
// },
// },
// });

// pipeline.push({
// $sort: {
// numericYear: SortType.Descending,
// numericSemester: SortType.Descending,
// },
// });

// Limit the number of FCEs to the specified numSemesters
// pipeline.push({
// $group: {
// _id: "$courseID",
// fces: { $push: "$fces" },
// },
// });

// pipeline.push({
// $project: {
// fces: { $slice: ["$fces", numSemesters] },
// },
// });

// Group by courseID to calculate aggregated values
pipeline.push({
$group: {
_id: "$courseID",
doc: { $first: "$$ROOT" },
avgTeachingRate: { $avg: { $arrayElemAt: ["$fces.rating", 7] } },
avgCourseRate: { $avg: { $arrayElemAt: ["$fces.rating", 8] } },
},
});

// Add the aggregated fields back to the root document
pipeline.push({
$replaceRoot: {
newRoot: {
$mergeObjects: ["$doc", { avgTeachingRate: "$avgTeachingRate", avgCourseRate: "$avgCourseRate" }],
},
},
});

const sorts = singleToArray(req.query.sort)
.reverse()
.map((sort) => JSON.parse(sort) as Sort);

sorts.forEach((sort) => {
switch (sort.option) {
case SortOption.FCE:
pipeline.push({
$addFields: {
fce: {
$avg: "$fces.hrsPerWeek",
},
},
});
pipeline.push({
$sort: {
fce: sort.type,
},
});
break;
case SortOption.TeachingRate:
pipeline.push({
$sort: {
avgTeachingRate: sort.type,
},
});
break;
case SortOption.CourseRate:
pipeline.push({
$sort: {
avgCourseRate: sort.type,
},
});
break;
case SortOption.Units:
pipeline.push({
$addFields: {
numericUnits: {
$cond: {
if: { $or: [{ $eq: ["$units", ""] }, { $eq: ["$units", "VAR"] }] },
then: 0,
else: {
$toDouble: {
$trim: {
input: {
// Some courses have units listed as 0-48 for example
// We don't want to complicate the pipeline, so just take the lower bound
$arrayElemAt: [{ $split: [{ $arrayElemAt: [{ $split: ["$units", "-"] }, 0] }, ","] }, 0],
},
},
},
},
},
},
},
});
pipeline.push({
$sort: {
numericUnits: sort.type,
},
});
break;
case SortOption.CourseNumber:
pipeline.push({
$addFields: {
numericCourseID: {
$toInt: { $arrayElemAt: [{ $split: ["$courseID", "-"] }, 1] },
},
},
});
pipeline.push({
$sort: {
numericCourseID: sort.type,
},
});
break;
}
});
}

const page = parseOptionalInt(req.query.page, 1);
Expand Down

0 comments on commit ea45ab9

Please sign in to comment.