Skip to content

Commit

Permalink
feat: UNI-298 wrapped api routes (#310)
Browse files Browse the repository at this point in the history
* create repository and service functions to find review with the most votes

* write tests for getMostLikedReview

* add get endpoint for most liked review

* use cardinality instead of array_length for most liked

* rename getMostLikedReview to getMostLiked, added caching

* add query to find course with highest enjoyability

* create stub fn and test for getHighestEnjoyability

* create getHighestEnjoyment method

* changed mostLiked endpoint to use kebab-case

* make nulls be considered last for highest enjoyability

* create endpoint to find highest-enjoyability

* write query to get course with highest usefulness

* write service method and test for highest usefulness

* add endpoint to get highest usefulness course

* write query for manageability

* write method for course manageability and tests

* add endpoint for highest manageability

* implement query to find highest rated course in term

* write method and tests for highest rated per term

* implement endpoint for highest rated per term

* add sort on review count for highest rated course per term

* add redis caching for obtaining highest rated course

* add second sort for review count on other queries

* delete unused review id types

* remove ReviewId import

* add unit test for no course in db

* remove comment

* remove redundant ReviewIdSchema import in review.repository

* refactor highest attribute query into one function

* refactor highest rated attribute course service functions to be one fn

* refactor course controller routes for highest rated attribute

* forgot to change logger string for highest-attribute

* remove unused imports

* prefix wrapped routes with /wrapped

* prefix logger messages
  • Loading branch information
adrianbalbs authored Jul 16, 2024
1 parent fa6a0af commit b446622
Show file tree
Hide file tree
Showing 9 changed files with 398 additions and 15 deletions.
63 changes: 60 additions & 3 deletions backend/src/controllers/course.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 (
Expand Down
31 changes: 25 additions & 6 deletions backend/src/controllers/review.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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}`,
);
Expand All @@ -72,6 +91,7 @@ export class ReviewController implements IController {
}
},
)

.post(
"/reviews",
[verifyToken, validationMiddleware(PostReviewSchema, "body")],
Expand Down Expand Up @@ -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) {
Expand Down
77 changes: 75 additions & 2 deletions backend/src/repositories/course.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down Expand Up @@ -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;
}
}
31 changes: 30 additions & 1 deletion backend/src/repositories/review.repository.ts
Original file line number Diff line number Diff line change
@@ -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) {}
Expand Down Expand Up @@ -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;
}
}
98 changes: 98 additions & 0 deletions backend/src/services/course.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
});
Loading

0 comments on commit b446622

Please sign in to comment.