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:
Tống Thành Đạt
2026-04-24 17:11:23 +07:00
parent caf38e8750
commit f6f2e0e03b
18 changed files with 1534 additions and 54 deletions
+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;