diff --git a/api/CourseInfo.d.ts b/api/CourseInfo.d.ts new file mode 100644 index 0000000..f565923 --- /dev/null +++ b/api/CourseInfo.d.ts @@ -0,0 +1,7 @@ +export interface CourseInfo { + id: string; + name: string; + units: string; + prerequisites: string; + corequisites: string; +} diff --git a/api/server.ts b/api/server.ts index 02d620c..a8d67b1 100644 --- a/api/server.ts +++ b/api/server.ts @@ -12,6 +12,8 @@ import { getProfNames, initializeMySQL, checkSQLConnection, + searchCourses, + getCourse, } from './sql'; const app = express(); @@ -182,6 +184,55 @@ app.get('/vote', (req, res) => { return res.status(400).send(result); }); +// Unified endpoint for searching courses or listing all +app.get('/courses', async (req, res) => { + let key: string | undefined; + + // Check if req.query.key is a string and assign it to key + // If not, key remains undefined + if (typeof req.query.key === 'string') { + key = req.query.key; + } else if (!req.query.key) { + key = undefined; + } else { + // Handle the case where key is not a string. + // Returning an error response + return res + .status(400) + .send('Invalid query parameter: key must be a string'); + } + try { + // If the key is empty, consider it as a request for all courses + if (!key) { + // Assuming searchCourses function can handle empty keys to return all courses + const results = await searchCourses(); + return res.json(results); + } + // If key is provided, search for courses based on the key + const results = await searchCourses(key); + + res.json(results); + } catch (error) { + console.error(error); + res.status(500).send('An error occurred while fetching the courses'); + } +}); + +// Endpoint for searching a course by an exact course number +app.get('/courses/:courseNumber', async (req, res) => { + const { courseNumber } = req.params; + try { + const result = await getCourse(courseNumber); + if (!result) { + return res.status(404).send('Course not found'); + } + res.json(result); + } catch (error) { + console.error(error); + res.status(500).send('An error occurred while searching for the course'); + } +}); + app.listen(process.env.PORT ?? 3000); void initializeMySQL(); diff --git a/api/sql.ts b/api/sql.ts index fc910ed..3736ec7 100644 --- a/api/sql.ts +++ b/api/sql.ts @@ -2,6 +2,7 @@ import { Connection, createConnection } from 'mysql2'; import { Professor, ProfessorUpdate } from './Professor'; import { getProfessorByName, getAllProfessor } from '../scraper/scraper'; import 'dotenv/config'; +import { CourseInfo } from './CourseInfo'; let connection: Connection; @@ -237,17 +238,70 @@ export async function getProfNames(): Promise { /* --- Curriculum FUNCTIONS --- */ /** - * Adds a new course to the 'Curriculum' table with all the necessary course details as parameters. - * @param courseName The name of the course - * @param courseNumber The number of the course - * @param preReqs The prerequisites of the course - * @param coReqs The corequeuistes of the course + * Retrieves the total count of courses currently stored in the Curriculum table. + * @returns {Promise} A promise that resolves to the number of courses. + */ +async function getCoursesCount(): Promise { + const result = await execute(`SELECT COUNT(*) FROM Curriculum`); + const resultAmount = Object.values(result[0])[0] as number; + return resultAmount; +} + +/** + * Fetches the latest courses from a specified URL and updates the local database. + * If the number of courses fetched matches the count in the database, the update is skipped. + * This function aims to prevent redundant updates on every process restart. + */ +async function fetchAndUpdateCourses(): Promise { + const year = '2023-2024'; + const url = `https://raw.githubusercontent.com/blu3eee/cpp-courses/main/parsed/courses_${year}.json`; + + try { + const response = await fetch(url); + // Check if the request was successful + if (!response.ok) { + throw new Error(`Error fetching courses: ${response.statusText}`); + } + + // Parse the JSON body of the response + const courses: Record = await response.json(); // Assuming this is an array of course objects + // Return if the courses amount is the same + // this logic can be further implemented, + // the current logic is just a temp guard to not check and create courses everytime the process restart on save (development env) + if (Object.values(courses).length === (await getCoursesCount())) { + return; + } + for (const course of Object.values(courses)) { + // Adapt based on your actual course object structure + // Assuming createCourse is a function you have for inserting course data into the database + await createCourse( + course.name, + course.id, + course.prerequisites, + course.corequisites, + course.units + ); + } + } catch (error) { + console.error('Error fetching or updating courses:', error); + } +} + +/** + * Inserts a new course into the Curriculum table. + * If the course already exists (determined by courseNumber), the insertion is skipped. + * @param {string} courseName - The name of the course. + * @param {string} courseNumber - The unique number of the course. + * @param {string} preReqs - The prerequisites of the course. + * @param {string} coReqs - The corequisites of the course. + * @param {string} units - The number of units the course is worth. */ export async function createCourse( courseName: string, courseNumber: string, preReqs: string, - coReqs: string + coReqs: string, + units: string ): Promise { try { // Check if course already exists in the database @@ -258,19 +312,21 @@ export async function createCourse( courseName, courseNumber, preReqs, - coReqs - ) VALUES (?, ?, ?, ?)`, - [courseName, courseNumber, preReqs, coReqs] + coReqs, + units + ) VALUES (?, ?, ?, ?, ?)`, + [courseName, courseNumber, preReqs, coReqs, units] ); console.log( `[SUCCESS] Course ${courseNumber} - ${courseName} has been added to the Curriculum.` ); } else { - console.error( - `Course ${courseNumber} - ${courseName} already exists in the Curriculum.` - ); + // console.error( + // `Course ${courseNumber} - ${courseName} already exists in the Curriculum.` + // ); } } catch (err) { + console.error(`Error inserting a course to table Curriculum`); console.error(err); } } @@ -281,12 +337,14 @@ interface CurriculumCourse { courseNumber: string; preReqs: string; coReqs: string; + units: string; } /** - * Updates an existing course by taking in the id of the target course, along with any course details that you intend to modify the target with. - * @param courseId The id of the target course - * @param updatedCourse An object containing the course details to be updated + * Updates the details of an existing course in the Curriculum table. + * If the course does not exist, an error is logged. + * @param {string} courseId - The ID of the course to update. + * @param {Partial} updatedCourse - An object containing the course details to be updated. */ export async function updateCourse( courseId: string, @@ -302,12 +360,14 @@ export async function updateCourse( courseNumber = ?, preReqs = ?, coReqs = ?, + units = ? WHERE id = ?`, [ mergedCourse.courseName, mergedCourse.courseNumber, mergedCourse.preReqs, mergedCourse.coReqs, + mergedCourse.units, // Convert units to string courseId, ] ); @@ -369,6 +429,37 @@ export async function getCourseById( return result; } +/** + * Searches the Curriculum for courses matching the given key phrase in either + * the course name or the course number. + * @param keyPhrase The phrase to search for. + * @returns A promise that resolves to an array of courses matching the search criteria. + */ +export async function searchCourses(keyPhrase?: string): Promise { + // Escape the keyPhrase to prevent SQL injection + const escapedKeyPhrase = (keyPhrase ?? '') + .replace(/%/g, '\\%') + .replace(/_/g, '\\_'); + + // Use the LIKE operator with wildcard (%) for partial matches + // Concatenate both courseNumber and courseName with an OR for a broader search + const query = ` + SELECT * FROM Curriculum + WHERE courseNumber LIKE ? OR courseName LIKE ? + `; + + // Add wildcards to the search phrase for partial matching + const searchPattern = `%${escapedKeyPhrase}%`; + + try { + const results = await execute(query, [searchPattern, searchPattern]); + return results; + } catch (error) { + console.error('Error searching for courses:', error); + throw error; // Re-throw the error for further handling, if necessary + } +} + /* --- MySQL FUNCTIONS --- */ /** @@ -449,13 +540,16 @@ export async function initializeMySQL(): Promise { legacyId int ) `); + // uncomment the line below to create the new Curriculum table on your first run + // void execute(`DROP table Curriculum`); void execute(` CREATE TABLE IF NOT EXISTS Curriculum ( id int NOT NULL PRIMARY KEY AUTO_INCREMENT, courseName varchar(255), courseNumber varchar(255), - preReqs varchar(255), - coReqs varchar(255) + preReqs TEXT, + coReqs TEXT, + units varchar(255) ) `); void execute(`CREATE TABLE IF NOT EXISTS professorDB ( @@ -484,6 +578,8 @@ export async function initializeMySQL(): Promise { ); } + await fetchAndUpdateCourses(); + console.log('MySQL server successfully started!'); // const sampleProf: Professor = { diff --git a/test/server.test.ts b/test/server.test.ts index 821d45d..b4ec8c1 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -54,37 +54,44 @@ describe('[Professor] 3 test cases:', function () { }); }); -describe('[Search] 3 test cases', function () { - it('Empty array', async function () { - const nameSend = {}; - const res = await request(server).post('/search').send(nameSend); +describe('[Courses] 4 tests cases', function () { + // Test fetching all courses + it('GET /courses should return all courses', async function () { + const res = await request(server).get('/courses'); + expect(res).to.have.status(200); + expect(res.body).to.be.an('array'); + }); - expect(res.body) - .to.be.an('object') - .that.includes({ err: 'please provide a json with key of count' }); + // Test filtering courses with a specific key + it('GET /courses?key=computer should filter courses based on the key', async function () { + const key = 'computer'; // Use an actual key that you expect to filter by + const res = await request(server).get(`/courses?key=${key}`); + expect(res).to.have.status(200); + expect(res.body).to.be.an('array'); }); - it('No count property', async function () { - const nameSend = { - test: 'val', - }; - const res = await request(server).post('/search').send(nameSend); - expect(res.body) - .to.be.an('object') - .that.includes({ err: 'must specify the amount of professors needed' }) || - expect(res.body) - .to.be.an('object') - .that.includes({ err: 'please specify a number' }); + + // Test fetching a course by its number + it('GET /courses/:courseNumber should return the course with the specific number', async function () { + const courseNumber = 'CS 1300'; // Use an actual course number from your database + const res = await request(server).get(`/courses/${courseNumber}`); + expect(res).to.have.status(200); + expect(res.body).to.be.an('object'); + expect(res.body).to.include.keys([ + 'id', + 'courseName', + 'courseNumber', + 'units', + 'preReqs', + 'coReqs', + ]); + // Assert more specific properties of the course, such as matching the course number + expect(res.body.courseNumber).to.equal(courseNumber); }); - it('correct ', async function () { - const nameSend = { - count: 1, - }; - const res = await request(server).post('/search').send(nameSend); - const keys = ['profs']; - expect(res.body).to.be.an('object').to.have.all.keys(keys); - expect(res.body.profs).to.be.a('array'); - res.body.profs.forEach((element: any) => { - expect(element).to.be.a('number'); - }); + + // Test fetching a course with an invalid course number + it('GET /courses/:courseNumber with an invalid number should return 404', async function () { + const invalidCourseNumber = 'INVALID123'; + const res = await request(server).get(`/courses/${invalidCourseNumber}`); + expect(res).to.have.status(404); }); });