diff --git a/src/firebase-admin-config.ts b/src/firebase-admin-config.ts index 20a421bdc..026d36d72 100644 --- a/src/firebase-admin-config.ts +++ b/src/firebase-admin-config.ts @@ -33,6 +33,10 @@ export const selectableRequirementChoicesCollection = db .collection('user-selectable-requirement-choices') .withConverter(getTypedFirestoreDataConverter()); +export const overriddenFulfillmentChoicesCollection = db + .collection('user-overridden-fulfillment-choices') + .withConverter(getTypedFirestoreDataConverter()); + export const subjectColorsCollection = db .collection('user-subject-colors') .withConverter(getTypedFirestoreDataConverter>>()); diff --git a/src/firebase-frontend-config.ts b/src/firebase-frontend-config.ts index b238aef81..49c529e30 100644 --- a/src/firebase-frontend-config.ts +++ b/src/firebase-frontend-config.ts @@ -56,6 +56,10 @@ export const selectableRequirementChoicesCollection = db .collection('user-selectable-requirement-choices') .withConverter(getTypedFirestoreDataConverter()); +export const overriddenFulfillmentChoicesCollection = db + .collection('user-overridden-fulfillment-choices') + .withConverter(getTypedFirestoreDataConverter()); + export const subjectColorsCollection = db .collection('user-subject-colors') .withConverter(getTypedFirestoreDataConverter>>()); diff --git a/src/requirements/admin/opt-out-data-migration.ts b/src/requirements/admin/opt-out-data-migration.ts new file mode 100644 index 000000000..5dde710ff --- /dev/null +++ b/src/requirements/admin/opt-out-data-migration.ts @@ -0,0 +1,62 @@ +/* eslint-disable no-console */ + +import { usernameCollection } from '../../firebase-admin-config'; +import { getFirestoreCourseOptInOptOutChoicesBuilder } from '../requirement-graph-builder-from-user-data'; +import { getUserDataOnAdmin } from './requirement-graph-admin-utils'; + +/** Compute opt-out choices for given user using their existing choices. */ +async function runOnUser(userEmail: string) { + const { + courses, + onboardingData, + toggleableRequirementChoices, + selectableRequirementChoices, + } = await getUserDataOnAdmin(userEmail); + + const builder = getFirestoreCourseOptInOptOutChoicesBuilder( + courses, + onboardingData, + toggleableRequirementChoices, + selectableRequirementChoices, + {} + ); + + console.log(userEmail); + courses.forEach(course => { + const choices = builder(course); + + console.log(`${course.code} (${course.uniqueId}):`); + console.log( + `- selected requirement: ${selectableRequirementChoices[course.uniqueId] || 'None'}` + ); + console.log(`- optOut = [${choices.optOut.join(', ')}]`); + console.log( + `- acknowledgedCheckerWarningOptIn = [${choices.acknowledgedCheckerWarningOptIn.join(', ')}]` + ); + }); + + console.log('\n'); +} + +async function main() { + const userEmailFromArgument = process.argv[2]; + if (userEmailFromArgument != null) { + await runOnUser(userEmailFromArgument); + return; + } + const collection = await usernameCollection.get(); + const userEmails = collection.docs.map(it => it.id); + for (const userEmail of userEmails) { + console.group(`Running on ${userEmail}...`); + try { + // Intentionally await in a loop to have no interleaved console logs. + // eslint-disable-next-line no-await-in-loop + await runOnUser(userEmail); + } catch (e) { + console.log(e); + } + console.groupEnd(); + } +} + +main(); diff --git a/src/requirements/admin/requirement-graph-admin-utils.ts b/src/requirements/admin/requirement-graph-admin-utils.ts index 9f240845f..1e4e35210 100644 --- a/src/requirements/admin/requirement-graph-admin-utils.ts +++ b/src/requirements/admin/requirement-graph-admin-utils.ts @@ -10,18 +10,19 @@ import { getCourseCodesArray } from '../requirement-frontend-computation'; import buildRequirementFulfillmentGraphFromUserData from '../requirement-graph-builder-from-user-data'; import RequirementFulfillmentGraph from '../requirement-graph'; -interface UserRequirementDataOnAdmin { +interface UserDataOnAdmin { readonly courses: readonly CourseTaken[]; readonly onboardingData: AppOnboardingData; readonly toggleableRequirementChoices: Readonly>; readonly selectableRequirementChoices: Readonly>; +} + +interface UserRequirementDataOnAdmin extends UserDataOnAdmin { readonly userRequirements: readonly RequirementWithIDSourceType[]; readonly requirementFulfillmentGraph: RequirementFulfillmentGraph; } -export default async function getUserRequirementDataOnAdmin( - userEmail: string -): Promise { +export async function getUserDataOnAdmin(userEmail: string): Promise { const [ semesters, toggleableRequirementChoices, @@ -47,6 +48,20 @@ export default async function getUserRequirementDataOnAdmin( ]); const courses = getCourseCodesArray(semesters, onboardingData); + + return { courses, onboardingData, toggleableRequirementChoices, selectableRequirementChoices }; +} + +export default async function getUserRequirementDataOnAdmin( + userEmail: string +): Promise { + const { + courses, + onboardingData, + toggleableRequirementChoices, + selectableRequirementChoices, + } = await getUserDataOnAdmin(userEmail); + const { userRequirements, requirementFulfillmentGraph, diff --git a/src/requirements/requirement-graph-builder-from-user-data.ts b/src/requirements/requirement-graph-builder-from-user-data.ts index 349b9e42f..3d134c8bb 100644 --- a/src/requirements/requirement-graph-builder-from-user-data.ts +++ b/src/requirements/requirement-graph-builder-from-user-data.ts @@ -1,7 +1,9 @@ import { CREDITS_COURSE_ID } from './data/constants'; -import { getUserRequirements } from './requirement-frontend-utils'; +import { courseIsAPIB, getUserRequirements } from './requirement-frontend-utils'; import RequirementFulfillmentGraph from './requirement-graph'; -import buildRequirementFulfillmentGraph from './requirement-graph-builder'; +import buildRequirementFulfillmentGraph, { + BuildRequirementFulfillmentGraphParameters, +} from './requirement-graph-builder'; /** * Removes all AP/IB equivalent course credit if it's a duplicate crseId. @@ -31,7 +33,9 @@ export default function buildRequirementFulfillmentGraphFromUserData( onboardingData: AppOnboardingData, toggleableRequirementChoices: AppToggleableRequirementChoices, selectableRequirementChoices: AppSelectableRequirementChoices, - overriddenFulfillmentChoices: AppOverriddenFulfillmentChoices + overriddenFulfillmentChoices: AppOverriddenFulfillmentChoices, + /** A flag for data migration. Prod code should never use this */ + keepCoursesWithoutDoubleCountingEliminationChoice = false ): { readonly userRequirements: readonly RequirementWithIDSourceType[]; readonly userRequirementsMap: Readonly>; @@ -40,7 +44,10 @@ export default function buildRequirementFulfillmentGraphFromUserData( const userRequirements = getUserRequirements(onboardingData); const userRequirementsMap = Object.fromEntries(userRequirements.map(it => [it.id, it])); - const requirementFulfillmentGraph = buildRequirementFulfillmentGraph({ + const requirementGraphBuilderParameters: BuildRequirementFulfillmentGraphParameters< + string, + CourseTaken + > = { requirements: userRequirements.map(it => it.id), userCourses: forfeitTransferCredit(coursesTaken), userChoiceOnFulfillmentStrategy: Object.fromEntries( @@ -99,7 +106,113 @@ export default function buildRequirementFulfillmentGraphFromUserData( }, allowDoubleCounting: requirementID => userRequirementsMap[requirementID].allowCourseDoubleCounting || false, - }); + }; + const requirementFulfillmentGraph = buildRequirementFulfillmentGraph( + requirementGraphBuilderParameters, + keepCoursesWithoutDoubleCountingEliminationChoice + ); return { userRequirements, userRequirementsMap, requirementFulfillmentGraph }; } + +export type FirestoreCourseOptInOptOutChoicesBuilder = ( + c: CourseTaken +) => FirestoreCourseOptInOptOutChoices; + +/** + * @returns a function that given a course, return a `FirestoreCourseOptInOptOutChoices`. + * + * Frontend code can use the returned function to compute the equivalent opt-out data, + * and the data migration code can use the returned function to compute opt-out choice for all courses. + * This function should be deleted once we fully finish the migration. + */ +export function getFirestoreCourseOptInOptOutChoicesBuilder( + coursesTaken: readonly CourseTaken[], + onboardingData: AppOnboardingData, + toggleableRequirementChoices: AppToggleableRequirementChoices, + selectableRequirementChoices: AppSelectableRequirementChoices, + overriddenFulfillmentChoices: AppOverriddenFulfillmentChoices +): FirestoreCourseOptInOptOutChoicesBuilder { + // In this graph, each course is connected to all requirements that course can be used to satisfy. + const { + requirementFulfillmentGraph: graphWithoutDoubleCountingAccounted, + userRequirementsMap, + } = buildRequirementFulfillmentGraphFromUserData( + coursesTaken, + onboardingData, + toggleableRequirementChoices, + {}, // Provide no double counting choices, so all the edges will be kept + overriddenFulfillmentChoices, + /* keepCoursesWithoutDoubleCountingEliminationChoice */ true + ); + + // In this graph, each course is only connected to the selected requirement and the requirements + // that allow double counting. + const graphWithDoubleCountingAccounted = buildRequirementFulfillmentGraphFromUserData( + coursesTaken, + onboardingData, + toggleableRequirementChoices, + selectableRequirementChoices, + overriddenFulfillmentChoices, + /* keepCoursesWithoutDoubleCountingEliminationChoice */ false + ).requirementFulfillmentGraph; + + /** + * The table below summerizes the type of all possible requirement-course edges before + * double-counting elimination, and where they will go in the new format. + * + * ------------------------------------------------------------------------------------------------- + * | Requirement Type | edge exists after double-counting elim | where is it in the new format | + * | ---------------- | -------------------------------------- | --------------------------------- | + * | selected | True | implicit (connected by default) | + * | (no warning) | | | + * | | | | + * | selected | True | `acknowledgedCheckerWarningOptIn` | + * | (has warning) | | | + * | | | | + * | allow double | True | implicit (connected by default) | + * | counting | | | + * | | | | + * | not connected | False | `optOut` | + * | (no warning) | | | + * | | | | + * | not connected | False | implicit (unconnected by default) | + * | (has warning) | | | + */ + return function builder(course) { + const requirementsWithDoubleCountingRemoved = graphWithDoubleCountingAccounted.getConnectedRequirementsFromCourse( + course + ); + const allRelevantRequirements = graphWithoutDoubleCountingAccounted.getConnectedRequirementsFromCourse( + course + ); + /** + * complementary == All unconnected requirement after double counting elimination + * + * It's true that + * ``` + * union( + * selectableRequirementChoices[course], + * requirements that allow double counting and the given course can be used to satisfy, + * complementary, + * ) == all requirements that course can be used to satisfy + * ``` + */ + const complementary = new Set(allRelevantRequirements); + requirementsWithDoubleCountingRemoved.forEach(r => complementary.delete(r)); + + // We only need to explicitly opt-out of requirements without checker warnings, since requirement + // with checker warnings need to be explicitly opt-in. + const optOut = Array.from(complementary).filter( + it => userRequirementsMap[it].checkerWarning == null + ); + // Find requirements with checker warnings that needs to be explictly opt-in. + const acknowledgedCheckerWarningOptIn = courseIsAPIB(course) + ? [] + : requirementsWithDoubleCountingRemoved.filter( + it => userRequirementsMap[it].checkerWarning != null + ); + + return { optOut, arbitraryOptIn: {}, acknowledgedCheckerWarningOptIn }; + }; +} diff --git a/src/requirements/requirement-graph-builder.ts b/src/requirements/requirement-graph-builder.ts index 0d0e7dfbe..3a9c3a0bb 100644 --- a/src/requirements/requirement-graph-builder.ts +++ b/src/requirements/requirement-graph-builder.ts @@ -4,7 +4,7 @@ interface CourseForRequirementGraph extends CourseWithUniqueId { readonly courseId: number; } -type BuildRequirementFulfillmentGraphParameters< +export type BuildRequirementFulfillmentGraphParameters< Requirement extends string, Course extends CourseForRequirementGraph > = { diff --git a/src/user-data.d.ts b/src/user-data.d.ts index 2d0663693..23c86a769 100644 --- a/src/user-data.d.ts +++ b/src/user-data.d.ts @@ -57,6 +57,22 @@ type FirestoreOnboardingUserData = { readonly tookSwim: 'yes' | 'no'; }; +type FirestoreCourseOptInOptOutChoices = { + /** A list of requirements to opt-out */ + readonly optOut: readonly string[]; + /** It is for opting-in requirements that has a checker warning. */ + readonly acknowledgedCheckerWarningOptIn: readonly string[]; + /** + * A list of requirement and their slots to opt-in arbitrarily. + * It's for attaching completely unknown courses to a requirement + * (e.g. opt-in CS 2112 for history requirement). + */ + readonly arbitraryOptIn: readonly { readonly [requirement: string]: readonly string[] }; +}; +type FirestoreOverriddenFulfillmentChoices = { + readonly [courseUniqueId: string]: FirestoreCourseOptInOptOutChoices; +}; + type FirestoreUserData = { readonly name: FirestoreUserName; readonly semesters: readonlyFirestoreSemester[]; @@ -65,6 +81,7 @@ type FirestoreUserData = { readonly subjectColors: { readonly [subject: string]: string }; readonly uniqueIncrementer: number; readonly userData: FirestoreOnboardingUserData; + // TODO: add overriddenFulfillmentChoices once we connect new requirement flow to prod. }; interface CornellCourseRosterCourse { @@ -150,7 +167,11 @@ type AppToggleableRequirementChoices = Readonly>; /** Map from course's unique ID to requirement ID */ type AppSelectableRequirementChoices = Readonly>; -/** Map from course's unique ID to override options */ +/** + * @deprecated replaced by `FirestoreOverriddenFulfillmentChoices` + * + * Map from course's unique ID to override options. + */ type AppOverriddenFulfillmentChoices = Readonly< Record< string,