Skip to content

Commit 9971210

Browse files
authored
Logic engine v2 (#1123)
* Revert "Revert "Logic engine (#1030)"" This reverts commit fa48909. * lint
1 parent 75a56c2 commit 9971210

69 files changed

Lines changed: 9855 additions & 1330 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursorrules

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ src/
9595
- Use SCSS Modules (`.module.scss`) for component styles
9696
- Use Bootstrap utility classes (`m-*`, `p-*`) for simple spacing when appropriate
9797
- Import styles: `import styles from "./Component.module.scss"`
98+
- Use `classNames` utility (or similar) when combining classes instead of manual string manipulation (e.g., `classNames(styles.base, { [styles.active]: isActive })` instead of `styles.base + (isActive ? ' ' + styles.active : '')`)
9899
- Use/extend `@repo/theme` components when available instead of building custom ones
99100

100101
### File Naming

apps/backend/src/modules/course/formatter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CourseModule } from "./generated-types/module-types";
55

66
interface CourseRelationships {
77
classes: null;
8+
mostRecentClass: null;
89
gradeDistribution: null;
910
crossListing: string[];
1011
requiredCourses: string[];
@@ -31,6 +32,7 @@ export function formatCourse(course: ICourseItem) {
3132
finalExam: course.finalExam as CourseModule.CourseFinalExam,
3233

3334
classes: null,
35+
mostRecentClass: null,
3436
gradeDistribution: null,
3537
crossListing: course.crossListing ?? [],
3638
requiredCourses: course.preparation?.requiredCourses ?? [],

apps/backend/src/modules/course/resolver.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,24 @@ const resolvers: CourseModule.Resolvers = {
156156
return classes.filter(matchesCourse) as unknown as CourseModule.Class[];
157157
},
158158

159+
mostRecentClass: async (
160+
parent: IntermediateCourse | CourseModule.Course
161+
) => {
162+
const classes = parent.classes
163+
? null
164+
: await getClassesByCourse(parent.courseId);
165+
166+
return (parent.classes ?? classes)!.toSorted((a, b) => {
167+
if (a.year === b.year) {
168+
return (
169+
(SEMESTER_RECENCY_ORDER[a.semester ?? ""] ?? -1) -
170+
(SEMESTER_RECENCY_ORDER[b.semester ?? ""] ?? -1)
171+
);
172+
}
173+
return b.year - a.year;
174+
})[0] as unknown as CourseModule.Class;
175+
},
176+
159177
crossListing: async (parent: IntermediateCourse | CourseModule.Course) => {
160178
// cross listings are stored as `${subject} ${number}`
161179

apps/backend/src/modules/plan/controller.ts

Lines changed: 232 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,24 @@ import { Types } from "mongoose";
33

44
import {
55
LabelModel,
6-
MajorReqModel,
76
PlanModel,
7+
PlanRequirementModel,
88
PlanTermModel,
99
SelectedCourseModel,
10+
SelectedPlanRequirementModel,
1011
} from "@repo/common/models";
1112

1213
import {
1314
Colleges,
1415
EditPlanTermInput,
1516
Plan,
1617
PlanInput,
18+
PlanRequirement,
1719
PlanTerm,
1820
PlanTermInput,
1921
SelectedCourseInput,
22+
SelectedPlanRequirementInput,
23+
UpdateManualOverrideInput,
2024
} from "../../generated-types/graphql";
2125
import { RequestContext } from "../../types/request-context";
2226
import { formatPlan, formatPlanTerm } from "./formatter";
@@ -238,20 +242,43 @@ export async function createPlan(
238242
})
239243
);
240244

245+
const selectedPlanRequirements = await buildSelectedPlanRequirementsForPlan(
246+
majors,
247+
minors,
248+
colleges.map((c) => String(c))
249+
);
250+
241251
const newPlan = await PlanModel.create({
242252
userEmail,
243253
planTerms: planTerms,
244254
majors: majors,
245255
minors: minors,
246-
majorReqs: [],
247256
colleges: colleges,
248257
labels: [],
249-
uniReqsSatisfied: [],
250-
collegeReqsSatisfied: [],
258+
selectedPlanRequirements: selectedPlanRequirements.map(
259+
(spr) =>
260+
new SelectedPlanRequirementModel({
261+
planRequirementId: new Types.ObjectId(spr.planRequirementId),
262+
manualOverrides: spr.manualOverrides,
263+
})
264+
),
251265
});
252266
return formatPlan(newPlan);
253267
}
254268

269+
/** Build selectedPlanRequirements for given majors, minors, colleges (UC + college + major/minor). Used by createPlan, editPlan sync, and migrations. */
270+
export async function buildSelectedPlanRequirementsForPlan(
271+
majors: string[],
272+
minors: string[],
273+
colleges: string[]
274+
): Promise<SelectedPlanRequirementInput[]> {
275+
const reqs = await getRequiredPlanRequirements(majors, minors, colleges);
276+
return reqs.map((r) => ({
277+
planRequirementId: r._id,
278+
manualOverrides: [],
279+
}));
280+
}
281+
255282
export async function editPlan(
256283
plan: PlanInput,
257284
context: RequestContext
@@ -263,6 +290,10 @@ export async function editPlan(
263290
throw new Error("No Plan found for this user");
264291
}
265292

293+
const majorsChanged = plan.majors != null;
294+
const minorsChanged = plan.minors != null;
295+
const collegesChanged = plan.colleges != null;
296+
266297
if (plan.colleges != null) {
267298
gt.colleges = plan.colleges;
268299
}
@@ -272,19 +303,32 @@ export async function editPlan(
272303
if (plan.minors != null) {
273304
gt.minors = plan.minors;
274305
}
275-
if (plan.majorReqs != null) {
276-
gt.majorReqs = plan.majorReqs.map(
277-
(majorReqInput) => new MajorReqModel(majorReqInput)
278-
);
279-
}
280306
if (plan.labels != null) {
281307
gt.labels = plan.labels.map((labelInput) => new LabelModel(labelInput));
282308
}
283-
if (plan.uniReqsSatisfied != null) {
284-
gt.uniReqsSatisfied = plan.uniReqsSatisfied;
285-
}
286-
if (plan.collegeReqsSatisfied != null) {
287-
gt.collegeReqsSatisfied = plan.collegeReqsSatisfied;
309+
310+
if (majorsChanged || minorsChanged || collegesChanged) {
311+
const wantedReqs = await getRequiredPlanRequirements(
312+
gt.majors,
313+
gt.minors,
314+
gt.colleges
315+
);
316+
const wantedIds = new Set(wantedReqs.map((r) => r._id));
317+
const currentIds = new Set(
318+
gt.selectedPlanRequirements.map((spr) => spr.planRequirementId.toString())
319+
);
320+
const kept = gt.selectedPlanRequirements.filter((spr) =>
321+
wantedIds.has(spr.planRequirementId.toString())
322+
);
323+
const newReqs = wantedReqs.filter((r) => !currentIds.has(r._id));
324+
const newSprs = newReqs.map(
325+
(r) =>
326+
new SelectedPlanRequirementModel({
327+
planRequirementId: new Types.ObjectId(r._id),
328+
manualOverrides: [],
329+
})
330+
);
331+
gt.selectedPlanRequirements = [...kept, ...newSprs];
288332
}
289333

290334
await gt.save();
@@ -297,3 +341,177 @@ export async function deletePlan(context: RequestContext): Promise<string> {
297341
await PlanModel.deleteOne({ userEmail });
298342
return userEmail;
299343
}
344+
345+
// Internal: get all PlanRequirement IDs that should be selected for given majors, minors, colleges (UC + college + major/minor). Deduped by _id.
346+
export async function getRequiredPlanRequirements(
347+
majors: string[],
348+
minors: string[],
349+
colleges: string[]
350+
): Promise<PlanRequirement[]> {
351+
const byId = new Map<string, PlanRequirement>();
352+
const add = (req: PlanRequirement) => byId.set(req._id, req);
353+
354+
const uc = await getUcRequirements();
355+
uc.forEach(add);
356+
for (const college of colleges) {
357+
const coll = await getCollegeRequirements(college);
358+
coll.forEach(add);
359+
}
360+
const mm = await getPlanRequirementsByMajorsAndMinors(majors, minors);
361+
mm.forEach(add);
362+
return Array.from(byId.values());
363+
}
364+
365+
// Get PlanRequirements by majors and minors (internal use)
366+
export async function getPlanRequirementsByMajorsAndMinors(
367+
majors: string[],
368+
minors: string[]
369+
): Promise<PlanRequirement[]> {
370+
const requirements = await PlanRequirementModel.find({
371+
$or: [{ major: { $in: majors } }, { minor: { $in: minors } }],
372+
});
373+
374+
return requirements.map((req) => ({
375+
_id: (req._id as Types.ObjectId).toString(),
376+
code: req.code,
377+
name: req.name,
378+
isUcReq: req.isUcReq,
379+
college: req.college || null,
380+
major: req.major || null,
381+
minor: req.minor || null,
382+
createdBy: req.createdBy,
383+
isOfficial: req.isOfficial,
384+
createdAt: req.createdAt?.toISOString() || "",
385+
updatedAt: req.updatedAt?.toISOString() || "",
386+
}));
387+
}
388+
389+
// Get UC requirements
390+
export async function getUcRequirements(): Promise<PlanRequirement[]> {
391+
const requirements = await PlanRequirementModel.find({ isUcReq: true });
392+
393+
return requirements.map((req) => ({
394+
_id: (req._id as Types.ObjectId).toString(),
395+
code: req.code,
396+
name: req.name,
397+
isUcReq: req.isUcReq,
398+
college: req.college || null,
399+
major: req.major || null,
400+
minor: req.minor || null,
401+
createdBy: req.createdBy,
402+
isOfficial: req.isOfficial,
403+
createdAt: req.createdAt?.toISOString() || "",
404+
updatedAt: req.updatedAt?.toISOString() || "",
405+
}));
406+
}
407+
408+
// Get college requirements
409+
export async function getCollegeRequirements(
410+
college: string
411+
): Promise<PlanRequirement[]> {
412+
const requirements = await PlanRequirementModel.find({ college });
413+
414+
return requirements.map((req) => ({
415+
_id: (req._id as Types.ObjectId).toString(),
416+
code: req.code,
417+
name: req.name,
418+
isUcReq: req.isUcReq,
419+
college: req.college || null,
420+
major: req.major || null,
421+
minor: req.minor || null,
422+
createdBy: req.createdBy,
423+
isOfficial: req.isOfficial,
424+
createdAt: req.createdAt?.toISOString() || "",
425+
updatedAt: req.updatedAt?.toISOString() || "",
426+
}));
427+
}
428+
429+
// Get PlanRequirement by ID
430+
export async function getPlanRequirementById(
431+
id: string
432+
): Promise<PlanRequirement | null> {
433+
const requirement = await PlanRequirementModel.findById(id);
434+
if (!requirement) {
435+
return null;
436+
}
437+
438+
return {
439+
_id: (requirement._id as Types.ObjectId).toString(),
440+
code: requirement.code,
441+
name: requirement.name,
442+
isUcReq: requirement.isUcReq,
443+
college: requirement.college || null,
444+
major: requirement.major || null,
445+
minor: requirement.minor || null,
446+
createdBy: requirement.createdBy,
447+
isOfficial: requirement.isOfficial,
448+
createdAt: requirement.createdAt?.toISOString() || "",
449+
updatedAt: requirement.updatedAt?.toISOString() || "",
450+
};
451+
}
452+
453+
// Update manual override for a specific requirement
454+
export async function updateManualOverride(
455+
input: UpdateManualOverrideInput,
456+
context: RequestContext
457+
): Promise<Plan> {
458+
if (!context.user?.email) throw new Error("Unauthorized");
459+
const userEmail = context.user.email;
460+
461+
const gt = await PlanModel.findOne({ userEmail });
462+
if (!gt) {
463+
throw new Error("No Plan found for this user");
464+
}
465+
466+
// Find the selectedPlanRequirement
467+
const sprIndex = gt.selectedPlanRequirements.findIndex(
468+
(spr) => spr.planRequirementId.toString() === input.planRequirementId
469+
);
470+
471+
if (sprIndex === -1) {
472+
throw new Error("SelectedPlanRequirement not found in user's plan");
473+
}
474+
475+
// Update the manual override at the specified index
476+
const spr = gt.selectedPlanRequirements[sprIndex];
477+
if (
478+
input.requirementIndex < 0 ||
479+
input.requirementIndex >= spr.manualOverrides.length
480+
) {
481+
// Extend the array if needed
482+
while (spr.manualOverrides.length <= input.requirementIndex) {
483+
spr.manualOverrides.push(null);
484+
}
485+
}
486+
// Store null when clearing override so it persists correctly in MongoDB
487+
spr.manualOverrides[input.requirementIndex] =
488+
input.manualOverride !== undefined ? input.manualOverride : null;
489+
490+
await gt.save();
491+
return formatPlan(gt);
492+
}
493+
494+
// Update all selectedPlanRequirements
495+
export async function updateSelectedPlanRequirements(
496+
selectedPlanRequirements: SelectedPlanRequirementInput[],
497+
context: RequestContext
498+
): Promise<Plan> {
499+
if (!context.user?.email) throw new Error("Unauthorized");
500+
const userEmail = context.user.email;
501+
502+
const gt = await PlanModel.findOne({ userEmail });
503+
if (!gt) {
504+
throw new Error("No Plan found for this user");
505+
}
506+
507+
gt.selectedPlanRequirements = selectedPlanRequirements.map(
508+
(spr) =>
509+
new SelectedPlanRequirementModel({
510+
planRequirementId: new Types.ObjectId(spr.planRequirementId),
511+
manualOverrides: spr.manualOverrides,
512+
})
513+
);
514+
515+
await gt.save();
516+
return formatPlan(gt);
517+
}

0 commit comments

Comments
 (0)