forked from UKSOURCE/cms.lams
feat(admin): implement unified submission and newsletter management
Introduce a centralized system to manage all website form submissions and newsletter subscriptions. - Add `Submission` and `NewsletterSubscription` models with MongoDB schema validation - Implement `submissionController` and `newsletterSubscriptionController` for CRUD operations and filtering - Create a unified admin UI for reviewing submissions across different sources (home, request, contact, partnership, newsletter) - Add database migration scripts for creating collections and indexes - Refactor partnership inquiry forms to use a fixed field structure - Update admin navigation and server CORS settings to support PATCH requests
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user