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) {
*/}
) => (
+
+);
+Pagination.displayName = "Pagination";
+
+const PaginationContent = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+));
+PaginationContent.displayName = "PaginationContent";
+
+const PaginationItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ className, ...props }, ref) => (
+
+));
+PaginationItem.displayName = "PaginationItem";
+
+type PaginationLinkProps = {
+ isActive?: boolean;
+ onClick?: (event: React.MouseEvent) => void;
+} & Pick &
+ Omit, "onClick">;
+
+const PaginationLink = ({
+ className,
+ isActive,
+ size = "icon",
+ onClick,
+ ...props
+}: PaginationLinkProps) => (
+
+);
+PaginationLink.displayName = "PaginationLink";
+
+const PaginationPrevious = ({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) => (
+
+
+ Previous
+
+);
+PaginationPrevious.displayName = "PaginationPrevious";
+
+const PaginationNext = ({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) => (
+
+ Next
+
+
+);
+PaginationNext.displayName = "PaginationNext";
+
+const PaginationEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More pages
+
+);
+PaginationEllipsis.displayName = "PaginationEllipsis";
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+};
diff --git a/course-matrix/frontend/src/components/ui/switch.tsx b/course-matrix/frontend/src/components/ui/switch.tsx
new file mode 100644
index 00000000..6be019d8
--- /dev/null
+++ b/course-matrix/frontend/src/components/ui/switch.tsx
@@ -0,0 +1,27 @@
+import * as React from "react";
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+
+import { cn } from "@/lib/utils";
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+Switch.displayName = SwitchPrimitives.Root.displayName;
+
+export { Switch };
diff --git a/course-matrix/frontend/src/constants/calendarConstants.ts b/course-matrix/frontend/src/constants/calendarConstants.ts
new file mode 100644
index 00000000..786bd7e4
--- /dev/null
+++ b/course-matrix/frontend/src/constants/calendarConstants.ts
@@ -0,0 +1,13 @@
+export const courseEventStyles = {
+ colorName: "courseEvent",
+ lightColors: {
+ main: "#1c7df9",
+ container: "#d2e7ff",
+ onContainer: "#002859",
+ },
+ darkColors: {
+ main: "#c0dfff",
+ onContainer: "#dee6ff",
+ container: "#426aa2",
+ },
+};
diff --git a/course-matrix/frontend/src/index.css b/course-matrix/frontend/src/index.css
index 8d24ac89..69731305 100644
--- a/course-matrix/frontend/src/index.css
+++ b/course-matrix/frontend/src/index.css
@@ -50,7 +50,7 @@ html {
--popover-foreground: 240 10% 3.9%;
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
- --secondary: 240 4.8% 95.9%;
+ --secondary: 240 4.8% 91%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
@@ -137,3 +137,20 @@ input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0px 1000px white inset !important;
caret-color: black !important; /* Ensures the cursor is visible */
}
+
+/* Prevent schedule X animations*/
+.sx__event-drag,
+.sx__time-grid-event,
+.sx__month-grid-event,
+.sx__event-calendar-popover,
+.sx__calendar-sidebar,
+.sx__event-modal {
+ animation: none !important;
+ transition: none !important;
+}
+
+/* Target any other animated elements */
+[class*="sx__"] {
+ transition-duration: 0s !important;
+ animation-duration: 0s !important;
+}
diff --git a/course-matrix/frontend/src/main.tsx b/course-matrix/frontend/src/main.tsx
index 057ccecb..4ea78b0a 100644
--- a/course-matrix/frontend/src/main.tsx
+++ b/course-matrix/frontend/src/main.tsx
@@ -7,11 +7,11 @@ import { Provider } from "react-redux";
import store from "./stores/store.ts";
createRoot(document.getElementById("root")!).render(
-
-
-
-
-
-
- ,
+ //
+
+
+
+
+ ,
+ // ,
);
diff --git a/course-matrix/frontend/src/models/models.ts b/course-matrix/frontend/src/models/models.ts
index cb1c1652..c2f988ef 100644
--- a/course-matrix/frontend/src/models/models.ts
+++ b/course-matrix/frontend/src/models/models.ts
@@ -111,12 +111,6 @@ export interface CorequisiteModel {
course_code: string;
/** Course code for the corequisite course */
- id: number;
- created_at: string;
- updated_at: string;
- course_id: number;
- corequisite_id: number;
- course_code: string;
corequisite_code: string;
}
@@ -175,3 +169,45 @@ export interface OfferingModel {
/** Additional notes about the course offering */
notes: string;
}
+
+/**
+ * Represents a timetable including details like its title, semester, and favorite satus
+ */
+export interface TimetableModel {
+ /** Unique identifier */
+ id: number;
+
+ /** Creation timestamp */
+ created_at: string;
+
+ /** Last updated at timestamp */
+ updated_at: string;
+
+ /** Name of timetable */
+ timetable_title: string;
+
+ /** ID of user owning this timetable */
+ user_id: string;
+
+ /** Semester that the timetable is for */
+ semester: string;
+
+ /** Is timetable favorited by user */
+ favorite: boolean;
+
+ /** Has user enabled email notifications for this timetable */
+ email_notifications_enabled: boolean;
+}
+
+export type GenerateTimetableOffering = Omit<
+ OfferingModel,
+ "created_at" | "updated_at"
+>;
+
+/**
+ * Response data of generate timetable call
+ */
+export interface TimetableGenerateResponseModel {
+ amount: number;
+ schedules: GenerateTimetableOffering[][];
+}
diff --git a/course-matrix/frontend/src/models/timetable-form.ts b/course-matrix/frontend/src/models/timetable-form.ts
index e4800f91..37d432e8 100644
--- a/course-matrix/frontend/src/models/timetable-form.ts
+++ b/course-matrix/frontend/src/models/timetable-form.ts
@@ -1,4 +1,5 @@
import { z, ZodType } from "zod";
+import { OfferingModel } from "./models";
const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
@@ -12,6 +13,7 @@ export type TimetableForm = {
code: string;
name: string;
}[];
+ offeringIds: number[];
restrictions: RestrictionForm[];
};
@@ -58,6 +60,36 @@ export const CourseSchema = z.object({
name: z.string(),
});
+export const TimetableSchema = z.object({
+ id: z.number(),
+ created_at: z.string(),
+ updated_at: z.string(),
+ user_id: z.string(),
+ semester: z.string(),
+ timetable_title: SemesterEnum,
+ favorite: z.boolean(),
+});
+
+export const OfferingSchema = z.object({
+ id: z.number(),
+ created_at: z.string(),
+ updated_at: z.string(),
+ course_id: z.number(),
+ code: z.string(),
+ meeting_section: z.string(),
+ offering: z.string(),
+ day: z.string(),
+ start: z.string(),
+ end: z.string(),
+ location: z.string(),
+ current: z.string(),
+ max: z.string(),
+ is_waitlisted: z.boolean(),
+ delivery_mode: z.string(),
+ instructor: z.string(),
+ notes: z.string().optional(),
+});
+
export const RestrictionSchema = z
.object({
type: z.string().min(1, "Restriction type is required"),
@@ -177,6 +209,40 @@ export const RestrictionSchema = z
message: "Number must be at least 1",
path: ["numDays"],
},
+ )
+ .refine(
+ (data) => {
+ if (
+ data.type &&
+ data.type === "Restrict Before" &&
+ data.endTime?.getHours() === 0 &&
+ data.endTime?.getMinutes() === 0
+ ) {
+ return false;
+ }
+ return true;
+ },
+ {
+ message: "Cannot restrict whole day",
+ path: ["endTime"],
+ },
+ )
+ .refine(
+ (data) => {
+ if (
+ data.type &&
+ data.type === "Restrict After" &&
+ data.startTime?.getHours() === 0 &&
+ data.startTime?.getMinutes() === 0
+ ) {
+ return false;
+ }
+ return true;
+ },
+ {
+ message: "Cannot restrict whole day",
+ path: ["startTime"],
+ },
);
export const TimetableFormSchema: ZodType = z
@@ -189,6 +255,7 @@ export const TimetableFormSchema: ZodType = z
semester: SemesterEnum,
search: z.string(),
courses: z.array(CourseSchema),
+ offeringIds: z.array(z.number()),
restrictions: z.array(RestrictionSchema),
})
.refine(
@@ -208,6 +275,26 @@ export const TimetableFormSchema: ZodType = z
message: "Cannot pick more than 8 courses",
path: ["search"],
},
+ )
+ .refine(
+ (data) => {
+ return !(
+ data.restrictions.filter((r) => r.type === "Days Off").length > 1
+ );
+ },
+ {
+ message: "Already added minimum days off per week",
+ path: ["restrictions"],
+ },
+ )
+ .refine(
+ (data) => {
+ return !hasDuplicate(data.restrictions);
+ },
+ {
+ message: "Duplicate restriction detected. Please remove.",
+ path: ["restrictions"],
+ },
);
export const baseTimetableForm: TimetableForm = {
@@ -217,6 +304,7 @@ export const baseTimetableForm: TimetableForm = {
search: "",
courses: [],
restrictions: [],
+ offeringIds: [],
};
export const baseRestrictionForm: RestrictionForm = {
@@ -224,3 +312,23 @@ export const baseRestrictionForm: RestrictionForm = {
days: [],
disabled: false,
};
+
+function hasDuplicate(restrictions: RestrictionForm[]) {
+ const seen: RestrictionForm[] = [];
+ for (const r of restrictions) {
+ if (
+ seen.some(
+ (s) =>
+ s.type === r.type &&
+ ((s.numDays && r.numDays && s.numDays === r.numDays) ||
+ (s.days?.sort().join(" ") === r.days?.sort().join(" ") &&
+ s.startTime?.getHours() === r.startTime?.getHours() &&
+ s.endTime?.getHours() === s.endTime?.getHours())),
+ )
+ ) {
+ return true;
+ }
+ seen.push(r);
+ }
+ return false;
+}
diff --git a/course-matrix/frontend/src/pages/Assistant/runtime-provider.tsx b/course-matrix/frontend/src/pages/Assistant/runtime-provider.tsx
index ad873af4..99d8024a 100644
--- a/course-matrix/frontend/src/pages/Assistant/runtime-provider.tsx
+++ b/course-matrix/frontend/src/pages/Assistant/runtime-provider.tsx
@@ -61,6 +61,7 @@ export function RuntimeProvider({
const runtime = useChatRuntime({
cloud,
api: `${SERVER_URL}/api/ai/chat`,
+ credentials: "include",
});
const contextValue = {
diff --git a/course-matrix/frontend/src/pages/Home/EmailNotificationSettings.tsx b/course-matrix/frontend/src/pages/Home/EmailNotificationSettings.tsx
new file mode 100644
index 00000000..6214575d
--- /dev/null
+++ b/course-matrix/frontend/src/pages/Home/EmailNotificationSettings.tsx
@@ -0,0 +1,97 @@
+import {
+ useGetTimetableQuery,
+ useUpdateTimetableMutation,
+} from "@/api/timetableApiSlice";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Switch } from "@/components/ui/switch";
+import { TimetableModel } from "@/models/models";
+import { useEffect, useState } from "react";
+
+interface EmailNotificationSettingsProps {
+ timetableId: number;
+}
+
+export const EmailNotificationSettings = ({
+ timetableId,
+}: EmailNotificationSettingsProps) => {
+ const { data, isLoading, refetch } = useGetTimetableQuery(timetableId);
+ const [updateTimetable] = useUpdateTimetableMutation();
+ const [toggled, setToggled] = useState(false);
+
+ const handleCancel = () => {
+ setToggled((data as TimetableModel[])[0]?.email_notifications_enabled);
+ };
+
+ useEffect(() => {
+ if (data) {
+ const val = (data as TimetableModel[])[0]?.email_notifications_enabled;
+ if (val !== undefined) {
+ setToggled(val);
+ }
+ }
+ }, [data]);
+
+ const handleUpdateEmailNotifications = async () => {
+ try {
+ await updateTimetable({
+ id: timetableId,
+ email_notifications_enabled: toggled,
+ }).unwrap();
+ refetch();
+ } catch (error) {
+ console.error("Failed to update timetable:", error);
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/course-matrix/frontend/src/pages/Home/Home.tsx b/course-matrix/frontend/src/pages/Home/Home.tsx
index dde76332..54cf7330 100644
--- a/course-matrix/frontend/src/pages/Home/Home.tsx
+++ b/course-matrix/frontend/src/pages/Home/Home.tsx
@@ -5,6 +5,16 @@ import TimetableCompareButton from "./TimetableCompareButton";
import TimetableCreateNewButton from "./TimetableCreateNewButton";
import { useGetTimetablesQuery } from "../../api/timetableApiSlice";
+export interface Timetable {
+ id: number;
+ created_at: string;
+ updated_at: string;
+ user_id: string;
+ semester: string;
+ timetable_title: string;
+ favorite: boolean;
+}
+
/**
* Home component that displays the user's timetables and provides options to create or compare timetables.
* @returns {JSX.Element} The rendered component.
@@ -15,7 +25,11 @@ const Home = () => {
(user_metadata?.user?.user_metadata?.username as string) ??
(user_metadata?.user?.email as string);
- const { data, isLoading, refetch } = useGetTimetablesQuery();
+ const { data, isLoading, refetch } = useGetTimetablesQuery() as {
+ data: Timetable[];
+ isLoading: boolean;
+ refetch: () => void;
+ };
return (
@@ -53,20 +67,24 @@ const Home = () => {
-
+
{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 (
-
+
+
+
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/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}
+
+ )}
+
+
+
+
+
+
-
-
+