diff --git a/README.md b/README.md index 537aab1e..59b88506 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ The `DATABASE_URL` variable should contain your Supabase project url and the `DA ``` VITE_SERVER_URL="http://localhost:8081" +VITE_PUBLIC_ASSISTANT_BASE_URL=[Insert vite public assistant bas URL] +VITE_ASSISTANT_UI_KEY=[Insert vite assistant UI key] ``` ### Running the Application diff --git a/course-matrix/backend/__tests__/analyzeQuery.test.ts b/course-matrix/backend/__tests__/analyzeQuery.test.ts new file mode 100644 index 00000000..c9311929 --- /dev/null +++ b/course-matrix/backend/__tests__/analyzeQuery.test.ts @@ -0,0 +1,119 @@ +import { analyzeQuery } from "../src/utils/analyzeQuery"; +import { describe, test, expect, jest } from "@jest/globals"; +import { + NAMESPACE_KEYWORDS, + ASSISTANT_TERMS, + DEPARTMENT_CODES, +} from "../src/constants/promptKeywords"; + +// Mock the constants if needed +jest.mock("../src/constants/promptKeywords", () => ({ + NAMESPACE_KEYWORDS: { + courses_v3: ["course", "class", "description"], + offerings: ["offering", "schedule", "timetable"], + prerequisites: ["prerequisite", "prereq"], + corequisites: ["corequisite", "coreq"], + departments: ["department", "faculty"], + programs: ["program", "major", "minor"], + }, + ASSISTANT_TERMS: ["you", "your", "morpheus", "assistant"], + DEPARTMENT_CODES: ["cs", "math", "eng"], + GENERAL_ACADEMIC_TERMS: ["academic", "study", "education"], +})); + +describe("analyzeQuery", () => { + test("should return no search required for assistant-related queries", () => { + const result = analyzeQuery("Can you help me with something?"); + expect(result).toEqual({ + requiresSearch: false, + relevantNamespaces: [], + }); + }); + + test("should detect course-related keywords and return appropriate namespaces", () => { + const result = analyzeQuery("Tell me about this course"); + expect(result.requiresSearch).toBe(true); + expect(result.relevantNamespaces).toContain("courses_v3"); + }); + + test("should detect course codes and include relevant namespaces", () => { + const result = analyzeQuery("What is CSC108 about?"); + expect(result.requiresSearch).toBe(true); + expect(result.relevantNamespaces).toContain("courses_v3"); + expect(result.relevantNamespaces).toContain("offerings"); + expect(result.relevantNamespaces).toContain("prerequisites"); + }); + + test("should detect department codes and include relevant namespaces", () => { + const result = analyzeQuery("What math courses are available?"); + expect(result.requiresSearch).toBe(true); + expect(result.relevantNamespaces).toContain("departments"); + expect(result.relevantNamespaces).toContain("courses_v3"); + }); + + test("should detect offering-related keywords", () => { + const result = analyzeQuery("What is the schedule for winter semester?"); + expect(result.requiresSearch).toBe(true); + expect(result.relevantNamespaces).toContain("offerings"); + }); + + test("should detect prerequisite-related keywords", () => { + const result = analyzeQuery("What are the prerequisites for this class?"); + expect(result.requiresSearch).toBe(true); + expect(result.relevantNamespaces).toContain("prerequisites"); + }); + + test("should detect corequisite-related keywords", () => { + const result = analyzeQuery("Are there any corequisites for this course?"); + expect(result.requiresSearch).toBe(true); + expect(result.relevantNamespaces).toContain("corequisites"); + }); + + test("should return all namespaces when search is required but no specific namespaces identified", () => { + // Assuming GENERAL_ACADEMIC_TERMS includes 'academic' + const result = analyzeQuery("I need academic information"); + expect(result.requiresSearch).toBe(true); + expect(result.relevantNamespaces).toEqual([ + "courses_v3", + "offerings", + "prerequisites", + "corequisites", + "departments", + "programs", + ]); + }); + + test("should be case insensitive", () => { + const result = analyzeQuery("TELL ME ABOUT THIS COURSE"); + expect(result.requiresSearch).toBe(true); + expect(result.relevantNamespaces).toContain("courses_v3"); + }); + + test("should detect multiple namespaces in a single query", () => { + const result = analyzeQuery( + "What are the prerequisites and schedule for CSC108?", + ); + expect(result.requiresSearch).toBe(true); + expect(result.relevantNamespaces).toContain("prerequisites"); + expect(result.relevantNamespaces).toContain("offerings"); + expect(result.relevantNamespaces).toContain("courses_v3"); + }); + + test("should correctly identify course codes with different formats", () => { + const formats = [ + "CSC108", // Standard format + "CSC108H", // With suffix + "CSCA08", // Four letters + "MAT224", // Different department + "ECO100Y", // Another format + ]; + + formats.forEach((code) => { + const result = analyzeQuery(`Tell me about ${code}`); + expect(result.requiresSearch).toBe(true); + expect(result.relevantNamespaces).toContain("courses_v3"); + expect(result.relevantNamespaces).toContain("offerings"); + expect(result.relevantNamespaces).toContain("prerequisites"); + }); + }); +}); diff --git a/course-matrix/backend/__tests__/auth.test.ts b/course-matrix/backend/__tests__/auth.test.ts new file mode 100644 index 00000000..6332e27b --- /dev/null +++ b/course-matrix/backend/__tests__/auth.test.ts @@ -0,0 +1,81 @@ +import request from "supertest"; +import { describe, expect, it, test } from "@jest/globals"; +import app from "../src/index"; + +describe("Authentication API", () => { + // The unit tests below are currently commented out because they require a database connection. + // They will be uncommented out once all the necessary mocks are in place. + + // describe('POST /auth/login', () => { + // it('should return 200 and a token for valid credentials', async () => { + // const response = await request(app) + // .post('/auth/login') + // .send({ username: 'validUser', password: 'validPassword' }); + // expect(response.status).toBe(200); + // expect(response.body).toHaveProperty('token'); + // }); + // it('should return 401 for invalid credentials', async () => { + // const response = await request(app) + // .post('/auth/login') + // .send({ username: 'invalidUser', password: 'wrongPassword' }); + // expect(response.status).toBe(401); + // expect(response.body).toHaveProperty('error', 'Invalid credentials'); + // }); + // it('should return 400 if username or password is missing', async () => { + // const response = await request(app) + // .post('/auth/login') + // .send({ username: 'validUser' }); + // expect(response.status).toBe(400); + // expect(response.body).toHaveProperty('error', 'Username and password are required'); + // }); + // }); + // describe('POST /auth/register', () => { + // it('should return 201 and create a new user for valid input', async () => { + // const response = await request(app) + // .post('/auth/register') + // .send({ username: 'newUser', password: 'newPassword' }); + // expect(response.status).toBe(201); + // expect(response.body).toHaveProperty('message', 'User registered successfully'); + // }); + // it('should return 400 if username is already taken', async () => { + // await request(app) + // .post('/auth/register') + // .send({ username: 'existingUser', password: 'password123' }); + // const response = await request(app) + // .post('/auth/register') + // .send({ username: 'existingUser', password: 'password123' }); + // expect(response.status).toBe(400); + // expect(response.body).toHaveProperty('error', 'Username is already taken'); + // }); + // it('should return 400 if username or password is missing', async () => { + // const response = await request(app) + // .post('/auth/register') + // .send({ username: '' }); + // expect(response.status).toBe(400); + // expect(response.body).toHaveProperty('error', 'Username and password are required'); + // }); + // }); + // describe('GET /auth/profile', () => { + // it('should return 200 and user profile for valid token', async () => { + // const loginResponse = await request(app) + // .post('/auth/login') + // .send({ username: 'validUser', password: 'validPassword' }); + // const token = loginResponse.body.token; + // const response = await request(app) + // .get('/auth/profile') + // .set('Authorization', `Bearer ${token}`); + // expect(response.status).toBe(200); + // expect(response.body).toHaveProperty('username', 'validUser'); + // }); + // it('should return 401 if token is missing or invalid', async () => { + // const response = await request(app) + // .get('/auth/profile') + // .set('Authorization', 'Bearer invalidToken'); + // expect(response.status).toBe(401); + // expect(response.body).toHaveProperty('error', 'Unauthorized'); + // }); + // }); + it("template test", () => { + expect(2 + 3).toEqual(5); + }); +}); diff --git a/course-matrix/backend/__tests__/canInsert.test.ts b/course-matrix/backend/__tests__/canInsert.test.ts new file mode 100644 index 00000000..6155bf3f --- /dev/null +++ b/course-matrix/backend/__tests__/canInsert.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, test } from "@jest/globals"; + +import { createOffering, canInsert } from "../src/utils/generatorHelpers"; +import { Offering } from "../src/types/generatorTypes"; + +describe("canInsert function", () => { + const offering1: Offering = createOffering({ + id: 1, + course_id: 101, + day: "MO", + start: "09:00:00", + end: "10:00:00", + }); + const offering2: Offering = createOffering({ + id: 2, + course_id: 102, + day: "MO", + start: "10:00:00", + end: "11:00:00", + }); + const offering3: Offering = createOffering({ + id: 3, + course_id: 103, + day: "MO", + start: "11:00:00", + end: "12:00:00", + }); + + it("should return true if there is no overlap with existing offerings", async () => { + const toInsert: Offering = createOffering({ + id: 4, + course_id: 104, + day: "MO", + start: "12:00:00", + end: "13:00:00", + }); + const curList: Offering[] = [offering1, offering2, offering3]; + + const result = await canInsert(toInsert, curList); + + expect(result).toBe(true); // No overlap, should return true + }); + + it("should return false if there is an overlap with an existing offering", async () => { + const toInsert: Offering = createOffering({ + id: 4, + course_id: 104, + day: "MO", + start: "09:30:00", + end: "10:30:00", + }); + const curList: Offering[] = [offering1, offering2, offering3]; + + const result = await canInsert(toInsert, curList); + + expect(result).toBe(false); // There is an overlap with offering1, should return false + }); + + it("should return true if the new offering starts after the last one ends", async () => { + const toInsert: Offering = createOffering({ + id: 4, + course_id: 104, + day: "MO", + start: "13:00:00", + end: "14:00:00", + }); + const curList: Offering[] = [offering1, offering2, offering3]; + + const result = await canInsert(toInsert, curList); + + expect(result).toBe(true); // No overlap, should return true + }); + + it("should return true if the new offering ends before the first one starts", async () => { + const toInsert: Offering = createOffering({ + id: 4, + course_id: 104, + day: "MO", + start: "07:00:00", + end: "08:00:00", + }); + const curList: Offering[] = [offering1, offering2, offering3]; + + const result = await canInsert(toInsert, curList); + + expect(result).toBe(true); // No overlap, should return true + }); + + it("should return false if the new offering is completely inside an existing one", async () => { + const toInsert: Offering = createOffering({ + id: 4, + course_id: 104, + day: "MO", + start: "09:30:00", + end: "09:45:00", + }); + const curList: Offering[] = [offering1, offering2, offering3]; + + const result = await canInsert(toInsert, curList); + + expect(result).toBe(false); // Overlaps with offering1, should return false + }); + + it("should return true if the day is different (no overlap)", async () => { + const toInsert: Offering = createOffering({ + id: 4, + course_id: 104, + day: "TU", + start: "09:00:00", + end: "10:00:00", + }); + const curList: Offering[] = [offering1, offering2, offering3]; + + const result = await canInsert(toInsert, curList); + + expect(result).toBe(true); // Different day, no overlap + }); +}); diff --git a/course-matrix/backend/__tests__/correctDay.test.ts b/course-matrix/backend/__tests__/correctDay.test.ts new file mode 100644 index 00000000..2dd90eaf --- /dev/null +++ b/course-matrix/backend/__tests__/correctDay.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, jest, test } from "@jest/globals"; +import { isDateBetween } from "../src/utils/compareDates"; + +// For testing purposes, we need to modify the function to accept a custom "now" date +// This allows us to test all scenarios regardless of the current date +function correctDay(offering: any, customNow?: Date): boolean { + const weekdays = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + const semester = offering?.offering; + const day = offering?.day; + if (!semester || !day) return false; + const now = customNow || new Date(); + let startDay; + let endDay; + if (semester === "Summer 2025") { + startDay = new Date(2025, 5, 2); + endDay = new Date(2025, 8, 7); + } else if (semester === "Fall 2025") { + startDay = new Date(2025, 9, 3); + endDay = new Date(2025, 12, 3); + } else { + // Winter 2026 + startDay = new Date(2026, 1, 6); + endDay = new Date(2026, 4, 4); + } + if (!isDateBetween(now, startDay, endDay)) { + return false; + } + if (weekdays[now.getDay()] !== day) { + return false; + } + return true; +} + +describe("correctDay function", () => { + test("should return false for null or undefined offering", () => { + expect(correctDay(null)).toBe(false); + expect(correctDay(undefined)).toBe(false); + }); + + test("should return false for missing offering properties", () => { + expect(correctDay({})).toBe(false); + expect(correctDay({ offering: "Summer 2025" })).toBe(false); + expect(correctDay({ day: "MO" })).toBe(false); + }); + + test("should validate correct day in Summer 2025", () => { + // Create specific dates for each day of the week within Summer 2025 + const summerDates = [ + new Date(2025, 5, 8), // Sunday (June 8, 2025) + new Date(2025, 5, 9), // Monday (June 9, 2025) + new Date(2025, 5, 10), // Tuesday (June 10, 2025) + new Date(2025, 5, 11), // Wednesday (June 11, 2025) + new Date(2025, 5, 12), // Thursday (June 12, 2025) + new Date(2025, 5, 13), // Friday (June 13, 2025) + new Date(2025, 5, 14), // Saturday (June 14, 2025) + ]; + + // Test each day with its corresponding date + const weekdays = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + summerDates.forEach((date, index) => { + // Make sure the getDay returns the expected index + jest.spyOn(date, "getDay").mockReturnValue(index); + + // This should pass only for the matching day + expect( + correctDay({ offering: "Summer 2025", day: weekdays[index] }, date), + ).toBe(true); + + // Test all other days should fail + weekdays.forEach((wrongDay, wrongIndex) => { + if (wrongIndex !== index) { + expect( + correctDay({ offering: "Summer 2025", day: wrongDay }, date), + ).toBe(false); + } + }); + }); + }); + + test("should validate correct day in Fall 2025", () => { + // Create a date in Fall 2025 + const fallDate = new Date(2025, 9, 15); // October 15, 2025 + + // Mock getDay to return 3 (Wednesday) + jest.spyOn(fallDate, "getDay").mockReturnValue(3); + + // Test all days - only Wednesday should pass + expect(correctDay({ offering: "Fall 2025", day: "WE" }, fallDate)).toBe( + true, + ); + expect(correctDay({ offering: "Fall 2025", day: "SU" }, fallDate)).toBe( + false, + ); + expect(correctDay({ offering: "Fall 2025", day: "MO" }, fallDate)).toBe( + false, + ); + expect(correctDay({ offering: "Fall 2025", day: "TU" }, fallDate)).toBe( + false, + ); + expect(correctDay({ offering: "Fall 2025", day: "TH" }, fallDate)).toBe( + false, + ); + expect(correctDay({ offering: "Fall 2025", day: "FR" }, fallDate)).toBe( + false, + ); + expect(correctDay({ offering: "Fall 2025", day: "SA" }, fallDate)).toBe( + false, + ); + }); + + test("should validate correct day in Winter 2026", () => { + // Create a date in Winter 2026 + const winterDate = new Date(2026, 1, 20); // February 20, 2026 + + // Mock getDay to return 5 (Friday) + jest.spyOn(winterDate, "getDay").mockReturnValue(5); + + // Test all days - only Friday should pass + expect(correctDay({ offering: "Winter 2026", day: "FR" }, winterDate)).toBe( + true, + ); + expect(correctDay({ offering: "Winter 2026", day: "SU" }, winterDate)).toBe( + false, + ); + expect(correctDay({ offering: "Winter 2026", day: "MO" }, winterDate)).toBe( + false, + ); + expect(correctDay({ offering: "Winter 2026", day: "TU" }, winterDate)).toBe( + false, + ); + expect(correctDay({ offering: "Winter 2026", day: "WE" }, winterDate)).toBe( + false, + ); + expect(correctDay({ offering: "Winter 2026", day: "TH" }, winterDate)).toBe( + false, + ); + expect(correctDay({ offering: "Winter 2026", day: "SA" }, winterDate)).toBe( + false, + ); + }); + + test("should return false when date is outside semester range", () => { + // Create dates outside each semester range + const beforeSummer = new Date(2025, 5, 1); // June 1, 2025 (before Summer 2025) + const afterSummer = new Date(2025, 8, 8); // September 8, 2025 (after Summer 2025) + const beforeFall = new Date(2025, 9, 2); // October 2, 2025 (before Fall 2025) + const afterFall = new Date(2025, 12, 4); // December 4, 2025 (after Fall 2025) + const beforeWinter = new Date(2026, 1, 5); // February 5, 2026 (before Winter 2026) + const afterWinter = new Date(2026, 4, 5); // May 5, 2026 (after Winter 2026) + + // Mock getDay to return 0 (Sunday) for all dates + const testDates = [ + beforeSummer, + afterSummer, + beforeFall, + afterFall, + beforeWinter, + afterWinter, + ]; + testDates.forEach((date) => { + jest.spyOn(date, "getDay").mockReturnValue(0); + }); + + // Test dates outside Summer 2025 + expect( + correctDay({ offering: "Summer 2025", day: "SU" }, beforeSummer), + ).toBe(false); + expect( + correctDay({ offering: "Summer 2025", day: "SU" }, afterSummer), + ).toBe(false); + + // Test dates outside Fall 2025 + expect(correctDay({ offering: "Fall 2025", day: "SU" }, beforeFall)).toBe( + false, + ); + expect(correctDay({ offering: "Fall 2025", day: "SU" }, afterFall)).toBe( + false, + ); + + // Test dates outside Winter 2026 + expect( + correctDay({ offering: "Winter 2026", day: "SU" }, beforeWinter), + ).toBe(false); + expect( + correctDay({ offering: "Winter 2026", day: "SU" }, afterWinter), + ).toBe(false); + }); + + test("should return true for dates inside semester range", () => { + // Create dates inside each semester range + const duringSummer = new Date(2025, 6, 15); // July 15, 2025 (during Summer 2025) + const duringFall = new Date(2025, 10, 15); // November 15, 2025 (during Fall 2025) + const duringWinter = new Date(2026, 2, 15); // March 15, 2026 (during Winter 2026) + + // Mock getDay to return 0 (Sunday) for all dates + const testDates = [duringSummer, duringFall, duringWinter]; + testDates.forEach((date) => { + jest.spyOn(date, "getDay").mockReturnValue(0); + }); + + // Test dates inside each semester (with matching day) + expect( + correctDay({ offering: "Summer 2025", day: "SU" }, duringSummer), + ).toBe(true); + expect(correctDay({ offering: "Fall 2025", day: "SU" }, duringFall)).toBe( + true, + ); + expect( + correctDay({ offering: "Winter 2026", day: "SU" }, duringWinter), + ).toBe(true); + }); + + test("should validate edge dates correctly", () => { + // Test exact start and end dates of semesters + const summerStart = new Date(2025, 5, 2); // June 2, 2025 + const summerEnd = new Date(2025, 8, 7); // September 7, 2025 + const fallStart = new Date(2025, 9, 3); // October 3, 2025 + const fallEnd = new Date(2025, 12, 3); // December 3, 2025 + const winterStart = new Date(2026, 1, 6); // February 6, 2026 + const winterEnd = new Date(2026, 4, 4); // May 4, 2026 + + // Mock getDay to return 0 (Sunday) for all dates + const edgeDates = [ + summerStart, + summerEnd, + fallStart, + fallEnd, + winterStart, + winterEnd, + ]; + edgeDates.forEach((date) => { + jest.spyOn(date, "getDay").mockReturnValue(0); + }); + + // Edge dates should be included in the valid range + expect( + correctDay({ offering: "Summer 2025", day: "SU" }, summerStart), + ).toBe(true); + expect(correctDay({ offering: "Summer 2025", day: "SU" }, summerEnd)).toBe( + true, + ); + expect(correctDay({ offering: "Fall 2025", day: "SU" }, fallStart)).toBe( + true, + ); + expect(correctDay({ offering: "Fall 2025", day: "SU" }, fallEnd)).toBe( + true, + ); + expect( + correctDay({ offering: "Winter 2026", day: "SU" }, winterStart), + ).toBe(true); + expect(correctDay({ offering: "Winter 2026", day: "SU" }, winterEnd)).toBe( + true, + ); + }); +}); diff --git a/course-matrix/backend/__tests__/getFrequencyTable.test.ts b/course-matrix/backend/__tests__/getFrequencyTable.test.ts new file mode 100644 index 00000000..b5aedade --- /dev/null +++ b/course-matrix/backend/__tests__/getFrequencyTable.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, test } from "@jest/globals"; + +import { + createOffering, + getFrequencyTable, +} from "../src/utils/generatorHelpers"; +import { Offering } from "../src/types/generatorTypes"; + +describe("getFrequencyTable", () => { + test("should return a frequency map of days", () => { + const offering1: Offering = createOffering({ + id: 1, + course_id: 101, + day: "MO", + start: "09:00:00", + end: "10:00:00", + }); + const offering2: Offering = createOffering({ + id: 2, + course_id: 102, + day: "TU", + start: "10:00:00", + end: "11:00:00", + }); + const offering3: Offering = createOffering({ + id: 3, + course_id: 103, + day: "TU", + start: "11:00:00", + end: "12:00:00", + }); + const offering4: Offering = createOffering({ + id: 4, + course_id: 104, + day: "MO", + start: "11:00:00", + end: "12:00:00", + }); + const offering5: Offering = createOffering({ + id: 5, + course_id: 105, + day: "WE", + start: "11:00:00", + end: "12:00:00", + }); + const offering6: Offering = createOffering({ + id: 6, + course_id: 106, + day: "WE", + start: "11:00:00", + end: "12:00:00", + }); + const offering7: Offering = createOffering({ + id: 7, + course_id: 107, + day: "WE", + start: "11:00:00", + end: "12:00:00", + }); + + const result = getFrequencyTable([ + offering1, + offering2, + offering3, + offering4, + offering5, + offering6, + offering7, + ]); + + expect(result.get("MO")).toBe(2); + expect(result.get("TU")).toBe(2); + expect(result.get("WE")).toBe(3); + expect(result.get("TH")).toBeUndefined(); // Day not in data + expect(result.get("FR")).toBeUndefined(); // Day not in data + expect(result.size).toBe(3); + }); + + test("should return an empty map for an empty array", () => { + const result = getFrequencyTable([]); + expect(result.size).toBe(0); + }); +}); diff --git a/course-matrix/backend/__tests__/includeFilters.test.ts b/course-matrix/backend/__tests__/includeFilters.test.ts new file mode 100644 index 00000000..a72dceff --- /dev/null +++ b/course-matrix/backend/__tests__/includeFilters.test.ts @@ -0,0 +1,124 @@ +import { + BREADTH_REQUIREMENT_KEYWORDS, + YEAR_LEVEL_KEYWORDS, +} from "../src/constants/promptKeywords"; +import { includeFilters } from "../src/utils/includeFilters"; +import { describe, test, expect, jest, beforeEach } from "@jest/globals"; +import * as ModuleType from "../src/utils/convert-breadth-requirement"; +import * as ModuleType0 from "../src/utils/convert-year-level"; + +// Create mock functions +const mockConvertBreadthRequirement = jest.fn( + (namespace) => `converted_${namespace}`, +); +const mockConvertYearLevel = jest.fn((namespace) => `converted_${namespace}`); + +// Mock the modules +jest.mock("../src/utils/convert-breadth-requirement", () => ({ + convertBreadthRequirement: (namespace: string) => + mockConvertBreadthRequirement(namespace), +})); + +jest.mock("../src/utils/convert-year-level", () => ({ + convertYearLevel: (namespace: string) => mockConvertYearLevel(namespace), +})); + +describe("includeFilters", () => { + beforeEach(() => { + // Clear mock data before each test + mockConvertBreadthRequirement.mockClear(); + mockConvertYearLevel.mockClear(); + }); + + test("should return empty object when no filters match", () => { + const query = "something random"; + const result = includeFilters(query); + expect(result).toEqual({}); + }); + + test("should match breadth requirement keywords case-insensitively", () => { + const query = "I want to study ART Literature"; + const result = includeFilters(query); + expect(result).toEqual({ + $or: [{ breadth_requirement: { $eq: "converted_ART_LIT_LANG" } }], + }); + }); + + test("should match year level keywords case-insensitively", () => { + const query = "Looking for A-level courses"; + const result = includeFilters(query); + expect(result).toEqual({ + $or: [{ year_level: { $eq: "converted_first_year" } }], + }); + }); + + test("should combine both breadth and year level filters with $and when both are present", () => { + const query = "Natural Science First-Year courses"; + const result = includeFilters(query); + expect(result).toEqual({ + $and: [ + { + $or: [{ breadth_requirement: { $eq: "converted_NAT_SCI" } }], + }, + { + $or: [{ year_level: { $eq: "converted_first_year" } }], + }, + ], + }); + }); + + test("should handle multiple breadth requirements", () => { + const query = "social science or quantitative reasoning"; + const result = includeFilters(query); + expect(result).toEqual({ + $or: [ + { breadth_requirement: { $eq: "converted_SOCIAL_SCI" } }, + { breadth_requirement: { $eq: "converted_QUANT" } }, + ], + }); + }); + + test("should handle multiple year levels", () => { + const query = "third year or fourth-year courses"; + const result = includeFilters(query); + expect(result).toEqual({ + $or: [ + { year_level: { $eq: "converted_third_year" } }, + { year_level: { $eq: "converted_fourth_year" } }, + ], + }); + }); + + test("should handle multiple breadth requirements and year levels", () => { + const query = "history philosophy B-level or C-level"; + const result = includeFilters(query); + console.log(result); + expect(result).toEqual({ + $and: [ + { + $or: [{ breadth_requirement: { $eq: "converted_HIS_PHIL_CUL" } }], + }, + { + $or: [ + { year_level: { $eq: "converted_second_year" } }, + { year_level: { $eq: "converted_third_year" } }, + ], + }, + ], + }); + }); + + test("should ignore partial keyword matches", () => { + // "art" alone shouldn't match "art literature language" + const query = "art courses"; + const result = includeFilters(query); + // This should not match any specific filter since "art" alone isn't in the keywords + expect(result).toEqual({}); + }); + + test("should handle edge case with empty query", () => { + const query = ""; + const result = includeFilters(query); + expect(result).toEqual({}); + }); +}); diff --git a/course-matrix/backend/__tests__/index.test.ts b/course-matrix/backend/__tests__/index.test.ts index 874e1000..28f3930f 100644 --- a/course-matrix/backend/__tests__/index.test.ts +++ b/course-matrix/backend/__tests__/index.test.ts @@ -7,12 +7,3 @@ describe("Sum function", () => { expect(2 + 3).toEqual(5); }); }); - -// Will finish the rest of the tests below in Sprint 3 - -// describe("GET /auth/session", () => { -// test("It should respond with 200 status", async () => { -// const response = await request(app).get("/auth/session"); -// expect(response.statusCode).toBe(200); -// }); -// }); diff --git a/course-matrix/backend/__tests__/isValidOffering.test.ts b/course-matrix/backend/__tests__/isValidOffering.test.ts new file mode 100644 index 00000000..70a28cb8 --- /dev/null +++ b/course-matrix/backend/__tests__/isValidOffering.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, test } from "@jest/globals"; + +import { createOffering, isValidOffering } from "../src/utils/generatorHelpers"; +import { + Offering, + Restriction, + RestrictionType, +} from "../src/types/generatorTypes"; + +describe("isValidOffering", () => { + const sampleOffering: Offering = createOffering({ + id: 1, + course_id: 101, + day: "MO", + start: "10:00:00", + end: "11:00:00", + }); + + test("should allow offering if there are no restrictions", () => { + expect(isValidOffering(sampleOffering, [])).toBe(true); + }); + + test("should allow offering if all restrictions are disabled", () => { + const restrictions: Restriction[] = [ + { + type: RestrictionType.RestrictBefore, + days: ["MO"], + startTime: "", + endTime: "09:00:00", + disabled: true, + numDays: 0, + }, + ]; + expect(isValidOffering(sampleOffering, restrictions)).toBe(true); + }); + + test("should reject offering if it starts before restriction start time", () => { + const restrictions: Restriction[] = [ + { + type: RestrictionType.RestrictBefore, + days: ["MO"], + startTime: "", + endTime: "11:00:00", + disabled: false, + numDays: 0, + }, + ]; + expect(isValidOffering(sampleOffering, restrictions)).toBe(false); + }); + + test("should reject offering if it ends after restriction end time", () => { + const restrictions: Restriction[] = [ + { + type: RestrictionType.RestrictAfter, + days: ["MO"], + startTime: "10:30:00", + endTime: "", + disabled: false, + numDays: 0, + }, + ]; + expect(isValidOffering(sampleOffering, restrictions)).toBe(false); + }); + + test("should reject offering if it is within restricted time range", () => { + const restrictions: Restriction[] = [ + { + type: RestrictionType.RestrictBetween, + days: ["MO"], + startTime: "09:00:00", + endTime: "12:00:00", + disabled: false, + numDays: 0, + }, + ]; + expect(isValidOffering(sampleOffering, restrictions)).toBe(false); + }); + + test("should reject offering if the day is restricted", () => { + const restrictions: Restriction[] = [ + { + type: RestrictionType.RestrictDay, + days: ["MO"], + startTime: "", + endTime: "", + disabled: false, + numDays: 0, + }, + ]; + expect(isValidOffering(sampleOffering, restrictions)).toBe(false); + }); + + test("should allow offering if the day is not restricted", () => { + const restrictions: Restriction[] = [ + { + type: RestrictionType.RestrictDay, + days: ["TU"], + startTime: "", + endTime: "", + disabled: false, + numDays: 0, + }, + ]; + expect(isValidOffering(sampleOffering, restrictions)).toBe(true); + }); +}); diff --git a/course-matrix/backend/__tests__/restrictionsController.test.ts b/course-matrix/backend/__tests__/restrictionsController.test.ts new file mode 100644 index 00000000..770c8f7c --- /dev/null +++ b/course-matrix/backend/__tests__/restrictionsController.test.ts @@ -0,0 +1,551 @@ +import request from "supertest"; +import restrictionsController from "../src/controllers/restrictionsController"; +import { Request, Response, NextFunction } from "express"; +import { + jest, + describe, + test, + expect, + beforeEach, + afterAll, +} from "@jest/globals"; +import { authHandler } from "../src/middleware/authHandler"; +import { supabase } from "../src/db/setupDb"; +import { + instanceOfErrorResponse, + Json, +} from "@pinecone-database/pinecone/dist/pinecone-generated-ts-fetch/db_control"; +import app from "../src/index"; +import { server } from "../src/index"; +import { errorConverter } from "../src/middleware/errorHandler"; + +//Handle AI import from index.ts +jest.mock("@ai-sdk/openai", () => ({ + createOpenAI: jest.fn(() => ({ + chat: jest.fn(), + })), +})); + +jest.mock("ai", () => ({ + streamText: jest.fn(() => + Promise.resolve({ pipeDataStreamToResponse: jest.fn() }), + ), +})); + +jest.mock("@pinecone-database/pinecone", () => ({ + Pinecone: jest.fn(() => ({ + Index: jest.fn(() => ({ + query: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + })), + })), +})); + +jest.mock("node-cron", () => ({ + schedule: jest.fn(), // Mock the `schedule` function +})); + +afterAll(async () => { + server.close(); +}); + +// Function to create authenticated session dynamically based as provided user_id +const mockAuthHandler = (user_id: string) => { + return (req: Request, res: Response, next: NextFunction) => { + (req as any).user = { id: user_id }; // Inject user_id dynamically + next(); + }; +}; + +// Mock authHandler globally +jest.mock("../src/middleware/authHandler", () => ({ + authHandler: jest.fn() as jest.MockedFunction, +})); + +// Mock timetables dataset +const mockRestriction = { + id: 1, + restriction_type: "Restrict Between", + user_id: "testuser04-f84fd0da-d775-4424-ad88-d9675282453c", + start_time: "13:30:00", + end_time: "14:30:00", + days: ["MO", "TUE"], + disabled: false, +}; + +const mockRestriction2 = { + id: 1, + calendar_id: 1, + restriction_type: "Restrict Between", + user_id: "testuser05-f84fd0da-d775-4424-ad88-d9675282453c", + start_time: "13:30:00", + end_time: "14:30:00", + days: ["MO", "TUE"], + disabled: false, +}; + +const mockTimetables1 = { + id: 1, + user_id: "testuser04-f84fd0da-d775-4424-ad88-d9675282453c", +}; + +const mockTimetables2 = { + id: 1, + user_id: "testuser05-f84fd0da-d775-4424-ad88-d9675282453c", +}; + +// Spy on the createRestriction method +jest + .spyOn(restrictionsController, "createRestriction") + .mockImplementation(restrictionsController.createRestriction); + +// Spy on the createTimetable method +jest + .spyOn(restrictionsController, "getRestriction") + .mockImplementation(restrictionsController.getRestriction); + +// Spy on the updateTimetable method +jest + .spyOn(restrictionsController, "updateRestriction") + .mockImplementation(restrictionsController.updateRestriction); + +// Spy on the deleteTimetable method +jest + .spyOn(restrictionsController, "deleteRestriction") + .mockImplementation(restrictionsController.deleteRestriction); + +// Mock data set response to qeury +jest.mock("../src/db/setupDb", () => ({ + supabase: { + //Mock return from schema, from and select to chain the next query command + schema: jest.fn().mockReturnThis(), + from: jest.fn().mockImplementation((key, value) => { + if (key === "timetables") { + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockImplementation((key, value) => { + //Each test case is codded by the user_id in session + //DB response 3: Combine .eq and .maybeSingle to signify that the return value could be single: Return non null value + if ( + key === "user_id" && + value === "testuser03-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockImplementation(() => { + return { data: null, error: null }; + }), + }; + } + //DB response 4: Combine .eq and .maybeSingle to signify that the return value could be single: Return null value + if ( + key === "user_id" && + value === "testuser04-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockReturnThis(), // Allow further chaining of eq if required + maybeSingle: jest.fn().mockImplementation(() => { + return { data: mockTimetables1, error: null }; + }), + }; + } + + if ( + key === "user_id" && + value === "testuser05-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockReturnThis(), // Allow further chaining of eq if required + maybeSingle: jest.fn().mockImplementation(() => { + return { data: mockTimetables2, error: null }; + }), + }; + } + }), + }; + } + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockImplementation((key, value) => { + if ( + key === "user_id" && + value === "testuser03-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockImplementation((key, value) => { + if (key === "calendar_id" && value === "1") { + return { + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockImplementation(() => { + return { + data: null, + error: null, + }; + }), + }; + } + }), + }; + } + if ( + key === "user_id" && + value === "testuser04-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockImplementation((key, value) => { + if (key === "calendar_id" && value === "1") { + return { data: mockRestriction, error: null }; + } + }), + }; + } + if ( + key === "user_id" && + value === "testuser05-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockImplementation((key, value) => { + if (key === "calendar_id" && value === "1") { + return { + eq: jest.fn().mockReturnThis(), + maybeSingle: jest.fn().mockImplementation(() => { + return { data: mockRestriction2, error: null }; + }), + }; + } + }), + }; + } + return { data: null, error: null }; + }), + insert: jest.fn().mockImplementation((data: Json) => { + //DB response 5: Create timetable successfully, new timetable data is responded + if ( + data && + data[0].user_id === + "testuser04-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + select: jest.fn().mockImplementation(() => { + // Return the input data when select is called + return { data: data, error: null }; // Return the data passed to insert + }), + }; + } + //DB response 6: Create timetable uncessfully, return error.message + return { + select: jest.fn().mockImplementation(() => { + return { + data: null, + error: { message: "Fail to create timetable" }, + }; + }), + }; + }), + update: jest.fn().mockImplementation((updatedata: Json) => { + //DB response 7: Timetable updated successfully, db return updated data in response + if (updatedata && updatedata.start_time === "09:00:00.000Z") { + return { + eq: jest.fn().mockReturnThis(), + select: jest.fn().mockImplementation(() => { + return { data: updatedata, error: null }; + }), + }; + } + //DB response 8: Update timetable uncessfully, return error.message + return { data: null, error: { message: "Fail to update timetable" } }; + }), + delete: jest.fn().mockImplementation(() => { + //DB response 9: Delete timetable successfully + return { + eq: jest.fn().mockImplementation((key, value) => { + if ( + key === "user_id" && + value === "testuser05-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockReturnThis(), + data: null, + error: null, + }; + } + return { + data: null, + error: { message: "Uncessful restriction delete" }, + }; + }), + }; + }), + }; + }), + select: jest.fn().mockReturnThis(), + // Mock db response to .eq query command + eq: jest.fn().mockReturnThis(), + // Mock db response to .insert query command + insert: jest.fn().mockReturnThis(), + + // Mock db response to .update query command + update: jest.fn().mockImplementation((updatedata: Json) => {}), + // Mock db response to .delete query command + delete: jest.fn().mockReturnThis(), + }, +})); + +//Test block 1: Get endpoint +describe("GET /api/timetables/restrictions/:id", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should return timetables", async () => { + // Initialize the authenticated session + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce( + mockAuthHandler("testuser04-f84fd0da-d775-4424-ad88-d9675282453c"), + ); + + const response = await request(app).get("/api/timetables/restrictions/1"); + + // Check database interaction and response + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(mockRestriction); + }); + + test("should return 404 if no timetables found", async () => { + // Initialize the authenticated session + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce( + mockAuthHandler("testuser03-f84fd0da-d775-4424-ad88-d9675282453c"), + ); + + const response = await request(app).get("/api/timetables/restrictions/1"); + // Verify the response status and error message + expect(response.statusCode).toBe(404); + expect(response.body).toEqual({ + error: "Calendar id not found", + }); + }); +}); + +//Test block 2: POST endpoint +describe("POST /api/timetables/restrictions", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should return error code 400 and error message: 'calendar id is required'", async () => { + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const newTimetable = {}; + + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .post("/api/timetables/restrictions") + .send(newTimetable); + // Check database interaction and response + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + error: "calendar id is required", + }); + }); + + test("should return error code 400 and error message: 'Start time or end time must be provided'", async () => { + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const newTimetable = { + calendar_id: "1", + }; + + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .post("/api/timetables/restrictions") + .send(newTimetable); + // Check database interaction and response + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + error: "Start time or end time must be provided", + }); + }); + + test("should create a new timetable given calendar_id, start_time and end_time", async () => { + const user_id = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; + const newRestriction = { + calendar_id: "1", + start_time: "2025-03-04T09:00:00.000Z", + end_time: "2025-03-04T10:00:00.000Z", + }; + + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .post("/api/timetables/restrictions") + .send(newRestriction); + // Check database interaction and response + + expect(response.statusCode).toBe(201); + expect(response.body).toEqual([ + { + user_id, + calendar_id: "1", + start_time: "09:00:00.000Z", + end_time: "10:00:00.000Z", + }, + ]); + }); + + test("should return error code 404 and error message: Calendar id not found", async () => { + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const newRestriction = { + calendar_id: "1", + start_time: "2025-03-04T09:00:00.000Z", + end_time: "2025-03-04T10:00:00.000Z", + }; + + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .post("/api/timetables/restrictions") + .send(newRestriction); + // Check database interaction and response + + expect(response.statusCode).toBe(404); + expect(response.body).toEqual({ + error: "Calendar id not found", + }); + }); +}); + +//Test block 3: Put endpoint +describe("PUT /api/timetables/restrictions/:id", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test("should return error code 400 and message: calendar id is required ", async () => { + // Make sure the test user is authenticated + const user_id = "testuser05-f84fd0da-d775-4424-ad88-d9675282453c"; + const timetableData = { + start_time: "2025-03-04T09:00:00.000Z", + }; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .put("/api/timetables/restrictions/1") + .send(timetableData); + + // Check that the `update` method was called + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + error: "calendar id is required", + }); + }); + + test("should update the timetable successfully", async () => { + // Make sure the test user is authenticated + const user_id = "testuser05-f84fd0da-d775-4424-ad88-d9675282453c"; + const timetableData = { + start_time: "2025-03-04T09:00:00.000Z", + }; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .put("/api/timetables/restrictions/1?calendar_id=1") + .send(timetableData); + + // Check that the `update` method was called + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + start_time: "09:00:00.000Z", + }); + }); + + test("should return error code 404 and message: Restriction id does not exist", async () => { + // Make sure the test user is authenticated + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const timetableData = { + start_time: "2025-03-04T09:00:00.000Z", + }; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .put("/api/timetables/restrictions/1?calendar_id=1") + .send(timetableData); + + // Check that the `update` method was called + expect(response.statusCode).toBe(404); + expect(response.body).toEqual({ + error: "Restriction id does not exist", + }); + }); +}); + +//Test block 4: Delete endpoint +describe("DELETE /api/timetables/:id", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should delete the timetable successfully", async () => { + // Make sure the test user is authenticated + const user_id = "testuser05-f84fd0da-d775-4424-ad88-d9675282453c"; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app).delete( + "/api/timetables/restrictions/1?calendar_id=1", + ); + + // Check that the `update` method was called + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + message: "Restriction successfully deleted", + }); + }); + + test("should return error code 404 and message: Calendar id not found and id not found", async () => { + // Make sure the test user is authenticated + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app).delete( + "/api/timetables/restrictions/1?calendar_id=1", + ); + + // Check that the `update` method was called + expect(response.statusCode).toBe(404); + expect(response.body).toEqual({ + error: "Restriction id does not exist", + }); + }); +}); diff --git a/course-matrix/backend/__tests__/timetablesController.test.ts b/course-matrix/backend/__tests__/timetablesController.test.ts new file mode 100644 index 00000000..a7ab3503 --- /dev/null +++ b/course-matrix/backend/__tests__/timetablesController.test.ts @@ -0,0 +1,431 @@ +import request from "supertest"; +import timetablesController from "../src/controllers/timetablesController"; +import { Request, Response, NextFunction } from "express"; +import { + jest, + describe, + test, + expect, + beforeEach, + afterAll, +} from "@jest/globals"; +import { authHandler } from "../src/middleware/authHandler"; +import { supabase } from "../src/db/setupDb"; +import { Json } from "@pinecone-database/pinecone/dist/pinecone-generated-ts-fetch/db_control"; +import app from "../src/index"; +import { server } from "../src/index"; + +//Handle AI import from index.ts +jest.mock("@ai-sdk/openai", () => ({ + createOpenAI: jest.fn(() => ({ + chat: jest.fn(), + })), +})); + +jest.mock("ai", () => ({ + streamText: jest.fn(() => + Promise.resolve({ pipeDataStreamToResponse: jest.fn() }), + ), +})); + +jest.mock("@pinecone-database/pinecone", () => ({ + Pinecone: jest.fn(() => ({ + Index: jest.fn(() => ({ + query: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + })), + })), +})); + +jest.mock("node-cron", () => ({ + schedule: jest.fn(), // Mock the `schedule` function +})); + +afterAll(async () => { + server.close(); +}); + +// Function to create authenticated session dynamically based as provided user_id +const mockAuthHandler = (user_id: string) => { + return (req: Request, res: Response, next: NextFunction) => { + (req as any).user = { id: user_id }; // Inject user_id dynamically + next(); + }; +}; + +// Mock authHandler globally +jest.mock("../src/middleware/authHandler", () => ({ + authHandler: jest.fn() as jest.MockedFunction, +})); + +// Mock timetables dataset +const mockTimetables1 = [ + { + id: 1, + name: "Timetable 1", + user_id: "testuser01-ab9e6877-f603-4c6a-9832-864e520e4d01", + }, + { + id: 2, + name: "Timetable 2", + user_id: "testuser01-ab9e6877-f603-4c6a-9832-864e520e4d01", + }, +]; + +// Spy on the getTimetables method +jest + .spyOn(timetablesController, "getTimetables") + .mockImplementation(timetablesController.getTimetables); + +// Spy on the createTimetable method +jest + .spyOn(timetablesController, "createTimetable") + .mockImplementation(timetablesController.createTimetable); + +// Spy on the updateTimetable method +jest + .spyOn(timetablesController, "updateTimetable") + .mockImplementation(timetablesController.updateTimetable); + +// Spy on the deleteTimetable method +jest + .spyOn(timetablesController, "deleteTimetable") + .mockImplementation(timetablesController.deleteTimetable); + +// Mock data set response to qeury +jest.mock("../src/db/setupDb", () => ({ + supabase: { + //Mock return from schema, from and select to chain the next query command + schema: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + // Mock db response to .eq query command + eq: jest.fn().mockImplementation((key, value) => { + //Each test case is codded by the user_id in session + //DB response 1: Query user timetable return non null value + if ( + key === "user_id" && + value === "testuser01-ab9e6877-f603-4c6a-9832-864e520e4d01" + ) { + // Return mock data when user_id matches + return { data: mockTimetables1, error: null }; + } + //DB response 2: Query user timetable return null value + if ( + key === "user_id" && + value === "testuser02-1d3f02df-f926-4c1f-9f41-58ca50816a33" + ) { + // Return null for this user_id + return { data: null, error: null }; + } + + //DB response 3: Combine .eq and .maybeSingle to signify that the return value could be single: Return non null value + if ( + key === "user_id" && + value === "testuser03-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockReturnThis(), // Allow further chaining of eq if required + maybeSingle: jest.fn().mockImplementation(() => { + return { data: null, error: null }; + }), + }; + } + //DB response 4: Combine .eq and .maybeSingle to signify that the return value could be single: Return null value + if ( + key === "user_id" && + value === "testuser04-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + eq: jest.fn().mockReturnThis(), // Allow further chaining of eq if required + maybeSingle: jest.fn().mockImplementation(() => { + return { data: mockTimetables1, error: null }; + }), + }; + } + }), + // Mock db response to .insert query command + insert: jest.fn().mockImplementation((data: Json) => { + //DB response 5: Create timetable successfully, new timetable data is responded + if ( + data && + data[0].user_id === "testuser03-f84fd0da-d775-4424-ad88-d9675282453c" + ) { + return { + select: jest.fn().mockImplementation(() => { + // Return the input data when select is called + return { data: data, error: null }; // Return the data passed to insert + }), + }; + } + //DB response 6: Create timetable uncessfully, return error.message + return { + select: jest.fn().mockImplementation(() => { + return { data: null, error: { message: "Fail to create timetable" } }; + }), + }; + }), + + // Mock db response to .update query command + update: jest.fn().mockImplementation((updatedata: Json) => { + //DB response 7: Timetable updated successfully, db return updated data in response + if (updatedata && updatedata.timetable_title === "Updated Title") { + return { + eq: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + single: jest.fn().mockImplementation((data) => { + return { data: updatedata, error: null }; + }), + }; + } + //DB response 8: Update timetable uncessfully, return error.message + return { data: null, error: { message: "Fail to update timetable" } }; + }), + + // Mock db response to .delete query command + delete: jest.fn().mockImplementation(() => { + //DB response 9: Delete timetable successfully + return { + eq: jest.fn().mockReturnThis(), + data: null, + error: null, + }; + }), + }, +})); + +//Test block 1: Get endpoint +describe("GET /api/timetables", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should return timetables for user 1", async () => { + // Initialize the authenticated session + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce( + mockAuthHandler("testuser01-ab9e6877-f603-4c6a-9832-864e520e4d01"), + ); + + const response = await request(app).get("/api/timetables"); + + // Check database interaction and response + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(mockTimetables1); + }); + + test("should return 404 if no timetables found", async () => { + // Initialize the authenticated session + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce( + mockAuthHandler("testuser02-1d3f02df-f926-4c1f-9f41-58ca50816a33"), + ); + + const response = await request(app).get("/api/timetables"); + // Verify the response status and error message + expect(response.statusCode).toBe(404); + expect(response.body).toEqual({ + error: "Calendar id not found", + }); + }); +}); + +//Test block 2: POST endpoint +describe("POST /api/timetables", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should return error code 400 and error message: 'timetable title and semester are required' when request body miss timetable_title or semester", async () => { + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const newTimetable = {}; + + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .post("/api/timetables") + .send(newTimetable); + // Check database interaction and response + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + error: "timetable title and semester are required", + }); + }); + + test("should create a new timetable given timetable title, timetable semester, timetable favorite", async () => { + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const newTimetable = { + timetable_title: "Minh timetable", + semester: "Fall 2025", + favorite: false, + }; + + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .post("/api/timetables") + .send(newTimetable); + // Check database interaction and response + + expect(response.statusCode).toBe(201); + expect(response.body).toEqual([ + { + user_id, + timetable_title: "Minh timetable", + semester: "Fall 2025", + favorite: false, + }, + ]); + }); + + test("should return error code 400 and error message: A timetable with this title already exists when checkduplicate query return a value", async () => { + const user_id = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; + const newTimetable = { + timetable_title: "Minh timetable", + semester: "Fall 2025", + favorite: false, + }; + + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .post("/api/timetables") + .send(newTimetable); + // Check database interaction and response + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + error: "A timetable with this title already exists", + }); + }); +}); + +//Test block 3: Put endpoint +describe("PUT /api/timetables/:id", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + test("should return error code 400 and message 'New timetable title or semester or updated favorite status is required when updating a timetable' if request body is empty", async () => { + // Make sure the test user is authenticated + const user_id = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; + const timetableData = {}; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .put("/api/timetables/1") + .send(timetableData); + + // Check that the `update` method was called + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + error: + "New timetable title or semester or updated favorite status or email notifications enabled is required when updating a timetable", + }); + }); + + test("should update the timetable successfully", async () => { + // Make sure the test user is authenticated + const user_id = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; + const timetableData = { + timetable_title: "Updated Title", + semester: "Spring 2025", + }; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .put("/api/timetables/1") + .send(timetableData); + + // Check that the `update` method was called + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + timetable_title: "Updated Title", + semester: "Spring 2025", + }); + }); + + test("should return error code 404 and message: Calendar id not found and id not found", async () => { + // Make sure the test user is authenticated + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + const timetableData = { + timetable_title: "Updated Title", + semester: "Spring 2025", + }; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app) + .put("/api/timetables/1") + .send(timetableData); + + // Check that the `update` method was called + expect(response.statusCode).toBe(404); + expect(response.body).toEqual({ + error: "Calendar id not found", + }); + }); +}); + +//Test block 4: Delete endpoint +describe("DELETE /api/timetables/:id", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should delete the timetable successfully", async () => { + // Make sure the test user is authenticated + const user_id = "testuser04-f84fd0da-d775-4424-ad88-d9675282453c"; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app).delete("/api/timetables/1"); + + // Check that the `update` method was called + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + message: "Timetable successfully deleted", + }); + }); + + test("should return error code 404 and message: Calendar id not found and id not found", async () => { + // Make sure the test user is authenticated + const user_id = "testuser03-f84fd0da-d775-4424-ad88-d9675282453c"; + + // Mock authHandler to simulate the user being logged in + ( + authHandler as jest.MockedFunction + ).mockImplementationOnce(mockAuthHandler(user_id)); + + const response = await request(app).delete("/api/timetables/1"); + + // Check that the `update` method was called + expect(response.statusCode).toBe(404); + expect(response.body).toEqual({ + error: "Calendar id not found", + }); + }); +}); diff --git a/course-matrix/backend/jest.config.ts b/course-matrix/backend/jest.config.ts index d51af79a..fcea6357 100644 --- a/course-matrix/backend/jest.config.ts +++ b/course-matrix/backend/jest.config.ts @@ -4,10 +4,9 @@ const config: Config = { preset: "ts-jest", moduleNameMapper: { "\\.(css|scss)$": "identity-obj-proxy", - "^.+\\.svg": "/tests/mocks/svgMock.tsx", + "^(\\.{1,2}/.*)\\.js$": "$1", }, // to obtain access to the matchers. - setupFilesAfterEnv: ["/tests/setupTests.ts"], moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], modulePaths: [""], testEnvironment: "jsdom", @@ -20,6 +19,10 @@ const config: Config = { ], "^.+\\.(js|jsx)$": "babel-jest", }, + transformIgnorePatterns: [ + "/node_modules/(?!(node-cron|uuid)/)", // Keep transforming `node-cron` + ], + setupFilesAfterEnv: ["/jest.setup.ts"], }; export default config; diff --git a/course-matrix/backend/jest.setup.ts b/course-matrix/backend/jest.setup.ts new file mode 100644 index 00000000..756600f9 --- /dev/null +++ b/course-matrix/backend/jest.setup.ts @@ -0,0 +1,10 @@ +import fetch from "node-fetch"; + +// Polyfill the global fetch for Pinecone to use +globalThis.fetch = fetch as unknown as WindowOrWorkerGlobalScope["fetch"]; +globalThis.TransformStream = require("stream/web").TransformStream; +globalThis.TextEncoder = require("util").TextEncoder; +globalThis.TextDecoder = require("util").TextDecoder; +globalThis.ReadableStream = require("stream/web").ReadableStream; +globalThis.TransformStream = require("stream/web").TransformStream; +globalThis.WritableStream = require("stream/web").WritableStream; diff --git a/course-matrix/backend/package-lock.json b/course-matrix/backend/package-lock.json index 87710f8b..9efce277 100644 --- a/course-matrix/backend/package-lock.json +++ b/course-matrix/backend/package-lock.json @@ -10,13 +10,16 @@ "license": "ISC", "dependencies": { "@ai-sdk/openai": "^1.1.13", + "@getbrevo/brevo": "^2.2.0", "@langchain/community": "^0.3.32", "@langchain/openai": "^0.4.4", "@langchain/pinecone": "^0.1.3", "@pinecone-database/pinecone": "^5.0.2", "@supabase/supabase-js": "^2.48.1", "@types/express": "^5.0.0", + "@types/node-fetch": "^2.6.12", "ai": "^4.1.45", + "axios": "^1.8.4", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "csv-parser": "^3.2.0", @@ -24,8 +27,11 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "langchain": "^0.3.19", + "node-cron": "^3.0.3", + "node-fetch": "^2.7.0", "openai": "^4.85.4", "pdf-parse": "^1.1.1", + "stream": "^0.0.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "validator": "^13.12.0" @@ -35,7 +41,8 @@ "@testing-library/react": "^16.0.0", "@types/cookie-parser": "^1.4.8", "@types/cors": "^2.8.17", - "@types/jest": "^29.5.12", + "@types/jest": "^29.5.14", + "@types/node-cron": "^3.0.11", "@types/supertest": "^6.0.2", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.7", @@ -43,7 +50,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "supertest": "^7.0.0", - "ts-jest": "^29.2.3", + "ts-jest": "^29.2.6", "ts-node": "^10.9.2" } }, @@ -102,12 +109,13 @@ } }, "node_modules/@ai-sdk/react": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.1.17.tgz", - "integrity": "sha512-NAuEflFvjw1uh1AOmpyi7rBF4xasWsiWUb86JQ8ScjDGxoGDYEdBnaHOxUpooLna0dGNbSPkvDMnVRhoLKoxPQ==", + "version": "1.1.25", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.1.25.tgz", + "integrity": "sha512-uKrnxvJmiixAhndquDtac/q/wOnG9EFBkAsL6mpDRDflHQv34+xtkOKswDxyEzt1FaQFoqig0J44Lx0F3vGSkg==", + "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider-utils": "2.1.9", - "@ai-sdk/ui-utils": "1.1.15", + "@ai-sdk/provider-utils": "2.1.15", + "@ai-sdk/ui-utils": "1.1.21", "swr": "^2.2.5", "throttleit": "2.1.0" }, @@ -127,13 +135,49 @@ } } }, + "node_modules/@ai-sdk/react/node_modules/@ai-sdk/provider": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.0.12.tgz", + "integrity": "sha512-88Uu1zJIE1UUOVJWfE2ybJXgiH8JJ97QY9fbmplErEbfa/k/1kF+tWMVAAJolF2aOGmazQGyQLhv4I9CCuVACw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/react/node_modules/@ai-sdk/provider-utils": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.1.15.tgz", + "integrity": "sha512-ndMVtDm2xS86t45CJZSfyl7UblZFewRB8gZkXQHeNi7BhjCYkhE+XQMwfDl6UOAO7kaV60IC1R4JLDWxWiiHug==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.0.12", + "eventsource-parser": "^3.0.0", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@ai-sdk/ui-utils": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.1.15.tgz", - "integrity": "sha512-NsV/3CMmjc4m53snzRdtZM6teTQUXIKi8u0Kf7GBruSzaMSuZ4DWaAAlUshhR3p2FpZgtsogW+vYG1/rXsGu+Q==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.1.21.tgz", + "integrity": "sha512-z88UBEioQvJM6JsBoLmG6MOholc5pDkq1BBeb53NZ7JmMeWX4btCbrGmM4qs+gYLDnZokV/HB8C6zpS1jaJbAw==", + "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "1.0.8", - "@ai-sdk/provider-utils": "2.1.9", + "@ai-sdk/provider": "1.0.12", + "@ai-sdk/provider-utils": "2.1.15", "zod-to-json-schema": "^3.24.1" }, "engines": { @@ -148,6 +192,41 @@ } } }, + "node_modules/@ai-sdk/ui-utils/node_modules/@ai-sdk/provider": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.0.12.tgz", + "integrity": "sha512-88Uu1zJIE1UUOVJWfE2ybJXgiH8JJ97QY9fbmplErEbfa/k/1kF+tWMVAAJolF2aOGmazQGyQLhv4I9CCuVACw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/ui-utils/node_modules/@ai-sdk/provider-utils": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.1.15.tgz", + "integrity": "sha512-ndMVtDm2xS86t45CJZSfyl7UblZFewRB8gZkXQHeNi7BhjCYkhE+XQMwfDl6UOAO7kaV60IC1R4JLDWxWiiHug==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.0.12", + "eventsource-parser": "^3.0.0", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -846,6 +925,170 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz", + "integrity": "sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@getbrevo/brevo": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@getbrevo/brevo/-/brevo-2.2.0.tgz", + "integrity": "sha512-mNCkXtgqn6jqLglAx1JzZcTj53kBZ5dK9Yd6zVuEyXAvhz68f5Ps6dSJar9HvkHH0Lfr3NrqW76xurjcoxqhIg==", + "dependencies": { + "bluebird": "^3.5.0", + "request": "^2.81.0", + "rewire": "^7.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead" + }, "node_modules/@ibm-cloud/watsonx-ai": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@ibm-cloud/watsonx-ai/-/watsonx-ai-1.5.0.tgz", @@ -2068,6 +2311,38 @@ "@langchain/core": ">=0.2.21 <0.4.0" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -2535,10 +2810,11 @@ } }, "node_modules/@types/jest": { - "version": "29.5.12", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", - "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, + "license": "MIT", "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" @@ -2605,10 +2881,17 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true + }, "node_modules/@types/node-fetch": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "license": "MIT", "dependencies": { "@types/node": "*", "form-data": "^4.0.0" @@ -2730,6 +3013,11 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -2764,7 +3052,6 @@ "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "devOptional": true, "bin": { "acorn": "bin/acorn" }, @@ -2782,6 +3069,14 @@ "acorn-walk": "^8.0.2" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", @@ -2841,15 +3136,17 @@ } }, "node_modules/ai": { - "version": "4.1.45", - "resolved": "https://registry.npmjs.org/ai/-/ai-4.1.45.tgz", - "integrity": "sha512-nQkxQ2zCD+O/h8zJ+PxmBv9coyMaG1uP9kGJvhNaGAA25hbZRQWL0NbTsSJ/QMOUraXKLa+6fBm3VF1NkJK9Kg==", - "dependencies": { - "@ai-sdk/provider": "1.0.8", - "@ai-sdk/provider-utils": "2.1.9", - "@ai-sdk/react": "1.1.17", - "@ai-sdk/ui-utils": "1.1.15", + "version": "4.1.65", + "resolved": "https://registry.npmjs.org/ai/-/ai-4.1.65.tgz", + "integrity": "sha512-JLZgYkg05If0bCjAHvHk5G9xz5wSo0h4aKi/L5EKU3NZX2v6EM+EgKGMQcMKeCGxjRbR3fyYyek6CjZn+1GkQA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.0.12", + "@ai-sdk/provider-utils": "2.1.15", + "@ai-sdk/react": "1.1.25", + "@ai-sdk/ui-utils": "1.1.21", "@opentelemetry/api": "1.9.0", + "eventsource-parser": "^3.0.0", "jsondiffpatch": "0.6.0" }, "engines": { @@ -2868,6 +3165,56 @@ } } }, + "node_modules/ai/node_modules/@ai-sdk/provider": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.0.12.tgz", + "integrity": "sha512-88Uu1zJIE1UUOVJWfE2ybJXgiH8JJ97QY9fbmplErEbfa/k/1kF+tWMVAAJolF2aOGmazQGyQLhv4I9CCuVACw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ai/node_modules/@ai-sdk/provider-utils": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.1.15.tgz", + "integrity": "sha512-ndMVtDm2xS86t45CJZSfyl7UblZFewRB8gZkXQHeNi7BhjCYkhE+XQMwfDl6UOAO7kaV60IC1R4JLDWxWiiHug==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.0.12", + "eventsource-parser": "^3.0.0", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2887,7 +3234,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2947,6 +3293,22 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2958,11 +3320,23 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==" + }, "node_modules/axios": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", - "peer": true, + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", + "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -3143,6 +3517,14 @@ } ] }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3154,6 +3536,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -3331,7 +3718,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -3367,6 +3753,11 @@ } ] }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" + }, "node_modules/chalk": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", @@ -3561,6 +3952,11 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -3635,7 +4031,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3712,6 +4107,17 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -3797,6 +4203,11 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3944,6 +4355,15 @@ "node": ">= 0.4" } }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -4111,6 +4531,232 @@ "source-map": "~0.6.1" } }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -4124,11 +4770,32 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "devOptional": true, "engines": { "node": ">=4.0" } @@ -4280,14 +4947,30 @@ "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "peer": true + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ] + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "node_modules/fast-safe-stringify": { "version": "2.1.1", @@ -4295,6 +4978,14 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "dev": true }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4304,6 +4995,17 @@ "bser": "2.1.1" } }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, "node_modules/file-type": { "version": "16.5.4", "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", @@ -4401,6 +5103,24 @@ "flat": "cli.js" } }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==" + }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -4411,7 +5131,6 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], - "peer": true, "engines": { "node": ">=4.0" }, @@ -4421,6 +5140,14 @@ } } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "engines": { + "node": "*" + } + }, "node_modules/form-data": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", @@ -4582,6 +5309,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -4602,6 +5337,17 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -4628,6 +5374,32 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" + }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/harmony-reflect": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", @@ -4757,6 +5529,20 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "devOptional": true }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -4842,6 +5628,17 @@ "integrity": "sha512-9taxKC944BqoTVjE+UT3pQH0nHZlTvITwfsOZqyc+R3sfJuxaTtxWjfn1K2UlxyPcKHf0rnaXcVFrS9F9vf0bw==", "peer": true }, + "node_modules/ibm-cloud-sdk-core/node_modules/axios": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "peer": true, + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/ibm-cloud-sdk-core/node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -4922,6 +5719,37 @@ ], "peer": true }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "engines": { + "node": ">=4" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -4945,7 +5773,6 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, "engines": { "node": ">=0.8.19" } @@ -5003,6 +5830,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -5021,6 +5856,17 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5030,6 +5876,14 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -5048,17 +5902,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "peer": true + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -5203,6 +6060,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6406,6 +7264,11 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" + }, "node_modules/jsdom": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", @@ -6497,6 +7360,11 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -6508,6 +7376,21 @@ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6572,6 +7455,20 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "peer": true }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -6593,6 +7490,14 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6768,6 +7673,18 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6846,6 +7763,11 @@ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, "node_modules/lodash.mergewith": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", @@ -7062,8 +7984,7 @@ "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, "node_modules/negotiator": { "version": "0.6.3", @@ -7073,6 +7994,25 @@ "node": ">= 0.6" } }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -7100,6 +8040,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -7154,6 +8095,14 @@ "integrity": "sha512-p1TRH/edngVEHVbwqWnxUViEmq5znDvyB+Sik5cmuLpGOIfDf/39zLiq3swPF8Vakqn+gvNiOQAZu8djYlQILA==", "devOptional": true }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "engines": { + "node": "*" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7208,9 +8157,9 @@ } }, "node_modules/openai": { - "version": "4.85.4", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.85.4.tgz", - "integrity": "sha512-Nki51PBSu+Aryo7WKbdXvfm0X/iKkQS2fq3O0Uqb/O3b4exOZFid2te1BZ52bbO5UwxQZ5eeHJDCTqtrJLPw0w==", + "version": "4.87.3", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.87.3.tgz", + "integrity": "sha512-d2D54fzMuBYTxMW8wcNmhT1rYKcTfMJ8t+4KjH2KtvYenygITiGBgHoIrzHwnDQWW+C5oCA+ikIR2jgPCFqcKQ==", "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -7254,6 +8203,22 @@ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==" }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -7266,7 +8231,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -7351,6 +8315,17 @@ "node": ">=6" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -7393,7 +8368,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -7410,7 +8384,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -7464,6 +8437,11 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7533,6 +8511,14 @@ "node": ">=18" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -7585,8 +8571,7 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "peer": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/psl": { "version": "1.15.0", @@ -7642,6 +8627,25 @@ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7748,6 +8752,79 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "dev": true }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/qs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/request/node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7832,6 +8909,60 @@ "axios": "*" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rewire": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rewire/-/rewire-7.0.0.tgz", + "integrity": "sha512-DyyNyzwMtGYgu0Zl/ya0PR/oaunM+VuCuBxCuhYJHHaV0V+YvYa3bBGxb5OZ71vndgmp1pYY8F4YOwQo1siRGw==", + "dependencies": { + "eslint": "^8.47.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", @@ -7958,7 +9089,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -7970,7 +9100,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -8094,6 +9223,30 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -8114,6 +9267,27 @@ "node": ">= 0.8" } }, + "node_modules/stream": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.3.tgz", + "integrity": "sha512-aMsbn7VKrl4A2T7QAQQbzgN7NVc70vgF5INQrBXqn4dCXN1zy3L9HGgLO5s7PExmdrzTJ8uR/27aviW8or8/+A==", + "license": "MIT", + "dependencies": { + "component-emitter": "^2.0.0" + } + }, + "node_modules/stream/node_modules/component-emitter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-2.0.0.tgz", + "integrity": "sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -8154,7 +9328,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -8196,7 +9369,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, "engines": { "node": ">=8" }, @@ -8365,9 +9537,10 @@ } }, "node_modules/swr": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.2.tgz", - "integrity": "sha512-RosxFpiabojs75IwQ316DGoDRmOqtiAj0tg8wCcbEu4CiLZBs/a9QNtHV7TUfDXmmlgqij/NqzKq/eLelyv9xA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz", + "integrity": "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==", + "license": "MIT", "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" @@ -8396,10 +9569,16 @@ "node": ">=8" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" + }, "node_modules/throttleit": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -8470,20 +9649,21 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/ts-jest": { - "version": "29.2.3", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.3.tgz", - "integrity": "sha512-yCcfVdiBFngVz9/keHin9EnsrQtQtEu3nRykNy9RVp+FiPFFbPJ3Sg6Qg4+TkmH0vMP5qsTKgXSsk80HRwvdgQ==", + "version": "29.2.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.6.tgz", + "integrity": "sha512-yTNZVZqc8lSixm+QGVFcPe6+yj7+TWZwIesuOWvfcn4B9bz5x4NDzVCQQjOs7Hfouu36aEqfEbo9Qpo+gq8dDg==", "dev": true, + "license": "MIT", "dependencies": { - "bs-logger": "0.x", + "bs-logger": "^0.2.6", "ejs": "^3.1.10", - "fast-json-stable-stringify": "2.x", + "fast-json-stable-stringify": "^2.1.0", "jest-util": "^29.0.0", "json5": "^2.2.3", - "lodash.memoize": "4.x", - "make-error": "1.x", - "semver": "^7.5.3", - "yargs-parser": "^21.0.1" + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.1", + "yargs-parser": "^21.1.1" }, "bin": { "ts-jest": "cli.js" @@ -8560,6 +9740,33 @@ } } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -8658,6 +9865,14 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -8671,6 +9886,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -8741,6 +9957,19 @@ "node": ">= 0.8" } }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -8821,7 +10050,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -8832,6 +10060,14 @@ "node": ">= 8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -8980,7 +10216,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "engines": { "node": ">=10" }, diff --git a/course-matrix/backend/package.json b/course-matrix/backend/package.json index a1d58c4c..1d0ec019 100644 --- a/course-matrix/backend/package.json +++ b/course-matrix/backend/package.json @@ -13,13 +13,16 @@ "license": "ISC", "dependencies": { "@ai-sdk/openai": "^1.1.13", + "@getbrevo/brevo": "^2.2.0", "@langchain/community": "^0.3.32", "@langchain/openai": "^0.4.4", "@langchain/pinecone": "^0.1.3", "@pinecone-database/pinecone": "^5.0.2", "@supabase/supabase-js": "^2.48.1", "@types/express": "^5.0.0", + "@types/node-fetch": "^2.6.12", "ai": "^4.1.45", + "axios": "^1.8.4", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "csv-parser": "^3.2.0", @@ -27,8 +30,11 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "langchain": "^0.3.19", + "node-cron": "^3.0.3", + "node-fetch": "^2.7.0", "openai": "^4.85.4", "pdf-parse": "^1.1.1", + "stream": "^0.0.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "validator": "^13.12.0" @@ -38,7 +44,8 @@ "@testing-library/react": "^16.0.0", "@types/cookie-parser": "^1.4.8", "@types/cors": "^2.8.17", - "@types/jest": "^29.5.12", + "@types/jest": "^29.5.14", + "@types/node-cron": "^3.0.11", "@types/supertest": "^6.0.2", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.7", @@ -46,7 +53,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "supertest": "^7.0.0", - "ts-jest": "^29.2.3", + "ts-jest": "^29.2.6", "ts-node": "^10.9.2" } } diff --git a/course-matrix/backend/src/constants/availableFunctions.ts b/course-matrix/backend/src/constants/availableFunctions.ts new file mode 100644 index 00000000..4237582c --- /dev/null +++ b/course-matrix/backend/src/constants/availableFunctions.ts @@ -0,0 +1,464 @@ +import { generateWeeklyCourseEvents } from "../controllers/eventsController"; +import { supabase } from "../db/setupDb"; +import { Request } from "express"; +import { + GroupedOfferingList, + Offering, + OfferingList, +} from "../types/generatorTypes"; +import { + categorizeValidOfferings, + getMaxDays, + getValidOfferings, + groupOfferings, + trim, +} from "../utils/generatorHelpers"; +import getOfferings from "../services/getOfferings"; +import { getValidSchedules } from "../services/getValidSchedules"; +import { RestrictionForm } from "../models/timetable-form"; +import { convertTimeStringToDate } from "../utils/convert-time-string"; + +// Add all possible function names here +export type FunctionNames = + | "getTimetables" + | "updateTimetable" + | "deleteTimetable" + | "generateTimetable" + | "getCourses"; + +type AvailableFunctions = { + [K in FunctionNames]: (args: any, req: Request) => Promise; +}; + +// Functions used for OpenAI function calling +export const availableFunctions: AvailableFunctions = { + getTimetables: async (args: any, req: Request) => { + try { + //Retrieve user_id + const user_id = (req as any).user.id; + + //Retrieve user timetable item based on user_id + let timeTableQuery = supabase + .schema("timetable") + .from("timetables") + .select() + .eq("user_id", user_id); + const { data: timetableData, error: timetableError } = + await timeTableQuery; + // console.log("Timetables: ", timetableData) + + if (timetableError) return { status: 400, error: timetableError.message }; + + // If no records were updated due to non-existence timetable or it doesn't belong to the user. + if (!timetableData || timetableData.length === 0) { + return { + status: 404, + error: "Timetable not found or you are not authorized to update it", + }; + } + + return { status: 200, data: timetableData }; + } catch (error) { + console.log(error); + return { status: 400, error: error }; + } + }, + updateTimetable: async (args: any, req: Request) => { + try { + const { id, timetable_title, semester } = args; + + if (!timetable_title && !semester) { + return { + status: 400, + error: + "New timetable title or semester is required when updating a timetable", + }; + } + + //Retrieve the authenticated user + const user_id = (req as any).user.id; + + //Retrieve users allowed to access the timetable + const { data: timetableUserData, error: timetableUserError } = + await supabase + .schema("timetable") + .from("timetables") + .select("*") + .eq("id", id) + .eq("user_id", user_id) + .maybeSingle(); + + const timetable_user_id = timetableUserData?.user_id; + + if (timetableUserError) + return { status: 400, error: timetableUserError.message }; + + //Validate timetable validity: + if (!timetableUserData || timetableUserData.length === 0) { + return { status: 404, error: "Calendar id not found" }; + } + + //Validate user access + if (user_id !== timetable_user_id) { + return { + status: 401, + error: "Unauthorized access to timetable events", + }; + } + + let updateData: any = {}; + if (timetable_title) updateData.timetable_title = timetable_title; + if (semester) updateData.semester = semester; + + //Update timetable title, for authenticated user only + let updateTimetableQuery = supabase + .schema("timetable") + .from("timetables") + .update(updateData) + .eq("id", id) + .eq("user_id", user_id) + .select(); + + const { data: timetableData, error: timetableError } = + await updateTimetableQuery; + + if (timetableError) return { status: 400, error: timetableError.message }; + + // If no records were updated due to non-existence timetable or it doesn't belong to the user. + if (!timetableData || timetableData.length === 0) { + return { + status: 404, + error: "Timetable not found or you are not authorized to update it", + }; + } + return { status: 200, data: timetableData }; + } catch (error) { + return { status: 500, error: error }; + } + }, + deleteTimetable: async (args: any, req: Request) => { + try { + const { id } = args; + + // Retrieve the authenticated user + const user_id = (req as any).user.id; + + //Retrieve users allowed to access the timetable + const { data: timetableUserData, error: timetableUserError } = + await supabase + .schema("timetable") + .from("timetables") + .select("*") + .eq("id", id) + .eq("user_id", user_id) + .maybeSingle(); + const timetable_user_id = timetableUserData?.user_id; + + if (timetableUserError) + return { status: 400, error: timetableUserError.message }; + + //Validate timetable validity: + if (!timetableUserData || timetableUserData.length === 0) { + return { status: 404, error: "Calendar id not found" }; + } + + //Validate user access + if (user_id !== timetable_user_id) { + return { + status: 401, + error: "Unauthorized access to timetable events", + }; + } + + // Delete only if the timetable belongs to the authenticated user + let deleteTimetableQuery = supabase + .schema("timetable") + .from("timetables") + .delete() + .eq("id", id) + .eq("user_id", user_id); + + const { error: timetableError } = await deleteTimetableQuery; + + if (timetableError) return { status: 400, error: timetableError.message }; + + return { status: 200, data: "Timetable successfully deleted" }; + } catch (error) { + return { status: 500, error: error }; + } + }, + generateTimetable: async (args: any, req: Request) => { + try { + // Extract event details and course information from the request + const { name, date, semester, search, courses, restrictions } = args; + const courseOfferingsList: OfferingList[] = []; + const validCourseOfferingsList: GroupedOfferingList[] = []; + const maxdays = await getMaxDays(restrictions); + const validSchedules: Offering[][] = []; + // Fetch offerings for each course + for (const course of courses) { + const { id } = course; + courseOfferingsList.push({ + course_id: id, + offerings: (await getOfferings(id, semester)) ?? [], + }); + } + + const groupedOfferingsList: GroupedOfferingList[] = + await groupOfferings(courseOfferingsList); + + // console.log(JSON.stringify(groupedOfferingsList, null, 2)); + + // Filter out invalid offerings based on the restrictions + for (const { course_id, groups } of groupedOfferingsList) { + validCourseOfferingsList.push({ + course_id: course_id, + groups: await getValidOfferings(groups, restrictions), + }); + } + + const categorizedOfferings = await categorizeValidOfferings( + validCourseOfferingsList, + ); + + // console.log(typeof categorizedOfferings); + // console.log(JSON.stringify(categorizedOfferings, null, 2)); + + // Generate valid schedules for the given courses and restrictions + await getValidSchedules( + validSchedules, + categorizedOfferings, + [], + 0, + categorizedOfferings.length, + maxdays, + ); + + // Return error if no valid schedules are found + if (validSchedules.length === 0) { + return { status: 404, error: "No valid schedules found." }; + } + + // ------ CREATE FLOW ------ + + //Get user id from session authentication to insert in the user_id col + const user_id = (req as any).user.id; + + //Retrieve timetable title + const schedule = trim(validSchedules)[0]; + if (!name || !semester) { + return { + status: 400, + error: "timetable title and semester are required", + }; + } + + // Check if a timetable with the same title already exist for this user + const { data: existingTimetable, error: existingTimetableError } = + await supabase + .schema("timetable") + .from("timetables") + .select("id") + .eq("user_id", user_id) + .eq("timetable_title", name) + .maybeSingle(); + + if (existingTimetableError) { + return { + status: 400, + error: `Existing timetable with name: ${name}. Please rename timetable.`, + }; + } + + if (existingTimetable) { + return { + status: 400, + error: "A timetable with this title already exists", + }; + } + + let favorite = false; + + // Insert the user_id and timetable_title into the db + let insertTimetable = supabase + .schema("timetable") + .from("timetables") + .insert([ + { + user_id, + timetable_title: name, + semester, + favorite, + }, + ]) + .select() + .single(); + + const { data: timetableData, error: timetableError } = + await insertTimetable; + + if (timetableError) { + return { + status: 400, + error: "Timetable error" + timetableError.message, + }; + } + console.log("1"); + // Insert events + for (const offering of schedule) { + //Query course offering information + const { data: offeringData, error: offeringError } = await supabase + .schema("course") + .from("offerings") + .select("*") + .eq("id", offering.id) + .maybeSingle(); + + if (offeringError) + return { + status: 400, + error: `Offering error id: ${offering.id} ` + offeringError.message, + }; + + if (!offeringData || offeringData.length === 0) { + return { + status: 400, + error: "Invalid offering_id or course offering not found.", + }; + } + + //Generate event details + const courseEventName = ` ${offeringData.code} - ${offeringData.meeting_section} `; + const courseDay = offeringData.day; + const courseStartTime = offeringData.start; + const courseEndTime = offeringData.end; + + if (!courseDay || !courseStartTime || !courseEndTime) { + return { + status: 400, + error: "Incomplete offering data to generate course event", + }; + } + + let eventsToInsert: any[] = []; + let semester_start_date; + let semester_end_date; + + if (semester === "Summer 2025") { + semester_start_date = "2025-05-02"; + semester_end_date = "2025-08-07"; + } else if (semester === "Fall 2025") { + semester_start_date = "2025-09-03"; + semester_end_date = "2025-12-03"; + } else { + // Winter 2026 + semester_start_date = "2026-01-06"; + semester_end_date = "2026-04-04"; + } + + if (semester_start_date && semester_end_date) { + eventsToInsert = generateWeeklyCourseEvents( + user_id, + courseEventName, + courseDay, + courseStartTime, + courseEndTime, + timetableData.id, + offering.id.toString(), + semester_start_date, + semester_end_date, + ); + } + + //Each week lecture will be inputted as a separate events from sememseter start to end date + //Semester start & end dates are inputted by user + const { data: courseEventData, error: courseEventError } = + await supabase + .schema("timetable") + .from("course_events") + .insert(eventsToInsert) + .select("*"); + + if (courseEventError) { + return { + status: 400, + error: "Coruse event error " + courseEventError.message, + }; + } + } + + // Save restrictions + for (const restriction of restrictions as RestrictionForm[]) { + let startTime: String | null = null; + let endTime: String | null = null; + + if (restriction.startTime) { + let restriction_start_time = convertTimeStringToDate( + restriction.startTime, + ); + startTime = restriction_start_time.toISOString().split("T")[1]; + } + + if (restriction.endTime) { + let restriction_end_time = convertTimeStringToDate( + restriction.endTime, + ); + endTime = restriction_end_time.toISOString().split("T")[1]; + } + const { data: restrictionData, error: restrictionError } = + await supabase + .schema("timetable") + .from("restriction") + .insert([ + { + user_id, + type: restriction?.type, + days: restriction?.days, + start_time: startTime, + end_time: endTime, + disabled: restriction?.disabled, + num_days: restriction?.numDays, + calendar_id: timetableData?.id, + }, + ]) + .select(); + + if (restrictionError) { + return { status: 400, error: restrictionError.message }; + } + } + + return { status: 201, data: timetableData }; + } catch (error) { + // Catch any error and return the error message + const errorMessage = + error instanceof Error ? error.message : "An unknown error occurred"; + return { status: 500, error: errorMessage }; + } + }, + getCourses: async (args: any, req: Request) => { + const { courses } = args; // course codes + try { + const filterConditions = courses.map((prefix: string) => { + return `code.ilike.${prefix}%`; + }); + + // Get all courses that have any of the provided courses as its prefix + const { data, error } = await supabase + .schema("course") + .from("courses") + .select("*") + .or(filterConditions.join(",")); + + if (error) { + return { status: 400, error: error.message }; + } + + return { status: 200, data }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "An unknown error occurred"; + return { status: 500, error: errorMessage }; + } + }, +}; diff --git a/course-matrix/backend/src/constants/constants.ts b/course-matrix/backend/src/constants/constants.ts index a8a7cf83..882000b1 100644 --- a/course-matrix/backend/src/constants/constants.ts +++ b/course-matrix/backend/src/constants/constants.ts @@ -37,9 +37,16 @@ export const yearToCode = (year: number) => { } }; +// true - notifications will be tested by mocking current Date +// false - normal application behavior +export const TEST_NOTIFICATIONS = false; +// Mock the current date +// Note: month index in date constructor is 0 indexed (0 - 11) +export const TEST_DATE_NOW = new Date(2025, 4, 14, 8, 45, 1); + // Set minimum results wanted for a similarity search on the associated namespace. export const namespaceToMinResults = new Map(); -namespaceToMinResults.set("courses_v2", 10); +namespaceToMinResults.set("courses_v3", 10); namespaceToMinResults.set("offerings", 16); // Typically, more offering info is wanted. namespaceToMinResults.set("prerequisites", 5); namespaceToMinResults.set("corequisites", 5); @@ -48,3 +55,7 @@ namespaceToMinResults.set("programs", 5); // Consider the last X messages in history to influence vector DB query export const CHATBOT_MEMORY_THRESHOLD = 3; + +export const CHATBOT_TIMETABLE_CMD = "/timetable"; + +export const CHATBOT_TOOL_CALL_MAX_STEPS = 5; diff --git a/course-matrix/backend/src/constants/promptKeywords.ts b/course-matrix/backend/src/constants/promptKeywords.ts index 6b71ffd6..0e0fe274 100644 --- a/course-matrix/backend/src/constants/promptKeywords.ts +++ b/course-matrix/backend/src/constants/promptKeywords.ts @@ -1,6 +1,6 @@ // Keywords related to each namespace export const NAMESPACE_KEYWORDS = { - courses_v2: [ + courses_v3: [ "course", "class", "description", @@ -61,6 +61,41 @@ export const NAMESPACE_KEYWORDS = { programs: ["program", "major", "minor", "specialist", "degree", "stream"], }; +export const BREADTH_REQUIREMENT_KEYWORDS = { + ART_LIT_LANG: [ + "art_lit_lang", + "art literature", + "arts literature", + "art language", + "arts language", + "literature language", + "art literature language", + "arts literature language", + ], + HIS_PHIL_CUL: [ + "his_phil_cul", + "history philosophy culture", + "history, philosophy, culture", + "history, philosophy, and culture", + "history, philosophy", + "history philosophy", + "philosophy culture", + "philosophy, culture", + "history culture", + "History, Philosophy and Cultural Studies", + ], + SOCIAL_SCI: ["social_sci", "social science", "social sciences"], + NAT_SCI: ["nat_sci", "natural science", "natural sciences"], + QUANT: ["quant", "quantitative reasoning", "quantitative"], +}; + +export const YEAR_LEVEL_KEYWORDS = { + first_year: ["first year", "first-year", "a-level", "a level", "1st year"], + second_year: ["second year", "second-year", "b-level", "b level", "2nd year"], + third_year: ["third year", "third-year", "c-level", "c level", "3rd year"], + fourth_year: ["fourth year", "fourth-year", "d-level", "d level", "4th year"], +}; + // General academic terms that might indicate a search is needed export const GENERAL_ACADEMIC_TERMS = ["credit", "enroll", "drop"]; diff --git a/course-matrix/backend/src/controllers/aiController.ts b/course-matrix/backend/src/controllers/aiController.ts index 15734728..accc0493 100644 --- a/course-matrix/backend/src/controllers/aiController.ts +++ b/course-matrix/backend/src/controllers/aiController.ts @@ -1,7 +1,16 @@ import asyncHandler from "../middleware/asyncHandler"; +import "openai/shims/node"; import { Request, Response } from "express"; import { createOpenAI } from "@ai-sdk/openai"; -import { streamText } from "ai"; +import { + CoreMessage, + generateObject, + InvalidToolArgumentsError, + NoSuchToolError, + streamText, + tool, + ToolExecutionError, +} from "ai"; import { Index, Pinecone, RecordMetadata } from "@pinecone-database/pinecone"; import { PineconeStore } from "@langchain/pinecone"; import { OpenAIEmbeddings } from "@langchain/openai"; @@ -12,10 +21,27 @@ import { DEPARTMENT_CODES, ASSISTANT_TERMS, USEFUL_INFO, + BREADTH_REQUIREMENT_KEYWORDS, + YEAR_LEVEL_KEYWORDS, } from "../constants/promptKeywords"; -import { CHATBOT_MEMORY_THRESHOLD, codeToYear } from "../constants/constants"; +import { + CHATBOT_MEMORY_THRESHOLD, + CHATBOT_TIMETABLE_CMD, + CHATBOT_TOOL_CALL_MAX_STEPS, +} from "../constants/constants"; import { namespaceToMinResults } from "../constants/constants"; import OpenAI from "openai"; +import { convertBreadthRequirement } from "../utils/convert-breadth-requirement"; +import { convertYearLevel } from "../utils/convert-year-level"; +import { + availableFunctions, + FunctionNames, +} from "../constants/availableFunctions"; +import { z } from "zod"; +import { analyzeQuery } from "../utils/analyzeQuery"; +import { includeFilters } from "../utils/includeFilters"; +import { TimetableFormSchema } from "../models/timetable-form"; +import { CreateTimetableArgs } from "../models/timetable-generate"; const openai = createOpenAI({ baseURL: process.env.OPENAI_BASE_URL, @@ -36,76 +62,11 @@ const index: Index = pinecone.Index( console.log("Connected to OpenAI API"); -// Analyze query contents and pick out relavent namespaces to search. -function analyzeQuery(query: string): { - requiresSearch: boolean; - relevantNamespaces: string[]; -} { - const lowerQuery = query.toLowerCase(); - - // Check for course codes (typically 3 letters followed by numbers) - const courseCodeRegex = /\b[a-zA-Z]{3}[a-zA-Z]?\d{2,3}[a-zA-Z]?\b/i; - const containsCourseCode = courseCodeRegex.test(query); - - const relevantNamespaces: string[] = []; - - // Check each namespace's keywords - Object.entries(NAMESPACE_KEYWORDS).forEach(([namespace, keywords]) => { - if (keywords.some((keyword) => lowerQuery.includes(keyword))) { - relevantNamespaces.push(namespace); - } - }); - - // If a course code is detected, add tehse namespaces - if (containsCourseCode) { - if (!relevantNamespaces.includes("courses_v2")) - relevantNamespaces.push("courses_v2"); - if (!relevantNamespaces.includes("offerings")) - relevantNamespaces.push("offerings"); - if (!relevantNamespaces.includes("prerequisites")) - relevantNamespaces.push("prerequisites"); - } - - // Check for dept codes - if (DEPARTMENT_CODES.some((code) => lowerQuery.includes(code))) { - if (!relevantNamespaces.includes("departments")) - relevantNamespaces.push("departments"); - if (!relevantNamespaces.includes("courses_v2")) - relevantNamespaces.push("courses_v2"); - } - - // If search is required at all - const requiresSearch = - relevantNamespaces.length > 0 || - GENERAL_ACADEMIC_TERMS.some((term) => lowerQuery.includes(term)) || - containsCourseCode; - - // If no specific namespaces identified & search required, then search all - if (requiresSearch && relevantNamespaces.length === 0) { - relevantNamespaces.push( - "courses_v2", - "offerings", - "prerequisites", - "corequisites", - "departments", - "programs", - ); - } - - if ( - ASSISTANT_TERMS.some((term) => lowerQuery.includes(term)) && - relevantNamespaces.length === 0 - ) { - return { requiresSearch: false, relevantNamespaces: [] }; - } - - return { requiresSearch, relevantNamespaces }; -} - -async function searchSelectedNamespaces( +export async function searchSelectedNamespaces( query: string, k: number, namespaces: string[], + filters?: Object, ): Promise { let allResults: Document[] = []; @@ -127,6 +88,7 @@ async function searchSelectedNamespaces( const results = await namespaceStore.similaritySearch( query, Math.max(k, namespaceToMinResults.get(namespace)), + namespace === "courses_v3" ? filters : undefined, ); console.log(`Found ${results.length} results in namespace: ${namespace}`); allResults = [...allResults, ...results]; @@ -145,12 +107,12 @@ async function searchSelectedNamespaces( } // Reformulate user query to make more concise query to database, taking into consideration context -async function reformulateQuery( +export async function reformulateQuery( latestQuery: string, conversationHistory: any[], ): Promise { try { - const openai = new OpenAI({ + const openai2 = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, }); @@ -172,16 +134,18 @@ async function reformulateQuery( - DO replace pronouns and references with specific names and identifiers - DO include course codes, names and specific details for academic entities - If the query is not about university courses & offerings, return exactly a copy of the user's query. + - Append "code: " before course codes For example: "CSCC01" -> "code: CSCC01" + - If a course year level is written as "first year", "second year", etc. Then replace "first" with "1st" and "second" with "2nd" etc. Examples: User: "When is it offered?" - Output: "When is CSCA48 Introduction to Computer Science offered in the 2024-2025 academic year?" + Output: "When is CSCA48 offered in the 2024-2025 academic year?" User: "Tell me more about that" - Output: "What are the details, descriptions, and requirements for MATA31 Calculus I?" + Output: "What are the details, descriptions, and requirements for MATA31?" User: "Who teaches it?" - Output: "Who are the instructors for MGEA02 Introduction to Microeconomics at UTSC?" + Output: "Who are the instructors for MGEA02 at UTSC?" User: "What are the course names of those codes?" Output: "What are the course names of course codes: MGTA01, CSCA08, MATA31, MATA35?" @@ -192,8 +156,13 @@ async function reformulateQuery( User: "Give 2nd year math courses." Output: "What are some 2nd year math courses?" - User: "Give first year math courses." - Output: "What are some 1st year math courses?"`, + User: "Give third year math courses." + Output: "What are some 3rd year math courses?" + + User: "What breadth requirement does CSCC01 satisfy?" + Output: "What breadth requirement does code: CSCC01 satisfy?" + + `, }, ]; @@ -211,7 +180,7 @@ async function reformulateQuery( content: latestQuery, }); - const response = await openai.chat.completions.create({ + const response = await openai2.chat.completions.create({ model: "gpt-4o-mini", messages: messages, temperature: 0.1, // Lower temperature for more consistent, focused queries @@ -227,93 +196,265 @@ async function reformulateQuery( } } +/** + * @description Handles user queries and generates responses using GPT-4o, with optional knowledge retrieval. + * + * @param {Request} req - The Express request object, containing: + * @param {Object[]} req.body.messages - Array of message objects representing the conversation history. + * @param {string} req.body.messages[].role - The role of the message sender (e.g., "user", "assistant"). + * @param {Object[]} req.body.messages[].content - An array containing message content objects. + * @param {string} req.body.messages[].content[].text - The actual text of the message. + * + * @param {Response} res - The Express response object used to stream the generated response. + * + * @returns {void} Responds with a streamed text response of the AI output + * + * @throws {Error} If query reformulation or knowledge retrieval fails. + */ export const chat = asyncHandler(async (req: Request, res: Response) => { const { messages } = req.body; - const latestMessage = messages[messages.length - 1].content[0].text; - - // Get conversation history (excluding the latest message) - const conversationHistory = (messages as any[]).slice(0, -1).map((msg) => ({ - role: msg?.role, - content: msg?.content[0]?.text, - })); - - // Use GPT-4o to reformulate the query based on conversation history - const reformulatedQuery = await reformulateQuery( - latestMessage, - conversationHistory.slice(-CHATBOT_MEMORY_THRESHOLD), // last K messages - ); - console.log(">>>> Original query:", latestMessage); - console.log(">>>> Reformulated query:", reformulatedQuery); - - // Analyze the query to determine if search is needed and which namespaces to search - const { requiresSearch, relevantNamespaces } = - analyzeQuery(reformulatedQuery); - - let context = "[No context provided]"; - - if (requiresSearch) { - console.log( - `Query requires knowledge retrieval, searching namespaces: ${relevantNamespaces.join( - ", ", - )}`, - ); - - // Search only relevant namespaces - const searchResults = await searchSelectedNamespaces( - reformulatedQuery, - 3, - relevantNamespaces, - ); - // console.log("Search Results: ", searchResults); - - // Format context from search results into plaintext - if (searchResults.length > 0) { - context = searchResults.map((doc) => doc.pageContent).join("\n\n"); - } - } else { - console.log("Query does not require knowledge retrieval, skipping search"); - } - // console.log("CONTEXT: ", context); - - const result = streamText({ - model: openai("gpt-4o-mini"), - system: `# Morpheus - Course Matrix Assistant - - ## Identity & Purpose - You are Morpheus, the official AI assistant for Course Matrix, an AI-powered platform that helps University of Toronto Scarborough (UTSC) students plan their academic journey. - - ## About Course Matrix - Course Matrix streamlines course selection and timetable creation by: - - Generating optimized timetables in one click based on selected courses and personal preferences - - Allowing natural language queries about courses, offerings, and academic programs - - Providing personalized recommendations based on degree requirements and course availability - - ## Your Capabilities - - Provide accurate information about UTSC courses, offerings, prerequisites, corequisites, and departments - - Answer questions about course descriptions, schedules, instructors, offerings, and requirements - - Explain degree program requirements and course relationships - - Answer questions about offerings of individual courses such as meeting section, time, day, instructor - - ## Response Guidelines - - Be concise and direct when answering course-related questions - - Use bullet points for listing multiple pieces of information - - Include course codes when referencing specific courses - - If information is missing from the context but likely exists, try to use info from web to answer. If still not able to form a decent response, acknowledge the limitation - - For unrelated questions, politely explain that you're specialized in UTSC academic information - - ## Available Knowledge - ${ - context === "[No context provided]" - ? "No specific course information is available for this query. Answer based on general knowledge about the Course Matrix platform." - : "Use the following information to inform your response. Also use conversation history to inform response as well.\n\n" + - context + try { + const latestMessage = messages[messages.length - 1].content[0].text; + + if (latestMessage.startsWith(CHATBOT_TIMETABLE_CMD)) { + // ----- Flow 1 - Agent performs action on timetable ----- + + // Get a new response from the model with all the tool responses + const result = streamText({ + model: openai("gpt-4o-mini"), + system: `# Morpheus - Course Matrix Assistant + + ## Identity & Purpose + You are Morpheus, the official AI assistant for Course Matrix, an AI-powered platform that helps University of Toronto Scarborough (UTSC) students plan their academic journey. + + ## About Course Matrix + Course Matrix streamlines course selection and timetable creation by: + - Generating optimized timetables in one click based on selected courses and personal preferences + - Allowing natural language queries about courses, offerings, and academic programs + - Providing personalized recommendations based on degree requirements and course availability + - Creating, reading, updating, and deleting user timetables based on natural language + + ## Your Capabilities + - Create new timetables based on provided courses and restrictions + - Update timetable names and semesters + - Delete a user's timetables + - Retrieve timetables that the user owns + + ## Response Guidelines + - Be concise and direct when answering course-related questions + - Use bullet points for listing multiple pieces of information + - Include course codes when referencing specific courses + - If information is missing from the context but likely exists, try to use info from web to answer. If still not able to form a decent response, acknowledge the limitation + - For unrelated questions, politely explain that you're specialized in UTSC academic information + - Format long lists of timetables as a table + + ## Tool call guidelines + - Include the timetable ID in all getTimetables tool call responses + - Link: For every tool call, for each timetable that it gets/deletes/modifies/creates, include a link with it displayed as "View Timetable" to ${process.env.CLIENT_APP_URL}/dashboard/timetable?edit=[[TIMETABLE_ID]] , where TIMETABLE_ID is the id of the respective timetable. + - If the user provides a course code of length 6 like CSCA08, then assume they mean CSCA08H3 (H3 appended) + - If the user wants to create a timetable, first call getCourses to get course information on the requested courses, then call generateTimetable. + - Do not make up fake courses or offerings. + `, + messages, + tools: { + getTimetables: tool({ + description: + "Get all the timetables of the currently logged in user.", + parameters: z.object({}), + execute: async (args) => { + return await availableFunctions.getTimetables(args, req); + }, + }), + updateTimetable: tool({ + description: "Update a user's timetable by title and/or semester", + parameters: z.object({ + id: z.number().positive(), + timetable_title: z.string().optional(), + semester: z + .enum(["Fall 2025", "Summer 2025", "Winter 2026"]) + .optional(), + }), + execute: async (args) => { + return await availableFunctions.updateTimetable(args, req); + }, + }), + deleteTimetable: tool({ + description: "Delete a user's timetable", + parameters: z.object({ + id: z.number().positive(), + }), + execute: async (args) => { + return await availableFunctions.deleteTimetable(args, req); + }, + }), + generateTimetable: tool({ + description: + "Return a list of possible timetables based on provided courses and restrictions.", + parameters: TimetableFormSchema, + execute: async (args) => { + // console.log("Args for generate: ", args) + console.log("restrictions :", JSON.stringify(args.restrictions)); + const data = await availableFunctions.generateTimetable( + args, + req, + ); + console.log("Generated timetable: ", data); + return data; + }, + }), + getCourses: tool({ + description: "Return course info for all course codes provided.", + parameters: z.object({ + courses: z.array(z.string()).describe("List of course codes"), + }), + execute: async (args) => { + return await availableFunctions.getCourses(args, req); + }, + }), + }, + maxSteps: CHATBOT_TOOL_CALL_MAX_STEPS, // Controls how many back and forths the model can take with user or calling multiple tools + experimental_repairToolCall: async ({ + toolCall, + tools, + parameterSchema, + error, + }) => { + if (NoSuchToolError.isInstance(error)) { + return null; // do not attempt to fix invalid tool names + } + + console.log("Error: ", error); + + const tool = tools[toolCall.toolName as keyof typeof tools]; + console.log( + `The model tried to call the tool "${toolCall.toolName}"` + + ` with the following arguments:`, + toolCall.args, + `The tool accepts the following schema:`, + parameterSchema(toolCall), + "Please fix the arguments.", + ); + + const { object: repairedArgs } = await generateObject({ + model: openai("gpt-4o", { structuredOutputs: true }), + schema: tool.parameters, + prompt: [ + `The model tried to call the tool "${toolCall.toolName}"` + + ` with the following arguments:`, + JSON.stringify(toolCall.args), + `The tool accepts the following schema:`, + JSON.stringify(parameterSchema(toolCall)), + "Please fix the arguments.", + ].join("\n"), + }); + + return { ...toolCall, args: JSON.stringify(repairedArgs) }; + }, + }); + + result.pipeDataStreamToResponse(res); + } else { + // ----- Flow 2 - Answer query ----- + + // Get conversation history (excluding the latest message) + const conversationHistory = (messages as any[]) + .slice(0, -1) + .map((msg) => ({ + role: msg?.role, + content: msg?.content[0]?.text, + })); + + // Use GPT-4o to reformulate the query based on conversation history + const reformulatedQuery = await reformulateQuery( + latestMessage, + conversationHistory.slice(-CHATBOT_MEMORY_THRESHOLD), // last K messages + ); + console.log(">>>> Original query:", latestMessage); + console.log(">>>> Reformulated query:", reformulatedQuery); + + // Analyze the query to determine if search is needed and which namespaces to search + const { requiresSearch, relevantNamespaces } = + analyzeQuery(reformulatedQuery); + + let context = "[No context provided]"; + + if (requiresSearch) { + console.log( + `Query requires knowledge retrieval, searching namespaces: ${relevantNamespaces.join( + ", ", + )}`, + ); + + const filters = includeFilters(reformulatedQuery); + // console.log("Filters: ", JSON.stringify(filters)) + + // Search only relevant namespaces + const searchResults = await searchSelectedNamespaces( + reformulatedQuery, + 3, + relevantNamespaces, + Object.keys(filters).length === 0 ? undefined : filters, + ); + // console.log("Search Results: ", searchResults); + + // Format context from search results into plaintext + if (searchResults.length > 0) { + context = searchResults.map((doc) => doc.pageContent).join("\n\n"); + } + } else { + console.log( + "Query does not require knowledge retrieval, skipping search", + ); } - `, - messages, - }); - result.pipeDataStreamToResponse(res); + // console.log("CONTEXT: ", context); + + const result = streamText({ + model: openai("gpt-4o-mini"), + system: `# Morpheus - Course Matrix Assistant + + ## Identity & Purpose + You are Morpheus, the official AI assistant for Course Matrix, an AI-powered platform that helps University of Toronto Scarborough (UTSC) students plan their academic journey. + + ## About Course Matrix + Course Matrix streamlines course selection and timetable creation by: + - Generating optimized timetables in one click based on selected courses and personal preferences + - Allowing natural language queries about courses, offerings, and academic programs + - Providing personalized recommendations based on degree requirements and course availability + + ## Your Capabilities + - Provide accurate information about UTSC courses, offerings, prerequisites, corequisites, and departments + - Answer questions about course descriptions, schedules, instructors, offerings, and requirements + - Explain degree program requirements and course relationships + - Answer questions about offerings of individual courses such as meeting section, time, day, instructor + + ## Response Guidelines + - Be concise and direct when answering course-related questions + - Use bullet points for listing multiple pieces of information + - Use tables for listing multiple offerings, courses, or other information that could be better viewed in tabular fashion + - Include course codes when referencing specific courses + - If information is missing from the context but likely exists, try to use info from web to answer. If still not able to form a decent response, acknowledge the limitation + - For unrelated questions, politely explain that you're specialized in UTSC academic information + - If a user prompt appears like a task that requires timetable operations (like create, read, update, delete a user's timetable) BUT the user prompt doesn't start with prefix "/timetable" then remind user to use "/timetable" in front of their prompt to access these capabilities + + ## Available Knowledge + ${ + context === "[No context provided]" + ? "No specific course information is available for this query. Answer based on general knowledge about the Course Matrix platform." + : "Use the following information to inform your response. Also use conversation history to inform response as well.\n\n" + + context + } + `, + messages, + }); + + result.pipeDataStreamToResponse(res); + } + } catch (error: any) { + console.error("Error:", error); + res.status(500).json({ error: error?.message }); + } }); // Test Similarity search diff --git a/course-matrix/backend/src/controllers/coursesController.ts b/course-matrix/backend/src/controllers/coursesController.ts index 4b820844..97f3ef5d 100644 --- a/course-matrix/backend/src/controllers/coursesController.ts +++ b/course-matrix/backend/src/controllers/coursesController.ts @@ -101,4 +101,65 @@ export default { return res.status(500).send({ err }); } }), + + /** + * Gets the total number of sections for a list of courses. + * + * @param {Request} req - The request object containing query parameters. + * @param {Response} res - The response object to send the total number of sections. + * @returns {Promise} - The response object with the total number of sections. + * + */ + getNumberOfSections: asyncHandler(async (req: Request, res: Response) => { + try { + const { course_ids, semester } = req.query; + + if (!semester) { + return res.status(400).send({ error: "Semester is required" }); + } + + if (!course_ids) { + return res.status(200).send({ totalNumberOfCourseSections: 0 }); + } + + const course_ids_array = (course_ids as string).split(","); + + let totalNumberOfCourseSections = 0; + const promises = course_ids_array.map(async (course_id) => { + const { data: courseOfferingsData, error: courseOfferingsError } = + await supabase + .schema("course") + .from("offerings") + .select() + .eq("course_id", course_id) + .eq("offering", semester); + + const offerings = courseOfferingsData || []; + + const hasLectures = offerings.some((offering) => + offering.meeting_section.startsWith("LEC"), + ); + const hasTutorials = offerings.some((offering) => + offering.meeting_section.startsWith("TUT"), + ); + const hasPracticals = offerings.some((offering) => + offering.meeting_section.startsWith("PRA"), + ); + if (hasLectures) { + totalNumberOfCourseSections += 1; + } + if (hasTutorials) { + totalNumberOfCourseSections += 1; + } + if (hasPracticals) { + totalNumberOfCourseSections += 1; + } + }); + + await Promise.all(promises); + return res.status(200).send({ totalNumberOfCourseSections }); + } catch (err) { + return res.status(500).send({ err }); + } + }), }; diff --git a/course-matrix/backend/src/controllers/eventsController.ts b/course-matrix/backend/src/controllers/eventsController.ts index 565fce01..c70fae10 100644 --- a/course-matrix/backend/src/controllers/eventsController.ts +++ b/course-matrix/backend/src/controllers/eventsController.ts @@ -16,7 +16,7 @@ import { start } from "repl"; * @returns An array of event objects ready to be inserted. */ -function getNextWeekDayOccurance(targetDay: string): string { +export function getNextWeekDayOccurance(targetDay: string): string { //Map weekday code to JS day number const weekdayMap: { [key: string]: number } = { SU: 0, @@ -45,7 +45,7 @@ function getNextWeekDayOccurance(targetDay: string): string { return today.toISOString().split("T")[0]; } -function generateWeeklyCourseEvents( +export function generateWeeklyCourseEvents( user_id: string, courseEventName: string, courseDay: string, @@ -482,20 +482,23 @@ export default { }); } - const { data: courseEventData, error: courseEventError } = - await supabase - .schema("timetable") - .from("course_events") - .select("*") - .eq("id", id) - .eq("user_id", user_id) - .eq("calendar_id", calendar_id) - .maybeSingle(); - - if (courseEventData.calendar_id !== timetableData.id) { - return res.status(400).json({ - error: "Restriction id does not belong to the provided calendar id", - }); + if (!old_offering_id && !new_offering_id) { + const { data: courseEventData, error: courseEventError } = + await supabase + .schema("timetable") + .from("course_events") + .select("*") + .eq("id", id) + .eq("user_id", user_id) + .eq("calendar_id", calendar_id) + .maybeSingle(); + + if (courseEventData.calendar_id !== timetableData.id) { + return res.status(400).json({ + error: + "Restriction id does not belong to the provided calendar id", + }); + } } const courseEventName = `${newofferingData.code} - ${newofferingData.meeting_section}`; @@ -714,16 +717,19 @@ export default { if (courseEventError) return res.status(400).json({ error: courseEventError.message }); - if (!courseEventData || courseEventData.length === 0) { - return res - .status(400) - .json({ error: "Provided note ID is invalid or does not exist" }); - } + if (!offering_id) { + if (!courseEventData || courseEventData.length === 0) { + return res + .status(400) + .json({ error: "Provided note ID is invalid or does not exist" }); + } - if (courseEventData.calendar_id !== timetableData.id) { - return res.status(400).json({ - error: "Restriction id does not belong to the provided calendar id", - }); + if (courseEventData.calendar_id !== timetableData.id) { + return res.status(400).json({ + error: + "Restriction id does not belong to the provided calendar id", + }); + } } //Build the delete query @@ -741,14 +747,13 @@ export default { deleteQuery = deleteQuery.eq("id", id); } - const { error: deleteError } = await deleteQuery + const { data: deleteData, error: deleteError } = await deleteQuery .eq("calendar_id", calendar_id) .eq("user_id", user_id) .select("*"); if (deleteError) return res.status(400).json({ error: deleteError.message }); - - return res.status(200).send("Event successfully deleted"); + return res.status(200).json("Event successfully deleted"); } else if (event_type === "user") { //Validate note availability const { data: userEventData, error: userEventError } = await supabase diff --git a/course-matrix/backend/src/controllers/generatorController.ts b/course-matrix/backend/src/controllers/generatorController.ts new file mode 100644 index 00000000..80a1d788 --- /dev/null +++ b/course-matrix/backend/src/controllers/generatorController.ts @@ -0,0 +1,87 @@ +import exp from "constants"; +import { Request, Response } from "express"; + +import { + Offering, + OfferingList, + GroupedOfferingList, +} from "../types/generatorTypes"; +import { + getMaxDays, + groupOfferings, + getValidOfferings, + categorizeValidOfferings, + trim, +} from "../utils/generatorHelpers"; +import getOfferings from "../services/getOfferings"; +import { getValidSchedules } from "../services/getValidSchedules"; + +import asyncHandler from "../middleware/asyncHandler"; // Middleware to handle async route handlers + +// Express route handler to generate timetables based on user input +export default { + generateTimetable: asyncHandler(async (req: Request, res: Response) => { + try { + // Extract event details and course information from the request + const { semester, courses, restrictions } = req.body; + + const courseOfferingsList: OfferingList[] = []; + const validCourseOfferingsList: GroupedOfferingList[] = []; + const maxdays = await getMaxDays(restrictions); + const validSchedules: Offering[][] = []; + // Fetch offerings for each course + for (const course of courses) { + const { id } = course; + courseOfferingsList.push({ + course_id: id, + offerings: (await getOfferings(id, semester)) ?? [], + }); + } + + const groupedOfferingsList: GroupedOfferingList[] = + await groupOfferings(courseOfferingsList); + + // console.log(JSON.stringify(groupedOfferingsList, null, 2)); + + // Filter out invalid offerings based on the restrictions + for (const { course_id, groups } of groupedOfferingsList) { + validCourseOfferingsList.push({ + course_id: course_id, + groups: await getValidOfferings(groups, restrictions), + }); + } + + const categorizedOfferings = await categorizeValidOfferings( + validCourseOfferingsList, + ); + + // console.log(typeof categorizedOfferings); + // console.log(JSON.stringify(categorizedOfferings, null, 2)); + + // Generate valid schedules for the given courses and restrictions + await getValidSchedules( + validSchedules, + categorizedOfferings, + [], + 0, + categorizedOfferings.length, + maxdays, + ); + + // Return error if no valid schedules are found + if (validSchedules.length === 0) { + return res.status(404).json({ error: "No valid schedules found." }); + } + // Return the valid schedules + return res.status(200).json({ + amount: validSchedules.length, + schedules: trim(validSchedules), + }); + } catch (error) { + // Catch any error and return the error message + const errorMessage = + error instanceof Error ? error.message : "An unknown error occurred"; + return res.status(500).send({ error: errorMessage }); + } + }), +}; diff --git a/course-matrix/backend/src/controllers/offeringsController.ts b/course-matrix/backend/src/controllers/offeringsController.ts index c5de99dd..eefa25c0 100644 --- a/course-matrix/backend/src/controllers/offeringsController.ts +++ b/course-matrix/backend/src/controllers/offeringsController.ts @@ -1,8 +1,95 @@ import { Request, Response } from "express"; import asyncHandler from "../middleware/asyncHandler"; import { supabase } from "../db/setupDb"; +import { generateWeeklyCourseEvents } from "./eventsController"; export default { + /** + * Get a list of offering events based on offering ids, semester start date, and semester end date. + * + * @param {Request} req - The request object containing query parameters. + * @param {Response} res - The response object to send the offering events data. + * @returns {Promise} - The response object with the offering events data. + */ + getOfferingEvents: asyncHandler(async (req: Request, res: Response) => { + const { offering_ids, semester_start_date, semester_end_date } = req.query; + + // Retrieve the authenticated user + const user_id = (req as any).user.id; + + // Check if semester start and end dates are provided + if (!semester_start_date || !semester_end_date) { + return res.status(400).json({ + error: "Semester start and end dates are required.", + }); + } + + if (!offering_ids) { + return res.status(200).json([]); // Return an empty array if no offering_ids are provided + } + + const offering_ids_array = (offering_ids as string).split(","); + let eventsToInsert: any[] = []; + + const promises = offering_ids_array.map(async (offering_id) => { + // Get the offering data + const { data: offeringData, error: offeringError } = await supabase + .schema("course") + .from("offerings") + .select("*") + .eq("id", offering_id) + .maybeSingle(); + + if (offeringError) { + return res.status(400).json({ error: offeringError.message }); + } + + if (!offeringData || offeringData.length === 0) { + return res.status(400).json({ + error: "Invalid offering_id or course offering not found.", + }); + } + + // Generate event details + const courseEventName = ` ${offeringData.code} - ${offeringData.meeting_section} `; + let courseDay = offeringData.day; + let courseStartTime = offeringData.start; + let courseEndTime = offeringData.end; + + // Some offerings do not have a day, start time, or end time in the database, so we set default values + if (!courseDay || !courseStartTime || !courseEndTime) { + courseDay = "MO"; + courseStartTime = "08:00:00"; + courseEndTime = "09:00:00"; + } + + const mockCalendarId = "1"; + const events = generateWeeklyCourseEvents( + user_id, + courseEventName, + courseDay, + courseStartTime, + courseEndTime, + mockCalendarId, + offering_id as string, + semester_start_date as string, + semester_end_date as string, + ); + eventsToInsert = [...eventsToInsert, ...events]; + }); + + await Promise.all(promises); + + if (eventsToInsert.length === 0) { + return res.status(400).json({ + error: "Failed to generate course events", + }); + } + + // Return the generated events + return res.status(200).json(eventsToInsert); + }), + /** * Get a list of offerings based on course code and semester. * @@ -14,7 +101,21 @@ export default { try { const { course_code, semester } = req.query; - let offeringsQuery = supabase + let offeringsQuery; + + // If course code or semester is not provided, return all offerings + if (!course_code || !semester) { + offeringsQuery = supabase.schema("course").from("offerings").select(); + + const { data: offeringsData, error: offeringsError } = + await offeringsQuery; + + const offerings = offeringsData || []; + + return res.status(200).json(offerings); + } + + offeringsQuery = supabase .schema("course") .from("offerings") .select() diff --git a/course-matrix/backend/src/controllers/restrictionsController.ts b/course-matrix/backend/src/controllers/restrictionsController.ts index 2e62179c..892ee655 100644 --- a/course-matrix/backend/src/controllers/restrictionsController.ts +++ b/course-matrix/backend/src/controllers/restrictionsController.ts @@ -33,7 +33,11 @@ export default { } // Function to construct date in local time - if (!start_time && !end_time) { + if ( + !["Restrict Day", "Days Off"].includes(type) && + !start_time && + !end_time + ) { return res .status(400) .json({ error: "Start time or end time must be provided" }); @@ -44,8 +48,8 @@ export default { .schema("timetable") .from("timetables") .select("*") - .eq("id", calendar_id) .eq("user_id", user_id) + .eq("id", calendar_id) .maybeSingle(); if (timetableError) @@ -78,12 +82,6 @@ export default { endTime = restriction_end_time.toISOString().split("T")[1]; } - if (!start_time && !end_time) { - return res - .status(400) - .json({ error: "Start time or end time must be provided" }); - } - const { data: restrictionData, error: restrictionError } = await supabase .schema("timetable") .from("restriction") @@ -131,8 +129,8 @@ export default { .schema("timetable") .from("timetables") .select("*") - .eq("id", calendar_id) .eq("user_id", user_id) + .eq("id", calendar_id) .maybeSingle(); if (timetableError) @@ -156,8 +154,8 @@ export default { .schema("timetable") .from("restriction") .select() - .eq("calendar_id", calendar_id) - .eq("user_id", user_id); + .eq("user_id", user_id) + .eq("calendar_id", calendar_id); if (restrictionError) { return res.status(400).json({ error: restrictionError.message }); @@ -192,9 +190,9 @@ export default { .schema("timetable") .from("restriction") .select("*") - .eq("id", id) .eq("user_id", user_id) .eq("calendar_id", calendar_id) + .eq("id", id) .maybeSingle(); if (restrictionCurrError) @@ -222,8 +220,8 @@ export default { .schema("timetable") .from("timetables") .select("*") - .eq("id", calendar_id) .eq("user_id", user_id) + .eq("id", calendar_id) .maybeSingle(); if (timetableError) @@ -260,9 +258,9 @@ export default { .schema("timetable") .from("restriction") .update(updateData) - .eq("id", id) .eq("user_id", user_id) .eq("calendar_id", calendar_id) + .eq("id", id) .select(); if (restrictionError) { @@ -300,9 +298,9 @@ export default { .schema("timetable") .from("restriction") .select("*") - .eq("id", id) .eq("user_id", user_id) .eq("calendar_id", calendar_id) + .eq("id", id) .maybeSingle(); if (restrictionCurrError) { @@ -318,8 +316,8 @@ export default { .schema("timetable") .from("timetables") .select("*") - .eq("id", calendar_id) .eq("user_id", user_id) + .eq("id", calendar_id) .maybeSingle(); if (timetableError) { @@ -351,15 +349,17 @@ export default { .schema("timetable") .from("restriction") .delete() - .eq("id", id) .eq("user_id", user_id) - .eq("calendar_id", calendar_id); + .eq("calendar_id", calendar_id) + .eq("id", id); if (restrictionError) { return res.status(400).json({ error: restrictionError.message }); } - return res.status(200).send("Restriction successfully deleted"); + return res + .status(200) + .json({ message: "Restriction successfully deleted" }); } catch (error) { return res.status(500).send({ error }); } diff --git a/course-matrix/backend/src/controllers/sharesController.ts b/course-matrix/backend/src/controllers/sharesController.ts new file mode 100644 index 00000000..0dba9fec --- /dev/null +++ b/course-matrix/backend/src/controllers/sharesController.ts @@ -0,0 +1,429 @@ +import { Request, Response } from "express"; +import asyncHandler from "../middleware/asyncHandler"; +import { supabase } from "../db/setupDb"; + +export default { + /** + * Create a new share entry + * @route POST /api/shared + */ + createShare: asyncHandler(async (req: Request, res: Response) => { + try { + const owner_id = (req as any).user.id; + const owner_email = (req as any).user.email; + const { shared_email, calendar_id } = req.body; + + if (!shared_email || !calendar_id) { + return res + .status(400) + .json({ error: "Shared user email and calendar ID are required" }); + } + + // Owner cannot share a timetable to themselves + if (shared_email === owner_email) { + return res + .status(400) + .json({ error: "Users cannot share a timetable with themselves" }); + } + + const { data: sharedUser, error: sharedError } = await supabase.rpc( + "get_user_id_by_email", + { email: shared_email }, + ); + + if (sharedError) { + return res.status(400).json({ Error: sharedError.message }); + } + + if (!sharedUser || sharedUser.length === 0) { + return res + .status(400) + .json({ error: "User with provided email not found" }); + } + const shared_id = sharedUser[0].id; + + // Check if the calendar exists and belongs to the owner + const { data: timeTable, error: timeTableError } = await supabase + .schema("timetable") + .from("timetables") + .select("id") + .eq("id", calendar_id) + .eq("user_id", owner_id) + .maybeSingle(); + + if (timeTableError || !timeTable) { + return res.status(404).json({ + error: "Timetable not found or user unauthorized to share", + }); + } + + // Check if the sharing already exists + const { data: existingShare, error: existingShareError } = await supabase + .schema("timetable") + .from("shared") + .select("id") + .eq("calendar_id", calendar_id) + .eq("owner_id", owner_id) + .eq("shared_id", shared_id) + .maybeSingle(); + + if (existingShare) { + return res.status(400).json({ + error: "This calendar has already been shared with the provided user", + }); + } + + // Insert the shared timetable entry + const { data: shareInsert, error: shareError } = await supabase + .schema("timetable") + .from("shared") + .insert([{ owner_id, shared_id, calendar_id }]) + .select("*") + .single(); + + if (shareError) { + return res.status(400).json({ error: shareError.message }); + } + + return res.status(201).json(shareInsert); + } catch (error) { + return res.status(500).send({ error }); + } + }), + + /** + * Get all timetables that the owner has shared + */ + getOwnerShare: asyncHandler(async (req: Request, res: Response) => { + try { + const user_id = (req as any).user.id; + + //Fetch all shared calendar IDs where user is the owner who share + const { data: shareData, error: sharedError } = await supabase + .schema("timetable") + .from("shared") + .select( + "id,calendar_id, owner_id, shared_id, timetables!inner(id, user_id, timetable_title, semester, favorite)", + ) + .eq("owner_id", user_id); + + if (sharedError) { + return res.status(400).json({ error: sharedError.message }); + } + + if (!shareData || shareData.length === 0) { + return res + .status(404) + .json({ error: "This user has not shared any timetables" }); + } + + return res.status(200).json(shareData); + } catch (error) { + return res.status(500).send({ error }); + } + }), + + /** + * Get all timetables shared with the current user + * @route GET /api/shared + */ + + getShare: asyncHandler(async (req: Request, res: Response) => { + try { + const user_id = (req as any).user.id; + + //Fetch all shared calendar IDs where user is the shared recipient + const { data: shareData, error: sharedError } = await supabase + .schema("timetable") + .from("shared") + .select( + "id, calendar_id, owner_id, shared_id, timetables!inner(id, user_id, timetable_title, semester, favorite)", + ) + .eq("shared_id", user_id); + + if (sharedError) { + return res.status(400).json({ error: sharedError.message }); + } + + if (!shareData || shareData.length === 0) { + return res + .status(404) + .json({ error: "No shared timetables found for this user" }); + } + + return res.status(200).json(shareData); + } catch (error) { + return res.status(500).send({ error }); + } + }), + + /** + * Delete all shared record for a timetable as the timetable's owner + * @route DELETE /api/shared/owner/:id? + */ + + deleteOwnerShare: asyncHandler(async (req: Request, res: Response) => { + try { + const owner_id = (req as any).user.id; + const { id } = req.params; + const { calendar_id, shared_email } = req.body; + + if (!id) { + if (calendar_id && !shared_email) { + // Check if the provided calendar_id belong to the current user + const { data: existingTimetable, error: existingTimetableError } = + await supabase + .schema("timetable") + .from("shared") + .select("*") + .eq("calendar_id", calendar_id) + .eq("owner_id", owner_id); + + if (existingTimetableError) { + return res + .status(500) + .json({ error: existingTimetableError.message }); + } + + if (!existingTimetable || existingTimetable.length === 0) { + return res + .status(404) + .json({ error: "Provided timetable for delete does not found" }); + } + + //Delete all shares belong to the owner for a specific table + const { error: deleteError } = await supabase + .schema("timetable") + .from("shared") + .delete() + .eq("calendar_id", calendar_id) + .eq("owner_id", owner_id); + + if (deleteError) { + return res.status(400).json({ error: deleteError.message }); + } + + return res.status(200).send({ + message: `All sharing records for the timetable: ${calendar_id} of user: ${ + (req as any).user.email + } have been deleted successfully`, + }); + } + + if (!calendar_id && shared_email) { + // Delete all shares belonging to the owner shared with a specific person + + // Get Person id via email + const { data: sharedUser, error: sharedError } = await supabase.rpc( + "get_user_id_by_email", + { email: shared_email }, + ); + + if (sharedError) { + return res.status(400).json({ error: sharedError.message }); + } + + if (!sharedUser || sharedUser.length === 0) { + return res + .status(400) + .json({ error: "User with provided email not found" }); + } + + const shared_id = sharedUser[0].id; + + //Check if the curernt owner has shared with the provided user + const { data: existingTimetable, error: existingTimetableError } = + await supabase + .schema("timetable") + .from("shared") + .select("*") + .eq("shared_id", shared_id) + .eq("owner_id", owner_id); + + if (existingTimetableError) { + return res + .status(500) + .json({ error: existingTimetableError.message }); + } + + if (!existingTimetable || existingTimetable.length === 0) { + return res.status(404).json({ + error: "You have not shared any timetable with the provided user", + }); + } + + const { error: deleteError } = await supabase + .schema("timetable") + .from("shared") + .delete() + .eq("owner_id", owner_id) + .eq("shared_id", shared_id); + + if (deleteError) { + return res.status(400).json({ error: deleteError.message }); + } + + return res.status(200).json({ + message: `All sharing records of user: ${ + (req as any).user.email + } to user: ${shared_email} have been deleted successfully`, + }); + } + + if (calendar_id && shared_email) { + // Get Person id via email + const { data: sharedUser, error: sharedError } = await supabase.rpc( + "get_user_id_by_email", + { email: shared_email }, + ); + + if (sharedError) { + return res.status(400).json({ error: sharedError.message }); + } + + if (!sharedUser || sharedUser.length === 0) { + return res + .status(400) + .json({ error: "User with provided email not found" }); + } + + const shared_id = sharedUser[0].id; + + //Check if the curernt owner has shared with the provided user + const { data: existingTimetable, error: existingTimetableError } = + await supabase + .schema("timetable") + .from("shared") + .select("*") + .eq("calendar_id", calendar_id) + .eq("shared_id", shared_id) + .eq("owner_id", owner_id); + + if (existingTimetableError) { + return res + .status(500) + .json({ error: existingTimetableError.message }); + } + + if (!existingTimetable || existingTimetable.length === 0) { + return res.status(404).json({ + error: + "You have not shared the provided timetable with the provided user", + }); + } + + const { error: deleteError } = await supabase + .schema("timetable") + .from("shared") + .delete() + .eq("calendar_id", calendar_id) + .eq("owner_id", owner_id) + .eq("shared_id", shared_id); + + if (deleteError) { + return res.status(400).json({ error: deleteError.message }); + } + + return res.status(200).json({ + message: `All sharing records of table: ${calendar_id} from user: ${ + (req as any).user.email + } to user: ${shared_email} have been deleted successfully`, + }); + } + return res.status(400).json({ + error: "Calendar_id, shared_email or share id is required", + }); + } else { + if (!calendar_id) { + return res.status(400).json({ + error: "Calendar_id is requried to delete a specific share entry", + }); + } + + const { data: existingShare, error: existingShareError } = + await supabase + .schema("timetable") + .from("shared") + .select("*") + .eq("id", id) + .eq("calendar_id", calendar_id) + .eq("owner_id", owner_id); + + if (existingShareError) { + return res.status(400).json({ error: existingShareError.message }); + } + + if (!existingShare || existingShare.length === 0) { + return res + .status(404) + .json({ error: "Cannot find the provided share entry" }); + } + + const { error: deleteError } = await supabase + .schema("timetable") + .from("shared") + .delete() + .eq("id", id) + .eq("calendar_id", calendar_id) + .eq("owner_id", owner_id); + + if (deleteError) { + return res.status(400).json({ error: deleteError.message }); + } + + return res.status(200).json({ + message: `Share number ${id} of calendar: ${calendar_id} has been sucessfully deleted`, + }); + } + } catch (error) { + return res.status(500).send({ error }); + } + }), + + /** + * Delete a shared entryas shared userd + * @route DELETE /api/shared/:id? + */ + deleteShare: asyncHandler(async (req: Request, res: Response) => { + try { + const shared_id = (req as any).user.id; + const { id } = req.params; + const { calendar_id } = req.body; + + const { data: existingTimetable, error: existingTimetableError } = + await supabase + .schema("timetable") + .from("shared") + .select("*") + .eq("id", id) + .eq("calendar_id", calendar_id) + .eq("shared_id", shared_id); + + if (existingTimetableError) { + return res.status(500).json({ error: existingTimetableError.message }); + } + + if (!existingTimetable || existingTimetable.length === 0) { + return res.status(404).json({ + error: "Provided timetable for delete does not found", + }); + } + const { error: deleteError } = await supabase + .schema("timetable") + .from("shared") + .delete() + .eq("id", id) + .eq("calendar_id", calendar_id) + .eq("shared_id", shared_id); + if (deleteError) { + return res.status(400).json({ error: deleteError.message }); + } + + return res.status(200).json({ + message: `Sharing record: ${id} of calendar: ${calendar_id} deleted successfully`, + }); + } catch (error) { + return res.status(500).send({ error }); + } + }), +}; diff --git a/course-matrix/backend/src/controllers/timetablesController.ts b/course-matrix/backend/src/controllers/timetablesController.ts index 01b8f172..b36e96be 100644 --- a/course-matrix/backend/src/controllers/timetablesController.ts +++ b/course-matrix/backend/src/controllers/timetablesController.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import asyncHandler from "../middleware/asyncHandler"; import { supabase } from "../db/setupDb"; +import { coreToolMessageSchema } from "ai"; export default { /** @@ -18,26 +19,49 @@ export default { const user_id = (req as any).user.id; //Retrieve timetable title - const { timetable_title, semester } = req.body; - if (!timetable_title) { - return res.status(400).json({ error: "timetable title is required" }); + const { timetable_title, semester, favorite = false } = req.body; + if (!timetable_title || !semester) { + return res + .status(400) + .json({ error: "timetable title and semester are required" }); } - if (!semester) { + // Check if a timetable with the same title already exist for this user + const { data: existingTimetable, error: existingTimetableError } = + await supabase + .schema("timetable") + .from("timetables") + .select("id") + .eq("user_id", user_id) + .eq("timetable_title", timetable_title) + .maybeSingle(); + + if (existingTimetableError) { + return res.status(400).json({ error: existingTimetableError.message }); + } + + if (existingTimetable) { return res .status(400) - .json({ error: "timetable semester is required" }); + .json({ error: "A timetable with this title already exists" }); } - //Create query to insert the user_id and timetable_title into the db let insertTimetable = supabase .schema("timetable") .from("timetables") - .insert([{ user_id, timetable_title, semester }]) - .select(); + .insert([ + { + user_id, + timetable_title, + semester, + favorite, + }, + ]) + .select("*"); const { data: timetableData, error: timetableError } = await insertTimetable; + if (timetableError) { return res.status(400).json({ error: timetableError.message }); } @@ -81,6 +105,42 @@ export default { } }), + /** + * Get a single timetable for a user + * @route GET /api/timetables/:id + */ + + getTimetable: asyncHandler(async (req: Request, res: Response) => { + try { + //Retrieve user_id and timetable_id + const user_id = (req as any).user.id; + const { id: timetable_id } = req.params; + + // Retrieve based on user_id and timetable_id + let timeTableQuery = supabase + .schema("timetable") + .from("timetables") + .select() + .eq("user_id", user_id) + .eq("id", timetable_id); + + const { data: timetableData, error: timetableError } = + await timeTableQuery; + + if (timetableError) + return res.status(400).json({ error: timetableError.message }); + + //Validate timetable validity: + if (!timetableData) { + return res.status(404).json({ error: "Calendar id not found" }); + } + + return res.status(200).json(timetableData); + } catch (error) { + return res.status(500).send({ error }); + } + }), + /** * Update a timetable * @route PUT /api/timetables/:id @@ -91,11 +151,21 @@ export default { const { id } = req.params; //Retrieve timetable title - const { timetable_title, semester } = req.body; - if (!timetable_title && !semester) { + const { + timetable_title, + semester, + favorite, + email_notifications_enabled, + } = req.body; + if ( + !timetable_title && + !semester && + favorite === undefined && + email_notifications_enabled === undefined + ) { return res.status(400).json({ error: - "New timetable title or semester is required when updating a timetable", + "New timetable title or semester or updated favorite status or email notifications enabled is required when updating a timetable", }); } @@ -108,12 +178,10 @@ export default { .schema("timetable") .from("timetables") .select("*") - .eq("id", id) .eq("user_id", user_id) + .eq("id", id) .maybeSingle(); - const timetable_user_id = timetableUserData?.user_id; - if (timetableUserError) return res.status(400).json({ error: timetableUserError.message }); @@ -122,25 +190,22 @@ export default { return res.status(404).json({ error: "Calendar id not found" }); } - //Validate user access - if (user_id !== timetable_user_id) { - return res - .status(401) - .json({ error: "Unauthorized access to timetable events" }); - } - let updateData: any = {}; if (timetable_title) updateData.timetable_title = timetable_title; if (semester) updateData.semester = semester; + if (favorite !== undefined) updateData.favorite = favorite; + if (email_notifications_enabled !== undefined) + updateData.email_notifications_enabled = email_notifications_enabled; //Update timetable title, for authenticated user only let updateTimetableQuery = supabase .schema("timetable") .from("timetables") .update(updateData) - .eq("id", id) .eq("user_id", user_id) - .select(); + .eq("id", id) + .select() + .single(); const { data: timetableData, error: timetableError } = await updateTimetableQuery; @@ -177,10 +242,9 @@ export default { .schema("timetable") .from("timetables") .select("*") - .eq("id", id) .eq("user_id", user_id) + .eq("id", id) .maybeSingle(); - const timetable_user_id = timetableUserData?.user_id; if (timetableUserError) return res.status(400).json({ error: timetableUserError.message }); @@ -190,27 +254,22 @@ export default { return res.status(404).json({ error: "Calendar id not found" }); } - //Validate user access - if (user_id !== timetable_user_id) { - return res - .status(401) - .json({ error: "Unauthorized access to timetable events" }); - } - // Delete only if the timetable belongs to the authenticated user let deleteTimetableQuery = supabase .schema("timetable") .from("timetables") .delete() - .eq("id", id) - .eq("user_id", user_id); + .eq("user_id", user_id) + .eq("id", id); const { error: timetableError } = await deleteTimetableQuery; if (timetableError) return res.status(400).json({ error: timetableError.message }); - return res.status(200).send("Timetable successfully deleted"); + return res + .status(200) + .json({ message: "Timetable successfully deleted" }); } catch (error) { return res.status(500).send({ error }); } diff --git a/course-matrix/backend/src/db/setupDb.ts b/course-matrix/backend/src/db/setupDb.ts index a2d35ba0..48e31aee 100644 --- a/course-matrix/backend/src/db/setupDb.ts +++ b/course-matrix/backend/src/db/setupDb.ts @@ -13,4 +13,14 @@ export const supabase = createClient( config.DATABASE_KEY!, ); +export const supabaseServersideClient = createClient( + config.DATABASE_URL!, + config.DATABASE_KEY!, + { + auth: { + persistSession: false, + }, + }, +); + console.log("Connected to Supabase Client!"); diff --git a/course-matrix/backend/src/index.ts b/course-matrix/backend/src/index.ts index 438df58e..0c84b2e7 100644 --- a/course-matrix/backend/src/index.ts +++ b/course-matrix/backend/src/index.ts @@ -4,7 +4,6 @@ import express, { Express } from "express"; import { Server } from "http"; import swaggerjsdoc from "swagger-jsdoc"; import swaggerUi from "swagger-ui-express"; - import config from "./config/config"; import { swaggerOptions } from "./config/swaggerOptions"; import { supabase } from "./db/setupDb"; @@ -18,6 +17,8 @@ import { } from "./routes/courseRouter"; import { timetableRouter } from "./routes/timetableRouter"; import { aiRouter } from "./routes/aiRouter"; +import cron from "node-cron"; +import { checkAndNotifyEvents } from "./services/emailNotificationService"; const app: Express = express(); const HOST = "localhost"; @@ -41,6 +42,10 @@ app.use("/api/offerings", offeringsRouter); app.use("/api/timetables", timetableRouter); app.use("/api/ai", aiRouter); +// Initialize cron job +// Note: For testing purposes can set first argument to '*/15 * * * * *' to run every 15s +cron.schedule("45 * * * *", checkAndNotifyEvents); + /** * Root route to test the backend server. * @route GET / @@ -95,4 +100,5 @@ const unexpectedErrorHandler = (error: unknown) => { process.on("uncaughtException", unexpectedErrorHandler); process.on("unhandledRejection", unexpectedErrorHandler); +export { server }; export default app; diff --git a/course-matrix/backend/src/models/timetable-form.ts b/course-matrix/backend/src/models/timetable-form.ts new file mode 100644 index 00000000..e9810afe --- /dev/null +++ b/course-matrix/backend/src/models/timetable-form.ts @@ -0,0 +1,120 @@ +import { z, ZodType } from "zod"; + +export type TimetableForm = { + name: string; + date: Date; + semester: string; + search: string; + courses: { + id: number; + code: string; + name: string; + }[]; + restrictions: RestrictionForm[]; +}; + +export type RestrictionForm = { + type: string; + days?: string[]; + numDays?: number; + startTime?: string; + endTime?: string; + disabled?: boolean; +}; + +export const daysOfWeek = [ + { + id: "MO", + label: "Monday", + }, + { + id: "TU", + label: "Tuesday", + }, + { + id: "WE", + label: "Wednesday", + }, + { + id: "TH", + label: "Thursday", + }, + { + id: "FR", + label: "Friday", + }, +] as const; + +export const DayOfWeekEnum = z.enum(["MO", "TU", "WE", "TH", "FR"]); + +export const SemesterEnum = z.enum(["Summer 2025", "Fall 2025", "Winter 2026"]); + +export const CourseSchema = z.object({ + id: z.number().describe("The id of the course"), + code: z + .string() + .max(8, "Invalid course code") + .min(1, "Course code is required") + .describe( + "The course code. Formatted like: CSCA08H3. Course codes cannot be provided without the H3 at the end.", + ), + name: z.string().describe("The name of the course"), +}); + +export const RestrictionSchema = z.object({ + type: z + .enum([ + "Restrict Before", + "Restrict After", + "Restrict Between", + "Restrict Day", + "Days Off", + ]) + .describe( + "The type of restriction being applied. Restrict before restricts all times before 'endTime', Restrict Before restricts all times after 'startTime', Restrict Between restricts all times between 'startTime' and 'endTime', Restrict Day restricts the entirety of each day in field 'days', and Days Off enforces as least 'numDays' days off per week.", + ), + days: z + .array(DayOfWeekEnum) + .default(["MO", "TU", "WE", "TH", "FR"]) + .describe("Specific days of the week this restriction applies to"), + numDays: z + .number() + .positive() + .max(4, "Cannot block all days of the week") + .optional() + .describe( + "If type is Days Off, then this field is used and describes min number of days off per week. For example, if set to 2, and 'type' is Days Off, then this means we want at least 2 days off per week.", + ), + startTime: z + .string() + .optional() + .describe( + "If type is Restrict After, or Restrict Between, then this field describes the start time of the restricted time. Formatted HH:mm:ss", + ), + endTime: z + .string() + .optional() + .describe( + "If type is Restrict Before, or Restrict Between, then this field describes the end time of the restricted time. Formatted HH:mm:ss", + ), + disabled: z + .boolean() + .optional() + .describe("Whether this restriction is currently disabled"), +}); + +export const TimetableFormSchema = z.object({ + name: z + .string() + .max(100, "Name cannot exceed 100 characters") + .min(1, "Name cannot be empty") + .describe("Title of timetable"), + date: z.string().describe("Creation time of timetable"), + semester: SemesterEnum, + search: z + .string() + .optional() + .describe("Keeps track of search query. Only used in UI."), + courses: z.array(CourseSchema), + restrictions: z.array(RestrictionSchema), +}); diff --git a/course-matrix/backend/src/models/timetable-generate.ts b/course-matrix/backend/src/models/timetable-generate.ts new file mode 100644 index 00000000..fb7bc316 --- /dev/null +++ b/course-matrix/backend/src/models/timetable-generate.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import { DayOfWeekEnum, SemesterEnum } from "./timetable-form"; + +export const CreateTimetableArgs = z.object({ + name: z + .string() + .min(1, "Must include timetable name") + .max(100, "Timetable name length cannot exceed 100 characters") + .describe("Name of the timetable"), + semester: SemesterEnum, + schedule: z + .array( + z + .object({ + id: z.number().nonnegative().describe("Offering ID"), + course_id: z + .number() + .nonnegative() + .describe("ID of course this event is for"), + meeting_section: z + .string() + .describe("Meeting section e.g LEC01, TUT0002, PRAC0003"), + offering: SemesterEnum, + day: DayOfWeekEnum, + start: z + .string() + .describe("Time string HH:mm:ss represnting start time of event"), + end: z + .string() + .describe("Time string HH:mm:ss represnting start time of event"), + location: z + .string() + .describe("Building and room in which event takes place"), + current: z + .number() + .nonnegative() + .optional() + .nullable() + .describe("Current number of people enrolled"), + max: z + .number() + .nonnegative() + .optional() + .nullable() + .describe("Max number of people that can enroll"), + isWaitlisted: z + .boolean() + .optional() + .nullable() + .describe("Whether the event is full or not"), + deliveryMode: z + .string() + .optional() + .nullable() + .describe("If event is online synchronough, in person, etc"), + instructor: z + .string() + .optional() + .nullable() + .describe("Instructor of course event"), + notes: z.string().optional(), + code: z.string().describe("Course code"), + }) + .describe("A course event"), + ) + .describe("List of meeting sections"), +}); diff --git a/course-matrix/backend/src/routes/aiRouter.ts b/course-matrix/backend/src/routes/aiRouter.ts index f6c4def1..97e1050b 100644 --- a/course-matrix/backend/src/routes/aiRouter.ts +++ b/course-matrix/backend/src/routes/aiRouter.ts @@ -1,8 +1,17 @@ import express from "express"; import { chat, testSimilaritySearch } from "../controllers/aiController"; -import { authRouter } from "./authRouter"; +import { authHandler } from "../middleware/authHandler"; export const aiRouter = express.Router(); -aiRouter.post("/chat", authRouter, chat); +/** + * @route POST /api/ai/chat + * @description Handles user queries and generates responses using GPT-4o, with optional knowledge retrieval. + */ +aiRouter.post("/chat", authHandler, chat); + +/** + * @route POST /api/ai/test-similarity-search + * @description Test vector database similarity search feature + */ aiRouter.post("/test-similarity-search", testSimilaritySearch); diff --git a/course-matrix/backend/src/routes/authRouter.ts b/course-matrix/backend/src/routes/authRouter.ts index 48572445..6895675f 100644 --- a/course-matrix/backend/src/routes/authRouter.ts +++ b/course-matrix/backend/src/routes/authRouter.ts @@ -11,6 +11,7 @@ import { accountDelete, updateUsername, } from "../controllers/userController"; +import { authHandler } from "../middleware/authHandler"; export const authRouter = express.Router(); @@ -66,4 +67,4 @@ authRouter.delete("/accountDelete", accountDelete); * Route to request to update username * @route POST /updateUsername */ -authRouter.post("/updateUsername", updateUsername); +authRouter.post("/updateUsername", authHandler, updateUsername); diff --git a/course-matrix/backend/src/routes/courseRouter.ts b/course-matrix/backend/src/routes/courseRouter.ts index e233b396..ac591c13 100644 --- a/course-matrix/backend/src/routes/courseRouter.ts +++ b/course-matrix/backend/src/routes/courseRouter.ts @@ -15,6 +15,17 @@ export const offeringsRouter = express.Router(); */ coursesRouter.get("/", authHandler, coursesController.getCourses); +/** + * Route to get the total number of sections from a list of courses. + * @route GET /total-courses + * @middleware authHandler - Middleware to check if the user is authenticated. + */ +coursesRouter.get( + "/total-sections", + authHandler, + coursesController.getNumberOfSections, +); + /** * Route to get a list of departments. * @route GET / @@ -22,6 +33,17 @@ coursesRouter.get("/", authHandler, coursesController.getCourses); */ departmentsRouter.get("/", authHandler, departmentsController.getDepartments); +/** + * Route to get a list of events for an offering. + * @route GET /events/:offering_id + * @middleware authHandler - Middleware to check if the user is authenticated. + */ +offeringsRouter.get( + "/events", + authHandler, + offeringsController.getOfferingEvents, +); + /** * Route to get a list of offerings. * @route GET / diff --git a/course-matrix/backend/src/routes/timetableRouter.ts b/course-matrix/backend/src/routes/timetableRouter.ts index 1132d7d0..8306346e 100644 --- a/course-matrix/backend/src/routes/timetableRouter.ts +++ b/course-matrix/backend/src/routes/timetableRouter.ts @@ -1,7 +1,9 @@ import express from "express"; -import timetableController from "../controllers/timetablesController"; import eventController from "../controllers/eventsController"; +import generatorController from "../controllers/generatorController"; import restrictionsController from "../controllers/restrictionsController"; +import sharesController from "../controllers/sharesController"; +import timetableController from "../controllers/timetablesController"; import { authHandler } from "../middleware/authHandler"; export const timetableRouter = express.Router(); @@ -20,6 +22,13 @@ timetableRouter.post("/", authHandler, timetableController.createTimetable); */ timetableRouter.get("/", authHandler, timetableController.getTimetables); +/** + * Route to get a single tiemtable for a user + * @route GET /api/timetables/:id + * @middleware authHandler - Middleware to check if the user is authenticated. + */ +timetableRouter.get("/:id", authHandler, timetableController.getTimetable); + /** * Route to update a timetable * @route PUT /api/timetable/:id @@ -113,3 +122,56 @@ timetableRouter.delete( authHandler, restrictionsController.deleteRestriction, ); + +timetableRouter.post( + "/generate", + authHandler, + generatorController.generateTimetable, +); + +/** + * Route to create shared entry + * @route POST /api/timetables/shared + * @middleware authHandler - Middleware to check if the user is authenticated + */ +timetableRouter.post("/shared", authHandler, sharesController.createShare); + +/** + * Route to get all shared entry of authenticated user + * @route GET /api/timetables/shared/owner + * @middleware authHandler - Middleware to check if the user is authenticated + */ +timetableRouter.get( + "/shared/owner", + authHandler, + sharesController.getOwnerShare, +); + +/** + * Route to get all shared entry with authenticated user + * @route GET /api/timetables/shared + * @middleware authHandler - Middleware to check if the user is authenticated + */ +timetableRouter.get("/shared", authHandler, sharesController.getShare); + +/** + * Route to delete all shared entries for a timetable as timetable's owner + * @route DELETE /api/timetables/shared/owner/:calendar_id + * @middleware authHandler - Middleware to check if the user is authenticated + */ +timetableRouter.delete( + "/shared/owner/:id?", + authHandler, + sharesController.deleteOwnerShare, +); + +/** + * Route to delete a single entry for the authneticate user + * @route DELETE /api/timetables/shared/:calendar_id + * @middleware authHandler - Middleware to check if the user is authenticated + */ +timetableRouter.delete( + "/shared/:id", + authHandler, + sharesController.deleteShare, +); diff --git a/course-matrix/backend/src/services/emailNotificationService.ts b/course-matrix/backend/src/services/emailNotificationService.ts new file mode 100644 index 00000000..a486a8ea --- /dev/null +++ b/course-matrix/backend/src/services/emailNotificationService.ts @@ -0,0 +1,221 @@ +import { TEST_DATE_NOW, TEST_NOTIFICATIONS } from "../constants/constants"; +import { supabaseServersideClient } from "../db/setupDb"; +import { isDateBetween } from "../utils/compareDates"; +import axios from "axios"; + +type EmaiLData = { + sender: { + email: string; + name: string; + }; + to: { + email: string; + name: string; + }[]; + subject: string; + htmlContent: string; +}; + +// Create a function to send emails via Brevo API +async function sendBrevoEmail(emailData: EmaiLData) { + try { + const response = await axios({ + method: "post", + url: "https://api.brevo.com/v3/smtp/email", + headers: { + accept: "application/json", + "Api-key": process.env.BREVO_API_KEY!, + "content-type": "application/json", + }, + data: emailData, + }); + + return response?.data; + } catch (error: any) { + console.error( + "Error sending email:", + error?.response ? error?.response?.data : error?.message, + ); + throw error; + } +} + +// Ensure offering is in current semester and current day of week +export function correctDay(offering: any): boolean { + const weekdays = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + const semester = offering?.offering; + const day = offering?.day; + + if (!semester || !day) return false; + + let now; + if (TEST_NOTIFICATIONS) { + now = TEST_DATE_NOW; + } else { + now = new Date(); + } + + let startDay; + let endDay; + + // console.log(offering) + + if (semester === "Summer 2025") { + startDay = new Date(2025, 4, 2); + endDay = new Date(2025, 7, 7); + } else if (semester === "Fall 2025") { + startDay = new Date(2025, 8, 3); + endDay = new Date(2025, 11, 3); + } else { + // Winter 2026 + startDay = new Date(2026, 0, 6); + endDay = new Date(2026, 3, 4); + } + + if (!isDateBetween(now, startDay, endDay)) { + // console.log(`${now.toDateString()} is not between ${startDay.toDateString()} and ${endDay.toDateString()}`) + return false; + } + + if (weekdays[now.getDay()] !== day) { + // console.log(`${weekdays[now.getDay()]} is not equal to ${day}`) + return false; + } + + return true; +} + +// Function to check for upcoming events and send notifications +export async function checkAndNotifyEvents() { + console.log("Checking for upcoming events..."); + let now; + if (TEST_NOTIFICATIONS) { + now = TEST_DATE_NOW; + } else { + now = new Date(); + } + + // Calculate time 15 minutes from now + const fifteenMinutesFromNow = new Date(now.getTime() + 15 * 60 * 1000); + + const formattedStartTime = now.toTimeString().slice(0, 8); + const formattedEndTime = fifteenMinutesFromNow.toTimeString().slice(0, 8); + const today = now.toISOString().split("T")[0]; + //console.log(today); + try { + // Get events that start between now and 15 minutes from now + const { data: events, error } = await supabaseServersideClient + .schema("timetable") + .from("course_events") + .select("*") + .gte("event_start", formattedStartTime) + .lte("event_start", formattedEndTime) + .eq("event_date", today); + + if (error) { + console.error("Error fetching events:", error); + return; + } + + console.log(`Found ${events.length} events to notify users about`); + + // Send email notifications for each event + for (const event of events) { + // get timetable notif enabled + const { data: timetables, error: errorTimetable } = + await supabaseServersideClient + .schema("timetable") + .from("timetables") + .select("email_notifications_enabled") + .eq("id", event.calendar_id) + .eq("user_id", event.user_id) + .limit(1); + + if (errorTimetable) { + console.error("Error fetching timetable: ", errorTimetable); + return; + } + + if (!timetables || timetables.length === 0) { + console.error("Timetable not found id:", event.offering_id); + return; + } + + if (!timetables[0].email_notifications_enabled) { + continue; + } + + // Get offering + const { data: offerings, error: errorOffering } = + await supabaseServersideClient + .schema("course") + .from("offerings") + .select("*") + .eq("id", event.offering_id) + .limit(1); + + if (errorOffering) { + console.error("Error fetching offering: ", errorOffering); + return; + } + + if (!offerings || offerings.length === 0) { + console.error("Offering not found id:", event.offering_id); + return; + } + + // Ensure we are in the correct semester and day of week + if (!correctDay(offerings[0])) { + continue; + } + + // Get user info + const { data: userData, error } = + await supabaseServersideClient.auth.admin.getUserById(event.user_id); + + if (error) { + console.error("Error fetching user: ", error); + return; + } + + if (!userData) { + console.error("User not found id:", event.user_id); + return; + } + + const user = userData?.user; + const userEmail = user?.email; + const userName = user?.user_metadata?.username; + + console.log(`Sending email to ${userEmail} for ${event.event_name}`); + + try { + const email = { + sender: { + email: process.env.SENDER_EMAIL!, + name: process.env.SENDER_NAME || "Course Matrix Notifications", + }, + to: [{ email: userEmail!, name: userName }], + subject: `Reminder: ${event.event_name} starting soon`, + htmlContent: ` +

