diff --git a/backend/src/controllers/course.controller.ts b/backend/src/controllers/course.controller.ts index 2d0698ceb..5582fd2e4 100644 --- a/backend/src/controllers/course.controller.ts +++ b/backend/src/controllers/course.controller.ts @@ -43,9 +43,8 @@ export class CourseController implements IController { if (offsetStr !== undefined) { offset = parseInt(offsetStr); } - const result = await this.courseService.getCoursesFromOffset( - offset, - ); + const result = + await this.courseService.getCoursesFromOffset(offset); this.logger.info(`Responding to client in GET /courses`); return res.status(200).json(result); } catch (err: any) { @@ -58,6 +57,64 @@ export class CourseController implements IController { } }, ) + .get( + "/wrapped/course/highest-rated/:term", + async ( + req: Request<{ term: string }, unknown>, + res: Response, + next: NextFunction, + ) => { + this.logger.debug( + `Received request in GET /course/highest-rated/:term`, + ); + try { + const term: string = req.params.term; + const result = + await this.courseService.getHighestRatedCourseInTerm(term); + this.logger.info( + `Responding to client in GET /wrapped/course/highest-rated/${term}`, + ); + return res.status(200).json(result); + } catch (err: any) { + this.logger.warn( + `An error occurred when trying to GET /wrapped/course/highest-rated ${formatError( + err, + )}`, + ); + return next(err); + } + }, + ) + .get( + "/wrapped/course/highest-attribute/:attribute", + async ( + req: Request<{ attribute: string }, unknown>, + res: Response, + next: NextFunction, + ) => { + this.logger.debug( + `Received request in GET /wrapped/course/highest-attribute/:attribute`, + ); + try { + const attribute: string = req.params.attribute; + const result = + await this.courseService.getCourseWithHighestRatedAttribute( + attribute, + ); + this.logger.info( + `Responding to client in GET /wrapped/course/highest-attribute/${attribute}`, + ); + return res.status(200).json(result); + } catch (err: any) { + this.logger.warn( + `An error occurred when trying to GET /wrapped/course/highest-attribute ${formatError( + err, + )}`, + ); + return next(err); + } + }, + ) .get( "/course/:courseCode", async ( diff --git a/backend/src/controllers/review.controller.ts b/backend/src/controllers/review.controller.ts index e39ee42aa..648aca8d3 100644 --- a/backend/src/controllers/review.controller.ts +++ b/backend/src/controllers/review.controller.ts @@ -45,6 +45,26 @@ export class ReviewController implements IController { } }, ) + .get( + "/wrapped/reviews/most-liked", + async (req: Request, res: Response, next: NextFunction) => { + this.logger.debug(`Received request in /wrapped/reviews/most-liked`); + try { + const result = await this.reviewService.getMostLiked(); + this.logger.info( + `Responding to client in GET /wrapped/reviews/most-liked`, + ); + return res.status(200).json(result); + } catch (err: any) { + this.logger.warn( + `An error occurred when trying to GET /wrapped/reviews/most-liked ${formatError( + err, + )}`, + ); + return next(err); + } + }, + ) .get( "/reviews/:courseCode", async ( @@ -55,9 +75,8 @@ export class ReviewController implements IController { this.logger.debug(`Received request in /reviews/:courseCode`); try { const courseCode: string = req.params.courseCode; - const result = await this.reviewService.getCourseReviews( - courseCode, - ); + const result = + await this.reviewService.getCourseReviews(courseCode); this.logger.info( `Responding to client in GET /reviews/${courseCode}`, ); @@ -72,6 +91,7 @@ export class ReviewController implements IController { } }, ) + .post( "/reviews", [verifyToken, validationMiddleware(PostReviewSchema, "body")], @@ -167,9 +187,8 @@ export class ReviewController implements IController { try { const reviewDetails = req.body; if (!reviewDetails) throw new HTTPError(badRequest); - const result = await this.reviewService.bookmarkReview( - reviewDetails, - ); + const result = + await this.reviewService.bookmarkReview(reviewDetails); this.logger.info(`Responding to client in POST /reviews/bookmark`); return res.status(200).json(result); } catch (err: any) { diff --git a/backend/src/repositories/course.repository.ts b/backend/src/repositories/course.repository.ts index dcc2621f5..51b571bb5 100644 --- a/backend/src/repositories/course.repository.ts +++ b/backend/src/repositories/course.repository.ts @@ -4,8 +4,6 @@ import { CourseCodeSchema, CourseSchema, } from "../api/schemas/course.schema"; -import e from "express"; -import { Console } from "console"; export class CourseRepository { constructor(private readonly prisma: PrismaClient) {} @@ -353,4 +351,79 @@ export class CourseRepository { return courses; } + + async getCourseWithHighestRatedAttribute(attribute: string) { + // attribute string is sanitised before query is called + const rawCourse = (await this.prisma.$queryRawUnsafe(` + SELECT + c.course_code AS "courseCode", + c.archived, + c.attributes, + c.calendar, + c.campus, + c.description, + c.enrolment_rules AS "enrolmentRules", + c.equivalents, + c.exclusions, + c.faculty, + c.field_of_education AS "fieldOfEducation", + c.gen_ed AS "genEd", + c.level, + c.school, + c.study_level AS "studyLevel", + c.terms, + c.title, + c.uoc, + AVG(r.overall_rating) AS "overallRating", + AVG(r.manageability) AS "manageability", + AVG(r.usefulness) AS "usefulness", + AVG(r.enjoyability) AS "enjoyability", + CAST(COUNT(r.review_id) AS INT) AS "reviewCount" + FROM courses c + LEFT JOIN reviews r ON c.course_code = r.course_code + WHERE cardinality(c.terms) > 0 + GROUP BY c.course_code + ORDER BY ${attribute} DESC NULLS LAST, "reviewCount" DESC NULLS LAST + LIMIT 1; + `)) as any[]; + const course = CourseSchema.parse(rawCourse[0]); + return course; + } + + async getHighestRatedCourseInTerm(term: string) { + const rawCourse = (await this.prisma.$queryRaw` + SELECT + c.course_code AS "courseCode", + c.archived, + c.attributes, + c.calendar, + c.campus, + c.description, + c.enrolment_rules AS "enrolmentRules", + c.equivalents, + c.exclusions, + c.faculty, + c.field_of_education AS "fieldOfEducation", + c.gen_ed AS "genEd", + c.level, + c.school, + c.study_level AS "studyLevel", + c.terms, + c.title, + c.uoc, + AVG(r.overall_rating) AS "overallRating", + AVG(r.manageability) AS "manageability", + AVG(r.usefulness) AS "usefulness", + AVG(r.enjoyability) AS "enjoyability", + CAST(COUNT(r.review_id) AS INT) AS "reviewCount" + FROM courses c + LEFT JOIN reviews r ON c.course_code = r.course_code + WHERE c.terms @> ARRAY[${term}]::integer[] + GROUP BY c.course_code + ORDER BY "overallRating" DESC NULLS LAST, "reviewCount" DESC NULLS LAST + LIMIT 1; + `) as any[]; + const course = CourseSchema.parse(rawCourse[0]); + return course; + } } diff --git a/backend/src/repositories/review.repository.ts b/backend/src/repositories/review.repository.ts index 18379ff50..d8753543d 100644 --- a/backend/src/repositories/review.repository.ts +++ b/backend/src/repositories/review.repository.ts @@ -1,5 +1,8 @@ import { PrismaClient, reviews } from "@prisma/client"; -import { PostReviewRequestBody } from "../api/schemas/review.schema"; +import { + PostReviewRequestBody, + ReviewSchema, +} from "../api/schemas/review.schema"; export class ReviewRepository { constructor(private readonly prisma: PrismaClient) {} @@ -84,4 +87,30 @@ export class ReviewRepository { }, }); } + + async getMostLiked() { + const rawReview = (await this.prisma.$queryRaw` + SELECT + r.review_id AS "reviewId", + r.zid, + r.course_code AS "courseCode", + r.author_name AS "authorName", + r.title, + r.description, + r.grade, + r.term_taken AS "termTaken", + r.created_timestamp AS "createdTimestamp", + r.updated_timestamp AS "updatedTimestamp", + r.upvotes, + r.manageability, + r.enjoyability, + r.usefulness, + r.overall_rating AS "overallRating" + FROM reviews r + ORDER BY cardinality(r.upvotes) DESC + LIMIT 1; + `) as any[]; + const review = ReviewSchema.parse(rawReview[0]); + return review; + } } diff --git a/backend/src/services/course.service.test.ts b/backend/src/services/course.service.test.ts index f33a2329f..bf841ec37 100644 --- a/backend/src/services/course.service.test.ts +++ b/backend/src/services/course.service.test.ts @@ -83,4 +83,102 @@ describe("CourseService", () => { }); }); }); + + describe("getCourseWithHighestRatedAttribute", () => { + it("should throw HTTP 500 if there is no course in the database", () => { + const service = courseService(); + courseRepository.getCourseWithHighestRatedAttribute = jest + .fn() + .mockResolvedValue(undefined); + const errorResult = new HTTPError(badRequest); + expect( + service.getCourseWithHighestRatedAttribute("manageability"), + ).rejects.toThrow(errorResult); + }); + + it("should throw HTTP 500 error if given an invalid attribute", () => { + const service = courseService(); + courseRepository.getHighestRatedCourseInTerm = jest + .fn() + .mockResolvedValue(undefined); + const errorResult = new HTTPError(badRequest); + expect( + service.getCourseWithHighestRatedAttribute("ratings"), + ).rejects.toThrow(errorResult); + }); + + it("should resolve and return the course with the highest manageability", () => { + const service = courseService(); + const courses = getMockCourses(); + courseRepository.getCourseWithHighestRatedAttribute = jest + .fn() + .mockResolvedValue(courses[0]); + expect( + service.getCourseWithHighestRatedAttribute("manageability"), + ).resolves.toEqual({ + courseCode: courses[0].courseCode, + }); + }); + + it("should resolve and return the course with the highest usefulness", () => { + const service = courseService(); + const courses = getMockCourses(); + courseRepository.getCourseWithHighestRatedAttribute = jest + .fn() + .mockResolvedValue(courses[0]); + expect( + service.getCourseWithHighestRatedAttribute("usefulness"), + ).resolves.toEqual({ + courseCode: courses[0].courseCode, + }); + }); + + it("should resolve and return the course with the highest enjoyability", () => { + const service = courseService(); + const courses = getMockCourses(); + courseRepository.getCourseWithHighestRatedAttribute = jest + .fn() + .mockResolvedValue(courses[0]); + expect( + service.getCourseWithHighestRatedAttribute("enjoyability"), + ).resolves.toEqual({ + courseCode: courses[0].courseCode, + }); + }); + }); + + describe("getHighestRatedCourseInTerm", () => { + it("should throw HTTP 500 if there is no course in the database", () => { + const service = courseService(); + courseRepository.getHighestRatedCourseInTerm = jest + .fn() + .mockResolvedValue(undefined); + const errorResult = new HTTPError(badRequest); + expect(service.getHighestRatedCourseInTerm("1")).rejects.toThrow( + errorResult, + ); + }); + + it("should throw HTTP 500 error if given an invalid term", () => { + const service = courseService(); + courseRepository.getHighestRatedCourseInTerm = jest + .fn() + .mockResolvedValue(undefined); + const errorResult = new HTTPError(badRequest); + expect(service.getHighestRatedCourseInTerm("21")).rejects.toThrow( + errorResult, + ); + }); + + it("should resolve and return the course with the highest rating in a term", () => { + const service = courseService(); + const courses = getMockCourses(); + courseRepository.getHighestRatedCourseInTerm = jest + .fn() + .mockResolvedValue(courses[0]); + expect(service.getHighestRatedCourseInTerm("1")).resolves.toEqual({ + courseCode: courses[0].courseCode, + }); + }); + }); }); diff --git a/backend/src/services/course.service.ts b/backend/src/services/course.service.ts index 2dd7990a1..96c8f8f9a 100644 --- a/backend/src/services/course.service.ts +++ b/backend/src/services/course.service.ts @@ -130,6 +130,65 @@ export class CourseService { return { courses }; } + async getHighestRatedCourseInTerm(term: string) { + const validTerms = ["1", "2", "3"]; + if (!validTerms.includes(term)) { + this.logger.error(`${term} is not a valid term`); + throw new HTTPError(badRequest); + } + + const cachedCourse = await this.redis.get( + `highestRatedCoursePerTerm:${term}`, + ); + let course: Course | null; + + if (!cachedCourse) { + this.logger.info(`Cache miss on highestRatedCoursePerTerm:${term}`); + + course = await this.courseRepository.getHighestRatedCourseInTerm(term); + if (!course) { + this.logger.error(`Could not find highest rated course in term`); + throw new HTTPError(badRequest); + } + await this.redis.set(`highestRatedCoursePerTerm:${term}`, course); + } else { + this.logger.info(`Cache hit on highestRatedCoursePerTerm:${term}`); + course = cachedCourse; + } + + return { courseCode: course.courseCode }; + } + + async getCourseWithHighestRatedAttribute(attribute: string) { + const attributes = ["manageability", "usefulness", "enjoyability"]; + if (!attributes.includes(attribute)) { + this.logger.error(`${attribute} is not a valid attribute`); + throw new HTTPError(badRequest); + } + let course: Course | null; + const cachedCourse = await this.redis.get( + `highestRatedAttribute:${attribute}`, + ); + + if (!cachedCourse) { + this.logger.info(`Cache miss on highestRatedAttribute:${attribute}`); + course = + await this.courseRepository.getCourseWithHighestRatedAttribute( + attribute, + ); + if (!course) { + this.logger.error(`Could not find course with highest ${attribute}`); + throw new HTTPError(badRequest); + } + await this.redis.set(`highestRatedAttribute:${attribute}`, course); + } else { + this.logger.info(`Cache hit on highestRatedAttribute:${attribute}`); + course = cachedCourse; + } + + return { courseCode: course.courseCode }; + } + async flushKey(zid: string, key: string) { const userInfo = await this.userRepository.getUser(zid); if (!userInfo) { diff --git a/backend/src/services/review.service.test.ts b/backend/src/services/review.service.test.ts index 249b5f235..0375c5a67 100644 --- a/backend/src/services/review.service.test.ts +++ b/backend/src/services/review.service.test.ts @@ -104,8 +104,10 @@ describe("ReviewService", () => { }; reviewRepository.getReview = jest.fn().mockReturnValue(reviewEntity); - reviewRepository.getCourseReviews= jest.fn().mockReturnValue([reviewEntity]); - redis.set= jest.fn().mockReturnValue("ok"); + reviewRepository.getCourseReviews = jest + .fn() + .mockReturnValue([reviewEntity]); + redis.set = jest.fn().mockReturnValue("ok"); reviewRepository.update = jest.fn().mockReturnValue(reviewEntity); expect( @@ -201,4 +203,22 @@ describe("ReviewService", () => { }); }); }); + + describe("getMostLiked", () => { + it("should throw HTTP 500 error if no reviews in database", () => { + const service = reviewService(); + reviewRepository.getMostLiked = jest.fn().mockReturnValue(undefined); + + const errorResult = new HTTPError(badRequest); + expect(service.getMostLiked()).rejects.toThrow(errorResult); + }); + + it("should retrieve the reviewId for the review with the most votes", async () => { + const service = reviewService(); + const reviews = getMockReviews(); + const { reviewId } = reviews[1]; + reviewRepository.getMostLiked = jest.fn().mockResolvedValue({ reviewId }); + expect(await service.getMostLiked()).toEqual({ reviewId }); + }); + }); }); diff --git a/backend/src/services/review.service.ts b/backend/src/services/review.service.ts index ed7a13f11..b7aef1b43 100644 --- a/backend/src/services/review.service.ts +++ b/backend/src/services/review.service.ts @@ -8,6 +8,7 @@ import { BookmarkReview, PostReviewRequestBody, PutReviewRequestBody, + Review, ReviewsSuccessResponse, ReviewSuccessResponse, UpvoteReview, @@ -228,4 +229,31 @@ export class ReviewService { review: review, }; } + + async getMostLiked() { + const cachedReview = await this.redis.get(`review:mostLiked`); + let review: Review | null; + + if (!cachedReview) { + this.logger.info(`Cache miss on review:mostLiked`); + review = await this.reviewRepository.getMostLiked(); + await this.redis.set(`review:mostLiked`, review); + + if (!review) { + this.logger.error(`Could not find review with the most likes`); + throw new HTTPError(badRequest); + } + } else { + this.logger.info(`Cache hit on review:mostLiked`); + review = cachedReview; + } + + this.logger.info( + `Sucessfully found review with reviewId ${review.reviewId} which contains the most votes`, + ); + + return { + reviewId: review.reviewId, + }; + } } diff --git a/backend/src/utils/testData.ts b/backend/src/utils/testData.ts index 919dec21a..abbe27e32 100644 --- a/backend/src/utils/testData.ts +++ b/backend/src/utils/testData.ts @@ -227,7 +227,7 @@ export const getMockReviews = (date = new Date()): Review[] => { termTaken: "T2", createdTimestamp: date, updatedTimestamp: date, - upvotes: ["z513131"], + upvotes: ["z513131", "z5123451"], manageability: 3, enjoyability: 3, usefulness: 3,