Merge pull request 'merge/toan-24042026' (#13) from merge/toan-24042026 into develop

Reviewed-on: UKSOURCE/cms.lams#13
This commit is contained in:
2026-04-24 16:38:32 +00:00
25 changed files with 1857 additions and 73 deletions
@@ -0,0 +1,194 @@
const NewsletterSubscription = require("../models/newsletterSubscription");
const emailPattern = /^\S+@\S+\.\S+$/;
function cleanString(value) {
return String(value || "").trim();
}
function normalizeEmail(value) {
return cleanString(value).toLowerCase();
}
function toSubmissionShape(item) {
return {
_id: item._id,
source: "newsletter",
name: item.email,
email: item.email,
phone: "",
pageUrl: item.pageUrl || "",
status: item.status,
internalNote: item.internalNote || "",
ipAddress: item.ipAddress || "",
userAgent: item.userAgent || "",
payload: {
email: item.email,
},
createdAt: item.createdAt,
updatedAt: item.updatedAt,
};
}
function buildListQuery(query) {
const mongoQuery = {};
const search = cleanString(query.search);
const status = cleanString(query.status);
if (status && NewsletterSubscription.STATUSES.includes(status)) {
mongoQuery.status = status;
}
if (query.startDate || query.endDate) {
mongoQuery.createdAt = {};
if (query.startDate) {
mongoQuery.createdAt.$gte = new Date(query.startDate);
}
if (query.endDate) {
const end = new Date(query.endDate);
end.setHours(23, 59, 59, 999);
mongoQuery.createdAt.$lte = end;
}
}
if (search) {
mongoQuery.email = new RegExp(
search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"i",
);
}
return mongoQuery;
}
exports.subscribe = async (req, res) => {
try {
const email = normalizeEmail(req.body.email);
if (!email || !emailPattern.test(email)) {
return res.status(400).json({ success: false, error: "Please enter a valid email" });
}
const subscription = await NewsletterSubscription.findOneAndUpdate(
{ email },
{
$set: {
email,
status: "subscribed",
pageUrl: cleanString(req.body.pageUrl) || "/",
ipAddress: req.ip || req.connection?.remoteAddress || "",
userAgent: req.get("User-Agent") || "",
},
$setOnInsert: {
internalNote: "",
},
},
{ new: true, upsert: true, runValidators: true, setDefaultsOnInsert: true },
);
return res.status(201).json({
success: true,
message: "Subscription received",
data: {
id: subscription._id,
email: subscription.email,
status: subscription.status,
},
});
} catch (error) {
if (error.name === "ValidationError") {
const errors = Object.values(error.errors).map((item) => item.message);
return res.status(400).json({ success: false, error: errors.join(", ") });
}
if (error.code === 11000) {
return res.status(409).json({ success: false, error: "Email is already subscribed" });
}
console.error("newsletter.subscribe error:", error);
return res.status(500).json({ success: false, error: "Error subscribing email" });
}
};
exports.list = async (req, res) => {
try {
const page = Math.max(parseInt(req.query.page, 10) || 1, 1);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
const skip = (page - 1) * limit;
const query = buildListQuery(req.query);
const [items, total] = await Promise.all([
NewsletterSubscription.find(query)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean(),
NewsletterSubscription.countDocuments(query),
]);
return res.json({
success: true,
data: items.map(toSubmissionShape),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
},
});
} catch (error) {
console.error("newsletter.list error:", error);
return res.status(500).json({ success: false, error: "Error loading subscriptions" });
}
};
exports.detail = async (req, res) => {
try {
const subscription = await NewsletterSubscription.findById(req.params.id).lean();
if (!subscription) {
return res.status(404).json({ success: false, error: "Subscription not found" });
}
return res.json({ success: true, data: toSubmissionShape(subscription) });
} catch (error) {
console.error("newsletter.detail error:", error);
return res.status(500).json({ success: false, error: "Error loading subscription" });
}
};
exports.update = async (req, res) => {
try {
const update = {};
const status = cleanString(req.body.status);
if (status) {
if (!NewsletterSubscription.STATUSES.includes(status)) {
return res.status(400).json({ success: false, error: "Invalid status" });
}
update.status = status;
}
if (req.body.internalNote !== undefined) {
update.internalNote = cleanString(req.body.internalNote);
}
const subscription = await NewsletterSubscription.findByIdAndUpdate(
req.params.id,
update,
{ new: true, runValidators: true },
);
if (!subscription) {
return res.status(404).json({ success: false, error: "Subscription not found" });
}
return res.json({ success: true, data: toSubmissionShape(subscription) });
} catch (error) {
if (error.name === "ValidationError") {
const errors = Object.values(error.errors).map((item) => item.message);
return res.status(400).json({ success: false, error: errors.join(", ") });
}
console.error("newsletter.update error:", error);
return res.status(500).json({ success: false, error: "Error updating subscription" });
}
};
+51 -16
View File
@@ -4,6 +4,14 @@ const partnershipsConfig = require("../utils/contentEditors/partnershipsConfig")
const createPageContentController = require("./_createPageContentController");
const { ensureUniqueIds } = require("../utils/contentEditorIds");
const FIXED_INQUIRY_FIELDS = [
{ id: "firstName", type: "text", width: "half", label: "First name", placeholder: "First name", required: true },
{ id: "lastName", type: "text", width: "half", label: "Last name", placeholder: "Last name", required: true },
{ id: "organization", type: "text", width: "full", label: "Organization name", placeholder: "Company or Institution", required: true },
{ id: "partnershipType", type: "select", width: "full", label: "Partnership type", placeholder: "Select partnership type", required: true },
{ id: "message", type: "textarea", width: "full", label: "Message", placeholder: "Tell us how you'd like to collaborate...", required: true },
];
function getTabConfig(tabKey) {
return (partnershipsConfig.tabs || []).find((tab) => tab.key === tabKey) || {};
}
@@ -85,7 +93,25 @@ function normalizeInquiryForm(data) {
}
if (Array.isArray(data.inquiryForm.fields)) {
return data;
const fieldsById = new Map(data.inquiryForm.fields.map((field) => [field.id, field]));
return {
...data,
inquiryForm: {
...data.inquiryForm,
fields: FIXED_INQUIRY_FIELDS.map((fixedField) => {
const field = fieldsById.get(fixedField.id) || {};
return {
id: fixedField.id,
label: field.label || fixedField.label,
placeholder: field.placeholder || fixedField.placeholder,
type: fixedField.type,
width: ["half", "full"].includes(field.width) ? field.width : fixedField.width,
required: field.required === undefined ? fixedField.required : Boolean(field.required),
options: Array.isArray(field.options) ? field.options.filter(Boolean) : [],
};
}),
},
};
}
const legacyFields = data.inquiryForm.fields || {};
@@ -110,7 +136,7 @@ function normalizeInquiryForm(data) {
};
}
function prepareInquiryPayload(payload) {
function prepareInquiryPayload(payload, context = {}) {
const normalized = normalizeInquiryForm(payload);
const fields = Array.isArray(normalized?.inquiryForm?.fields)
? normalized.inquiryForm.fields
@@ -144,6 +170,14 @@ function prepareInquiryPayload(payload) {
);
}
const fieldsById = new Map(fields.map((field) => [field.id, field]));
const beforeData = context.beforeData || {};
const beforeNormalized = normalizeInquiryForm(beforeData || {});
const beforeFields = Array.isArray(beforeNormalized?.inquiryForm?.fields)
? beforeNormalized.inquiryForm.fields
: [];
const beforeFieldsById = new Map(beforeFields.map((field) => [field.id, field]));
return {
...normalized,
directory: {
@@ -157,20 +191,18 @@ function prepareInquiryPayload(payload) {
},
inquiryForm: {
...normalized.inquiryForm,
fields: ensureUniqueIds(
fields,
(field) => field.id,
(field, index) => field.label || field.placeholder || `field-${index + 1}`,
"field",
).map((field) => ({
id: field.id,
label: field.label || "",
placeholder: field.placeholder || "",
type: field.type || "text",
width: field.width || "full",
required: Boolean(field.required),
options: Array.isArray(field.options) ? field.options.filter(Boolean) : [],
})),
fields: FIXED_INQUIRY_FIELDS.map((fixedField) => {
const field = fieldsById.get(fixedField.id) || beforeFieldsById.get(fixedField.id) || {};
return {
id: fixedField.id,
label: field.label || fixedField.label,
placeholder: field.placeholder || fixedField.placeholder,
type: fixedField.type,
width: ["half", "full"].includes(field.width) ? field.width : fixedField.width,
required: field.required === undefined ? fixedField.required : Boolean(field.required),
options: Array.isArray(field.options) ? field.options.filter(Boolean) : [],
};
}),
},
};
}
@@ -195,6 +227,9 @@ controller.index = async function index(req, res) {
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
const defaultTab = partnershipsConfig.tabs[0]?.key;
const requestedTab = req.query.tab;
if (requestedTab === "submissions") {
return res.redirect("/admin/submissions?tab=partnership");
}
const activeTab = partnershipsConfig.tabs.some((tab) => tab.key === requestedTab)
? requestedTab
: defaultTab;
+150
View File
@@ -4,6 +4,45 @@ const Programme = require("../models/programme");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
const { readJsonFile, writeJsonFile } = require("../utils/jsonHelper");
const DEFAULT_PROGRAMME_FIELDS = [
"Computer Science",
"Data Analytics",
"Business & Admin",
"Healthcare",
"Finance & Accounting",
"Marketing",
"Education",
"Engineering",
"Psychology",
"Cybersecurity",
];
const DEFAULT_DURATION_RANGES = [
"< 6 months",
"6-12 months",
"12-24 months",
"24-48 months",
"48+ months",
];
const DEFAULT_DEGREE_LEVELS = ["Bachelor's", "Master's"];
function readProgrammeFiltersConfig() {
const saved = readJsonFile("programme-filters");
return {
levels:
saved && Array.isArray(saved.levels) && saved.levels.length > 0
? saved.levels
: DEFAULT_DEGREE_LEVELS,
fields:
saved && Array.isArray(saved.fields) && saved.fields.length > 0
? saved.fields
: DEFAULT_PROGRAMME_FIELDS,
durations:
saved && Array.isArray(saved.durations) && saved.durations.length > 0
? saved.durations
: DEFAULT_DURATION_RANGES,
};
}
/**
* Normalize all icon fields in the payload against the ICON_OPTIONS whitelist.
@@ -69,15 +108,24 @@ exports.edit = async (req, res) => {
try {
const { id } = req.params;
let programme;
const filtersConfig = readProgrammeFiltersConfig();
const levelOptions = Array.isArray(filtersConfig.levels)
? [...filtersConfig.levels]
: [];
const durationOptions = Array.isArray(filtersConfig.durations)
? [...filtersConfig.durations]
: [];
if (id === "new") {
programme = {
_id: null,
id: "",
level: "",
fieldOfStudy: "",
title: "",
description: "",
duration: "",
durationInMonths: 0,
cost: "",
selected: false,
link: "",
@@ -103,11 +151,19 @@ exports.edit = async (req, res) => {
req.flash("error_msg", "Programme not found");
return res.redirect("/admin/programme");
}
if (programme.level && !levelOptions.includes(programme.level)) {
levelOptions.push(programme.level);
}
if (programme.duration && !durationOptions.includes(programme.duration)) {
durationOptions.push(programme.duration);
}
}
res.render("admin/programme/edit", {
title: id === "new" ? "Add New Programme" : `Edit: ${programme.title}`,
programme,
levelOptions,
durationOptions,
iconOptions: ICON_OPTIONS,
frontendUrl: process.env.FRONTEND_URL || "",
currentPath: req.path,
@@ -145,7 +201,9 @@ exports.update = async (req, res) => {
title: (body.title || "").trim(),
description: (body.description || "").trim(),
duration: (body.duration || "").trim(),
durationInMonths: parseInt(body.durationInMonths, 10) || 0,
cost: (body.cost || "").trim(),
fieldOfStudy: (body.fieldOfStudy || "").trim(),
selected: body.selected === "true" || body.selected === true,
link: (body.link || "").trim(),
shortName: (body.shortName || "").trim(),
@@ -272,6 +330,98 @@ exports.api = async (req, res) => {
}
};
/**
* GET /api/programmes/filters
* Return filter options for FE (field of study + duration ranges + levels).
*/
exports.apiFilters = async (req, res) => {
try {
const data = await Programme.find().lean();
const cfg = readProgrammeFiltersConfig();
const normalize = (value) =>
String(value || "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
const levels = cfg.levels.map((label) => {
const normalizedLabel = normalize(label);
const count = data.filter((p) =>
normalize(p.level).includes(normalizedLabel)
).length;
return { label, count };
});
const fieldsMap = new Map(cfg.fields.map((f) => [f, 0]));
data.forEach((p) => {
if (p.fieldOfStudy && fieldsMap.has(p.fieldOfStudy)) {
fieldsMap.set(p.fieldOfStudy, (fieldsMap.get(p.fieldOfStudy) || 0) + 1);
}
});
const fields = [...fieldsMap.entries()].map(([label, count]) => ({ label, count }));
res.json({
levels,
fields,
durations: cfg.durations.map((label) => ({ label })),
});
} catch (err) {
console.error("programme.apiFilters:", err);
res.status(500).json({ error: "Error loading programme filters" });
}
};
/**
* GET /admin/programme/filters
* CMS page to manage programme filter options centrally.
*/
exports.filtersPage = async (req, res) => {
try {
const cfg = readProgrammeFiltersConfig();
res.render("admin/programme/filters", {
title: "Programme Filters",
filters: cfg,
frontendUrl: process.env.FRONTEND_URL || "",
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error("programme.filtersPage:", err);
req.flash("error_msg", "Error loading programme filters");
res.redirect("/admin/programme");
}
};
/**
* POST /admin/programme/filters
* Save centralized programme filters.
*/
exports.updateFilters = async (req, res) => {
try {
const normalizeLines = (text) =>
String(text || "")
.split(/\r?\n/)
.map((x) => x.trim())
.filter(Boolean);
const levels = normalizeLines(req.body.levels);
const fields = normalizeLines(req.body.fields);
const durations = normalizeLines(req.body.durations);
if (!levels.length || !fields.length || !durations.length) {
req.flash("error_msg", "Degree levels, fields and duration ranges cannot be empty.");
return res.redirect("/admin/programme/filters");
}
writeJsonFile("programme-filters", { levels, fields, durations });
req.flash("success_msg", "Programme filters updated successfully.");
res.redirect("/admin/programme/filters");
} catch (err) {
console.error("programme.updateFilters:", err);
req.flash("error_msg", "Error updating programme filters.");
res.redirect("/admin/programme/filters");
}
};
/**
* GET /api/programmes/:id
* Return a single programme matched by the slug `id` field.
+255
View File
@@ -0,0 +1,255 @@
const Submission = require("../models/submission");
const NewsletterSubscription = require("../models/newsletterSubscription");
const SOURCE_LABELS = {
home: "Home",
request: "Request",
contact: "Contact",
partnership: "Partnerships",
newsletter: "Newsletter",
};
const SOURCE_URLS = {
home: "/",
request: "/request",
contact: "/contact",
partnership: "/about/partnerships",
newsletter: "Footer",
};
function cleanString(value) {
return String(value || "").trim();
}
function pickFirst(payload, keys) {
for (const key of keys) {
const value = cleanString(payload?.[key]);
if (value) return value;
}
return "";
}
function buildName(payload) {
const directName = pickFirst(payload, ["name", "fullName", "full_name"]);
if (directName) return directName;
const firstName = pickFirst(payload, ["firstName", "first_name"]);
const lastName = pickFirst(payload, ["lastName", "last_name"]);
const combined = [firstName, lastName].filter(Boolean).join(" ").trim();
if (combined) return combined;
return pickFirst(payload, ["organization", "organisation", "company"]);
}
function normalizePayload(body) {
const payload =
body && typeof body.payload === "object" && body.payload !== null
? body.payload
: body || {};
return Object.entries(payload).reduce((acc, [key, value]) => {
if (typeof value === "string") {
acc[key] = value.trim();
return acc;
}
acc[key] = value;
return acc;
}, {});
}
function buildListQuery(query) {
const mongoQuery = {};
const source = cleanString(query.source);
const status = cleanString(query.status);
const search = cleanString(query.search);
if (source && Submission.SOURCES.includes(source)) {
mongoQuery.source = source;
}
if (status && Submission.STATUSES.includes(status)) {
mongoQuery.status = status;
}
if (query.startDate || query.endDate) {
mongoQuery.createdAt = {};
if (query.startDate) {
mongoQuery.createdAt.$gte = new Date(query.startDate);
}
if (query.endDate) {
const end = new Date(query.endDate);
end.setHours(23, 59, 59, 999);
mongoQuery.createdAt.$lte = end;
}
}
if (search) {
const regex = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
mongoQuery.$or = [
{ name: regex },
{ email: regex },
{ phone: regex },
{ "payload.firstName": regex },
{ "payload.lastName": regex },
{ "payload.organization": regex },
{ "payload.partnershipType": regex },
{ "payload.message": regex },
];
}
return mongoQuery;
}
exports.index = async (req, res) => {
const sources = ["home", "request", "contact", "partnership", "newsletter"];
const requestedTab = cleanString(req.query.tab);
const activeSource = sources.includes(requestedTab) ? requestedTab : sources[0];
res.render("admin/submissions/index", {
layout: "layouts/main",
title: "Submission Management",
subtitle: "Review and manage website form submissions",
sources,
activeSource,
sourceLabels: SOURCE_LABELS,
sourceUrls: SOURCE_URLS,
statuses: Submission.STATUSES,
newsletterStatuses: NewsletterSubscription.STATUSES,
currentPath: req.path,
user: req.session.user,
});
};
exports.create = async (req, res) => {
try {
const source = cleanString(req.body.source);
const payload = normalizePayload(req.body);
if (!Submission.SOURCES.includes(source)) {
return res.status(400).json({ success: false, error: "Invalid source" });
}
const name = buildName(payload);
const email = pickFirst(payload, ["email", "email_address"]);
const phone = pickFirst(payload, ["phone", "phone_number"]);
if (source !== "partnership" && !email) {
return res.status(400).json({ success: false, error: "Email is required" });
}
const submission = await Submission.create({
source,
pageUrl: cleanString(req.body.pageUrl) || SOURCE_URLS[source] || "",
name,
email,
phone,
payload,
ipAddress: req.ip || req.connection?.remoteAddress || "",
userAgent: req.get("User-Agent") || "",
});
return res.status(201).json({
success: true,
message: "Submission received",
data: {
id: submission._id,
source: submission.source,
status: submission.status,
},
});
} catch (error) {
if (error.name === "ValidationError") {
const errors = Object.values(error.errors).map((item) => item.message);
return res.status(400).json({ success: false, error: errors.join(", ") });
}
console.error("submission.create error:", error);
return res
.status(500)
.json({ success: false, error: "Error submitting form" });
}
};
exports.list = async (req, res) => {
try {
const page = Math.max(parseInt(req.query.page, 10) || 1, 1);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
const skip = (page - 1) * limit;
const query = buildListQuery(req.query);
const [items, total] = await Promise.all([
Submission.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
Submission.countDocuments(query),
]);
return res.json({
success: true,
data: items,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
},
});
} catch (error) {
console.error("submission.list error:", error);
return res
.status(500)
.json({ success: false, error: "Error loading submissions" });
}
};
exports.detail = async (req, res) => {
try {
const submission = await Submission.findById(req.params.id).lean();
if (!submission) {
return res.status(404).json({ success: false, error: "Submission not found" });
}
return res.json({ success: true, data: submission });
} catch (error) {
console.error("submission.detail error:", error);
return res
.status(500)
.json({ success: false, error: "Error loading submission" });
}
};
exports.update = async (req, res) => {
try {
const update = {};
const status = cleanString(req.body.status);
if (status) {
if (!Submission.STATUSES.includes(status)) {
return res.status(400).json({ success: false, error: "Invalid status" });
}
update.status = status;
}
if (req.body.internalNote !== undefined) {
update.internalNote = cleanString(req.body.internalNote);
}
const submission = await Submission.findByIdAndUpdate(req.params.id, update, {
new: true,
runValidators: true,
});
if (!submission) {
return res.status(404).json({ success: false, error: "Submission not found" });
}
return res.json({ success: true, data: submission });
} catch (error) {
if (error.name === "ValidationError") {
const errors = Object.values(error.errors).map((item) => item.message);
return res.status(400).json({ success: false, error: errors.join(", ") });
}
console.error("submission.update error:", error);
return res
.status(500)
.json({ success: false, error: "Error updating submission" });
}
};
+22
View File
@@ -0,0 +1,22 @@
{
"levels": [
"Bachelor's",
"Master's",
"truong's"
],
"fields": [
"Computer Science",
"Data Analytics",
"Business & Admin",
"Healthcare",
"Finance & Accounting",
"Marketing"
],
"durations": [
"< 6 months",
"6-12 months",
"12-24 months",
"24-48 months",
"48+ months"
]
}
+58
View File
@@ -0,0 +1,58 @@
const mongoose = require("mongoose");
const STATUSES = ["subscribed", "unsubscribed"];
const emailPattern = /^\S+@\S+\.\S+$/;
const newsletterSubscriptionSchema = new mongoose.Schema(
{
email: {
type: String,
required: [true, "Email is required"],
trim: true,
lowercase: true,
validate: {
validator(value) {
return emailPattern.test(value);
},
message: "Please enter a valid email",
},
},
status: {
type: String,
enum: STATUSES,
default: "subscribed",
},
pageUrl: {
type: String,
trim: true,
default: "",
},
ipAddress: {
type: String,
default: "",
},
userAgent: {
type: String,
default: "",
},
internalNote: {
type: String,
trim: true,
default: "",
maxlength: [2000, "Internal note cannot exceed 2000 characters"],
},
},
{
collection: "newsletter_subscriptions",
timestamps: true,
autoCreate: false,
autoIndex: false,
},
);
newsletterSubscriptionSchema.statics.STATUSES = STATUSES;
module.exports = mongoose.model(
"NewsletterSubscription",
newsletterSubscriptionSchema,
);
+2
View File
@@ -34,7 +34,9 @@ const programmeSchema = new mongoose.Schema(
title: { type: String, default: "" },
description: { type: String, default: "" },
duration: { type: String, default: "" },
durationInMonths: { type: Number, default: 0 },
cost: { type: String, default: "" },
fieldOfStudy: { type: String, default: "" },
selected: { type: Boolean, default: false },
link: { type: String, default: "" },
shortName: { type: String, default: "" },
+85
View File
@@ -0,0 +1,85 @@
const mongoose = require("mongoose");
const SOURCES = ["home", "request", "contact", "partnership"];
const STATUSES = ["new", "contacted", "info_provided", "closed"];
const emailPattern = /^\S+@\S+\.\S+$/;
const phonePattern = /^[+()\d\s.-]{7,20}$/;
const submissionSchema = new mongoose.Schema(
{
source: {
type: String,
enum: SOURCES,
required: [true, "Source is required"],
},
pageUrl: {
type: String,
trim: true,
default: "",
},
name: {
type: String,
required: [true, "Name is required"],
trim: true,
maxlength: [160, "Name cannot exceed 160 characters"],
},
email: {
type: String,
default: "",
trim: true,
lowercase: true,
validate: {
validator(value) {
return !value || emailPattern.test(value);
},
message: "Please enter a valid email",
},
},
phone: {
type: String,
trim: true,
default: "",
validate: {
validator(value) {
return !value || phonePattern.test(value);
},
message: "Please enter a valid phone number",
},
},
payload: {
type: mongoose.Schema.Types.Mixed,
default: {},
},
status: {
type: String,
enum: STATUSES,
default: "new",
},
internalNote: {
type: String,
trim: true,
default: "",
maxlength: [2000, "Internal note cannot exceed 2000 characters"],
},
ipAddress: {
type: String,
default: "",
},
userAgent: {
type: String,
default: "",
},
},
{
collection: "submissions",
timestamps: true,
autoCreate: false,
autoIndex: false,
},
);
submissionSchema.statics.SOURCES = SOURCES;
submissionSchema.statics.STATUSES = STATUSES;
module.exports = mongoose.model("Submission", submissionSchema);
+25
View File
@@ -19,6 +19,8 @@ const formController = require("../controllers/formController");
const contactController = require("../controllers/contactController");
const studentSupportController = require("../controllers/studentSupportController");
const requestInfoController = require("../controllers/requestInfoController");
const submissionController = require("../controllers/submissionController");
const newsletterSubscriptionController = require("../controllers/newsletterSubscriptionController");
const pageController = require("../controllers/pageController");
const settingController = require("../controllers/settingController");
@@ -199,6 +201,27 @@ router.put(
contactController.updateSubmissionStatus,
);
// Unified submission management
router.get("/submissions", ensureAuthenticated, submissionController.index);
router.get("/submissions/data", ensureAuthenticated, submissionController.list);
router.get("/submissions/:id", ensureAuthenticated, submissionController.detail);
router.patch("/submissions/:id", ensureAuthenticated, submissionController.update);
router.get(
"/newsletter-subscriptions/data",
ensureAuthenticated,
newsletterSubscriptionController.list,
);
router.get(
"/newsletter-subscriptions/:id",
ensureAuthenticated,
newsletterSubscriptionController.detail,
);
router.patch(
"/newsletter-subscriptions/:id",
ensureAuthenticated,
newsletterSubscriptionController.update,
);
// Student Support (LAMS static page)
router.get(
"/student-support",
@@ -289,6 +312,8 @@ router.get("/test-images", ensureAuthenticated, (req, res) => {
// Programme Management
router.get("/programme", ensureAuthenticated, programmeController.index);
router.get("/programme/filters", ensureAuthenticated, programmeController.filtersPage);
router.post("/programme/filters", ensureAuthenticated, programmeController.updateFilters);
router.get("/programme/edit/:id", ensureAuthenticated, programmeController.edit);
router.post("/programme/update/:id", ensureAuthenticated, upload.single('heroImage'), programmeController.update);
router.post("/programme/delete/:id", ensureAuthenticated, programmeController.delete);
+5
View File
@@ -14,6 +14,8 @@ const footerController = require("../controllers/footerController");
const contactController = require("../controllers/contactController");
const studentSupportController = require("../controllers/studentSupportController");
const requestInfoController = require("../controllers/requestInfoController");
const submissionController = require("../controllers/submissionController");
const newsletterSubscriptionController = require("../controllers/newsletterSubscriptionController");
const headerMenuController = require("../controllers/headerMenuController");
const programmeController = require("../controllers/programmeController");
@@ -64,6 +66,8 @@ router.get("/api/request-info", requestInfoController.api);
// Contact form submission (public)
router.post("/api/contact/submit", contactController.submitForm);
router.post("/api/submissions", submissionController.create);
router.post("/api/newsletter/subscribe", newsletterSubscriptionController.subscribe);
// Blog API Routes
router.get("/api/blog", blogController.api);
@@ -93,6 +97,7 @@ router.get("/api/blog/:slug", blogController.apiShow);
// Programmes API
router.get("/api/programmes", programmeController.api);
router.get("/api/programmes/filters", programmeController.apiFilters);
router.get("/api/programmes/:id", programmeController.apiDetail);
+124
View File
@@ -0,0 +1,124 @@
require("dotenv").config();
const mongoose = require("mongoose");
const connectDB = require("../config/database");
const COLLECTION_NAME = "submissions";
const validator = {
$jsonSchema: {
bsonType: "object",
required: ["source", "name", "status", "createdAt", "updatedAt"],
properties: {
source: {
enum: ["home", "request", "contact", "partnership"],
description: "Source must be one of the supported submission sources",
},
pageUrl: {
bsonType: "string",
},
name: {
bsonType: "string",
minLength: 1,
maxLength: 160,
},
email: {
bsonType: "string",
pattern: "^$|^\\S+@\\S+\\.\\S+$",
},
phone: {
bsonType: "string",
pattern: "^$|^[+()\\d\\s.-]{7,20}$",
},
payload: {
bsonType: "object",
},
status: {
enum: ["new", "contacted", "info_provided", "closed"],
},
internalNote: {
bsonType: "string",
maxLength: 2000,
},
ipAddress: {
bsonType: "string",
},
userAgent: {
bsonType: "string",
},
createdAt: {
bsonType: "date",
},
updatedAt: {
bsonType: "date",
},
},
},
};
async function ensureCollection(db) {
const collections = await db
.listCollections({ name: COLLECTION_NAME }, { nameOnly: true })
.toArray();
if (collections.length === 0) {
await db.createCollection(COLLECTION_NAME, {
validator,
validationLevel: "moderate",
validationAction: "error",
});
return;
}
await db.command({
collMod: COLLECTION_NAME,
validator,
validationLevel: "moderate",
validationAction: "error",
});
}
async function ensureIndexes(collection) {
await dropLegacyIndex(collection, "source_1");
await dropLegacyIndex(collection, "status_1");
await collection.createIndex(
{ source: 1, status: 1, createdAt: -1 },
{ name: "source_1_status_1_createdAt_-1" },
);
await collection.createIndex({ email: 1 }, { name: "email_1" });
await collection.createIndex(
{ name: "text", email: "text", phone: "text" },
{ name: "name_text_email_text_phone_text" },
);
}
async function dropLegacyIndex(collection, name) {
try {
await collection.dropIndex(name);
} catch (error) {
if (error.codeName !== "IndexNotFound") {
throw error;
}
}
}
async function migrate() {
try {
await connectDB();
const db = mongoose.connection.db;
await ensureCollection(db);
await ensureIndexes(db.collection(COLLECTION_NAME));
console.log("Submissions collection migration completed successfully");
await mongoose.disconnect();
process.exit(0);
} catch (error) {
console.error("Submissions migration error:", error);
process.exit(1);
}
}
if (require.main === module) {
migrate();
}
module.exports = { migrate };
@@ -0,0 +1,92 @@
require("dotenv").config();
const mongoose = require("mongoose");
const connectDB = require("../config/database");
const COLLECTION_NAME = "newsletter_subscriptions";
const validator = {
$jsonSchema: {
bsonType: "object",
required: ["email", "status", "createdAt", "updatedAt"],
properties: {
email: {
bsonType: "string",
pattern: "^\\S+@\\S+\\.\\S+$",
},
status: {
enum: ["subscribed", "unsubscribed"],
},
pageUrl: {
bsonType: "string",
},
ipAddress: {
bsonType: "string",
},
userAgent: {
bsonType: "string",
},
internalNote: {
bsonType: "string",
maxLength: 2000,
},
createdAt: {
bsonType: "date",
},
updatedAt: {
bsonType: "date",
},
},
},
};
async function ensureCollection(db) {
const collections = await db
.listCollections({ name: COLLECTION_NAME }, { nameOnly: true })
.toArray();
if (collections.length === 0) {
await db.createCollection(COLLECTION_NAME, {
validator,
validationLevel: "moderate",
validationAction: "error",
});
return;
}
await db.command({
collMod: COLLECTION_NAME,
validator,
validationLevel: "moderate",
validationAction: "error",
});
}
async function ensureIndexes(collection) {
await collection.createIndex({ email: 1 }, { name: "email_1", unique: true });
await collection.createIndex(
{ status: 1, createdAt: -1 },
{ name: "status_1_createdAt_-1" },
);
await collection.createIndex({ createdAt: -1 }, { name: "createdAt_-1" });
}
async function migrate() {
try {
await connectDB();
const db = mongoose.connection.db;
await ensureCollection(db);
await ensureIndexes(db.collection(COLLECTION_NAME));
console.log("Newsletter subscriptions migration completed successfully");
await mongoose.disconnect();
process.exit(0);
} catch (error) {
console.error("Newsletter subscriptions migration error:", error);
process.exit(1);
}
}
if (require.main === module) {
migrate();
}
module.exports = { migrate };
+1 -1
View File
@@ -144,7 +144,7 @@ app.use((req, res, next) => {
if (isOriginAllowed) {
res.setHeader("Access-Control-Allow-Origin", origin || "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Credentials", "true");
}
+13 -4
View File
@@ -55,11 +55,20 @@
<%- include("partials/templates", { editorUi }) %>
<script type="application/json" id="partnershipsPageBootstrapJson"><%- JSON.stringify({
data,
backendUrl,
editorConfig,
editorUi,
}) %></script>
<script>
window.partnershipsPageData = <%- JSON.stringify(data) %>;
window.partnershipsBackendUrl = <%- JSON.stringify(backendUrl) %>;
window.partnershipsEditorConfig = <%- JSON.stringify(editorConfig) %>;
window.partnershipsEditorUi = <%- JSON.stringify(editorUi) %>;
const partnershipsPageBootstrap = JSON.parse(
document.getElementById("partnershipsPageBootstrapJson").textContent || "{}",
);
window.partnershipsPageData = partnershipsPageBootstrap.data;
window.partnershipsBackendUrl = partnershipsPageBootstrap.backendUrl;
window.partnershipsEditorConfig = partnershipsPageBootstrap.editorConfig;
window.partnershipsEditorUi = partnershipsPageBootstrap.editorUi;
</script>
<%- include("partials/editor-script") %>
@@ -97,12 +97,6 @@
renderPartners();
});
document.getElementById("addInquiryFieldBtn")?.addEventListener("click", function () {
state.inquiryForm.fields.push(getDefaultInquiryField());
ensurePartnershipIds();
renderInquiryFields();
});
document.getElementById("resetPartnershipsForm")?.addEventListener("click", function () {
window.location.reload();
});
@@ -378,7 +372,7 @@
input.value = field[key] || "";
input.addEventListener("input", function () {
field[key] = input.value;
if (key === "label") {
if (key === "label") {
title.textContent = input.value || `${inquiryFieldFields.label?.label || "Field"} ${index + 1}`;
}
if (key === "type") {
@@ -401,11 +395,6 @@
}
});
node.querySelector("[data-remove-item]").addEventListener("click", function () {
state.inquiryForm.fields.splice(index, 1);
renderInquiryFields();
});
node.querySelector("[data-add-option]").addEventListener("click", function () {
field.options = Array.isArray(field.options) ? field.options : [];
field.options.push("");
@@ -444,7 +433,6 @@
inquiryFieldsList.appendChild(node);
});
initSortable(inquiryFieldsList, state.inquiryForm.fields, renderInquiryFields, '[data-item="inquiry-field"]');
}
function initSortable(container, list, rerender, draggableSelector) {
@@ -16,17 +16,13 @@
<div class="mb-3">
<div>
<label class="form-label fw-semibold mb-1"><%= inquiryUi.fields?.label || "Form fields" %></label>
<div class="form-text mt-0"><%= inquiryUi.fieldsHelpText || inquiryUi.fields?.helpText || "" %></div>
<div class="form-text mt-0">
Field structure is fixed to match the inquiry form: First Name, Last Name, Organization Name, Partnership Type, and Message. You can edit field text, required state, width, and Partnership Type select options.
</div>
</div>
</div>
<div id="inquiryFieldsList"></div>
<button type="button" class="cms-add-button mt-3" id="addInquiryFieldBtn">
<i class="fas fa-plus me-2"></i><%= inquiryUi.fields?.addLabel || "Add Field" %>
</button>
</div>
</div>
</div>
</div>
@@ -100,9 +100,6 @@
<div class="card cms-item-card mb-3" data-item="inquiry-field">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div class="d-flex align-items-center gap-2">
<button type="button" class="drag-handle" title="Drag to reorder">
<i class="fas fa-grip-vertical"></i>
</button>
<div>
<div class="fw-semibold" data-title>Field</div>
<div class="small text-muted" data-subtitle></div>
@@ -112,9 +109,6 @@
<button type="button" class="cms-collapse-toggle" data-toggle-item title="Collapse item">
<i class="fas fa-chevron-down"></i>
</button>
<button type="button" class="cms-remove-button" data-remove-item title="Remove item">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="card-body">
@@ -129,7 +123,7 @@
</div>
<div class="col-md-4">
<label class="form-label fw-semibold"><%= inquiryFieldFields.type?.label || "Field type" %></label>
<select class="form-select" data-field="type">
<select class="form-select" data-field="type" disabled>
<% (inquiryFieldFields.type?.options || []).forEach((option) => { %>
<option value="<%= option.value %>"><%= option.label %></option>
<% }) %>
@@ -144,10 +138,10 @@
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" data-field="required">
<label class="form-check-label fw-semibold"><%= inquiryFieldFields.required?.label || "Required field" %></label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" data-field="required">
<label class="form-check-label fw-semibold"><%= inquiryFieldFields.required?.label || "Required field" %></label>
</div>
</div>
<div class="col-12" data-options-wrap>
<div class="cms-editor-group">
+43 -3
View File
@@ -78,12 +78,33 @@
</div>
<div class="col-md-4">
<label class="form-label fw-medium" for="fieldLevel">Level</label>
<input type="text" class="form-control" id="fieldLevel" name="level" value="<%= programme.level || '' %>" placeholder="Bachelor's Degree, Master's…">
<select class="form-select" id="fieldLevel" name="level">
<option value="">-- Select level --</option>
<% (levelOptions || []).forEach(function(level) { %>
<option value="<%= level %>" <%= programme.level === level ? 'selected' : '' %>><%= level %></option>
<% }) %>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-medium" for="fieldShortName">Short Name</label>
<input type="text" class="form-control" id="fieldShortName" name="shortName" value="<%= programme.shortName || '' %>" placeholder="MBA, B.S., Cert…">
</div>
<div class="col-md-4">
<label class="form-label fw-medium" for="fieldFieldOfStudy">Field of Study</label>
<select class="form-select" id="fieldFieldOfStudy" name="fieldOfStudy">
<option value="">-- Select field --</option>
<option value="Computer Science" <%= programme.fieldOfStudy === 'Computer Science' ? 'selected' : '' %>>Computer Science</option>
<option value="Data Analytics" <%= programme.fieldOfStudy === 'Data Analytics' ? 'selected' : '' %>>Data Analytics</option>
<option value="Business & Admin" <%= programme.fieldOfStudy === 'Business & Admin' ? 'selected' : '' %>>Business & Admin</option>
<option value="Healthcare" <%= programme.fieldOfStudy === 'Healthcare' ? 'selected' : '' %>>Healthcare</option>
<option value="Finance & Accounting" <%= programme.fieldOfStudy === 'Finance & Accounting' ? 'selected' : '' %>>Finance & Accounting</option>
<option value="Marketing" <%= programme.fieldOfStudy === 'Marketing' ? 'selected' : '' %>>Marketing</option>
<option value="Education" <%= programme.fieldOfStudy === 'Education' ? 'selected' : '' %>>Education</option>
<option value="Engineering" <%= programme.fieldOfStudy === 'Engineering' ? 'selected' : '' %>>Engineering</option>
<option value="Psychology" <%= programme.fieldOfStudy === 'Psychology' ? 'selected' : '' %>>Psychology</option>
<option value="Cybersecurity" <%= programme.fieldOfStudy === 'Cybersecurity' ? 'selected' : '' %>>Cybersecurity</option>
</select>
</div>
<div class="col-md-12">
<label class="form-label fw-medium" for="fieldTitle">Title <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="fieldTitle" name="title" value="<%= programme.title || '' %>" required>
@@ -93,8 +114,27 @@
<textarea class="form-control" id="fieldDescription" name="description" rows="3"><%= programme.description || '' %></textarea>
</div>
<div class="col-md-4">
<label class="form-label fw-medium" for="fieldDuration">Duration</label>
<input type="text" class="form-control" id="fieldDuration" name="duration" value="<%= programme.duration || '' %>" placeholder="4 Years (Flexible)">
<label class="form-label fw-medium" for="fieldDuration">Duration (Display)</label>
<input
type="text"
class="form-control"
id="fieldDuration"
name="duration"
value="<%= programme.duration || '' %>"
placeholder="4 Years (Flexible)"
list="durationSuggestions"
>
<datalist id="durationSuggestions">
<% (durationOptions || []).forEach(function(duration) { %>
<option value="<%= duration %>"></option>
<% }) %>
</datalist>
<div class="form-text">Text hiển thị trên chương trình.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-medium" for="fieldDurationInMonths">Duration (months)</label>
<input type="number" class="form-control" id="fieldDurationInMonths" name="durationInMonths" value="<%= programme.durationInMonths || 0 %>" min="0" placeholder="48">
<div class="form-text">Dùng để match vào Duration filter.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-medium" for="fieldFormat">Format</label>
+65
View File
@@ -0,0 +1,65 @@
<div class="container">
<div class="d-flex justify-content-between align-items-center mt-4 mb-4 flex-wrap gap-2">
<div>
<h1 class="h3 mb-0" style="color: var(--primary-dark);">Programme Filters</h1>
<p class="text-muted mb-0">Manage centralized filter options used by frontend programme page.</p>
</div>
<div class="d-flex gap-2">
<% if (frontendUrl) { %>
<a href="<%= frontendUrl %>/programmes" class="btn btn-outline-primary" target="_blank" rel="noopener">
<i class="fas fa-external-link-alt me-2"></i>View Programmes Page
</a>
<% } %>
<a href="/admin/programme" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Back to programmes
</a>
</div>
</div>
<form action="/admin/programme/filters" method="POST" class="card shadow-sm border-0">
<div class="card-body">
<div class="row g-4">
<div class="col-md-4">
<label class="form-label fw-medium" for="levelsInput">Degree level options</label>
<textarea
id="levelsInput"
name="levels"
class="form-control"
rows="14"
placeholder="One option per line"
><%= (filters.levels || []).join('\n') %></textarea>
<div class="form-text">Use one line per option. Example: Bachelor's, Master's.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-medium" for="fieldsInput">Field of Study options</label>
<textarea
id="fieldsInput"
name="fields"
class="form-control"
rows="14"
placeholder="One option per line"
><%= (filters.fields || []).join('\n') %></textarea>
<div class="form-text">Use one line per option. These appear as checkboxes in frontend filters.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-medium" for="durationsInput">Duration range options</label>
<textarea
id="durationsInput"
name="durations"
class="form-control"
rows="14"
placeholder="One option per line"
><%= (filters.durations || []).join('\n') %></textarea>
<div class="form-text">Use one line per option. Example: &lt; 6 months, 12-24 months, 48+ months.</div>
</div>
</div>
</div>
<div class="card-footer bg-white border-top d-flex justify-content-end gap-2">
<a href="/admin/programme" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>Save Filters
</button>
</div>
</form>
</div>
+8 -1
View File
@@ -10,6 +10,9 @@
<i class="fas fa-external-link-alt me-2"></i>View Programmes Page
</a>
<% } %>
<a href="/admin/programme/filters" class="btn btn-outline-secondary">
<i class="fas fa-filter me-2"></i>Manage Filters
</a>
<a href="/admin/programme/edit/new" class="btn btn-primary shadow-sm">
<i class="fas fa-plus me-2"></i>Add New Programme
</a>
@@ -25,6 +28,8 @@
<th class="ps-4" style="width:110px;">ID / Code</th>
<th>Title</th>
<th>Level</th>
<th>Field</th>
<th class="text-center" style="width:90px;">Months</th>
<th>Format</th>
<th>Cost</th>
<th class="text-center" style="width:80px;">Courses</th>
@@ -48,6 +53,8 @@
<td>
<span class="badge bg-light text-dark border"><%= item.level || '—' %></span>
</td>
<td class="text-muted small"><%= item.fieldOfStudy || '—' %></td>
<td class="text-center text-muted small"><%= item.durationInMonths || '—' %></td>
<td class="text-muted small"><%= item.format || '—' %></td>
<td class="text-muted small"><%= item.monthlyCost || item.cost || '—' %></td>
<td class="text-center">
@@ -75,7 +82,7 @@
<% }); %>
<% } else { %>
<tr>
<td colspan="8" class="text-center text-muted py-5">
<td colspan="10" class="text-center text-muted py-5">
<i class="fas fa-graduation-cap fa-2x mb-3 d-block opacity-25"></i>
No programmes found.
<a href="/admin/programme/edit/new" class="d-block mt-2">Add your first programme &rarr;</a>
+30 -15
View File
@@ -344,16 +344,28 @@
// return html;
// }
function iconPickerInputHtml(extraClass, currentValue) {
return (
'<div class="input-group">' +
'<span class="input-group-text icon-preview-cell" style="min-width:38px">' +
(currentValue ? '<i class="' + escapeHtml(currentValue) + '"></i>' : '') +
'</span>' +
'<input type="text" class="form-control ch-icon ' + escapeHtml(extraClass) + '"' +
' value="' + escapeHtml(currentValue || '') + '"' +
' placeholder="Click to pick..." readonly' +
' style="cursor:pointer;background:#fff" onclick="IconPicker.open(this)">' +
'</div>'
);
}
function buildServiceRow(svc) {
svc = svc || {};
var iconSel = '<select class="form-select svc-icon-select">' + iconOptionsHtml(svc.icon) + '</select>';
var hoursIconSel = '<select class="form-select svc-hours-icon-select">' + iconOptionsHtml(svc.hoursIcon) + '</select>';
return (
'<div class="card service-row border">' +
'<div class="card-body">' +
'<div class="row g-2">' +
'<div class="col-md-4"><label class="form-label small">Icon</label>' + iconPickerInput('svc-icon-select', svc.icon) + '</div>' +
'<div class="col-md-4"><label class="form-label small">Hours icon</label>' + iconPickerInput('svc-hours-icon-select', svc.hoursIcon) + '</div>' +
'<div class="col-md-4"><label class="form-label small">Icon</label>' + iconPickerInputHtml('svc-icon-field', svc.icon) + '</div>' +
'<div class="col-md-4"><label class="form-label small">Hours icon</label>' + iconPickerInputHtml('svc-hours-icon-field', svc.hoursIcon) + '</div>' +
'<div class="col-md-4 d-flex align-items-end justify-content-end">' +
'<button type="button" class="btn btn-outline-danger btn-sm remove-service"><i class="fas fa-trash"></i></button>' +
'</div>' +
@@ -376,7 +388,7 @@
return (
'<div class="card channel-row border"><div class="card-body py-2">' +
'<div class="row g-2 align-items-end">' +
'<div class="col-md-3"><label class="form-label small">Icon</label>' + iconPickerInput('ch-icon-select', ch.icon) + '</div>' +
'<div class="col-md-3"><label class="form-label small">Icon</label>' + iconPickerInputHtml('ch-icon-field', ch.icon) + '</div>' +
'<div class="col-md-3"><label class="form-label small">Title</label>' +
'<input type="text" class="form-control ch-title" value="' + escapeHtml(ch.title) + '"></div>' +
'<div class="col-md-5"><label class="form-label small">Detail</label>' +
@@ -427,14 +439,16 @@
var services = [];
document.querySelectorAll('#servicesContainer .service-row').forEach(function(row) {
// Icon inputs: all .ch-icon inside this row — first = service icon, second = hours icon
var iconInputs = row.querySelectorAll('.ch-icon');
services.push({
icon: row.querySelector('.svc-icon-select').value,
title: row.querySelector('.svc-title').value.trim(),
description: row.querySelector('.svc-description').value.trim(),
hours: row.querySelector('.svc-hours').value.trim(),
hoursIcon: row.querySelector('.svc-hours-icon-select').value,
buttonLabel: row.querySelector('.svc-btn-label').value.trim(),
buttonHref: row.querySelector('.svc-btn-href').value.trim(),
icon: iconInputs[0] ? iconInputs[0].value : '',
title: row.querySelector('.svc-title') ? row.querySelector('.svc-title').value.trim() : '',
description: row.querySelector('.svc-description') ? row.querySelector('.svc-description').value.trim() : '',
hours: row.querySelector('.svc-hours') ? row.querySelector('.svc-hours').value.trim() : '',
hoursIcon: iconInputs[1] ? iconInputs[1].value : '',
buttonLabel: row.querySelector('.svc-btn-label') ? row.querySelector('.svc-btn-label').value.trim() : '',
buttonHref: row.querySelector('.svc-btn-href') ? row.querySelector('.svc-btn-href').value.trim() : '',
});
});
document.getElementById('directoryJson').value = JSON.stringify({
@@ -445,10 +459,11 @@
var channels = [];
document.querySelectorAll('#channelsContainer .channel-row').forEach(function(row) {
var iconInput = row.querySelector('.ch-icon');
channels.push({
icon: row.querySelector('.ch-icon-select').value,
title: row.querySelector('.ch-title').value.trim(),
detail: row.querySelector('.ch-detail').value.trim(),
icon: iconInput ? iconInput.value : '',
title: row.querySelector('.ch-title') ? row.querySelector('.ch-title').value.trim() : '',
detail: row.querySelector('.ch-detail') ? row.querySelector('.ch-detail').value.trim() : '',
});
});
var departments = [];
+137
View File
@@ -0,0 +1,137 @@
<div class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0" style="color: var(--primary-dark)"><%= title %></h1>
<p class="text-muted mb-0"><%= subtitle %></p>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-bottom">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<% sources.forEach((source) => { %>
<li class="nav-item" role="presentation">
<button
class="nav-link <%= activeSource === source ? 'active' : '' %>"
type="button"
role="tab"
data-submission-tab="<%= source %>"
>
<%= sourceLabels[source] %>
<small class="text-muted">(<%= sourceUrls[source] %>)</small>
</button>
</li>
<% }) %>
</ul>
</div>
<div class="card-body">
<div class="row g-3 align-items-end mb-4">
<div class="col-md-3">
<label class="form-label small text-muted">Search</label>
<input type="search" class="form-control" id="submissionSearch" placeholder="Name, email, phone">
</div>
<div class="col-md-2">
<label class="form-label small text-muted">Status</label>
<select class="form-select" id="submissionStatus">
<option value="">All statuses</option>
<% statuses.forEach((status) => { %>
<option value="<%= status %>"><%= status.replace("_", " ") %></option>
<% }) %>
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">Start Date</label>
<input type="date" class="form-control" id="submissionStartDate">
</div>
<div class="col-md-2">
<label class="form-label small text-muted">End Date</label>
<input type="date" class="form-control" id="submissionEndDate">
</div>
<div class="col-md-1">
<label class="form-label small text-muted">Show</label>
<select class="form-select" id="submissionPageSize">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="button" class="btn btn-primary flex-fill" id="submissionApplyFilters">
<i class="fas fa-filter me-1"></i>Filter
</button>
<button type="button" class="btn btn-outline-secondary" id="submissionClearFilters">
Clear
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Date</th>
<th data-submission-name-column>Name</th>
<th data-submission-email-column>Email</th>
<th data-submission-phone-column>Phone</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="submissionRows">
<tr>
<td colspan="6" class="text-center text-muted py-5">Loading submissions...</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted" id="submissionPaginationSummary"></small>
<div class="btn-group">
<button type="button" class="btn btn-outline-secondary btn-sm" id="submissionPrevPage">Previous</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="submissionNextPage">Next</button>
</div>
</div>
</div>
</div>
</div>
<%- include("partials/submission-modal", { statuses }) %>
<script type="application/json" id="submissionManagerConfigJson"><%- JSON.stringify({
sources,
statuses,
statusesBySource: {
newsletter: newsletterStatuses,
},
initialSource: activeSource,
urlTabParam: true,
hiddenFieldsBySource: {
partnership: {
email: true,
phone: true,
},
newsletter: {
name: true,
phone: true,
},
},
apiBySource: {
newsletter: {
list: "/admin/newsletter-subscriptions/data",
detail: "/admin/newsletter-subscriptions",
update: "/admin/newsletter-subscriptions",
},
},
itemLabelBySource: {
newsletter: "subscriptions",
},
}) %></script>
<script>
window.submissionManagerConfig = JSON.parse(
document.getElementById("submissionManagerConfigJson").textContent || "{}",
);
</script>
<%- include("partials/submission-manager-script") %>
@@ -0,0 +1,418 @@
<script>
(function () {
const config = window.submissionManagerConfig || {};
const sources = config.sources || ["home", "request", "contact"];
const statuses = config.statuses || [];
const statusesBySource = config.statusesBySource || {};
const hiddenFieldsBySource = config.hiddenFieldsBySource || {};
const apiBySource = config.apiBySource || {};
const itemLabelBySource = config.itemLabelBySource || {};
const rows = document.getElementById("submissionRows");
const searchInput = document.getElementById("submissionSearch");
const statusInput = document.getElementById("submissionStatus");
const startDateInput = document.getElementById("submissionStartDate");
const endDateInput = document.getElementById("submissionEndDate");
const pageSizeInput = document.getElementById("submissionPageSize");
const summary = document.getElementById("submissionPaginationSummary");
const prevButton = document.getElementById("submissionPrevPage");
const nextButton = document.getElementById("submissionNextPage");
const saveButton = document.getElementById("submissionSaveDetail");
const detailModalNode = document.getElementById("submissionDetailModal");
if (detailModalNode && detailModalNode.parentElement !== document.body) {
document.body.appendChild(detailModalNode);
}
const detailModal = detailModalNode ? new bootstrap.Modal(detailModalNode) : null;
const state = {
source: config.initialSource || sources[0] || "home",
page: 1,
totalPages: 1,
limit: parseInt(pageSizeInput?.value, 10) || 20,
};
applyFieldVisibility();
renderStatusFilterOptions();
document.querySelectorAll("[data-submission-tab]").forEach((tab) => {
tab.addEventListener("click", function () {
document.querySelectorAll("[data-submission-tab]").forEach((item) => item.classList.remove("active"));
tab.classList.add("active");
state.source = tab.dataset.submissionTab;
state.page = 1;
updateUrlTab(state.source);
applyFieldVisibility();
renderStatusFilterOptions();
loadSubmissions();
});
});
document.getElementById("submissionApplyFilters")?.addEventListener("click", function () {
state.page = 1;
loadSubmissions();
});
document.getElementById("submissionClearFilters")?.addEventListener("click", function () {
if (searchInput) searchInput.value = "";
if (statusInput) statusInput.value = "";
if (startDateInput) startDateInput.value = "";
if (endDateInput) endDateInput.value = "";
state.page = 1;
loadSubmissions();
});
searchInput?.addEventListener("keydown", function (event) {
if (event.key === "Enter") {
event.preventDefault();
state.page = 1;
loadSubmissions();
}
});
prevButton?.addEventListener("click", function () {
if (state.page > 1) {
state.page -= 1;
loadSubmissions();
}
});
nextButton?.addEventListener("click", function () {
if (state.page < state.totalPages) {
state.page += 1;
loadSubmissions();
}
});
pageSizeInput?.addEventListener("change", function () {
state.limit = parseInt(pageSizeInput.value, 10) || 20;
state.page = 1;
loadSubmissions();
});
saveButton?.addEventListener("click", saveDetail);
async function loadSubmissions() {
if (!rows) return;
rows.innerHTML = `<tr><td colspan="${getTableColumnCount()}" class="text-center text-muted py-5">Loading submissions...</td></tr>`;
const params = new URLSearchParams({
source: state.source,
page: String(state.page),
limit: String(state.limit),
});
if (searchInput?.value) params.set("search", searchInput.value);
if (statusInput?.value) params.set("status", statusInput.value);
if (startDateInput?.value) params.set("startDate", startDateInput.value);
if (endDateInput?.value) params.set("endDate", endDateInput.value);
try {
const response = await fetch(`${getListUrl()}?${params.toString()}`);
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || "Unable to load submissions");
}
state.totalPages = result.pagination.totalPages || 1;
renderRows(result.data || []);
renderPagination(result.pagination);
} catch (error) {
rows.innerHTML = `<tr><td colspan="${getTableColumnCount()}" class="text-center text-danger py-5">${escapeHtml(error.message)}</td></tr>`;
}
}
function renderRows(items) {
if (!items.length) {
rows.innerHTML = `<tr><td colspan="${getTableColumnCount()}" class="text-center text-muted py-5">No submissions found</td></tr>`;
return;
}
rows.innerHTML = items.map((item) => `
<tr>
<td>${formatDate(item.createdAt)}</td>
${isNameHidden() ? "" : `<td>${escapeHtml(item.name || "-")}</td>`}
${isEmailHidden() ? "" : `<td><a href="mailto:${escapeHtml(item.email || "")}">${escapeHtml(item.email || "-")}</a></td>`}
${isPhoneHidden() ? "" : `<td>${escapeHtml(item.phone || "-")}</td>`}
<td><span class="badge ${statusClass(item.status)} rounded-pill">${escapeHtml((item.status || "").replace("_", " "))}</span></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-primary" data-view-submission="${item._id}">
<i class="fas fa-eye me-1"></i>View
</button>
</td>
</tr>
`).join("");
rows.querySelectorAll("[data-view-submission]").forEach((button) => {
button.addEventListener("click", function () {
openDetail(button.dataset.viewSubmission);
});
});
}
function renderPagination(pagination) {
if (summary) {
summary.textContent = `Page ${pagination.page} of ${pagination.totalPages || 1} - ${pagination.total} ${getItemLabel()}`;
}
if (prevButton) prevButton.disabled = pagination.page <= 1;
if (nextButton) nextButton.disabled = pagination.page >= (pagination.totalPages || 1);
}
async function openDetail(id) {
try {
const response = await fetch(`${getDetailBaseUrl()}/${id}`);
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || "Unable to load submission");
}
fillDetail(result.data);
detailModal?.show();
} catch (error) {
alert(error.message);
}
}
function fillDetail(item) {
setText("submissionDetailMeta", `${item.source || ""} - ${formatDate(item.createdAt)}`);
setValue("submissionDetailId", item._id);
renderDetailStatusOptions(item.source, item.status);
setValue("submissionDetailNote", item.internalNote || "");
const email = document.getElementById("submissionDetailEmail");
applyFieldVisibility(item.source);
if (item.source === "partnership") {
setText("submissionDetailNameLabel", getPayloadLabel("firstName", item.source));
setText("submissionDetailName", item.payload?.firstName || "-");
if (!isEmailHidden(item.source)) {
setText("submissionDetailEmailLabel", getPayloadLabel("lastName", item.source));
if (email) {
email.textContent = item.payload?.lastName || "-";
email.href = "#";
}
}
if (!isPhoneHidden(item.source)) {
setText("submissionDetailPhoneLabel", getPayloadLabel("organization", item.source));
setText("submissionDetailPhone", item.payload?.organization || "-");
}
setText("submissionDetailPageLabel", getPayloadLabel("partnershipType", item.source));
setText("submissionDetailPage", item.payload?.partnershipType || "-");
} else {
setText("submissionDetailNameLabel", "Name");
setText("submissionDetailName", item.name || "-");
setText("submissionDetailEmailLabel", "Email");
if (email) {
email.textContent = item.email || "-";
email.href = item.email ? `mailto:${item.email}` : "#";
}
setText("submissionDetailPhoneLabel", "Phone");
setText("submissionDetailPhone", item.phone || "-");
setText("submissionDetailPageLabel", "Page");
setText("submissionDetailPage", item.pageUrl || "-");
}
const payload = document.getElementById("submissionPayload");
if (payload) {
payload.innerHTML = getPayloadEntries(item).map(([key, value]) => `
<div class="row py-1 border-bottom">
<div class="col-md-4 text-muted">${escapeHtml(getPayloadLabel(key, item.source))}</div>
<div class="col-md-8">${escapeHtml(formatValue(value))}</div>
</div>
`).join("") || '<span class="text-muted">No payload values</span>';
}
}
function getTableColumnCount() {
return 6 - (isNameHidden() ? 1 : 0) - (isEmailHidden() ? 1 : 0) - (isPhoneHidden() ? 1 : 0);
}
function applyFieldVisibility(source) {
const nameHidden = isNameHidden(source);
const emailHidden = isEmailHidden(source);
const phoneHidden = isPhoneHidden(source);
document.querySelectorAll("[data-submission-name-column], [data-submission-name-field]").forEach((node) => {
node.classList.toggle("d-none", nameHidden);
});
document.querySelectorAll("[data-submission-email-column], [data-submission-email-field]").forEach((node) => {
node.classList.toggle("d-none", emailHidden);
});
document.querySelectorAll("[data-submission-phone-column], [data-submission-phone-field]").forEach((node) => {
node.classList.toggle("d-none", phoneHidden);
});
}
function isNameHidden(source) {
return Boolean(hiddenFieldsBySource[source || state.source]?.name);
}
function isEmailHidden(source) {
return Boolean(hiddenFieldsBySource[source || state.source]?.email);
}
function isPhoneHidden(source) {
return Boolean(hiddenFieldsBySource[source || state.source]?.phone);
}
function getStatuses(source) {
return statusesBySource[source || state.source] || statuses;
}
function renderStatusFilterOptions() {
if (!statusInput) return;
const previousValue = statusInput.value;
statusInput.innerHTML = '<option value="">All statuses</option>';
getStatuses().forEach((status) => {
const option = document.createElement("option");
option.value = status;
option.textContent = status.replace("_", " ");
statusInput.appendChild(option);
});
statusInput.value = getStatuses().includes(previousValue) ? previousValue : "";
}
function renderDetailStatusOptions(source, selectedStatus) {
const node = document.getElementById("submissionDetailStatus");
if (!node) return;
node.innerHTML = "";
getStatuses(source).forEach((status) => {
const option = document.createElement("option");
option.value = status;
option.textContent = status.replace("_", " ");
node.appendChild(option);
});
node.value = selectedStatus || getStatuses(source)[0] || "";
}
function getListUrl() {
return apiBySource[state.source]?.list || "/admin/submissions/data";
}
function getDetailBaseUrl() {
return apiBySource[state.source]?.detail || "/admin/submissions";
}
function getUpdateBaseUrl() {
return apiBySource[state.source]?.update || "/admin/submissions";
}
function getItemLabel() {
return itemLabelBySource[state.source] || "submissions";
}
function updateUrlTab(tabKey) {
if (!config.urlTabParam || !tabKey) return;
const url = new URL(window.location.href);
url.searchParams.set("tab", tabKey);
window.history.replaceState({}, "", `${url.pathname}?${url.searchParams.toString()}`);
}
async function saveDetail() {
const id = document.getElementById("submissionDetailId")?.value;
if (!id) return;
try {
const response = await fetch(`${getUpdateBaseUrl()}/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: document.getElementById("submissionDetailStatus")?.value,
internalNote: document.getElementById("submissionDetailNote")?.value || "",
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || "Unable to update submission");
}
detailModal?.hide();
loadSubmissions();
} catch (error) {
alert(error.message);
}
}
function setText(id, value) {
const node = document.getElementById(id);
if (node) node.textContent = value;
}
function setValue(id, value) {
const node = document.getElementById(id);
if (node) node.value = value;
}
function formatDate(value) {
if (!value) return "-";
return new Date(value).toLocaleString();
}
function formatKey(value) {
return String(value || "").replace(/_/g, " ").replace(/([A-Z])/g, " $1").trim();
}
function getPayloadEntries(item) {
const payload = item.payload || {};
if (item.source !== "partnership") {
return Object.entries(payload);
}
const orderedKeys = getPartnershipFieldOrder();
return orderedKeys
.filter((key) => Object.prototype.hasOwnProperty.call(payload, key))
.map((key) => [key, payload[key]]);
}
function getPayloadLabel(key, source) {
if (source === "partnership") {
const field = getPartnershipFields().find((item) => item.id === key);
if (field?.label) {
return field.label;
}
const fallback = {
firstName: "First Name",
lastName: "Last Name",
organization: "Organization Name",
partnershipType: "Partnership Type",
message: "Message",
};
return fallback[key] || formatKey(key);
}
return formatKey(key);
}
function getPartnershipFieldOrder() {
const fields = getPartnershipFields();
if (fields.length > 0) {
return fields.map((field) => field.id);
}
return ["firstName", "lastName", "organization", "partnershipType", "message"];
}
function getPartnershipFields() {
return Array.isArray(window.partnershipsPageData?.inquiryForm?.fields)
? window.partnershipsPageData.inquiryForm.fields
: [];
}
function formatValue(value) {
if (Array.isArray(value)) return value.join(", ");
if (value && typeof value === "object") return JSON.stringify(value);
if (typeof value === "boolean") return value ? "Yes" : "No";
return value == null || value === "" ? "-" : String(value);
}
function statusClass(status) {
if (status === "new") return "bg-warning text-dark";
if (status === "contacted") return "bg-info text-dark";
if (status === "info_provided") return "bg-success";
if (status === "closed") return "bg-secondary";
if (status === "subscribed") return "bg-success";
if (status === "unsubscribed") return "bg-secondary";
return "bg-light text-dark";
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
loadSubmissions();
})();
</script>
@@ -0,0 +1,62 @@
<div class="modal fade" id="submissionDetailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable" style="max-height: calc(100vh - 3.5rem);">
<div class="modal-content" style="max-height: calc(100vh - 3.5rem);">
<div class="modal-header">
<div>
<h5 class="modal-title mb-0">Submission Detail</h5>
<small class="text-muted" id="submissionDetailMeta"></small>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" style="overflow-y: auto;">
<input type="hidden" id="submissionDetailId">
<div class="row g-3 mb-4">
<div class="col-md-6" data-submission-name-field>
<label class="form-label small text-muted" id="submissionDetailNameLabel">Name</label>
<div class="fw-semibold" id="submissionDetailName">-</div>
</div>
<%# Hidden by Partnerships submissions because that inquiry form has no email field. %>
<div class="col-md-6" data-submission-email-field>
<label class="form-label small text-muted" id="submissionDetailEmailLabel">Email</label>
<div><a id="submissionDetailEmail" href="#">-</a></div>
</div>
<%# Hidden by Partnerships submissions because that inquiry form has no phone field. %>
<div class="col-md-6" data-submission-phone-field>
<label class="form-label small text-muted" id="submissionDetailPhoneLabel">Phone</label>
<div id="submissionDetailPhone">-</div>
</div>
<div class="col-md-6">
<label class="form-label small text-muted" id="submissionDetailPageLabel">Page</label>
<div id="submissionDetailPage">-</div>
</div>
</div>
<div class="mb-4">
<label class="form-label small text-muted">Submitted Values</label>
<div class="border rounded p-3 bg-light" id="submissionPayload"></div>
</div>
<div class="row g-3">
<div class="col-md-5">
<label class="form-label">Status</label>
<select class="form-select" id="submissionDetailStatus">
<% statuses.forEach((status) => { %>
<option value="<%= status %>"><%= status.replace("_", " ") %></option>
<% }) %>
</select>
</div>
<div class="col-12">
<label class="form-label">Internal note</label>
<textarea class="form-control" id="submissionDetailNote" rows="4" maxlength="2000" placeholder="Add internal processing notes"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="submissionSaveDetail">
<i class="fas fa-save me-1"></i>Save
</button>
</div>
</div>
</div>
</div>
+7 -1
View File
@@ -1109,7 +1109,13 @@
</li>
<li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/audit-logs' ? 'active' : '' %>"
<a class="nav-link <%= currentPath === '/admin/submissions' ? 'active' : '' %>"
href="/admin/submissions">Submission
</a>
</li>
<li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/request-info' ? 'active' : '' %>"
href="/admin/request-info">Request Info
</a>
</li>