const AdmissionsPage = require("../models/admissionsPage"); const AUDIT_ACTIONS = require("../constants/auditAction"); const admissionsConfig = require("../utils/contentEditors/admissionsConfig"); const createPageContentController = require("./_createPageContentController"); const createRenderSingletonPageView = require("./_renderSingletonPageView"); const jsonHelper = require("../utils/jsonHelper"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const { ensureUniqueIds } = require("../utils/contentEditorIds"); const { ICON_OPTIONS } = require("../utils/contentEditors/sharedFields"); function getAdmissionsEditorUi() { return admissionsConfig.editorUi || {}; } function getDefaultCalculatorOptionValues() { return getAdmissionsEditorUi().calculator?.defaultOption || {}; } function getCalculatorOptionFieldConfig(fieldKey) { const calculatorTab = (admissionsConfig.tabs || []).find((tab) => tab.key === "calculator"); const calculatorFields = calculatorTab?.schema?.fields || []; const optionsField = calculatorFields.find((field) => field.key === "options"); const optionItemFields = optionsField?.itemSchema?.fields || []; return optionItemFields.find((field) => field.key === fieldKey) || {}; } function hasDuplicateCalculatorOptionLabel(options, currentOptionId, nextLabel) { const normalizedLabel = String(nextLabel || "").trim().toLowerCase(); if (!normalizedLabel) { return false; } return (options || []).some((option) => { if (String(option?.id || "") === String(currentOptionId || "")) { return false; } return String(option?.label || "").trim().toLowerCase() === normalizedLabel; }); } function normalizePositiveAmount(value, fallback = "1") { const match = String(value || "").match(/\d[\d,]*/); const numericValue = Number((match ? match[0] : "").replace(/,/g, "")); return numericValue > 0 ? String(numericValue) : fallback; } function normalizeCalculatorOption(option, index, calculator) { const source = typeof option === "string" ? { label: option } : { ...(option || {}) }; const defaultOption = getDefaultCalculatorOptionValues(); const labelMaxLength = getCalculatorOptionFieldConfig("label").maxLength || 12; return { ...source, label: String(source.label || source.title || `Option ${index + 1}`).slice(0, labelMaxLength), paceLabel: String(source.paceLabel || calculator.paceLabel || defaultOption.paceLabel || "Target Pace"), minPaceLabel: String(source.minPaceLabel || calculator.minPaceLabel || defaultOption.minPaceLabel || "Relaxed"), maxPaceLabel: String(source.maxPaceLabel || calculator.maxPaceLabel || defaultOption.maxPaceLabel || "Accelerated"), resultLabel: String( source.resultLabel || calculator.resultLabel || defaultOption.resultLabel || "Estimated Monthly Payment", ), monthlyAmount: normalizePositiveAmount( source.monthlyAmount || calculator.monthlyAmount || defaultOption.monthlyAmount || "299", String(defaultOption.monthlyAmount || "299"), ), monthlySuffix: String(source.monthlySuffix || calculator.monthlySuffix || defaultOption.monthlySuffix || "/mo"), noteIcon: String(source.noteIcon || calculator.noteIcon || defaultOption.noteIcon || "fa-bolt"), note: String(source.note || calculator.note || defaultOption.note || ""), }; } function normalizeCalculator(calculator) { const nextCalculator = { ...(calculator || {}) }; const legacyOptions = Array.isArray(nextCalculator.modelOptions) ? nextCalculator.modelOptions : []; const rawOptions = Array.isArray(nextCalculator.options) && nextCalculator.options.length ? nextCalculator.options : legacyOptions; nextCalculator.title = String(nextCalculator.title || ""); nextCalculator.description = String(nextCalculator.description || ""); nextCalculator.cta = { label: String(nextCalculator?.cta?.label || "").slice(0, 15), href: String(nextCalculator?.cta?.href || ""), }; nextCalculator.options = ensureUniqueIds( rawOptions.slice(0, 3).map((option, index) => normalizeCalculatorOption(option, index, nextCalculator)), (item) => item.id, (item) => item.label, "calculator-option", ); delete nextCalculator.modelOptions; delete nextCalculator.paceLabel; delete nextCalculator.minPaceLabel; delete nextCalculator.maxPaceLabel; delete nextCalculator.resultLabel; delete nextCalculator.monthlyAmount; delete nextCalculator.monthlySuffix; delete nextCalculator.noteIcon; delete nextCalculator.note; return nextCalculator; } function normalizeTuitionPoint(point, index) { if (point && typeof point === "object" && !Array.isArray(point)) { const numericValue = Number(point.value); return { time: String(point.time || point.label || `Year ${index + 1}`), value: Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0, }; } const numericValue = Number(point); return { time: `Year ${index + 1}`, value: Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0, }; } function normalizeTuitionSeries(series, index) { const source = typeof series === "string" ? { label: series } : { ...(series || {}) }; const rawPoints = Array.isArray(source.points) && source.points.length ? source.points : Array.isArray(source.values) ? source.values : []; return { label: String(source.label || `Series ${index + 1}`).slice(0, 40), color: String(source.color || "#0F172A"), points: rawPoints.map(normalizeTuitionPoint), }; } function normalizeTuition(tuition) { const nextTuition = { ...(tuition || {}) }; nextTuition.title = String(nextTuition.title || ""); nextTuition.chartTitle = String(nextTuition.chartTitle || ""); nextTuition.chartDescription = String(nextTuition.chartDescription || ""); nextTuition.series = Array.isArray(nextTuition.series) ? nextTuition.series.map(normalizeTuitionSeries) : []; return nextTuition; } function normalizeAdmissionsPayload(rawPayload) { const payload = JSON.parse(JSON.stringify(rawPayload || {})); payload.process = { ...(payload.process || {}), id: "admissions-process", }; payload.eligibility = { ...(payload.eligibility || {}), id: "eligibility", }; payload.tuition = { ...normalizeTuition(payload.tuition), id: "tuition-breakdown", }; payload.keyDates = { ...(payload.keyDates || {}), id: "key-dates", }; payload.calculator = normalizeCalculator(payload.calculator); return payload; } const controller = createPageContentController({ model: AdmissionsPage, modelName: "AdmissionsPage", auditAction: AUDIT_ACTIONS.UPDATE_ADMISSIONS, editorConfig: admissionsConfig, preparePayload: normalizeAdmissionsPayload, normalizeForEditor: normalizeAdmissionsPayload, normalizeForApi: normalizeAdmissionsPayload, }); controller.index = async function index(req, res) { try { return await createRenderSingletonPageView({ model: AdmissionsPage, editorConfig: admissionsConfig, view: "admin/admissions/index", normalizeForEditor: normalizeAdmissionsPayload, })(req, res); } catch (error) { console.error("admissions index error:", error); req.flash("error_msg", "Error loading Admissions Management"); return req.session.save(() => res.redirect("/admin/dashboard")); } }; controller.editCalculatorOption = async function editCalculatorOption(req, res) { try { const optionId = String(req.params.optionId || ""); const doc = await AdmissionsPage.getSingle(); const data = normalizeAdmissionsPayload(doc.toObject()); const option = data.calculator.options.find((item) => item.id === optionId); if (!option) { req.flash("error_msg", "Calculator option not found"); return req.session.save(() => res.redirect("/admin/admissions?tab=calculator")); } const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const backendUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; const fieldKeys = [ "label", "paceLabel", "minPaceLabel", "maxPaceLabel", "resultLabel", "monthlyAmount", "monthlySuffix", "noteIcon", "note", ]; const fieldConfig = fieldKeys.reduce((acc, key) => { acc[key] = getCalculatorOptionFieldConfig(key); return acc; }, {}); return res.render("admin/admissions/calculator-option", { layout: "layouts/main", title: `Edit ${option.label}`, subtitle: "Update the calculator option details", option, existingOptionLabels: data.calculator.options .filter((item) => item.id !== optionId) .map((item) => item.label) .filter(Boolean), fieldLimits: { label: fieldConfig.label.maxLength || 12, paceLabel: fieldConfig.paceLabel.maxLength || 20, minPaceLabel: fieldConfig.minPaceLabel.maxLength || 7, maxPaceLabel: fieldConfig.maxPaceLabel.maxLength || 7, resultLabel: fieldConfig.resultLabel.maxLength || 40, monthlySuffix: fieldConfig.monthlySuffix.maxLength || 10, note: fieldConfig.note.maxLength || 60, }, fieldConfig, iconOptions: ICON_OPTIONS, editorConfig: admissionsConfig, previewUrl: `${frontendUrl}${admissionsConfig.previewPath}`, currentPath: req.path, backendUrl, user: req.session.user, }); } catch (error) { console.error("admissions calculator option index error:", error); req.flash("error_msg", "Error loading calculator option"); return req.session.save(() => res.redirect("/admin/admissions?tab=calculator")); } }; controller.updateCalculatorOption = async function updateCalculatorOption(req, res) { try { const optionId = String(req.params.optionId || ""); const doc = await AdmissionsPage.getSingle(); const beforeData = JSON.parse(JSON.stringify(doc.toObject())); const payload = normalizeAdmissionsPayload(beforeData); const optionIndex = payload.calculator.options.findIndex((item) => item.id === optionId); if (optionIndex === -1) { req.flash("error_msg", "Calculator option not found"); return req.session.save(() => res.redirect("/admin/admissions?tab=calculator")); } const nextLabel = String(req.body.label || "").trim().slice(0, getCalculatorOptionFieldConfig("label").maxLength || 12); if (hasDuplicateCalculatorOptionLabel(payload.calculator.options, optionId, nextLabel)) { req.flash("error_msg", `Option label "${nextLabel}" already exists. Please use a unique label.`); return req.session.save(() => res.redirect(`/admin/admissions/calculator/${optionId}`)); } payload.calculator.options[optionIndex] = { ...payload.calculator.options[optionIndex], label: nextLabel, paceLabel: String(req.body.paceLabel || "").trim(), minPaceLabel: String(req.body.minPaceLabel || "").trim(), maxPaceLabel: String(req.body.maxPaceLabel || "").trim(), resultLabel: String(req.body.resultLabel || "").trim(), monthlyAmount: normalizePositiveAmount(req.body.monthlyAmount, payload.calculator.options[optionIndex].monthlyAmount || "1"), monthlySuffix: String(req.body.monthlySuffix || "").trim(), noteIcon: String(req.body.noteIcon || "").trim(), note: String(req.body.note || "").trim(), }; const normalizedPayload = normalizeAdmissionsPayload(payload); doc.set(normalizedPayload); Object.keys(normalizedPayload).forEach((key) => doc.markModified(key)); await doc.save(); const afterData = JSON.parse(JSON.stringify(doc.toObject())); const changes = diffObject(beforeData, afterData); if (changes.length > 0) { await writeAuditLog({ model: "AdmissionsPage", documentId: doc._id, action: AUDIT_ACTIONS.UPDATE_ADMISSIONS, before: beforeData, after: afterData, changes, req, }); } const finalData = await AdmissionsPage .findOne() .select("-_id -__v -createdAt -updatedAt") .lean(); jsonHelper.writeJsonFile(admissionsConfig.dataFile, normalizeAdmissionsPayload(finalData)); req.flash("success_msg", "Calculator option updated successfully"); return req.session.save(() => res.redirect(`/admin/admissions/calculator/${optionId}`)); } catch (error) { console.error("admissions calculator option update error:", error); req.flash("error_msg", `Error updating calculator option: ${error.message}`); return req.session.save(() => res.redirect(`/admin/admissions/calculator/${req.params.optionId}`)); } }; module.exports = controller;