/********************************* * moduleyear.js *********************************/ /** * Fetch all relevant module rules from Dataverse for the given program, * program version, and intake date. * * @param {string} programId - The GUID of the program * @param {string} programVersionId - The GUID of the program version * @param {string|Date} selectedIntakeDate - e.g. "2025-02-17" or a Date * @returns {Promise} Array of rule records from edv_modulerules */ async function fetchModuleRules(programId, programVersionId, selectedIntakeDate) { let isoDate; // Convert selectedIntakeDate to YYYY-MM-DD (ISO date) if needed if (typeof selectedIntakeDate === 'string') { isoDate = selectedIntakeDate.substring(0, 10); } else { isoDate = new Date(selectedIntakeDate).toISOString().split('T')[0]; } // We assume the user has a custom table `edv_modulerules` with fields: // - _edv_program_value // - _edv_programversion_value // - edv_startdate // - edv_enddate // - edv_ruletype (numeric choice) // - edv_oldmodules (JSON string) // - edv_newmodules (JSON string) // - statecode eq 0 => Active // // If no startdate is on the record, it can be assumed to start from the intake date. // If no enddate, ignore. We handle that in the filter logic. // // The filter ensures: // (program = this program OR program is null) // (programversion = this program version OR programversion is null) // (startdate is null or <= isoDate) // (enddate is null or >= isoDate) // (statecode eq 0 => active) const filter = ` (_edv_program_value eq '${programId}' or _edv_program_value eq null) and (_edv_programversion_value eq '${programVersionId}' or _edv_programversion_value eq null) and (edv_startdate eq null or edv_startdate le '${isoDate}') and (edv_enddate eq null or edv_enddate ge '${isoDate}') and (statecode eq 0) `.replace(/\s+/g, ' ').trim(); const selectFields = [ 'edv_modulerulename', 'edv_ruletype', // numeric choice field 'edv_oldmodules', // JSON 'edv_newmodules', // JSON '_edv_program_value', // references program '_edv_programversion_value', // references program version 'edv_startdate', 'edv_enddate' ].join(','); const url = `https://www.vossie.net/_api/edv_modulerules?$select=${selectFields}&$filter=${filter}`; try { const response = await fetch(url); const data = await response.json(); return data.value || []; } catch (err) { console.error("Error fetching module rules:", err); // In production you might return an empty array or re-throw: return []; } } /** * removeModulesFromQualProgData - removes modules from qualProgData matching the root codes in moduleCodes. * @param {Array} qualProgData - array of module objects for the student * @param {Array} moduleCodes - array of module code strings to remove */ function removeModulesFromQualProgData(qualProgData, moduleCodes) { moduleCodes.forEach(oldCode => { const root = getRootCode(oldCode); for (let i = qualProgData.length - 1; i >= 0; i--) { if (getRootCode(qualProgData[i].ModuleCode) === root) { qualProgData.splice(i, 1); } } }); } /** * ensureModuleIsPresent - adds or updates a module in qualProgData * @param {Array} qualProgData * @param {String} moduleCode * @param {String} [status="OUTSTANDING"] * @param {Object} qualPlannerData - optional planner data to find details */ function ensureModuleIsPresent(qualProgData, moduleCode, status = "OUTSTANDING", qualPlannerData) { const root = getRootCode(moduleCode); let existing = qualProgData.find(m => getRootCode(m.ModuleCode) === root); if (!existing) { // Try to find more details from qualPlannerData let plannerModule = null; if (qualPlannerData && qualPlannerData.results) { plannerModule = qualPlannerData.results.find(m => { return getRootCode(m.ProductCode.split('-')[0]) === root; }); } if (plannerModule) { qualProgData.push({ ModuleCode: root, ModuleStatus: status, Name: plannerModule.Name, Credits: Number(plannerModule.Credits) || 0, Type: plannerModule.Type || 'Core', Year: plannerModule.Year || 1, YearOfUnderTaking: plannerModule.YearOfUnderTaking || "2024", ModulePeriod: plannerModule.ModulePeriod || '99', ProductCode: plannerModule.ProductCode, ProductId: plannerModule.ProductId, ProductNumber: plannerModule.ProductNumber, Elective: plannerModule.Elective ? plannerModule.Elective.toString() : "false", ModuleClass: plannerModule.ModuleClass, ModuleType: plannerModule.ModuleType }); } else { // fallback minimal qualProgData.push({ ModuleCode: root, ModuleStatus: status, Name: root, Credits: 0, Type: 'Core', Year: 1, YearOfUnderTaking: "2024" }); } } else { existing.ModuleStatus = status; } } /** * studentPassedAll - checks if all modules in moduleCodes are "passed" or "credit" * @param {Array} moduleCodes * @param {Map} moduleStatusMap - maps uppercase code => status */ function studentPassedAll(moduleCodes, moduleStatusMap) { return moduleCodes.every(c => { const status = moduleStatusMap.get(c.toUpperCase()) || "OUTSTANDING"; return ["PASSED", "CREDIT", "COMPETENT", "CREDIT EXEMPTION"].includes(status); }); } /** * studentFailedOrNotAttempted - checks if a single module is "FAILED" or "OUTSTANDING" * @param {String} moduleCode * @param {Map} moduleStatusMap */ function studentFailedOrNotAttempted(moduleCode, moduleStatusMap) { const status = moduleStatusMap.get(moduleCode.toUpperCase()) || "OUTSTANDING"; return (status === "FAILED" || status === "OUTSTANDING"); } /** * getRootCode - extracts the root code from a dash-delimited code e.g. "SCAPA2-B11" => "SCAPA2" * @param {String} moduleCode */ function getRootCode(moduleCode) { if (!moduleCode || typeof moduleCode !== 'string') return ""; if (!moduleCode.includes('-')) return moduleCode.trim().toUpperCase(); return moduleCode.split('-')[0].trim().toUpperCase(); } /** * applyOldToNewModuleRules - queries the edv_modulerules DB, merges them with the student's plan, * and modifies qualProgData according to the rule type. * * @param {Array} qualProgData - the modules in student's plan * @param {Object} courseHistoryData - the student's course history * @param {Object} qualPlannerData - the qualification planner (with .results array) * @param {String} programId - the GUID of the program * @param {String} programVersionId - the GUID of the program version * @param {String|Date} selectedIntakeDate - e.g. "2025-02-17" or a Date */ async function applyOldToNewModuleRules( qualProgData, courseHistoryData, qualPlannerData, programId, programVersionId, selectedIntakeDate ) { let moduleRules = []; try { moduleRules = await fetchModuleRules(programId, programVersionId, selectedIntakeDate); } catch (e) { console.error("Failed to fetch module rules:", e); // If we fail to get rules, we won't apply anything; just return return; } // Build a status map from courseHistoryData let moduleStatusMap = new Map(); if (courseHistoryData && courseHistoryData.value) { courseHistoryData.value.forEach(course => { const code = (course.mshied_CourseId.mshied_coursenumber || "").toUpperCase().trim(); const status = (course["bt_modulestatus@OData.Community.Display.V1.FormattedValue"] || "OUTSTANDING").toUpperCase(); moduleStatusMap.set(code, status); }); } // For each rule record from edv_modulerules for (let rule of moduleRules) { // rule.edv_ruletype is the numeric choice const ruleType = rule.edv_ruletype; let oldModules = []; let newModules = []; // Attempt to parse old/new module strings as JSON arrays try { oldModules = JSON.parse(rule.edv_oldmodules || "[]"); } catch (err) { console.warn("Could not parse oldModules from rule:", rule.edv_modulerulename, err); } try { newModules = JSON.parse(rule.edv_newmodules || "[]"); } catch (err) { console.warn("Could not parse newModules from rule:", rule.edv_modulerulename, err); } switch (ruleType) { case 948781000: // "All Must Pass then Exempt" if (studentPassedAll(oldModules, moduleStatusMap)) { removeModulesFromQualProgData(qualProgData, oldModules); // No new modules => exempt } else { // remove old, add new as OUTSTANDING removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData)); } break; case 948781001: // "Failed Not Attempted Single" { const oldModule = oldModules[0]; if (studentFailedOrNotAttempted(oldModule, moduleStatusMap)) { removeModulesFromQualProgData(qualProgData, oldModules); ensureModuleIsPresent(qualProgData, newModules[0], "OUTSTANDING", qualPlannerData); } else { removeModulesFromQualProgData(qualProgData, oldModules); ensureModuleIsPresent(qualProgData, newModules[0], "PASSED", qualPlannerData); } } break; case 948781002: // "Failed Not Attempted Choose Any" { const oldModule = oldModules[0]; if (studentFailedOrNotAttempted(oldModule, moduleStatusMap)) { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData)); } else { removeModulesFromQualProgData(qualProgData, oldModules); if (newModules.length > 0) { ensureModuleIsPresent(qualProgData, newModules[0], "PASSED", qualPlannerData); } } } break; case 948781003: // "Failed Not Attempted Choose All" { const allPassed = studentPassedAll(oldModules, moduleStatusMap); if (!allPassed) { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData)); } else { removeModulesFromQualProgData(qualProgData, oldModules); } } break; case 948781004: // "Module Name Change" { const allPassed = studentPassedAll(oldModules, moduleStatusMap); removeModulesFromQualProgData(qualProgData, oldModules); if (allPassed) { if (newModules[0]) { ensureModuleIsPresent(qualProgData, newModules[0], "PASSED", qualPlannerData); } } else { if (newModules[0]) { ensureModuleIsPresent(qualProgData, newModules[0], "OUTSTANDING", qualPlannerData); } } } break; case 948781005: // "Special Assessment" { // Possibly mark or remove. The logic is up to you. const allPassed = studentPassedAll(oldModules, moduleStatusMap); if (!allPassed) { // ... } } break; case 948781006: // "All Must Pass Then Replace with New" { if (studentPassedAll(oldModules, moduleStatusMap)) { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "PASSED", qualPlannerData)); } else { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData)); } } break; case 948781007: // "Pre-Requisite Override" { // Typically we'd remove old modules and add new ones as "OUTSTANDING" removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData)); } break; case 948781008: // "Attempted but Failed – Replace Theory" case 948781009: // "Attempted but Failed – Replace Practical" { // The logic is basically the same for these two: const oldModule = oldModules[0]; if (studentFailedOrNotAttempted(oldModule, moduleStatusMap)) { removeModulesFromQualProgData(qualProgData, oldModules); newModules.forEach(nm => ensureModuleIsPresent(qualProgData, nm, "OUTSTANDING", qualPlannerData)); } else { removeModulesFromQualProgData(qualProgData, oldModules); } } break; case 948781010: // "Other" default: // Possibly do nothing or custom fallback logic break; } } } // Attach functions to the global window object for easy usage in Dynamics 365 portal, etc. window.fetchModuleRules = fetchModuleRules; window.removeModulesFromQualProgData = removeModulesFromQualProgData; window.ensureModuleIsPresent = ensureModuleIsPresent; window.studentPassedAll = studentPassedAll; window.studentFailedOrNotAttempted = studentFailedOrNotAttempted; window.getRootCode = getRootCode; window.applyOldToNewModuleRules = applyOldToNewModuleRules;