diff --git a/src/components/planner/Tiles/SemesterCourseItem.tsx b/src/components/planner/Tiles/SemesterCourseItem.tsx index 01ec206c6..dee3eb7c8 100644 --- a/src/components/planner/Tiles/SemesterCourseItem.tsx +++ b/src/components/planner/Tiles/SemesterCourseItem.tsx @@ -239,7 +239,7 @@ const DraggableSemesterCourseItem: FC = ({ const hoverList: [Array, Array, Array] = [[], [], []]; const prereqData = requirementsData.data?.prereq?.get(course.code); const coreqData = requirementsData.data?.coreq?.get(course.code); - const coorpreData = requirementsData.data?.coorpre?.get(course.code); + const coorpreData = requirementsData.data?.coorepre?.get(course.code); if (coreqData) { isValid[1] = coreqData.length > 0 ? false : true; coreqData.map((data) => { diff --git a/src/server/trpc/router/courseCache.ts b/src/server/trpc/router/courseCache.ts index acf4f7a3e..26100155e 100644 --- a/src/server/trpc/router/courseCache.ts +++ b/src/server/trpc/router/courseCache.ts @@ -12,6 +12,7 @@ class CourseCache { private mutex = new Mutex(); public async getCourses(year: number) { + const formattedYear = year.toString().slice(-2); // Acquire lock before success check so if another request is fetching, we don't fetch again. const release = await this.mutex.acquire(); if (this.coursesByYear.has(year)) { @@ -23,7 +24,9 @@ class CourseCache { console.info(`Fetching courses for year ${year}...`); return await platformPrisma.courses .findMany({ - distinct: ['title', 'course_number', 'subject_prefix'], + where: { + catalog_year: formattedYear, + }, }) .then((courses) => { this.coursesByYear.set(year, courses); diff --git a/src/server/trpc/router/plan.ts b/src/server/trpc/router/plan.ts index fcb157601..18279d88c 100644 --- a/src/server/trpc/router/plan.ts +++ b/src/server/trpc/router/plan.ts @@ -1,4 +1,4 @@ -import { Prisma, PrismaClient, Semester } from '@prisma/client'; +import { Prisma, Semester } from '@prisma/client'; import { TRPCError } from '@trpc/server'; import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; @@ -13,42 +13,6 @@ import { SemesterCode, computeSemesterCode } from 'prisma/utils'; import { protectedProcedure, router } from '../trpc'; -// extracted for reusability for other trpc routes -export const getPlanFromUserId = async (ctx: { prisma: PrismaClient }, id: string) => { - // Fetch current plan - const planData = await ctx.prisma.plan.findUnique({ - where: { - id, - }, - select: { - name: true, - id: true, - userId: true, - semesters: { - include: { - courses: true, - }, - }, - transferCredits: true, - }, - }); - - // Make sure semesters are in right orer - if (planData && planData.semesters) { - planData.semesters = planData.semesters.sort((a, b) => - isEarlierSemester(computeSemesterCode(a), computeSemesterCode(b)) ? -1 : 1, - ); - } - - if (!planData) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Plan not found', - }); - } - return planData; -}; - export const planRouter = router({ // Protected route: route uses session user id to find user plans getUserPlans: protectedProcedure.query(async ({ ctx }) => { @@ -72,7 +36,36 @@ export const planRouter = router({ // Protected route: checks if session user and plan owner have the same id getPlanById: protectedProcedure.input(z.string().min(1)).query(async ({ ctx, input }) => { // Fetch current plan - const planData = await getPlanFromUserId(ctx, input); + const planData = await ctx.prisma.plan.findUnique({ + where: { + id: input, + }, + select: { + name: true, + id: true, + userId: true, + semesters: { + include: { + courses: true, + }, + }, + transferCredits: true, + }, + }); + + // Make sure semesters are in right orer + if (planData && planData.semesters) { + planData.semesters = planData.semesters.sort((a, b) => + isEarlierSemester(computeSemesterCode(a), computeSemesterCode(b)) ? -1 : 1, + ); + } + + if (!planData) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Plan not found', + }); + } if (ctx.session.user.id !== planData.userId) { throw new TRPCError({ code: 'FORBIDDEN' }); diff --git a/src/server/trpc/router/validator.ts b/src/server/trpc/router/validator.ts index 2110e9c95..6522d6a5b 100644 --- a/src/server/trpc/router/validator.ts +++ b/src/server/trpc/router/validator.ts @@ -7,7 +7,6 @@ import { courses as PlatformCourse } from 'prisma/generated/platform'; import { courseCache } from './courseCache'; import { DegreeNotFound, DegreeValidationError } from './errors'; -import { getPlanFromUserId } from './plan'; import { protectedProcedure, router } from '../trpc'; type PlanData = { @@ -21,7 +20,30 @@ export const validatorRouter = router({ prereqValidator: protectedProcedure.input(z.string().min(1)).query(async ({ ctx, input }) => { try { // Fetch current plan - const planData = await getPlanFromUserId(ctx, input); + const planData = await ctx.prisma.plan.findUnique({ + where: { + id: input, + }, + select: { + name: true, + id: true, + userId: true, + transferCredits: true, + semesters: { + include: { + courses: true, + }, + }, + }, + }); + + if (!planData) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Plan not found', + }); + } + if (ctx.session.user.id !== planData.userId) { throw new TRPCError({ code: 'FORBIDDEN' }); } @@ -36,11 +58,8 @@ export const validatorRouter = router({ /* sanitizing data from API db. * TODO: Fix this later somehow */ - - // internal course id -> course code - const courseInternalToCode = new Map(); - // course code -> requisite information - const courseCodeToReqs = new Map< + const courseMapWithIdKey = new Map(); + const courseMapWithCodeKey = new Map< string, { prereqs: Prisma.JsonValue; @@ -50,105 +69,241 @@ export const validatorRouter = router({ >(); for (const course of coursesFromAPI) { - const code = `${course.subject_prefix} ${course.course_number}`; - courseCodeToReqs.set(code, { + courseMapWithCodeKey.set(`${course.subject_prefix} ${course.course_number}`, { prereqs: course.prerequisites, coreqs: course.corequisites, co_or_pre_requisites: course.co_or_pre_requisites, }); - courseInternalToCode.set(course.internal_course_number, code); + courseMapWithIdKey.set( + course.internal_course_number, + `${course.subject_prefix} ${course.course_number}`, + ); } - // course -> semester index, used to determine if A is taken before B by comparing indices - const courseToSemester = new Map(); - - planData?.semesters.forEach((semester, index) => { - for (const course of semester.courses) { - courseToSemester.set(course.code.trim(), index); + /* Hash to store pre req data. + * key: course id + * value: boolean to represent validity + */ + const courseHash = new Map(); + const coReqHash = new Map, number]>>(); + const preReqHash = new Map, number]>>(); + const coOrPreReqHash = new Map, number]>>(); + // Regex to parse course from description of improperly parsed course + const re = /\b[A-Z]{2,4} \d{4}\b/; + + /* Recursive function to check for prereqs. + * TODO: Move to a client side function. Possibly a hook. + */ + for (let i = 0; i < planData?.semesters.length; i++) { + if (!planData?.semesters[i] || !planData?.semesters[i].courses) continue; + for (let j = 0; j < planData?.semesters[i].courses.length; j++) { + const course = planData?.semesters[i].courses[j]; + courseHash.set(course.code.trim(), i); } - }); + } - planData?.transferCredits.forEach((course) => { - // set semester index to -1, indicating credit comes before all semesters - courseToSemester.set(course.trim(), -1); - }); + // Run on transfer credits + for (let i = 0; i < planData.transferCredits.length; i++) { + const course = planData.transferCredits[i]; + courseHash.set(course.trim(), -1); + } - const checkForRequisites = ( + const checkForPreRecursive = ( requirements: CollectionOptions, semester: number, - requisiteType: RequisiteType, - ) => { - if (!requirements || requirements.options.length === 0) return []; - // count the number of requisites not met, if 0 then requisites are satisfied - let numRequisitesNotMet = requirements.required; - // storing course codes of the unmet requirements - const currentUnmetCourses: string[] = []; - // this function's output: [currentUnmetCourses, numRequisitesNotMet] - const requisitesNotMet: [string[], number][] = []; + ): Array<[Array, number]> => { + const prereqNotMet: Array<[Array, number]> = []; + let count = 0; + if (!requirements || requirements.options.length === 0) { + return []; + } + const temp: [Array, number] = [[], 0]; for (const option of requirements.options) { - if (option.type === 'course') { - const courseCode = courseInternalToCode.get(option.class_reference); - const courseSemesterIndex = courseToSemester.get(courseCode as string); - if ( - courseSemesterIndex !== undefined && - ((requisiteType == RequisiteType.PRE && courseSemesterIndex < semester) || // if pre-req, semester index has to be less than - courseSemesterIndex <= semester) // if coreq, it can be taken in the same semester - ) { - // course is satisfied - numRequisitesNotMet--; - } else { - currentUnmetCourses.push(courseCode as string); + if (option.type === 'course' || option.type === 'other') { + // 'other' might be an improperly parsed course + // if it's not, `course` will be set to undefined so nothing will happen + const course = + option.type === 'course' + ? courseMapWithIdKey.get(option.class_reference) + : option.description.match(re)?.[0]; + if (course) { + const data = courseHash.get(course as string); + if (data === undefined) { + temp[0].push(course as string); + } else if (data < semester) { + count++; + } else { + temp[0].push(course as string); + } } } else if (option.type === 'collection') { - // recursively gather requisite information - const nestedRequisitesNotMet = checkForRequisites(option, semester, requisiteType); - if (nestedRequisitesNotMet.length > 0) { - requisitesNotMet.push(...nestedRequisitesNotMet); + const prereq = checkForPreRecursive(option, semester); + if (prereq.length > 0) { + prereqNotMet.push(...prereq); } else { - numRequisitesNotMet--; + count++; } } } - if (numRequisitesNotMet <= 0) return []; - if (currentUnmetCourses.length > 0) - requisitesNotMet.push([currentUnmetCourses, numRequisitesNotMet]); - return requisitesNotMet; + if (count >= requirements.required) { + return []; + } + if (temp[0].length > 0) { + temp[1] = requirements.required - count; + prereqNotMet.push(temp); + } + return prereqNotMet; }; - enum RequisiteType { - PRE = 'prereq', - CO = 'coreq', - CO_PRE = 'coorpre', - } + const checkForCoRecursive = ( + requirements: CollectionOptions, + semester: number, + ): Array<[Array, number]> => { + const coreqNotMet: Array<[Array, number]> = []; + let count = 0; + if (!requirements || requirements.options.length === 0) { + return []; + } + const temp: [Array, number] = [[], 0]; + for (const option of requirements.options) { + if (option.type === 'course' || option.type === 'other') { + // 'other' might be an improperly parsed course + // if it's not, `course` will be set to undefined so nothing will happen + const course = + option.type === 'course' + ? courseMapWithIdKey.get(option.class_reference) + : option.description.match(re)?.[0]; + if (course) { + const data = courseHash.get(course as string); + if (data === undefined) { + temp[0].push(course as string); + continue; + } + if (data <= semester) { + count++; + } else { + temp[0].push(course as string); + } + } + } else if (option.type === 'collection') { + const coreq = checkForCoRecursive(option, semester); + if (coreq.length > 0) { + coreqNotMet.push(...coreq); + } else { + count++; + } + } + } + if (count >= requirements.required) { + return []; + } + if (temp[0].length > 0) { + temp[1] = requirements.required; + coreqNotMet.push(temp); + } + return coreqNotMet; + }; - type RequisitesOutput = Map, number]>>; - const requisiteOutput: { [Key in RequisiteType]: RequisitesOutput } = { - [RequisiteType.PRE]: new Map(), - [RequisiteType.CO]: new Map(), - [RequisiteType.CO_PRE]: new Map(), + const checkForCoOrPreRecursive = ( + requirements: CollectionOptions, + semester: number, + ): Array<[Array, number]> => { + const coreqNotMet: Array<[Array, number]> = []; + let count = 0; + if (!requirements || requirements.options.length === 0) { + return []; + } + const temp: [Array, number] = [[], 0]; + for (const option of requirements.options) { + if (option.type === 'course' || option.type === 'other') { + // 'other' might be an improperly parsed course + // if it's not, `course` will be set to undefined so nothing will happen + const course = + option.type === 'course' + ? courseMapWithIdKey.get(option.class_reference) + : option.description.match(re)?.[0]; + if (course) { + const data = courseHash.get(course as string); + if (data === undefined) { + temp[0].push(course as string); + continue; + } + if (data <= semester) { + count++; + } else { + temp[0].push(course as string); + } + } + } else if (option.type === 'collection') { + const coreq = checkForCoOrPreRecursive(option, semester); + if (coreq.length > 0) { + coreqNotMet.push(...coreq); + } else { + count++; + } + } + } + if (count >= requirements.required) { + return []; + } + if (temp[0].length > 0) { + temp[1] = requirements.required; + coreqNotMet.push(temp); + } + return coreqNotMet; + }; + const prereqValidation = async (planData: PlanData) => { + for (let i = 0; i < planData?.semesters.length; i++) { + if (!planData?.semesters[i] || !planData?.semesters[i].courses) continue; + for (let j = 0; j < planData?.semesters[i].courses.length; j++) { + const course = planData?.semesters[i].courses[j]; + const reqsForCourse = courseMapWithCodeKey.get(course.code); + if (!reqsForCourse) { + continue; + } + const flag = checkForPreRecursive(reqsForCourse.prereqs as CollectionOptions, i); + preReqHash.set(course.code, flag); + } + } }; - // populates pre-req validation into output object - const validateRequisites = async (planData: PlanData, requisiteType: RequisiteType) => { - planData?.semesters.forEach((semester, index) => { - for (const course of semester.courses) { - const reqsForCourse = courseCodeToReqs.get(course.code); - const unfulfilledRequisites = checkForRequisites( - reqsForCourse?.prereqs as CollectionOptions, - index, - requisiteType, + const coreqValidation = async (planData: PlanData) => { + for (let i = 0; i < planData?.semesters.length; i++) { + if (!planData?.semesters[i] || !planData?.semesters[i].courses) continue; + for (let j = 0; j < planData?.semesters[i].courses.length; j++) { + const course = planData?.semesters[i].courses[j]; + const reqsForCourse = courseMapWithCodeKey.get(course.code); + if (!reqsForCourse) { + continue; + } + const flag = checkForCoRecursive(reqsForCourse.coreqs as CollectionOptions, i); + coReqHash.set(course.code, flag); + } + } + }; + const coOrPrereqValidation = async (planData: PlanData) => { + for (let i = 0; i < planData?.semesters.length; i++) { + if (!planData?.semesters[i] || !planData?.semesters[i].courses) continue; + for (let j = 0; j < planData?.semesters[i].courses.length; j++) { + const course = planData?.semesters[i].courses[j]; + const reqsForCourse = courseMapWithCodeKey.get(course.code); + if (!reqsForCourse) { + continue; + } + const flag = checkForCoOrPreRecursive( + reqsForCourse.co_or_pre_requisites as CollectionOptions, + i, ); - requisiteOutput[requisiteType].set(course.code, unfulfilledRequisites); + coOrPreReqHash.set(course.code, flag); } - }); + } }; + await prereqValidation(planData); + await coreqValidation(planData); + await coOrPrereqValidation(planData); - await validateRequisites(planData, RequisiteType.PRE); - await validateRequisites(planData, RequisiteType.CO); - await validateRequisites(planData, RequisiteType.CO_PRE); - - return requisiteOutput; + return { prereq: preReqHash, coreq: coReqHash, coorepre: coOrPreReqHash }; } catch (error) { console.error(error); return null;