diff --git a/package-lock.json b/package-lock.json index 0d40458d5..218d74788 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4827,6 +4827,8 @@ "version": "8.12.0", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -4841,7 +4843,9 @@ "node_modules/ajv-formats/node_modules/json-schema-traverse": { "version": "1.0.0", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/ajv-keywords": { "version": "3.5.2", @@ -20322,13 +20326,13 @@ "ajv-formats": { "version": "2.1.1", "dev": true, - "requires": { - "ajv": "^8.0.0" - }, + "requires": {}, "dependencies": { "ajv": { "version": "8.12.0", "dev": true, + "optional": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -20338,7 +20342,9 @@ }, "json-schema-traverse": { "version": "1.0.0", - "dev": true + "dev": true, + "optional": true, + "peer": true } } }, @@ -27192,7 +27198,6 @@ "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", - "postcss": "^8.4.18", "postcss-import": "^14.1.0", "postcss-js": "^4.0.0", "postcss-load-config": "^3.1.4", diff --git a/src/components/planner/Tiles/SemesterCourseItem.tsx b/src/components/planner/Tiles/SemesterCourseItem.tsx index d51c64ed1..d1a532e19 100644 --- a/src/components/planner/Tiles/SemesterCourseItem.tsx +++ b/src/components/planner/Tiles/SemesterCourseItem.tsx @@ -237,7 +237,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?.coorepre?.get(course.code); + const coorpreData = requirementsData.data?.coorpre?.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 26100155e..acf4f7a3e 100644 --- a/src/server/trpc/router/courseCache.ts +++ b/src/server/trpc/router/courseCache.ts @@ -12,7 +12,6 @@ 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)) { @@ -24,9 +23,7 @@ class CourseCache { console.info(`Fetching courses for year ${year}...`); return await platformPrisma.courses .findMany({ - where: { - catalog_year: formattedYear, - }, + distinct: ['title', 'course_number', 'subject_prefix'], }) .then((courses) => { this.coursesByYear.set(year, courses); diff --git a/src/server/trpc/router/plan.ts b/src/server/trpc/router/plan.ts index 18279d88c..fcb157601 100644 --- a/src/server/trpc/router/plan.ts +++ b/src/server/trpc/router/plan.ts @@ -1,4 +1,4 @@ -import { Prisma, Semester } from '@prisma/client'; +import { Prisma, PrismaClient, Semester } from '@prisma/client'; import { TRPCError } from '@trpc/server'; import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; @@ -13,6 +13,42 @@ 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 }) => { @@ -36,36 +72,7 @@ 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 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', - }); - } + const planData = await getPlanFromUserId(ctx, input); 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 77b49326b..b1c0214ab 100644 --- a/src/server/trpc/router/validator.ts +++ b/src/server/trpc/router/validator.ts @@ -7,6 +7,7 @@ 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 = { @@ -20,30 +21,7 @@ export const validatorRouter = router({ prereqValidator: protectedProcedure.input(z.string().min(1)).query(async ({ ctx, input }) => { try { // Fetch current plan - 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', - }); - } - + const planData = await getPlanFromUserId(ctx, input); if (ctx.session.user.id !== planData.userId) { throw new TRPCError({ code: 'FORBIDDEN' }); } @@ -58,8 +36,11 @@ export const validatorRouter = router({ /* sanitizing data from API db. * TODO: Fix this later somehow */ - const courseMapWithIdKey = new Map(); - const courseMapWithCodeKey = new Map< + + // internal course id -> course code + const courseInternalToCode = new Map(); + // course code -> requisite information + const courseCodeToReqs = new Map< string, { prereqs: Prisma.JsonValue; @@ -69,241 +50,105 @@ export const validatorRouter = router({ >(); for (const course of coursesFromAPI) { - courseMapWithCodeKey.set(`${course.subject_prefix} ${course.course_number}`, { + const code = `${course.subject_prefix} ${course.course_number}`; + courseCodeToReqs.set(code, { prereqs: course.prerequisites, coreqs: course.corequisites, co_or_pre_requisites: course.co_or_pre_requisites, }); - courseMapWithIdKey.set( - course.internal_course_number, - `${course.subject_prefix} ${course.course_number}`, - ); + courseInternalToCode.set(course.internal_course_number, code); } - /* 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); + // 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); } - } + }); - // Run on transfer credits - for (let i = 0; i < planData.transferCredits.length; i++) { - const course = planData.transferCredits[i]; - courseHash.set(course.trim(), -1); - } + planData?.transferCredits.forEach((course) => { + // set semester index to -1, indicating credit comes before all semesters + courseToSemester.set(course.trim(), -1); + }); - const checkForPreRecursive = ( + const checkForRequisites = ( requirements: CollectionOptions, semester: number, - ): Array<[Array, number]> => { - const prereqNotMet: Array<[Array, number]> = []; - let count = 0; - if (!requirements || requirements.options.length === 0) { - return []; - } - const temp: [Array, number] = [[], 0]; + 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][] = []; 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); - } else if (data < semester) { - count++; - } else { - temp[0].push(course as string); - } + 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); } } else if (option.type === 'collection') { - const prereq = checkForPreRecursive(option, semester); - if (prereq.length > 0) { - prereqNotMet.push(...prereq); + // recursively gather requisite information + const nestedRequisitesNotMet = checkForRequisites(option, semester, requisiteType); + if (nestedRequisitesNotMet.length > 0) { + requisitesNotMet.push(...nestedRequisitesNotMet); } else { - count++; + numRequisitesNotMet--; } } } - if (count >= requirements.required) { - return []; - } - if (temp[0].length > 0) { - temp[1] = requirements.required - count; - prereqNotMet.push(temp); - } - return prereqNotMet; + if (numRequisitesNotMet <= 0) return []; + if (currentUnmetCourses.length > 0) + requisitesNotMet.push([currentUnmetCourses, numRequisitesNotMet]); + return requisitesNotMet; }; - 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; - }; + enum RequisiteType { + PRE = 'prereq', + CO = 'coreq', + CO_PRE = 'coorpre', + } - 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); - } - } + type RequisitesOutput = Map, number]>>; + const requisiteOutput: { [Key in RequisiteType]: RequisitesOutput } = { + [RequisiteType.PRE]: new Map(), + [RequisiteType.CO]: new Map(), + [RequisiteType.CO_PRE]: new Map(), }; - 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, + // 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, ); - coOrPreReqHash.set(course.code, flag); + requisiteOutput[requisiteType].set(course.code, unfulfilledRequisites); } - } + }); }; - await prereqValidation(planData); - await coreqValidation(planData); - await coOrPrereqValidation(planData); - return { prereq: preReqHash, coreq: coReqHash, coorepre: coOrPreReqHash }; + await validateRequisites(planData, RequisiteType.PRE); + await validateRequisites(planData, RequisiteType.CO); + await validateRequisites(planData, RequisiteType.CO_PRE); + + return requisiteOutput; } catch (error) { console.error(error); return null;