Skip to content

feature/uni 261 filter button #308

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions backend/src/controllers/course.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,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 (
Expand Down
161 changes: 159 additions & 2 deletions backend/src/repositories/course.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ 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 @@ -184,8 +186,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
Expand All @@ -196,4 +198,159 @@ export class CourseRepository {
const courses = rawCourses.map((course) => CourseSchema.parse(course));
return courses;
}

async filterCourse(
terms: string,
faculties: string,
searchTerm: string,
): Promise<Course[]> {
// 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<Course[]> {
// 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;
}
}
42 changes: 42 additions & 0 deletions backend/src/services/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,48 @@ export class CourseService {
return { courses };
}

async filterCourse(
terms: string,
faculties: string,
searchTerm: string,
): Promise<CoursesSuccessResponse | undefined> {
let courses = await this.redis.get<Course[]>(
`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 flushKey(zid: string, key: string) {
const userInfo = await this.userRepository.getUser(zid);
if (!userInfo) {
Expand Down
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default async function RootLayout({
<AlertProvider>
<ThemeProviderComponent>
<Navbar userZid={session?.user?.id} />
<div className='ml-20 xs:ml-15 h-screen overflow-y-scroll'>
<div className='ml-20 xs:ml-15'>
{children}
</div>
</ThemeProviderComponent>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/CourseCard/CourseCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default function CourseCard({
</div>
</div>
{/* Course title */}
<p className='text-sm text-unilectives-headings dark:text-gray-200 h-16 break-all line-clamp-3'>
<p className='text-sm text-unilectives-headings dark:text-gray-200 h-16 line-clamp-3'>
{title}
</p>
{/* Terms */}
Expand Down
Loading
Loading