diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45b042e63..600665ff7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,16 @@ jobs: - run: npm ci - run: npm run lint - run: npm run build + migration-ci: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./migration + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - run: npm ci + - run: npm run lint + - run: npm run build diff --git a/backend/package.json b/backend/package.json index ae85b7404..97ee24fc5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,6 +4,7 @@ "main": "index.js", "scripts": { "lint": "eslint -c .eslintrc.js \"src/**/*.{js,ts,tsx}\" --quiet --fix", + "format": "prettier '**/*.ts' --write", "build": "tsc", "start": "npx prisma migrate deploy && node dist/src/index.js", "dev": "NODE_ENV=dev tsx src/index.ts", diff --git a/backend/src/controllers/course.controller.ts b/backend/src/controllers/course.controller.ts index 00d161185..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 ( @@ -107,6 +164,30 @@ export class CourseController implements IController { } }, ) + .get( + "/course/filter/:terms/:faculties/:searchTerm", + async (req: Request, res: Response, next: NextFunction) => { + this.logger.debug(`Received request in GET /course/filter`); + try { + const { terms, faculties, searchTerm } = req.params; + + const result = await this.courseService.filterCourse( + terms, + faculties, + searchTerm, + ); + + return res.status(200).json(result); + } catch (err: any) { + this.logger.warn( + `An error occurred when trying to GET /course/filter ${formatError( + err, + )}`, + ); + return next(err); + } + }, + ) .delete( "/cached/flush", 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 b848ec22d..51b571bb5 100644 --- a/backend/src/repositories/course.repository.ts +++ b/backend/src/repositories/course.repository.ts @@ -184,8 +184,8 @@ export class CourseRepository { LEFT JOIN reviews r ON c.course_code = r.course_code WHERE c.course_code ILIKE ${searchQuery} OR c.title ILIKE ${searchQuery} GROUP BY c.course_code - ORDER BY - CASE + ORDER BY + CASE WHEN c.course_code ILIKE ${searchQuery} THEN 1 WHEN c.title ILIKE ${searchQuery} THEN 2 ELSE 3 @@ -196,4 +196,234 @@ export class CourseRepository { const courses = rawCourses.map((course) => CourseSchema.parse(course)); return courses; } + + async filterCourse( + terms: string, + faculties: string, + searchTerm: string, + ): Promise { + // default filters (all options) + let searchQuery = `%`; + let termFilters = ["0", "1", "2", "3", "-1", "-2"]; + let facultyFilters = [ + "%arts%", + "%business%", + "%engineering%", + "%law%", + "%medicine%", + "%science%", + "%unsw canberra%", + ]; + + if (searchTerm !== "_") { + searchQuery = `%${searchTerm}%`; + } + + // there are selected terms + if (terms !== "_") { + // 0&1&2 => ["0", "1", "2"]; + termFilters = terms.split("&"); + } + + // there are selected faculties + if (faculties !== "_") { + // ['arts', 'law'] => `'%arts%', '%law%'` + facultyFilters = faculties.split("&").map((faculty) => `%${faculty}%`); + const index = facultyFilters.indexOf("%UNSW_Canberra%"); + if (index !== -1) { + facultyFilters[index] = "%unsw canberra%"; + } + } + + const rawCourses = (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.course_code ILIKE ${searchQuery} OR c.title ILIKE ${searchQuery}) AND + c.terms && ${termFilters}::integer[] AND + c.faculty ILIKE ANY(${facultyFilters}) + GROUP BY c.course_code + ORDER BY "reviewCount" DESC; + `) as any[]; + const courses = rawCourses.map((course) => CourseSchema.parse(course)); + return courses; + } + + async filterNotOfferedCourses( + terms: string, + faculties: string, + searchTerm: string, + ): Promise { + // default filters (all options) + let searchQuery = `%`; + let termFilters: number[] = []; + let facultyFilters = [ + "%arts%", + "%business%", + "%engineering%", + "%law%", + "%medicine%", + "%science%", + "%unsw canberra%", + ]; + + if (searchTerm !== "_") { + searchQuery = `%${searchTerm}%`; + } + + // there are selected terms + if (terms !== "_") { + // 0&1&2 => ["0", "1", "2"]; + + termFilters = terms + .split("&") + .filter((term) => term !== "None") + .map((term) => parseInt(term, 10)); + } + + // there are selected faculties + if (faculties !== "_") { + // ['arts', 'law'] => `'%arts%', '%law%'` + facultyFilters = faculties.split("&").map((faculty) => `%${faculty}%`); + const index = facultyFilters.indexOf("%UNSW_Canberra%"); + if (index !== -1) { + facultyFilters[index] = "%unsw canberra%"; + } + } + + const rawCourses = (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.course_code ILIKE ${searchQuery} OR c.title ILIKE ${searchQuery}) AND + (c.terms = ARRAY[]::integer[] OR c.terms && ${termFilters}::integer[]) AND + c.faculty ILIKE ANY(${facultyFilters}) + GROUP BY c.course_code + ORDER BY "reviewCount" DESC; + `) as any[]; + const courses = rawCourses.map((course) => CourseSchema.parse(course)); + + 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 c19fad6da..96c8f8f9a 100644 --- a/backend/src/services/course.service.ts +++ b/backend/src/services/course.service.ts @@ -88,6 +88,107 @@ export class CourseService { return { courses }; } + async filterCourse( + terms: string, + faculties: string, + searchTerm: string, + ): Promise { + let courses = await this.redis.get( + `filterCourses:${terms}&${faculties}&${searchTerm}`, + ); + if (!courses) { + this.logger.info( + `Cache miss on filterCourses:${terms}&${faculties}&${searchTerm}`, + ); + + if (terms.includes("None")) { + // filters for not offered courses + courses = await this.courseRepository.filterNotOfferedCourses( + terms, + faculties, + searchTerm, + ); + } else { + courses = await this.courseRepository.filterCourse( + terms, + faculties, + searchTerm, + ); + } + + await this.redis.set( + `filterCourses:${terms}&${faculties}&${searchTerm}`, + courses, + ); + } else { + this.logger.info( + `Cache hit on filterCourses:${terms}&${faculties}&${searchTerm}`, + ); + } + + this.logger.info(`Found ${courses.length} courses.`); + 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, diff --git a/frontend/package.json b/frontend/package.json index 0f3dfcfed..48b0bef7d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "format": "prettier '**/*.ts{,x}' --write" }, "dependencies": { "@headlessui/react": "^1.7.14", diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 7201a3d70..555b0f7bb 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -34,13 +34,14 @@ export default async function RootLayout({ `, }} /> + -
+
{children}
diff --git a/frontend/src/components/CourseCard/CourseCard.tsx b/frontend/src/components/CourseCard/CourseCard.tsx index fe0e03335..1711a594b 100644 --- a/frontend/src/components/CourseCard/CourseCard.tsx +++ b/frontend/src/components/CourseCard/CourseCard.tsx @@ -41,7 +41,7 @@ export default function CourseCard({
{/* Course title */} -

+

{title}

{/* Terms */} diff --git a/frontend/src/components/CoursesList/CoursesList.tsx b/frontend/src/components/CoursesList/CoursesList.tsx index cc7bef8d5..ccf8599bd 100644 --- a/frontend/src/components/CoursesList/CoursesList.tsx +++ b/frontend/src/components/CoursesList/CoursesList.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react"; import { get } from "@/utils/request"; import { sortCourses } from "@/utils/sortCourses"; import SortDropdown from "../SortDropdown/SortDropdown"; +import FilterModal from "../FilterModal/FilterModal"; export default function CoursesList({ initialCourses, @@ -17,18 +18,34 @@ export default function CoursesList({ const courseFinishedRef = useRef(false); const indexRef = useRef(initialCourses.length); const searchCoursesRef = useRef([]); + const filteredCoursesRef = useRef([]); const [displayCourses, setDisplayCourses] = useState(initialCourses); const [initialLoading, setInitialLoading] = useState(true); const [selected, setSelected] = useState(""); - + const [filters, setFilters] = useState<{ + faculties: string[]; + terms: string[]; + }>({ + faculties: [], + terms: [], + }); const paginationOffset = 25; const loadMore = async (index: number) => { const fetchCourses = async () => { let fetchedCourses: Course[] = []; - if (searchTerm === "") { + + // there are applied filters and search + if ( + searchTerm !== "" || + filters.faculties.length !== 0 || + filters.terms.length !== 0 + ) { + // filtered courses based on search + filter (if any) + fetchedCourses = filteredCoursesRef.current.slice(index, index + 25); + } else { // default courses try { const { courses } = (await get( @@ -38,9 +55,6 @@ export default function CoursesList({ } catch (err) { fetchedCourses = []; } - } else { - // searched courses - fetchedCourses = searchCoursesRef.current.slice(index, index + 25); } return fetchedCourses; @@ -62,33 +76,56 @@ export default function CoursesList({ setDisplayCourses((prev) => [...prev, ...courses]); }; + // filters courses based on search + selected filters + const getFilterResults = async () => { + let terms = filters.terms.join("&"); + let faculties = filters.faculties.join("&"); + + if (terms === "") { + terms = "_"; + } + if (faculties === "") { + faculties = "_"; + } + + if (searchTerm === "") { + searchTerm = "_"; + } + + // EXAMPLE URL: /course/filter/1&3/art&engineering/comp + try { + const { courses } = (await get( + `/course/filter/${terms}/${faculties}/${searchTerm}`, + )) as Courses; + filteredCoursesRef.current = courses; + } catch (err) { + filteredCoursesRef.current = []; + } + setDisplayCourses(filteredCoursesRef.current.slice(0, paginationOffset)); + indexRef.current += paginationOffset; + setInitialLoading(false); + }; + useEffect(() => { const resetRefs = () => { courseFinishedRef.current = false; indexRef.current = initialCourses.length; - searchCoursesRef.current = []; - }; - const getSearchResults = async () => { - try { - const { courses } = (await get( - `/course/search/${searchTerm}`, - )) as Courses; - searchCoursesRef.current = courses; - } catch (err) { - searchCoursesRef.current = []; - } - setDisplayCourses(searchCoursesRef.current.slice(0, paginationOffset)); - indexRef.current += paginationOffset; - setInitialLoading(false); + filteredCoursesRef.current = []; }; + const getInitialDisplayCourses = () => { - if (searchTerm !== "") { - getSearchResults(); + if ( + searchTerm !== "" || + filters.faculties.length !== 0 || + filters.terms.length !== 0 + ) { + getFilterResults(); } else { setDisplayCourses(initialCourses.slice(0, paginationOffset)); setInitialLoading(false); } }; + const loadOnScroll = () => { if ( window.innerHeight + window.pageYOffset >= document.body.offsetHeight && @@ -104,13 +141,16 @@ export default function CoursesList({ window.addEventListener("scroll", loadOnScroll); return () => window.removeEventListener("scroll", loadOnScroll); - }, [searchTerm]); + }, [searchTerm, filters]); return ( <> - {/* SortDropdown Bar */} - -
+ {/* SortDropdown Bar and Filter Buttion*/} +
+ + +
+ diff --git a/frontend/src/components/FilterModal/FilterModal.tsx b/frontend/src/components/FilterModal/FilterModal.tsx new file mode 100644 index 000000000..11b31b7ec --- /dev/null +++ b/frontend/src/components/FilterModal/FilterModal.tsx @@ -0,0 +1,240 @@ +"use client"; +import React from "react"; +import Dropdown from "../Dropdown/Dropdown"; +import { useState } from "react"; +import { Dialog } from "@headlessui/react"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { AdjustmentsHorizontalIcon } from "@heroicons/react/24/outline"; +import { Course } from "@/types/api"; + +export default function FilterModal({ + filters, + setFilters, +}: { + filters: { terms: string[]; faculties: string[] }; + setFilters: React.Dispatch< + React.SetStateAction<{ faculties: string[]; terms: string[] }> + >; +}) { + const faculties = [ + "Arts", + "Business", + "Engineering", + "Law", + "Medicine", + "Science", + "UNSW Canberra", + ]; + + const terms = [ + "Summer", + "Term 1", + "Term 2", + "Term 3", + "Semester 1", + "Semester 2", + "Not Offered", + ]; + + const termsShortened = [ + "Summer", + "T1", + "T2", + "T3", + "Sem 1", + "Sem 2", + "Not Offered", + ]; + + const [facultiesCheckedState, setFacultiesCheckedState] = useState( + new Array(faculties.length).fill(false), + ); + + const [termsCheckedState, setTermsCheckedState] = useState( + new Array(terms.length).fill(false), + ); + + const [open, setOpen] = useState(false); + + const handleClose = () => { + // if no filters were applied then clear all + if (filters.terms.length === 0 && filters.faculties.length === 0) { + handleClearAll(); + } + setOpen(false); + }; + + const handleClearAll = () => { + setTermsCheckedState(new Array(terms.length).fill(false)); + setFacultiesCheckedState(new Array(faculties.length).fill(false)); + }; + + const handleApply = () => { + const selectedFaculties: string[] = []; + const selectedTerms: string[] = []; + + faculties.map((faculty, index) => { + // if selected + if (facultiesCheckedState[index]) { + if (faculty === "UNSW Canberra") { + selectedFaculties.push("UNSW_Canberra"); + } else { + selectedFaculties.push(faculty); + } + } + }); + + // terms are 0, 1, 2, 3 (summer, t1, t2, t3) + // semesters are -1, -2 + // not offered is "None" + terms.map((term, index) => { + // if selected + if (termsCheckedState[index]) { + if (term === "Not Offered") { + selectedTerms.push("None"); + } else if (term === "Summer") { + selectedTerms.push("0"); + } else { + let termString = term.split(" "); + if (termString[0] === "Term") { + selectedTerms.push(termString[1]); + } else if (termString[0] === "Semester") { + selectedTerms.push(`-${termString[1]}`); + } + } + } + }); + + setFilters({ faculties: selectedFaculties, terms: selectedTerms }); + + setOpen(false); + }; + + const handleTagOnClick = (type: string, position: number) => { + if (type === "faculty") { + const updatedCheckedState = facultiesCheckedState.map((item, index) => + index === position ? !item : item, + ); + setFacultiesCheckedState(updatedCheckedState); + } else if (type === "term") { + const updatedCheckedState = termsCheckedState.map((item, index) => + index === position ? !item : item, + ); + setTermsCheckedState(updatedCheckedState); + } + }; + + const styledFilterButton = ( + type: string, + index: number, + isChecked: boolean, + label: string, + ) => { + return ( + + ); + }; + + return ( + <> + {/* filter button */} +
+ +
+ + {/* filter dialog */} + + {/* the blurred backdrop */} + + + ); +} diff --git a/frontend/src/components/SortDropdown/SortDropdown.tsx b/frontend/src/components/SortDropdown/SortDropdown.tsx index 8767dc575..69743d123 100644 --- a/frontend/src/components/SortDropdown/SortDropdown.tsx +++ b/frontend/src/components/SortDropdown/SortDropdown.tsx @@ -1,6 +1,6 @@ -"use client"; -import React from "react"; -import Dropdown from "../Dropdown/Dropdown"; +'use client'; +import React from 'react'; +import Dropdown from '../Dropdown/Dropdown'; export default function SortDropdownBar({ selected, @@ -10,16 +10,16 @@ export default function SortDropdownBar({ setSelected: (str: string) => void; }) { return ( -
-
+
+
{ - const reviews: any = await this.manager.query(` - select * from unilectives.reviews - `); - return reviews.map((r: any) => ({ - reviewId: r.review_id, + const reviews = await this.manager.getRepository(ReviewEntity).find(); + return reviews.map((r: ReviewEntity) => ({ + reviewId: r.reviewId, zid: r.zid, - courseCode: r.course_code, - authorName: r.author_name, + courseCode: r.courseCode, + authorName: r.authorName, title: r.title, description: r.description, - grade: r.grade, - termTaken: r.term_taken, - createdTimestamp: r.created_timestamp, - updatedTimestamp: r.updated_timestamp, + grade: r.grade ? r.grade.toString() : null, + termTaken: r.termTaken, + createdTimestamp: r.createdTimestamp.toString(), + updatedTimestamp: r.updatedTimestamp.toString(), upvotes: r.upvotes, manageability: r.manageability, usefulness: r.usefulness, enjoyability: r.enjoyability, - overallRating: r.overall_rating, + overallRating: r.overallRating, })); } diff --git a/migration/src/migrate/service.ts b/migration/src/migrate/service.ts index 4415e5d67..e52348210 100644 --- a/migration/src/migrate/service.ts +++ b/migration/src/migrate/service.ts @@ -13,6 +13,10 @@ export default class MigrationService { readonly migrationRepository: MigrationRepository, ) {} + inferError(err: unknown) { + return err instanceof Error ? err.message : "An unknown error occured"; + } + async migrateReviews(): Promise { try { const oldReviews = await this.fetcher.getProdReviews(); @@ -40,10 +44,10 @@ export default class MigrationService { status: "SUCCESS", message: "Successfully migrated reviews", }; - } catch (err: any) { + } catch (err) { return { status: "FAILURE", - message: err.message, + message: this.inferError(err), }; } } @@ -75,10 +79,10 @@ export default class MigrationService { status: "SUCCESS", message: "Successfully migrated reviews", }; - } catch (err: any) { + } catch (err) { return { status: "FAILURE", - message: err.message, + message: this.inferError(err), }; } } @@ -121,10 +125,10 @@ export default class MigrationService { status: "SUCCESS", message: "Successfully updated reviews", }; - } catch (err: any) { + } catch (err) { return { status: "FAILURE", - message: err.message, + message: this.inferError(err), }; } } @@ -138,10 +142,10 @@ export default class MigrationService { status: "SUCCESS", message: "Successfully migrated courses", }; - } catch (err: any) { + } catch (err) { return { status: "FAILURE", - message: err.message, + message: this.inferError(err), }; } } @@ -155,10 +159,10 @@ export default class MigrationService { status: "SUCCESS", message: "Successfully updated courses", }; - } catch (err: any) { + } catch (err) { return { status: "FAILURE", - message: err.message, + message: this.inferError(err), }; } } @@ -170,10 +174,10 @@ export default class MigrationService { status: "SUCCESS", message: "Successfully updated user to admin" + ` ${zid}`, }; - } catch (err: any) { + } catch (err) { return { status: "FAILURE", - message: err.message, + message: this.inferError(err), }; } } @@ -185,10 +189,10 @@ export default class MigrationService { status: "SUCCESS", message: "Successfully flushed database", }; - } catch (err: any) { + } catch (err) { return { status: "FAILURE", - message: err.message, + message: this.inferError(err), }; } } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..795fe9231 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "unilectives", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}