From 2009d615445aafb21129149f80a7b63acd4080a5 Mon Sep 17 00:00:00 2001 From: Shaam <109339363+Shzmj@users.noreply.github.com> Date: Fri, 15 Nov 2024 11:44:06 +1100 Subject: [PATCH] Sync timetables merge dev (#916) * feat(client): dialog for when user logs out * added auth cleanup and user addition to db * lint * changed ci * Update auth.service.ts * Feature: Changed dark mode settings toggle to icon button in sidebar (Sidebar Dark Mode Icon #850) (#850) * adding redirect link to devsoc * made dark mode button component * removed dark mode toggle * removed darkmode toggle from settings * made sidebar footer icons padding consistent * added prop type for dark mode button * sync dark mode button updates * fully implemented dark mode button --------- Co-authored-by: ray Co-authored-by: Dylan Zhang * docs: added our latest 2024 subcom member * renamed migrations * testing out db migration changes * changing script * Created Landing Page * setup tailwind * updated packages for tailwind * added white devsoc svg * started hero section * added hero section * changed to black svg * added lib utils * added hovering animation * new component * moved out of herosection * added sponsor section * changed packes * slight changes to colours and imports * added macquarie * sync slight changes * made bigger added animations * changing host name redirect handler * fixed redirect details * docker file fixed * docker file fixed * cleaned conditional * cleaned conditional * testing migrations changes * changing migrations stuff * removing migrations folder * sync * sync from macbook * sync for cooperative work * feat: year, term and classNo added to class data * feat: add sync functions for add/remove/edit timetables. Create/duplicate/delete one timetable synced, and history sync started * finished scrolling features section except for gifs * slightly changed sponsors * Merge divergent branches nikki -> main landing page branch (#888) * tried some stuff for key features * added features blocks * completed key features component * added footer * deleted unecessary file --------- Co-authored-by: nikkichins * added blob image * added how it works text * hacky solution for changing page * make further above * chore: revert addition of syncing fuctions to individual components * feat: sync timetables function refactored to use setInterval * fix: logic for sync timetables * fix: bugs with typing w/ course codes and class no * temp fix: change events back to old schema: * added gifs * removed unnecessary bg * removed unnecessary bg colour * added gifs * removed 0 margin * feat: add database dto to frontend object parsing. Also, add event to eventDTO parsing. * feat: add subtype to event in schema * fix: class info is correctly extracted from map * feat: course code returned in scraped-class object from backend * feat: add default assigned colors for courses when reconstructing timetable from db * feat: syncing timetable updates user context * fix: updated type in runsync arg * fix: reverted unwanted regression in timetable creation logic * feat: store timetable logic moved to usercontext * fix: bug fixes * feat: add mapkey/termkey to schema * feat: add term key logic to frontend * fixed header bug * sent request to BE when creating a default timetable * removing logout problems * Changed createDefaultTimetable to accept userID and make a call to BE when setting default timetable. In convertClassToDTO, fixed reduce to have initial empty [] otherwise it breaks. Sent missing mapKey from FE to BE in createNewTimetable * reduced four useEffects in App.tsx into one * added fix for logout bugs * checked for valid term, before gettingUserInfo and creating default timetableg * removed timeout and fixed delete timetable by including header * separated useEffect in App.tsx so that displayTimetables is its own to avoid circular calls * fix: user display timetables are deep copies, and other course/app context variables set during init fetch of timetables * fix: remove hard-coding of term in add timetable, and added back interval/altered logic to handle duplication of new timetables bug * fix: first user timetable not being saved to backend fix * added types for tt dto * fix: DTO structure fixed, and cleaned up code * Added Feedback section to landing page and cleaned up code in the Features section * changed to accommodate snap scrolling * change so default is landing page then after visiting becomes normal app * renamed hero section * changed duplicateClasses in timetableHelpers to generate a new uuid for duplicated classes * Made hero section responsive * made features and sponsors responsive * made footer and feedback responsive * made scrolling features section responsive * made landing page responsive * Merge conflicts resoplved * fixing merge conflicts * http change * user context put in index * added activity to be saved in the backend for classes * unscheduled items are saved to BE * fixed interface typing * fix: revert compaction of useUpdateEffects to fix local storage on load bug * init fix for gql * added fix for uris * fix: add term and classid fields in gql query * fix: use unique classID in backend class cache; and fix bug with term parameter for getCourseInfo * working fe hero lp * fixed type error bugs * fixed more type errors bugs * gql added and working * fix: remove mutation of classID * fix: attempted fix of scraped class DTO construction from gql * fix: fix type issues * fix: time in right form * fix: map key is properly convered to term * final checks :) * running lint --------- Co-authored-by: hhuolu Co-authored-by: ray Co-authored-by: Raiyan Ahmed <80839724+Rayahhhmed@users.noreply.github.com> Co-authored-by: dlyn <100419289+dlynz@users.noreply.github.com> Co-authored-by: Dylan Zhang Co-authored-by: Jasmine Tran Co-authored-by: dylan Co-authored-by: Dylan Zhang Co-authored-by: Michael Siu Co-authored-by: nikkichins --- client/package.json | 7 +- client/src/App.tsx | 22 +- client/src/api/getCourseInfo.ts | 4 +- client/src/components/controls/History.tsx | 5 +- client/src/components/sidebar/UserAccount.tsx | 10 +- .../timetableTabs/TimetableTabs.tsx | 1 - client/src/constants/defaults.ts | 2 +- client/src/context/UserContext.tsx | 57 +++- client/src/interfaces/Database.ts | 2 + client/src/interfaces/GraphQLCourseInfo.ts | 2 + client/src/interfaces/Periods.ts | 62 ++++ client/src/utils/DbCourse.ts | 3 + client/src/utils/graphQLCourseToDbCourse.ts | 2 + client/src/utils/syncTimetables.ts | 305 ++++++++++++++++++ client/src/utils/timetableHelpers.ts | 30 +- client/tailwind.config.js | 4 +- server/pnpm-lock.yaml | 21 +- .../migrations/20241003042905_/migration.sql | 40 +++ .../20241101072349_activity/migration.sql | 8 + server/prisma/schema.prisma | 14 +- server/src/app.module.ts | 5 +- server/src/auth/auth.controller.ts | 6 - server/src/auth/auth.module.ts | 2 + server/src/auth/oidc.strategy.ts | 1 + server/src/graphql/graphql.module.ts | 7 + server/src/graphql/graphql.response.ts | 24 ++ server/src/graphql/graphql.service.spec.ts | 18 ++ server/src/graphql/graphql.service.ts | 67 ++++ server/src/group/group.module.ts | 3 +- server/src/user/dto/timetable.dto.ts | 6 +- server/src/user/user.controller.ts | 4 +- server/src/user/user.module.ts | 3 +- server/src/user/user.service.ts | 64 +++- 33 files changed, 729 insertions(+), 82 deletions(-) create mode 100644 client/src/utils/syncTimetables.ts create mode 100644 server/prisma/migrations/20241003042905_/migration.sql create mode 100644 server/prisma/migrations/20241101072349_activity/migration.sql create mode 100644 server/src/graphql/graphql.module.ts create mode 100644 server/src/graphql/graphql.response.ts create mode 100644 server/src/graphql/graphql.service.spec.ts create mode 100644 server/src/graphql/graphql.service.ts diff --git a/client/package.json b/client/package.json index a7e2343f4..76f83cde7 100644 --- a/client/package.json +++ b/client/package.json @@ -16,7 +16,6 @@ "style": "prettier --write 'src/**/*.{ts,tsx}' && eslint --fix 'src/**/*.{ts,tsx}'" }, "dependencies": { - "@apollo/client": "3.11.8", "@date-io/date-fns": "2.17.0", "@emotion/react": "11.11.1", "@emotion/styled": "11.11.0", @@ -33,6 +32,7 @@ "@sentry/tracing": "7.84.0", "@uiw/react-color": "2.1.1", "clsx": "2.1.1", + "@apollo/client": "3.11.8", "date-fns": "2.30.0", "dayjs": "1.11.12", "file-saver": "2.0.5", @@ -88,10 +88,10 @@ "@types/react-transition-group": "4.4.10", "@types/react-window": "1.8.8", "@types/uuid": "9.0.8", + "autoprefixer": "10.4.19", "@typescript-eslint/eslint-plugin": "7.16.1", "@typescript-eslint/parser": "7.16.1", "@vitejs/plugin-react-swc": "3.7.0", - "autoprefixer": "10.4.19", "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-react": "7.35.0", @@ -107,5 +107,6 @@ "vite-plugin-svgr": "4.2.0", "vite-tsconfig-paths": "5.0.1", "vitest": "2.1.2" - } + }, + "packageManager": "pnpm@9.4.0+sha512.f549b8a52c9d2b8536762f99c0722205efc5af913e77835dbccc3b0b0b2ca9e7dc8022b78062c17291c48e88749c70ce88eb5a74f1fa8c4bf5e18bb46c8bd83a" } diff --git a/client/src/App.tsx b/client/src/App.tsx index ca7ccb88a..97eebbe31 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -45,6 +45,7 @@ import { import { setDropzoneRange, useDrag } from './utils/Drag'; import { downloadIcsFile } from './utils/generateICS'; import storage from './utils/storage'; +import { runSync } from './utils/syncTimetables'; import { createDefaultTimetable } from './utils/timetableHelpers'; const StyledApp = styled(Box)` @@ -134,7 +135,7 @@ const App: React.FC = () => { setAssignedColors, } = useContext(CourseContext); - const { groupsSidebarCollapsed, setGroupsSidebarCollapsed } = useContext(UserContext); + const { user, setUser, groupsSidebarCollapsed, setGroupsSidebarCollapsed } = useContext(UserContext); setDropzoneRange(days.length, earliestStartTime, latestEndTime); @@ -186,7 +187,7 @@ const App: React.FC = () => { ...{ [termId as string]: oldData.hasOwnProperty(termId as string) ? oldData[termId as string] - : createDefaultTimetable(), + : createDefaultTimetable(user.userID), }, }; } @@ -429,36 +430,53 @@ const App: React.FC = () => { updateTimetableEvents(); }, [year, isConvertToLocalTimezone]); + const syncTimetables = () => { + if (!user.userID) { + return; + } + + runSync(user, setUser, displayTimetables, setDisplayTimetables); + }; + // The following three useUpdateEffects update local storage whenever a change is made to the timetable useUpdateEffect(() => { displayTimetables[term][selectedTimetable].selectedCourses = selectedCourses; const newCourseData = courseData; storage.set('courseData', newCourseData); + storage.set('timetables', displayTimetables); setDisplayTimetables(displayTimetables); + syncTimetables(); }, [selectedCourses]); useUpdateEffect(() => { displayTimetables[term][selectedTimetable].selectedClasses = selectedClasses; + storage.set('timetables', displayTimetables); setDisplayTimetables(displayTimetables); + syncTimetables(); }, [selectedClasses]); useUpdateEffect(() => { displayTimetables[term][selectedTimetable].createdEvents = createdEvents; + storage.set('timetables', displayTimetables); setDisplayTimetables(displayTimetables); + syncTimetables(); }, [createdEvents]); useUpdateEffect(() => { displayTimetables[term][selectedTimetable].assignedColors = assignedColors; + storage.set('timetables', displayTimetables); setDisplayTimetables(displayTimetables); + syncTimetables(); }, [assignedColors]); // Update storage when dragging timetables useUpdateEffect(() => { storage.set('timetables', displayTimetables); + syncTimetables(); }, [displayTimetables]); /** diff --git a/client/src/api/getCourseInfo.ts b/client/src/api/getCourseInfo.ts index 2394285f8..06011ba0f 100644 --- a/client/src/api/getCourseInfo.ts +++ b/client/src/api/getCourseInfo.ts @@ -17,6 +17,8 @@ const GET_COURSE_INFO = gql` activity status course_enrolment + class_id + term section times { day @@ -113,7 +115,6 @@ const getCourseInfo = async ( }); const json: DbCourse = graphQLCourseToDbCourse(data); - json.classes.forEach((dbClass) => { // Some courses split up a single class into two separate classes. e.g. CHEM1011 does it (as of 22T3) // because one half of the course is taught by one lecturer and the other half is taught by another. @@ -173,7 +174,6 @@ const getCourseInfo = async ( }); if (!json) throw new NetworkError('Internal server error'); - return dbCourseToCourseData(json, isConvertToLocalTimezone); } catch (error) { console.log(error); diff --git a/client/src/components/controls/History.tsx b/client/src/components/controls/History.tsx index 6224d8e33..d9441d4ac 100644 --- a/client/src/components/controls/History.tsx +++ b/client/src/components/controls/History.tsx @@ -4,6 +4,7 @@ import React, { useContext, useEffect, useRef, useState } from 'react'; import { AppContext } from '../../context/AppContext'; import { CourseContext } from '../../context/CourseContext'; +import { UserContext } from '../../context/UserContext'; import { CourseData, CreatedEvents, DisplayTimetablesMap, SelectedClasses } from '../../interfaces/Periods'; import { ActionsPointer, @@ -30,6 +31,7 @@ const History: React.FC = () => { useContext(CourseContext); const { isDrag, setIsDrag, selectedTimetable, setSelectedTimetable, displayTimetables, setDisplayTimetables, term } = useContext(AppContext); + const { user } = useContext(UserContext); const timetableActions = useRef({}); const actionsPointer = useRef({}); @@ -205,11 +207,12 @@ const History: React.FC = () => { * Resets all timetables - leave one as default */ const clearAll = () => { + const newTimetables = createDefaultTimetable(user.userID); if (!term) return; const newDisplayTimetables: DisplayTimetablesMap = { ...displayTimetables, - [term]: createDefaultTimetable(), + [term]: newTimetables, }; setTimetableState([], {}, {}, newDisplayTimetables, 0); }; diff --git a/client/src/components/sidebar/UserAccount.tsx b/client/src/components/sidebar/UserAccount.tsx index d2ae641af..ad4b58e47 100644 --- a/client/src/components/sidebar/UserAccount.tsx +++ b/client/src/components/sidebar/UserAccount.tsx @@ -5,7 +5,9 @@ import React, { useContext, useState } from 'react'; import { API_URL } from '../../api/config'; import { undefinedUser, UserContext } from '../../context/UserContext'; -import { TimetableData } from '../../interfaces/Periods'; +import { DisplayTimetablesMap } from '../../interfaces/Periods'; +import storage from '../../utils/storage'; +import { createDefaultTimetable } from '../../utils/timetableHelpers'; import StyledDialog from '../StyledDialog'; import UserProfile from './groupsSidebar/friends/UserProfile'; @@ -55,7 +57,7 @@ export interface User { friends: User[]; incoming: User[]; outgoing: User[]; - timetables: TimetableData[]; + timetables: DisplayTimetablesMap; } const UserAccount: React.FC = ({ collapsed }) => { @@ -63,7 +65,6 @@ const UserAccount: React.FC = ({ collapsed }) => { const [logoutDialog, setLogoutDialog] = useState(false); const { user, setUser } = useContext(UserContext); - const loginCall = async () => { setWindowLocation(window.location.href); try { @@ -71,8 +72,6 @@ const UserAccount: React.FC = ({ collapsed }) => { } catch (error) { console.log(error); } - // Replaces current history item rather than adding item to history - // window.location.replace(`${API_URL.server}/auth/login`); }; const logoutCall = async () => { @@ -85,6 +84,7 @@ const UserAccount: React.FC = ({ collapsed }) => { } window.location.replace(windowLocation); setUser(undefinedUser); + storage.set('timetables', createDefaultTimetable(undefined)); }; if (!user.userID) { return collapsed ? ( diff --git a/client/src/components/timetableTabs/TimetableTabs.tsx b/client/src/components/timetableTabs/TimetableTabs.tsx index 881ffa1e5..f8fc29082 100644 --- a/client/src/components/timetableTabs/TimetableTabs.tsx +++ b/client/src/components/timetableTabs/TimetableTabs.tsx @@ -101,7 +101,6 @@ const TimetableTabs: React.FC = () => { }; storage.set('timetables', addingNewTimetables); setDisplayTimetables(addingNewTimetables); - // Clearing the selected courses, classes and created events for the new timetable setTimetableState([], {}, {}, {}, nextIndex); } diff --git a/client/src/constants/defaults.ts b/client/src/constants/defaults.ts index fd1d96f75..418217cd9 100644 --- a/client/src/constants/defaults.ts +++ b/client/src/constants/defaults.ts @@ -10,7 +10,7 @@ const defaults: Record = { isHideExamClasses: false, isConvertToLocalTimezone: true, courseData: { map: [] }, - timetables: { T0: createDefaultTimetable() }, + timetables: { T0: createDefaultTimetable('') }, }; export default defaults; diff --git a/client/src/context/UserContext.tsx b/client/src/context/UserContext.tsx index 9b7c2d651..1dabb21c1 100644 --- a/client/src/context/UserContext.tsx +++ b/client/src/context/UserContext.tsx @@ -1,10 +1,15 @@ -import { createContext, useEffect, useMemo, useState } from 'react'; +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { API_URL } from '../api/config'; import { User } from '../components/sidebar/UserAccount'; import { Group } from '../interfaces/Group'; import NetworkError from '../interfaces/NetworkError'; +import { DisplayTimetablesMap, TimetableDTO } from '../interfaces/Periods'; import { UserContextProviderProps } from '../interfaces/PropTypes'; +import { parseTimetableDTO } from '../utils/syncTimetables'; +import { createDefaultTimetable } from '../utils/timetableHelpers'; +import { AppContext } from './AppContext'; +import { CourseContext } from './CourseContext'; export const undefinedUser = { userID: '', @@ -18,7 +23,7 @@ export const undefinedUser = { friends: [], incoming: [], outgoing: [], - timetables: [], + timetables: {}, }; export interface IUserContext { @@ -50,6 +55,8 @@ const UserContextProvider = ({ children }: UserContextProviderProps) => { const [groups, setGroups] = useState([]); const [selectedGroupIndex, setSelectedGroupIndex] = useState(-1); const [groupsSidebarCollapsed, setGroupsSidebarCollapsed] = useState(true); + const { setDisplayTimetables, setSelectedTimetable, term, year } = useContext(AppContext); + const { setSelectedClasses, setSelectedCourses, setCreatedEvents, setAssignedColors } = useContext(CourseContext); const getUserInfo = async (userID: string) => { try { @@ -60,10 +67,41 @@ const UserContextProvider = ({ children }: UserContextProviderProps) => { 'Content-Type': 'application/json', }, }); - const userResponse = await response.text(); - console.log(userResponse); + const res = await response.json(); + const timetables = await Promise.all( + res.data.timetables.map((timetable: TimetableDTO) => parseTimetableDTO(timetable, year)), + ); - if (userResponse !== '') setUser(JSON.parse(userResponse).data); + // Unpack timetables based on key + const timetableMap: DisplayTimetablesMap = {}; + + timetables.forEach(({ mapKey, timetable }) => { + if (!timetableMap[mapKey]) { + timetableMap[mapKey] = []; + } + timetableMap[mapKey].push(timetable); + }); + + const userResponse = { ...res.data, timetables: structuredClone(timetableMap) }; + setUser(userResponse); + + // Check current term exists. If not, create default timetable for this term + // NOTE: This is AFTER setting the timetableMap for user.timetable. By doing this, we allow the runSync + // function to pick up that there's a difference, and sync the default timetable with the backend. + if (!Object.keys(timetableMap).includes(term)) { + timetableMap[term] = createDefaultTimetable(res.data.userID); + } + setDisplayTimetables({ ...timetableMap }); + + // TODO: check if this conditional is necessary + if (timetableMap[term] && timetableMap[term][0]) { + const { selectedCourses, selectedClasses, createdEvents, assignedColors } = timetableMap[term][0]; + setSelectedCourses(selectedCourses); + setSelectedClasses(selectedClasses); + setCreatedEvents(createdEvents); + setAssignedColors(assignedColors); + setSelectedTimetable(0); + } } catch (error) { console.log(error); } @@ -89,7 +127,7 @@ const UserContextProvider = ({ children }: UserContextProviderProps) => { }; const fetchUserInfo = (userID: string) => { - getUserInfo(userID); + if (term !== 'T0') getUserInfo(userID); getGroups(userID); }; @@ -104,15 +142,16 @@ const UserContextProvider = ({ children }: UserContextProviderProps) => { const userID = JSON.parse(userResponse); fetchUserInfo(userID); } else { - console.error("Couldn't get response for user information!"); - // throw new NetworkError("Couldn't get response"); + setUser(undefinedUser); + console.log('user is not logged in'); + throw new NetworkError("Couldn't get response for user information!"); } } catch (error) { console.log(error); } }; getZid(); - }, []); + }, [term]); const initialContext = useMemo( () => ({ diff --git a/client/src/interfaces/Database.ts b/client/src/interfaces/Database.ts index af9d68d88..bded71661 100644 --- a/client/src/interfaces/Database.ts +++ b/client/src/interfaces/Database.ts @@ -9,9 +9,11 @@ export interface DbCourse { export interface DbClass { activity: Activity; times: DbTimes[]; + classID: string; status: Status; courseEnrolment: DbCourseEnrolment; section: Section; + term: string; } export interface DbCourseEnrolment { diff --git a/client/src/interfaces/GraphQLCourseInfo.ts b/client/src/interfaces/GraphQLCourseInfo.ts index 84a6e92b3..d0143f77a 100644 --- a/client/src/interfaces/GraphQLCourseInfo.ts +++ b/client/src/interfaces/GraphQLCourseInfo.ts @@ -13,6 +13,8 @@ interface Class { course_enrolment: string; section: string; times: Time[]; + term: string; + class_id: string; } interface Course { diff --git a/client/src/interfaces/Periods.ts b/client/src/interfaces/Periods.ts index 181b536bd..d7b37376a 100644 --- a/client/src/interfaces/Periods.ts +++ b/client/src/interfaces/Periods.ts @@ -1,3 +1,6 @@ +import { User } from '../components/sidebar/UserAccount'; +import { Group } from './Group'; + export type CourseCode = string; export type Activity = string; export type InInventory = null; @@ -27,6 +30,7 @@ export interface TermData { export interface ClassData { id: string; + classNo: string; courseCode: CourseCode; courseName: string; activity: Activity; @@ -35,6 +39,8 @@ export interface ClassData { capacity: number; periods: ClassPeriod[]; section: Section; + term: string; + year: string; } export interface TimetableData { @@ -46,6 +52,62 @@ export interface TimetableData { assignedColors: Record; } +export interface EventDTO { + id: string; + name: string; + location?: string | null; + description?: string | null; + colour: string; + day: string; + start: Date; + end: Date; + timetableId?: string | null; + timetable?: TimetableDTO | null; + groupIds: string[]; +} + +export interface ClassTimeDTO { + day: string; + time: { + start: string; + end: string; + }; + weeks: string; + location: string; +} + +export interface ScrapedClassDTO { + classID: string; + section: string; + term: string; + activity: string; + status: string; + courseEnrolment: { + enrolments: number; + capacity: number; + }; + termDates: { + start: string; + end: string; + }; + needsConsent: boolean; + mode: string; + times: ClassTimeDTO[]; + courseCode: string; + notes: []; +} + +export interface TimetableDTO { + id: string; + name: string; + selectedCourses: string[]; + selectedClasses: ScrapedClassDTO[]; + createdEvents: EventDTO[]; + user: User[]; + groups: Group[]; + mapKey: string; +} + export interface InventoryData { courseCode: CourseCode; activity: Activity; diff --git a/client/src/utils/DbCourse.ts b/client/src/utils/DbCourse.ts index bc28e1aec..a8bb30501 100644 --- a/client/src/utils/DbCourse.ts +++ b/client/src/utils/DbCourse.ts @@ -167,6 +167,9 @@ export const dbCourseToCourseData = (dbCourse: DbCourse, isConvertToLocalTimezon capacity: dbClass.courseEnrolment.capacity, periods: [], section: dbClass.section, + classNo: dbClass.classID, + term: dbClass.term, + year: '2025', // TODO: REPLACE }; classData.periods = dbClass.times.map((dbTime) => dbTimesToPeriod(dbTime, classData, isConvertToLocalTimezone)); diff --git a/client/src/utils/graphQLCourseToDbCourse.ts b/client/src/utils/graphQLCourseToDbCourse.ts index 5da1dcfa1..0bb9855c1 100644 --- a/client/src/utils/graphQLCourseToDbCourse.ts +++ b/client/src/utils/graphQLCourseToDbCourse.ts @@ -41,6 +41,8 @@ export const graphQLCourseToDbCourse = (graphQLCourse: GraphQLCourse): DbCourse weeks: time.weeks, location: time.location, })), + term: classItem.term, + classID: classItem.class_id, })), }; }; diff --git a/client/src/utils/syncTimetables.ts b/client/src/utils/syncTimetables.ts new file mode 100644 index 000000000..412262cfa --- /dev/null +++ b/client/src/utils/syncTimetables.ts @@ -0,0 +1,305 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { API_URL } from '../api/config'; +import getCourseInfo from '../api/getCourseInfo'; +import { User } from '../components/sidebar/UserAccount'; +import useColorMapper from '../hooks/useColorMapper'; +import { + ClassData, + CourseData, + CreatedEvents, + DisplayTimetablesMap, + EventPeriod, + ScrapedClassDTO, + SelectedClasses, + TimetableData, + TimetableDTO, +} from '../interfaces/Periods'; + +interface DiffID { + delete: Set; + update: Set; + add: Set; +} + +let timeoutID: NodeJS.Timeout; + +const convertClassToDTO = (selectedClasses: SelectedClasses) => { + const courseCodes = Object.keys(selectedClasses); + + const res = courseCodes.map((courseCode) => { + const activityNames = Object.keys(selectedClasses[courseCode]); + return activityNames.map((activity) => { + const activityData = selectedClasses[courseCode][activity]; + if (activityData) { + const { id, classNo, year, term, courseCode, activity } = activityData as ClassData; + return { id, classNo: String(classNo), year, term, courseCode, activity }; + } else { + // if activityData === null, then it is in inventory. + return { id: uuidv4(), classNo: '', year: '', term: '', courseCode, activity }; + } + }); + }); + + return res.reduce((prev, curr) => prev.concat(curr), []); + + // const a = Object.values(selectedClasses); + // const b = a.map((c) => { + // const d = Object.values(c); + + // return d.map((c2) => { + // const { id, classNo, year, term, courseCode, activity } = c2 as ClassData; + // return { id, classNo: String(classNo), year, term, courseCode, activity }; + // }); + // }); + + // return b.reduce((prev, curr) => prev.concat(curr), []); +}; + +const convertEventToDTO = (createdEvents: CreatedEvents, timetableId?: string) => { + return Object.values(createdEvents).map((period) => { + const { subtype, event, time } = period; + + const eventDTO = { + id: event.id, + name: event.name, + location: event.location, + description: event.description, + colour: event.color, + day: Number(time.day), + start: Number(time.start), + end: Number(time.end), + subtype: subtype, + }; + + return { + ...eventDTO, + ...(timetableId && { timetableId }), + }; + }); +}; + +const convertTimetableToDTO = (timetable: TimetableData) => { + return { + ...timetable, + selectedCourses: timetable.selectedCourses.map((t) => t.code), + selectedClasses: convertClassToDTO(timetable.selectedClasses), + createdEvents: convertEventToDTO(timetable.createdEvents, timetable.id), + }; +}; + +// DATABASE TO FRONTEND PARSING of a timetable. TODO: change type later +const parseTimetableDTO = async (timetableDTO: TimetableDTO, currentYear: string) => { + // First, recover course information from course info API + const courseInfo: CourseData[] = await Promise.all( + timetableDTO.selectedCourses.map((code: string) => { + // TODO: populate with year and term dynamically (is convert to local timezone is a setting to recover) + return getCourseInfo(timetableDTO.mapKey.slice(0, 2), code, currentYear, true); + }), + ); + + // Next, reverse the selected classes info from class data + const classDataMap: Record = {}; // k (course code): v (ClassData[]) + courseInfo.forEach((course) => { + classDataMap[course.code] = Object.values(course.activities).reduce((prev, curr) => prev.concat(curr), []); + }); + + const selectedClasses: SelectedClasses = {}; + timetableDTO.selectedClasses.forEach((scrapedClassDTO: ScrapedClassDTO) => { + const classID = scrapedClassDTO.classID; + + const courseCode: string = scrapedClassDTO.courseCode; + + if (!selectedClasses[courseCode]) { + selectedClasses[courseCode] = {}; + } + + selectedClasses[courseCode][scrapedClassDTO.activity] = + classDataMap[courseCode].find((clz) => clz.classNo === classID) || null; + }); + + // Finally, reverse created events + const eventsList: EventPeriod[] = timetableDTO.createdEvents.map((eventDTO: any) => { + return { + type: 'event', + subtype: eventDTO.subtype, + time: { + day: eventDTO.day, + start: eventDTO.start, + end: eventDTO.end, + }, + event: { + id: eventDTO.id, + name: eventDTO.name, + location: eventDTO.location, + description: eventDTO.description || '', + color: eventDTO.colour, + }, + }; + }); + const createdEvents = eventsList.reduce((prev: CreatedEvents, curr) => { + const id = curr.event.id; + prev[id] = curr; + return prev; + }, {}); + + const parsedTimetable: TimetableData = { + id: timetableDTO.id, + name: timetableDTO.name, + selectedCourses: courseInfo, + selectedClasses: selectedClasses, + createdEvents: createdEvents, + assignedColors: useColorMapper(timetableDTO.selectedCourses, {}), + }; + + return { mapKey: timetableDTO.mapKey, timetable: parsedTimetable }; +}; + +export const syncAddTimetable = async (userId: string, newTimetable: TimetableData, term: string) => { + try { + if (!userId) { + console.log('User is not logged in'); + return; + } + const { selectedCourses, selectedClasses, createdEvents, name } = newTimetable; + const res = await fetch(`${API_URL.server}/user/timetable`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId, + selectedCourses: selectedCourses.map((t) => t.code), + selectedClasses: convertClassToDTO(selectedClasses), + createdEvents: convertEventToDTO(createdEvents), + name, + mapKey: term, + }), + }); + + const json = await res.json(); + return json.data; // This is the new ID of timetable + } catch (e) { + console.log(e); + } +}; + +const syncDeleteTimetable = async (timetableId: string) => { + try { + await fetch(`${API_URL.server}/user/timetable/${timetableId}`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + } catch (e) { + console.log(e); + } +}; + +const syncEditTimetable = async (userId: string, editedTimetable: TimetableData) => { + try { + if (!userId) { + console.log('User is not logged in'); + return; + } + + await fetch(`${API_URL.server}/user/timetable`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId: userId, + timetable: convertTimetableToDTO(editedTimetable), + }), + }); + } catch (e) { + console.log(e); + } +}; + +/** + * Between two term timetables - find new timetables, deleted timetables, and updated timetable ids + */ +const getTimetableDiffs = (oldTimetables: TimetableData[], newTimetables: TimetableData[]) => { + const diffIds: DiffID = { + delete: new Set(), + update: new Set(), + add: new Set(), + }; + + const oldIds = new Set(oldTimetables.map((t) => t.id)); + const newIds = new Set(newTimetables.map((t) => t.id)); + + diffIds.delete = oldIds.difference(newIds); + diffIds.add = newIds.difference(oldIds); + + oldIds.intersection(newIds).forEach((id) => { + const oldTarget = oldTimetables.find((t) => t.id === id); + const newTarget = newTimetables.find((t) => t.id === id); + + if (JSON.stringify(oldTarget) !== JSON.stringify(newTarget)) { + diffIds.update.add(id); + } + }); + + return diffIds; +}; + +const updateTimetableDiffs = async ( + zid: string, + newTimetables: TimetableData[], + diffIds: DiffID, + term: string, +): Promise => { + diffIds.delete.forEach((id) => { + syncDeleteTimetable(id); + }); + + return Promise.all( + newTimetables.map(async (t) => { + if (diffIds.add.has(t.id)) { + const newId = await syncAddTimetable(zid, t, term); + return { ...t, id: newId }; + } else if (diffIds.update.has(t.id)) { + syncEditTimetable(zid, t); + } + return { ...t }; + }), + ); +}; + +const runSync = ( + user: User, + setUser: (user: User) => void, + newMap: DisplayTimetablesMap, + setMap: (m: DisplayTimetablesMap) => void, +) => { + clearTimeout(timeoutID); + timeoutID = setTimeout(async () => { + const oldMap = { ...user.timetables }; + const trueMap: DisplayTimetablesMap = {}; + + if (JSON.stringify(oldMap) === JSON.stringify(newMap)) { + return; + } + + for (const key of Object.keys(newMap)) { + const oldTimetables = oldMap[key] || []; + const newTimetables = newMap[key]; + + const diffs = getTimetableDiffs(oldTimetables, newTimetables); + + trueMap[key] = await updateTimetableDiffs(user.userID, newTimetables, diffs, key); + } + + // Save to user timetable + setMap(trueMap); + setUser({ ...user, timetables: structuredClone(trueMap) }); + }, 5000); +}; + +export { parseTimetableDTO, runSync }; diff --git a/client/src/utils/timetableHelpers.ts b/client/src/utils/timetableHelpers.ts index 1906a90ea..733383fe4 100644 --- a/client/src/utils/timetableHelpers.ts +++ b/client/src/utils/timetableHelpers.ts @@ -26,7 +26,13 @@ const duplicateClasses = (selectedClasses: SelectedClasses) => { const newActivityCopy: Record = {}; Object.entries(activities).forEach(([activity, classData]) => { - newActivityCopy[activity] = classData !== null ? { ...classData } : null; + if (classData !== null) { + newActivityCopy[activity] = { ...classData }; + newActivityCopy[activity].id = uuidv4(); + } else { + newActivityCopy[activity] = null; + } + // newActivityCopy[activity] = classData !== null ? { ...classData } : null; }); newClasses[courseCode] = { ...newActivityCopy }; }); @@ -139,17 +145,17 @@ const areIdenticalTimetables = ( ); }; -const createDefaultTimetable = (): TimetableData[] => { - return [ - { - name: 'My timetable', - id: uuidv4(), - selectedCourses: [], - selectedClasses: {}, - createdEvents: {}, - assignedColors: {}, - }, - ]; +const createDefaultTimetable = (userID: string | undefined): TimetableData[] => { + const defaultTimetable = { + name: 'My timetable', + id: uuidv4(), + selectedCourses: [], + selectedClasses: {}, + createdEvents: {}, + assignedColors: {}, + }; + + return [defaultTimetable]; }; export { diff --git a/client/tailwind.config.js b/client/tailwind.config.js index f398a2b80..2d69ded12 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -37,12 +37,12 @@ module.exports = { }; // This plugin adds each Tailwind color as a global CSS variable, e.g. var(--gray-200). -function addVariablesForColors({ addBase, theme }) { +function addVariablesForColors({ addBase, theme }: any) { let allColors = flattenColorPalette(theme("colors")); let newVars = Object.fromEntries( Object.entries(allColors).map(([key, val]) => [`--${key}`, val]) ); - + addBase({ ":root": newVars, }); diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index cbbb7eccd..2d4f39637 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -2010,7 +2010,6 @@ packages: /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true /array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} @@ -2210,11 +2209,6 @@ packages: ieee754: 1.2.1 dev: true - /bufferview@1.0.1: - resolution: {integrity: sha512-q87jdvsZ/sEngmDUvPT/PJsBGCi998c3B1U/6IN1uGg+R2HrTFJUDccXZEx6OxpuLySyBDGXc7vkSt4BXTyKxA==} - engines: {node: '>=0.8'} - dev: false - /busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -2838,6 +2832,7 @@ packages: /eslint@8.57.0: resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) @@ -3220,7 +3215,6 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - dev: true /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} @@ -3309,17 +3303,6 @@ packages: path-scurry: 1.11.1 dev: true - /glob@5.0.15: - resolution: {integrity: sha512-c9IPMazfRITpmAAKi22dK1VKxGDX9ehhqfABDriL/lzO92xcUKEJPQHrVA/2YHSNFB4iFlykVmWvwo48nr3OxA==} - deprecated: Glob versions prior to v9 are no longer supported - dependencies: - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - dev: false - /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -3330,7 +3313,6 @@ packages: minimatch: 3.1.2 once: 1.4.0 path-is-absolute: 1.0.1 - dev: true /globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -4179,7 +4161,6 @@ packages: hasBin: true dependencies: argparse: 2.0.1 - dev: true /jsesc@2.5.2: resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} diff --git a/server/prisma/migrations/20241003042905_/migration.sql b/server/prisma/migrations/20241003042905_/migration.sql new file mode 100644 index 000000000..4c9b2fbac --- /dev/null +++ b/server/prisma/migrations/20241003042905_/migration.sql @@ -0,0 +1,40 @@ +/* + Warnings: + + - You are about to drop the column `classType` on the `classes` table. All the data in the column will be lost. + - You are about to drop the column `courseName` on the `classes` table. All the data in the column will be lost. + - You are about to drop the column `userID` on the `timetables` table. All the data in the column will be lost. + - Added the required column `classNo` to the `classes` table without a default value. This is not possible if the table is not empty. + - Added the required column `courseCode` to the `classes` table without a default value. This is not possible if the table is not empty. + - Added the required column `term` to the `classes` table without a default value. This is not possible if the table is not empty. + - Added the required column `year` to the `classes` table without a default value. This is not possible if the table is not empty. + - Added the required column `subtype` to the `events` table without a default value. This is not possible if the table is not empty. + - Changed the type of `day` on the `events` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Changed the type of `start` on the `events` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Changed the type of `end` on the `events` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Added the required column `mapKey` to the `timetables` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "classes" DROP COLUMN "classType", +DROP COLUMN "courseName", +ADD COLUMN "classNo" TEXT NOT NULL, +ADD COLUMN "courseCode" TEXT NOT NULL, +ADD COLUMN "term" TEXT NOT NULL, +ADD COLUMN "year" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "events" ADD COLUMN "subtype" TEXT NOT NULL, +DROP COLUMN "day", +ADD COLUMN "day" INTEGER NOT NULL, +DROP COLUMN "start", +ADD COLUMN "start" INTEGER NOT NULL, +DROP COLUMN "end", +ADD COLUMN "end" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "timetables" DROP COLUMN "userID", +ADD COLUMN "mapKey" TEXT NOT NULL; + +-- DropEnum +DROP TYPE "ClassType"; diff --git a/server/prisma/migrations/20241101072349_activity/migration.sql b/server/prisma/migrations/20241101072349_activity/migration.sql new file mode 100644 index 000000000..b85d4f6d3 --- /dev/null +++ b/server/prisma/migrations/20241101072349_activity/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `activity` to the `classes` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "classes" ADD COLUMN "activity" TEXT NOT NULL; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 857d096f2..11a2d6441 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -49,9 +49,15 @@ model Event { colour String @default("#1F7E8C") // Notangles Blue // Event Time - day String - start DateTime - end DateTime + // TODO: revert this change after custom event dates are added + day Int + start Int + end Int + subtype String + // day String + // start DateTime + // end DateTime + timetable Timetable? @relation(fields: [timetableId], references: [id], onDelete: Cascade) // TODO: was there a reason this wasn't cascaded on delete? timetableId String? groupIds String[] // Added to link an event to a group @@ -67,6 +73,7 @@ model Class { courseCode String Timetable Timetable? @relation(fields: [timetableId], references: [id], onDelete: Cascade) timetableId String? + activity String @@map("classes") } @@ -79,6 +86,7 @@ model Timetable { createdEvents Event[] user User[] groups Group[] @relation("group_timetables") + mapKey String // Key for timetable map @@map("timetables") } diff --git a/server/src/app.module.ts b/server/src/app.module.ts index a6e59e56f..a7ff65f71 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -9,6 +9,8 @@ import { FriendModule } from './friend/friend.module'; import { GroupModule } from './group/group.module'; import { PrismaModule } from './prisma/prisma.module'; import { UserModule } from './user/user.module'; +import { GraphqlService } from './graphql/graphql.service'; +import { GraphqlModule } from './graphql/graphql.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -21,9 +23,10 @@ import { UserModule } from './user/user.module'; UserModule, FriendModule, PrismaModule, + GraphqlModule, GroupModule, ], controllers: [AppController], - providers: [AppService], + providers: [AppService, GraphqlService], }) export class AppModule {} diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index d7fe9aca0..e2aff98da 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -73,12 +73,6 @@ export class AuthController { @UseGuards(LoginGuard) @Get('/callback/csesoc') loginCallback(@Res() res: Response) { - console.log( - this.configService.get( - 'app.redirectLink', - 'https://notangles.devsoc.app/api/auth/callback/csesoc', - ), - ); res.redirect( this.configService.get( 'app.redirectLink', diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts index 8e17ed30b..023a37a54 100644 --- a/server/src/auth/auth.module.ts +++ b/server/src/auth/auth.module.ts @@ -6,6 +6,7 @@ import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { UserService } from 'src/user/user.service'; import { PrismaService } from 'src/prisma/prisma.service'; +import { GraphqlService } from 'src/graphql/graphql.service'; const OidcStrategyFactory = { provide: 'OidcStrategy', @@ -27,6 +28,7 @@ const OidcStrategyFactory = { AuthService, UserService, PrismaService, + GraphqlService, ], }) export class AuthModule {} diff --git a/server/src/auth/oidc.strategy.ts b/server/src/auth/oidc.strategy.ts index 7c2f93116..db7e5ab41 100644 --- a/server/src/auth/oidc.strategy.ts +++ b/server/src/auth/oidc.strategy.ts @@ -52,6 +52,7 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { refresh_token, userinfo, }; + return user; } catch (err) { throw new UnauthorizedException(); diff --git a/server/src/graphql/graphql.module.ts b/server/src/graphql/graphql.module.ts new file mode 100644 index 000000000..19b41c5b5 --- /dev/null +++ b/server/src/graphql/graphql.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { GraphqlService } from './graphql.service'; + +@Module({ + providers: [GraphqlService], +}) +export class GraphqlModule {} diff --git a/server/src/graphql/graphql.response.ts b/server/src/graphql/graphql.response.ts new file mode 100644 index 000000000..f47b7d774 --- /dev/null +++ b/server/src/graphql/graphql.response.ts @@ -0,0 +1,24 @@ +export interface GQLCourseInfo { + course_code: string; + course_name: string; + classes: { + activity: string; + status: string; + course_enrolment: string; + class_id: string; + term: string; + section: string; + times: { + day: string; + time: string; + weeks: string; + location: string; + }[]; + }[]; +} + +export type GQLCourseData = { + data: { + courses: GQLCourseInfo[]; + }; +}; diff --git a/server/src/graphql/graphql.service.spec.ts b/server/src/graphql/graphql.service.spec.ts new file mode 100644 index 000000000..b390186b5 --- /dev/null +++ b/server/src/graphql/graphql.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GraphqlService } from './graphql.service'; + +describe('GraphqlService', () => { + let service: GraphqlService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [GraphqlService], + }).compile(); + + service = module.get(GraphqlService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/server/src/graphql/graphql.service.ts b/server/src/graphql/graphql.service.ts new file mode 100644 index 000000000..b52c78e15 --- /dev/null +++ b/server/src/graphql/graphql.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { GQLCourseData } from './graphql.response'; +const HASURAGRES_GRAPHQL_API = 'https://graphql.csesoc.app/v1/graphql'; + +export const GET_COURSE_INFO = ` + query GetCourseInfo($courseCode: String!, $term: String!, $year: String!) { + courses(where: { course_code: { _eq: $courseCode } }) { + course_code + course_name + classes( + where: { + term: { _eq: $term } + year: { _eq: $year } + activity: { _neq: "Course Enrolment" } + } + ) { + activity + status + course_enrolment + class_id + term + section + times { + day + time + weeks + location + } + consent + mode + class_notes + } + } + } +`; + +@Injectable() +export class GraphqlService { + async fetchData( + query: string, + variables?: Record, + ): Promise { + try { + const data = await fetch(HASURAGRES_GRAPHQL_API, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + variables, + }), + }); + return data.json(); + } catch (error) { + console.error('GraphQL Request Error:', error); + throw error; + } + } + async fetchCourseData( + courseCode: string, + term: string, + year: string, + ): Promise { + return this.fetchData(GET_COURSE_INFO, { courseCode, term, year }); + } +} diff --git a/server/src/group/group.module.ts b/server/src/group/group.module.ts index 498d1e7c9..bdf62e060 100644 --- a/server/src/group/group.module.ts +++ b/server/src/group/group.module.ts @@ -3,9 +3,10 @@ import { GroupService } from './group.service'; import { GroupController } from './group.controller'; import { PrismaService } from 'src/prisma/prisma.service'; import { UserService } from 'src/user/user.service'; +import { GraphqlService } from 'src/graphql/graphql.service'; @Module({ controllers: [GroupController], - providers: [GroupService, PrismaService, UserService], + providers: [GroupService, PrismaService, UserService, GraphqlService], }) export class GroupModule {} diff --git a/server/src/user/dto/timetable.dto.ts b/server/src/user/dto/timetable.dto.ts index b9481e09c..e8dc1e10e 100644 --- a/server/src/user/dto/timetable.dto.ts +++ b/server/src/user/dto/timetable.dto.ts @@ -10,6 +10,7 @@ export class TimetableDto { selectedClasses: ClassDto[]; createdEvents: EventDto[]; name?: string; + mapKey: string; } export class ReconstructedTimetableDto { @@ -22,6 +23,7 @@ export class ReconstructedTimetableDto { selectedClasses: ScrapedClassDto[]; createdEvents: EventDto[]; name?: string; + mapKey: string; } export class ClassDto { @@ -31,6 +33,7 @@ export class ClassDto { term: string; courseCode: string; timetableId?: string; + activity: string; } export class ClassTimeDto { @@ -45,7 +48,7 @@ export class ClassTimeDto { // Get class from scraper export class ScrapedClassDto { - classID: number; + classID: string; section: string; term: string; activity: string; @@ -61,6 +64,7 @@ export class ScrapedClassDto { needsConsent: boolean; mode: string; times: ClassTimeDto[]; + courseCode: string; notes: []; } diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 4f07fbf6e..50d4ec6c5 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -71,6 +71,7 @@ export class UserController { @Body('selectedCourses') selectedCourses: string[], @Body('selectedClasses') selectedClasses: ClassDto[], @Body('createdEvents') createdEvents: EventDto[], + @Body('mapKey') mapKey: string, @Body('name') timetableName?: string, ) { return this.userService @@ -79,6 +80,7 @@ export class UserController { selectedCourses, selectedClasses, createdEvents, + mapKey, timetableName, ) .then((res) => { @@ -90,7 +92,7 @@ export class UserController { } @Put('timetable') - editUserTimetable( + async editUserTimetable( @Body('userId') userId: string, @Body('timetable') timetable: TimetableDto, ) { diff --git a/server/src/user/user.module.ts b/server/src/user/user.module.ts index 1b541b22a..9c2545efd 100644 --- a/server/src/user/user.module.ts +++ b/server/src/user/user.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { UserService } from './user.service'; import { UserController } from './user.controller'; import { PrismaService } from 'src/prisma/prisma.service'; +import { GraphqlService } from 'src/graphql/graphql.service'; @Module({ - providers: [UserService, PrismaService], + providers: [UserService, PrismaService, GraphqlService], controllers: [UserController], }) export class UserModule {} diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 4adbe0471..778ea4261 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -13,11 +13,14 @@ import { PrismaService } from 'src/prisma/prisma.service'; import { v4 as uuidv4 } from 'uuid'; import { User } from '@prisma/client'; import { GroupDto } from 'src/group/dto/group.dto'; +import { GraphqlService } from 'src/graphql/graphql.service'; -const API_URL = 'https://timetable.csesoc.app/api/terms'; @Injectable({}) export class UserService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly gql: GraphqlService, + ) {} private async convertClasses( classes: ClassDto[], @@ -25,21 +28,58 @@ export class UserService { try { // For each class in class DTO, we need to fetch information const cache = {}; - for (const clz of classes) { const k = `${clz.year}-${clz.term}/courses/${clz.courseCode}`; - if (!(k in cache)) { - const data = await fetch(`${API_URL}/${k}`); - const json = await data.json(); - cache[k] = json.classes; + + if (!(k in cache) && clz.classNo !== '') { + const courseInfoFetchPromise = await this.gql.fetchCourseData( + clz.courseCode, + clz.term, + clz.year, + ); + const courseInfoFetch = courseInfoFetchPromise.data.courses; + cache[k] = courseInfoFetch[0].classes; } } - return classes.map((clz) => { + const res = classes.map((clz) => { + if (clz.classNo === '') + return { + classID: '', + courseCode: clz.courseCode, + activity: clz.activity, + }; const k = `${clz.year}-${clz.term}/courses/${clz.courseCode}`; - const data = cache[k].find((c) => String(c.classID) === clz.classNo); - return data; + const { + class_id, + course_enrolment, + consent, + times, + class_notes, + ...data + } = cache[k].find((c) => c.class_id === clz.classNo); + + const [enrolments, capacity] = course_enrolment.split('/'); + const [start, end] = times[0].time.replace(/\s/g, '').split('-'); + return { + ...data, + classID: class_id, + courseEnrolment: { + enrolments: Number(enrolments), + capacity: Number(capacity), + }, + termDates: { + start: '', + end: '', + }, + times: { ...times[0], time: { start, end } }, + needsConsent: consent == 'Consent not required', + courseCode: clz.courseCode, + notes: [class_notes], + }; }); + + return res; } catch (e) { throw new Error(e); } @@ -48,6 +88,7 @@ export class UserService { private async convertTimetable(timetable: TimetableDto): Promise { try { const c = await this.convertClasses(timetable.selectedClasses); + return { ...timetable, selectedClasses: c, @@ -171,6 +212,7 @@ export class UserService { _selectedCourses: string[], _selectedClasses: ClassDto[], _createdEvents: EventDto[], + _mapKey: string, _timetableName?: string, ): Promise { try { @@ -182,6 +224,7 @@ export class UserService { id: _timetableId, name: _timetableName, selectedCourses: _selectedCourses, + mapKey: _mapKey, selectedClasses: { create: _selectedClasses, }, @@ -217,6 +260,7 @@ export class UserService { data: { name: _timetable.name, selectedCourses: _timetable.selectedCourses, + mapKey: _timetable.mapKey, }, });