From 399f14249a1db9bd71508e8bbdc58cd4749ad569 Mon Sep 17 00:00:00 2001
From: Ravi Gulhane <>
Date: Wed, 8 Jul 2026 11:54:28 +0530
Subject: [PATCH] Your commit message
---
.env.example | 15 +
.nvmrc | 1 +
.../event-up/assets/js/eventup-ssr-bridge.js | 138 ++++++
microsite-v2-templates/event-up/template.html | 450 ++++++++++++++++++
microsite-v2-templates/event-up/template.json | 337 +++++++++++++
package-lock.json | 404 +++++-----------
package.json | 9 +-
scripts/backfill-form-submission-files.js | 125 +++++
scripts/seed-microsite-v2-template.js | 175 +++++++
src/app.js | 4 +
src/config/constants.js | 2 +-
src/controllers/form.controller.js | 102 +++-
.../publicEventTicket.controller.js | 21 +-
src/controllers/publicMicrosite.controller.js | 11 +-
src/controllers/staticMicrosite.controller.js | 410 ++++++++++++++++
src/controllers/visitor.controller.js | 18 +-
src/models/EventStaticMicrosite.js | 53 +++
src/models/Form.js | 11 +-
src/models/MicrositeV2Template.js | 34 ++
src/routes/form.routes.js | 7 +-
src/routes/staticMicrosite.routes.js | 140 ++++++
src/utils/formSubmissionFiles.js | 155 ++++++
src/utils/handlebarsRender.js | 92 ++++
src/utils/publishStaticMicrosite.js | 107 +++++
src/utils/registrationFormFields.js | 53 +++
src/utils/s3MicrositeV2.js | 113 +++++
src/utils/subdomainAvailability.js | 78 +++
src/utils/templateSchema.js | 113 +++++
28 files changed, 2833 insertions(+), 345 deletions(-)
create mode 100644 .nvmrc
create mode 100644 microsite-v2-templates/event-up/assets/js/eventup-ssr-bridge.js
create mode 100644 microsite-v2-templates/event-up/template.html
create mode 100644 microsite-v2-templates/event-up/template.json
create mode 100644 scripts/backfill-form-submission-files.js
create mode 100644 scripts/seed-microsite-v2-template.js
create mode 100644 src/controllers/staticMicrosite.controller.js
create mode 100644 src/models/EventStaticMicrosite.js
create mode 100644 src/models/MicrositeV2Template.js
create mode 100644 src/routes/staticMicrosite.routes.js
create mode 100644 src/utils/formSubmissionFiles.js
create mode 100644 src/utils/handlebarsRender.js
create mode 100644 src/utils/publishStaticMicrosite.js
create mode 100644 src/utils/registrationFormFields.js
create mode 100644 src/utils/s3MicrositeV2.js
create mode 100644 src/utils/subdomainAvailability.js
create mode 100644 src/utils/templateSchema.js
diff --git a/.env.example b/.env.example
index dbafdeb..d4195ce 100644
--- a/.env.example
+++ b/.env.example
@@ -2,6 +2,8 @@ NODE_ENV=development
PORT=3001
# Max JSON body size (bytes). Default 25MB if unset — needed for event create with base64 logo/banner.
# PAYLOAD_MAX_BYTES=26214400
+# Max size per form file upload after base64 decode (default 10MB)
+# FORM_SUBMISSION_MAX_FILE_BYTES=10485760
MONGODB_URI=mongodb://localhost:27017/eventbuzz
JWT_SECRET=your-super-secret-jwt-key-change-this
JWT_REFRESH_SECRET=your-refresh-secret-key-change-this
@@ -87,8 +89,21 @@ MICROSITE_API_BASE_URL=http://localhost:3001/api
# MICROSITE_REGISTRATION_URL_TEMPLATE=https://web.eventbuzz.live/event/{eventId}/register
# Published microsite folder base (custom microsite static HTML)
# MICROSITE_PUBLIC_BASE_URL=https://web.eventbuzz.live
+
+# --- Microsite V2 (Handlebars static microsites on S3 + subdomain) ---
+# S3 key prefix for template and published site objects (default: eventbuzz/microsite-v2)
+# MICROSITE_V2_S3_PREFIX=eventbuzz/microsite-v2
+# Public URL pattern for event subdomains; {subdomain} is replaced at runtime
+# MICROSITE_V2_PUBLIC_BASE_URL=https://{subdomain}.eventbuzz.live
+# Infra: wildcard DNS *.eventbuzz.live → CloudFront; map Host header to
+# s3://{AWS_BUCKET}/{MICROSITE_V2_S3_PREFIX}/sites/{subdomain}/index.html
+# Dev fallback: open published HTML via CloudFront URL of index.html key directly.
+# Seed first template: npm run seed:microsite-v2-template
+
# Comma-separated CORS origins (include console + public web app)
# CORS_ORIGIN=https://console.eventbuzz.live,https://web.eventbuzz.live
+# Public routes under /api/public/* also echo the request Origin (any domain) when the API is up.
+# Browser "access control checks" errors on api.eventbuzz.live often mean nginx 502 (Node not running), not CORS config.
# Firebase Realtime Database (optional — quiz/poll sync). Paths relative to backend/ when starting the API from backend/
FIREBASE_DATABASE_URL=https://eventbuzz-live-default-rtdb.firebaseio.com
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..209e3ef
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+20
diff --git a/microsite-v2-templates/event-up/assets/js/eventup-ssr-bridge.js b/microsite-v2-templates/event-up/assets/js/eventup-ssr-bridge.js
new file mode 100644
index 0000000..53f8c66
--- /dev/null
+++ b/microsite-v2-templates/event-up/assets/js/eventup-ssr-bridge.js
@@ -0,0 +1,138 @@
+(function () {
+ 'use strict';
+
+ /** Minimal runtime bridge for SSR-published EventUp pages (no site-config.js DOM hydration). */
+ function readEmbeddedConfig() {
+ var el = document.getElementById('eventup-microsite-config');
+ if (!el || !el.textContent) return null;
+ try {
+ return JSON.parse(el.textContent);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function applyTheme(theme) {
+ if (!theme) return;
+ var root = document.documentElement;
+ if (theme.accent) {
+ root.style.setProperty('--eventup-accent', theme.accent);
+ root.style.setProperty('--eventup-accent-start', theme.accent);
+ }
+ var headingColor = (theme.heading && theme.heading.color) || theme.headingColor;
+ if (headingColor) {
+ root.style.setProperty('--eventup-heading-color', headingColor);
+ }
+ }
+
+ function initCountdown(hero) {
+ if (!hero || !hero.countdown || hero.countdown.show === false) {
+ var countSection = document.querySelector('[data-site-component="countdown"]');
+ if (countSection) countSection.style.display = 'none';
+ return;
+ }
+ if (!window.jQuery || !window.jQuery.fn.countdown) return;
+ var date = hero.countdown.date;
+ var time = hero.countdown.time || '12:00';
+ if (!date) return;
+ var target = new Date(date + 'T' + time + ':00');
+ window.jQuery('#clock').countdown(target, function (event) {
+ window.jQuery(this).html(event.strftime('%D days %H:%M:%S'));
+ });
+ }
+
+ function applyLinkTarget(linkEl, item) {
+ if (!linkEl || !item || !item.url) return;
+ linkEl.href = item.url;
+ if (item.target === '_blank') {
+ linkEl.target = '_blank';
+ linkEl.rel = 'noopener noreferrer';
+ return;
+ }
+ linkEl.removeAttribute('target');
+ if (item.target === '_self' || !item.target) {
+ linkEl.removeAttribute('rel');
+ }
+ }
+
+ function bindCtaLinks(config) {
+ var components = config.components || config.sections || {};
+ var heroCta = components.hero && components.hero.cta;
+ var heroLinks = document.querySelectorAll('[data-hero-cta-link], [data-nav-cta-link]');
+
+ if (heroCta && heroCta.url) {
+ if (heroCta.target === 'modal' && window.EventUpRegistrationModal && window.EventUpRegistrationModal.bindLink) {
+ heroLinks.forEach(function (a) {
+ window.EventUpRegistrationModal.bindLink(a, heroCta, config);
+ });
+ } else {
+ heroLinks.forEach(function (a) {
+ applyLinkTarget(a, heroCta);
+ });
+ }
+ }
+
+ var footer = components.footer;
+ if (!footer || !footer.links || !footer.links.length) return;
+
+ var footerLinkEls = document.querySelectorAll('[data-footer-link]');
+ footer.links.forEach(function (item, index) {
+ var linkEl = footerLinkEls[index];
+ if (!linkEl || !item.url) return;
+
+ if (item.target === 'modal' && window.EventUpRegistrationModal && window.EventUpRegistrationModal.bindLink) {
+ window.EventUpRegistrationModal.bindLink(linkEl, item, config);
+ return;
+ }
+
+ applyLinkTarget(linkEl, item);
+ });
+ }
+
+ function boot(config) {
+ if (!config) return;
+ applyTheme(config.theme || {});
+ initCountdown((config.components || config.sections || {}).hero);
+ bindCtaLinks(config);
+
+ var components = config.components || config.sections || {};
+ if (window.EventUpContactForm && components.contact) {
+ window.EventUpContactForm.init(components.contact, config);
+ }
+ if (window.EventUpFeedbackFab && components.feedback) {
+ window.EventUpFeedbackFab.init(components.feedback, config);
+ }
+ if (window.EventUpMarketingPopup && components.marketing_popup) {
+ window.EventUpMarketingPopup.init(components.marketing_popup, config);
+ }
+
+ document.documentElement.classList.add('site-config-loaded');
+ document.dispatchEvent(new CustomEvent('site-config-loaded'));
+ if (typeof window.updateNavbarSpacer === 'function') {
+ window.updateNavbarSpacer();
+ }
+ }
+
+ function init() {
+ var config = readEmbeddedConfig();
+ if (config) {
+ boot(config);
+ return;
+ }
+ fetch('config.json', { cache: 'no-store' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (json) {
+ if (json) {
+ if (!json.components && json.sections) json.components = json.sections;
+ boot(json);
+ }
+ })
+ .catch(function () {});
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/microsite-v2-templates/event-up/template.html b/microsite-v2-templates/event-up/template.html
new file mode 100644
index 0000000..23d4f4c
--- /dev/null
+++ b/microsite-v2-templates/event-up/template.html
@@ -0,0 +1,450 @@
+
+
+
+
+
+ {{meta.title}}
+
+
+
+
+
+
+
+
+
+
+
+ {{#if_show sections.logo}}
+
+
+ {{/if_show}}
+
+
+
+ {{#if sections.hero.countdown.show}}
+
+ {{/if}}
+
+ {{#if_show sections.about}}
+
+
+
+
+
+
+
{{sections.about.heading}}
+
{{{sections.about.content}}}
+
+
+
+
+
+
+ {{/if_show}}
+
+ {{#if_show sections.video}}
+
+
+
+
+ {{#each sections.video.items}}
+
+
+ {{#if this.title}}
{{this.title}}
{{/if}}
+
+ {{/each}}
+
+
+
+ {{/if_show}}
+
+ {{#if_show sections.informationBar}}
+
+ {{/if_show}}
+
+ {{#if_show sections.event_highlights}}
+
+
+
+
+ {{#each sections.event_highlights.items}}
+
+
+
+
{{this.title}}
+
{{this.description}}
+
0{{@index}}
+
+
+ {{/each}}
+
+
+
+ {{/if_show}}
+
+ {{#if_show sections.event_stats}}
+
+
+
+ {{#each sections.event_stats.items}}
+
+ {{/each}}
+
+
+
+ {{/if_show}}
+
+ {{#if_show sections.speakers}}
+
+
+
+
+ {{#each sections.speakers.items}}
+
+
+ {{#if this.image}}

{{/if}}
+
{{this.name}}
+
{{this.role}}
+
+
+ {{/each}}
+
+
+
+ {{/if_show}}
+
+ {{#if_show sections.event_schedule}}
+
+
+
+ {{#each sections.event_schedule.days}}
+
+
{{this.label}}{{#if this.date}} — {{this.date}}{{/if}}
+
+
+
+ {{#each this.sessions}}
+
+ | {{this.time}} |
+ {{this.type}} |
+ {{this.title}} |
+
+ {{/each}}
+
+
+
+
+ {{/each}}
+
+
+ {{/if_show}}
+
+ {{#if_show sections.about_crest}}
+
+ {{/if_show}}
+
+ {{#if_show sections.sponsors}}
+
+ {{/if_show}}
+
+ {{#if_show sections.contact}}
+
+ {{/if_show}}
+
+ {{#if_show sections.map}}
+
+ {{/if_show}}
+
+ {{#if_show sections.footer}}
+
+ {{/if_show}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/microsite-v2-templates/event-up/template.json b/microsite-v2-templates/event-up/template.json
new file mode 100644
index 0000000..b001318
--- /dev/null
+++ b/microsite-v2-templates/event-up/template.json
@@ -0,0 +1,337 @@
+{
+ "version": 1,
+ "meta": {
+ "title": "EventUp Conference",
+ "eventId": ""
+ },
+ "theme": {
+ "fields": [
+ { "key": "accent", "type": "color", "label": "Accent color", "default": "#189A95" },
+ { "key": "headingColor", "type": "color", "label": "Heading color", "default": "#015AAA" }
+ ]
+ },
+ "sections": [
+ {
+ "id": "marketing_popup",
+ "label": "Marketing popup",
+ "show": false,
+ "fields": [
+ { "key": "heading", "type": "text", "label": "Heading", "default": "Introducing" },
+ { "key": "logo", "type": "image", "label": "Popup logo" },
+ {
+ "key": "autoCloseDuration",
+ "type": "text",
+ "label": "Auto-close after (ms)",
+ "default": "3000"
+ }
+ ]
+ },
+ {
+ "id": "logo",
+ "label": "Logos",
+ "show": true,
+ "fields": [
+ { "key": "header.url", "type": "image", "label": "Header logo" },
+ { "key": "header.alt", "type": "text", "label": "Header logo alt", "default": "Event logo" },
+ { "key": "header.maxHeight", "type": "text", "label": "Header logo max height (px)", "default": "56" },
+ { "key": "hero.url", "type": "image", "label": "Hero logo" },
+ { "key": "hero.alt", "type": "text", "label": "Hero logo alt", "default": "" },
+ { "key": "footer.url", "type": "image", "label": "Footer logo" },
+ { "key": "footer.alt", "type": "text", "label": "Footer logo alt", "default": "" }
+ ]
+ },
+ {
+ "id": "hero",
+ "label": "Hero",
+ "show": true,
+ "fields": [
+ { "key": "event_name", "type": "text", "label": "Event name", "default": "Your Event Name" },
+ { "key": "date_venue", "type": "text", "label": "Date & venue", "default": "Date and location" },
+ { "key": "image.url", "type": "image", "label": "Hero background image" },
+ { "key": "cta.text", "type": "text", "label": "CTA label", "default": "Register Now" },
+ { "key": "cta.url", "type": "url", "label": "CTA URL", "default": "#" },
+ {
+ "key": "cta.target",
+ "type": "select",
+ "label": "CTA target",
+ "default": "_blank",
+ "options": [
+ { "value": "_blank", "label": "New tab" },
+ { "value": "modal", "label": "Modal (iframe)" },
+ { "value": "_self", "label": "Same tab" }
+ ]
+ },
+ { "key": "countdown.show", "type": "boolean", "label": "Show countdown", "default": true },
+ { "key": "countdown.date", "type": "text", "label": "Countdown date (YYYY-MM-DD)", "default": "" },
+ { "key": "countdown.time", "type": "text", "label": "Countdown time (HH:mm)", "default": "12:00" }
+ ]
+ },
+ {
+ "id": "about",
+ "label": "About the event",
+ "show": true,
+ "fields": [
+ { "key": "heading", "type": "text", "label": "Heading", "default": "About the Event" },
+ { "key": "content", "type": "richtext", "label": "Content", "default": "Describe your event here.
" },
+ { "key": "youtubeUrl", "type": "url", "label": "YouTube embed URL (optional)" }
+ ]
+ },
+ {
+ "id": "video",
+ "label": "Expert videos",
+ "show": false,
+ "fields": [
+ { "key": "heading", "type": "text", "label": "Heading", "default": "Hear it from experts" },
+ {
+ "key": "items",
+ "type": "array",
+ "label": "Videos",
+ "itemFields": [
+ { "key": "url", "type": "url", "label": "Video URL" },
+ { "key": "title", "type": "text", "label": "Title" }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "informationBar",
+ "label": "Information bar",
+ "show": true,
+ "fields": [
+ {
+ "key": "items",
+ "type": "array",
+ "label": "Info items",
+ "itemFields": [
+ { "key": "icon", "type": "text", "label": "Icon class (LineIcons)", "default": "lni-map-marker" },
+ { "key": "label", "type": "text", "label": "Label" },
+ { "key": "value", "type": "text", "label": "Value" }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "event_highlights",
+ "label": "Why join (highlights)",
+ "show": true,
+ "fields": [
+ { "key": "heading", "type": "text", "label": "Heading", "default": "Why You Should Join?" },
+ { "key": "subtitle", "type": "textarea", "label": "Subtitle", "default": "" },
+ {
+ "key": "items",
+ "type": "array",
+ "label": "Highlight cards",
+ "itemFields": [
+ { "key": "icon", "type": "text", "label": "Icon class", "default": "lni-microphone" },
+ { "key": "title", "type": "text", "label": "Title" },
+ { "key": "description", "type": "textarea", "label": "Description" }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "event_stats",
+ "label": "Stats counters",
+ "show": false,
+ "fields": [
+ {
+ "key": "items",
+ "type": "array",
+ "label": "Stats",
+ "itemFields": [
+ { "key": "icon", "type": "text", "label": "Icon class", "default": "lni-mic" },
+ { "key": "value", "type": "text", "label": "Value" },
+ { "key": "label", "type": "text", "label": "Label" }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "speakers",
+ "label": "Speakers",
+ "show": true,
+ "fields": [
+ { "key": "heading", "type": "text", "label": "Heading", "default": "Our Speakers" },
+ { "key": "subtitle", "type": "textarea", "label": "Subtitle", "default": "" },
+ {
+ "key": "items",
+ "type": "array",
+ "label": "Speakers",
+ "itemFields": [
+ { "key": "name", "type": "text", "label": "Name" },
+ { "key": "role", "type": "text", "label": "Role" },
+ { "key": "image", "type": "image", "label": "Photo" },
+ { "key": "url", "type": "url", "label": "Profile URL", "default": "#" }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "event_schedule",
+ "label": "Schedule",
+ "show": true,
+ "fields": [
+ { "key": "heading", "type": "text", "label": "Heading", "default": "Event Schedule" },
+ { "key": "subtitle", "type": "textarea", "label": "Subtitle", "default": "" },
+ {
+ "key": "days",
+ "type": "array",
+ "label": "Days",
+ "itemFields": [
+ { "key": "label", "type": "text", "label": "Day label", "default": "Day 01" },
+ { "key": "date", "type": "text", "label": "Date line", "default": "" },
+ {
+ "key": "sessions",
+ "type": "array",
+ "label": "Sessions",
+ "itemFields": [
+ { "key": "time", "type": "text", "label": "Time" },
+ { "key": "type", "type": "text", "label": "Type", "default": "Main programme" },
+ { "key": "title", "type": "text", "label": "Title" }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "about_crest",
+ "label": "About CREST / secondary about",
+ "show": false,
+ "fields": [
+ { "key": "heading", "type": "text", "label": "Heading", "default": "About" },
+ { "key": "content", "type": "richtext", "label": "Content", "default": "" },
+ { "key": "cta.text", "type": "text", "label": "CTA text", "default": "" },
+ { "key": "cta.url", "type": "url", "label": "CTA URL", "default": "#" }
+ ]
+ },
+ {
+ "id": "latest_news",
+ "label": "Latest news",
+ "show": false,
+ "fields": [
+ { "key": "heading", "type": "text", "label": "Heading", "default": "Latest News" },
+ { "key": "subtitle", "type": "textarea", "label": "Subtitle", "default": "" }
+ ]
+ },
+ {
+ "id": "sponsors",
+ "label": "Sponsors",
+ "show": true,
+ "fields": [
+ { "key": "heading", "type": "text", "label": "Heading (optional)", "default": "Sponsors" },
+ { "key": "subtitle", "type": "textarea", "label": "Subtitle", "default": "" },
+ { "key": "hideHeading", "type": "boolean", "label": "Hide section heading", "default": false },
+ { "key": "hideGroupTitles", "type": "boolean", "label": "Hide sponsor tier titles", "default": false },
+ {
+ "key": "groups",
+ "type": "array",
+ "label": "Sponsor groups",
+ "itemFields": [
+ { "key": "title", "type": "text", "label": "Tier title" },
+ { "key": "tier", "type": "text", "label": "Tier id", "default": "other" },
+ {
+ "key": "items",
+ "type": "array",
+ "label": "Logos",
+ "itemFields": [
+ { "key": "image", "type": "image", "label": "Logo image" },
+ { "key": "alt", "type": "text", "label": "Alt text" }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "map",
+ "label": "Map",
+ "show": false,
+ "fields": [
+ { "key": "embedUrl", "type": "url", "label": "Google Maps embed URL" },
+ { "key": "height", "type": "text", "label": "Iframe height (px)", "default": "450" }
+ ]
+ },
+ {
+ "id": "contact",
+ "label": "Contact",
+ "show": true,
+ "fields": [
+ { "key": "heading", "type": "text", "label": "Heading", "default": "Contact Us" },
+ { "key": "subtitle", "type": "textarea", "label": "Subtitle", "default": "" },
+ { "key": "formId", "type": "text", "label": "EventBuzz form ID" },
+ { "key": "apiBaseUrl", "type": "url", "label": "API base URL", "default": "https://api.eventbuzz.live/api" },
+ { "key": "address", "type": "textarea", "label": "Address", "default": "" },
+ {
+ "key": "phones",
+ "type": "array",
+ "label": "Phone numbers",
+ "itemFields": [
+ { "key": "label", "type": "text", "label": "Label" },
+ { "key": "number", "type": "text", "label": "Display number" },
+ { "key": "tel", "type": "text", "label": "tel: href value" }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "feedback",
+ "label": "Feedback FAB",
+ "show": false,
+ "fields": [
+ { "key": "label", "type": "text", "label": "Button label", "default": "Feedback" },
+ { "key": "url", "type": "url", "label": "Feedback form URL" },
+ {
+ "key": "target",
+ "type": "select",
+ "label": "Open in",
+ "default": "_blank",
+ "options": [
+ { "value": "_blank", "label": "New tab" },
+ { "value": "modal", "label": "Modal" }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "footer",
+ "label": "Footer",
+ "show": true,
+ "fields": [
+ { "key": "tagline", "type": "text", "label": "Tagline", "default": "" },
+ { "key": "linksHeading", "type": "text", "label": "Links heading", "default": "Quick Links" },
+ { "key": "social.heading", "type": "text", "label": "Social heading", "default": "Follow us" },
+ {
+ "key": "links",
+ "type": "array",
+ "label": "Footer links",
+ "itemFields": [
+ { "key": "text", "type": "text", "label": "Text" },
+ { "key": "url", "type": "url", "label": "URL" },
+ {
+ "key": "target",
+ "type": "select",
+ "label": "Target",
+ "default": "_self",
+ "options": [
+ { "value": "modal", "label": "Modal" },
+ { "value": "_blank", "label": "New tab" },
+ { "value": "_self", "label": "Same tab" }
+ ]
+ }
+ ]
+ },
+ {
+ "key": "social.links",
+ "type": "array",
+ "label": "Social links",
+ "itemFields": [
+ { "key": "url", "type": "url", "label": "URL" },
+ { "key": "icon", "type": "text", "label": "Icon class", "default": "lni-facebook-filled" },
+ { "key": "label", "type": "text", "label": "Aria label" }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index 0a3fb7c..4b93d63 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,13 +13,15 @@
"@aws-sdk/lib-storage": "^3.1012.0",
"@hapi/hapi": "^21.3.0",
"@napi-rs/canvas": "^0.1.56",
+ "adm-zip": "^0.5.18",
"archiver": "^7.0.1",
- "bcrypt": "^5.1.1",
+ "bcrypt": "^6.0.0",
"bullmq": "^5.34.5",
"cheerio": "^1.0.0",
"dotenv": "^16.6.1",
"firebase-admin": "^13.0.0",
"google-auth-library": "^9.14.0",
+ "handlebars": "^4.7.9",
"hapi-auth-jwt2": "^10.2.0",
"ioredis": "^5.4.2",
"joi": "^17.13.0",
@@ -41,6 +43,9 @@
"jest": "^29.7.0",
"nodemon": "^3.0.2",
"supertest": "^6.3.3"
+ },
+ "engines": {
+ "node": ">=20"
}
},
"node_modules/@aws-crypto/crc32": {
@@ -3033,26 +3038,6 @@
"url": "https://opencollective.com/js-sdsl"
}
},
- "node_modules/@mapbox/node-pre-gyp": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
- "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "detect-libc": "^2.0.0",
- "https-proxy-agent": "^5.0.0",
- "make-dir": "^3.1.0",
- "node-fetch": "^2.6.7",
- "nopt": "^5.0.0",
- "npmlog": "^5.0.1",
- "rimraf": "^3.0.2",
- "semver": "^7.3.5",
- "tar": "^6.1.11"
- },
- "bin": {
- "node-pre-gyp": "bin/node-pre-gyp"
- }
- },
"node_modules/@mongodb-js/saslprep": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz",
@@ -4600,12 +4585,6 @@
"node": ">=10.0.0"
}
},
- "node_modules/abbrev": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
- "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
- "license": "ISC"
- },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -4618,11 +4597,21 @@
"node": ">=6.5"
}
},
+ "node_modules/adm-zip": {
+ "version": "0.5.18",
+ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz",
+ "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0"
+ }
+ },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
+ "optional": true,
"dependencies": {
"debug": "4"
},
@@ -4684,12 +4673,6 @@
"node": ">= 8"
}
},
- "node_modules/aproba": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
- "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
- "license": "ISC"
- },
"node_modules/archiver": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
@@ -4860,20 +4843,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
- "node_modules/are-we-there-yet": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
- "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
- "deprecated": "This package is no longer supported.",
- "license": "ISC",
- "dependencies": {
- "delegates": "^1.0.0",
- "readable-stream": "^3.6.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -5205,17 +5174,17 @@
}
},
"node_modules/bcrypt": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
- "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
+ "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
- "@mapbox/node-pre-gyp": "^1.0.11",
- "node-addon-api": "^5.0.0"
+ "node-addon-api": "^8.3.0",
+ "node-gyp-build": "^4.8.4"
},
"engines": {
- "node": ">= 10.0.0"
+ "node": ">= 18"
}
},
"node_modules/bignumber.js": {
@@ -5256,6 +5225,7 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -5578,15 +5548,6 @@
"fsevents": "~2.3.2"
}
},
- "node_modules/chownr": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
- "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
- "license": "ISC",
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
@@ -5704,15 +5665,6 @@
"node": ">=12.20"
}
},
- "node_modules/color-support": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
- "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
- "license": "ISC",
- "bin": {
- "color-support": "bin.js"
- }
- },
"node_modules/color/node_modules/color-convert": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
@@ -5829,6 +5781,7 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
@@ -5846,12 +5799,6 @@
"typedarray": "^0.0.6"
}
},
- "node_modules/console-control-strings": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
- "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
- "license": "ISC"
- },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -6101,12 +6048,6 @@
"node": ">=0.4.0"
}
},
- "node_modules/delegates": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
- "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
- "license": "MIT"
- },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -6901,40 +6842,11 @@
"url": "https://github.com/sponsors/rawify"
}
},
- "node_modules/fs-minipass": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
- "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
- "license": "ISC",
- "dependencies": {
- "minipass": "^3.0.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/fs-minipass/node_modules/minipass": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
- "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
- "license": "ISC",
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/fs-minipass/node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "license": "ISC"
- },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
@@ -6968,27 +6880,6 @@
"license": "MIT",
"optional": true
},
- "node_modules/gauge": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
- "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
- "deprecated": "This package is no longer supported.",
- "license": "ISC",
- "dependencies": {
- "aproba": "^1.0.3 || ^2.0.0",
- "color-support": "^1.1.2",
- "console-control-strings": "^1.0.0",
- "has-unicode": "^2.0.1",
- "object-assign": "^4.1.1",
- "signal-exit": "^3.0.0",
- "string-width": "^4.2.3",
- "strip-ansi": "^6.0.1",
- "wide-align": "^1.1.2"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/gaxios": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
@@ -7124,6 +7015,7 @@
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
@@ -7271,6 +7163,27 @@
"node": ">=14.0.0"
}
},
+ "node_modules/handlebars": {
+ "version": "4.7.9",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
+ "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.2",
+ "source-map": "^0.6.1",
+ "wordwrap": "^1.0.0"
+ },
+ "bin": {
+ "handlebars": "bin/handlebars"
+ },
+ "engines": {
+ "node": ">=0.4.7"
+ },
+ "optionalDependencies": {
+ "uglify-js": "^3.1.4"
+ }
+ },
"node_modules/hapi-auth-jwt2": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/hapi-auth-jwt2/-/hapi-auth-jwt2-10.7.0.tgz",
@@ -7322,12 +7235,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/has-unicode": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
- "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
- "license": "ISC"
- },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -7421,6 +7328,7 @@
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
+ "optional": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
@@ -7513,6 +7421,7 @@
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "dev": true,
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
@@ -8754,30 +8663,6 @@
"node": ">=12"
}
},
- "node_modules/make-dir": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
- "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
- "license": "MIT",
- "dependencies": {
- "semver": "^6.0.0"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/make-dir/node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- }
- },
"node_modules/makeerror": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -8914,6 +8799,7 @@
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -8922,6 +8808,15 @@
"node": "*"
}
},
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
@@ -8931,49 +8826,6 @@
"node": ">=8"
}
},
- "node_modules/minizlib": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
- "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
- "license": "MIT",
- "dependencies": {
- "minipass": "^3.0.0",
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/minizlib/node_modules/minipass": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
- "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
- "license": "ISC",
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/minizlib/node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "license": "ISC"
- },
- "node_modules/mkdirp": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
- "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
- "license": "MIT",
- "bin": {
- "mkdirp": "bin/cmd.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/mongodb": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz",
@@ -9117,6 +8969,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+ "license": "MIT"
+ },
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
@@ -9124,10 +8982,13 @@
"license": "MIT"
},
"node_modules/node-addon-api": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
- "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
- "license": "MIT"
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
+ "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
},
"node_modules/node-cron": {
"version": "3.0.3",
@@ -9227,6 +9088,17 @@
"node": ">= 6.13.0"
}
},
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
@@ -9356,21 +9228,6 @@
"node": ">=4"
}
},
- "node_modules/nopt": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
- "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
- "license": "ISC",
- "dependencies": {
- "abbrev": "1"
- },
- "bin": {
- "nopt": "bin/nopt.js"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -9393,19 +9250,6 @@
"node": ">=8"
}
},
- "node_modules/npmlog": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
- "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
- "deprecated": "This package is no longer supported.",
- "license": "ISC",
- "dependencies": {
- "are-we-there-yet": "^2.0.0",
- "console-control-strings": "^1.1.0",
- "gauge": "^3.0.0",
- "set-blocking": "^2.0.0"
- }
- },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -9418,15 +9262,6 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
- "node_modules/object-assign": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
@@ -9471,6 +9306,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "devOptional": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@@ -9672,6 +9508,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10312,22 +10149,6 @@
"node": ">=14"
}
},
- "node_modules/rimraf": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
- "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
- "deprecated": "Rimraf versions prior to v4 are no longer supported",
- "license": "ISC",
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -10556,6 +10377,7 @@
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true,
"license": "ISC"
},
"node_modules/simple-swizzle": {
@@ -10607,7 +10429,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -10916,24 +10737,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/tar": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
- "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
- "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
- "license": "ISC",
- "dependencies": {
- "chownr": "^2.0.0",
- "fs-minipass": "^2.0.0",
- "minipass": "^5.0.0",
- "minizlib": "^2.1.1",
- "mkdirp": "^1.0.3",
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/tar-stream": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
@@ -10946,12 +10749,6 @@
"streamx": "^2.15.0"
}
},
- "node_modules/tar/node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "license": "ISC"
- },
"node_modules/teeny-request": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
@@ -11148,6 +10945,19 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
+ "node_modules/uglify-js": {
+ "version": "3.19.3",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
+ "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "bin": {
+ "uglifyjs": "bin/uglifyjs"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -11342,15 +11152,6 @@
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
- "node_modules/wide-align": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
- "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
- "license": "ISC",
- "dependencies": {
- "string-width": "^1.0.2 || 2 || 3 || 4"
- }
- },
"node_modules/winston": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
@@ -11387,6 +11188,12 @@
"node": ">= 12.0.0"
}
},
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "license": "MIT"
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -11427,6 +11234,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "devOptional": true,
"license": "ISC"
},
"node_modules/write-file-atomic": {
diff --git a/package.json b/package.json
index 6dd4041..b41cb6a 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,9 @@
"version": "1.0.0",
"description": "EventBuzz Node.js backend with Hapi and MongoDB",
"main": "server.js",
+ "engines": {
+ "node": ">=20"
+ },
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
@@ -11,6 +14,8 @@
"backfill:session-join-codes": "node scripts/backfill-session-join-codes.js",
"backfill:join-codes": "node scripts/backfill-join-codes.js",
"backfill:tenant-organization": "node scripts/backfill-tenant-organization.js",
+ "backfill:form-submission-files": "node scripts/backfill-form-submission-files.js",
+ "seed:microsite-v2-template": "node scripts/seed-microsite-v2-template.js",
"email-worker": "node src/workers/emailQueueWorker.js",
"whatsapp-worker": "node src/workers/whatsappQueueWorker.js",
"concierge-ingest-worker": "node ../chatbot/workers/ingest.js",
@@ -23,13 +28,15 @@
"@aws-sdk/lib-storage": "^3.1012.0",
"@hapi/hapi": "^21.3.0",
"@napi-rs/canvas": "^0.1.56",
+ "adm-zip": "^0.5.18",
"archiver": "^7.0.1",
- "bcrypt": "^5.1.1",
+ "bcrypt": "^6.0.0",
"bullmq": "^5.34.5",
"cheerio": "^1.0.0",
"dotenv": "^16.6.1",
"firebase-admin": "^13.0.0",
"google-auth-library": "^9.14.0",
+ "handlebars": "^4.7.9",
"hapi-auth-jwt2": "^10.2.0",
"ioredis": "^5.4.2",
"joi": "^17.13.0",
diff --git a/scripts/backfill-form-submission-files.js b/scripts/backfill-form-submission-files.js
new file mode 100644
index 0000000..c4f4044
--- /dev/null
+++ b/scripts/backfill-form-submission-files.js
@@ -0,0 +1,125 @@
+/**
+ * Backfill: upload base64 data-URL file values in FormSubmission.data to S3
+ * and replace with CloudFront URLs.
+ *
+ * Run:
+ * cd backend && npm run backfill:form-submission-files
+ * cd backend && npm run backfill:form-submission-files -- --dry-run
+ * cd backend && npm run backfill:form-submission-files -- --form-id=
+ */
+const path = require('path');
+
+require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
+
+const mongoose = require('mongoose');
+const Form = require(path.join(__dirname, '..', 'src', 'models', 'Form'));
+const FormSubmission = require(path.join(__dirname, '..', 'src', 'models', 'FormSubmission'));
+const {
+ processAllSubmissionDataUrls,
+ hasDataUrlValue,
+ dataMapToObject,
+} = require(path.join(__dirname, '..', 'src', 'utils', 'formSubmissionFiles'));
+
+function parseArgs(argv) {
+ const dryRun = argv.includes('--dry-run');
+ const formIdArg = argv.find((a) => a.startsWith('--form-id='));
+ const formId = formIdArg ? formIdArg.split('=')[1]?.trim() : '';
+ const limitArg = argv.find((a) => a.startsWith('--limit='));
+ const limit = limitArg ? Math.max(1, parseInt(limitArg.split('=')[1], 10) || 0) : 0;
+ return { dryRun, formId, limit };
+}
+
+function dataChanged(before, after) {
+ return JSON.stringify(before) !== JSON.stringify(after);
+}
+
+async function run() {
+ const { dryRun, formId, limit } = parseArgs(process.argv.slice(2));
+ const uri = process.env.MONGODB_URI;
+ if (!uri) {
+ console.error('MONGODB_URI is not set');
+ process.exit(1);
+ }
+
+ if (!process.env.AWS_BUCKET || !process.env.AWS_URL) {
+ console.error('AWS_BUCKET and AWS_URL must be set for S3 upload');
+ process.exit(1);
+ }
+
+ await mongoose.connect(uri);
+ console.log(`Connected. dryRun=${dryRun}${formId ? ` formId=${formId}` : ''}${limit ? ` limit=${limit}` : ''}`);
+
+ const query = {};
+ if (formId) {
+ if (!mongoose.Types.ObjectId.isValid(formId)) {
+ console.error('Invalid --form-id');
+ process.exit(1);
+ }
+ query.form = formId;
+ }
+
+ let cursor = FormSubmission.find(query).sort({ submittedAt: 1 }).cursor();
+ const formCache = new Map();
+
+ let scanned = 0;
+ let candidates = 0;
+ let updated = 0;
+ let skipped = 0;
+ let failed = 0;
+
+ for await (const submission of cursor) {
+ if (limit && scanned >= limit) break;
+ scanned += 1;
+
+ if (!hasDataUrlValue(submission.data)) {
+ skipped += 1;
+ continue;
+ }
+
+ candidates += 1;
+ const formKey = String(submission.form);
+ let form = formCache.get(formKey);
+ if (form === undefined) {
+ form = submission.form ? await Form.findById(submission.form).lean() : null;
+ formCache.set(formKey, form || null);
+ }
+
+ const before = dataMapToObject(submission.data);
+ const orgId = String(submission.tenantOrganization || form?.tenantOrganization || 'org');
+ const eventId = submission.event ? String(submission.event) : '';
+
+ try {
+ const after = await processAllSubmissionDataUrls(before, { orgId, eventId, form });
+ if (!dataChanged(before, after)) {
+ skipped += 1;
+ continue;
+ }
+
+ if (dryRun) {
+ const keys = Object.keys(before).filter((k) => before[k] !== after[k]);
+ console.log(`[dry-run] submission ${submission._id} would update keys: ${keys.join(', ')}`);
+ updated += 1;
+ continue;
+ }
+
+ submission.data = after;
+ await submission.save();
+ updated += 1;
+ console.log(`Updated submission ${submission._id}`);
+ } catch (err) {
+ failed += 1;
+ console.error(`Failed submission ${submission._id}:`, err.message || err);
+ }
+ }
+
+ console.log(
+ `Done. scanned=${scanned} candidates=${candidates} updated=${updated} skipped=${skipped} failed=${failed}`,
+ );
+
+ await mongoose.disconnect();
+}
+
+run().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/seed-microsite-v2-template.js b/scripts/seed-microsite-v2-template.js
new file mode 100644
index 0000000..23b165f
--- /dev/null
+++ b/scripts/seed-microsite-v2-template.js
@@ -0,0 +1,175 @@
+#!/usr/bin/env node
+/**
+ * Seed the EventUp microsite-v2 template: upload files to S3 and create MicrositeV2Template row.
+ *
+ * Usage: node scripts/seed-microsite-v2-template.js [--user-id=]
+ *
+ * Requires: MONGODB_URI, AWS_* env vars (or will create DB row only with --db-only)
+ */
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const mongoose = require('mongoose');
+const AdmZip = require('adm-zip');
+const { connectDB } = require('../src/config/database');
+const MicrositeV2Template = require('../src/models/MicrositeV2Template');
+const User = require('../src/models/User');
+const {
+ uploadTemplateFile,
+ uploadTemplateAsset,
+ templateAssetsBaseUrl,
+} = require('../src/utils/s3MicrositeV2');
+const { parseTemplateSchema } = require('../src/utils/templateSchema');
+
+const SLUG = 'event-up';
+const TEMPLATE_DIR = path.join(__dirname, '..', 'microsite-v2-templates', SLUG);
+const TEMPLATE_ASSETS = path.join(TEMPLATE_DIR, 'assets');
+const EVENT_UP_ASSETS = path.join(__dirname, '..', '..', 'microsites', 'event-up', 'assets');
+
+async function findSeedUser(userIdArg) {
+ if (userIdArg) {
+ const u = await User.findById(userIdArg);
+ if (u) return u;
+ }
+ const superAdmin = await User.findOne({ role: 'super_admin' }).sort({ createdAt: 1 });
+ if (superAdmin) return superAdmin;
+ throw new Error('No super_admin user found. Pass --user-id=');
+}
+
+function walkDir(dir, base = dir) {
+ const files = [];
+ if (!fs.existsSync(dir)) return files;
+ for (const name of fs.readdirSync(dir)) {
+ const full = path.join(dir, name);
+ const stat = fs.statSync(full);
+ if (stat.isDirectory()) {
+ files.push(...walkDir(full, base));
+ } else {
+ files.push({ full, rel: path.relative(base, full).replace(/\\/g, '/') });
+ }
+ }
+ return files;
+}
+
+function mimeFor(filePath) {
+ if (filePath.endsWith('.css')) return 'text/css';
+ if (filePath.endsWith('.js')) return 'application/javascript';
+ if (filePath.endsWith('.woff') || filePath.endsWith('.woff2')) return 'font/woff2';
+ if (filePath.endsWith('.png')) return 'image/png';
+ if (filePath.endsWith('.jpg') || filePath.endsWith('.jpeg')) return 'image/jpeg';
+ if (filePath.endsWith('.svg')) return 'image/svg+xml';
+ return 'application/octet-stream';
+}
+
+async function main() {
+ const dbOnly = process.argv.includes('--db-only');
+ const userIdArg = (process.argv.find((a) => a.startsWith('--user-id=')) || '').split('=')[1];
+
+ await connectDB();
+
+ const user = await findSeedUser(userIdArg);
+ console.log(`Using user: ${user.email || user._id}`);
+
+ const htmlPath = path.join(TEMPLATE_DIR, 'template.html');
+ const jsonPath = path.join(TEMPLATE_DIR, 'template.json');
+ if (!fs.existsSync(htmlPath) || !fs.existsSync(jsonPath)) {
+ throw new Error(`Missing template files in ${TEMPLATE_DIR}`);
+ }
+
+ const htmlBuf = fs.readFileSync(htmlPath);
+ const jsonBuf = fs.readFileSync(jsonPath);
+ const schema = parseTemplateSchema(jsonBuf);
+
+ let htmlS3Key;
+ let schemaS3Key;
+ let thumbnailUrl;
+ let assetsBaseUrl = templateAssetsBaseUrl(SLUG);
+
+ if (!dbOnly && process.env.AWS_BUCKET) {
+ console.log('Uploading template.html...');
+ const htmlUp = await uploadTemplateFile(SLUG, 'template.html', htmlBuf, 'text/html; charset=utf-8');
+ htmlS3Key = htmlUp.s3Key;
+
+ console.log('Uploading template.json...');
+ const jsonUp = await uploadTemplateFile(SLUG, 'template.json', jsonBuf, 'application/json');
+ schemaS3Key = jsonUp.s3Key;
+
+ const thumbCandidates = [
+ path.join(TEMPLATE_DIR, 'thumbnail.jpg'),
+ path.join(TEMPLATE_DIR, 'thumbnail.png'),
+ path.join(EVENT_UP_ASSETS, 'img', 'hero.jpg'),
+ ];
+ for (const tp of thumbCandidates) {
+ if (fs.existsSync(tp)) {
+ const ext = path.extname(tp).slice(1) || 'jpg';
+ const thumbUp = await uploadTemplateFile(
+ SLUG,
+ `thumbnail.${ext}`,
+ fs.readFileSync(tp),
+ `image/${ext === 'jpg' ? 'jpeg' : ext}`,
+ );
+ thumbnailUrl = thumbUp.url;
+ break;
+ }
+ }
+ if (!thumbnailUrl) {
+ const placeholder = Buffer.from(
+ '',
+ );
+ const thumbUp = await uploadTemplateFile(SLUG, 'thumbnail.svg', placeholder, 'image/svg+xml');
+ thumbnailUrl = thumbUp.url;
+ }
+
+ if (fs.existsSync(EVENT_UP_ASSETS)) {
+ console.log('Uploading assets from event-up...');
+ const assetFiles = walkDir(EVENT_UP_ASSETS);
+ for (const { full, rel } of assetFiles) {
+ await uploadTemplateAsset(SLUG, rel, fs.readFileSync(full), mimeFor(rel));
+ }
+ }
+ if (fs.existsSync(TEMPLATE_ASSETS)) {
+ console.log('Uploading template-specific assets (SSR bridge)...');
+ const overlayFiles = walkDir(TEMPLATE_ASSETS);
+ for (const { full, rel } of overlayFiles) {
+ await uploadTemplateAsset(SLUG, rel, fs.readFileSync(full), mimeFor(rel));
+ }
+ }
+ assetsBaseUrl = templateAssetsBaseUrl(SLUG);
+ console.log('S3 upload complete.');
+ } else {
+ console.log('Skipping S3 upload (--db-only or AWS_BUCKET not set).');
+ }
+
+ const existing = await MicrositeV2Template.findOne({ slug: SLUG });
+ const doc = {
+ slug: SLUG,
+ name: 'EventUp Conference',
+ description: 'Full EventUp conference microsite ported from microsites/event-up — hero, schedule, speakers, sponsors, contact, and more.',
+ thumbnailUrl: thumbnailUrl || '',
+ htmlS3Key: htmlS3Key || '',
+ schemaS3Key: schemaS3Key || '',
+ schema,
+ assetsBaseUrl,
+ assetsS3Prefix: `templates/${SLUG}/assets`,
+ isActive: Boolean(htmlS3Key && schemaS3Key),
+ version: 1,
+ createdBy: user._id,
+ };
+
+ if (existing) {
+ Object.assign(existing, doc);
+ await existing.save();
+ console.log(`Updated MicrositeV2Template: ${existing._id}`);
+ } else {
+ const created = await MicrositeV2Template.create(doc);
+ console.log(`Created MicrositeV2Template: ${created._id}`);
+ }
+
+ await mongoose.disconnect();
+ console.log('Done.');
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/src/app.js b/src/app.js
index b352fe5..51cd8d7 100644
--- a/src/app.js
+++ b/src/app.js
@@ -42,6 +42,7 @@ const eventTicketingManualRoutes = require('./routes/eventTicketingManual.routes
const conciergeBotRoutes = require('./routes/conciergeBot.routes');
const publicConciergeRoutes = require('./routes/publicConcierge.routes');
const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes');
+const staticMicrositeRoutes = require('./routes/staticMicrosite.routes');
const createServer = async () => {
/** Production: set CORS_ORIGIN (or FRONTEND_URL) to every origin that loads the SPA, e.g. https://console.example.com (no trailing slash). */
@@ -160,6 +161,7 @@ const createServer = async () => {
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
+ res.header('Access-Control-Expose-Headers', 'Content-Disposition');
res.header('Access-Control-Max-Age', '86400');
}
return res;
@@ -275,6 +277,7 @@ const createServer = async () => {
server.route(eventStaffRoutes);
server.route(websiteSettingsRoutes);
server.route(eventMicrositeRoutes);
+ server.route(staticMicrositeRoutes);
server.route(whatsappTemplateRoutes);
server.route(badgeBatchRoutes);
server.route(eventTicketRoutes);
@@ -309,6 +312,7 @@ const createServer = async () => {
if (allowOrigin && typeof response.header === 'function') {
response.header('Access-Control-Allow-Origin', allowOrigin);
response.header('Access-Control-Allow-Credentials', 'true');
+ response.header('Access-Control-Expose-Headers', 'Content-Disposition');
}
return h.continue;
});
diff --git a/src/config/constants.js b/src/config/constants.js
index 3e2c2b8..ff72ffb 100644
--- a/src/config/constants.js
+++ b/src/config/constants.js
@@ -5,7 +5,7 @@ const PARTICIPANT_STATUS = ['pending', 'confirmed', 'checked_in', 'cancelled'];
const SESSION_TYPES = ['keynote', 'workshop', 'panel', 'breakout', 'networking'];
const SESSION_STATUS = ['scheduled', 'ongoing', 'completed', 'cancelled'];
const FORM_TYPES = ['registration', 'feedback', 'survey', 'custom', 'other', 'web-check-in'];
-const FIELD_TYPES = ['text', 'textarea', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file'];
+const FIELD_TYPES = ['text', 'textarea', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file', 'date', 'time', 'section', 'country_state'];
/** Registration field → Participant property mapping (or `custom` + customMapsKey). */
const FORM_FIELD_MAPS_TO = [
'firstName',
diff --git a/src/controllers/form.controller.js b/src/controllers/form.controller.js
index d5f62aa..b40fa4e 100644
--- a/src/controllers/form.controller.js
+++ b/src/controllers/form.controller.js
@@ -14,6 +14,8 @@ const {
} = require('../utils/formPublicHelpers');
const { verifyCaptchaChallenge } = require('../utils/publicCaptcha');
const { getMembership } = require('../utils/participantMembership');
+const { publicRegistrationFields } = require('../utils/registrationFormFields');
+const { processFormSubmissionFileUploads } = require('../utils/formSubmissionFiles');
function throwRegError(message, code) {
const err = new Error(message);
@@ -82,6 +84,26 @@ function normalizeSubmitData(raw) {
return { ...raw };
}
+function fieldDataKey(f) {
+ return String(f?.name || f?.label || '').trim();
+}
+
+function csvColumnsFromFormFields(fields) {
+ const cols = [];
+ for (const f of fields || []) {
+ if (f.type === 'section' || !(f.label || f.name)) continue;
+ const base = fieldDataKey(f);
+ if (f.type === 'country_state') {
+ const label = f.label || f.name;
+ cols.push({ header: `${label} - Country`, key: `${base} - Country` });
+ cols.push({ header: `${label} - State`, key: `${base} - State` });
+ } else {
+ cols.push({ header: f.label || f.name, key: base });
+ }
+ }
+ return cols;
+}
+
async function upsertParticipantFromRegistrationForm(form, tenantOrganization, dataObj) {
const fields = form.fields || [];
const orgOid =
@@ -98,7 +120,7 @@ async function upsertParticipantFromRegistrationForm(form, tenantOrganization, d
const requireMappedValue = (mapsTo, emptyMessage, missingConfigMessage) => {
const fld = fields.find((f) => f.mapsTo === mapsTo);
if (!fld?.label) throwRegValidation(missingConfigMessage, 'FORM_FIELD_CONFIG');
- const raw = dataObj[fld.label.trim()];
+ const raw = dataObj[fieldDataKey(fld)];
const val = raw != null ? String(raw).trim() : '';
if (!val) throwRegValidation(emptyMessage, 'REGISTRATION_REQUIRED');
};
@@ -107,7 +129,7 @@ async function upsertParticipantFromRegistrationForm(form, tenantOrganization, d
if (!emailField?.label) {
throwRegValidation('Form missing email field mapping', 'FORM_FIELD_CONFIG');
}
- const emailRaw = dataObj[emailField.label.trim()];
+ const emailRaw = dataObj[fieldDataKey(emailField)];
const email = emailRaw != null ? String(emailRaw).trim().toLowerCase() : '';
if (!email) {
throwRegValidation('Email is required', 'EMAIL_REQUIRED');
@@ -121,8 +143,8 @@ async function upsertParticipantFromRegistrationForm(form, tenantOrganization, d
const allowed = ['firstName', 'lastName', 'email', 'phone', 'organization', 'designation', 'notes', 'displayName'];
for (const f of fields) {
- if (!f.label) continue;
- const key = f.label.trim();
+ if (f.type === 'section' || !f.label) continue;
+ const key = fieldDataKey(f);
const val = dataObj[key];
if (val === undefined || val === null || String(val).trim() === '') continue;
const map = f.mapsTo;
@@ -372,11 +394,25 @@ const submit = async (request, h) => {
}
const dataObj = normalizeSubmitData(request.payload.data);
+ let processedData = dataObj;
+
+ try {
+ processedData = await processFormSubmissionFileUploads(form, dataObj, {
+ orgId: String(tenantOrganization),
+ eventId: submissionEventId,
+ });
+ } catch (err) {
+ if (err.code === 'FILE_TOO_LARGE') {
+ return h.response(error('VALIDATION_ERROR', err.message || 'File too large')).code(400);
+ }
+ throw err;
+ }
+
let participantId = request.payload.participantId;
if (form.type === 'registration') {
try {
- participantId = await upsertParticipantFromRegistrationForm(form, tenantOrganization, dataObj);
+ participantId = await upsertParticipantFromRegistrationForm(form, tenantOrganization, processedData);
} catch (err) {
if (
err.code === 'EMAIL_REQUIRED' ||
@@ -399,7 +435,7 @@ const submit = async (request, h) => {
event: submissionEventId || undefined,
user: user?._id,
participant: participantId,
- data: dataObj,
+ data: processedData,
});
if (submissionEventId) {
@@ -450,14 +486,48 @@ function escapeCsvCell(val) {
return s;
}
+function sanitizeExportFilenamePart(value, fallback) {
+ const trimmed = String(value || '').trim();
+ if (!trimmed) return fallback;
+ return (
+ trimmed
+ .replace(/[\\/:*?"<>|]+/g, '-')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '')
+ .slice(0, 80) || fallback
+ );
+}
+
+function formatExportDateTime(date = new Date()) {
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
+}
+
+function buildFormSubmissionsExportFilename(formName, eventName) {
+ const eventPart = sanitizeExportFilenamePart(eventName, 'Event');
+ const formPart = sanitizeExportFilenamePart(formName, 'Form');
+ return `${eventPart}-${formPart}-${formatExportDateTime()}.csv`;
+}
+
+async function resolveFormEventName(form) {
+ if (form.event && typeof form.event === 'object' && form.event.name) {
+ return String(form.event.name).trim() || 'Event';
+ }
+ const eventId = extractObjectIdString(form.event);
+ if (!eventId) return 'Event';
+ const ev = await Event.findById(eventId).select('name').lean();
+ return ev?.name?.trim() || 'Event';
+}
+
function submissionsToCsv(form, rows) {
- const labels = (form.fields || []).map((f) => f.label).filter(Boolean);
- const header = ['Submitted At', 'Participant Email', ...labels];
+ const cols = csvColumnsFromFormFields(form.fields);
+ const header = ['Submitted At', 'Participant Email', ...cols.map((c) => c.header)];
const lines = [header.map(escapeCsvCell).join(',')];
for (const s of rows) {
const o = dataMapToObject(s.data);
const email = s.participant && typeof s.participant === 'object' ? s.participant.email || '' : '';
- const vals = labels.map((lab) => o[lab] ?? '');
+ const vals = cols.map((c) => o[c.key] ?? '');
const row = [new Date(s.submittedAt).toISOString(), email, ...vals];
lines.push(row.map(escapeCsvCell).join(','));
}
@@ -492,11 +562,13 @@ const listSubmissions = async (request, h) => {
}
if (exportCsv) {
+ const eventName = await resolveFormEventName(form);
+ const filename = buildFormSubmissionsExportFilename(form.name, eventName);
const csv = submissionsToCsv(form, all);
return h
.response(csv)
.type('text/csv; charset=utf-8')
- .header('Content-Disposition', `attachment; filename="form-${form._id}-submissions.csv"`)
+ .header('Content-Disposition', `attachment; filename="${filename}"`)
.code(200);
}
@@ -525,15 +597,7 @@ const getPublicForm = async (request, h) => {
description: form.description || '',
type: form.type,
event: eventPublic,
- fields: (form.fields || []).map((f) => ({
- label: f.label,
- type: f.type,
- required: f.required,
- options: f.options,
- placeholder: f.placeholder,
- mapsTo: f.mapsTo,
- isFixed: f.isFixed,
- })),
+ fields: publicRegistrationFields(form.fields, form.type),
settings: {
thankYouMessage: form.settings?.thankYouMessage,
showThankYou: form.settings?.showThankYou !== false,
diff --git a/src/controllers/publicEventTicket.controller.js b/src/controllers/publicEventTicket.controller.js
index 79f332c..6e711f7 100644
--- a/src/controllers/publicEventTicket.controller.js
+++ b/src/controllers/publicEventTicket.controller.js
@@ -31,14 +31,19 @@ async function loadFormFieldsForPublic(formId) {
if (!formId || !mongoose.Types.ObjectId.isValid(String(formId))) return null;
const form = await Form.findById(formId).lean();
if (!form || !form.settings?.isActive) return null;
- const fields = (form.fields || []).map((f) => ({
- label: f.label,
- type: f.type,
- required: Boolean(f.required),
- options: f.options,
- placeholder: f.placeholder,
- mapsTo: f.mapsTo,
- }));
+ const fields = (form.fields || []).map((f) => {
+ const remarks = f.remarks != null ? String(f.remarks).trim() : '';
+ return {
+ label: f.label,
+ type: f.type,
+ required: Boolean(f.required),
+ options: f.options,
+ ...(f.allowOther ? { allowOther: true, otherLabel: f.otherLabel || 'Other' } : {}),
+ placeholder: f.placeholder,
+ mapsTo: f.mapsTo,
+ ...(remarks ? { remarks } : {}),
+ };
+ });
return fields;
}
diff --git a/src/controllers/publicMicrosite.controller.js b/src/controllers/publicMicrosite.controller.js
index 38c12dd..24a3be4 100644
--- a/src/controllers/publicMicrosite.controller.js
+++ b/src/controllers/publicMicrosite.controller.js
@@ -1,4 +1,5 @@
const mongoose = require('mongoose');
+const { publicRegistrationFields } = require('../utils/registrationFormFields');
const Event = require('../models/Event');
const Form = require('../models/Form');
const { success, error } = require('../utils/response');
@@ -172,15 +173,7 @@ async function getPublicEventForm(request, h) {
description: form.description || '',
type: form.type,
event: eventPublic,
- fields: (form.fields || []).map((f) => ({
- label: f.label,
- type: f.type,
- required: f.required,
- options: f.options,
- placeholder: f.placeholder,
- mapsTo: f.mapsTo,
- isFixed: f.isFixed,
- })),
+ fields: publicRegistrationFields(form.fields, form.type),
settings: {
thankYouMessage: form.settings?.thankYouMessage,
showThankYou: form.settings?.showThankYou !== false,
diff --git a/src/controllers/staticMicrosite.controller.js b/src/controllers/staticMicrosite.controller.js
new file mode 100644
index 0000000..e89bab8
--- /dev/null
+++ b/src/controllers/staticMicrosite.controller.js
@@ -0,0 +1,410 @@
+const mongoose = require('mongoose');
+const Event = require('../models/Event');
+const EventStaticMicrosite = require('../models/EventStaticMicrosite');
+const MicrositeV2Template = require('../models/MicrositeV2Template');
+const { success, error } = require('../utils/response');
+const { normalizeSubdomain, subdomainAvailability } = require('../utils/subdomainAvailability');
+const {
+ uploadTemplateFile,
+ uploadTemplateAsset,
+ templateAssetsBaseUrl,
+ getS3ObjectBuffer,
+} = require('../utils/s3MicrositeV2');
+const { parseTemplateSchema, buildDefaultConfig } = require('../utils/templateSchema');
+const {
+ renderStaticMicrositeHtml,
+ publishStaticMicrosite,
+ loadTemplateSchema,
+} = require('../utils/publishStaticMicrosite');
+const { renderHandlebarsTemplate } = require('../utils/handlebarsRender');
+
+function requireSuperAdmin(user) {
+ return user.role === 'super_admin';
+}
+
+function orgMismatch(event, user) {
+ if (user.role === 'super_admin') return false;
+ return event.organization?.toString() !== (user.organization?._id || user.organization)?.toString();
+}
+
+async function loadEventForUser(eventId, user) {
+ const event = await Event.findById(eventId);
+ if (!event) return { event: null, forbidden: false };
+ if (orgMismatch(event, user)) return { event: null, forbidden: true };
+ return { event, forbidden: false };
+}
+
+function extractBufferPart(part, fallbackName) {
+ if (!part) return null;
+ if (Buffer.isBuffer(part)) {
+ return { buffer: part, filename: fallbackName, mimeType: 'application/octet-stream' };
+ }
+ if (typeof part === 'object' && part.payload && Buffer.isBuffer(part.payload)) {
+ return {
+ buffer: part.payload,
+ filename: part.filename || fallbackName,
+ mimeType: part.headers?.['content-type'] || 'application/octet-stream',
+ };
+ }
+ if (typeof part === 'object' && part._data && Buffer.isBuffer(part._data)) {
+ return {
+ buffer: part._data,
+ filename: part.filename || fallbackName,
+ mimeType: part.headers?.['content-type'] || 'application/octet-stream',
+ };
+ }
+ return null;
+}
+
+// --- Template catalog (read) ---
+
+const listTemplates = async (request, h) => {
+ const rows = await MicrositeV2Template.find({ isActive: true })
+ .sort({ createdAt: -1 })
+ .select('slug name description thumbnailUrl version isActive createdAt')
+ .lean();
+ return h.response(success(rows));
+};
+
+const listAdminTemplates = async (request, h) => {
+ const user = request.auth.credentials.user;
+ if (!requireSuperAdmin(user)) {
+ return h.response(error('FORBIDDEN', 'Only super_admin can list all templates')).code(403);
+ }
+ const rows = await MicrositeV2Template.find({})
+ .sort({ createdAt: -1 })
+ .populate('createdBy', 'name email')
+ .lean();
+ return h.response(success(rows));
+};
+
+const getTemplate = async (request, h) => {
+ const template = await MicrositeV2Template.findById(request.params.id).lean();
+ if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
+
+ let schema = template.schema;
+ if (!schema && template.schemaS3Key) {
+ try {
+ const buf = await getS3ObjectBuffer(template.schemaS3Key);
+ schema = JSON.parse(buf.toString('utf8'));
+ } catch {
+ schema = null;
+ }
+ }
+
+ return h.response(success({ ...template, schema }));
+};
+
+// --- Template admin ---
+
+const createTemplate = async (request, h) => {
+ const user = request.auth.credentials.user;
+ if (!requireSuperAdmin(user)) {
+ return h.response(error('FORBIDDEN', 'Only super_admin can create templates')).code(403);
+ }
+
+ const { slug, name, description, version } = request.payload;
+ const normalizedSlug = normalizeSubdomain(slug);
+ if (!normalizedSlug) {
+ return h.response(error('VALIDATION_ERROR', 'Valid slug is required')).code(400);
+ }
+
+ const existing = await MicrositeV2Template.findOne({ slug: normalizedSlug }).lean();
+ if (existing) {
+ return h.response(error('CONFLICT', 'Template slug already exists')).code(409);
+ }
+
+ const template = await MicrositeV2Template.create({
+ slug: normalizedSlug,
+ name: name || normalizedSlug,
+ description: description || '',
+ version: version || 1,
+ isActive: false,
+ createdBy: user._id,
+ });
+
+ return h.response(success(template, 'Template created', 201)).code(201);
+};
+
+const updateTemplate = async (request, h) => {
+ const user = request.auth.credentials.user;
+ if (!requireSuperAdmin(user)) {
+ return h.response(error('FORBIDDEN', 'Only super_admin can update templates')).code(403);
+ }
+
+ const template = await MicrositeV2Template.findById(request.params.id);
+ if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
+
+ const { name, description, isActive, version } = request.payload;
+ if (name !== undefined) template.name = name;
+ if (description !== undefined) template.description = description;
+ if (isActive !== undefined) template.isActive = isActive;
+ if (version !== undefined) template.version = version;
+
+ await template.save();
+ return h.response(success(template));
+};
+
+const removeTemplate = async (request, h) => {
+ const user = request.auth.credentials.user;
+ if (!requireSuperAdmin(user)) {
+ return h.response(error('FORBIDDEN', 'Only super_admin can delete templates')).code(403);
+ }
+
+ const template = await MicrositeV2Template.findById(request.params.id);
+ if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
+
+ const inUse = await EventStaticMicrosite.countDocuments({ template: template._id });
+ if (inUse > 0) {
+ return h.response(error('CONFLICT', 'Template is in use by event microsites')).code(409);
+ }
+
+ await template.deleteOne();
+ return h.response(success(null, 'Template deleted'));
+};
+
+const uploadTemplateFiles = async (request, h) => {
+ const user = request.auth.credentials.user;
+ if (!requireSuperAdmin(user)) {
+ return h.response(error('FORBIDDEN', 'Only super_admin can upload templates')).code(403);
+ }
+
+ const template = await MicrositeV2Template.findById(request.params.id);
+ if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
+
+ const payload = request.payload || {};
+ const slug = template.slug;
+
+ const htmlPart = extractBufferPart(payload.templateHtml, 'template.html');
+ const jsonPart = extractBufferPart(payload.templateJson, 'template.json');
+ const thumbPart = extractBufferPart(payload.thumbnail, 'thumbnail.jpg');
+ const zipPart = extractBufferPart(payload.assetsZip, 'assets.zip');
+
+ if (htmlPart) {
+ const up = await uploadTemplateFile(slug, 'template.html', htmlPart.buffer, 'text/html; charset=utf-8');
+ template.htmlS3Key = up.s3Key;
+ }
+ if (jsonPart) {
+ const schema = parseTemplateSchema(jsonPart.buffer);
+ const up = await uploadTemplateFile(slug, 'template.json', jsonPart.buffer, 'application/json');
+ template.schemaS3Key = up.s3Key;
+ template.schema = schema;
+
+ if (htmlPart) {
+ try {
+ const htmlStr = htmlPart.buffer.toString('utf8');
+ const defaultConfig = buildDefaultConfig(schema);
+ renderHandlebarsTemplate(htmlStr, {
+ meta: defaultConfig.meta,
+ theme: defaultConfig.theme,
+ sections: defaultConfig.sections,
+ assetsBaseUrl: template.assetsBaseUrl || templateAssetsBaseUrl(slug),
+ eventId: '',
+ subdomain: '',
+ templateSlug: slug,
+ });
+ } catch (e) {
+ return h.response(error('VALIDATION_ERROR', `Handlebars compile failed: ${e.message}`)).code(400);
+ }
+ }
+ }
+ if (thumbPart) {
+ const ext = (thumbPart.filename || '').split('.').pop() || 'jpg';
+ const mime = thumbPart.mimeType.startsWith('image/') ? thumbPart.mimeType : `image/${ext}`;
+ const up = await uploadTemplateFile(slug, `thumbnail.${ext}`, thumbPart.buffer, mime);
+ template.thumbnailUrl = up.url;
+ }
+ if (zipPart) {
+ // Lazy require — only needed for super-admin template zip upload.
+ // eslint-disable-next-line global-require
+ const AdmZip = require('adm-zip');
+ const zip = new AdmZip(zipPart.buffer);
+ const entries = zip.getEntries();
+ for (const entry of entries) {
+ if (entry.isDirectory) continue;
+ const rel = entry.entryName.replace(/^\/+/, '');
+ const data = entry.getData();
+ const mime = rel.endsWith('.css')
+ ? 'text/css'
+ : rel.endsWith('.js')
+ ? 'application/javascript'
+ : 'application/octet-stream';
+ await uploadTemplateAsset(slug, rel, data, mime);
+ }
+ template.assetsS3Prefix = `templates/${slug}/assets`;
+ }
+
+ template.assetsBaseUrl = templateAssetsBaseUrl(slug);
+ if (template.htmlS3Key && template.schemaS3Key) {
+ template.isActive = true;
+ }
+
+ await template.save();
+ return h.response(success(template, 'Template files uploaded'));
+};
+
+const previewTemplateHtml = async (request, h) => {
+ const user = request.auth.credentials.user;
+ if (!requireSuperAdmin(user)) {
+ return h.response(error('FORBIDDEN', 'Only super_admin can preview templates')).code(403);
+ }
+
+ const template = await MicrositeV2Template.findById(request.params.id).lean();
+ if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
+
+ try {
+ const schema = await loadTemplateSchema(template);
+ const defaultConfig = buildDefaultConfig(schema);
+ const html = await renderStaticMicrositeHtml({
+ template,
+ config: defaultConfig,
+ });
+ return h.response(html).type('text/html');
+ } catch (e) {
+ return h.response(error('RENDER_ERROR', e.message)).code(400);
+ }
+};
+
+// --- Event static microsite ---
+
+const getForEvent = async (request, h) => {
+ const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user);
+ if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
+ if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
+
+ const site = await EventStaticMicrosite.findOne({ event: event._id })
+ .populate('template', 'slug name description thumbnailUrl version assetsBaseUrl')
+ .lean();
+
+ if (!site) return h.response(error('NOT_FOUND', 'Static microsite not configured')).code(404);
+ return h.response(success(site));
+};
+
+const upsertForEvent = async (request, h) => {
+ const user = request.auth.credentials.user;
+ const { event, forbidden } = await loadEventForUser(request.params.eventId, user);
+ if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
+ if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
+
+ const { subdomain, templateId, config } = request.payload;
+ const normalized = normalizeSubdomain(subdomain);
+ if (!normalized) {
+ return h.response(error('VALIDATION_ERROR', 'Valid subdomain is required')).code(400);
+ }
+ if (!templateId || !mongoose.Types.ObjectId.isValid(templateId)) {
+ return h.response(error('VALIDATION_ERROR', 'Valid template id is required')).code(400);
+ }
+
+ const template = await MicrositeV2Template.findOne({ _id: templateId, isActive: true });
+ if (!template) return h.response(error('NOT_FOUND', 'Template not found or inactive')).code(404);
+
+ let site = await EventStaticMicrosite.findOne({ event: event._id });
+ const avail = await subdomainAvailability(normalized, event._id.toString(), site?._id?.toString());
+ if (!avail.available) {
+ return h.response(error('VALIDATION_ERROR', avail.message)).code(400);
+ }
+
+ if (!site) {
+ site = new EventStaticMicrosite({
+ event: event._id,
+ organization: event.organization,
+ subdomain: normalized,
+ template: template._id,
+ config: config || {},
+ updatedBy: user._id,
+ });
+ } else {
+ site.subdomain = normalized;
+ site.template = template._id;
+ if (config !== undefined) site.config = config;
+ site.updatedBy = user._id;
+ }
+
+ const wasNew = site.isNew;
+ await site.save();
+ const populated = await EventStaticMicrosite.findById(site._id)
+ .populate('template', 'slug name description thumbnailUrl version assetsBaseUrl')
+ .lean();
+
+ return h.response(success(populated, wasNew ? 'Created' : 'Updated'));
+};
+
+const checkSubdomain = async (request, h) => {
+ const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user);
+ if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
+ if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
+
+ const normalized = normalizeSubdomain(request.query.subdomain);
+ const site = await EventStaticMicrosite.findOne({ event: event._id }).select('_id').lean();
+ const avail = await subdomainAvailability(
+ normalized,
+ event._id.toString(),
+ site?._id?.toString(),
+ );
+ return h.response(success(avail));
+};
+
+const publishForEvent = async (request, h) => {
+ const user = request.auth.credentials.user;
+ const { event, forbidden } = await loadEventForUser(request.params.eventId, user);
+ if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
+ if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
+
+ const site = await EventStaticMicrosite.findOne({ event: event._id }).populate('template');
+ if (!site) return h.response(error('NOT_FOUND', 'Static microsite not configured')).code(404);
+
+ try {
+ const result = await publishStaticMicrosite({
+ staticMicrosite: site,
+ template: site.template,
+ userId: user._id,
+ });
+ Object.assign(site, result);
+ await site.save();
+
+ const populated = await EventStaticMicrosite.findById(site._id)
+ .populate('template', 'slug name description thumbnailUrl version assetsBaseUrl')
+ .lean();
+
+ return h.response(success({ ...populated, publicUrl: result.publicUrl }, 'Published'));
+ } catch (e) {
+ return h.response(error('PUBLISH_ERROR', e.message)).code(400);
+ }
+};
+
+const previewForEvent = async (request, h) => {
+ const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user);
+ if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
+ if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
+
+ const site = await EventStaticMicrosite.findOne({ event: event._id }).populate('template');
+ if (!site) return h.response(error('NOT_FOUND', 'Static microsite not configured')).code(404);
+
+ try {
+ const html = await renderStaticMicrositeHtml({
+ template: site.template,
+ config: site.config,
+ eventId: event._id,
+ subdomain: site.subdomain,
+ });
+ return h.response(html).type('text/html');
+ } catch (e) {
+ return h.response(error('RENDER_ERROR', e.message)).code(400);
+ }
+};
+
+module.exports = {
+ listTemplates,
+ listAdminTemplates,
+ getTemplate,
+ createTemplate,
+ updateTemplate,
+ removeTemplate,
+ uploadTemplateFiles,
+ previewTemplateHtml,
+ getForEvent,
+ upsertForEvent,
+ checkSubdomain,
+ publishForEvent,
+ previewForEvent,
+};
diff --git a/src/controllers/visitor.controller.js b/src/controllers/visitor.controller.js
index d4af6f5..b1bbb26 100644
--- a/src/controllers/visitor.controller.js
+++ b/src/controllers/visitor.controller.js
@@ -1075,13 +1075,17 @@ async function visitorEventRewardHistory(request, h) {
/** GET …/visitor/events/{eventId}/feedback-forms/{formId} — public schema for rendering (org must match event). */
function visitorFeedbackFormPayload(form) {
- const fields = (form.fields || []).map((f) => ({
- label: f.label,
- type: f.type,
- required: !!f.required,
- options: Array.isArray(f.options) ? f.options.map((o) => String(o)) : [],
- placeholder: f.placeholder ? String(f.placeholder) : '',
- }));
+ const fields = (form.fields || []).map((f) => {
+ const remarks = f.remarks != null ? String(f.remarks).trim() : '';
+ return {
+ label: f.label,
+ type: f.type,
+ required: !!f.required,
+ options: Array.isArray(f.options) ? f.options.map((o) => String(o)) : [],
+ placeholder: f.placeholder ? String(f.placeholder) : '',
+ ...(remarks ? { remarks } : {}),
+ };
+ });
return {
_id: String(form._id),
name: form.name,
diff --git a/src/models/EventStaticMicrosite.js b/src/models/EventStaticMicrosite.js
new file mode 100644
index 0000000..6c77522
--- /dev/null
+++ b/src/models/EventStaticMicrosite.js
@@ -0,0 +1,53 @@
+const mongoose = require('mongoose');
+
+const eventStaticMicrositeSchema = new mongoose.Schema(
+ {
+ event: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Event',
+ required: true,
+ unique: true,
+ index: true,
+ },
+ organization: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Organization',
+ required: true,
+ index: true,
+ },
+ subdomain: {
+ type: String,
+ required: true,
+ trim: true,
+ lowercase: true,
+ unique: true,
+ index: true,
+ },
+ template: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'MicrositeV2Template',
+ required: true,
+ },
+ config: { type: mongoose.Schema.Types.Mixed, default: {} },
+ status: {
+ type: String,
+ enum: ['draft', 'published'],
+ default: 'draft',
+ },
+ publishedHtmlUrl: String,
+ publishedHtmlS3Key: String,
+ publishedConfigS3Key: String,
+ publishedAt: Date,
+ publishedBy: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ },
+ updatedBy: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ },
+ },
+ { timestamps: true },
+);
+
+module.exports = mongoose.model('EventStaticMicrosite', eventStaticMicrositeSchema);
diff --git a/src/models/Form.js b/src/models/Form.js
index 816c6de..9c4cc64 100644
--- a/src/models/Form.js
+++ b/src/models/Form.js
@@ -2,13 +2,22 @@ const mongoose = require('mongoose');
const formFieldSchema = new mongoose.Schema({
label: String,
+ /** Unique submission key; duplicates of the same label use `-1`, `-2`, … suffixes. */
+ name: String,
type: {
type: String,
- enum: ['text', 'textarea', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file'],
+ enum: ['text', 'textarea', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file', 'date', 'time', 'section', 'country_state'],
},
required: Boolean,
options: [String],
+ /** Dropdown: show an "Other" choice; submission stores the custom text, not the label. */
+ allowOther: { type: Boolean, default: false },
+ otherLabel: { type: String, default: 'Other' },
placeholder: String,
+ /** Optional helper text shown below the field on public forms. */
+ remarks: String,
+ /** Builder display type (e.g. Text vs Short Text, Dropdown vs Multiple Choice) for other/custom forms. */
+ uiType: String,
/** Registration: which Participant field this collects (`custom` uses customMapsKey → customFields). */
mapsTo: String,
customMapsKey: String,
diff --git a/src/models/MicrositeV2Template.js b/src/models/MicrositeV2Template.js
new file mode 100644
index 0000000..a735c61
--- /dev/null
+++ b/src/models/MicrositeV2Template.js
@@ -0,0 +1,34 @@
+const mongoose = require('mongoose');
+
+const micrositeV2TemplateSchema = new mongoose.Schema(
+ {
+ slug: {
+ type: String,
+ required: true,
+ trim: true,
+ lowercase: true,
+ unique: true,
+ },
+ name: { type: String, required: true, trim: true },
+ description: { type: String, trim: true },
+ thumbnailUrl: String,
+ htmlS3Key: String,
+ schemaS3Key: String,
+ /** Inline copy of template.json schema when small enough for fast reads */
+ schema: mongoose.Schema.Types.Mixed,
+ assetsBaseUrl: String,
+ assetsS3Prefix: String,
+ isActive: { type: Boolean, default: true },
+ version: { type: Number, default: 1 },
+ createdBy: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true,
+ },
+ },
+ { timestamps: true },
+);
+
+micrositeV2TemplateSchema.index({ isActive: 1, createdAt: -1 });
+
+module.exports = mongoose.model('MicrositeV2Template', micrositeV2TemplateSchema);
diff --git a/src/routes/form.routes.js b/src/routes/form.routes.js
index 1e336d9..e5b5cb1 100644
--- a/src/routes/form.routes.js
+++ b/src/routes/form.routes.js
@@ -5,10 +5,15 @@ const { FORM_TYPES, FIELD_TYPES, FORM_FIELD_MAPS_TO } = require('../config/const
/** stripUnknown: Mongo subdocs include _id; duplicates must not fail validation. */
const fieldSchema = Joi.object({
label: Joi.string(),
+ name: Joi.string().allow('', null),
type: Joi.string().valid(...FIELD_TYPES),
required: Joi.boolean(),
options: Joi.array().items(Joi.string()),
- placeholder: Joi.string(),
+ allowOther: Joi.boolean(),
+ otherLabel: Joi.string().allow('', null),
+ placeholder: Joi.string().allow('', null),
+ remarks: Joi.string().allow('', null),
+ uiType: Joi.string().allow('', null),
mapsTo: Joi.string().valid(...FORM_FIELD_MAPS_TO).allow('', null),
customMapsKey: Joi.string().allow('', null),
isFixed: Joi.boolean(),
diff --git a/src/routes/staticMicrosite.routes.js b/src/routes/staticMicrosite.routes.js
new file mode 100644
index 0000000..7a38de9
--- /dev/null
+++ b/src/routes/staticMicrosite.routes.js
@@ -0,0 +1,140 @@
+const Joi = require('joi');
+const controller = require('../controllers/staticMicrosite.controller');
+
+const mongoId = Joi.string().length(24).hex().required();
+const optionalMongoId = Joi.string().length(24).hex().allow('', null);
+
+const multipartPayload = {
+ maxBytes: 100 * 1024 * 1024,
+ output: 'data',
+ parse: true,
+ multipart: true,
+ allow: ['multipart/form-data'],
+};
+
+module.exports = [
+ {
+ method: 'GET',
+ path: '/api/microsite-v2-templates',
+ options: { handler: controller.listTemplates },
+ },
+ {
+ method: 'GET',
+ path: '/api/microsite-v2-templates/admin',
+ options: { handler: controller.listAdminTemplates },
+ },
+ {
+ method: 'GET',
+ path: '/api/microsite-v2-templates/{id}',
+ options: {
+ validate: { params: Joi.object({ id: mongoId }) },
+ handler: controller.getTemplate,
+ },
+ },
+ {
+ method: 'POST',
+ path: '/api/microsite-v2-templates',
+ options: {
+ validate: {
+ payload: Joi.object({
+ slug: Joi.string().required(),
+ name: Joi.string().required(),
+ description: Joi.string().allow('', null),
+ version: Joi.number().integer().min(1).optional(),
+ }),
+ },
+ handler: controller.createTemplate,
+ },
+ },
+ {
+ method: 'PUT',
+ path: '/api/microsite-v2-templates/{id}',
+ options: {
+ validate: {
+ params: Joi.object({ id: mongoId }),
+ payload: Joi.object({
+ name: Joi.string(),
+ description: Joi.string().allow('', null),
+ isActive: Joi.boolean(),
+ version: Joi.number().integer().min(1),
+ }).min(1),
+ },
+ handler: controller.updateTemplate,
+ },
+ },
+ {
+ method: 'DELETE',
+ path: '/api/microsite-v2-templates/{id}',
+ options: {
+ validate: { params: Joi.object({ id: mongoId }) },
+ handler: controller.removeTemplate,
+ },
+ },
+ {
+ method: 'POST',
+ path: '/api/microsite-v2-templates/{id}/upload',
+ options: {
+ payload: multipartPayload,
+ validate: { params: Joi.object({ id: mongoId }) },
+ handler: controller.uploadTemplateFiles,
+ },
+ },
+ {
+ method: 'GET',
+ path: '/api/microsite-v2-templates/{id}/preview-html',
+ options: {
+ validate: { params: Joi.object({ id: mongoId }) },
+ handler: controller.previewTemplateHtml,
+ },
+ },
+ {
+ method: 'GET',
+ path: '/api/events/{eventId}/static-microsite',
+ options: {
+ validate: { params: Joi.object({ eventId: mongoId }) },
+ handler: controller.getForEvent,
+ },
+ },
+ {
+ method: 'PUT',
+ path: '/api/events/{eventId}/static-microsite',
+ options: {
+ validate: {
+ params: Joi.object({ eventId: mongoId }),
+ payload: Joi.object({
+ subdomain: Joi.string().required(),
+ templateId: mongoId,
+ config: Joi.object().unknown(true),
+ }),
+ },
+ handler: controller.upsertForEvent,
+ },
+ },
+ {
+ method: 'GET',
+ path: '/api/events/{eventId}/static-microsite/check-subdomain',
+ options: {
+ validate: {
+ params: Joi.object({ eventId: mongoId }),
+ query: Joi.object({ subdomain: Joi.string().required() }),
+ },
+ handler: controller.checkSubdomain,
+ },
+ },
+ {
+ method: 'POST',
+ path: '/api/events/{eventId}/static-microsite/publish',
+ options: {
+ validate: { params: Joi.object({ eventId: mongoId }) },
+ handler: controller.publishForEvent,
+ },
+ },
+ {
+ method: 'GET',
+ path: '/api/events/{eventId}/static-microsite/preview-html',
+ options: {
+ validate: { params: Joi.object({ eventId: mongoId }) },
+ handler: controller.previewForEvent,
+ },
+ },
+];
diff --git a/src/utils/formSubmissionFiles.js b/src/utils/formSubmissionFiles.js
new file mode 100644
index 0000000..e20f79e
--- /dev/null
+++ b/src/utils/formSubmissionFiles.js
@@ -0,0 +1,155 @@
+const { uploadBufferToS3AndGetCloudFrontUrl } = require('./s3Media');
+
+const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
+
+function fieldDataKey(field) {
+ return String(field?.name || field?.label || '').trim();
+}
+
+function parseDataUrl(value) {
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ const match = /^data:([^;]+);base64,(.+)$/.exec(trimmed);
+ if (!match) return null;
+ return {
+ mimeType: match[1],
+ buffer: Buffer.from(match[2], 'base64'),
+ };
+}
+
+function extensionFromMimeType(mimeType) {
+ const mt = String(mimeType || '').toLowerCase();
+ const exact = {
+ 'application/pdf': 'pdf',
+ 'application/msword': 'doc',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
+ 'application/vnd.ms-excel': 'xls',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
+ 'application/vnd.ms-powerpoint': 'ppt',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
+ 'text/plain': 'txt',
+ 'text/csv': 'csv',
+ 'image/jpeg': 'jpg',
+ 'image/jpg': 'jpg',
+ 'image/png': 'png',
+ 'image/webp': 'webp',
+ 'image/gif': 'gif',
+ };
+ if (exact[mt]) return exact[mt];
+ if (mt.includes('png')) return 'png';
+ if (mt.includes('jpeg') || mt.includes('jpg')) return 'jpg';
+ if (mt.includes('webp')) return 'webp';
+ if (mt.includes('gif')) return 'gif';
+ if (mt.includes('pdf')) return 'pdf';
+ return 'bin';
+}
+
+function maxFormFileBytes() {
+ const n = Number(process.env.FORM_SUBMISSION_MAX_FILE_BYTES);
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_BYTES;
+}
+
+async function uploadFormFileValue({ value, orgId, eventId, fieldKey }) {
+ if (value == null) return value;
+ if (typeof value !== 'string') return value;
+
+ const trimmed = value.trim();
+ if (!trimmed) return '';
+
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
+ return trimmed;
+ }
+
+ const parsed = parseDataUrl(trimmed);
+ if (!parsed) return trimmed;
+
+ const maxBytes = maxFormFileBytes();
+ if (!parsed.buffer.length) {
+ return '';
+ }
+ if (parsed.buffer.length > maxBytes) {
+ const err = new Error(
+ `File for "${fieldKey}" exceeds the maximum size (${Math.round(maxBytes / (1024 * 1024))}MB).`,
+ );
+ err.code = 'FILE_TOO_LARGE';
+ throw err;
+ }
+
+ const safeKey = String(fieldKey).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 48) || 'file';
+ const filename = `${safeKey}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extensionFromMimeType(parsed.mimeType)}`;
+
+ const uploaded = await uploadBufferToS3AndGetCloudFrontUrl({
+ buffer: parsed.buffer,
+ contentType: parsed.mimeType,
+ orgId,
+ eventId: eventId || 'no-event',
+ module: 'form-submissions',
+ filename,
+ });
+
+ return uploaded.url;
+}
+
+/**
+ * Upload base64 data-URL file field values to S3 and replace with CloudFront URLs.
+ */
+async function processFormSubmissionFileUploads(form, data, { orgId, eventId }) {
+ const out = { ...(data || {}) };
+ const fields = form?.fields || [];
+
+ for (const field of fields) {
+ if (field.type !== 'file') continue;
+ const key = fieldDataKey(field);
+ if (!key || !Object.prototype.hasOwnProperty.call(out, key)) continue;
+ const current = out[key];
+ if (current == null || current === '') continue;
+ out[key] = await uploadFormFileValue({
+ value: current,
+ orgId,
+ eventId,
+ fieldKey: key,
+ });
+ }
+
+ return out;
+}
+
+function dataMapToObject(data) {
+ if (!data) return {};
+ if (data instanceof Map) return Object.fromEntries(data);
+ return typeof data === 'object' ? { ...data } : {};
+}
+
+function hasDataUrlValue(data) {
+ const obj = dataMapToObject(data);
+ return Object.values(obj).some((val) => typeof val === 'string' && !!parseDataUrl(val));
+}
+
+/**
+ * Upload file fields from form schema, then any remaining data-URL strings (legacy keys).
+ */
+async function processAllSubmissionDataUrls(data, { orgId, eventId, form }) {
+ let out = dataMapToObject(data);
+ if (form) {
+ out = await processFormSubmissionFileUploads(form, out, { orgId, eventId });
+ }
+ for (const [key, val] of Object.entries(out)) {
+ if (typeof val !== 'string' || !parseDataUrl(val)) continue;
+ out[key] = await uploadFormFileValue({
+ value: val,
+ orgId,
+ eventId,
+ fieldKey: key,
+ });
+ }
+ return out;
+}
+
+module.exports = {
+ processFormSubmissionFileUploads,
+ processAllSubmissionDataUrls,
+ uploadFormFileValue,
+ parseDataUrl,
+ hasDataUrlValue,
+ dataMapToObject,
+};
diff --git a/src/utils/handlebarsRender.js b/src/utils/handlebarsRender.js
new file mode 100644
index 0000000..9453f1d
--- /dev/null
+++ b/src/utils/handlebarsRender.js
@@ -0,0 +1,92 @@
+let HandlebarsLib;
+
+function getHandlebars() {
+ if (!HandlebarsLib) {
+ // eslint-disable-next-line global-require
+ HandlebarsLib = require('handlebars');
+ }
+ return HandlebarsLib;
+}
+
+let helpersRegistered = false;
+
+function registerHelpers() {
+ if (helpersRegistered) return;
+ helpersRegistered = true;
+
+ const Handlebars = getHandlebars();
+ Handlebars.registerHelper('eq', (a, b) => a === b);
+ Handlebars.registerHelper('neq', (a, b) => a !== b);
+ Handlebars.registerHelper('and', (a, b) => a && b);
+ Handlebars.registerHelper('or', (a, b) => a || b);
+ Handlebars.registerHelper('not', (a) => !a);
+
+ Handlebars.registerHelper('if_show', function ifShow(section, options) {
+ if (section && section.show !== false) {
+ return options.fn(this);
+ }
+ return options.inverse(this);
+ });
+
+ Handlebars.registerHelper('assetUrl', (base, path) => {
+ const b = String(base || '').replace(/\/+$/, '');
+ const p = String(path || '').replace(/^\/+/, '');
+ return p ? `${b}/${p}` : b;
+ });
+
+ Handlebars.registerHelper('resolveAsset', (base, path) => {
+ const p = String(path || '');
+ if (!p) return '';
+ if (/^https?:\/\//i.test(p)) return p;
+ const b = String(base || '').replace(/\/+$/, '');
+ return `${b}/${p.replace(/^\/+/, '')}`;
+ });
+
+ Handlebars.registerHelper('json', (obj) => {
+ const safe = obj && typeof obj === 'object' ? obj : {};
+ return JSON.stringify(safe).replace(/ !list.some((f) => f.mapsTo === def.mapsTo),
+ );
+ if (!missing.length) return list;
+ const injected = missing.map((def) => ({
+ ...def,
+ name: def.label,
+ required: true,
+ options: [],
+ }));
+ return [...injected, ...list];
+}
+
+function mapFieldForPublicApi(f) {
+ const remarks = f.remarks != null ? String(f.remarks).trim() : '';
+ return {
+ label: f.label,
+ name: f.name || f.label,
+ type: f.type,
+ required: f.required !== false,
+ options: f.options,
+ ...(f.allowOther ? { allowOther: true, otherLabel: f.otherLabel || 'Other' } : {}),
+ placeholder: f.placeholder,
+ mapsTo: f.mapsTo,
+ isFixed: f.isFixed,
+ ...(remarks ? { remarks } : {}),
+ };
+}
+
+function publicRegistrationFields(fields, formType) {
+ const raw = formType === 'registration' ? ensureRegistrationCoreFields(fields) : fields || [];
+ return raw.map(mapFieldForPublicApi);
+}
+
+module.exports = {
+ REGISTRATION_CORE_FIELD_DEFS,
+ ensureRegistrationCoreFields,
+ mapFieldForPublicApi,
+ publicRegistrationFields,
+};
diff --git a/src/utils/s3MicrositeV2.js b/src/utils/s3MicrositeV2.js
new file mode 100644
index 0000000..c6365dc
--- /dev/null
+++ b/src/utils/s3MicrositeV2.js
@@ -0,0 +1,113 @@
+const {
+ uploadBufferToS3AndGetCloudFrontUrl,
+ getBufferFromS3Key,
+ cloudfrontUrlForKey,
+} = require('./s3Media');
+
+function getEnv(name, fallback) {
+ const v = process.env[name];
+ if (v == null || String(v).trim() === '') return fallback;
+ return String(v).trim();
+}
+
+function sanitizeSegment(segment) {
+ return String(segment || '')
+ .trim()
+ .replace(/[^a-zA-Z0-9._-]/g, '_');
+}
+
+function getS3Prefix() {
+ return getEnv('MICROSITE_V2_S3_PREFIX', 'eventbuzz/microsite-v2').replace(/\/+$/, '');
+}
+
+function templateKey(slug, filename) {
+ const safeSlug = sanitizeSegment(slug);
+ const safeFile = sanitizeSegment(filename);
+ return `${getS3Prefix()}/templates/${safeSlug}/${safeFile}`;
+}
+
+function templateAssetsKey(slug, relativePath) {
+ const safeSlug = sanitizeSegment(slug);
+ const parts = String(relativePath || '')
+ .split('/')
+ .map(sanitizeSegment)
+ .filter(Boolean);
+ return `${getS3Prefix()}/templates/${safeSlug}/assets/${parts.join('/')}`;
+}
+
+function siteKey(subdomain, filename) {
+ const safeSub = sanitizeSegment(subdomain);
+ const safeFile = sanitizeSegment(filename);
+ return `${getS3Prefix()}/sites/${safeSub}/${safeFile}`;
+}
+
+function templateAssetsBaseUrl(slug) {
+ const prefix = templateAssetsKey(slug, '');
+ return cloudfrontUrlForKey(prefix);
+}
+
+function publicSiteUrl(subdomain) {
+ const basePattern = getEnv('MICROSITE_V2_PUBLIC_BASE_URL', 'https://{subdomain}.eventbuzz.live');
+ return basePattern.replace('{subdomain}', subdomain);
+}
+
+async function uploadMicrositeV2Object({ key, buffer, contentType }) {
+ const bucket = getEnv('AWS_BUCKET');
+ if (!bucket) throw new Error('AWS_BUCKET is not configured');
+
+ const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
+ const region = getEnv('AWS_DEFAULT_REGION', 'ap-south-1');
+ const accessKeyId = getEnv('AWS_ACCESS_KEY_ID');
+ const secretAccessKey = getEnv('AWS_SECRET_ACCESS_KEY');
+
+ const client = new S3Client({
+ region,
+ credentials: accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined,
+ });
+
+ await client.send(
+ new PutObjectCommand({
+ Bucket: bucket,
+ Key: key,
+ Body: buffer,
+ ContentType: contentType || 'application/octet-stream',
+ }),
+ );
+
+ return { s3Key: key, url: cloudfrontUrlForKey(key) };
+}
+
+async function uploadTemplateFile(slug, filename, buffer, contentType) {
+ const key = templateKey(slug, filename);
+ return uploadMicrositeV2Object({ key, buffer, contentType });
+}
+
+async function uploadTemplateAsset(slug, relativePath, buffer, contentType) {
+ const key = templateAssetsKey(slug, relativePath);
+ return uploadMicrositeV2Object({ key, buffer, contentType });
+}
+
+async function uploadSiteFile(subdomain, filename, buffer, contentType) {
+ const key = siteKey(subdomain, filename);
+ return uploadMicrositeV2Object({ key, buffer, contentType });
+}
+
+async function getS3ObjectBuffer(s3Key) {
+ return getBufferFromS3Key(s3Key);
+}
+
+module.exports = {
+ getS3Prefix,
+ templateKey,
+ templateAssetsKey,
+ siteKey,
+ templateAssetsBaseUrl,
+ publicSiteUrl,
+ uploadMicrositeV2Object,
+ uploadTemplateFile,
+ uploadTemplateAsset,
+ uploadSiteFile,
+ getS3ObjectBuffer,
+ cloudfrontUrlForKey,
+ uploadBufferToS3AndGetCloudFrontUrl,
+};
diff --git a/src/utils/subdomainAvailability.js b/src/utils/subdomainAvailability.js
new file mode 100644
index 0000000..c529f37
--- /dev/null
+++ b/src/utils/subdomainAvailability.js
@@ -0,0 +1,78 @@
+const Event = require('../models/Event');
+const EventMicrosite = require('../models/EventMicrosite');
+const EventStaticMicrosite = require('../models/EventStaticMicrosite');
+const { normalizePublishFolder } = require('./publishFolderAvailability');
+
+/**
+ * Normalize subdomain slug (same rules as legacy publish folder).
+ * @param {string} raw
+ * @returns {string}
+ */
+function normalizeSubdomain(raw) {
+ return normalizePublishFolder(raw);
+}
+
+/**
+ * Reserved subdomains that cannot be claimed.
+ */
+const RESERVED_SUBDOMAINS = new Set([
+ 'www',
+ 'api',
+ 'app',
+ 'console',
+ 'web',
+ 'admin',
+ 'mail',
+ 'cdn',
+ 'static',
+ 'assets',
+ 'meet',
+ 'eventbuzz',
+]);
+
+/**
+ * Check global subdomain availability across new static microsites and legacy folders.
+ * @param {string} subdomain normalized
+ * @param {string} eventId
+ * @param {string} [excludeStaticMicrositeId]
+ * @returns {Promise<{ available: boolean, message?: string }>}
+ */
+async function subdomainAvailability(subdomain, eventId, excludeStaticMicrositeId) {
+ if (!subdomain) {
+ return { available: false, message: 'Subdomain is required' };
+ }
+ if (subdomain.length < 3) {
+ return { available: false, message: 'Subdomain must be at least 3 characters' };
+ }
+ if (RESERVED_SUBDOMAINS.has(subdomain)) {
+ return { available: false, message: 'This subdomain is reserved' };
+ }
+
+ const staticFilter = { subdomain };
+ if (excludeStaticMicrositeId) {
+ staticFilter._id = { $ne: excludeStaticMicrositeId };
+ }
+ const takenStatic = await EventStaticMicrosite.findOne(staticFilter).select('_id').lean();
+ if (takenStatic) {
+ return { available: false, message: 'This subdomain is already in use' };
+ }
+
+ const takenMs = await EventMicrosite.findOne({ publishFolder: subdomain }).select('_id').lean();
+ if (takenMs) {
+ return { available: false, message: 'This subdomain is already in use by another microsite' };
+ }
+
+ const legacyOther = await Event.findOne({
+ 'website.folder': subdomain,
+ _id: { $ne: eventId },
+ })
+ .select('_id')
+ .lean();
+ if (legacyOther) {
+ return { available: false, message: 'Another event already uses this subdomain' };
+ }
+
+ return { available: true };
+}
+
+module.exports = { normalizeSubdomain, subdomainAvailability, RESERVED_SUBDOMAINS };
diff --git a/src/utils/templateSchema.js b/src/utils/templateSchema.js
new file mode 100644
index 0000000..4ea158e
--- /dev/null
+++ b/src/utils/templateSchema.js
@@ -0,0 +1,113 @@
+/**
+ * Parse and validate microsite-v2 template.json schema.
+ */
+
+function parseTemplateSchema(raw) {
+ let schema;
+ if (Buffer.isBuffer(raw)) {
+ schema = JSON.parse(raw.toString('utf8'));
+ } else if (typeof raw === 'string') {
+ schema = JSON.parse(raw);
+ } else {
+ schema = raw;
+ }
+
+ if (!schema || typeof schema !== 'object') {
+ throw new Error('template.json must be a JSON object');
+ }
+ if (!schema.version) {
+ throw new Error('template.json must include version');
+ }
+ if (!Array.isArray(schema.sections)) {
+ throw new Error('template.json must include sections array');
+ }
+
+ for (const section of schema.sections) {
+ if (!section.id || !section.label) {
+ throw new Error('Each section must have id and label');
+ }
+ if (!Array.isArray(section.fields)) {
+ section.fields = [];
+ }
+ }
+
+ return schema;
+}
+
+function defaultForField(field) {
+ if (field.default !== undefined) return field.default;
+ switch (field.type) {
+ case 'boolean':
+ return false;
+ case 'array':
+ return [];
+ case 'color':
+ return '#5c6ac4';
+ default:
+ return '';
+ }
+}
+
+function setNested(obj, path, value) {
+ const parts = String(path).split('.');
+ let cur = obj;
+ for (let i = 0; i < parts.length - 1; i++) {
+ const p = parts[i];
+ if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
+ cur = cur[p];
+ }
+ cur[parts[parts.length - 1]] = value;
+}
+
+function buildDefaultConfig(schema) {
+ const config = {
+ meta: { ...(schema.meta || {}) },
+ theme: {},
+ sections: {},
+ };
+
+ if (schema.theme?.fields) {
+ for (const field of schema.theme.fields) {
+ if (field.key) config.theme[field.key] = defaultForField(field);
+ }
+ }
+
+ for (const section of schema.sections) {
+ const sectionData = { show: section.show !== false };
+ for (const field of section.fields || []) {
+ if (field.type === 'array') {
+ sectionData[field.key] = defaultForField(field);
+ } else if (field.key && field.key.includes('.')) {
+ setNested(sectionData, field.key, defaultForField(field));
+ } else if (field.key) {
+ sectionData[field.key] = defaultForField(field);
+ }
+ }
+ config.sections[section.id] = sectionData;
+ }
+
+ return config;
+}
+
+function mergeConfigWithDefaults(schema, userConfig) {
+ const defaults = buildDefaultConfig(schema);
+ const merged = {
+ meta: { ...defaults.meta, ...(userConfig?.meta || {}) },
+ theme: { ...defaults.theme, ...(userConfig?.theme || {}) },
+ sections: { ...defaults.sections },
+ };
+
+ if (userConfig?.sections && typeof userConfig.sections === 'object') {
+ for (const [id, data] of Object.entries(userConfig.sections)) {
+ merged.sections[id] = { ...(merged.sections[id] || {}), ...data };
+ }
+ }
+
+ return merged;
+}
+
+module.exports = {
+ parseTemplateSchema,
+ buildDefaultConfig,
+ mergeConfigWithDefaults,
+};