Event Reminder

+

Hello ${userName},

+

Your event "${event.event_name}" is starting soon

+

Start time: ${event.event_start}

+

Description: ${ + event.event_description || "No description provided" + }

+

Thank you for using our calendar service!

+ `, + }; + + const result = await sendBrevoEmail(email); + console.log("Email sent successfully:", result); + } catch (error) { + console.error("Failed to send email:", error); + } + } + } catch (err) { + console.error("Error in notification process:", err); + } +} diff --git a/course-matrix/backend/src/services/getOfferings.ts b/course-matrix/backend/src/services/getOfferings.ts new file mode 100644 index 00000000..6b085e9a --- /dev/null +++ b/course-matrix/backend/src/services/getOfferings.ts @@ -0,0 +1,34 @@ +import { supabase } from "../db/setupDb"; + +// Function to fetch offerings from the database for a given course and semester +export default async function getOfferings( + course_id: number, + semester: string, +) { + let { data: offeringData, error: offeringError } = await supabase + .schema("course") + .from("offerings") + .select( + ` + id, + course_id, + meeting_section, + offering, + day, + start, + end, + location, + current, + max, + is_waitlisted, + delivery_mode, + instructor, + notes, + code + `, + ) + .eq("course_id", course_id) + .eq("offering", semester); + + return offeringData; +} diff --git a/course-matrix/backend/src/services/getValidSchedules.ts b/course-matrix/backend/src/services/getValidSchedules.ts new file mode 100644 index 00000000..9f68695c --- /dev/null +++ b/course-matrix/backend/src/services/getValidSchedules.ts @@ -0,0 +1,49 @@ +import { Offering, CategorizedOfferingList } from "../types/generatorTypes"; +import { getFrequencyTable, canInsertList } from "../utils/generatorHelpers"; +// Function to generate all valid schedules based on offerings and restrictions + +export async function getValidSchedules( + validSchedules: Offering[][], + courseOfferingsList: CategorizedOfferingList[], + curList: Offering[], + cur: number, + len: number, + maxdays: number, +) { + // Base case: if all courses have been considered + if (cur == len) { + const freq: Map = getFrequencyTable(curList); + + // If the number of unique days is within the allowed limit, add the current + // schedule to the list + if (freq.size <= maxdays) { + validSchedules.push([...curList]); // Push a copy of the current list + } + return; + } + + const offeringsForCourse = courseOfferingsList[cur]; + + // Recursively attempt to add offerings for the current course + for (const [groupKey, offerings] of Object.entries( + offeringsForCourse.offerings, + )) { + if (await canInsertList(offerings, curList)) { + const count = offerings.length; + curList.push(...offerings); // Add offering to the current list + + // Recursively generate schedules for the next course + await getValidSchedules( + validSchedules, + courseOfferingsList, + curList, + cur + 1, + len, + maxdays, + ); + + // Backtrack: remove the last offering if no valid schedule was found + for (let i = 0; i < count; i++) curList.pop(); + } + } +} diff --git a/course-matrix/backend/src/types/generatorTypes.ts b/course-matrix/backend/src/types/generatorTypes.ts new file mode 100644 index 00000000..57061e8c --- /dev/null +++ b/course-matrix/backend/src/types/generatorTypes.ts @@ -0,0 +1,57 @@ +// Interface to define the structure of an Offering +export interface Offering { + id: number; + course_id: number; + meeting_section: string; + offering: string; + day: string; + start: string; + end: string; + location: string; + current: number; + max: number; + is_waitlisted: boolean; + delivery_mode: string; + instructor: string; + notes: string; + code: string; +} + +// Enum to define different types of restrictions for offerings +export enum RestrictionType { + RestrictBefore = "Restrict Before", + RestrictAfter = "Restrict After", + RestrictBetween = "Restrict Between", + RestrictDay = "Restrict Day", + RestrictDaysOff = "Days Off", +} + +// Interface for the restriction object +export interface Restriction { + type: RestrictionType; + days: string[]; + startTime: string; + endTime: string; + disabled: boolean; + numDays: number; +} + +// Interface for organizing offerings with the same meeting_section together +export interface GroupedOfferingList { + course_id: number; + groups: Record; +} + +// Interface for organizing offerings by course ID +export interface OfferingList { + course_id: number; + offerings: Offering[]; +} + +// Interface for organizing offerings by course ID and the category of the +// course (LEC, TUT, PRA) +export interface CategorizedOfferingList { + course_id: number; + category: "LEC" | "TUT" | "PRA"; + offerings: Record; +} diff --git a/course-matrix/backend/src/utils/analyzeQuery.ts b/course-matrix/backend/src/utils/analyzeQuery.ts new file mode 100644 index 00000000..3ac5a601 --- /dev/null +++ b/course-matrix/backend/src/utils/analyzeQuery.ts @@ -0,0 +1,72 @@ +import { + NAMESPACE_KEYWORDS, + DEPARTMENT_CODES, + GENERAL_ACADEMIC_TERMS, + ASSISTANT_TERMS, +} from "../constants/promptKeywords"; + +// Analyze query contents and pick out relavent namespaces to search. +export function analyzeQuery(query: string): { + requiresSearch: boolean; + relevantNamespaces: string[]; +} { + const lowerQuery = query.toLowerCase(); + + // Check for course codes (typically 3 letters followed by numbers) + const courseCodeRegex = /\b[a-zA-Z]{3}[a-zA-Z]?\d{2,3}[a-zA-Z]?\b/i; + const containsCourseCode = courseCodeRegex.test(query); + + const relevantNamespaces: string[] = []; + + // Check each namespace's keywords + Object.entries(NAMESPACE_KEYWORDS).forEach(([namespace, keywords]) => { + if (keywords.some((keyword) => lowerQuery.includes(keyword))) { + relevantNamespaces.push(namespace); + } + }); + + // If a course code is detected, add tehse namespaces + if (containsCourseCode) { + if (!relevantNamespaces.includes("courses_v3")) + relevantNamespaces.push("courses_v3"); + if (!relevantNamespaces.includes("offerings")) + relevantNamespaces.push("offerings"); + if (!relevantNamespaces.includes("prerequisites")) + relevantNamespaces.push("prerequisites"); + } + + // Check for dept codes + if (DEPARTMENT_CODES.some((code) => lowerQuery.includes(code))) { + if (!relevantNamespaces.includes("departments")) + relevantNamespaces.push("departments"); + if (!relevantNamespaces.includes("courses_v3")) + relevantNamespaces.push("courses_v3"); + } + + // If search is required at all + const requiresSearch = + relevantNamespaces.length > 0 || + GENERAL_ACADEMIC_TERMS.some((term) => lowerQuery.includes(term)) || + containsCourseCode; + + // If no specific namespaces identified & search required, then search all + if (requiresSearch && relevantNamespaces.length === 0) { + relevantNamespaces.push( + "courses_v3", + "offerings", + "prerequisites", + "corequisites", + "departments", + "programs", + ); + } + + if ( + ASSISTANT_TERMS.some((term) => lowerQuery.includes(term)) && + relevantNamespaces.length === 0 + ) { + return { requiresSearch: false, relevantNamespaces: [] }; + } + + return { requiresSearch, relevantNamespaces }; +} diff --git a/course-matrix/backend/src/utils/compareDates.ts b/course-matrix/backend/src/utils/compareDates.ts new file mode 100644 index 00000000..b30d249b --- /dev/null +++ b/course-matrix/backend/src/utils/compareDates.ts @@ -0,0 +1,13 @@ +export const compareDates = (date1: Date, date2: Date) => { + if (date1 < date2) { + return -1; + } else if (date1 > date2) { + return 1; + } else { + return 0; + } +}; + +export const isDateBetween = (date: Date, start: Date, end: Date): boolean => { + return compareDates(date, start) >= 0 && compareDates(date, end) <= 0; +}; diff --git a/course-matrix/backend/src/utils/convert-breadth-requirement.ts b/course-matrix/backend/src/utils/convert-breadth-requirement.ts new file mode 100644 index 00000000..0537ea3b --- /dev/null +++ b/course-matrix/backend/src/utils/convert-breadth-requirement.ts @@ -0,0 +1,9 @@ +export const convertBreadthRequirement = (code: string) => { + if (code === "ART_LIT_LANG") return "Arts, Literature and Language"; + else if (code === "HIS_PHIL_CUL") + return "History, Philosophy and Cultural Studies"; + else if (code === "SOCIAL_SCI") return "Social and Behavioral Sciences"; + else if (code === "NAT_SCI") return "Natural Sciences"; + else if (code === "QUANT") return "Quantitative Reasoning"; + else return ""; +}; diff --git a/course-matrix/backend/src/utils/convert-time-string.ts b/course-matrix/backend/src/utils/convert-time-string.ts new file mode 100644 index 00000000..1107f1b9 --- /dev/null +++ b/course-matrix/backend/src/utils/convert-time-string.ts @@ -0,0 +1,20 @@ +export function convertTimeStringToDate(timeString: string): Date { + // Validate the timeString format (HH:mm:ss) + const isValidFormat = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/.test( + timeString, + ); + if (!isValidFormat) { + throw new Error("Invalid time format. Expected HH:mm:ss"); + } + + const date = new Date(); + + const [hours, minutes, seconds] = timeString.split(":").map(Number); + + date.setHours(hours); + date.setMinutes(minutes); + date.setSeconds(seconds); + date.setMilliseconds(0); + + return date; +} diff --git a/course-matrix/backend/src/utils/convert-year-level.ts b/course-matrix/backend/src/utils/convert-year-level.ts new file mode 100644 index 00000000..7be7ad17 --- /dev/null +++ b/course-matrix/backend/src/utils/convert-year-level.ts @@ -0,0 +1,7 @@ +export const convertYearLevel = (code: string) => { + if (code === "first_year") return "1st year"; + else if (code === "second_year") return "2nd year"; + else if (code === "third_year") return "3rd year"; + else if (code === "fourth_year") return "4th year"; + else return ""; +}; diff --git a/course-matrix/backend/src/utils/embeddings.ts b/course-matrix/backend/src/utils/embeddings.ts index a8f11569..25d56fde 100644 --- a/course-matrix/backend/src/utils/embeddings.ts +++ b/course-matrix/backend/src/utils/embeddings.ts @@ -5,6 +5,7 @@ import { PineconeStore } from "@langchain/pinecone"; import { Pinecone } from "@pinecone-database/pinecone"; import config from "../config/config"; import path from "path"; +import { convertBreadthRequirement } from "./convert-breadth-requirement"; console.log("Running embeddings process..."); @@ -37,6 +38,35 @@ async function processCSV(filePath: string, namespace: string) { }); } +// Generate embeddings for courses.csv +async function processCoursesCSV(filePath: string, namespace: string) { + const fileName = path.basename(filePath); + const loader = new CSVLoader(filePath); + let docs = await loader.load(); + + docs = docs.map((doc, index) => ({ + ...doc, + metadata: { + ...doc.metadata, + source: fileName, + row: index + 1, + breadth_requirement: convertBreadthRequirement( + doc.pageContent.split("\n")[1].split(": ")[1], + ), + year_level: doc.pageContent.split("\n")[10].split(": ")[1], + }, + })); + console.log("Sample doc: ", docs[0]); + + const index = pinecone.Index(process.env.PINECONE_INDEX_NAME!); + + // Store each row as an individual embedding + await PineconeStore.fromDocuments(docs, embeddings, { + pineconeIndex: index as any, + namespace: namespace, + }); +} + // Generate embeddings for pdfs async function processPDF(filePath: string, namespace: string) { const fileName = path.basename(filePath); @@ -98,6 +128,7 @@ async function processPDF(filePath: string, namespace: string) { // processCSV("../data/tables/offerings_winter_2026.csv", "offerings") // processCSV("../data/tables/departments.csv", "departments") // processCSV("../data/tables/courses_with_year.csv", "courses_v2") +// processCoursesCSV("../data/tables/courses_with_year.csv", "courses_v3"); console.log("embeddings done."); diff --git a/course-matrix/backend/src/utils/generatorHelpers.ts b/course-matrix/backend/src/utils/generatorHelpers.ts new file mode 100644 index 00000000..7d4459c5 --- /dev/null +++ b/course-matrix/backend/src/utils/generatorHelpers.ts @@ -0,0 +1,222 @@ +import { + Offering, + OfferingList, + GroupedOfferingList, + Restriction, + RestrictionType, + CategorizedOfferingList, +} from "../types/generatorTypes"; + +// Utility function to create an Offering object with optional overrides +export function createOffering(overrides: Partial = {}): Offering { + return { + id: overrides.id ?? -1, + course_id: overrides.course_id ?? -1, + meeting_section: overrides.meeting_section ?? "No Section", + offering: overrides.offering ?? "No Offering", + day: overrides.day ?? "N/A", + start: overrides.start ?? "00:00:00", + end: overrides.end ?? "00:00:00", + location: overrides.location ?? "No Room", + current: overrides.current ?? -1, + max: overrides.max ?? -1, + is_waitlisted: overrides.is_waitlisted ?? false, + delivery_mode: overrides.delivery_mode ?? "N/A", + instructor: overrides.instructor ?? "N/A", + notes: overrides.notes ?? "N/A", + code: overrides.code ?? "N/A", + }; +} + +// Function to group offerings with the same meeting section together +export async function groupOfferings(courseOfferingsList: OfferingList[]) { + const groupedOfferingsList: GroupedOfferingList[] = []; + for (const offering of courseOfferingsList) { + const groupedOfferings: GroupedOfferingList = { + course_id: offering.course_id, + groups: {}, + }; + offering.offerings.forEach((offering) => { + if (!groupedOfferings.groups[offering.meeting_section]) { + groupedOfferings.groups[offering.meeting_section] = []; + } + groupedOfferings.groups[offering.meeting_section].push(offering); + }); + groupedOfferingsList.push(groupedOfferings); + } + + return groupedOfferingsList; +} + +// Function to get the maximum number of days allowed based on restrictions +export async function getMaxDays(restrictions: Restriction[]) { + for (const restriction of restrictions) { + if (restriction.disabled) continue; + if (restriction.type == RestrictionType.RestrictDaysOff) { + return 5 - restriction.numDays; // Subtract the restricted days from the total days + } + } + return 5; // Default to 5 days if no restrictions +} + +// Function to check if an offering satisfies the restrictions +export function isValidOffering( + offering: Offering, + restrictions: Restriction[], +) { + for (const restriction of restrictions) { + if (restriction.disabled) continue; + if (!restriction.days.includes(offering.day)) continue; + // Check based on the restriction type + switch (restriction.type) { + case RestrictionType.RestrictBefore: + if (offering.start < restriction.endTime) return false; + break; + + case RestrictionType.RestrictAfter: + // console.log("===="); + // console.log(offering.end); + // console.log(restriction.endTime); + if (offering.end > restriction.startTime) return false; + break; + + case RestrictionType.RestrictBetween: + if ( + offering.start < restriction.endTime && + restriction.startTime < offering.end + ) { + return false; + } + break; + + case RestrictionType.RestrictDay: + if (restriction.days.includes(offering.day)) { + return false; + } + break; + } + } + + // console.log(offering); + return true; +} + +// Function to get valid offerings by filtering them based on the restrictions +export async function getValidOfferings( + groups: Record, + restrictions: Restriction[], +) { + const validGroups: Record = {}; + + // Loop through each group in the groups object + for (const [groupKey, offerings] of Object.entries(groups)) { + // Check if all offerings in the group are valid + const allValid = offerings.every((offering) => + isValidOffering(offering, restrictions), + ); + + // Only add the group to validGroups if all offerings are valid + if (allValid) { + validGroups[groupKey] = offerings; + } + } + + // Return the object with valid groups + return validGroups; +} + +// Function to categorize offerings into lectures, tutorials, and practicals +export async function categorizeValidOfferings( + offerings: GroupedOfferingList[], +) { + const lst: CategorizedOfferingList[] = []; + + for (const offering of offerings) { + const lectures: CategorizedOfferingList = { + course_id: offering.course_id, + category: "LEC", + offerings: {}, + }; + const tutorials: CategorizedOfferingList = { + course_id: offering.course_id, + category: "TUT", + offerings: {}, + }; + const practicals: CategorizedOfferingList = { + course_id: offering.course_id, + category: "PRA", + offerings: {}, + }; + + for (const [meeting_section, offerings] of Object.entries( + offering.groups, + )) { + if (meeting_section && meeting_section.startsWith("PRA")) { + practicals.offerings[meeting_section] = offerings; + } else if (meeting_section && meeting_section.startsWith("TUT")) { + tutorials.offerings[meeting_section] = offerings; + } else { + lectures.offerings[meeting_section] = offerings; + } + } + + for (const x of [lectures, practicals, tutorials]) { + if (Object.keys(x.offerings).length > 0) { + lst.push(x); + } + } + } + return lst; +} + +// Function to check if an offering can be inserted into the current list of +// offerings without conflicts +export async function canInsert(toInsert: Offering, curList: Offering[]) { + for (const offering of curList) { + if (offering.day == toInsert.day) { + if (offering.start < toInsert.end && toInsert.start < offering.end) { + return false; // Check if the time overlaps + } + } + } + + return true; // No conflict found +} + +// Function to check if an ever offerings in toInstList can be inserted into +// the current list of offerings without conflicts +export async function canInsertList( + toInsertList: Offering[], + curList: Offering[], +) { + // console.log(toInsertList); + return toInsertList.every((x) => canInsert(x, curList)); +} + +// Function to generate a frequency table of days from a list of offerings +export function getFrequencyTable(arr: Offering[]): Map { + const freqMap = new Map(); + + for (const item of arr) { + const count = freqMap.get(item.day) || 0; + freqMap.set(item.day, count + 1); + } + return freqMap; +} + +// Trims the list of scheules to only return 10 random schedule if there is more +// than 10 available options. +export function trim(schedules: Offering[][]) { + if (schedules.length <= 10) return schedules; + const num = schedules.length; + + const uniqueNumbers = new Set(); + while (uniqueNumbers.size < 10) { + uniqueNumbers.add(Math.floor(Math.random() * num)); + } + // console.log(uniqueNumbers); + const trim_schedule: Offering[][] = []; + for (const value of uniqueNumbers) trim_schedule.push(schedules[value]); + + return trim_schedule; +} diff --git a/course-matrix/backend/src/utils/includeFilters.ts b/course-matrix/backend/src/utils/includeFilters.ts new file mode 100644 index 00000000..94c86210 --- /dev/null +++ b/course-matrix/backend/src/utils/includeFilters.ts @@ -0,0 +1,54 @@ +import { + BREADTH_REQUIREMENT_KEYWORDS, + YEAR_LEVEL_KEYWORDS, +} from "../constants/promptKeywords"; +import { convertBreadthRequirement } from "./convert-breadth-requirement"; +import { convertYearLevel } from "./convert-year-level"; + +// Determines whether to apply metadata filtering based on user query. +export function includeFilters(query: string) { + const lowerQuery = query.toLocaleLowerCase(); + const relaventBreadthRequirements: string[] = []; + const relaventYearLevels: string[] = []; + + Object.entries(BREADTH_REQUIREMENT_KEYWORDS).forEach( + ([namespace, keywords]) => { + if (keywords.some((keyword) => lowerQuery.includes(keyword))) { + relaventBreadthRequirements.push(convertBreadthRequirement(namespace)); + } + }, + ); + + Object.entries(YEAR_LEVEL_KEYWORDS).forEach(([namespace, keywords]) => { + if (keywords.some((keyword) => lowerQuery.includes(keyword))) { + relaventYearLevels.push(convertYearLevel(namespace)); + } + }); + + let filter = {}; + if (relaventBreadthRequirements.length > 0 && relaventYearLevels.length > 0) { + filter = { + $and: [ + { + $or: relaventBreadthRequirements.map((req) => ({ + breadth_requirement: { $eq: req }, + })), + }, + { + $or: relaventYearLevels.map((yl) => ({ year_level: { $eq: yl } })), + }, + ], + }; + } else if (relaventBreadthRequirements.length > 0) { + filter = { + $or: relaventBreadthRequirements.map((req) => ({ + breadth_requirement: { $eq: req }, + })), + }; + } else if (relaventYearLevels.length > 0) { + filter = { + $or: relaventYearLevels.map((yl) => ({ year_level: { $eq: yl } })), + }; + } + return filter; +} diff --git a/course-matrix/frontend/__tests__/integration-tests/integration-tests.test.tsx b/course-matrix/frontend/__tests__/integration-tests/integration-tests.test.tsx new file mode 100644 index 00000000..a7cb7171 --- /dev/null +++ b/course-matrix/frontend/__tests__/integration-tests/integration-tests.test.tsx @@ -0,0 +1,128 @@ +import "@testing-library/jest-dom/jest-globals"; +import "@testing-library/jest-dom"; + +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; +import { MemoryRouter } from "react-router-dom"; +import { expect, test } from "@jest/globals"; +import App from "../../src/App"; +import SignupPage from "../../src/pages/Signup/SignUpPage"; +import LoginPage from "../../src/pages/Login/LoginPage"; +import Dashboard from "../../src/pages/Dashboard/Dashboard"; +import TimetableBuilder from "../../src/pages/TimetableBuilder/TimetableBuilder"; +import Home from "../../src/pages/Home/Home"; + +test("typical flow for creating an account and logging in", () => { + render(, { wrapper: MemoryRouter }); + render(, { wrapper: MemoryRouter }); + render(, { wrapper: MemoryRouter }); + render(, { wrapper: MemoryRouter }); + + // Going to login page + expect(screen.getByText("Login")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Login")); + + // Logging in with invalid credentials + fireEvent.change(screen.getByLabelText("Email"), { target: { value: "" } }); + fireEvent.change(screen.getByLabelText("Password"), { + target: { value: "password" }, + }); + fireEvent.click(screen.getByText("Login")); + expect( + screen.getByText("Login invalid. Please check your email or password."), + ).toBeInTheDocument(); + + // Going to signup page + fireEvent.click(screen.getByText("Sign up!")); + expect(screen.getByText("Sign up")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Email"), { target: { value: "" } }); + fireEvent.change(screen.getByLabelText("Password"), { + target: { value: "" }, + }); + fireEvent.click(screen.getByText("Sign up")); + expect(screen.getByText("Email is required")).toBeInTheDocument(); + expect(screen.getByText("Password is required")).toBeInTheDocument(); + + // Creating an account + fireEvent.change(screen.getByLabelText("Email"), { + target: { value: "test@example.com" }, + }); + fireEvent.change(screen.getByLabelText("Password"), { + target: { value: "password123" }, + }); + fireEvent.click(screen.getByText("Sign up")); + expect(screen.getByText("Account created successfully!")).toBeInTheDocument(); + + // Logging in with valid credentials + fireEvent.change(screen.getByLabelText("Email"), { + target: { value: "test@example.com" }, + }); + fireEvent.change(screen.getByLabelText("Password"), { + target: { value: "password123" }, + }); + fireEvent.click(screen.getByText("Login")); + + // Check if user is redirected to home page + expect(screen.getByText("Home Page")).toBeInTheDocument(); +}); + +test("typical flow for creating a new timetable, adding courses, adding restrictions, saving the timetable, and editing the timetable", () => { + render(, { wrapper: MemoryRouter }); + render(, { wrapper: MemoryRouter }); + render(, { wrapper: MemoryRouter }); + render(, { wrapper: MemoryRouter }); + render(, { wrapper: MemoryRouter }); + render(, { wrapper: MemoryRouter }); + + expect(screen.getByText("My Timetables")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Create New")); + + expect(screen.getByText("New Timetable")).toBeInTheDocument(); + + // Adding courses + fireEvent.click(screen.getByText("Choose meeting sections manually")); + fireEvent.click(screen.getByText("Search")); + fireEvent.click(screen.getByText("ACMB10H3")); + fireEvent.click(screen.getByText("ACMC01H3")); + fireEvent.click(screen.getByText("ACMD01H3")); + screen.getAllByText("No LEC Selected").forEach((element) => { + fireEvent.click(element); + fireEvent.click(screen.getByText("LEC 01")); + }); + + // Adding a restriction + fireEvent.click(screen.getByText("Add New")); + fireEvent.click(screen.getByText("Select a type")); + fireEvent.click(screen.getByText("Restrict Entire Day")); + fireEvent.click(screen.getByText("Tuesday")); + fireEvent.click(screen.getByText("Create")); + + // Saving the timetable + fireEvent.click(screen.getByText("Create Timetable")); + fireEvent.change(screen.getByLabelText("Timetable Name"), { + target: { value: "Test Timetable Name" }, + }); + fireEvent.click(screen.getByText("Save")); + + // Check if user is redirected to home page and timetable is saved + expect(screen.getByText("My Timetables")).toBeInTheDocument(); + expect(screen.getByText("Test Timetable Name")).toBeInTheDocument(); + + // Editing the timetable by resetting all courses and restrictions + fireEvent.click(screen.getByText("Test Timetable Name")); + expect(screen.getByText("Edit Timetable")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Reset")); + expect(screen.queryByText("ACMB10H3")).toBeNull(); + expect(screen.queryByText("ACMC01H3")).toBeNull(); + expect(screen.queryByText("ACMD01H3")).toBeNull(); + expect(screen.queryByText("Tuesday")).toBeNull(); + fireEvent.click(screen.getByText("Update Timetable")); + + // Check if user is redirected to home page and timetable is updated + expect(screen.getByText("My Timetables")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Test Timetable Name")); + expect(screen.queryByText("ACMB10H3")).toBeNull(); + expect(screen.queryByText("ACMC01H3")).toBeNull(); + expect(screen.queryByText("ACMD01H3")).toBeNull(); + expect(screen.queryByText("Tuesday")).toBeNull(); +}); diff --git a/course-matrix/frontend/__tests__/unit-tests/TimetableBuilder.test.tsx b/course-matrix/frontend/__tests__/unit-tests/TimetableBuilder.test.tsx new file mode 100644 index 00000000..9a97f01a --- /dev/null +++ b/course-matrix/frontend/__tests__/unit-tests/TimetableBuilder.test.tsx @@ -0,0 +1,196 @@ +import "@testing-library/jest-dom/jest-globals"; +import "@testing-library/jest-dom"; + +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; +import { + beforeEach, + describe, + vi, + Mock, + afterEach, + it, + expect, + MockedFunction, +} from "vitest"; +import TimetableBuilder from "../../src/pages/TimetableBuilder/TimetableBuilder"; +import { useForm, UseFormWatch } from "react-hook-form"; +import { z } from "zod"; +import React from "react"; + +vi.mock("react-hook-form", () => ({ + useForm: vi.fn(), +})); + +vi.mock("@/api/coursesApiSlice", () => ({ + useGetCoursesQuery: vi.fn(() => ({ data: [], isLoading: false })), +})); + +vi.mock("@/api/timetableApiSlice", () => ({ + useGetTimetablesQuery: vi.fn(() => ({ data: [] })), +})); + +vi.mock("@/api/eventsApiSlice", () => ({ + useGetEventsQuery: vi.fn(() => ({ data: null })), +})); + +vi.mock("@/api/offeringsApiSlice", () => ({ + useGetOfferingsQuery: vi.fn(() => ({ data: [] })), +})); + +vi.mock("@/api/restrictionsApiSlice", () => ({ + useGetRestrictionsQuery: vi.fn(() => ({ data: [] })), +})); + +vi.mock("@/utils/useDebounce", () => ({ + useDebounceValue: (value: string) => value, +})); + +describe("TimetableBuilder", () => { + const mockSetValue = vi.fn(); + const mockHandleSubmit = vi.fn((fn) => fn); + + beforeEach(() => { + (useForm as Mock).mockReturnValue({ + control: {}, + handleSubmit: mockHandleSubmit, + setValue: mockSetValue, + watch: vi.fn(() => { + const result: { unsubscribe: () => void } & any[] = []; + result.unsubscribe = () => {}; + return result; + }) as unknown as UseFormWatch, + reset: vi.fn(), + getValues: vi.fn(() => []), + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders the TimetableBuilder component", () => { + render( + + + , + ); + + expect(screen.getByText(/New Timetable/i)).toBeInTheDocument(); + expect( + screen.getByText(/Pick a few courses you'd like to take/i), + ).toBeInTheDocument(); + }); + + it("calls the reset function when the Reset button is clicked", () => { + const mockReset = vi.fn(); + (useForm as MockedFunction).mockReturnValue({ + reset: mockReset, + handleSubmit: mockHandleSubmit, + setValue: mockSetValue, + watch: vi.fn(() => { + const result: unknown = []; + result.unsubscribe = () => {}; + return result; + }), + getValues: vi.fn(() => []), + }); + + render( + + + , + ); + + const resetButton = screen.getByText(/Reset/i); + fireEvent.click(resetButton); + + expect(mockReset).toHaveBeenCalled(); + }); + + it("opens the custom settings modal when the Add new button is clicked", () => { + render( + + + , + ); + + const addNewButton = screen.getByText(/\+ Add new/i); + fireEvent.click(addNewButton); + + expect(screen.getByText(/Custom Settings/i)).toBeInTheDocument(); + }); + + it("displays selected courses when courses are added", async () => { + const mockWatch = vi.fn(() => [ + { id: 1, code: "CS101", name: "Introduction to Computer Science" }, + ]); + (useForm as vi.Mock).mockReturnValue({ + watch: mockWatch, + handleSubmit: mockHandleSubmit, + setValue: mockSetValue, + reset: vi.fn(), + getValues: vi.fn(() => []), + }); + + render( + + + , + ); + + expect(screen.getByText(/Selected courses: 1/i)).toBeInTheDocument(); + expect(screen.getByText(/CS101/i)).toBeInTheDocument(); + }); + + it("removes a course when the remove button is clicked", async () => { + const mockWatch = vi.fn(() => [ + { id: 1, code: "CS101", name: "Introduction to Computer Science" }, + ]); + const mockSetValue = vi.fn(); + (useForm as vi.Mock).mockReturnValue({ + watch: mockWatch, + handleSubmit: mockHandleSubmit, + setValue: mockSetValue, + reset: vi.fn(), + getValues: vi.fn(() => [ + { id: 1, code: "CS101", name: "Introduction to Computer Science" }, + ]), + }); + + render( + + + , + ); + + const removeButton = screen.getByRole("button", { name: /Remove/i }); + fireEvent.click(removeButton); + + await waitFor(() => { + expect(mockSetValue).toHaveBeenCalledWith("courses", []); + }); + }); + + it("submits the form when the Generate button is clicked", () => { + const mockSubmit = vi.fn(); + (useForm as vi.Mock).mockReturnValue({ + handleSubmit: vi.fn((fn) => fn), + setValue: mockSetValue, + watch: vi.fn(() => []), + reset: vi.fn(), + getValues: vi.fn(() => []), + }); + + render( + + + , + ); + + const generateButton = screen.getByText(/Generate/i); + fireEvent.click(generateButton); + + expect(mockHandleSubmit).toHaveBeenCalled(); + }); +}); diff --git a/course-matrix/frontend/__tests__/unit-tests/UserMenu.test.tsx b/course-matrix/frontend/__tests__/unit-tests/UserMenu.test.tsx new file mode 100644 index 00000000..0e094802 --- /dev/null +++ b/course-matrix/frontend/__tests__/unit-tests/UserMenu.test.tsx @@ -0,0 +1,88 @@ +import "@testing-library/jest-dom/jest-globals"; +import "@testing-library/jest-dom"; + +import configureStore from "redux-mock-store"; +import { UserMenu } from "../../src/components/UserMenu"; +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Router } from "lucide-react"; +import React from "react"; +import { Provider } from "react-redux"; + +const mockStore = configureStore([]); + +describe("UserMenu Component", () => { + beforeEach(() => { + localStorage.setItem( + "userInfo", + JSON.stringify({ + user: { + id: "123", + user_metadata: { + username: "John Doe", + email: "john.doe@example.com", + }, + }, + }), + ); + }); + + afterEach(() => { + localStorage.clear(); + }); + + test("check local storage", () => { + expect(localStorage.getItem("userInfo")).not.toBeNull(); + }); + + test("opens edit account dialog", () => { + render( + + + + + , + ); + + fireEvent.click(screen.getByText("John Doe")); + fireEvent.click(screen.getByText("Edit Account")); + + expect(screen.getByText("Edit Account")).toBeInTheDocument(); + expect(screen.getByLabelText("New User Name")).toBeInTheDocument(); + }); + + test("opens delete account dialog", () => { + render( + + + + + , + ); + + fireEvent.click(screen.getByText("John Doe")); + fireEvent.click(screen.getByText("Delete Account")); + + expect(screen.getByText("Delete Account")).toBeInTheDocument(); + expect( + screen.getByText( + "Are you sure you want to delete your account? This action cannot be undone.", + ), + ).toBeInTheDocument(); + }); + + test("logs out user", () => { + render( + + + + + , + ); + + fireEvent.click(screen.getByText("John Doe")); + fireEvent.click(screen.getByText("Logout")); + + expect(localStorage.getItem("userInfo")).toBeNull(); + }); +}); diff --git a/course-matrix/frontend/jest.config.ts b/course-matrix/frontend/jest.config.ts index d51af79a..00bf9415 100644 --- a/course-matrix/frontend/jest.config.ts +++ b/course-matrix/frontend/jest.config.ts @@ -2,12 +2,8 @@ import type { Config } from "jest"; const config: Config = { preset: "ts-jest", - moduleNameMapper: { - "\\.(css|scss)$": "identity-obj-proxy", - "^.+\\.svg": "/tests/mocks/svgMock.tsx", - }, + moduleNameMapper: { "@/(.*)$": "/src/$1" }, // to obtain access to the matchers. - setupFilesAfterEnv: ["/tests/setupTests.ts"], moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], modulePaths: [""], testEnvironment: "jsdom", @@ -20,6 +16,12 @@ const config: Config = { ], "^.+\\.(js|jsx)$": "babel-jest", }, + modulePathIgnorePatterns: [ + "/dist/", + "/node_modules/", + "/__tests__/integration-tests/", + "/__tests__/unit-tests/", + ], }; export default config; diff --git a/course-matrix/frontend/package-lock.json b/course-matrix/frontend/package-lock.json index 57157bc7..c9ab7e61 100644 --- a/course-matrix/frontend/package-lock.json +++ b/course-matrix/frontend/package-lock.json @@ -24,12 +24,14 @@ "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", "@reduxjs/toolkit": "^2.5.1", "@schedule-x/drag-and-drop": "^2.21.1", "@schedule-x/event-modal": "^2.21.1", "@schedule-x/events-service": "^2.21.0", "@schedule-x/react": "^2.21.0", + "@schedule-x/scroll-controller": "^2.23.0", "@schedule-x/theme-default": "^2.21.0", "ai": "^4.1.45", "class-variance-authority": "^0.7.1", @@ -73,7 +75,8 @@ "ts-node": "^10.9.2", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vitest": "^3.0.9" } }, "node_modules/@adobe/css-tools": { @@ -2797,6 +2800,34 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz", + "integrity": "sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", @@ -3276,6 +3307,14 @@ "react-dom": "^16.7.0 || ^17 || ^18 || ^19" } }, + "node_modules/@schedule-x/scroll-controller": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/@schedule-x/scroll-controller/-/scroll-controller-2.23.0.tgz", + "integrity": "sha512-VT7pJzvSzhhneiDG7CAzOV0pwJ0h1Ma05qheuNR5iHqGQ4AXWDDs2NpYeWTM8tDWtJ0SbNoPv7hb134OSS0ZkQ==", + "peerDependencies": { + "@preact/signals": "^1.1.5" + } + }, "node_modules/@schedule-x/theme-default": { "version": "2.21.0", "resolved": "https://registry.npmjs.org/@schedule-x/theme-default/-/theme-default-2.21.0.tgz", @@ -3964,6 +4003,112 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.9.tgz", + "integrity": "sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.0.9", + "@vitest/utils": "3.0.9", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.9.tgz", + "integrity": "sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.0.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.9.tgz", + "integrity": "sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.9.tgz", + "integrity": "sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==", + "dev": true, + "dependencies": { + "@vitest/utils": "3.0.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.9.tgz", + "integrity": "sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.0.9", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.9.tgz", + "integrity": "sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.9.tgz", + "integrity": "sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.0.9", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -4158,6 +4303,15 @@ "dequal": "^2.0.3" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/assistant-stream": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/assistant-stream/-/assistant-stream-0.0.21.tgz", @@ -4446,6 +4600,15 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4514,6 +4677,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4575,6 +4754,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4955,6 +5143,15 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5176,6 +5373,12 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", + "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "dev": true + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5465,6 +5668,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5536,6 +5748,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.0.tgz", + "integrity": "sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -7605,6 +7826,12 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "dev": true + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7632,6 +7859,15 @@ "lz-string": "bin/bin.js" } }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -8880,6 +9116,21 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9796,6 +10047,12 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9885,6 +10142,18 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz", + "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", + "dev": true + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -10258,6 +10527,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -10882,6 +11190,97 @@ } } }, + "node_modules/vite-node": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.9.tgz", + "integrity": "sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.9.tgz", + "integrity": "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==", + "dev": true, + "dependencies": { + "@vitest/expect": "3.0.9", + "@vitest/mocker": "3.0.9", + "@vitest/pretty-format": "^3.0.9", + "@vitest/runner": "3.0.9", + "@vitest/snapshot": "3.0.9", + "@vitest/spy": "3.0.9", + "@vitest/utils": "3.0.9", + "chai": "^5.2.0", + "debug": "^4.4.0", + "expect-type": "^1.1.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.0.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.0.9", + "@vitest/ui": "3.0.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -10960,6 +11359,22 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/course-matrix/frontend/package.json b/course-matrix/frontend/package.json index 00b11f8c..89bb5ba9 100644 --- a/course-matrix/frontend/package.json +++ b/course-matrix/frontend/package.json @@ -28,12 +28,14 @@ "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", "@reduxjs/toolkit": "^2.5.1", "@schedule-x/drag-and-drop": "^2.21.1", "@schedule-x/event-modal": "^2.21.1", "@schedule-x/events-service": "^2.21.0", "@schedule-x/react": "^2.21.0", + "@schedule-x/scroll-controller": "^2.23.0", "@schedule-x/theme-default": "^2.21.0", "ai": "^4.1.45", "class-variance-authority": "^0.7.1", @@ -77,6 +79,7 @@ "ts-node": "^10.9.2", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vitest": "^3.0.9" } } diff --git a/course-matrix/frontend/src/api/baseApiSlice.ts b/course-matrix/frontend/src/api/baseApiSlice.ts index c464b0b7..7b7cff9e 100644 --- a/course-matrix/frontend/src/api/baseApiSlice.ts +++ b/course-matrix/frontend/src/api/baseApiSlice.ts @@ -5,6 +5,14 @@ const baseQuery = fetchBaseQuery({ baseUrl: BASE_URL }); export const apiSlice = createApi({ baseQuery, - tagTypes: ["Auth", "Course", "Department", "Offering", "Timetable", "Event"], + tagTypes: [ + "Auth", + "Course", + "Department", + "Offering", + "Timetable", + "Event", + "Restrictions", + ], endpoints: () => ({}), }); diff --git a/course-matrix/frontend/src/api/coursesApiSlice.ts b/course-matrix/frontend/src/api/coursesApiSlice.ts index 5e72775f..c641c22c 100644 --- a/course-matrix/frontend/src/api/coursesApiSlice.ts +++ b/course-matrix/frontend/src/api/coursesApiSlice.ts @@ -17,7 +17,21 @@ export const coursesApiSlice = apiSlice.injectEndpoints({ credentials: "include", }), }), + getNumberOfCourseSections: builder.query({ + query: (params) => ({ + url: `${COURSES_URL}/total-sections`, + method: "GET", + params: params, + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/plain, */*", + }, + providesTags: ["Course"], + credentials: "include", + }), + }), }), }); -export const { useGetCoursesQuery } = coursesApiSlice; +export const { useGetCoursesQuery, useGetNumberOfCourseSectionsQuery } = + coursesApiSlice; diff --git a/course-matrix/frontend/src/api/eventsApiSlice.ts b/course-matrix/frontend/src/api/eventsApiSlice.ts index 48f9b271..102b63dd 100644 --- a/course-matrix/frontend/src/api/eventsApiSlice.ts +++ b/course-matrix/frontend/src/api/eventsApiSlice.ts @@ -1,4 +1,3 @@ -import { data } from "react-router-dom"; import { apiSlice } from "./baseApiSlice"; import { EVENTS_URL } from "./config"; @@ -13,10 +12,10 @@ export const eventsApiSlice = apiSlice.injectEndpoints({ "Content-Type": "application/json", Accept: "application/json, text/plain, */*", }, - providesTags: ["Event"], body: data, credentials: "include", }), + invalidatesTags: ["Event"], }), getEvents: builder.query({ query: (id) => ({ @@ -29,6 +28,7 @@ export const eventsApiSlice = apiSlice.injectEndpoints({ providesTags: ["Event"], credentials: "include", }), + keepUnusedDataFor: 0, }), updateEvent: builder.mutation({ query: (data) => ({ @@ -38,22 +38,23 @@ export const eventsApiSlice = apiSlice.injectEndpoints({ "Content-Type": "application/json", Accept: "application/json, text/plain, */*", }, - providesTags: ["Event"], body: data, credentials: "include", }), + invalidatesTags: ["Event"], }), deleteEvent: builder.mutation({ - query: (id) => ({ - url: `${EVENTS_URL}/${id}`, + query: (data) => ({ + url: `${EVENTS_URL}/${data.id}`, method: "DELETE", + params: data, headers: { "Content-Type": "application/json", Accept: "application/json, text/plain, */*", }, - providesTags: ["Event"], credentials: "include", }), + invalidatesTags: ["Event"], }), }), }); diff --git a/course-matrix/frontend/src/api/offeringsApiSlice.ts b/course-matrix/frontend/src/api/offeringsApiSlice.ts index 16ce173e..097b1c27 100644 --- a/course-matrix/frontend/src/api/offeringsApiSlice.ts +++ b/course-matrix/frontend/src/api/offeringsApiSlice.ts @@ -4,6 +4,19 @@ import { OFFERINGS_URL } from "./config"; // Endpoints for /api/offerings export const offeringsApiSlice = apiSlice.injectEndpoints({ endpoints: (builder) => ({ + getOfferingEvents: builder.query({ + query: (params) => ({ + url: `${OFFERINGS_URL}/events`, + method: "GET", + params: params, + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/plain, */*", + }, + providesTags: ["OfferingEvents"], + credentials: "include", + }), + }), getOfferings: builder.query({ query: (params) => ({ url: `${OFFERINGS_URL}`, @@ -20,4 +33,5 @@ export const offeringsApiSlice = apiSlice.injectEndpoints({ }), }); -export const { useGetOfferingsQuery } = offeringsApiSlice; +export const { useGetOfferingsQuery, useGetOfferingEventsQuery } = + offeringsApiSlice; diff --git a/course-matrix/frontend/src/api/restrictionsApiSlice.ts b/course-matrix/frontend/src/api/restrictionsApiSlice.ts new file mode 100644 index 00000000..7e77bb8a --- /dev/null +++ b/course-matrix/frontend/src/api/restrictionsApiSlice.ts @@ -0,0 +1,53 @@ +import { apiSlice } from "./baseApiSlice"; +import { TIMETABLES_URL } from "./config"; + +// Endpoints for /api/timetables/restrictions +export const restrictionsApiSlice = apiSlice.injectEndpoints({ + endpoints: (builder) => ({ + getRestrictions: builder.query({ + query: (id) => ({ + url: `${TIMETABLES_URL}/restrictions/${id}`, + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/plain, */*", + }, + providesTags: ["Restrictions"], + credentials: "include", + }), + keepUnusedDataFor: 0, + }), + createRestriction: builder.mutation({ + query: (data) => ({ + url: `${TIMETABLES_URL}/restrictions`, + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/plain, */*", + }, + body: data, + credentials: "include", + }), + invalidatesTags: ["Restrictions"], + }), + deleteRestriction: builder.mutation({ + query: (data) => ({ + url: `${TIMETABLES_URL}/restrictions/${data.id}`, + method: "DELETE", + params: data, + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/plain, */*", + }, + credentials: "include", + }), + invalidatesTags: ["Restrictions"], + }), + }), +}); + +export const { + useGetRestrictionsQuery, + useCreateRestrictionMutation, + useDeleteRestrictionMutation, +} = restrictionsApiSlice; diff --git a/course-matrix/frontend/src/api/timetableApiSlice.ts b/course-matrix/frontend/src/api/timetableApiSlice.ts index 35075fd3..215cdb31 100644 --- a/course-matrix/frontend/src/api/timetableApiSlice.ts +++ b/course-matrix/frontend/src/api/timetableApiSlice.ts @@ -12,10 +12,10 @@ export const timetableApiSlice = apiSlice.injectEndpoints({ "Content-Type": "application/json", Accept: "application/json, text/plain, */*", }, - providesTags: ["Timetable"], body: data, credentials: "include", }), + invalidatesTags: ["Timetable"], }), getTimetables: builder.query({ query: () => ({ @@ -28,6 +28,18 @@ export const timetableApiSlice = apiSlice.injectEndpoints({ providesTags: ["Timetable"], credentials: "include", }), + keepUnusedDataFor: 0, + }), + getTimetable: builder.query({ + query: (id) => ({ + url: `${TIMETABLES_URL}/${id}`, + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/plain, */*", + }, + credentials: "include", + }), }), updateTimetable: builder.mutation({ query: (data) => ({ @@ -37,10 +49,10 @@ export const timetableApiSlice = apiSlice.injectEndpoints({ "Content-Type": "application/json", Accept: "application/json, text/plain, */*", }, - providesTags: ["Timetable"], body: data, credentials: "include", }), + invalidatesTags: ["Timetable"], }), deleteTimetable: builder.mutation({ query: (id) => ({ @@ -50,7 +62,19 @@ export const timetableApiSlice = apiSlice.injectEndpoints({ "Content-Type": "application/json", Accept: "application/json, text/plain, */*", }, - providesTags: ["Timetable"], + credentials: "include", + }), + invalidatesTags: ["Timetable"], + }), + generateTimetable: builder.mutation({ + query: (data) => ({ + url: `${TIMETABLES_URL}/generate`, + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/plain, */*", + }, + body: data, credentials: "include", }), }), @@ -59,7 +83,9 @@ export const timetableApiSlice = apiSlice.injectEndpoints({ export const { useGetTimetablesQuery, + useGetTimetableQuery, useUpdateTimetableMutation, useCreateTimetableMutation, useDeleteTimetableMutation, + useGenerateTimetableMutation, } = timetableApiSlice; diff --git a/course-matrix/frontend/src/components/UserMenu.tsx b/course-matrix/frontend/src/components/UserMenu.tsx index dca42f05..619ef1bb 100644 --- a/course-matrix/frontend/src/components/UserMenu.tsx +++ b/course-matrix/frontend/src/components/UserMenu.tsx @@ -125,7 +125,7 @@ export function UserMenu() { {username} {/* Avatar Image is the profile picture of the user. The default avatar is used as a placeholder for now. */} - + {/* Avatar Fallback is the initials of the user. Avatar Fallback will be used if Avatar Image fails to load */} {initials} diff --git a/course-matrix/frontend/src/components/assistant-ui/thread.tsx b/course-matrix/frontend/src/components/assistant-ui/thread.tsx index e67387b5..5eb88de8 100644 --- a/course-matrix/frontend/src/components/assistant-ui/thread.tsx +++ b/course-matrix/frontend/src/components/assistant-ui/thread.tsx @@ -12,6 +12,8 @@ import { ChevronLeftIcon, ChevronRightIcon, CopyIcon, + Info, + Lightbulb, PencilIcon, RefreshCwIcon, SendHorizontalIcon, @@ -81,6 +83,12 @@ const ThreadWelcome: FC = () => { Hi my name is Morpheus. How can I help you today?

+

+ + + Tip: Use /timetable to work with your timetables + +

@@ -111,6 +119,16 @@ const ThreadWelcomeSuggestions: FC = () => { What is Course Matrix? + + + /timetable show my timetables + + ); }; diff --git a/course-matrix/frontend/src/components/time-picker-hr.tsx b/course-matrix/frontend/src/components/time-picker-hr.tsx index 93709eee..cce49d5f 100644 --- a/course-matrix/frontend/src/components/time-picker-hr.tsx +++ b/course-matrix/frontend/src/components/time-picker-hr.tsx @@ -60,7 +60,7 @@ export function TimePickerHr({ date, setDate }: TimePickerHrProps) { */}
) => ( +

-
+
{isLoading ? (

Loading...

) : ( - data?.map((timetable, index) => ( - - )) + [...data] + .sort((a: Timetable, b: Timetable) => + b?.updated_at.localeCompare(a?.updated_at), + ) + .map((timetable) => ( + + )) )}
diff --git a/course-matrix/frontend/src/pages/Home/TimetableCard.tsx b/course-matrix/frontend/src/pages/Home/TimetableCard.tsx index 85fb1c23..bad82fb5 100644 --- a/course-matrix/frontend/src/pages/Home/TimetableCard.tsx +++ b/course-matrix/frontend/src/pages/Home/TimetableCard.tsx @@ -11,6 +11,7 @@ import { Pencil } from "lucide-react"; import { useState } from "react"; import TimetableCardKebabMenu from "./TimetableCardKebabMenu"; import { useUpdateTimetableMutation } from "@/api/timetableApiSlice"; +import { Link } from "react-router-dom"; interface TimetableCardProps { refetch: () => void; @@ -62,10 +63,12 @@ const TimetableCard = ({ return ( - Timetable default image + + Timetable default image +
setTimetableCardTitle(e.target.value)} diff --git a/course-matrix/frontend/src/pages/Home/TimetableCardKebabMenu.tsx b/course-matrix/frontend/src/pages/Home/TimetableCardKebabMenu.tsx index 252e5882..69176874 100644 --- a/course-matrix/frontend/src/pages/Home/TimetableCardKebabMenu.tsx +++ b/course-matrix/frontend/src/pages/Home/TimetableCardKebabMenu.tsx @@ -18,6 +18,7 @@ import { } from "@/components/ui/dialog"; import { EllipsisVertical } from "lucide-react"; import { useDeleteTimetableMutation } from "@/api/timetableApiSlice"; +import { EmailNotificationSettings } from "./EmailNotificationSettings"; interface TimetableCardKebabMenuProps { refetch: () => void; @@ -56,6 +57,9 @@ const TimetableCardKebabMenu = ({ Edit Timetable + e.preventDefault()}> + + e.preventDefault()}> diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/Calendar.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/Calendar.tsx index b787afd0..a82e5244 100644 --- a/course-matrix/frontend/src/pages/TimetableBuilder/Calendar.tsx +++ b/course-matrix/frontend/src/pages/TimetableBuilder/Calendar.tsx @@ -7,98 +7,413 @@ import { createViewWeek, viewWeek, } from "@schedule-x/calendar"; -import { createDragAndDropPlugin } from "@schedule-x/drag-and-drop"; +// import { createDragAndDropPlugin } from "@schedule-x/drag-and-drop"; import { createEventModalPlugin } from "@schedule-x/event-modal"; import "@schedule-x/theme-default/dist/index.css"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, + DialogClose, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useForm } from "react-hook-form"; +import { TimetableFormSchema } from "@/models/timetable-form"; +import { + useGetTimetablesQuery, + useCreateTimetableMutation, +} from "@/api/timetableApiSlice"; +import { + useGetRestrictionsQuery, + useCreateRestrictionMutation, + useDeleteRestrictionMutation, +} from "@/api/restrictionsApiSlice"; +import { z } from "zod"; +import React, { useEffect, useRef } from "react"; +import { useGetNumberOfCourseSectionsQuery } from "@/api/coursesApiSlice"; +import { + useCreateEventMutation, + useGetEventsQuery, + useDeleteEventMutation, +} from "@/api/eventsApiSlice"; +import { useGetOfferingsQuery } from "@/api/offeringsApiSlice"; +import { useGetOfferingEventsQuery } from "@/api/offeringsApiSlice"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { + Event, + Timetable, + TimetableEvents, + Restriction, + Offering, +} from "@/utils/type-utils"; +import { TimetableForm } from "@/models/timetable-form"; +import { + getSemesterStartAndEndDates, + getSemesterStartAndEndDatesPlusOneWeek, +} from "@/utils/semester-utils"; +import { courseEventStyles } from "@/constants/calendarConstants"; -function Calendar({ courseEvents, userEvents }) { - let index = 1; - const courseEventsParsed = courseEvents.map( - (event: { - event_name: string; - event_date: string; - event_start: string; - event_end: string; - }) => ({ - id: index++, - title: event.event_name, - start: - event.event_date + - " " + - event.event_start.split(":")[0] + - ":" + - event.event_start.split(":")[1], - end: - event.event_date + - " " + - event.event_end.split(":")[0] + - ":" + - event.event_end.split(":")[1], - calendarId: "courseEvent", - }), - ); - const userEventsParsed = userEvents.map( - (event: { - event_name: string; - event_date: string; - event_start: string; - event_end: string; - }) => ({ - id: index++, - title: event.event_name, - start: - event.event_date + - " " + - event.event_start.split(":")[0] + - ":" + - event.event_start.split(":")[1], - end: - event.event_date + - " " + - event.event_end.split(":")[0] + - ":" + - event.event_end.split(":")[1], - calendarId: "userEvent", - }), - ); - - const calendar = createCalendar({ - views: [ - createViewDay(), - createViewWeek(), - createViewMonthGrid(), - createViewMonthAgenda(), - ], - defaultView: viewWeek.name, - events: [...courseEventsParsed, ...userEventsParsed], - calendars: { - courseEvent: { - colorName: "courseEvent", - lightColors: { - main: "#1c7df9", - container: "#d2e7ff", - onContainer: "#002859", - }, - darkColors: { - main: "#c0dfff", - onContainer: "#dee6ff", - container: "#426aa2", - }, - }, - }, - plugins: [createDragAndDropPlugin(), createEventModalPlugin()], - }); - - return ( -
-

-
Your Timetable
- -

- -
- ); +interface CalendarProps { + setShowLoadingPage: React.Dispatch>; + isChoosingSectionsManually: boolean; + semester: string; + selectedCourses: TimetableForm["courses"]; + newOfferingIds: number[]; + restrictions: TimetableForm["restrictions"]; } +function parseEvent(id: number, event: Event, calendarId: string) { + return { + id: id, + title: event.event_name, + start: + event.event_date + + " " + + event.event_start.split(":")[0] + + ":" + + event.event_start.split(":")[1], + end: + event.event_date + + " " + + event.event_end.split(":")[0] + + ":" + + event.event_end.split(":")[1], + calendarId: calendarId, + }; +} + +const Calendar = React.memo( + ({ + setShowLoadingPage, + semester, + selectedCourses, + newOfferingIds, + restrictions, + isChoosingSectionsManually, + }) => { + const form = useForm>(); + + const navigate = useNavigate(); + const [queryParams] = useSearchParams(); + const isEditingTimetable = queryParams.has("edit"); + const editingTimetableId = parseInt(queryParams.get("edit") ?? "0"); + + const [createTimetable] = useCreateTimetableMutation(); + const [createEvent] = useCreateEventMutation(); + const [deleteEvent] = useDeleteEventMutation(); + const [createRestriction] = useCreateRestrictionMutation(); + const [deleteRestriction] = useDeleteRestrictionMutation(); + + const semesterStartDate = getSemesterStartAndEndDates(semester).start; + const { start: semesterStartDatePlusOneWeek, end: semesterEndDate } = + getSemesterStartAndEndDatesPlusOneWeek(semester); + + const { data: offeringsData } = useGetOfferingsQuery({}) as { + data: Offering[]; + }; + + const { data: courseEventsData } = useGetOfferingEventsQuery({ + offering_ids: newOfferingIds.join(","), + semester_start_date: semesterStartDate, + semester_end_date: semesterEndDate, + }) as { data: Event[] }; + + const { data: timetablesData } = useGetTimetablesQuery() as { + data: Timetable[]; + }; + + const courseEvents = courseEventsData ?? []; + const userEvents: Event[] = []; + + let index = 1; + const courseEventsParsed = courseEvents.map((event) => + parseEvent(index++, event, "courseEvent"), + ); + const userEventsParsed = userEvents.map((event) => + parseEvent(index++, event, "userEvent"), + ); + + const calendar = createCalendar({ + views: [ + createViewDay(), + createViewWeek(), + createViewMonthGrid(), + createViewMonthAgenda(), + ], + selectedDate: semesterStartDatePlusOneWeek, + minDate: semesterStartDate, + maxDate: semesterEndDate, + defaultView: viewWeek.name, + events: [...courseEventsParsed, ...userEventsParsed], + calendars: { + courseEvent: courseEventStyles, + }, + plugins: [createEventModalPlugin()], + weekOptions: { + gridHeight: 600, + }, + dayBoundaries: { + start: "06:00", + end: "21:00", + }, + isResponsive: false, + }); + + const { data: timetableEvents } = useGetEventsQuery(editingTimetableId, { + skip: !isEditingTimetable, + }) as { + data: TimetableEvents; + }; + + const oldOfferingIds = [ + ...new Set( + timetableEvents?.courseEvents.map((event) => event.offering_id), + ), + ].sort((a, b) => a - b); + + const { data: restrictionsData } = useGetRestrictionsQuery( + editingTimetableId, + { + skip: !isEditingTimetable, + }, + ) as { + data: Restriction[]; + }; + + const oldRestrictions = restrictionsData ?? []; + + const timetableTitleRef = useRef(null); + const selectedCourseIds = selectedCourses.map((course) => course.id); + + const { data: numberOfSectionsData } = useGetNumberOfCourseSectionsQuery( + { + course_ids: selectedCourseIds.join(","), + semester: semester, + }, + { + skip: !selectedCourses.length, + }, + ); + + const totalNumberOfRequiredSections = !selectedCourses.length + ? 0 + : (numberOfSectionsData?.totalNumberOfCourseSections ?? 0); + const totalNumberOfSelectedSections = [ + ...new Set( + offeringsData + ?.filter((offering) => newOfferingIds.includes(offering.id)) + .map( + (offering) => + `${offering.code} ${offering.offering} ${offering.meeting_section}`, + ), + ), + ].length; + const allOfferingSectionsHaveBeenSelected = + totalNumberOfSelectedSections === totalNumberOfRequiredSections; + + useEffect(() => { + if (!isEditingTimetable) { + return; + } + }, [timetablesData, editingTimetableId, isEditingTimetable]); + + const handleCreate = async () => { + setShowLoadingPage(true); + const timetableTitle = timetableTitleRef.current?.value ?? ""; + // Create timetable + const { data, error } = await createTimetable({ + timetable_title: timetableTitle, + semester: semester, + }); + if (error) { + console.error(error); + return; + } + // Create course events for the newly created timetable + const newTimetableId = data?.id; + for (const offeringId of newOfferingIds) { + const { error: offeringError } = await createEvent({ + calendar_id: newTimetableId, + offering_id: offeringId, + semester_start_date: semesterStartDate, + semester_end_date: semesterEndDate, + }); + if (offeringError) { + console.error(offeringError); + } + } + // Create restrictions for the newly created timetable + for (const restriction of restrictions) { + const restrictionObject = { + calendar_id: newTimetableId, + type: restriction.type, + days: restriction.days, + start_time: restriction.startTime, + end_time: restriction.endTime, + disabled: restriction.disabled, + num_days: restriction.numDays, + }; + const { error: restrictionError } = + await createRestriction(restrictionObject); + if (restrictionError) { + console.error(restrictionError); + } + } + // Redirect to the home page to see the newly created timetable + navigate("/home"); + }; + + const handleUpdate = async () => { + setShowLoadingPage(true); + const offeringIdsToDelete = oldOfferingIds.filter( + (offeringId) => !newOfferingIds.includes(offeringId), + ); + const offeringIdsToAdd = newOfferingIds.filter( + (offeringId) => !oldOfferingIds.includes(offeringId), + ); + // Delete course events + for (const offeringId of offeringIdsToDelete) { + const { error: deleteError } = await deleteEvent({ + id: 1, + calendar_id: editingTimetableId, + event_type: "course", + offering_id: offeringId, + }); + if (deleteError) { + console.error(deleteError); + } + } + // Create course events + for (const offeringId of offeringIdsToAdd) { + const { error: createError } = await createEvent({ + calendar_id: editingTimetableId, + offering_id: offeringId, + semester_start_date: semesterStartDate, + semester_end_date: semesterEndDate, + }); + if (createError) { + console.error(createError); + } + } + form.setValue("offeringIds", newOfferingIds); + // Delete restrictions + for (const restriction of oldRestrictions) { + const { error: deleteError } = await deleteRestriction({ + id: restriction.id, + calendar_id: editingTimetableId, + }); + if (deleteError) { + console.error(deleteError); + } + } + // Create restrictions + for (const restriction of restrictions) { + const restrictionObject = { + calendar_id: editingTimetableId, + type: restriction.type, + days: restriction.days, + start_time: restriction.startTime, + end_time: restriction.endTime, + disabled: restriction.disabled, + num_days: restriction.numDays, + }; + const { error: restrictionError } = + await createRestriction(restrictionObject); + if (restrictionError) { + console.error(restrictionError); + } + } + navigate("/home"); + }; + + return ( +
+

+
Your Timetable
+ {!isEditingTimetable ? ( + + {isChoosingSectionsManually && + !allOfferingSectionsHaveBeenSelected && ( +

+ Please select all LEC/TUT/PRA sections for your courses in + order to save your timetable. +

+ )} + + {isChoosingSectionsManually && ( + + )} + + + + Timetable Creation + + What would you like to name your timetable? + + + + + + + + + + + + + +
+ ) : ( +
+ {isChoosingSectionsManually && + !allOfferingSectionsHaveBeenSelected && ( +

+ Please select all LEC/TUT/PRA sections for your courses in + order to save your timetable. +

+ )} + + + +
+ )} +

+ +
+ ); + }, +); + export default Calendar; diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/CourseSearch.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/CourseSearch.tsx index b829b4ac..26b07a05 100644 --- a/course-matrix/frontend/src/pages/TimetableBuilder/CourseSearch.tsx +++ b/course-matrix/frontend/src/pages/TimetableBuilder/CourseSearch.tsx @@ -81,6 +81,7 @@ const CourseSearch = ({ const handleAddCourse = (item: CourseModel) => { if (!form) return; const currentList = form.getValues("courses") || []; + if (currentList.length > 7) return; // ensure max courses added is 8 if (currentList.find((c) => c.id === item.id)) return; // ensure uniqueness const newList = [...currentList, item]; console.log(newList); diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/CreateCustomSetting.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/CreateCustomSetting.tsx index aae0747c..2975c2e6 100644 --- a/course-matrix/frontend/src/pages/TimetableBuilder/CreateCustomSetting.tsx +++ b/course-matrix/frontend/src/pages/TimetableBuilder/CreateCustomSetting.tsx @@ -96,6 +96,16 @@ const CreateCustomSetting = ({ closeHandler(); }; + const form = useContext(FormContext); + + const isDaysOffRestrictionApplied = () => { + const val = form + ?.getValues("restrictions") + .some((r) => r.type === "Days Off"); + console.log(val); + return val; + }; + const getRestrictionType = (value: string) => { if ( value === "Restrict Before" || @@ -166,7 +176,10 @@ const CreateCustomSetting = ({ Restrict Entire Day - + Enforce Minimum Days Off Per Week diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/GeneratedCalendars.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/GeneratedCalendars.tsx new file mode 100644 index 00000000..a55d87e4 --- /dev/null +++ b/course-matrix/frontend/src/pages/TimetableBuilder/GeneratedCalendars.tsx @@ -0,0 +1,293 @@ +import { Spinner } from "@/components/ui/spinner"; +import { TimetableGenerateResponseModel } from "@/models/models"; +import { ScheduleXCalendar } from "@schedule-x/react"; +import { + createCalendar, + createViewDay, + createViewMonthAgenda, + createViewMonthGrid, + createViewWeek, + viewWeek, +} from "@schedule-x/calendar"; +import { Event } from "@/utils/type-utils"; +import { + getSemesterStartAndEndDates, + getSemesterStartAndEndDatesPlusOneWeek, +} from "@/utils/semester-utils"; +import { courseEventStyles } from "@/constants/calendarConstants"; +import { createEventModalPlugin } from "@schedule-x/event-modal"; +import React, { useRef, useState } from "react"; +import { useGetOfferingEventsQuery } from "@/api/offeringsApiSlice"; +import { Button } from "@/components/ui/button"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationPrevious, + PaginationLink, + PaginationEllipsis, + PaginationNext, +} from "@/components/ui/pagination"; +import { + DialogHeader, + DialogFooter, + DialogTrigger, + Dialog, + DialogContent, + DialogTitle, + DialogDescription, + DialogClose, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@radix-ui/react-label"; +import { useNavigate } from "react-router-dom"; +import { useCreateTimetableMutation } from "@/api/timetableApiSlice"; +import { useCreateEventMutation } from "@/api/eventsApiSlice"; +import { useCreateRestrictionMutation } from "@/api/restrictionsApiSlice"; +import { TimetableForm } from "@/models/timetable-form"; + +interface GeneratedCalendarsProps { + setShowLoadingPage: (_: boolean) => void; + setIsGeneratingTimetables: (_: boolean) => void; + semester: string; + generatedTimetables?: TimetableGenerateResponseModel; + restrictions: TimetableForm["restrictions"]; +} + +function parseEvent(id: number, event: Event, calendarId: string) { + return { + id: id, + title: event.event_name, + start: + event.event_date + + " " + + event.event_start.split(":")[0] + + ":" + + event.event_start.split(":")[1], + end: + event.event_date + + " " + + event.event_end.split(":")[0] + + ":" + + event.event_end.split(":")[1], + calendarId: calendarId, + }; +} + +export const GeneratedCalendars = React.memo( + ({ + setShowLoadingPage, + setIsGeneratingTimetables, + semester, + generatedTimetables, + restrictions, + }) => { + if (!generatedTimetables) { + return ( +
+ Generating... +
+ ); + } + const timetableTitleRef = useRef(null); + const navigate = useNavigate(); + + // Current timetable we are viewing + const [currentTimetableIndex, setCurrentTimetableIndex] = useState(0); + + const currentTimetableOfferings = + generatedTimetables.schedules[currentTimetableIndex]; + const numberOfTimetables = generatedTimetables.schedules.length; + + const semesterStartDate = getSemesterStartAndEndDates(semester).start; + const { start: semesterStartDatePlusOneWeek, end: semesterEndDate } = + getSemesterStartAndEndDatesPlusOneWeek(semester); + + const { data: courseEventsData, isLoading } = useGetOfferingEventsQuery({ + offering_ids: currentTimetableOfferings + .map((offering) => offering.id) + .join(","), + semester_start_date: semesterStartDate, + semester_end_date: semesterEndDate, + }) as { data: Event[]; isLoading: boolean }; + + const [createTimetable] = useCreateTimetableMutation(); + const [createEvent] = useCreateEventMutation(); + const [createRestriction] = useCreateRestrictionMutation(); + + const courseEventsParsed = + courseEventsData?.map((event, index) => + parseEvent(index + 1, event, "courseEvent"), + ) ?? []; + + const handleCreate = async () => { + setShowLoadingPage(true); + const timetableTitle = timetableTitleRef.current?.value ?? ""; + // Create timetable + const { data, error } = await createTimetable({ + timetable_title: timetableTitle, + semester: semester, + }); + if (error) { + console.error(error); + return; + } + // Create course events for the newly created timetable + const newTimetableId = data?.id; + for (const offering of currentTimetableOfferings) { + const offeringId = offering.id; + const { error: offeringError } = await createEvent({ + calendar_id: newTimetableId, + offering_id: offeringId, + semester_start_date: semesterStartDate, + semester_end_date: semesterEndDate, + }); + if (offeringError) { + console.error(offeringError); + } + } + // Create restrictions for the newly created timetable + for (const restriction of restrictions) { + const restrictionObject = { + calendar_id: newTimetableId, + type: restriction.type, + days: restriction.days, + start_time: restriction.startTime, + end_time: restriction.endTime, + disabled: restriction.disabled, + num_days: restriction.numDays, + }; + const { error: restrictionError } = + await createRestriction(restrictionObject); + if (restrictionError) { + console.error(restrictionError); + } + } + // Redirect to the home page to see the newly created timetable + navigate("/home"); + }; + + const calendar = createCalendar({ + views: [ + createViewDay(), + createViewWeek(), + createViewMonthGrid(), + createViewMonthAgenda(), + ], + selectedDate: semesterStartDatePlusOneWeek, + minDate: semesterStartDate, + maxDate: semesterEndDate, + defaultView: viewWeek.name, + events: [...courseEventsParsed], + calendars: { + courseEvent: courseEventStyles, + }, + plugins: [createEventModalPlugin()], + weekOptions: { + gridHeight: 600, + }, + dayBoundaries: { + start: "06:00", + end: "21:00", + }, + isResponsive: false, + }); + + return ( + <> +
+
+

+ Generated Timetables ( + {generatedTimetables ? generatedTimetables?.schedules?.length : 0} + ) +

+
+ + + + + + + + Timetable Creation + + What would you like to name your timetable? + + + + + + + + + + + + + + +
+
+ {isLoading ? ( + + ) : ( + + )} +
+ + + + + setCurrentTimetableIndex((prev) => Math.max(0, prev - 1)) + } + disabled={currentTimetableIndex === 0} + /> + + {Array.from({ length: numberOfTimetables }).map((_, index) => ( + + setCurrentTimetableIndex(index)} + isActive={currentTimetableIndex === index} + > + {index + 1} + + + ))} + + + + setCurrentTimetableIndex((prev) => + Math.min(numberOfTimetables - 1, prev + 1), + ) + } + disabled={currentTimetableIndex === numberOfTimetables - 1} + /> + + + +
+
+ + ); + }, +); diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/OfferingContent.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/OfferingContent.tsx index 30ac9a0d..dc6841d2 100644 --- a/course-matrix/frontend/src/pages/TimetableBuilder/OfferingContent.tsx +++ b/course-matrix/frontend/src/pages/TimetableBuilder/OfferingContent.tsx @@ -72,22 +72,25 @@ const OfferingContent = ({ item, semester }: OfferingContentProps) => { - {(data as OfferingModel[]).map((offering, index) => ( - - - {offering.meeting_section} - - {offering.day ?? ""} - {`${offering.start ?? ""} - ${offering.end ?? ""}`} - {offering.location ?? ""} - {offering.current ?? ""} - {offering.max ?? ""} - {offering.is_waitlisted ? "N" : "Y"} - {offering.delivery_mode ?? ""} - {offering.instructor ?? ""} - {offering.notes ?? ""} - - ))} + {(data as OfferingModel[]).map((offering, index) => { + console.log("offering", offering); + return ( + + + {offering.meeting_section} + + {offering.day ?? ""} + {`${offering.start ?? ""} - ${offering.end ?? ""}`} + {offering.location ?? ""} + {offering.current ?? ""} + {offering.max ?? ""} + {offering.is_waitlisted ? "N" : "Y"} + {offering.delivery_mode ?? ""} + {offering.instructor ?? ""} + {offering.notes ?? ""} + + ); + })}
diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/OfferingInfo.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/OfferingInfo.tsx new file mode 100644 index 00000000..1b42a9f0 --- /dev/null +++ b/course-matrix/frontend/src/pages/TimetableBuilder/OfferingInfo.tsx @@ -0,0 +1,407 @@ +import { useGetOfferingsQuery } from "@/api/offeringsApiSlice"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { CourseModel, OfferingModel } from "@/models/models"; +import { useEffect, useMemo, useState } from "react"; +import { Edit } from "lucide-react"; +import { UseFormReturn } from "react-hook-form"; +import { TimetableFormSchema } from "@/models/timetable-form"; +import { z } from "zod"; + +interface OfferingInfoProps { + course: Pick; + semester: string; + form: UseFormReturn>; +} + +const OfferingInfo = ({ course, semester, form }: OfferingInfoProps) => { + const { data: offeringsData, isLoading } = useGetOfferingsQuery({ + course_code: course.code, + semester: semester, + }); + + const offeringIds = form.watch("offeringIds") ?? []; + + const lectures = offeringsData + ?.filter((offering: OfferingModel) => + offering.meeting_section.startsWith("LEC"), + ) + .sort((a: OfferingModel, b: OfferingModel) => + a.meeting_section < b.meeting_section ? -1 : 1, + ); + const tutorials = offeringsData + ?.filter((offering: OfferingModel) => + offering.meeting_section.startsWith("TUT"), + ) + .sort((a: OfferingModel, b: OfferingModel) => + a.meeting_section < b.meeting_section ? -1 : 1, + ); + const practicals = offeringsData + ?.filter((offering: OfferingModel) => + offering.meeting_section.startsWith("PRA"), + ) + .sort((a: OfferingModel, b: OfferingModel) => + a.meeting_section < b.meeting_section ? -1 : 1, + ); + + const lectureSections: string[] = [ + ...new Set( + lectures?.map((lec: { meeting_section: string }) => lec.meeting_section), + ), + ] as string[]; + const tutorialSections = [ + ...new Set( + tutorials?.map((tut: { meeting_section: string }) => tut.meeting_section), + ), + ] as string[]; + const practicalSections = [ + ...new Set( + practicals?.map( + (pra: { meeting_section: string }) => pra.meeting_section, + ), + ), + ] as string[]; + const sections = [ + ...new Set([...lectureSections, ...tutorialSections, ...practicalSections]), + ]; + const sectionsToOfferingIdsMap = new Map(); + sections.forEach((section: string) => { + const lectureOfferingIds = lectures + ?.filter( + (lec: { meeting_section: string }) => lec.meeting_section === section, + ) + .map((lec: { id: number }) => lec.id); + const tutorialOfferingIds = tutorials + ?.filter( + (tut: { meeting_section: string }) => tut.meeting_section === section, + ) + .map((tut: { id: number }) => tut.id); + const practicalOfferingIds = practicals + ?.filter( + (pra: { meeting_section: string }) => pra.meeting_section === section, + ) + .map((pra: { id: number }) => pra.id); + const offeringIds = [ + ...lectureOfferingIds, + ...tutorialOfferingIds, + ...practicalOfferingIds, + ]; + sectionsToOfferingIdsMap.set(section, offeringIds); + }); + + const selectedOfferingIds = offeringsData?.filter((offering: OfferingModel) => + offeringIds.includes(offering.id), + ); + + const initialSelectedLecture = useMemo( + () => + selectedOfferingIds?.filter((offering: { meeting_section: string }) => + offering.meeting_section.startsWith("LEC"), + ) ?? [], + [selectedOfferingIds], + ); + const initialSelectedTutorial = useMemo( + () => + selectedOfferingIds?.filter((offering: { meeting_section: string }) => + offering.meeting_section.startsWith("TUT"), + ) ?? [], + [selectedOfferingIds], + ); + const initialSelectedPractical = useMemo( + () => + selectedOfferingIds?.filter((offering: { meeting_section: string }) => + offering.meeting_section.startsWith("PRA"), + ) ?? [], + [selectedOfferingIds], + ); + + // Load initial selected lecture, tutorial, practical + const [loadedInitialSelectedLecture, setLoadedInitialSelectedLecture] = + useState(false); + const [loadedInitialSelectedTutorial, setLoadedInitialSelectedTutorial] = + useState(false); + const [loadedInitialSelectedPractical, setLoadedInitialSelectedPractical] = + useState(false); + useEffect(() => { + if (!loadedInitialSelectedLecture && initialSelectedLecture.length > 0) { + setSelectedLecture(initialSelectedLecture); + setLoadedInitialSelectedLecture(true); + } + }, [initialSelectedLecture, loadedInitialSelectedLecture]); + useEffect(() => { + if (!loadedInitialSelectedTutorial && initialSelectedTutorial.length > 0) { + setSelectedTutorial(initialSelectedTutorial); + setLoadedInitialSelectedTutorial(true); + } + }, [initialSelectedTutorial, loadedInitialSelectedTutorial]); + useEffect(() => { + if ( + !loadedInitialSelectedPractical && + initialSelectedPractical.length > 0 + ) { + setSelectedPractical(initialSelectedPractical); + setLoadedInitialSelectedPractical(true); + } + }, [initialSelectedPractical, loadedInitialSelectedPractical]); + + const [selectedLecture, setSelectedLecture] = useState([]); + const [selectedTutorial, setSelectedTutorial] = useState([]); + const [selectedPractical, setSelectedPractical] = useState( + [], + ); + + const handleSelect = ( + lecture: OfferingModel[], + tutorial: OfferingModel[], + practical: OfferingModel[], + ) => { + if (lecture) { + setSelectedLecture(lecture); + setIsEditingLectureSection(false); + } + if (tutorial) { + setSelectedTutorial(tutorial); + setIsEditingTutorialSection(false); + } + if (practical) { + setSelectedPractical(practical); + setIsEditingPracticalSection(false); + } + const oldOfferingIds: number[] = [ + ...selectedLecture, + ...selectedTutorial, + ...selectedPractical, + ].map((offering: OfferingModel) => offering.id); + const newOfferingIds: number[] = [ + ...lecture, + ...tutorial, + ...practical, + ].map((offering: OfferingModel) => offering.id); + + const filteredOfferingIds = offeringIds.filter( + (id: number) => !oldOfferingIds.includes(id), + ); + const resultOfferingIds = [...filteredOfferingIds, ...newOfferingIds]; + form.setValue("offeringIds", resultOfferingIds); + }; + + const [isEditingLectureSection, setIsEditingLectureSection] = useState(false); + const [isEditingTutorialSection, setIsEditingTutorialSection] = + useState(false); + const [isEditingPracticalSection, setIsEditingPracticalSection] = + useState(false); + + return ( + <> + {isLoading ? ( +

Loading...

+ ) : ( +
+ {lectures?.length > 0 && + (!isEditingLectureSection ? ( +
+ {selectedLecture[0]?.meeting_section ?? "No LEC selected"} + setIsEditingLectureSection(true)} + /> +
+ ) : ( +
+ +
+ ))} + {tutorials?.length > 0 && + (!isEditingTutorialSection ? ( +
+ {selectedTutorial[0]?.meeting_section ?? "No TUT selected"} + setIsEditingTutorialSection(true)} + /> +
+ ) : ( +
+ +
+ ))} + {practicals?.length > 0 && + (!isEditingPracticalSection ? ( +
+ {selectedPractical[0]?.meeting_section ?? "No PRA selected"} + setIsEditingPracticalSection(true)} + /> +
+ ) : ( +
+ +
+ ))} +
+ )} + + ); +}; + +export default OfferingInfo; diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/TimetableBuilder.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/TimetableBuilder.tsx index 33a3ab97..b2ee82d7 100644 --- a/course-matrix/frontend/src/pages/TimetableBuilder/TimetableBuilder.tsx +++ b/course-matrix/frontend/src/pages/TimetableBuilder/TimetableBuilder.tsx @@ -13,7 +13,7 @@ import { TimetableFormSchema, baseTimetableForm, } from "@/models/timetable-form"; -import { Edit, X } from "lucide-react"; +import { WandSparkles, X } from "lucide-react"; import { createContext, useEffect, useState } from "react"; import { useForm, UseFormReturn } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -21,26 +21,38 @@ import { z } from "zod"; import { Select, SelectContent, - SelectGroup, SelectItem, - SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; import CourseSearch from "@/pages/TimetableBuilder/CourseSearch"; -import { mockSearchData } from "./mockSearchData"; -import { CourseModel } from "@/models/models"; import CreateCustomSetting from "./CreateCustomSetting"; import { formatTime } from "@/utils/format-date-time"; import { FilterForm, FilterFormSchema } from "@/models/filter-form"; import { useGetCoursesQuery } from "@/api/coursesApiSlice"; -import { useGetTimetablesQuery } from "@/api/timetableApiSlice"; +import { + useGenerateTimetableMutation, + useGetTimetablesQuery, +} from "@/api/timetableApiSlice"; import { useGetEventsQuery } from "@/api/eventsApiSlice"; +import { useGetOfferingsQuery } from "@/api/offeringsApiSlice"; +import { useGetRestrictionsQuery } from "@/api/restrictionsApiSlice"; import { useDebounceValue } from "@/utils/useDebounce"; import SearchFilters from "./SearchFilters"; import Calendar from "./Calendar"; -import { Timetable } from "@/utils/type-utils"; -import { useSearchParams } from "react-router-dom"; +import { + Offering, + TimetableEvents, + Timetable, + Restriction, +} from "@/utils/type-utils"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import OfferingInfo from "./OfferingInfo"; +import { Checkbox } from "@/components/ui/checkbox"; +import { CourseModel, TimetableGenerateResponseModel } from "@/models/models"; +import LoadingPage from "@/pages/Loading/LoadingPage"; +import { GeneratedCalendars } from "./GeneratedCalendars"; +import { Spinner } from "@/components/ui/spinner"; type FormContextType = UseFormReturn>; export const FormContext = createContext(null); @@ -92,19 +104,30 @@ const TimetableBuilder = () => { resolver: zodResolver(FilterFormSchema), }); - const [queryParams, setQueryParams] = useSearchParams(); + const [queryParams] = useSearchParams(); const isEditingTimetable = queryParams.has("edit"); - const editingTimetableId = queryParams.get("edit"); + const timetableId = parseInt(queryParams.get("edit") || "0"); + + const [showLoadingPage, setShowLoadingPage] = useState(isEditingTimetable); const selectedCourses = form.watch("courses") || []; const enabledRestrictions = form.watch("restrictions") || []; const searchQuery = form.watch("search"); const debouncedSearchQuery = useDebounceValue(searchQuery, 250); + const selectedSemester = form.watch("semester"); + const offeringIds = form.watch("offeringIds"); + + // console.log("Selected courses", selectedCourses); + // console.log("Enabled restrictions", enabledRestrictions); const [isCustomSettingsOpen, setIsCustomSettingsOpen] = useState(false); const [filters, setFilters] = useState(null); const [showFilters, setShowFilters] = useState(false); - const [timetableId, setTimetableId] = useState(-1); + const [isChoosingSectionsManually, setIsChoosingSectionsManually] = + useState(isEditingTimetable); + const [isGeneratingTimetables, setIsGeneratingTimetables] = useState(false); + const [generatedTimetables, setGeneratedTimetables] = + useState(); const noSearchAndFilter = () => { return !searchQuery && !filters; @@ -112,26 +135,144 @@ const TimetableBuilder = () => { // limit search number if no search query or filters for performance purposes. // Otherwise, limit is 10k, which effectively gets all results. - const { data, isLoading, error, refetch } = useGetCoursesQuery({ + const { + data: coursesData, + isLoading, + refetch, + } = useGetCoursesQuery({ limit: noSearchAndFilter() ? SEARCH_LIMIT : 10000, search: debouncedSearchQuery || undefined, - semester: form.getValues("semester"), + semester: selectedSemester, ...filters, }); - const { data: eventsData, isLoading: eventsLoading } = useGetEventsQuery( - timetableId, - ) as { - data: { courseEvents: unknown[]; userEvents: unknown[] }; - isLoading: boolean; + const [generateTimetable, { isLoading: isGenerateLoading }] = + useGenerateTimetableMutation(); + + const { data: allCoursesData } = useGetCoursesQuery({ + limit: 10000, + }) as { + data: CourseModel[]; + }; + + const { data: offeringData } = useGetOfferingsQuery({}) as { + data: Offering[]; + }; + const offerings = offeringData || []; + const offeringIdToCourseIdMap = offerings.reduce( + (acc, offering) => { + acc[offering.id] = offering.course_id; + return acc; + }, + {} as Record, + ); + + const { data: timetableEventsData } = useGetEventsQuery(timetableId, { + skip: !isEditingTimetable, + }) as { + data: TimetableEvents; + }; + + const [loadedSemester, setLoadedSemester] = useState(false); + const [loadedCourses, setLoadedCourses] = useState(false); + const [loadedOfferingIds, setLoadedOfferingIds] = useState(false); + const [loadedRestrictions, setLoadedRestrictions] = useState(false); + + const { data: restrictionsData } = useGetRestrictionsQuery(timetableId, { + skip: !isEditingTimetable, + }) as { + data: Restriction[]; }; - const courseEvents = eventsData?.courseEvents || []; - const userEvents = eventsData?.userEvents || []; const { data: timetablesData } = useGetTimetablesQuery() as { data: Timetable[]; }; const timetables = timetablesData || []; + const currentTimetableTitle = timetables.find( + (timetable) => timetable.id === timetableId, + )?.timetable_title; + const currentTimetableSemester = timetables.find( + (timetable) => timetable.id === timetableId, + )?.semester; + + // Set the state variable courseEvents, and set the form values for 'offeringIds', 'courses', and 'restrictions' + useEffect(() => { + if (!loadedSemester && currentTimetableSemester) { + form.setValue("semester", currentTimetableSemester); + setLoadedSemester(true); + } + if ( + timetableEventsData && + coursesData && + allCoursesData && + offeringIdToCourseIdMap && + restrictionsData && + !loadedCourses && + !loadedOfferingIds && + !loadedRestrictions + ) { + const existingOfferingIds = [ + ...new Set( + timetableEventsData?.courseEvents.map((event) => event.offering_id), + ), + ].sort((a, b) => a - b); + form.setValue("offeringIds", existingOfferingIds); + setLoadedOfferingIds(true); + + const existingCourseIds = [ + ...new Set( + existingOfferingIds.map( + (offeringId) => offeringIdToCourseIdMap[offeringId], + ), + ), + ]; + const existingCourses = allCoursesData.filter((course: CourseModel) => + existingCourseIds.includes(course.id), + ); + form.setValue("courses", existingCourses); + setLoadedCourses(true); + // Parse restrictions data (For startTime and endTime, we just care about the time, so we use the random date of 2025-01-01 so that the date can be parsed correctly) + // We also add 1 hour (i.e. 60 * 60 * 1000 milliseconds) to the time to account for the timezone difference between the server and the client + const parsedRestrictions = restrictionsData.map( + (restriction: Restriction) => + ({ + days: JSON.parse(restriction?.days) as string[], + disabled: restriction?.disabled, + startTime: restriction?.start_time + ? new Date( + new Date( + `2025-01-01T${restriction.start_time}.00Z`, + ).getTime() + + 60 * 60 * 1000, + ) + : undefined, + endTime: restriction?.end_time + ? new Date( + new Date(`2025-01-01T${restriction.end_time}.00Z`).getTime() + + 60 * 60 * 1000, + ) + : undefined, + type: restriction?.type, + numDays: restriction?.num_days, + }) as z.infer, + ); + form.setValue("restrictions", parsedRestrictions); + setLoadedRestrictions(true); + setShowLoadingPage(false); + } + }, [ + timetableEventsData, + coursesData, + restrictionsData, + loadedCourses, + loadedOfferingIds, + loadedRestrictions, + form, + allCoursesData, + offeringIdToCourseIdMap, + loadedSemester, + currentTimetableSemester, + ]); useEffect(() => { if (searchQuery) { @@ -139,9 +280,18 @@ const TimetableBuilder = () => { } }, [debouncedSearchQuery]); - const createTimetable = (values: z.infer) => { - console.log(values); - // TODO Send request to /api/timetable/create + const handleGenerate = async ( + values: z.infer, + ) => { + console.log(">> Timetable options:", values); + try { + const res = await generateTimetable(values); + const data: TimetableGenerateResponseModel = res.data; + setIsGeneratingTimetables(true); + setGeneratedTimetables(data); + } catch (error) { + console.error("Error generating timetables: ", error); + } }; const handleReset = () => { @@ -177,164 +327,210 @@ const TimetableBuilder = () => { console.log("Apply filters", values); }; - return ( - <> -
-
-
-
-

- {isEditingTimetable ? "Edit Timetable" : "New Timetable"} -

- {isEditingTimetable && ( - - )} -
-
- - + return showLoadingPage ? ( + + ) : ( +
+
+
+
+
+
+

+ {isEditingTimetable ? "Edit Timetable" : "New Timetable"} +

+ {isEditingTimetable && ( +

+ Editing: {currentTimetableTitle} +

+ )} +
+
+ + +
+
-
-
+
+ + +
+ {/* ( + + Semester + + + + + + )} + /> */} -
-
- - - -
- ( - - Semester - - - - - - )} - /> - - ( - - - Pick a few courses you'd like to take - - - { - field.onChange(value); - }} - data={data} // TODO: Replace with variable data - isLoading={isLoading} - showFilter={() => setShowFilters(true)} - /> - - - - )} - /> -
-
-

- Selected courses: {selectedCourses.length} + ( + + + Pick a few courses you'd like to take + + + { + field.onChange(value); + }} + data={coursesData} // TODO: Replace with variable data + isLoading={isLoading} + showFilter={() => setShowFilters(true)} + /> + + + + )} + /> +

+
+
+

+ Selected courses: {selectedCourses.length} (Max 8)

-
- {selectedCourses.map((course, index) => ( -
+ + setIsChoosingSectionsManually(checked === true) + } + /> +
- ))} -
+ Choose meeting sections manually? + +
+ )}
- -
-
-

Custom Settings

-

- Add additional restrictions to your timetable to - personalize it to your needs. +

+ {!isEditingTimetable || + (isEditingTimetable && + loadedCourses && + loadedOfferingIds && + selectedCourses) ? ( + selectedCourses.map((course, index) => { + return ( +
+
+

+ {course.code}: {course.name} +

+
+ { + handleRemoveCourse(course); + const newOfferingsIds = form + .getValues("offeringIds") + .filter( + (offeringId: number) => + offeringIdToCourseIdMap[ + offeringId + ] !== course.id, + ); + form.setValue( + "offeringIds", + newOfferingsIds, + ); + }} + /> +
+
+ {isChoosingSectionsManually && ( + + )} +
+ ); + }) + ) : ( +

+ Loading courses...

-
- + )}
+
-
-

- Enabled Restrictions: {enabledRestrictions.length} +

+
+

Custom Settings

+

+ Add additional restrictions to your timetable to + personalize it to your needs.

-
- {enabledRestrictions.map((restric, index) => ( +
+ +
+ +
+ ( + +

+ Enabled Restrictions: {enabledRestrictions.length} +

+ +
+ )} + /> +
+ {enabledRestrictions && + enabledRestrictions.map((restric, index) => (
{ />
))} -
+
- - - - -
-
- -
- {isCustomSettingsOpen && ( - setIsCustomSettingsOpen(false)} - /> - )} + {!isChoosingSectionsManually && ( +
+ {isGenerateLoading ? ( + + ) : ( + + )} +
+ )} + - {showFilters && ( - setShowFilters(false)} - resetHandler={() => { - setFilters(null); - setShowFilters(false); - console.log("reset filters"); - }} - filterForm={filterForm} + {isCustomSettingsOpen && ( + setIsCustomSettingsOpen(false)} + /> + )} +
+ +
+
+ {isGeneratingTimetables ? ( + + ) : ( + )}
+ + {showFilters && ( + setShowFilters(false)} + resetHandler={() => { + setFilters(null); + setShowFilters(false); + console.log("reset filters"); + }} + filterForm={filterForm} + /> + )}
- +
); }; diff --git a/course-matrix/frontend/src/utils/semester-utils.ts b/course-matrix/frontend/src/utils/semester-utils.ts new file mode 100644 index 00000000..f4821b9f --- /dev/null +++ b/course-matrix/frontend/src/utils/semester-utils.ts @@ -0,0 +1,34 @@ +export function getSemesterStartAndEndDates(semester: string) { + return { + start: + semester === "Summer 2025" + ? "2025-05-02" + : semester === "Fall 2025" + ? "2025-09-02" + : "2026-01-05", + end: + semester === "Summer 2025" + ? "2025-08-07" + : semester === "Fall 2025" + ? "2025-12-02" + : "2026-04-06", + }; +} + +export function getSemesterStartAndEndDatesPlusOneWeek(semester: string) { + // Note: We make the start date 1 week after actual in order to not trunacte first week of calendar + return { + start: + semester === "Summer 2025" + ? "2025-05-09" + : semester === "Fall 2025" + ? "2025-09-09" + : "2026-01-12", + end: + semester === "Summer 2025" + ? "2025-08-07" + : semester === "Fall 2025" + ? "2025-12-02" + : "2026-04-06", + }; +} diff --git a/course-matrix/frontend/src/utils/type-utils.ts b/course-matrix/frontend/src/utils/type-utils.ts index a46a8151..707d270e 100644 --- a/course-matrix/frontend/src/utils/type-utils.ts +++ b/course-matrix/frontend/src/utils/type-utils.ts @@ -3,6 +3,40 @@ export type MakeOptionalExcept = Partial> & Pick; +export type Event = { + id: number; + event_name: string; + event_date: string; + event_start: string; + event_end: string; + offering_id: number; +}; + +export type Offering = { + id: number; + created_at: Date; + updated_at: Date; + course_id: number; + meeting_section: string; + offering: string; + day: string; + start: string; + end: string; + location: string; + current: number; + max: number; + is_waitlisted: boolean; + delivery_mode: string; + instructor: string; + notes: string; + code: string; +}; + +export type TimetableEvents = { + courseEvents: Event[]; + userEvents: Event[]; +}; + export type Timetable = { id: number; created_at: Date; @@ -11,3 +45,17 @@ export type Timetable = { timetable_title: string; user_id: string; }; + +export type Restriction = { + id: number; + user_id: string; + created_at: Date; + updated_at: Date; + type: string; + days: string; + start_time: string; + end_time: string; + disabled: boolean; + num_days: number; + calendar_id: number; +}; diff --git a/course-matrix/frontend/tsconfig.app.json b/course-matrix/frontend/tsconfig.app.json index c12cc5e3..2ed01044 100644 --- a/course-matrix/frontend/tsconfig.app.json +++ b/course-matrix/frontend/tsconfig.app.json @@ -27,5 +27,5 @@ "@/*": ["./src/*"] } }, - "include": ["src", "tests/setupTests.ts"] + "include": ["src"] } diff --git a/course-matrix/frontend/tsconfig.json b/course-matrix/frontend/tsconfig.json index ae9f04dc..586ee7ac 100644 --- a/course-matrix/frontend/tsconfig.json +++ b/course-matrix/frontend/tsconfig.json @@ -1,12 +1,15 @@ { + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], "compilerOptions": { - "target": "es6", - "module": "commonjs", - "outDir": "./build", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "tests"] + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "jsx": "react-jsx", + "types": ["@testing-library/jest-dom"] + } } diff --git a/course-matrix/frontend/tsconfig.test.json b/course-matrix/frontend/tsconfig.test.json index 05992782..dde8fcf3 100644 --- a/course-matrix/frontend/tsconfig.test.json +++ b/course-matrix/frontend/tsconfig.test.json @@ -6,6 +6,10 @@ "esModuleInterop": true, "skipLibCheck": true, "strict": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, "jsx": "react-jsx", "types": ["jest", "@testing-library/jest-dom"] }, diff --git a/doc/sprint3/NFR.pdf b/doc/sprint3/NFR.pdf new file mode 100644 index 00000000..34af274b Binary files /dev/null and b/doc/sprint3/NFR.pdf differ diff --git a/doc/sprint3/Performance Testing Report.pdf b/doc/sprint3/Performance Testing Report.pdf new file mode 100644 index 00000000..56f69c56 Binary files /dev/null and b/doc/sprint3/Performance Testing Report.pdf differ diff --git a/doc/sprint3/RPM.md b/doc/sprint3/RPM.md new file mode 100644 index 00000000..a7f794cf --- /dev/null +++ b/doc/sprint3/RPM.md @@ -0,0 +1,101 @@ +# Release Plan + +## Release Name: Course_Matrix_V1.3.0 + +## 1. Release Objectives for Sprint 3 + +### 1.1 Goals + +- Develop enhanced scheduling features: + + - Ability to insert, update, and delete timetables and events entries (course events for offered lectures, tutorials, etc and user events for users’ personal events) . + - Automatic and customized timetable generation based on user constraints. + - Custom colour customization for timetable entries. + - Favourite timetable functionality. + - Timetable Export/Share + - Timetable compare + - Email Notifications + +- Build upon AI-powered assistant: + + - Timetable Generation via AI. + - AI Chatbot refinement. + +- Project Deployment: + + - Project has a usable dockerfile + - Project is running on a VM instance + - Project on update is automatically tested + - Project auto redeploys on update + +- Integration Test Cases: + - Project functions will have integration unit tests written for them + - Project functions will pass integration unit tests written for them + +### 1.2 Metrics for Measurement + +- **Timetable Management** + + - Users can create, modify, and delete timetables and event entries without errors. + - Timetable generation respects user constraints (e.g., time preferences, course exclusions). + - Custom colour selections persist across sessions. + - Favourite timetables are stored and retrievable. + - User generated timetables can be exported + - Stored timetables can be compared to one another if the user has access to them. + - Email notifications are sent to user based on upcoming timetable events + +- **AI Assistant Features** + + - AI can be queried to generate a timetable for the user based on a list of courses and a list of time restrictions + +- **Deployment Features** + - Project when deployed is deployed using a docker image + - Project when deployed is accessible online on a virtual Machine + - Project when updated is automatically unit tested + - Project when updated and passing unit tests is auto-redeployed + +– **Integration / Unit Testing** + +- Project functions are unit tested so that their behaviour is clear and potential bugs are caught +- Project functions passes unit tests so bug free functionality is ensured + +## 2. Release Scope + +- **Timetable Management** + + - Add, update, and delete timetables and event entries. + - Generate an optimized schedule based on user preferences. + - Customize timetable entry colours. + - Favourite timetables for quick access. + - Export/Share timetables + +- **AI Assistant** + + - AI-Powered timetable generation + +- **Deployment** + - Project runs on a docker image + - Project is accessible on the web while running on a VM instance + - Project on update is automatically tested + - Project auto redeploys on update that passes tests + +### 2.2 Excluded Features + +### 2.3 Bug Fixes + +- Fix module importing linting errors +- Fix Restriction creation bugs +- Fix text highlight on edit username + +### 2.4 Non-Functional Requirements + +- **Performance** + - AI output refinement. The AI chatbot responses need to be more reliable and cover more cases (e.g. querying for the year level of courses). + - Timetable generator algorithm returns suggested timetables based on users' input within 5-10 seconds for a standard course load of 5 courses. + +### 2.5 Dependencies and Limitations + +- The AI assistant relies on querying an external vector database and open AI. Bothe of these are online resources so if they are down our feature will be down as well. +- The Timetable relies solely on the internal course database and installed dependencies. It does not fetch web-based content. +- Unit testing relies solely on internal functions and installed dependencies. It does not fetch any web-based content. +- The deployment relies on fetching the latest project version from github and (if it passes all unit tests) deploys the latest version on our google cloud virtual machine instance. diff --git a/doc/sprint3/Scalability and Availability Concerns.pdf b/doc/sprint3/Scalability and Availability Concerns.pdf new file mode 100644 index 00000000..7ebc27a1 Binary files /dev/null and b/doc/sprint3/Scalability and Availability Concerns.pdf differ diff --git a/doc/sprint3/Security Measures & Testing.pdf b/doc/sprint3/Security Measures & Testing.pdf new file mode 100644 index 00000000..d21ba7fb Binary files /dev/null and b/doc/sprint3/Security Measures & Testing.pdf differ diff --git a/doc/sprint3/System Design.pdf b/doc/sprint3/System Design.pdf new file mode 100644 index 00000000..708f12d4 Binary files /dev/null and b/doc/sprint3/System Design.pdf differ diff --git a/doc/sprint3/burndown.pdf b/doc/sprint3/burndown.pdf new file mode 100644 index 00000000..9d45ffd7 Binary files /dev/null and b/doc/sprint3/burndown.pdf differ diff --git a/doc/sprint3/images/Blocked_ticket.png b/doc/sprint3/images/Blocked_ticket.png new file mode 100644 index 00000000..a74939af Binary files /dev/null and b/doc/sprint3/images/Blocked_ticket.png differ diff --git a/doc/sprint3/images/Blocking_tickets.png b/doc/sprint3/images/Blocking_tickets.png new file mode 100644 index 00000000..cc7ce174 Binary files /dev/null and b/doc/sprint3/images/Blocking_tickets.png differ diff --git a/doc/sprint3/images/Burndown.png b/doc/sprint3/images/Burndown.png new file mode 100644 index 00000000..7665826d Binary files /dev/null and b/doc/sprint3/images/Burndown.png differ diff --git a/doc/sprint3/images/JIRA_Backlog.png b/doc/sprint3/images/JIRA_Backlog.png new file mode 100644 index 00000000..2e4b70c2 Binary files /dev/null and b/doc/sprint3/images/JIRA_Backlog.png differ diff --git a/doc/sprint3/images/Ticket_Description_and_Child_Issue.png b/doc/sprint3/images/Ticket_Description_and_Child_Issue.png new file mode 100644 index 00000000..9f0fe4df Binary files /dev/null and b/doc/sprint3/images/Ticket_Description_and_Child_Issue.png differ diff --git a/doc/sprint3/images/Ticket_Detail.png b/doc/sprint3/images/Ticket_Detail.png new file mode 100644 index 00000000..e909a2d3 Binary files /dev/null and b/doc/sprint3/images/Ticket_Detail.png differ diff --git a/doc/sprint3/images/Ticket_Workflow.png b/doc/sprint3/images/Ticket_Workflow.png new file mode 100644 index 00000000..52bc8283 Binary files /dev/null and b/doc/sprint3/images/Ticket_Workflow.png differ diff --git a/doc/sprint3/images/account_schema.png b/doc/sprint3/images/account_schema.png new file mode 100644 index 00000000..a82bbbeb Binary files /dev/null and b/doc/sprint3/images/account_schema.png differ diff --git a/doc/sprint3/images/course_filter.png b/doc/sprint3/images/course_filter.png new file mode 100644 index 00000000..2898b1bc Binary files /dev/null and b/doc/sprint3/images/course_filter.png differ diff --git a/doc/sprint3/images/course_information.png b/doc/sprint3/images/course_information.png new file mode 100644 index 00000000..d871e1f7 Binary files /dev/null and b/doc/sprint3/images/course_information.png differ diff --git a/doc/sprint3/images/course_schema.png b/doc/sprint3/images/course_schema.png new file mode 100644 index 00000000..3fbed2f6 Binary files /dev/null and b/doc/sprint3/images/course_schema.png differ diff --git a/doc/sprint3/images/courses.png b/doc/sprint3/images/courses.png new file mode 100644 index 00000000..999815e0 Binary files /dev/null and b/doc/sprint3/images/courses.png differ diff --git a/doc/sprint3/images/image.png b/doc/sprint3/images/image.png new file mode 100644 index 00000000..5ec3c7ac Binary files /dev/null and b/doc/sprint3/images/image.png differ diff --git a/doc/sprint3/images/user_delete.png b/doc/sprint3/images/user_delete.png new file mode 100644 index 00000000..227b1aad Binary files /dev/null and b/doc/sprint3/images/user_delete.png differ diff --git a/doc/sprint3/images/user_edit.png b/doc/sprint3/images/user_edit.png new file mode 100644 index 00000000..78424513 Binary files /dev/null and b/doc/sprint3/images/user_edit.png differ diff --git a/doc/sprint3/iteration-03.plan.md b/doc/sprint3/iteration-03.plan.md new file mode 100644 index 00000000..f472f1ed --- /dev/null +++ b/doc/sprint3/iteration-03.plan.md @@ -0,0 +1,165 @@ +# Course Matrix + +## Iteration 03 + +- **Start date**: 03/8/2025 +- **End date**: 03/21/2025 + +## 1. Process + +### 1.1 Roles & Responsibilities + +#### Epic 1: Scheduler + +**Team Members:** Austin, Minh, and Thomas + +- Develop a calendar interface that allows users to retrieve, add, modify, and delete timetables and events both user custom event entries and predefined course entries. +- Develop an algorithm that optimally schedules events based on user preferences and constraints. +- Develop an algorithm that emails notification its users based on upcoming timetable events +- Develop custom color customization so that users can select what colors they want for different courses +- Develop a calendar interface that allows users to export and share their timetables with other users +- Develop an algorithm that allows users to compare two different timetables together + +#### Epic 2: AI Assistant + +**Team Members:** Kevin + +- Develop an AI-powered chat interface that takes a list of courses and a list of time constraints and generates a possible user timetable fulfilling the given requirements. +- Refine AI-powered chat interface so that querying for database information is more reliable and understandable + +- **Note taking & Documentation**: Minh, Masahisa, and Thomas + - Taking notes during stand-ups + - Create sprint 2 documentation: iteration-plan-02, RPM, and sprint-02 review + - Update System Design Document + +In addition to their specific roles, all team members have a collective responsibility to support and assist other team members to ensure that the goals (listed in section 2.1) are achieved and develop a working prototype. + +#### Epic 3: Deployment + +**Team Members:** Masahisa + +- Create a dockerfile such that our application can be run on a docker image with application setup being done automatically. +- Ensure that our application’s docker image runs on a VM instance accessible on the web. +- Ensure that our deployed project automatically redeploys when a new version of our application is pushed that passes all unit testing. + +#### Epic 4: Unit and integration Testing + +**Team Members:** Austin + +- Create unit / integration tests for our application functions (timetable, ai assistant, user stories, etc.) such that their functionality is clear and bug free. + +#### 1.2 Events + +- **Initial planning meeting**: + + - Location: Virtual + - Time: 3/8/2025 + - Purposes: + - Go over the sprint 3 requirements + - Define tasks and responsibilities for each team member + +- **Stand up meeting**: + + - Location: Online or in-person depending on members availability + - Time: Every Tuesday from 12 pm to 1 pm, Thursday and Sunday from 9 pm to 10 pm + - Purposes + - Progress updates: What has each member done since the last stand-up + - Determine the next steps and deadlines + - Discuss current blockers and possible solutions + +- **Final review meeting** + - Location: Online + - Time: 3/21/2025 + - Purposes: + - Review features and deliverables implemented in sprint 3 + - Determine changes that need to be made in sprint 4 + +#### 1.3 Artifacts + +- Our team will track the progress through Jira + + - Each user story will be uploaded to Jira as a ticket: + + - Categorized in the backlog by its epic, and execution sprint + + ![JIRA Backlog](./images/JIRA_Backlog.png) + + - Ticket details include: estimated story point to determine its priority, assignees + + ![Ticket Detail](./images/Ticket_Detail.png) + + - Tickets of large user stories will be broken down into smaller child issues + + ![Ticket Description and Child Issue](./images/Ticket_Description_and_Child_Issue.png) + + - Each ticket will also show: + + - Other tickets blocked by it + + ![Blocked tickets](./images/Blocked_ticket.png) + + - Other tickets blocking it + + ![Blocking tickets](./images/Blocking_tickets.png) + + - Additional tasks required during the development process will also be submitted as a task ticket on JIRA for tracking. + - Students or groups of students will be assigned first to epic and then to the specific user story. + - Workflow + + ![Ticket Workflow](./images/Ticket_Workflow.png) + +- Furthermore, we will implement a Burndown Chart, which will be included as `burndown.pdf` by the end of the sprint. This chart will also feature comments on the sprint's progress and a velocity comparison. +- Below is an example Burndown Chart from Sprint 0: + +![Burndown Chart](./images/Burndown.png) + +## 2. Product + +#### 2.1 Goal and Tasks + +**1. Develop product features for the product demo:** + +- _Epic 1: Scheduler_ + + - Timetable Basics/Insertion: [SCRUM-46](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-46) + - Entries Update/Delete: [SCRUM-47](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-47) + - Timetable Generation: [SCRUM-52](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-52) + - Entries Visualization: [SCRUM-50](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-50) + - Entries Colour Customization: [SCRUM-51](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-51) + - Timetable Favourite: [SCRUM-57](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-57) + - Timetable Export/Share: + [SCRUM-58](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-58) + +- _Epic 2: AI Assistant_ + + - Timetable Generation via AI: + [SCRUM-31](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-31) + - Refine AI outputs: + [SCRUM-132](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-132) + +- _Epic 3: Deployment_ + - Project Deployment: + [SCRUM-130](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-130) + +**3. Create sprint3 documentation:** +[SCRUM-127](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-127) + +#### 2.2 Artifacts + +### Pages/Features + +#### Scheduler + +- Timetable management: insertion, updating, and deletion of timetables and events (course entries and custom user entries). +- Algorithm for automated timetable generation. +- Hover effect: calendar highlights selected course entry. +- Custom colour selection for timetable entries. +- Option to favourite timetables for quick access. +- Option to share/export timetable to other users +- Option to compare two separate timetables +- Option to send email notifications on upcoming timetable events + +#### AI Assistant + +- Ability to generate an optimal timetable based on a list of courses and time restrictions +- Refined AI outputs so that all database information can be quickly obtained by the user diff --git a/doc/sprint3/schedule.pdf b/doc/sprint3/schedule.pdf new file mode 100644 index 00000000..39537b86 Binary files /dev/null and b/doc/sprint3/schedule.pdf differ diff --git a/doc/sprint3/sprint-03.review.md b/doc/sprint3/sprint-03.review.md new file mode 100644 index 00000000..d78e7727 --- /dev/null +++ b/doc/sprint3/sprint-03.review.md @@ -0,0 +1,95 @@ +# Course Matrix/term-group-project-c01w25-project-course-matrix + +## Iteration 03 - Review & Retrospect + +When: 3/20/2025 at 9:00 pm + +Where: Online + +## Process - Reflection + +In Sprint 3, our team focused on completing our most complex and difficult features needed to establish our software’s main functionality, which will be further tested and bug fixed in future sprints. +Our team successfully generated and implemented the following features: + +- Timetable operations via AI +- Timetable Basics/Insertion +- Timetable generation +- Entries Update/Delete +- Email notifications +- Unit/Integration testing our application + +By the end of sprint 3 we were able to have most of these features completed and for the features that weren’t completed either excellent progress had been made into completing them or they were deemed redundant. + +Our timetable is now fully functional. The user can create timetables with their chosen courses and their times will be displayed properly. The user can update timetables. All of the basic features are done. Additionally, the user can automatically generate a timetable that follows a list of courses and a list of time restrictions. + +Our AI assistant’s functionality has been expanded upon and refined. Now it can execute timetable functions when queried to by the user. For instance, the user can create, edit and delete a timetable from our AI chatbot. Additionally, various queries that could potentially break the chatbot have been patched. + +The setup for deploying our application has been completed, a version of our application is already deployed on google cloud with a CI/CD pipeline. Currently, if a new change is pushed to develop and it passes all tests the application is then deployed on our google cloud virtual machine. + +In conclusion, during sprint 3 excellent progress has been made in completing our software’s main features. + +#### Decisions that turned out well + +1. **Using Existing Developed Functions** + One decision that turned out well for us was when integrating our AI with our timetables. Normally, getting our AI trained well enough that it could properly interact with our timetable database would’ve taken far too long. By instead making our AI chatbot call existing functions (e.g. calling timetable generate to generate a timetable) we greatly simplified this process while also making it more reliable. + +2. **Google Cloud Virtual Machine** + When deploying our application with a proper CI/CD pipeline choosing google cloud was greatly beneficial to our group. Firstly, our group had experience working with google cloud thus we were able to have a robust environment setup to run our application well ahead of time. Due to this adding the CI/CD pipeline was all we had to do to get our application fully deployed. + +#### Decisions that did not turn out as well as we hoped + +1. **Timetable Database Bad Data** + During sprint 3 we had run into many technical difficulties with our timetable database. Due to a few key coding oversights made earlier in development we had to reset our database to fix corrupted entries filling it up. This cost our team valuable time. + +2. **Incorrect Estimation of Bug Fix Difficulty** + During sprint 3 we underestimated the difficulty of fixing what seemed to be small bugs in our code. Most of our bugs were visual bugs whose origin were nested deeply in our dependencies. Due to this, fixing these bug issues often took as much time as developing a feature or were outright unfixable in the allotted time. + +#### Planned Changes + +**Reassessing Bugs** +Various bugs that were planned on being completed today were instead pushed back to be completed later. Their difficulty score will likely be increased as well. + +**Pushing Back/Scrapping Various User Stories** +There were a few user stories that we have decided to push back to sprint 4. Additionally, we considered scrapping some of the low priority features as we want to prioritize on refining existing features. + +## Product - Review + +#### Goals and/or tasks that were met/completed + +- Timetable Operations via AI [SCRUM-31](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-31). From the AI chatbot we can use various of our timetable operations like for instance, generating a timetable using timetable generate. +- Timetable Basics/Insertion [Scrum-46](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-46). Basic timetable functionalities like insertion. +- Entries Update/Delete + [Scrum-47](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-47). Updating timetable entries and deleting them. +- Email Notifications [Scrum-56](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-56). Notifying users via email based on upcoming timetable events. +- Fix restriction Creation Bugs [SCRUM-129](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-129) +- Fix module importing linting errors [SCRUM-125](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-125) +- Updated ReadMe file [SCRUM-126](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-126). Updated ReadMe file so that app setup is up to date. +- Sprint 3 Documentation + [SCRUM-127](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-127). Creating iteration-03.md and other required sprint 3 documents. +- Sprint 3 Retrospective + [SCRUM-128](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-128). Finishing sprint 3 retrospective documents (e.g. burndown.pdf). +- Refine AI Outputs (especially for year level) [SCRUM-132](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-132). Making sure AI output does not break a specified format +- Enhancement: Check timetable name duplicate [SCRUM-137](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-137) +- Add Backend Testing [SCRUM-138](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-138) + +#### Goals and/or tasks that were planned but not met/completed + +- Timetable Export/Share [SCRUM-58](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-58). Sharing your timetable with others and exporting them to a copyable format. +- Timetable Favorite [SCRUM-57](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-57). Being able to favorite a timetable. +- Fix text highlight on edit username [SCRUM-131](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-131) +- Project Deployment + [SCRUM-130](https://cscc01-course-matrix.atlassian.net/browse/SCRUM-130). Making a CI/CD pipeline with all requirements defined by assignment 2. + +## Meeting Highlights + +We have decided to do the following from here on out: + +1. Prioritize polishing existing features +2. Preparing our application for presentation +3. Scrapping few user stories that are deemed either redundant or unimportant + +For the next meetings our development efforts will focus on: + +1. Adding additional unit/integration tests to our program to ensure stability +2. Patching various bug fixes +3. Completing any unfinished user stories diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..3f24dec2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "term-group-project-c01w25-project-course-matrix", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}