From f6f2e0e03bf096889eaaa4dfcef7b0a0aad0f94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Th=C3=A0nh=20=C4=90=E1=BA=A1t?= <84076965+tongthanhdat009@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:11:23 +0700 Subject: [PATCH] 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 --- .../newsletterSubscriptionController.js | 194 ++++++++ controllers/partnershipsController.js | 67 ++- controllers/submissionController.js | 255 +++++++++++ models/newsletterSubscription.js | 58 +++ models/submission.js | 85 ++++ routes/admin.js | 23 + routes/index.js | 4 + scripts/2026_04_24_150000_submissions.js | 124 ++++++ ...6_04_24_160000_newsletter_subscriptions.js | 92 ++++ server.js | 2 +- views/admin/partnerships/index.ejs | 17 +- .../partnerships/partials/editor-script.ejs | 14 +- .../partials/inquiry-form-tab.ejs | 10 +- .../admin/partnerships/partials/templates.ejs | 16 +- views/admin/submissions/index.ejs | 137 ++++++ .../partials/submission-manager-script.ejs | 418 ++++++++++++++++++ .../submissions/partials/submission-modal.ejs | 62 +++ views/layouts/main.ejs | 10 +- 18 files changed, 1534 insertions(+), 54 deletions(-) create mode 100644 controllers/newsletterSubscriptionController.js create mode 100644 controllers/submissionController.js create mode 100644 models/newsletterSubscription.js create mode 100644 models/submission.js create mode 100644 scripts/2026_04_24_150000_submissions.js create mode 100644 scripts/2026_04_24_160000_newsletter_subscriptions.js create mode 100644 views/admin/submissions/index.ejs create mode 100644 views/admin/submissions/partials/submission-manager-script.ejs create mode 100644 views/admin/submissions/partials/submission-modal.ejs diff --git a/controllers/newsletterSubscriptionController.js b/controllers/newsletterSubscriptionController.js new file mode 100644 index 0000000..ea8ea87 --- /dev/null +++ b/controllers/newsletterSubscriptionController.js @@ -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" }); + } +}; diff --git a/controllers/partnershipsController.js b/controllers/partnershipsController.js index be5df7e..aafa0fd 100644 --- a/controllers/partnershipsController.js +++ b/controllers/partnershipsController.js @@ -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; diff --git a/controllers/submissionController.js b/controllers/submissionController.js new file mode 100644 index 0000000..dd747d0 --- /dev/null +++ b/controllers/submissionController.js @@ -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" }); + } +}; diff --git a/models/newsletterSubscription.js b/models/newsletterSubscription.js new file mode 100644 index 0000000..05cbc1e --- /dev/null +++ b/models/newsletterSubscription.js @@ -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, +); diff --git a/models/submission.js b/models/submission.js new file mode 100644 index 0000000..b57dd54 --- /dev/null +++ b/models/submission.js @@ -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); diff --git a/routes/admin.js b/routes/admin.js index d6201f6..c7fa283 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -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", diff --git a/routes/index.js b/routes/index.js index d22df96..aa6724c 100644 --- a/routes/index.js +++ b/routes/index.js @@ -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); diff --git a/scripts/2026_04_24_150000_submissions.js b/scripts/2026_04_24_150000_submissions.js new file mode 100644 index 0000000..920926f --- /dev/null +++ b/scripts/2026_04_24_150000_submissions.js @@ -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 }; diff --git a/scripts/2026_04_24_160000_newsletter_subscriptions.js b/scripts/2026_04_24_160000_newsletter_subscriptions.js new file mode 100644 index 0000000..9fcbd4b --- /dev/null +++ b/scripts/2026_04_24_160000_newsletter_subscriptions.js @@ -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 }; diff --git a/server.js b/server.js index 07f84b0..1d8755b 100644 --- a/server.js +++ b/server.js @@ -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"); } diff --git a/views/admin/partnerships/index.ejs b/views/admin/partnerships/index.ejs index b44a7eb..42ff452 100644 --- a/views/admin/partnerships/index.ejs +++ b/views/admin/partnerships/index.ejs @@ -55,11 +55,20 @@ <%- include("partials/templates", { editorUi }) %> + <%- include("partials/editor-script") %> diff --git a/views/admin/partnerships/partials/editor-script.ejs b/views/admin/partnerships/partials/editor-script.ejs index 24da14d..61a2be3 100644 --- a/views/admin/partnerships/partials/editor-script.ejs +++ b/views/admin/partnerships/partials/editor-script.ejs @@ -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) { diff --git a/views/admin/partnerships/partials/inquiry-form-tab.ejs b/views/admin/partnerships/partials/inquiry-form-tab.ejs index 0328e78..522dd05 100644 --- a/views/admin/partnerships/partials/inquiry-form-tab.ejs +++ b/views/admin/partnerships/partials/inquiry-form-tab.ejs @@ -16,17 +16,13 @@
<%= subtitle %>
+| Date | +Name | +Phone | +Status | +Actions | +|
|---|---|---|---|---|---|
| Loading submissions... | +|||||