forked from UKSOURCE/cms.lams
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
256 lines
7.1 KiB
JavaScript
256 lines
7.1 KiB
JavaScript
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" });
|
|
}
|
|
};
|