diff --git a/apps/backend/src/bootstrap/loaders/passport.ts b/apps/backend/src/bootstrap/loaders/passport.ts index d2e3d966e..0e7207b29 100644 --- a/apps/backend/src/bootstrap/loaders/passport.ts +++ b/apps/backend/src/bootstrap/loaders/passport.ts @@ -139,7 +139,7 @@ export default async (app: Application, redis: RedisClientType) => { const email = profile.emails?.[0].value; if (!email) { - return done(null, false, { message: "No email found" }); + return done(null, false, { message: "Invalid" }); } let user = await UserModel.findOne({ email }); @@ -147,9 +147,9 @@ export default async (app: Application, redis: RedisClientType) => { if (!user) { user = new UserModel({ email, - google_id: profile.id, + googleId: profile.id, name: profile.displayName, - // refresh_token: refreshToken, <-------------- currently not needed. + // TODO: refreshToken }); } diff --git a/apps/backend/src/modules/course/controller.ts b/apps/backend/src/modules/course/controller.ts index 732d99906..678f2d6bb 100644 --- a/apps/backend/src/modules/course/controller.ts +++ b/apps/backend/src/modules/course/controller.ts @@ -10,12 +10,12 @@ import { IntermediateCourse, formatCourse } from "./formatter"; export const getCourse = async ( subject: string, - courseNumber: string, + number: string, info?: GraphQLResolveInfo | null ) => { const course = await CourseModel.findOne({ "subjectArea.code": subject, - "catalogNumber.formatted": courseNumber, + "catalogNumber.formatted": number, }) .sort({ fromDate: -1 }) .lean(); diff --git a/apps/backend/src/modules/schedule/controller.ts b/apps/backend/src/modules/schedule/controller.ts index 0ed078813..4368645a0 100644 --- a/apps/backend/src/modules/schedule/controller.ts +++ b/apps/backend/src/modules/schedule/controller.ts @@ -1,10 +1,15 @@ -import { ScheduleModel, TermModel } from "@repo/common"; +import { ClassModel, ScheduleModel, TermModel } from "@repo/common"; import { CreateScheduleInput, + SelectedClassInput, + Semester, UpdateScheduleInput, } from "../../generated-types/graphql"; +import { formatClass } from "../class/formatter"; +import { ClassModule } from "../class/generated-types/module-types"; import { formatSchedule } from "./formatter"; +import { ScheduleModule } from "./generated-types/module-types"; export const getSchedules = async (context: any) => { if (!context.user._id) throw new Error("Unauthorized"); @@ -91,3 +96,29 @@ export const updateSchedule = async ( return await formatSchedule(schedule); }; + +export const getClasses = async ( + year: Number, + semester: Semester, + selectedClasses: SelectedClassInput[] +) => { + const classes = []; + + for (const selectedClass of selectedClasses) { + const _class = await ClassModel.findOne({ + number: selectedClass.number, + "course.subjectArea.code": selectedClass.subject, + "course.catalogNumber.formatted": selectedClass.courseNumber, + "session.term.name": `${year} ${semester}`, + }).lean(); + + if (!_class) continue; + + classes.push({ + class: formatClass(_class) as unknown as ClassModule.Class, + selectedSections: selectedClass.sections, + } as ScheduleModule.SelectedClass); + } + + return classes; +}; diff --git a/apps/backend/src/modules/schedule/formatter.ts b/apps/backend/src/modules/schedule/formatter.ts index e4e8e6e8a..4fc2b9ef4 100644 --- a/apps/backend/src/modules/schedule/formatter.ts +++ b/apps/backend/src/modules/schedule/formatter.ts @@ -1,38 +1,22 @@ -import { ClassModel, ScheduleType } from "@repo/common"; +import { ScheduleType } from "@repo/common"; -import { formatClass } from "../class/formatter"; -import { ClassModule } from "../class/generated-types/module-types"; import { ScheduleModule } from "./generated-types/module-types"; -export type IntermediateSchedule = Omit & { +export type IntermediateSchedule = Omit< + ScheduleModule.Schedule, + "term" | "classes" +> & { term: null; + classes: ScheduleModule.SelectedClassInput[]; }; export const formatSchedule = async (schedule: ScheduleType) => { - const classes = []; - - for (const selectedClass of schedule.classes) { - const _class = await ClassModel.findOne({ - number: selectedClass.number, - "course.subjectArea.code": selectedClass.subject, - "course.catalogNumber.formatted": selectedClass.courseNumber, - "session.term.name": `${schedule.year} ${schedule.semester}`, - }).lean(); - - if (!_class) continue; - - classes.push({ - class: formatClass(_class) as unknown as ClassModule.Class, - selectedSections: selectedClass.sections, - }); - } - return { _id: schedule._id as string, name: schedule.name, createdBy: schedule.createdBy, public: schedule.public, - classes, + classes: schedule.classes, year: schedule.year, semester: schedule.semester, term: null, diff --git a/apps/backend/src/modules/schedule/resolver.ts b/apps/backend/src/modules/schedule/resolver.ts index e03338363..81d84cc36 100644 --- a/apps/backend/src/modules/schedule/resolver.ts +++ b/apps/backend/src/modules/schedule/resolver.ts @@ -4,6 +4,7 @@ import { TermModule } from "../term/generated-types/module-types"; import { createSchedule, deleteSchedule, + getClasses, getSchedule, getSchedules, updateSchedule, @@ -34,6 +35,22 @@ const resolvers: ScheduleModule.Resolvers = { return term as unknown as TermModule.Term; }, + + classes: async (parent: IntermediateSchedule | ScheduleModule.Schedule) => { + if ( + parent.classes[0] && + (parent.classes[0] as ScheduleModule.SelectedClass).class + ) + return parent.classes as ScheduleModule.SelectedClass[]; + + const classes = await getClasses( + parent.year, + parent.semester as Semester, + parent.classes as ScheduleModule.SelectedClassInput[] + ); + + return classes; + }, }, Mutation: { diff --git a/apps/backend/src/modules/schedule/typedefs/schedule.ts b/apps/backend/src/modules/schedule/typedefs/schedule.ts index 5778084a7..14d7c382f 100644 --- a/apps/backend/src/modules/schedule/typedefs/schedule.ts +++ b/apps/backend/src/modules/schedule/typedefs/schedule.ts @@ -3,7 +3,7 @@ import { gql } from "graphql-tag"; const typedef = gql` type SelectedClass { class: Class! - selectedSections: [Int!] + selectedSections: [String!] } type Event { @@ -45,7 +45,7 @@ const typedef = gql` subject: String! courseNumber: String! number: String! - sections: [Int!]! + sections: [String!]! } input UpdateScheduleInput { diff --git a/apps/backend/src/modules/user/controller.ts b/apps/backend/src/modules/user/controller.ts index 05d93f6db..400599f17 100644 --- a/apps/backend/src/modules/user/controller.ts +++ b/apps/backend/src/modules/user/controller.ts @@ -1,6 +1,10 @@ -import { UserModel } from "@repo/common"; +import { ClassModel, CourseModel, UserModel } from "@repo/common"; +import { UpdateUserInput } from "../../generated-types/graphql"; +import { formatClass } from "../class/formatter"; +import { formatCourse } from "../course/formatter"; import { formatUser } from "./formatter"; +import { UserModule } from "./generated-types/module-types"; export const getUser = async (context: any) => { if (!context.user._id) throw new Error("Unauthorized"); @@ -11,3 +15,59 @@ export const getUser = async (context: any) => { return formatUser(user); }; + +export const updateUser = async (context: any, user: UpdateUserInput) => { + if (!context.user._id) throw new Error("Unauthorized"); + + const updatedUser = await UserModel.findByIdAndUpdate( + context.user._id, + user, + { new: true } + ); + + if (!updatedUser) throw new Error("Invalid"); + + return formatUser(updatedUser); +}; + +export const getBookmarkedCourses = async ( + bookmarkedCourses: UserModule.BookmarkedCourseInput[] +) => { + const courses = []; + + for (const bookmarkedCourse of bookmarkedCourses) { + const course = await CourseModel.findOne({ + "subjectArea.code": bookmarkedCourse.subject, + "catalogNumber.formatted": bookmarkedCourse.number, + }) + .sort({ fromDate: -1 }) + .lean(); + + if (!course) continue; + + courses.push(course); + } + + return courses.map(formatCourse); +}; + +export const getBookmarkedClasses = async ( + bookmarkedClasses: UserModule.BookmarkedClassInput[] +) => { + const classes = []; + + for (const bookmarkedClass of bookmarkedClasses) { + const _class = await ClassModel.findOne({ + number: bookmarkedClass.number, + "course.subjectArea.code": bookmarkedClass.subject, + "course.catalogNumber.formatted": bookmarkedClass.courseNumber, + "session.term.name": `${bookmarkedClass.year} ${bookmarkedClass.semester}`, + }).lean(); + + if (!_class) continue; + + classes.push(_class); + } + + return classes.map(formatClass); +}; diff --git a/apps/backend/src/modules/user/formatter.ts b/apps/backend/src/modules/user/formatter.ts index 35a6388bc..8be92bc6c 100644 --- a/apps/backend/src/modules/user/formatter.ts +++ b/apps/backend/src/modules/user/formatter.ts @@ -2,9 +2,19 @@ import { UserType } from "@repo/common"; import { UserModule } from "./generated-types/module-types"; +export type IntermediateUser = Omit< + UserModule.User, + "bookmarkedClasses" | "bookmarkedCourses" +> & { + bookmarkedCourses: UserModule.BookmarkedCourseInput[]; + bookmarkedClasses: UserModule.BookmarkedClassInput[]; +}; + export const formatUser = (user: UserType) => { return { email: user.email, student: user.email.endsWith("@berkeley.edu"), - } as UserModule.User; + bookmarkedCourses: user.bookmarkedCourses, + bookmarkedClasses: user.bookmarkedClasses, + } as IntermediateUser; }; diff --git a/apps/backend/src/modules/user/resolver.ts b/apps/backend/src/modules/user/resolver.ts index 742898aed..f19b82208 100644 --- a/apps/backend/src/modules/user/resolver.ts +++ b/apps/backend/src/modules/user/resolver.ts @@ -1,10 +1,52 @@ -import { getUser } from "./controller"; +import { + getBookmarkedClasses, + getBookmarkedCourses, + getUser, + updateUser, +} from "./controller"; +import { IntermediateUser } from "./formatter"; import { UserModule } from "./generated-types/module-types"; const resolvers: UserModule.Resolvers = { Query: { - user(_, __, context) { - return getUser(context); + user: async (_, __, context) => { + const user = await getUser(context); + + return user as unknown as UserModule.User; + }, + }, + + User: { + bookmarkedClasses: async (parent: UserModule.User | IntermediateUser) => { + if ( + parent.bookmarkedClasses[0] && + (parent.bookmarkedClasses[0] as UserModule.Class).title + ) + return parent.bookmarkedClasses as UserModule.Class[]; + + const classes = await getBookmarkedClasses(parent.bookmarkedClasses); + + return classes as unknown as UserModule.Class[]; + }, + + bookmarkedCourses: async (parent: UserModule.User | IntermediateUser) => { + if ( + parent.bookmarkedCourses[0] && + (parent.bookmarkedCourses[0] as UserModule.Course).title + ) + return parent.bookmarkedCourses as UserModule.Course[]; + + const courses = await getBookmarkedCourses(parent.bookmarkedCourses); + + return courses as unknown as UserModule.Course[]; + }, + }, + + Mutation: { + updateUser: async (_, { user: input }, context) => { + const user = await updateUser(context, input); + + return user as unknown as UserModule.User; }, }, }; diff --git a/apps/backend/src/modules/user/typedefs/user.ts b/apps/backend/src/modules/user/typedefs/user.ts index 7720deae1..8e2481954 100644 --- a/apps/backend/src/modules/user/typedefs/user.ts +++ b/apps/backend/src/modules/user/typedefs/user.ts @@ -4,11 +4,35 @@ const typedef = gql` type User { email: String! student: Boolean! + bookmarkedCourses: [Course!]! + bookmarkedClasses: [Class!]! } type Query { user: User @auth } + + input BookmarkedCourseInput { + subject: String! + number: String! + } + + input BookmarkedClassInput { + year: Int! + semester: Semester! + subject: String! + courseNumber: String! + number: String! + } + + input UpdateUserInput { + bookmarkedClasses: [BookmarkedClassInput!] + bookmarkedCourses: [BookmarkedCourseInput!] + } + + type Mutation { + updateUser(user: UpdateUserInput!): User @auth + } `; export default typedef; diff --git a/apps/frontend/schema.graphql b/apps/frontend/schema.graphql index 8a5c45991..af63ffd82 100644 --- a/apps/frontend/schema.graphql +++ b/apps/frontend/schema.graphql @@ -14,6 +14,8 @@ directive @oneOf on INPUT_OBJECT type User { email: String! student: Boolean! + bookmarkedCourses: [Course!]! + bookmarkedClasses: [Class!]! } type Query { @@ -24,47 +26,62 @@ type Query { classNum: String term: TermInput ): Grade - course(subject: String!, courseNumber: String!, term: TermInput): Course + catalog(term: TermInput!): [Course!]! + ping: String! @deprecated(reason: "test") + schedules: [Schedule] + schedule(id: ID!): Schedule + + """ + Query for terms. + """ + terms: [Term] + + """ + Query for a term. + """ + term(year: Int!, semester: Semester!): Term + course(subject: String!, number: String!): Course + courses: [Course!]! class( + year: Int! + semester: Semester! subject: String! courseNumber: String! - term: TermInput! - classNumber: String! + number: String! ): Class section( + year: Int! + semester: Semester! subject: String! courseNumber: String! - term: TermInput! classNumber: String! - sectionNumber: String! + number: String! ): Section +} - """ - Get info about all courses and their corresponding classes for a given semester. - - Used primarily in the catalog page. - """ - catalog(term: TermInput!): [Course!] - - """ - Get a list of all course names across all semesters. +input BookmarkedCourseInput { + subject: String! + number: String! +} - Useful for searching for courses. - """ - courseList: [Course!] - ping: String! @deprecated(reason: "test") - schedules: [Schedule] - schedule(id: String!): Schedule +input BookmarkedClassInput { + year: Int! + semester: Semester! + subject: String! + courseNumber: String! + number: String! +} - """ - Query for terms. - """ - terms: [Term] +input UpdateUserInput { + bookmarkedClasses: [BookmarkedClassInput!] + bookmarkedCourses: [BookmarkedCourseInput!] +} - """ - Query for a term. - """ - term(year: Int!, semester: Semester!): Term +type Mutation { + updateUser(user: UpdateUserInput!): User + deleteSchedule(id: ID!): ID + createSchedule(schedule: CreateScheduleInput!): Schedule + updateSchedule(id: ID!, schedule: UpdateScheduleInput!): Schedule } type Grade { @@ -77,14 +94,144 @@ type GradeDistributionItem { count: Int! } +enum CacheControlScope { + PUBLIC + PRIVATE +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON + """ -Info shared between Classes within and across semesters. +The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSONObject + +""" +ISODate custom scalar type +""" +scalar ISODate + +""" +The combination of year and season that corresponds to a specific term. Both year and season/semester are required. """ +input TermInput { + year: Int! + semester: Semester! +} + +enum Semester { + Summer + Fall + Spring + Winter +} + +type SelectedClass { + class: Class! + selectedSections: [String!] +} + +type Event { + startTime: String! + endTime: String! + days: [Boolean!]! + location: String + title: String! + description: String +} + +type Schedule { + _id: ID + name: String! + createdBy: String! + year: Int! + semester: String! + term: Term! + public: Boolean! + classes: [SelectedClass!]! + events: [Event!]! +} + +input EventInput { + startTime: String! + endTime: String! + days: [Boolean!]! + location: String + title: String! + description: String +} + +input SelectedClassInput { + subject: String! + courseNumber: String! + number: String! + sections: [String!]! +} + +input UpdateScheduleInput { + name: String + events: [EventInput] + classes: [SelectedClassInput] + public: Boolean +} + +input CreateScheduleInput { + name: String! + year: Int! + semester: String! + events: [EventInput!] + classes: [SelectedClassInput!] + public: Boolean +} + +enum TemporalPosition { + Past + Current + Future +} + +""" +Session +""" +type Session { + temporalPosition: TemporalPosition! + name: String! + startDate: String + endDate: String +} + +""" +Term +""" +type Term { + semester: Semester! + year: Int! + temporalPosition: TemporalPosition! + startDate: String! + endDate: String! + sessions: [Session!]! +} + type Course { - classes(term: TermInput): [Class!]! + """ + Identifiers + """ + subject: String! + number: String! + + """ + Relationships + """ + classes: [Class!]! crossListing: [Course!]! - sections(term: TermInput, primary: Boolean): [Section!]! requiredCourses: [Course!]! + + """ + Attributes + """ requirements: String description: String! fromDate: String! @@ -92,31 +239,18 @@ type Course { gradingBasis: CourseGradingBasis! finalExam: CourseFinalExam academicCareer: AcademicCareer! - number: String! - subject: String! title: String! primaryInstructionMethod: InstructionMethod! toDate: String! typicallyOffered: [String!] - raw: JSONObject! - lastUpdated: ISODate! } -enum AcademicCareer { - """ - Undergraduate - """ - UGRD - - """ - Graduate - """ - GRAD - - """ - UC Extension - """ - UCBX +enum CourseGradingBasis { + completedNotation + passFail + letter + satisfactory + graded } enum CourseFinalExam { @@ -146,268 +280,269 @@ enum CourseFinalExam { Y } -enum CourseGradingBasis { - completedNotation - passFail - letter - satisfactory - graded -} +enum AcademicCareer { + """ + Undergraduate + """ + UGRD -""" -Data for a specific class in a specific semester. There may be more than one Class for a given Course in a given semester. -""" -type Class { - course: Course! - primarySection: Section! - sections: [Section!]! - session: Session! - gradingBasis: ClassGradingBasis! - finalExam: ClassFinalExam! - description: String - title: String - number: String! - semester: Semester! - year: Int! - unitsMax: Float! - unitsMin: Float! - raw: JSONObject! - lastUpdated: ISODate! + """ + Graduate + """ + GRAD + + """ + UC Extension + """ + UCBX } -enum ClassFinalExam { +enum InstructionMethod { """ - Yes + Unknown """ - Y + UNK """ - No + Demonstration """ - N + DEM """ - Alernate Method + Conversation """ - A + CON """ - Common Final + Workshop """ - C + WOR """ - Last Class Meeting + Web-Based Discussion """ - L -} + WBD -enum ClassGradingBasis { """ - Elective Satisfactory/Unsat + Clinic """ - ESU + CLC """ - Satisfactory/Unsatisfactory + Directed Group Study """ - SUS + GRP """ - Student Option + Discussion """ - OPT + DIS """ - Pass/Not Pass + Tutorial """ - PNP + TUT """ - Multi-Term Course: Not Graded + Field Work """ - BMT + FLD """ - Graded + Lecture """ - GRD + LEC """ - Instructor Option + Laboratory """ - IOP -} + LAB -""" -Session -""" -type Session { - temporalPosition: TemporalPosition! - name: String! - startDate: String - endDate: String -} + """ + Session + """ + SES -""" -Sections are each associated with one Class. -""" -type Section { - class: Class! - course: Course! - enrollmentHistory: [EnrollmentDay!] - ccn: Int! - number: String! - primary: Boolean! - component: Component! - meetings: [Meeting!]! - exams: [Exam!]! - startDate: String! - endDate: String! - online: Boolean! - open: Boolean! - reservations: [Reservation!] - enrollCount: Int! - waitlistCount: Int! - enrollMax: Int! - waitlistMax: Int! - raw: JSONObject! - lastUpdated: ISODate! -} + """ + Studio + """ + STD -enum Component { """ - Workshop + Self-paced """ - WOR + SLF """ - Web-Based Discussion + Colloquium """ - WBD + COL """ - Clinic + Web-Based Lecture """ - CLN + WBL """ - Practicum + Independent Study """ - PRA + IND """ - Directed Group Study + Internship """ - GRP + INT """ - Discussion + Reading """ - DIS + REA """ - Voluntary + Recitation """ - VOL + REC """ - Tutorial + Seminar """ - TUT + SEM +} +type Class { """ - Field Work + Identifiers """ - FLD + subject: String! + courseNumber: String! + number: String! + year: Int! + semester: Semester! + session: String! """ - Lecture + Relationships """ - LEC + course: Course! + primarySection: Section! + sections: [Section!]! + term: Term! """ - Supplementary + Attributes """ - SUP + gradingBasis: ClassGradingBasis! + finalExam: ClassFinalExam! + description: String + title: String + unitsMax: Float! + unitsMin: Float! +} +enum ClassFinalExam { """ - Laboratory + Yes """ - LAB + Y """ - Session + No """ - SES + N """ - Studio + Alernate Method """ - STD + A """ - Self-paced + Common Final """ - SLF + C """ - Colloquium + Last Class Meeting """ - COL + L +} +enum ClassGradingBasis { """ - Web-Based Lecture + Elective Satisfactory/Unsat """ - WBL + ESU """ - Independent Study + Satisfactory/Unsatisfactory """ - IND + SUS """ - Internship + Student Option """ - INT + OPT """ - Reading + Pass/Not Pass """ - REA + PNP """ - Recitation + Multi-Term Course: Not Graded """ - REC + BMT """ - Seminar + Graded """ - SEM + GRD """ - Demonstration + Instructor Option """ - DEM + IOP } -enum InstructionMethod { +type Section { """ - Unknown + Identifiers """ - UNK + subject: String! + courseNumber: String! + classNumber: String! + number: String! + year: Int! + semester: Semester! + ccn: Int! """ - Demonstration + Relationships """ - DEM + class: Class! + course: Course! + term: Term! """ - Conversation + Attributes """ - CON + session: String! + primary: Boolean! + enrollmentHistory: [EnrollmentDay!] + component: Component! + meetings: [Meeting!]! + exams: [Exam!]! + startDate: String! + endDate: String! + online: Boolean! + open: Boolean! + reservations: [Reservation!] + enrollCount: Int! + waitlistCount: Int! + enrollMax: Int! + waitlistMax: Int! +} +enum Component { """ Workshop """ @@ -421,7 +556,12 @@ enum InstructionMethod { """ Clinic """ - CLC + CLN + + """ + Practicum + """ + PRA """ Directed Group Study @@ -433,6 +573,11 @@ enum InstructionMethod { """ DIS + """ + Voluntary + """ + VOL + """ Tutorial """ @@ -448,6 +593,11 @@ enum InstructionMethod { """ LEC + """ + Supplementary + """ + SUP + """ Laboratory """ @@ -502,6 +652,11 @@ enum InstructionMethod { Seminar """ SEM + + """ + Demonstration + """ + DEM } type Reservation { @@ -539,127 +694,3 @@ type EnrollmentDay { waitlistCount: Int! waitlistMax: Int! } - -type CourseListItem { - subject: String! - number: String! -} - -enum CacheControlScope { - PUBLIC - PRIVATE -} - -""" -The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSON - -""" -The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSONObject - -""" -ISODate custom scalar type -""" -scalar ISODate - -""" -The combination of year and season that corresponds to a specific term. Both year and season/semester are required. -""" -input TermInput { - year: Int! - semester: Semester! -} - -enum Semester { - Summer - Fall - Spring - Winter -} - -type TermOutput { - year: Int! - semester: String! -} - -type SelectedClass { - class: Class! - selectedSections: [String!] -} - -type Event { - startTime: String! - endTime: String! - days: [Boolean!]! - location: String - title: String! - description: String -} - -type Schedule { - _id: ID - name: String! - createdBy: String! - term: TermOutput! - public: Boolean! - classes: [SelectedClass!] - events: [Event!] -} - -input EventInput { - startTime: String! - endTime: String! - days: [Boolean!]! - location: String - title: String! - description: String -} - -input SelectedClassInput { - subject: String! - courseNumber: String! - classNumber: String! - sections: [String!]! -} - -input UpdateScheduleInput { - name: String - events: [EventInput] - courses: [SelectedClassInput] - public: Boolean -} - -input CreateScheduleInput { - name: String! - term: TermInput! - events: [EventInput!] - courses: [SelectedClassInput!] - public: Boolean -} - -type Mutation { - deleteSchedule(id: ID!): ID - createSchedule(schedule: CreateScheduleInput!): Schedule - updateSchedule(id: ID!, schedule: UpdateScheduleInput!): Schedule -} - -enum TemporalPosition { - Past - Current - Future -} - -""" -Term -""" -type Term { - semester: Semester! - year: Int! - temporalPosition: TemporalPosition! - startDate: String - endDate: String - sessions: [Session!]! -} diff --git a/apps/frontend/src/app/Schedules/index.tsx b/apps/frontend/src/app/Schedules/index.tsx index 3ccfe0927..c7777097c 100644 --- a/apps/frontend/src/app/Schedules/index.tsx +++ b/apps/frontend/src/app/Schedules/index.tsx @@ -2,12 +2,11 @@ import { Link } from "react-router-dom"; import { Container } from "@repo/theme"; -import { useCreateSchedule, useReadSchedules } from "@/hooks/api"; -import useUser from "@/hooks/useUser"; +import { useCreateSchedule, useReadSchedules, useReadUser } from "@/hooks/api"; import { Semester } from "@/lib/api"; export default function Schedules() { - const { data: user, loading: userLoading } = useUser(); + const { data: user, loading: userLoading } = useReadUser(); const { data: schedules, loading: schedulesLoading } = useReadSchedules({ skip: !user, diff --git a/apps/frontend/src/components/Course/index.tsx b/apps/frontend/src/components/Course/index.tsx index 89edfcbfa..b79fd07c9 100644 --- a/apps/frontend/src/components/Course/index.tsx +++ b/apps/frontend/src/components/Course/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode, Suspense, useMemo, useState } from "react"; +import { ReactNode, Suspense, useMemo } from "react"; import * as Tabs from "@radix-ui/react-tabs"; import classNames from "classnames"; @@ -8,6 +8,8 @@ import { Expand, GridPlus, Link as LinkIcon, + Pin, + PinSolid, ShareIos, Xmark, } from "iconoir-react"; @@ -25,7 +27,9 @@ import { import AverageGrade from "@/components/AverageGrade"; import CourseContext from "@/contexts/CourseContext"; -import { useReadCourse } from "@/hooks/api"; +import { CoursePin } from "@/contexts/PinsContext"; +import { useReadCourse, useReadUser, useUpdateUser } from "@/hooks/api"; +import usePins from "@/hooks/usePins"; import { ICourse } from "@/lib/api"; import styles from "./Class.module.scss"; @@ -94,10 +98,13 @@ export default function Course({ dialog, course: providedCourse, }: CourseProps) { + const { pins, addPin, removePin } = usePins(); + const location = useLocation(); - // TODO: Bookmarks - const [bookmarked, setBookmarked] = useState(false); + const { data: user } = useReadUser(); + + const [updateUser] = useUpdateUser(); const { data, loading } = useReadCourse(subject as string, number as string, { // Allow course to be provided @@ -106,6 +113,45 @@ export default function Course({ const course = useMemo(() => providedCourse ?? data, [providedCourse, data]); + const input = useMemo(() => { + if (!course) return; + + return { + subject: course.subject, + number: course.number, + }; + }, [course]); + + const pin = useMemo(() => { + if (!input) return; + + return { + id: `${input.subject}-${input.number}`, + type: "course", + data: input, + } as CoursePin; + }, [input, pins]); + + const pinned = useMemo(() => { + if (!input) return; + + return pins.find( + (pin) => + pin.type === "course" && + pin.data.subject === input.subject && + pin.data.number === input.number + ); + }, [input, pins]); + + const bookmarked = useMemo(() => { + if (!user || !input) return; + + return user.bookmarkedCourses.some( + (course) => + course.subject === input.subject && course.number === input.number + ); + }, [user, input]); + const context = useMemo(() => { if (!course) return; @@ -121,6 +167,39 @@ export default function Course({ [context] ); + const bookmark = async () => { + if (!user || !course) return; + + const bookmarked = user.bookmarkedCourses.some( + (course) => + course.subject === course.subject && course.number === course.number + ); + + const bookmarkedCourses = bookmarked + ? user.bookmarkedCourses.filter( + (course) => + course.subject !== course.subject || course.number !== course.number + ) + : user.bookmarkedCourses.concat(course); + + await updateUser( + { + bookmarkedCourses: bookmarkedCourses.map((course) => ({ + subject: course.subject, + number: course.number, + })), + }, + { + optimisticResponse: { + updateUser: { + ...user, + bookmarkedCourses, + }, + }, + } + ); + }; + const share = () => { if (!context) return; @@ -147,7 +226,7 @@ export default function Course({ } // TODO: Error state - if (!course) { + if (!course || !pin) { return <>; } @@ -163,11 +242,21 @@ export default function Course({ className={classNames(styles.bookmark, { [styles.active]: bookmarked, })} - onClick={() => setBookmarked(!bookmarked)} + onClick={() => bookmark()} > {bookmarked ? : } + + (pinned ? removePin(pin) : addPin(pin))} + > + {pinned ? : } + + diff --git a/apps/frontend/src/components/NavigationBar/index.tsx b/apps/frontend/src/components/NavigationBar/index.tsx index d2ba52050..441986b9f 100644 --- a/apps/frontend/src/components/NavigationBar/index.tsx +++ b/apps/frontend/src/components/NavigationBar/index.tsx @@ -4,7 +4,7 @@ import { Link, NavLink } from "react-router-dom"; import { Button, IconButton, MenuItem } from "@repo/theme"; -import useUser from "@/hooks/useUser"; +import { useReadUser } from "@/hooks/api"; import useWindowDimensions from "@/hooks/useWindowDimensions"; import { signIn, signOut } from "@/lib/api"; @@ -18,7 +18,7 @@ interface NavigationBarProps { export default function NavigationBar({ invert }: NavigationBarProps) { const { width } = useWindowDimensions(); - const { data: user } = useUser(); + const { data: user } = useReadUser(); return (
) => { + const query = useQuery(READ_USER, options); + + return { + ...query, + data: query.data?.user, + }; +}; diff --git a/apps/frontend/src/hooks/api/users/useUpdateUser.ts b/apps/frontend/src/hooks/api/users/useUpdateUser.ts new file mode 100644 index 000000000..5ae0aec80 --- /dev/null +++ b/apps/frontend/src/hooks/api/users/useUpdateUser.ts @@ -0,0 +1,39 @@ +import { useCallback } from "react"; + +import { MutationHookOptions, useMutation } from "@apollo/client"; + +import { IUserInput, UPDATE_USER, UpdateUserResponse } from "@/lib/api"; + +export const useUpdateUser = () => { + const mutation = useMutation(UPDATE_USER, { + update(cache, { data }) { + if (!data?.updateUser) return; + + cache.modify({ + fields: { + user: () => data.updateUser, + }, + }); + }, + }); + + const updateUser = useCallback( + async ( + user: Partial, + options?: Omit, "variables"> + ) => { + const mutate = mutation[0]; + + return await mutate({ + ...options, + variables: { user }, + }); + }, + [mutation] + ); + + return [updateUser, mutation[1]] as [ + mutate: typeof updateUser, + result: (typeof mutation)[1], + ]; +}; diff --git a/apps/frontend/src/hooks/useUser.ts b/apps/frontend/src/hooks/useUser.ts deleted file mode 100644 index a34c04d57..000000000 --- a/apps/frontend/src/hooks/useUser.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { QueryHookOptions, useQuery } from "@apollo/client"; - -import { GET_USER, IUser } from "@/lib/api"; - -interface Data { - user: IUser; -} - -const useUser = (options?: QueryHookOptions) => { - const query = useQuery(GET_USER, options); - - return { - ...query, - data: query.data?.user, - }; -}; - -export default useUser; diff --git a/apps/frontend/src/lib/api/users.ts b/apps/frontend/src/lib/api/users.ts index 85931214f..4ab6daba2 100644 --- a/apps/frontend/src/lib/api/users.ts +++ b/apps/frontend/src/lib/api/users.ts @@ -1,15 +1,81 @@ import { gql } from "@apollo/client"; +import { IClass } from "./classes"; +import { ICourse } from "./courses"; + export interface IUser { email: string; student: boolean; + bookmarkedCourses: ICourse[]; + bookmarkedClasses: IClass[]; +} + +export interface ReadUserResponse { + user: IUser; } -export const GET_USER = gql` +export const READ_USER = gql` query GetUser { user { email student + bookmarkedCourses { + title + subject + number + } + bookmarkedClasses { + title + subject + number + courseNumber + year + semester + } + } + } +`; + +export interface IBookmarkedCourseInput { + subject: string; + number: string; +} + +export interface IBookmarkedClassInput { + subject: string; + number: string; + courseNumber: string; + year: string; + semester: string; +} + +export interface IUserInput { + bookmarkedCourses?: IBookmarkedCourseInput[]; + bookmarkedClasses?: IBookmarkedClassInput[]; +} + +export interface UpdateUserResponse { + updateUser: IUser; +} + +export const UPDATE_USER = gql` + mutation UpdateUser($user: UpdateUserInput!) { + updateUser(user: $user) { + email + student + bookmarkedCourses { + title + subject + number + } + bookmarkedClasses { + title + subject + number + courseNumber + year + semester + } } } `; diff --git a/packages/common/src/models/user.ts b/packages/common/src/models/user.ts index 74db77ec0..1b1462961 100644 --- a/packages/common/src/models/user.ts +++ b/packages/common/src/models/user.ts @@ -2,7 +2,7 @@ import mongoose, { Document, InferSchemaType, Schema } from "mongoose"; export const userSchema = new Schema( { - google_id: { + googleId: { type: String, trim: true, required: true, @@ -20,6 +20,50 @@ export const userSchema = new Schema( trim: true, required: true, }, + bookmarkedClasses: { + required: false, + default: [], + type: [ + { + year: { + type: Number, + required: true, + }, + semester: { + type: String, + required: true, + }, + subject: { + type: String, + required: true, + }, + courseNumber: { + type: String, + required: true, + }, + number: { + type: String, + required: true, + }, + }, + ], + }, + bookmarkedCourses: { + required: false, + default: [], + type: [ + { + subject: { + type: String, + required: true, + }, + number: { + type: String, + required: true, + }, + }, + ], + }, refresh_token: { type: String, trim: true,