first commit

This commit is contained in:
Ravi Gulhane 2026-06-02 10:43:50 +05:30
parent a257817f8c
commit 2a5629c2ae
169 changed files with 15978 additions and 511 deletions

View File

@ -0,0 +1,22 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BedrockInvokeLlamaForEventBuzz",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
"bedrock:Converse",
"bedrock:ConverseStream"
],
"Resource": [
"arn:aws:bedrock:ap-south-1::foundation-model/meta.llama3-8b-instruct-v1:0",
"arn:aws:bedrock:ap-south-1::foundation-model/meta.llama3-70b-instruct-v1:0",
"arn:aws:bedrock:ap-south-1::foundation-model/amazon.titan-embed-text-v2:0",
"arn:aws:bedrock:ap-south-1::foundation-model/amazon.titan-embed-text-v1:0",
"arn:aws:bedrock:ap-south-1::foundation-model/amazon.titan-embed-text-v1"
]
}
]
}

2689
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,14 +10,26 @@
"test": "jest --coverage",
"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:tenant-organization": "node scripts/backfill-tenant-organization.js",
"email-worker": "node src/workers/emailQueueWorker.js",
"whatsapp-worker": "node src/workers/whatsappQueueWorker.js",
"concierge-ingest-worker": "node ../chatbot/workers/ingest.js",
"concierge-inbound-worker": "node ../chatbot/workers/inbound.js",
"badge-batch-worker": "node src/workers/badgeBatchWorker.js"
},
"dependencies": {
"@napi-rs/canvas": "^0.1.56",
"archiver": "^7.0.1",
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
"@aws-sdk/client-s3": "^3.1012.0",
"@aws-sdk/lib-storage": "^3.1012.0",
"@hapi/hapi": "^21.3.0",
"bcrypt": "^5.1.1",
"bullmq": "^5.34.5",
"cheerio": "^1.0.0",
"ioredis": "^5.4.2",
"mathjs": "^14.0.1",
"pdf-parse": "^1.1.1",
"dotenv": "^16.3.1",
"firebase-admin": "^13.0.0",
"google-auth-library": "^9.14.0",
@ -25,9 +37,14 @@
"joi": "^17.13.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.0.0",
"pdf-lib": "^1.17.1",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.7",
"officeparser": "^5.1.1",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"razorpay": "^2.9.6",
"sharp": "^0.33.5",
"winston": "^3.11.0"
},
"devDependencies": {

21
public/embed-demo.html Normal file
View File

@ -0,0 +1,21 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EventBuzz Concierge Widget Demo</title>
</head>
<body style="font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; padding: 24px;">
<h1>Concierge widget demo</h1>
<p>Set a real bot id below.</p>
<script>
window.EB_CONCIERGE = {
botId: "6a16c86483f93a9c9d04220d",
apiBase: (location.origin.replace(/\/$/, "") + "/api"),
};
</script>
<script async src="/embed/concierge-widget.js"></script>
</body>
</html>

View File

@ -0,0 +1,54 @@
:root{
--ebc-bg:#ffffff;
--ebc-fg:#111827;
--ebc-muted:#6b7280;
--ebc-border:#e5e7eb;
--ebc-primary:#2563eb;
--ebc-primary-fg:#ffffff;
}
.ebc-launcher{
position:fixed; right:18px; bottom:18px;
width:54px; height:54px; border-radius:999px;
border:1px solid var(--ebc-border);
background:var(--ebc-primary); color:var(--ebc-primary-fg);
cursor:pointer; box-shadow:0 10px 25px rgba(0,0,0,.12);
font:600 14px/1 system-ui,-apple-system,Segoe UI,Roboto,Arial;
}
.ebc-panel{
position:fixed; right:18px; bottom:84px;
width:340px; height:460px;
border:1px solid var(--ebc-border);
background:var(--ebc-bg); color:var(--ebc-fg);
border-radius:14px; box-shadow:0 14px 40px rgba(0,0,0,.14);
display:none; overflow:hidden;
font:400 13px/1.35 system-ui,-apple-system,Segoe UI,Roboto,Arial;
}
.ebc-panel.ebc-open{ display:flex; flex-direction:column; }
.ebc-header{
padding:12px 12px;
border-bottom:1px solid var(--ebc-border);
display:flex; align-items:center; justify-content:space-between;
}
.ebc-title{ font-weight:700; font-size:13px; }
.ebc-close{ border:0; background:transparent; cursor:pointer; color:var(--ebc-muted); font-size:18px; }
.ebc-messages{ padding:12px; gap:8px; display:flex; flex-direction:column; overflow:auto; flex:1; }
.ebc-msg{ max-width:85%; padding:9px 10px; border-radius:12px; border:1px solid var(--ebc-border); background:#f9fafb; }
.ebc-msg.ebc-user{ margin-left:auto; background:#eff6ff; border-color:#dbeafe; }
.ebc-meta{ font-size:11px; color:var(--ebc-muted); margin-top:2px; }
.ebc-inputbar{
padding:10px; border-top:1px solid var(--ebc-border);
display:flex; gap:8px; align-items:center;
}
.ebc-input{
flex:1; border:1px solid var(--ebc-border);
border-radius:10px; padding:9px 10px;
outline:none;
}
.ebc-send{
border:1px solid var(--ebc-border);
background:var(--ebc-primary); color:var(--ebc-primary-fg);
border-radius:10px; padding:9px 12px;
cursor:pointer; font-weight:600;
}
.ebc-error{ color:#b91c1c; font-size:12px; padding:0 12px 10px; display:none; }

View File

@ -0,0 +1,138 @@
(function () {
var cfg = window.EB_CONCIERGE || {};
var botId = cfg.botId;
var apiBase = (cfg.apiBase || '').replace(/\/$/, '');
if (!botId || !apiBase) {
// eslint-disable-next-line no-console
console.error('[EventBuzz Concierge] Missing window.EB_CONCIERGE.botId or apiBase');
return;
}
var storageKey = 'ebc_session_' + botId;
var sessionId = null;
try { sessionId = localStorage.getItem(storageKey); } catch {}
function el(tag, attrs, children) {
var n = document.createElement(tag);
if (attrs) for (var k in attrs) n.setAttribute(k, attrs[k]);
if (children) children.forEach(function (c) { n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
return n;
}
function addCssOnce() {
if (document.getElementById('ebc-css')) return;
var link = el('link', { id: 'ebc-css', rel: 'stylesheet', href: apiBase.replace(/\/api$/, '') + '/embed/concierge-widget.css' });
document.head.appendChild(link);
}
addCssOnce();
var launcher = el('button', { class: 'ebc-launcher', type: 'button', 'aria-label': 'Chat with event concierge' }, ['Chat']);
var panel = el('div', { class: 'ebc-panel', role: 'dialog', 'aria-label': 'Event concierge chat' });
var header = el('div', { class: 'ebc-header' });
var title = el('div', { class: 'ebc-title' }, ['Event Concierge']);
var closeBtn = el('button', { class: 'ebc-close', type: 'button', 'aria-label': 'Close' }, ['×']);
header.appendChild(title);
header.appendChild(closeBtn);
var messages = el('div', { class: 'ebc-messages' });
var error = el('div', { class: 'ebc-error' }, ['']);
var inputBar = el('div', { class: 'ebc-inputbar' });
var input = el('input', { class: 'ebc-input', placeholder: 'Ask a question…', maxlength: '800' });
var sendBtn = el('button', { class: 'ebc-send', type: 'button' }, ['Send']);
inputBar.appendChild(input);
inputBar.appendChild(sendBtn);
panel.appendChild(header);
panel.appendChild(messages);
panel.appendChild(error);
panel.appendChild(inputBar);
document.body.appendChild(launcher);
document.body.appendChild(panel);
function setError(msg) {
if (!msg) {
error.style.display = 'none';
error.textContent = '';
return;
}
error.style.display = 'block';
error.textContent = msg;
}
function addMessage(role, text) {
var bubble = el('div', { class: 'ebc-msg' + (role === 'user' ? ' ebc-user' : '') });
bubble.textContent = text;
messages.appendChild(bubble);
messages.scrollTop = messages.scrollHeight;
}
function openPanel() {
panel.classList.add('ebc-open');
input.focus();
}
function closePanel() {
panel.classList.remove('ebc-open');
}
launcher.addEventListener('click', function () {
if (panel.classList.contains('ebc-open')) closePanel();
else openPanel();
});
closeBtn.addEventListener('click', closePanel);
function http(path, body) {
return fetch(apiBase + path, {
method: body ? 'POST' : 'GET',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
credentials: 'omit'
}).then(function (r) {
return r.json().then(function (j) { return { status: r.status, json: j }; });
});
}
// Load config
http('/public/concierge/' + encodeURIComponent(botId) + '/config').then(function (res) {
if (res.json && res.json.success && res.json.data) {
title.textContent = res.json.data.eventName ? ('Concierge · ' + res.json.data.eventName) : 'Event Concierge';
var greet = res.json.data.greeting;
if (greet) addMessage('assistant', greet.replace(/\{eventName\}/g, res.json.data.eventName || 'the event'));
if (res.json.data.maxMessageLength) input.setAttribute('maxlength', String(res.json.data.maxMessageLength));
}
});
function send() {
var text = String(input.value || '').trim();
if (!text) return;
setError(null);
input.value = '';
addMessage('user', text);
sendBtn.disabled = true;
http('/public/concierge/' + encodeURIComponent(botId) + '/chat', {
sessionId: sessionId || undefined,
message: text
}).then(function (res) {
sendBtn.disabled = false;
if (!res.json || !res.json.success) {
setError((res.json && res.json.error && res.json.error.message) ? res.json.error.message : 'Request failed');
return;
}
if (res.json.data && res.json.data.sessionId) {
sessionId = res.json.data.sessionId;
try { localStorage.setItem(storageKey, sessionId); } catch {}
}
addMessage('assistant', (res.json.data && res.json.data.reply) ? res.json.data.reply : '…');
}).catch(function (e) {
sendBtn.disabled = false;
setError(e && e.message ? e.message : 'Network error');
});
}
sendBtn.addEventListener('click', send);
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') send();
});
})();

View File

@ -0,0 +1,57 @@
/**
* One-time generator: writes backend/src/config/permissions/*.json from the canonical catalog.
* Run: node backend/scripts/generate-permission-json-files.js
*/
const fs = require('fs');
const path = require('path');
const DIR = path.join(__dirname, '../src/config/permissions');
const FEATURES = [
['participants', 'Participants', 'portfolio'],
['event_website', 'Event Website', 'website'],
['customise_event_website', 'Customise event website', 'website'],
['custom_domain', 'Custom Domain', 'website'],
['invites_rsvp', 'Invites +RSVP', 'registration'],
['registration_forms_with_custom_fields', 'Registration forms with Custom fields', 'registration'],
['custom_registration_workflow', 'Custom Registration workflow', 'registration'],
['email_confirmation_reminders', 'Email Confirmation & Reminders', 'comms'],
['email_outreach_campaigns', 'Email outreach campaigns', 'comms'],
['custom_badge_and_print_file_creation', 'Custom Badge and print file creation', 'design'],
['whatsapp_confirmation_reminders', 'WhatsApp confirmation & reminders', 'comms'],
['bring_your_wa_number', 'Bring your WA number', 'comms'],
['custom_template_for_wa_messaging', 'Custom template for WA messaging', 'comms'],
['agenda_builder', 'Agenda builder', 'agenda'],
['multi_track_agenda', 'Multi track agenda', 'agenda'],
['speaker_management', 'Speaker management', 'agenda'],
['abstract_management', 'Abstract management', 'agenda'],
['ticketing_free_and_paid', 'Ticketing free & paid', 'payments'],
['collect_payment_using_your_upi_qr', 'Collect Payment using your UPI QR', 'payments'],
['payment_gateway_integration', 'Payment gateway integration', 'payments'],
['qna_with_moderation', 'QnA with moderation', 'engagement'],
['live_polls', 'Live Polls', 'engagement'],
['multiple_quiz_modes', 'Multiple Quiz modes', 'engagement'],
['word_cloud', 'Word Cloud', 'engagement'],
['custom_theme', 'Custom Theme', 'branding'],
['ai_summarization', 'AI Summarization', 'ai'],
['ai_engagement_insights', 'AI Engagement insights', 'ai'],
['content_management', 'Content management', 'content'],
['ratings_for_sessions_and_speakers', 'Ratings for sessions and speakers', 'feedback'],
['feedback_forms_and_surveys', 'Feedback forms & Surveys', 'feedback'],
['gamification', 'Gamification', 'engagement'],
['push_notifications', 'Push Notifications', 'comms'],
];
fs.mkdirSync(DIR, { recursive: true });
for (const [key, label, category] of FEATURES) {
const file = path.join(DIR, `${key}.json`);
const doc = {
key,
label,
description: '',
category,
};
fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
console.log('wrote', path.basename(file));
}
console.log('done', FEATURES.length, 'files');

View File

@ -22,13 +22,26 @@ const contentCollectionRoutes = require('./routes/contentCollection.routes');
const paymentRoutes = require('./routes/payment.routes');
const organizationRoutes = require('./routes/organization.routes');
const subscriptionRoutes = require('./routes/subscription.routes');
const platformAdminRoutes = require('./routes/platformAdmin.routes');
const adminRequestRoutes = require('./routes/adminRequest.routes');
const eventStaffRoutes = require('./routes/eventStaff.routes');
const websiteSettingsRoutes = require('./routes/websiteSettings.routes');
const eventMicrositeRoutes = require('./routes/eventMicrosite.routes');
const visitorRoutes = require('./routes/visitor.routes');
const whatsappTemplateRoutes = require('./routes/whatsappTemplate.routes');
const badgeBatchRoutes = require('./routes/badgeBatch.routes');
const eventTicketRoutes = require('./routes/eventTicket.routes');
const publicEventTicketRoutes = require('./routes/publicEventTicket.routes');
const publicAttendanceRoutes = require('./routes/publicAttendance.routes');
const attendanceRoutes = require('./routes/attendance.routes');
const checkinsRoutes = require('./routes/checkins.routes');
const ticketManualRoutes = require('./routes/ticketManual.routes');
const eventTicketingManualRoutes = require('./routes/eventTicketingManual.routes');
const conciergeBotRoutes = require('./routes/conciergeBot.routes');
const publicConciergeRoutes = require('./routes/publicConcierge.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). */
const fromEnv = (process.env.CORS_ORIGIN || process.env.FRONTEND_URL || '')
.split(',')
.map((s) => s.trim().replace(/\/$/, ''))
@ -63,7 +76,12 @@ const createServer = async () => {
: localDevOrigins;
/** Extra allowed origins (e.g. GrapesJS app on a different port/domain). Also set WEBSITE_BUILDER_CORS_ORIGINS in prod. */
const allowedOrigins = [...new Set([...originList, ...builderExtras])];
const conciergeExtras = (process.env.CONCIERGE_PUBLIC_ALLOWED_ORIGINS || '')
.split(',')
.map((s) => s.trim().replace(/\/$/, ''))
.filter(Boolean);
const allowedOrigins = [...new Set([...originList, ...builderExtras, ...conciergeExtras])];
/** In development, allow any http://localhost or http://127.0.0.1 port (Vite may use 5175, 5176, …). Production: whitelist only. */
function resolveCorsAllowOrigin(rawOrigin) {
@ -167,7 +185,40 @@ const createServer = async () => {
options: { auth: false },
});
// Embeddable concierge widget assets (served directly from backend/public).
const fs = require('fs/promises');
const path = require('path');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const sendFile = async (h, relPath, contentType) => {
const abs = path.join(PUBLIC_DIR, relPath);
const body = await fs.readFile(abs, 'utf8');
return h.response(body).type(contentType);
};
server.route([
{
method: 'GET',
path: '/embed/concierge-widget.js',
options: { auth: false },
handler: async (request, h) => sendFile(h, 'embed/concierge-widget.js', 'application/javascript'),
},
{
method: 'GET',
path: '/embed/concierge-widget.css',
options: { auth: false },
handler: async (request, h) => sendFile(h, 'embed/concierge-widget.css', 'text/css'),
},
{
method: 'GET',
path: '/embed/demo',
options: { auth: false },
handler: async (request, h) => sendFile(h, 'embed-demo.html', 'text/html'),
},
]);
server.route(visitorRoutes);
server.route(publicEventTicketRoutes);
server.route(publicAttendanceRoutes);
server.route(publicConciergeRoutes);
server.route(authRoutes);
server.route(eventRoutes);
@ -189,10 +240,19 @@ const createServer = async () => {
server.route(paymentRoutes);
server.route(organizationRoutes);
server.route(subscriptionRoutes);
server.route(platformAdminRoutes);
server.route(adminRequestRoutes);
server.route(eventStaffRoutes);
server.route(websiteSettingsRoutes);
server.route(eventMicrositeRoutes);
server.route(whatsappTemplateRoutes);
server.route(badgeBatchRoutes);
server.route(eventTicketRoutes);
server.route(ticketManualRoutes);
server.route(eventTicketingManualRoutes);
server.route(conciergeBotRoutes);
server.route(attendanceRoutes);
server.route(checkinsRoutes);
server.ext('onPreResponse', (request, h) => {
const response = request.response;
@ -231,7 +291,37 @@ const app = {
async initialize() {
await connectDB();
const { warmPlanCache } = require('./config/subscriptionCatalog');
await warmPlanCache();
this._server = await createServer();
const cron = require('node-cron');
const { runSubscriptionExpiry } = require('./jobs/subscriptionExpiry.job');
cron.schedule(
'0 3 * * *',
async () => {
try {
const n = await runSubscriptionExpiry();
if (n > 0) console.log(`[cron] subscription expiry: ${n} organisation(s) set to free`);
} catch (err) {
console.error('[cron] subscription expiry failed', err);
}
},
{ timezone: process.env.CRON_TZ || undefined },
);
const { runWhatsAppTemplateSync } = require('./jobs/whatsappTemplateSync.job');
const waCron = process.env.WHATSAPP_TEMPLATE_SYNC_CRON || '*/12 * * * *';
cron.schedule(
waCron,
async () => {
try {
const n = await runWhatsAppTemplateSync();
if (n > 0) console.log(`[cron] whatsapp templates: synced ${n} organisation(s)`);
} catch (err) {
console.error('[cron] whatsapp template sync failed', err);
}
},
{ timezone: process.env.CRON_TZ || undefined },
);
},
async start() {

View File

@ -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'];
const FIELD_TYPES = ['text', 'email', 'phone', 'select', 'radio', 'checkbox', 'checkboxes', 'file'];
const FIELD_TYPES = ['text', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file'];
/** Registration field → Participant property mapping (or `custom` + customMapsKey). */
const FORM_FIELD_MAPS_TO = [
'firstName',

View File

@ -1,13 +1,43 @@
const mongoose = require('mongoose');
let connectPromise = null;
/**
* Single shared MongoDB connection for API, workers, and chatbot service.
* Always require models from this backend package so they use this mongoose instance.
*/
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI);
console.log(`MongoDB connected: ${conn.connection.host}`);
} catch (error) {
console.error('MongoDB connection error:', error);
process.exit(1);
if (mongoose.connection.readyState === 1) {
return mongoose.connection;
}
if (connectPromise) {
return connectPromise;
}
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error('MONGODB_URI is not configured');
}
connectPromise = mongoose
.connect(uri, {
maxPoolSize: Number(process.env.MONGODB_MAX_POOL_SIZE) || 50,
serverSelectionTimeoutMS: Number(process.env.MONGODB_SERVER_SELECTION_MS) || 30000,
socketTimeoutMS: Number(process.env.MONGODB_SOCKET_TIMEOUT_MS) || 45000,
})
.then((conn) => {
console.log(`MongoDB connected: ${conn.connection.host}`);
return conn;
})
.catch((err) => {
console.error('MongoDB connection error:', err);
throw err;
})
.finally(() => {
connectPromise = null;
});
return connectPromise;
};
module.exports = { connectDB };
module.exports = { connectDB, mongoose };

View File

@ -0,0 +1,36 @@
,Free,Starter,Growth,Pro,Enterprise
MANAGE,,,,,
Participants,50,300,500,1000,Unlimited
Event Website,Event Page,Event microsite,Event microsite,Custom domain event microsite,Custom Website
Customise event website,No,No,Yes,Yes,Yes
Custom Domain,No,No,Yes,Yes,Yes
Invites +RSVP,Yes,Yes,Yes,Yes,Yes
Registration forms with Custom fields,Yes,Yes,Yes,Yes,Yes
Custom Registration workflow,No,No,No,Yes,Yes
Email Confirmation & Reminders,Yes,Yes,Yes,Yes,Yes
Email outreach campaigns,Upto 500 emails,Upto 5000 emails,Upto 10K emails,Upto 10K emails,Unlimited
Custom Badge and print file creation,Yes,Yes,Yes,Yes,Yes
WhatsApp confirmation & reminders,No,No,Yes,Yes,Yes
Bring your WA number,No,No,No,Yes,Yes
Custom template for WA messaging,No,No,No,Yes,Yes
Agenda builder,Yes,Yes,Yes,Yes,Yes
Multi track agenda,No,Yes,Yes,Yes,Yes
Speaker management,No,Yes,Yes,Yes,Yes
Abstract management,No,No,No,Yes,Yes
Ticketing free & paid,Yes,Yes,Yes,Yes,Yes
Collect Payment using your UPI QR,Add on,Yes,Yes,Yes,Yes
Payment gateway integration,No,No,Yes,Yes,Yes
,,,,,
ENGAGE,,,,,
QnA with moderation,Yes,Yes,Yes,Yes,Yes
Live Polls,Yes,Yes,Yes,Yes,Yes
Multiple Quiz modes,Yes,Yes,Yes,Yes,Yes
Word Cloud,Yes,Yes,Yes,Yes,Yes
Custom Theme,No,No,No,Yes,Yes
AI Summarization,No,No,Yes,Yes,Yes
AI Engagement insights ,No,No,Yes,Yes,Yes
Content management,No,No,Yes,Yes,Yes
Ratings for sessions and speakers,Yes,Yes,Yes,Yes,Yes
Feedback forms & Surveys,Yes,Yes,Yes,Yes,Yes
Gamification,No,No,Yes,Yes,Yes
Push Notifications,Yes,Yes,Yes,Yes,Yes
1 Free Starter Growth Pro Enterprise
2 MANAGE
3 Participants 50 300 500 1000 Unlimited
4 Event Website Event Page Event microsite Event microsite Custom domain event microsite Custom Website
5 Customise event website No No Yes Yes Yes
6 Custom Domain No No Yes Yes Yes
7 Invites +RSVP Yes Yes Yes Yes Yes
8 Registration forms with Custom fields Yes Yes Yes Yes Yes
9 Custom Registration workflow No No No Yes Yes
10 Email Confirmation & Reminders Yes Yes Yes Yes Yes
11 Email outreach campaigns Upto 500 emails Upto 5000 emails Upto 10K emails Upto 10K emails Unlimited
12 Custom Badge and print file creation Yes Yes Yes Yes Yes
13 WhatsApp confirmation & reminders No No Yes Yes Yes
14 Bring your WA number No No No Yes Yes
15 Custom template for WA messaging No No No Yes Yes
16 Agenda builder Yes Yes Yes Yes Yes
17 Multi track agenda No Yes Yes Yes Yes
18 Speaker management No Yes Yes Yes Yes
19 Abstract management No No No Yes Yes
20 Ticketing free & paid Yes Yes Yes Yes Yes
21 Collect Payment using your UPI QR Add on Yes Yes Yes Yes
22 Payment gateway integration No No Yes Yes Yes
23
24 ENGAGE
25 QnA with moderation Yes Yes Yes Yes Yes
26 Live Polls Yes Yes Yes Yes Yes
27 Multiple Quiz modes Yes Yes Yes Yes Yes
28 Word Cloud Yes Yes Yes Yes Yes
29 Custom Theme No No No Yes Yes
30 AI Summarization No No Yes Yes Yes
31 AI Engagement insights No No Yes Yes Yes
32 Content management No No Yes Yes Yes
33 Ratings for sessions and speakers Yes Yes Yes Yes Yes
34 Feedback forms & Surveys Yes Yes Yes Yes Yes
35 Gamification No No Yes Yes Yes
36 Push Notifications Yes Yes Yes Yes Yes

View File

@ -0,0 +1,40 @@
/**
* Loads subscription feature definitions from backend/src/config/permissions/*.json
* (one file per feature). Used by subscriptionCatalog and featureAccess.
*/
const fs = require('fs');
const path = require('path');
const PERMISSIONS_DIR = path.join(__dirname, 'permissions');
/**
* @returns {{ FEATURES: Record<string, { key: string, label: string, description: string, category: string }>, ORDERED_KEYS: string[] }}
*/
function loadPermissionFeatures() {
if (!fs.existsSync(PERMISSIONS_DIR)) {
return { FEATURES: {}, ORDERED_KEYS: [] };
}
const files = fs.readdirSync(PERMISSIONS_DIR).filter((f) => f.endsWith('.json')).sort();
const FEATURES = {};
const ORDERED_KEYS = [];
for (const file of files) {
const raw = JSON.parse(fs.readFileSync(path.join(PERMISSIONS_DIR, file), 'utf8'));
if (!raw.key || !raw.label) continue;
if (FEATURES[raw.key]) {
throw new Error(`Duplicate permission key "${raw.key}" in ${file}`);
}
const entry = {
key: raw.key,
label: raw.label,
description: raw.description || '',
category: raw.category || 'general',
};
if (raw.configKind) entry.configKind = raw.configKind;
if (Array.isArray(raw.options)) entry.options = raw.options;
FEATURES[raw.key] = entry;
ORDERED_KEYS.push(raw.key);
}
return { FEATURES, ORDERED_KEYS };
}
module.exports = { loadPermissionFeatures, PERMISSIONS_DIR };

View File

@ -0,0 +1,6 @@
{
"key": "abstract_management",
"label": "Abstract management",
"description": "",
"category": "agenda"
}

View File

@ -0,0 +1,6 @@
{
"key": "agenda_builder",
"label": "Agenda builder",
"description": "",
"category": "agenda"
}

View File

@ -0,0 +1,6 @@
{
"key": "ai_engagement_insights",
"label": "AI Engagement insights",
"description": "",
"category": "ai"
}

View File

@ -0,0 +1,6 @@
{
"key": "ai_summarization",
"label": "AI Summarization",
"description": "",
"category": "ai"
}

View File

@ -0,0 +1,6 @@
{
"key": "bring_your_wa_number",
"label": "Bring your WA number",
"description": "",
"category": "comms"
}

View File

@ -0,0 +1,6 @@
{
"key": "collect_payment_using_your_upi_qr",
"label": "Collect Payment using your UPI QR",
"description": "",
"category": "payments"
}

View File

@ -0,0 +1,6 @@
{
"key": "content_management",
"label": "Content management",
"description": "",
"category": "content"
}

View File

@ -0,0 +1,6 @@
{
"key": "custom_badge_and_print_file_creation",
"label": "Custom Badge and print file creation",
"description": "",
"category": "design"
}

View File

@ -0,0 +1,6 @@
{
"key": "custom_domain",
"label": "Custom Domain",
"description": "",
"category": "website"
}

View File

@ -0,0 +1,6 @@
{
"key": "custom_registration_workflow",
"label": "Custom Registration workflow",
"description": "",
"category": "registration"
}

View File

@ -0,0 +1,6 @@
{
"key": "custom_template_for_wa_messaging",
"label": "Custom template for WA messaging",
"description": "",
"category": "comms"
}

View File

@ -0,0 +1,6 @@
{
"key": "custom_theme",
"label": "Custom Theme",
"description": "",
"category": "branding"
}

View File

@ -0,0 +1,6 @@
{
"key": "customise_event_website",
"label": "Customise event website",
"description": "",
"category": "website"
}

View File

@ -0,0 +1,6 @@
{
"key": "email_confirmation_reminders",
"label": "Email Confirmation & Reminders",
"description": "",
"category": "comms"
}

View File

@ -0,0 +1,7 @@
{
"key": "email_outreach_campaigns",
"label": "Email outreach campaigns",
"description": "Maximum outreach emails (CSV: Upto N / Unlimited). null = unlimited.",
"category": "comms",
"configKind": "number"
}

View File

@ -0,0 +1,13 @@
{
"key": "event_website",
"label": "Event Website",
"description": "Tier from CSV (Event Page → Custom Website).",
"category": "website",
"configKind": "select",
"options": [
{ "value": "event_page", "label": "Event Page" },
{ "value": "event_microsite", "label": "Event microsite" },
{ "value": "custom_domain_microsite", "label": "Custom domain event microsite" },
{ "value": "custom_website", "label": "Custom Website" }
]
}

View File

@ -0,0 +1,6 @@
{
"key": "feedback_forms_and_surveys",
"label": "Feedback forms & Surveys",
"description": "",
"category": "feedback"
}

View File

@ -0,0 +1,6 @@
{
"key": "gamification",
"label": "Gamification",
"description": "",
"category": "engagement"
}

View File

@ -0,0 +1,6 @@
{
"key": "invites_rsvp",
"label": "Invites +RSVP",
"description": "",
"category": "registration"
}

View File

@ -0,0 +1,6 @@
{
"key": "live_polls",
"label": "Live Polls",
"description": "",
"category": "engagement"
}

View File

@ -0,0 +1,6 @@
{
"key": "multi_track_agenda",
"label": "Multi track agenda",
"description": "",
"category": "agenda"
}

View File

@ -0,0 +1,6 @@
{
"key": "multiple_quiz_modes",
"label": "Multiple Quiz modes",
"description": "",
"category": "engagement"
}

View File

@ -0,0 +1,7 @@
{
"key": "participants",
"label": "Participants",
"description": "Max participants per event (CSV cap). Edited via plan participant limit.",
"category": "portfolio",
"configKind": "participants_limit"
}

View File

@ -0,0 +1,6 @@
{
"key": "payment_gateway_integration",
"label": "Payment gateway integration",
"description": "",
"category": "payments"
}

View File

@ -0,0 +1,6 @@
{
"key": "push_notifications",
"label": "Push Notifications",
"description": "",
"category": "comms"
}

View File

@ -0,0 +1,6 @@
{
"key": "qna_with_moderation",
"label": "QnA with moderation",
"description": "",
"category": "engagement"
}

View File

@ -0,0 +1,6 @@
{
"key": "ratings_for_sessions_and_speakers",
"label": "Ratings for sessions and speakers",
"description": "",
"category": "feedback"
}

View File

@ -0,0 +1,6 @@
{
"key": "registration_forms_with_custom_fields",
"label": "Registration forms with Custom fields",
"description": "",
"category": "registration"
}

View File

@ -0,0 +1,6 @@
{
"key": "speaker_management",
"label": "Speaker management",
"description": "",
"category": "agenda"
}

View File

@ -0,0 +1,6 @@
{
"key": "ticketing_free_and_paid",
"label": "Ticketing free & paid",
"description": "",
"category": "payments"
}

View File

@ -0,0 +1,6 @@
{
"key": "whatsapp_confirmation_reminders",
"label": "WhatsApp confirmation & reminders",
"description": "",
"category": "comms"
}

View File

@ -0,0 +1,6 @@
{
"key": "word_cloud",
"label": "Word Cloud",
"description": "",
"category": "engagement"
}

View File

@ -0,0 +1,50 @@
/**
* Feature order for subscription plans (Manage & Engage). Keys match permissions/*.json.
* Used for catalog ordering and Super Admin plan editor grouping.
*/
const MANAGE_KEYS = [
'participants',
'event_website',
'customise_event_website',
'custom_domain',
'invites_rsvp',
'registration_forms_with_custom_fields',
'custom_registration_workflow',
'email_confirmation_reminders',
'email_outreach_campaigns',
'custom_badge_and_print_file_creation',
'whatsapp_confirmation_reminders',
'bring_your_wa_number',
'custom_template_for_wa_messaging',
'agenda_builder',
'multi_track_agenda',
'speaker_management',
'abstract_management',
'ticketing_free_and_paid',
'collect_payment_using_your_upi_qr',
'payment_gateway_integration',
];
const ENGAGE_KEYS = [
'qna_with_moderation',
'live_polls',
'multiple_quiz_modes',
'word_cloud',
'custom_theme',
'ai_summarization',
'ai_engagement_insights',
'content_management',
'ratings_for_sessions_and_speakers',
'feedback_forms_and_surveys',
'gamification',
'push_notifications',
];
const COMBINED_PLAN_KEYS = [...MANAGE_KEYS, ...ENGAGE_KEYS];
module.exports = {
MANAGE_KEYS,
ENGAGE_KEYS,
COMBINED_PLAN_KEYS,
};

View File

@ -0,0 +1,127 @@
/**
* Feature inclusion per plan — synced with eventbuzz_plans.csv (Manage / Engage rows).
* Source CSV columns: Free, Starter, Growth, Pro, Enterprise.
*
* Rules: cell "No" (or empty where the row is a yes/no row) → feature excluded for that tier.
* "Yes", tier text, "Add on", "Upto …", numeric caps → feature included.
* Value-only rows (Participants, Event Website, Email outreach) are always included when the tier exists;
* their values live in `defaultFeatureValuesByPlanId` + `defaultLimits.maxParticipants`.
*
* Tier key counts: free 17, starter 19, growth 27, pro/enterprise 32 (all permission keys).
*/
const { COMBINED_PLAN_KEYS } = require('./planFeatureGroups');
/** Keys not included in the Free tier (CSV column “Free” = No / empty). */
const FREE_EXCLUDE = new Set([
'customise_event_website',
'custom_domain',
'custom_registration_workflow',
'whatsapp_confirmation_reminders',
'bring_your_wa_number',
'custom_template_for_wa_messaging',
'multi_track_agenda',
'speaker_management',
'abstract_management',
'payment_gateway_integration',
'custom_theme',
'ai_summarization',
'ai_engagement_insights',
'content_management',
'gamification',
]);
/** Keys not included in Starter (CSV column “Starter”). */
const STARTER_EXCLUDE = new Set([
'customise_event_website',
'custom_domain',
'custom_registration_workflow',
'whatsapp_confirmation_reminders',
'bring_your_wa_number',
'custom_template_for_wa_messaging',
'abstract_management',
'payment_gateway_integration',
'custom_theme',
'ai_summarization',
'ai_engagement_insights',
'content_management',
'gamification',
]);
/** Keys not included in Growth (CSV column “Growth”) — Pro adds the remainder. */
const GROWTH_EXCLUDE = new Set([
'custom_registration_workflow',
'abstract_management',
'bring_your_wa_number',
'custom_template_for_wa_messaging',
'custom_theme',
]);
/** Event website tier per CSV row “Event Website”. Values stored on plans as `featureValues.event_website`. */
const EVENT_WEBSITE_DEFAULT = {
free: 'event_page',
starter: 'event_microsite',
growth: 'event_microsite',
pro: 'custom_domain_microsite',
enterprise: 'custom_website',
};
/** Email outreach caps per “Email outreach campaigns” row; null = unlimited. */
const EMAIL_OUTREACH_MAX_DEFAULT = {
free: 500,
starter: 5000,
growth: 10000,
pro: 10000,
enterprise: null,
};
function allKeysOrdered(FEATURES) {
return COMBINED_PLAN_KEYS.filter((k) => FEATURES[k]);
}
function buildProEnterpriseKeys(FEATURES) {
return allKeysOrdered(FEATURES);
}
/**
* Default structured values for features that are not simple on/off (CSV-driven).
* `participants` uses `defaultLimits.maxParticipants` elsewhere; this object holds the rest.
*
* @param {string} planId
*/
function defaultFeatureValuesByPlanId(planId) {
const ew =
EVENT_WEBSITE_DEFAULT[planId] != null ? EVENT_WEBSITE_DEFAULT[planId] : EVENT_WEBSITE_DEFAULT.free;
const maxEmails =
Object.prototype.hasOwnProperty.call(EMAIL_OUTREACH_MAX_DEFAULT, planId) ?
EMAIL_OUTREACH_MAX_DEFAULT[planId]
: EMAIL_OUTREACH_MAX_DEFAULT.free;
return {
event_website: ew,
email_outreach_campaigns: { maxEmails },
};
}
/**
* @param {Record<string, unknown>} FEATURES - loaded feature map
*/
function defaultFeatureKeysByPlanId(FEATURES) {
const proEnterprise = buildProEnterpriseKeys(FEATURES);
return {
free: COMBINED_PLAN_KEYS.filter((k) => FEATURES[k] && !FREE_EXCLUDE.has(k)),
starter: COMBINED_PLAN_KEYS.filter((k) => FEATURES[k] && !STARTER_EXCLUDE.has(k)),
growth: COMBINED_PLAN_KEYS.filter((k) => FEATURES[k] && !GROWTH_EXCLUDE.has(k)),
pro: [...proEnterprise],
enterprise: [...proEnterprise],
};
}
module.exports = {
FREE_EXCLUDE,
STARTER_EXCLUDE,
GROWTH_EXCLUDE,
EVENT_WEBSITE_DEFAULT,
EMAIL_OUTREACH_MAX_DEFAULT,
defaultFeatureKeysByPlanId,
defaultFeatureValuesByPlanId,
buildProEnterpriseKeys,
};

View File

@ -0,0 +1,93 @@
/**
* BullMQ / ioredis connection for concierge queues.
* DigitalOcean Managed Redis (port 25061) requires TLS — use rediss:// or REDIS_TLS=true.
*/
function parseRedisUrl(raw) {
const url = String(raw || 'redis://127.0.0.1:6379').trim();
try {
return new URL(url);
} catch {
return null;
}
}
function shouldUseTls(parsed) {
if (!parsed) return false;
const force =
String(process.env.REDIS_TLS || '').trim().toLowerCase() === '1' ||
String(process.env.REDIS_TLS || '').trim().toLowerCase() === 'true';
if (force) return true;
if (parsed.protocol === 'rediss:') return true;
if (parsed.port === '25061') return true;
if (parsed.hostname && parsed.hostname.includes('ondigitalocean.com')) return true;
return false;
}
/**
* Options for BullMQ `connection` (ioredis). maxRetriesPerRequest must be null for workers.
* @returns {import('ioredis').RedisOptions}
*/
function getBullMQConnection() {
const raw = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
const parsed = parseRedisUrl(raw);
const conn = {
maxRetriesPerRequest: null,
enableReadyCheck: true,
retryStrategy(times) {
return Math.min(times * 300, 8000);
},
};
if (!parsed) {
return { ...conn, host: '127.0.0.1', port: 6379 };
}
Object.assign(conn, {
host: parsed.hostname,
port: Number(parsed.port) || 6379,
username: parsed.username || undefined,
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
});
if (parsed.pathname && parsed.pathname.length > 1) {
const db = Number(parsed.pathname.slice(1));
if (!Number.isNaN(db)) conn.db = db;
}
if (shouldUseTls(parsed)) {
conn.tls = {};
if (!parsed.port) conn.port = 25061;
}
return conn;
}
/**
* Verify Redis is reachable at startup (workers).
*/
async function verifyRedisConnection() {
const Redis = require('ioredis');
const opts = getBullMQConnection();
const client = new Redis(opts);
try {
await client.ping();
const tlsNote = opts.tls ? ' (TLS)' : '';
console.log(`[redis] connected: ${opts.host}:${opts.port}${tlsNote}`);
} catch (err) {
const hint = opts.tls
? ''
: '\n Hint: DigitalOcean Managed Redis needs TLS. Set REDIS_TLS=true or use rediss:// in REDIS_URL.';
throw new Error(`Redis connection failed: ${err.message}${hint}`);
} finally {
client.disconnect();
}
}
module.exports = {
getBullMQConnection,
verifyRedisConnection,
shouldUseTls,
parseRedisUrl,
};

View File

@ -1,59 +1,310 @@
/**
* Code-first subscription catalog. Plans reference feature keys from FEATURES.
* Align Organization.subscription.plan with PLAN ids (free | pro | enterprise).
* Subscription catalog: feature metadata from permissions/*.json; plans from MongoDB `SubscriptionPlan`.
* Feature tiers per plan: `planFeatureMatrix.js` (from eventbuzz_plans.csv). Manage/Engage order: `planFeatureGroups.js`.
*/
const FEATURES = {
basic_analytics: { key: 'basic_analytics', label: 'Basic analytics', description: 'Core event metrics' },
advanced_analytics: { key: 'advanced_analytics', label: 'Advanced analytics', description: 'Deep insights' },
custom_forms: { key: 'custom_forms', label: 'Custom forms', description: 'Advanced form builder' },
email_support: { key: 'email_support', label: 'Email support', description: 'Standard email support' },
priority_support: { key: 'priority_support', label: 'Priority support', description: 'Faster response' },
white_label: { key: 'white_label', label: 'White-label', description: 'Remove EventBuzz branding' },
api_access: { key: 'api_access', label: 'API access', description: 'REST API' },
dedicated_support: { key: 'dedicated_support', label: 'Dedicated support', description: 'Named CSM' },
content_library: { key: 'content_library', label: 'Content library', description: 'Portfolio media' },
const { loadPermissionFeatures } = require('./loadPermissionFeatures');
const { COMBINED_PLAN_KEYS } = require('./planFeatureGroups');
const { defaultFeatureKeysByPlanId, defaultFeatureValuesByPlanId } = require('./planFeatureMatrix');
const { FEATURES } = loadPermissionFeatures();
/** Canonical order (Manage → Engage); only keys present in loaded FEATURES are kept. */
const ALL_KEYS = COMBINED_PLAN_KEYS.filter((k) => FEATURES[k]);
const PLAN_FEATURE_KEYS = defaultFeatureKeysByPlanId(FEATURES);
/** Maps legacy catalog keys to canonical keys. */
const LEGACY_FEATURE_TO_CANONICAL = {
basic_analytics: 'live_polls',
advanced_analytics: 'ai_engagement_insights',
custom_forms: 'registration_forms_with_custom_fields',
email_support: 'email_confirmation_reminders',
priority_support: 'email_outreach_campaigns',
white_label: 'custom_theme',
api_access: 'payment_gateway_integration',
dedicated_support: 'speaker_management',
content_library: 'content_management',
};
const PLANS = {
function canonicalizeFeatureKey(k) {
if (typeof k !== 'string' || !k.trim()) return null;
const trimmed = k.trim();
if (FEATURES[trimmed]) return trimmed;
const mapped = LEGACY_FEATURE_TO_CANONICAL[trimmed];
if (mapped && FEATURES[mapped]) return mapped;
return null;
}
/**
* Default pricing for payment integration (amount = INR major units; Razorpay etc. can multiply by 100 for paise).
* Enterprise: contact sales, no fixed amount.
*/
const DEFAULT_PRICING = {
free: { amount: 0, currency: 'INR', interval: 'none', contactSales: false },
starter: { amount: 1499, currency: 'INR', interval: 'month', contactSales: false },
growth: { amount: 4999, currency: 'INR', interval: 'month', contactSales: false },
pro: { amount: 9999, currency: 'INR', interval: 'month', contactSales: false },
enterprise: { amount: null, currency: 'INR', interval: 'custom', contactSales: true },
};
/**
* Five default commercial tiers (planId slugs).
*/
const DEFAULT_PLANS = {
free: {
id: 'free',
name: 'Free',
tagline: '₹0\nforever · up to 50 participants/event',
featureKeys: PLAN_FEATURE_KEYS.free,
defaultLimits: { maxEvents: null, maxParticipants: 50 },
featureValues: defaultFeatureValuesByPlanId('free'),
pricing: DEFAULT_PRICING.free,
},
starter: {
id: 'starter',
name: 'Starter',
featureKeys: ['basic_analytics', 'email_support', 'content_library'],
defaultLimits: { maxEvents: 3, maxParticipants: 500 },
tagline: '₹1,499\nper month · up to 300 participants',
featureKeys: PLAN_FEATURE_KEYS.starter,
defaultLimits: { maxEvents: null, maxParticipants: 300 },
featureValues: defaultFeatureValuesByPlanId('starter'),
pricing: DEFAULT_PRICING.starter,
},
growth: {
id: 'growth',
name: 'Growth',
tagline: '₹4,999\nper month · up to 500 participants',
featureKeys: PLAN_FEATURE_KEYS.growth,
defaultLimits: { maxEvents: null, maxParticipants: 500 },
featureValues: defaultFeatureValuesByPlanId('growth'),
pricing: DEFAULT_PRICING.growth,
},
pro: {
id: 'pro',
name: 'Pro',
featureKeys: [
'basic_analytics',
'advanced_analytics',
'custom_forms',
'email_support',
'priority_support',
'content_library',
],
defaultLimits: { maxEvents: 20, maxParticipants: 5000 },
tagline: '₹9999\nper month · up to 1,000 participants',
featureKeys: PLAN_FEATURE_KEYS.pro,
defaultLimits: { maxEvents: null, maxParticipants: 1000 },
featureValues: defaultFeatureValuesByPlanId('pro'),
pricing: DEFAULT_PRICING.pro,
},
enterprise: {
id: 'enterprise',
name: 'Enterprise',
featureKeys: Object.keys(FEATURES),
tagline: 'Custom',
featureKeys: PLAN_FEATURE_KEYS.enterprise,
defaultLimits: { maxEvents: null, maxParticipants: null },
featureValues: defaultFeatureValuesByPlanId('enterprise'),
pricing: DEFAULT_PRICING.enterprise,
},
};
const DEFAULT_PLAN_ORDER = ['free', 'starter', 'growth', 'pro', 'enterprise'];
/** Merged DB + defaults; null until warmPlanCache() runs. */
let mergedPlansById = null;
function mergeFeatureValuesFromDoc(doc, def, planId) {
const fallback = def?.featureValues || defaultFeatureValuesByPlanId(planId);
const p = doc?.featureValues && typeof doc.featureValues === 'object' ? doc.featureValues : {};
const emIn = p.email_outreach_campaigns && typeof p.email_outreach_campaigns === 'object' ? p.email_outreach_campaigns : {};
return {
event_website:
p.event_website !== undefined ? String(p.event_website) : fallback.event_website,
email_outreach_campaigns: {
maxEmails:
emIn.maxEmails !== undefined ? emIn.maxEmails : fallback.email_outreach_campaigns.maxEmails,
},
};
}
function mergePricingFromDoc(doc, def) {
const d = def?.pricing || {
amount: null,
currency: 'INR',
interval: 'month',
contactSales: false,
};
const p = doc?.pricing && typeof doc.pricing === 'object' ? doc.pricing : {};
return {
amount: p.amount !== undefined ? p.amount : d.amount,
currency: p.currency != null ? String(p.currency) : d.currency,
interval: p.interval != null ? String(p.interval) : d.interval,
contactSales: typeof p.contactSales === 'boolean' ? p.contactSales : d.contactSales,
};
}
function buildMergedRow(planId, doc) {
const def = DEFAULT_PLANS[planId];
if (!doc) {
return def || null;
}
const base = def || {
id: planId,
name: doc.name || planId,
tagline: doc.tagline || '',
featureKeys: Array.isArray(doc.featureKeys) ? doc.featureKeys : [],
defaultLimits: { maxEvents: null, maxParticipants: null },
featureValues: defaultFeatureValuesByPlanId(planId),
pricing: {
amount: null,
currency: 'INR',
interval: 'month',
contactSales: false,
},
};
return {
id: planId,
name: doc.name != null && String(doc.name).trim() ? String(doc.name).trim() : base.name,
tagline:
doc.tagline != null && String(doc.tagline).trim() !== ''
? String(doc.tagline)
: base.tagline || '',
featureKeys:
Array.isArray(doc.featureKeys) && doc.featureKeys.length ? doc.featureKeys : base.featureKeys,
defaultLimits:
doc.defaultLimits && typeof doc.defaultLimits === 'object'
? {
maxEvents:
doc.defaultLimits.maxEvents !== undefined
? doc.defaultLimits.maxEvents
: base.defaultLimits.maxEvents,
maxParticipants:
doc.defaultLimits.maxParticipants !== undefined
? doc.defaultLimits.maxParticipants
: base.defaultLimits.maxParticipants,
}
: base.defaultLimits,
featureValues: mergeFeatureValuesFromDoc(doc, def, planId),
pricing: mergePricingFromDoc(doc, def || base),
};
}
/**
* Upsert each canonical plan; backfill tagline / pricing when missing (legacy DBs).
*/
async function upsertCanonicalPlans() {
const SubscriptionPlan = require('../models/SubscriptionPlan');
for (const planId of DEFAULT_PLAN_ORDER) {
const d = DEFAULT_PLANS[planId];
await SubscriptionPlan.findOneAndUpdate(
{ planId },
{
$setOnInsert: {
planId,
name: d.name,
tagline: d.tagline,
featureKeys: d.featureKeys,
defaultLimits: d.defaultLimits,
featureValues: d.featureValues,
pricing: d.pricing,
},
},
{ upsert: true },
);
}
for (const planId of DEFAULT_PLAN_ORDER) {
const d = DEFAULT_PLANS[planId];
const doc = await SubscriptionPlan.findOne({ planId }).lean();
if (!doc) continue;
const patch = {};
if (doc.tagline === undefined || doc.tagline === null || String(doc.tagline).trim() === '') {
patch.tagline = d.tagline;
patch.name = d.name;
patch.defaultLimits = d.defaultLimits;
}
if (!doc.pricing || typeof doc.pricing !== 'object') {
patch.pricing = d.pricing;
}
if (!doc.featureValues || typeof doc.featureValues !== 'object') {
patch.featureValues = d.featureValues;
}
if (Object.keys(patch).length) {
await SubscriptionPlan.updateOne({ planId }, { $set: patch });
}
}
}
async function warmPlanCache() {
const SubscriptionPlan = require('../models/SubscriptionPlan');
await upsertCanonicalPlans();
const rows = await SubscriptionPlan.find().lean();
mergedPlansById = {};
for (const row of rows) {
const planId = row.planId;
mergedPlansById[planId] = buildMergedRow(planId, row);
}
for (const planId of DEFAULT_PLAN_ORDER) {
if (!mergedPlansById[planId]) {
mergedPlansById[planId] = buildMergedRow(planId, null);
}
}
}
function invalidatePlanCache() {
mergedPlansById = null;
}
/**
* @param {string} planKey
*/
function getPlanForAccess(planKey) {
const key = typeof planKey === 'string' && planKey.trim() ? planKey.trim() : 'free';
if (mergedPlansById && mergedPlansById[key]) return mergedPlansById[key];
if (DEFAULT_PLANS[key]) return DEFAULT_PLANS[key];
if (mergedPlansById && mergedPlansById.free) return mergedPlansById.free;
return DEFAULT_PLANS.free;
}
function listFeatures() {
return Object.values(FEATURES);
return ALL_KEYS.map((k) => FEATURES[k]).filter(Boolean);
}
function listPlans() {
return Object.values(PLANS).map((p) => ({
id: p.id,
name: p.name,
featureKeys: p.featureKeys,
defaultLimits: p.defaultLimits,
features: p.featureKeys.map((k) => FEATURES[k]).filter(Boolean),
}));
async function listPlans() {
if (!mergedPlansById) await warmPlanCache();
const ids = sortPlanIds(Object.keys(mergedPlansById));
return ids.map((id, tierIndex) => {
const p = mergedPlansById[id];
const featureKeys = p.featureKeys;
return {
id: p.id,
name: p.name,
tagline: p.tagline || '',
/** Sort order for tier comparison (0 = lowest). Used to hide upgrades to lower tiers. */
tierIndex,
pricing: p.pricing || {
amount: null,
currency: 'INR',
interval: 'month',
contactSales: false,
},
featureKeys,
defaultLimits: p.defaultLimits,
featureValues: p.featureValues || defaultFeatureValuesByPlanId(id),
features: featureKeys.map((k) => FEATURES[k]).filter(Boolean),
};
});
}
module.exports = { FEATURES, PLANS, listFeatures, listPlans };
function sortPlanIds(ids) {
const set = new Set(ids);
const out = [];
for (const id of DEFAULT_PLAN_ORDER) {
if (set.has(id)) out.push(id);
}
const rest = [...set].filter((id) => !DEFAULT_PLAN_ORDER.includes(id)).sort();
return [...out, ...rest];
}
module.exports = {
FEATURES,
ORDERED_KEYS: ALL_KEYS,
DEFAULT_PLANS,
LEGACY_FEATURE_TO_CANONICAL,
canonicalizeFeatureKey,
listFeatures,
listPlans,
getPlanForAccess,
warmPlanCache,
invalidatePlanCache,
sortPlanIds,
};

View File

@ -0,0 +1,16 @@
/** Allowed `variableBindings` merge keys — must match campaignMerge paths. */
const ALLOWED_MERGE_KEYS = [
'contact.firstName',
'contact.lastName',
'contact.name',
'contact.email',
'contact.organization',
'contact.phone',
'event.name',
'event.startDate',
'event.endDate',
'event.venue',
'event.virtualLink',
];
module.exports = { ALLOWED_MERGE_KEYS };

View File

@ -0,0 +1,302 @@
const mongoose = require('mongoose');
const Attendance = require('../models/Attendance');
const AttendanceParticipant = require('../models/AttendanceParticipant');
const Event = require('../models/Event');
const Participant = require('../models/Participant');
const { getMembership } = require('../utils/participantMembership');
const { participantDisplayName } = require('../services/attendanceCheckIn.service');
const { success, error } = require('../utils/response');
const {
performCheckIn,
getAttendanceStats,
searchEventParticipantsForAttendance,
} = require('../services/attendanceCheckIn.service');
const { buildAttendanceCheckInsCsv } = require('../utils/csvAttendance');
async function assertEventAccess(eventId, user) {
const event = await Event.findById(eventId);
if (!event) return { error: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) };
if (
user.role !== 'super_admin' &&
event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()
) {
return { error: (h) => h.response(error('FORBIDDEN', 'Access denied')).code(403) };
}
return { event };
}
async function loadAttendanceForEvent(eventId, attendanceId) {
if (!mongoose.Types.ObjectId.isValid(String(attendanceId))) {
return { err: error('VALIDATION_ERROR', 'Invalid attendance id'), status: 400 };
}
const attendance = await Attendance.findOne({ _id: attendanceId, event: eventId }).lean();
if (!attendance) return { err: error('NOT_FOUND', 'Attendance session not found'), status: 404 };
return { attendance };
}
const list = async (request, h) => {
const { eventId } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const items = await Attendance.find({ event: eventId }).sort({ createdAt: -1 }).lean();
const withStats = await Promise.all(
items.map(async (item) => {
const stats = await getAttendanceStats(item._id);
return { ...item, ...stats };
})
);
return h.response(success({ attendances: withStats }));
};
const create = async (request, h) => {
const { eventId } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const { name, description, settings } = request.payload;
const publicToken = Attendance.generatePublicToken();
const item = await Attendance.create({
event: eventId,
organization: r.event.organization,
name,
description: description || '',
settings: {
allowDuplicateCheckIn: Boolean(settings?.allowDuplicateCheckIn),
publicLinkEnabled: settings?.publicLinkEnabled !== false,
},
publicToken,
status: 'active',
});
return h.response(success({ ...item.toObject(), uniqueParticipants: 0, totalCheckedIn: 0 }, 'Attendance created', 201)).code(201);
};
const get = async (request, h) => {
const { eventId, id } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const loaded = await loadAttendanceForEvent(eventId, id);
if (loaded.err) return h.response(loaded.err).code(loaded.status);
const stats = await getAttendanceStats(loaded.attendance._id);
return h.response(success({ ...loaded.attendance, ...stats }));
};
const update = async (request, h) => {
const { eventId, id } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const item = await Attendance.findOne({ _id: id, event: eventId });
if (!item) return h.response(error('NOT_FOUND', 'Attendance session not found')).code(404);
const { name, description, settings, status } = request.payload;
if (name !== undefined) item.name = name;
if (description !== undefined) item.description = description;
if (status !== undefined) item.status = status;
if (settings) {
item.settings = item.settings || {};
if (settings.allowDuplicateCheckIn !== undefined) {
item.settings.allowDuplicateCheckIn = Boolean(settings.allowDuplicateCheckIn);
}
if (settings.publicLinkEnabled !== undefined) {
item.settings.publicLinkEnabled = Boolean(settings.publicLinkEnabled);
}
}
await item.save();
const stats = await getAttendanceStats(item._id);
return h.response(success({ ...item.toObject(), ...stats }, 'Attendance updated'));
};
const remove = async (request, h) => {
const { eventId, id } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const item = await Attendance.findOne({ _id: id, event: eventId });
if (!item) return h.response(error('NOT_FOUND', 'Attendance session not found')).code(404);
item.status = 'archived';
await item.save();
return h.response(success(null, 'Attendance archived'));
};
const regenerateToken = async (request, h) => {
const { eventId, id } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const item = await Attendance.findOne({ _id: id, event: eventId });
if (!item) return h.response(error('NOT_FOUND', 'Attendance session not found')).code(404);
item.publicToken = Attendance.generatePublicToken();
await item.save();
return h.response(success({ publicToken: item.publicToken }, 'Public link token regenerated'));
};
const listCheckIns = async (request, h) => {
const { eventId, id } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const loaded = await loadAttendanceForEvent(eventId, id);
if (loaded.err) return h.response(loaded.err).code(loaded.status);
const { page = 1, limit = 20 } = request.query;
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
const skip = (pageNum - 1) * limitNum;
const filter = { attendance: loaded.attendance._id };
const total = await AttendanceParticipant.countDocuments(filter);
const rows = await AttendanceParticipant.find(filter)
.sort({ updatedAt: -1 })
.skip(skip)
.limit(limitNum)
.lean();
const checkIns = rows.map((row) => ({
_id: String(row._id),
participantId: String(row.participant),
firstName: row.firstName,
lastName: row.lastName,
email: row.email,
phone: row.phone,
checkIns: row.checkIns || [],
checkInCount: (row.checkIns || []).length,
lastCheckedInAt:
row.checkIns?.length > 0 ? row.checkIns[row.checkIns.length - 1].checkedInAt : null,
}));
return h.response(success({ checkIns, total, page: pageNum, limit: limitNum }));
};
const searchParticipants = async (request, h) => {
const { eventId, id } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const loaded = await loadAttendanceForEvent(eventId, id);
if (loaded.err) return h.response(loaded.err).code(loaded.status);
const { q } = request.query;
const hasQuery = Boolean(q && String(q).trim());
const data = await searchEventParticipantsForAttendance({
eventId,
organizationId: r.event.organization,
q,
attendanceId: loaded.attendance._id,
limit: hasQuery ? 50 : 5000,
maxCap: 5000,
});
return h.response(success(data));
};
const getParticipantPreview = async (request, h) => {
const { eventId, id, participantId } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const loaded = await loadAttendanceForEvent(eventId, id);
if (loaded.err) return h.response(loaded.err).code(loaded.status);
if (!mongoose.Types.ObjectId.isValid(String(participantId))) {
return h.response(error('VALIDATION_ERROR', 'Invalid participant id')).code(400);
}
const participant = await Participant.findById(participantId).lean();
if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404);
if (!getMembership(participant, eventId)) {
return h.response(error('BAD_REQUEST', 'Participant is not registered for this event')).code(400);
}
const ap = await AttendanceParticipant.findOne({
attendance: loaded.attendance._id,
participant: participantId,
})
.select('checkIns')
.lean();
const checkIns = ap?.checkIns || [];
const checkInCount = checkIns.length;
const lastCheckedInAt = checkInCount > 0 ? checkIns[checkInCount - 1].checkedInAt : null;
return h.response(
success({
_id: String(participant._id),
firstName: participant.firstName,
lastName: participant.lastName,
email: participant.email,
phone: participant.phone,
displayName: participantDisplayName(participant),
alreadyCheckedIn: checkInCount > 0,
checkInCount,
lastCheckedInAt,
allowDuplicateCheckIn: Boolean(loaded.attendance.settings?.allowDuplicateCheckIn),
})
);
};
const checkIn = async (request, h) => {
const { eventId, id } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const { participantId } = request.payload;
const outcome = await performCheckIn({
attendanceId: id,
eventId,
participantId,
source: 'dashboard',
checkedInBy: user._id?.toString() || user.id,
});
if (outcome.err) return h.response(outcome.err).code(outcome.statusCode || 400);
return h.response(success(outcome.result, 'Checked in'));
};
const exportCsv = async (request, h) => {
const { eventId, id } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.error) return r.error(h);
const loaded = await loadAttendanceForEvent(eventId, id);
if (loaded.err) return h.response(loaded.err).code(loaded.status);
const rows = await AttendanceParticipant.find({ attendance: loaded.attendance._id })
.sort({ updatedAt: -1 })
.lean();
const safeName = String(loaded.attendance.name || 'attendance')
.replace(/[^a-z0-9-_]+/gi, '-')
.slice(0, 40);
const csv = buildAttendanceCheckInsCsv(loaded.attendance.name, rows);
return h
.response(csv)
.type('text/csv; charset=utf-8')
.header('Content-Disposition', `attachment; filename="${safeName}-check-ins.csv"`)
.code(200);
};
module.exports = {
list,
create,
get,
update,
remove,
regenerateToken,
listCheckIns,
searchParticipants,
getParticipantPreview,
checkIn,
exportCsv,
};

View File

@ -16,6 +16,50 @@ const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'default-refresh-se
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m';
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '7d';
function resolveOrganizationName(g, { teamName, organizationName, firstName, lastName } = {}) {
const explicit = String(teamName || organizationName || '').trim();
if (explicit) return explicit;
const fn = String(firstName || g.firstName || '').trim();
const ln = String(lastName || g.lastName || '').trim();
const fullName = [fn, ln].filter(Boolean).join(' ');
if (fullName) return `${fullName}'s Organization`;
const local = String(g.email || '').split('@')[0] || '';
if (local) return `${local}'s Organization`;
return 'My Organization';
}
async function createGoogleUserAccount(g, opts = {}) {
const { teamName, firstName, lastName, phone, organizationName } = opts;
const existing = await User.findOne({
$or: [{ email: g.email }, { googleId: g.sub }],
});
if (existing) {
const err = new Error('Already registered');
err.code = 'ALREADY_REGISTERED';
throw err;
}
const org = await Organization.create({
name: resolveOrganizationName(g, { teamName, organizationName, firstName, lastName }),
});
const user = await User.create({
email: g.email,
googleId: g.sub,
firstName: firstName || g.firstName,
lastName: lastName || g.lastName,
phone: phone || undefined,
profileImage: g.picture || undefined,
organization: org._id,
role: 'super_admin',
});
await OrganizationUserMap.create({
user: user._id,
organization: org._id,
role: 'super_admin',
eventAccess: [],
});
return User.findById(user._id).select('-password').populate('organization');
}
const register = async (request, h) => {
try {
const { email, password, firstName, lastName, teamName } = request.payload;
@ -90,7 +134,20 @@ const googleLogin = async (request, h) => {
user = await User.findOne({ email: g.email }).populate('organization');
}
if (!user) {
return h.response(error('NOT_REGISTERED', 'No account for this Google user. Register first.')).code(400);
try {
user = await createGoogleUserAccount(g, {});
} catch (createErr) {
if (createErr.code === 'ALREADY_REGISTERED') {
user = await User.findOne({
$or: [{ email: g.email }, { googleId: g.sub }],
}).populate('organization');
} else {
throw createErr;
}
}
if (!user) {
return h.response(error('VALIDATION_ERROR', 'Could not create account')).code(400);
}
}
if (user.googleId && user.googleId !== g.sub) {
return h.response(error('ACCOUNT_MISMATCH', 'This email is linked to a different Google account.')).code(400);
@ -137,36 +194,23 @@ const googleRegister = async (request, h) => {
return h.response(error('UNAUTHORIZED', e.message || 'Invalid Google credential')).code(401);
}
const { teamName, firstName, lastName, phone } = request.payload;
const organizationName = teamName || request.payload.organizationName;
if (!organizationName || !String(organizationName).trim()) {
return h.response(error('VALIDATION_ERROR', 'Team Name is required')).code(400);
let populated;
try {
populated = await createGoogleUserAccount(g, {
teamName,
firstName,
lastName,
phone,
organizationName: request.payload.organizationName,
});
} catch (createErr) {
if (createErr.code === 'ALREADY_REGISTERED') {
return h.response(error('VALIDATION_ERROR', 'Already registered — sign in with Google')).code(400);
}
throw createErr;
}
const existing = await User.findOne({
$or: [{ email: g.email }, { googleId: g.sub }],
});
if (existing) {
return h.response(error('VALIDATION_ERROR', 'Already registered — sign in with Google')).code(400);
}
const org = await Organization.create({ name: String(organizationName).trim() });
const user = await User.create({
email: g.email,
googleId: g.sub,
firstName: firstName || g.firstName,
lastName: lastName || g.lastName,
phone: phone || undefined,
profileImage: g.picture || undefined,
organization: org._id,
role: 'super_admin',
});
await OrganizationUserMap.create({
user: user._id,
organization: org._id,
role: 'super_admin',
eventAccess: [],
});
const token = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
const refreshToken = jwt.sign({ id: user._id }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN });
const populated = await User.findById(user._id).select('-password').populate('organization');
const token = jwt.sign({ id: populated._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
const refreshToken = jwt.sign({ id: populated._id }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN });
return h.response(success({
user: populated,
token,

View File

@ -0,0 +1,182 @@
const mongoose = require('mongoose');
const BadgeBatch = require('../models/BadgeBatch');
const Event = require('../models/Event');
const Organization = require('../models/Organization');
const { success, error } = require('../utils/response');
const { assertFeature } = require('../services/featureAccess');
const MAX_BADGE_RECIPIENTS = parseInt(process.env.BADGE_BATCH_MAX_RECIPIENTS || '1500', 10);
function userOrgId(user) {
return user.organization?._id || user.organization;
}
function resolveOrganizationId(user, payloadOrg) {
if (user.role === 'super_admin' && payloadOrg && mongoose.Types.ObjectId.isValid(String(payloadOrg))) {
return new mongoose.Types.ObjectId(String(payloadOrg));
}
const oid = userOrgId(user);
if (!oid) return null;
return oid instanceof mongoose.Types.ObjectId ? oid : new mongoose.Types.ObjectId(String(oid));
}
function canManageBadges(user) {
return user.role === 'super_admin' || user.role === 'admin' || user.role === 'moderator';
}
async function allowedEventIdsForOrg(user) {
const oid = userOrgId(user);
if (user.role === 'super_admin') {
const all = await Event.find({}).select('_id').lean();
return all.map((e) => e._id);
}
if (!oid) return [];
const evs = await Event.find({ organization: oid }).select('_id').lean();
return evs.map((e) => e._id);
}
const list = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = resolveOrganizationId(user, request.query.organization);
if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
const filter = { organization: orgId };
if (request.query.event && mongoose.Types.ObjectId.isValid(String(request.query.event))) {
filter.event = new mongoose.Types.ObjectId(String(request.query.event));
}
const { page = 1, limit = 20 } = request.query;
const lim = Math.min(parseInt(limit, 10) || 20, 100);
const skip = (Math.max(parseInt(page, 10) || 1, 1) - 1) * lim;
const [batches, total] = await Promise.all([
BadgeBatch.find(filter).sort({ createdAt: -1 }).skip(skip).limit(lim).populate('event', 'name').lean(),
BadgeBatch.countDocuments(filter),
]);
return h.response(success({ batches, total, page: parseInt(page, 10), limit: lim }));
};
const get = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = resolveOrganizationId(user, request.query.organization);
if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
const batch = await BadgeBatch.findById(request.params.id).populate('event', 'name').populate('createdBy', 'firstName lastName email');
if (!batch) return h.response(error('NOT_FOUND', 'Badge batch not found')).code(404);
if (batch.organization.toString() !== orgId.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
return h.response(success(batch));
};
const create = async (request, h) => {
const user = request.auth.credentials.user;
if (!canManageBadges(user)) {
return h.response(error('FORBIDDEN', 'Insufficient permissions to create badge batches')).code(403);
}
const orgId = resolveOrganizationId(user, request.payload.organization);
if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
const orgDoc = await Organization.findById(orgId).lean();
if (!orgDoc) return h.response(error('NOT_FOUND', 'Organization not found')).code(404);
if (!assertFeature(orgDoc, 'custom_badge_and_print_file_creation')) {
return h.response(error('FORBIDDEN', 'This feature is not included in your plan.')).code(403);
}
const p = request.payload;
if (!p.includeFaculty && !p.includeParticipants) {
return h.response(error('VALIDATION_ERROR', 'Select at least Faculty and/or Participants')).code(400);
}
if (p.includeParticipants && p.participantScope === 'participant_list' && !p.participantListId) {
return h.response(error('VALIDATION_ERROR', 'Select a contact list for participants')).code(400);
}
if (p.backgroundMode === 'image' && !String(p.backgroundImageUrl || '').trim()) {
return h.response(error('VALIDATION_ERROR', 'Background image URL is required when using image background')).code(400);
}
const event = await Event.findById(p.event).select('organization').lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (String(event.organization) !== orgId.toString()) {
return h.response(error('FORBIDDEN', 'Event does not belong to this organisation')).code(403);
}
const allowed = await allowedEventIdsForOrg(user);
if (!allowed.some((id) => String(id) === String(p.event))) {
return h.response(error('FORBIDDEN', 'You cannot create badge batches for this event')).code(403);
}
const { resolveBadgeRecipients } = require('../utils/badgeRecipients');
const prelim = await resolveBadgeRecipients(
{
includeFaculty: Boolean(p.includeFaculty),
includeParticipants: Boolean(p.includeParticipants),
participantScope: p.participantScope,
participantListId: p.participantListId,
},
p.event,
event.organization,
allowed,
);
if (prelim.error) {
return h.response(error(prelim.error.code, prelim.error.message)).code(400);
}
const count = (prelim.rows || []).length;
if (count > MAX_BADGE_RECIPIENTS) {
return h
.response(
error(
'VALIDATION_ERROR',
`Too many recipients (${count}). Maximum per batch is ${MAX_BADGE_RECIPIENTS}. Narrow your lists or contact support.`,
),
)
.code(400);
}
if (count === 0) {
return h.response(error('VALIDATION_ERROR', 'No recipients match your selection for this event')).code(400);
}
const listOid =
p.participantListId && mongoose.Types.ObjectId.isValid(String(p.participantListId))
? new mongoose.Types.ObjectId(String(p.participantListId))
: undefined;
const config = {
sizePreset: p.sizePreset,
widthMm: p.widthMm,
heightMm: p.heightMm,
orientation: p.orientation,
includeFaculty: Boolean(p.includeFaculty),
includeParticipants: Boolean(p.includeParticipants),
participantScope: p.participantScope,
participantListId: listOid,
backgroundMode: p.backgroundMode,
backgroundImageUrl: String(p.backgroundImageUrl || '').trim(),
backgroundColor: p.backgroundColor || '#0B4DD1',
fields: Array.isArray(p.fields) ? p.fields : [],
outputFormat: p.outputFormat,
badgeTypeLabel: p.badgeTypeLabel || 'Attendee',
};
const batch = await BadgeBatch.create({
organization: orgId,
event: p.event,
createdBy: user._id,
name: p.name.trim(),
status: 'pending',
config,
});
return h
.response(
success(
batch,
'We have received your request. Badge generation runs in the background; refresh this page in a few minutes to download the zip.',
201,
),
)
.code(201);
};
module.exports = { list, get, create };

View File

@ -1,27 +1,64 @@
const mongoose = require('mongoose');
const MessagingCampaign = require('../models/MessagingCampaign');
const EmailQueue = require('../models/EmailQueue');
const WhatsAppQueue = require('../models/WhatsAppQueue');
const Event = require('../models/Event');
const DesignTemplate = require('../models/DesignTemplate');
const WhatsAppTemplate = require('../models/WhatsAppTemplate');
const Participant = require('../models/Participant');
const { success, error } = require('../utils/response');
const { mergeCampaignContent } = require('../utils/campaignMerge');
const { resolveRecipientsForCampaign, resolveRecipientsForWhatsAppCampaign } = require('../utils/campaignRecipients');
const { assertWhatsAppCampaignContentReady, buildWhatsAppTemplatePayload } = require('../utils/waTemplateSendParams');
const { getOrgWhatsAppCredentials, assertPhoneNumberOnWaba } = require('../utils/metaWhatsApp');
const { normalizeWhatsAppTo } = require('../utils/phone');
const { getCampaignContentObject } = require('../utils/campaignContent');
async function getEventForUser(eventId, user) {
const event = await Event.findById(eventId).lean();
if (!event) return { error: { code: 'NOT_FOUND', message: 'Event not found', status: 404 } };
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return { error: { code: 'FORBIDDEN', message: 'Access denied', status: 403 } };
}
return { event };
}
async function allowedEventIdsForOrg(user) {
const oid = user.organization?._id || user.organization;
if (user.role === 'super_admin') {
const all = await Event.find({}).select('_id').lean();
return all.map((e) => e._id);
}
if (!oid) return [];
const evs = await Event.find({ organization: oid }).select('_id').lean();
return evs.map((e) => e._id);
}
const list = async (request, h) => {
const { eventId } = request.params;
const event = await Event.findById(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const { error: err } = await getEventForUser(eventId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const campaigns = await MessagingCampaign.find({ event: eventId }).populate('createdBy').sort({ createdAt: -1 }).lean();
return h.response(success({ campaigns }));
};
/** GET /api/campaigns?channel=email */
const listByOrg = async (request, h) => {
const user = request.auth.credentials.user;
const channel = request.query.channel;
const ids = await allowedEventIdsForOrg(user);
if (!ids.length) return h.response(success({ campaigns: [] }));
const q = { event: { $in: ids } };
if (channel) q.channel = channel;
const campaigns = await MessagingCampaign.find(q).populate('event', 'name startDate').populate('createdBy').sort({ createdAt: -1 }).lean();
return h.response(success({ campaigns }));
};
const create = async (request, h) => {
const { eventId } = request.params;
const event = await Event.findById(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const { error: err } = await getEventForUser(eventId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const campaign = await MessagingCampaign.create({
...request.payload,
event: eventId,
@ -33,26 +70,33 @@ const create = async (request, h) => {
const get = async (request, h) => {
const campaign = await MessagingCampaign.findById(request.params.id).populate('event createdBy');
if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404);
const event = await Event.findById(campaign.event);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
return h.response(success(campaign));
};
const update = async (request, h) => {
const campaign = await MessagingCampaign.findById(request.params.id);
if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404);
const event = await Event.findById(campaign.event);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
if (campaign.status === 'stopped') {
return h.response(error('VALIDATION_ERROR', 'Cannot update a stopped campaign')).code(400);
}
if (campaign.status === 'sent') {
return h.response(error('VALIDATION_ERROR', 'Cannot update sent campaign')).code(400);
if (campaign.status !== 'draft') {
return h.response(error('VALIDATION_ERROR', 'Only draft campaigns can be edited')).code(400);
}
const p = request.payload;
if (p.name != null) campaign.name = p.name;
if (p.channel != null) campaign.channel = p.channel;
if (p.type != null) campaign.type = p.type;
if (p.triggerEvent != null) campaign.triggerEvent = p.triggerEvent;
if (p.recipients != null) campaign.recipients = p.recipients;
if (p.content != null) {
const cur = campaign.content && typeof campaign.content.toObject === 'function' ? campaign.content.toObject() : campaign.content || {};
campaign.content = { ...cur, ...p.content };
campaign.markModified('content');
}
Object.assign(campaign, request.payload);
await campaign.save();
return h.response(success(campaign));
};
@ -60,34 +104,287 @@ const update = async (request, h) => {
const remove = async (request, h) => {
const campaign = await MessagingCampaign.findById(request.params.id);
if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404);
const event = await Event.findById(campaign.event);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
if (!['draft', 'stopped'].includes(campaign.status)) {
return h.response(error('VALIDATION_ERROR', 'Delete only allowed for draft or stopped campaigns')).code(400);
}
await EmailQueue.deleteMany({ campaign: campaign._id });
await WhatsAppQueue.deleteMany({ campaign: campaign._id });
await campaign.deleteOne();
return h.response(success(null, 'Campaign deleted')).code(200);
};
async function getTemplateBodies(campaign) {
let subject = campaign.content?.subject || '';
let body = campaign.content?.body || '';
const tplId = campaign.content?.template;
if (tplId && mongoose.Types.ObjectId.isValid(String(tplId))) {
const tpl = await DesignTemplate.findById(tplId).lean();
if (tpl?.content != null) {
const c = tpl.content;
if (typeof c === 'string') body = body || c;
else if (c && typeof c.html === 'string') body = body || c.html;
if (c && typeof c === 'object') {
const subjTpl = String(c.subjectLine || c.subject || '').trim();
if (subjTpl) subject = subject || subjTpl;
}
}
}
return { subject, body };
}
/** When using a design template only, subject may be omitted; merge still needs a line. */
function resolveSubjectForSend(campaignDoc, subjTpl) {
const s = String(subjTpl || '').trim();
if (s) return s;
const n = campaignDoc.name && String(campaignDoc.name).trim();
if (n) return n;
return 'News from {{event.name}}';
}
async function enqueueCampaignEmails(campaignDoc, processAfter) {
const eventDoc = await Event.findById(campaignDoc.event).lean();
if (!eventDoc) return { error: { code: 'NOT_FOUND', message: 'Event not found' } };
const idsForList = await Event.find({ organization: eventDoc.organization }).select('_id').lean().then((r) => r.map((x) => x._id));
const tenantOrg = eventDoc.organization;
const resolved = await resolveRecipientsForCampaign(campaignDoc, tenantOrg, idsForList);
if (resolved.error) return resolved;
const rows = resolved.rows || [];
const { subject: subjTpl, body: bodyTpl } = await getTemplateBodies(campaignDoc);
if (!String(bodyTpl).trim()) {
return { error: { code: 'VALIDATION_ERROR', message: 'Message body is required (add content or pick a design template with HTML)' } };
}
const subjResolved = resolveSubjectForSend(campaignDoc, subjTpl);
const seen = new Set();
const docs = [];
for (const { participant, flat } of rows) {
const email = String(flat.email || '').trim().toLowerCase();
if (!email || seen.has(email)) continue;
seen.add(email);
const { subject, body } = mergeCampaignContent(subjResolved, bodyTpl, flat, eventDoc);
docs.push({
campaign: campaignDoc._id,
event: campaignDoc.event,
organization: eventDoc.organization,
participant: participant._id,
to: email,
subject,
body,
status: 'pending',
processAfter: processAfter || new Date(),
});
}
if (!docs.length) {
return { error: { code: 'VALIDATION_ERROR', message: 'No recipients with email addresses' } };
}
await EmailQueue.insertMany(docs);
return { count: docs.length };
}
/**
* @param {import('mongoose').Document} campaignDoc
* @param {Date} [processAfter]
*/
async function enqueueWhatsAppSends(campaignDoc, processAfter) {
const eventDoc = await Event.findById(campaignDoc.event).lean();
if (!eventDoc) return { error: { code: 'NOT_FOUND', message: 'Event not found' } };
const idsForList = await Event.find({ organization: eventDoc.organization })
.select('_id')
.lean()
.then((r) => r.map((x) => x._id));
const content = getCampaignContentObject(campaignDoc);
const tplId = content.whatsappTemplateId;
if (!tplId || !mongoose.Types.ObjectId.isValid(String(tplId))) {
return { error: { code: 'VALIDATION_ERROR', message: 'Select a WhatsApp template (content.whatsappTemplateId).' } };
}
const tpl = await WhatsAppTemplate.findById(tplId).lean();
if (!tpl) return { error: { code: 'NOT_FOUND', message: 'WhatsApp template not found' } };
if (String(tpl.organization) !== String(eventDoc.organization)) {
return { error: { code: 'FORBIDDEN', message: 'Template belongs to another organisation' } };
}
const ready = assertWhatsAppCampaignContentReady(tpl, content);
if (!ready.ok) {
return { error: { code: 'VALIDATION_ERROR', message: ready.message } };
}
const creds = await getOrgWhatsAppCredentials(String(eventDoc.organization));
if (!creds || !creds.accessToken) {
return { error: { code: 'VALIDATION_ERROR', message: 'WhatsApp is not configured (access token).' } };
}
if (!String(creds.phoneNumberId || '').trim()) {
return {
error: {
code: 'VALIDATION_ERROR',
message: 'Set WhatsApp phone number ID in Settings or META_WHATSAPP_DEFAULT_PHONE_NUMBER_ID.',
},
};
}
const phoneChk = await assertPhoneNumberOnWaba(creds.wabaId, creds.phoneNumberId, creds.accessToken);
if (!phoneChk.ok) {
return { error: { code: 'VALIDATION_ERROR', message: phoneChk.message } };
}
const tenantOrg = eventDoc.organization;
const resolved = await resolveRecipientsForWhatsAppCampaign(campaignDoc, tenantOrg, idsForList);
if (resolved.error) return resolved;
const rows = resolved.rows || [];
if (!rows.length) {
return { error: { code: 'VALIDATION_ERROR', message: 'No recipients with WhatsApp phone numbers' } };
}
const when = processAfter || new Date();
const docs = rows.map(({ participant, flat }) => ({
campaign: campaignDoc._id,
event: campaignDoc.event,
organization: eventDoc.organization,
participant: participant._id,
to: normalizeWhatsAppTo(flat.phone),
status: 'pending',
processAfter: when,
}));
if (rows[0]) {
try {
buildWhatsAppTemplatePayload(tpl, content, rows[0].flat, eventDoc);
} catch (e) {
return { error: { code: 'VALIDATION_ERROR', message: e.message || 'Invalid template or merge for send' } };
}
}
await WhatsAppQueue.insertMany(docs);
return { count: docs.length };
}
/**
* Apply optional name/recipients/content to a draft (same rules as PUT) before send.
* @param {import('mongoose').Document} campaign
* @param {object} p
*/
async function applyDraftPatchIfPresent(campaign, p) {
if (!p || typeof p !== 'object') return { ok: true };
const has =
(p.name !== undefined && p.name !== null) ||
p.recipients !== undefined ||
p.content !== undefined;
if (!has) return { ok: true };
if (campaign.status !== 'draft') {
return {
ok: false,
message:
'Cannot update campaign fields in the same request as Send unless the campaign is a draft. Save changes first or cancel the schedule.',
};
}
if (p.name != null) campaign.name = p.name;
if (p.recipients != null) campaign.recipients = p.recipients;
if (p.content != null) {
const cur =
campaign.content && typeof campaign.content.toObject === 'function'
? campaign.content.toObject()
: campaign.content || {};
campaign.content = { ...cur, ...p.content };
campaign.markModified('content');
}
await campaign.save();
return { ok: true };
}
const send = async (request, h) => {
const campaign = await MessagingCampaign.findById(request.params.id);
if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404);
campaign.status = 'sending';
await campaign.save();
// TODO: queue actual send (nodemailer / WhatsApp API)
campaign.status = 'sent';
campaign.sentAt = new Date();
campaign.stats = campaign.stats || { total: 0, sent: 0, delivered: 0, opened: 0, clicked: 0, failed: 0 };
campaign.stats.sent = campaign.stats.total || 0;
await campaign.save();
return h.response(success(campaign, 'Campaign sent'));
const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
if (campaign.status === 'stopped') {
return h.response(error('VALIDATION_ERROR', 'Campaign is stopped')).code(400);
}
if (!['draft', 'scheduled'].includes(campaign.status)) {
return h.response(error('VALIDATION_ERROR', 'Campaign cannot be sent in current status')).code(400);
}
const patch = await applyDraftPatchIfPresent(campaign, request.payload);
if (!patch.ok) return h.response(error('VALIDATION_ERROR', patch.message)).code(400);
if (campaign.channel === 'email') {
await EmailQueue.deleteMany({ campaign: campaign._id, status: 'pending' });
const enq = await enqueueCampaignEmails(campaign, new Date());
if (enq.error) return h.response(error(enq.error.code, enq.error.message)).code(400);
campaign.status = 'sending';
campaign.scheduledFor = undefined;
campaign.stats = {
total: enq.count,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
failed: 0,
};
await campaign.save();
return h.response(success(campaign, 'Campaign queued for sending'));
}
if (campaign.channel === 'whatsapp') {
await WhatsAppQueue.deleteMany({ campaign: campaign._id, status: 'pending' });
const enq = await enqueueWhatsAppSends(campaign, new Date());
if (enq.error) return h.response(error(enq.error.code, enq.error.message)).code(400);
campaign.status = 'sending';
campaign.scheduledFor = undefined;
campaign.stats = {
total: enq.count,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
failed: 0,
};
await campaign.save();
return h.response(success(campaign, 'WhatsApp campaign queued for sending'));
}
return h.response(error('VALIDATION_ERROR', 'Unknown campaign channel')).code(400);
};
const schedule = async (request, h) => {
const campaign = await MessagingCampaign.findById(request.params.id);
if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404);
const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
if (campaign.status === 'stopped') {
return h.response(error('VALIDATION_ERROR', 'Campaign is stopped')).code(400);
}
if (!['draft'].includes(campaign.status)) {
return h.response(error('VALIDATION_ERROR', 'Only draft campaigns can be scheduled')).code(400);
}
if (campaign.channel !== 'email' && campaign.channel !== 'whatsapp') {
return h.response(error('VALIDATION_ERROR', 'Only email and WhatsApp campaigns can be scheduled')).code(400);
}
const scheduledFor = new Date(request.payload.scheduledFor);
if (Number.isNaN(scheduledFor.getTime()) || scheduledFor.getTime() < Date.now()) {
return h.response(error('VALIDATION_ERROR', 'scheduledFor must be a future date')).code(400);
}
let enq;
if (campaign.channel === 'email') {
await EmailQueue.deleteMany({ campaign: campaign._id, status: 'pending' });
enq = await enqueueCampaignEmails(campaign, scheduledFor);
} else {
await WhatsAppQueue.deleteMany({ campaign: campaign._id, status: 'pending' });
enq = await enqueueWhatsAppSends(campaign, scheduledFor);
}
if (enq.error) return h.response(error(enq.error.code, enq.error.message)).code(400);
campaign.status = 'scheduled';
campaign.scheduledFor = request.payload.scheduledFor;
campaign.scheduledFor = scheduledFor;
campaign.stats = {
total: enq.count,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
failed: 0,
};
await campaign.save();
return h.response(success(campaign, 'Campaign scheduled'));
};
@ -95,19 +392,107 @@ const schedule = async (request, h) => {
const cancel = async (request, h) => {
const campaign = await MessagingCampaign.findById(request.params.id);
if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404);
const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
if (campaign.status === 'stopped') {
return h.response(error('VALIDATION_ERROR', 'Stopped campaigns cannot be unscheduled this way')).code(400);
}
if (campaign.status !== 'scheduled') {
return h.response(error('VALIDATION_ERROR', 'Only scheduled campaigns can be cancelled')).code(400);
}
await EmailQueue.updateMany({ campaign: campaign._id, status: 'pending' }, { $set: { status: 'cancelled' } });
await WhatsAppQueue.updateMany({ campaign: campaign._id, status: 'pending' }, { $set: { status: 'cancelled' } });
campaign.status = 'draft';
campaign.scheduledFor = undefined;
campaign.stats = undefined;
await campaign.save();
return h.response(success(campaign, 'Campaign cancelled'));
};
const analytics = async (request, h) => {
const campaign = await MessagingCampaign.findById(request.params.id).select('stats name status sentAt');
const stop = async (request, h) => {
const campaign = await MessagingCampaign.findById(request.params.id);
if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404);
return h.response(success(campaign));
const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
if (campaign.status === 'stopped') {
return h.response(success(campaign, 'Already stopped'));
}
campaign.status = 'stopped';
campaign.stoppedAt = new Date();
campaign.scheduledFor = undefined;
await campaign.save();
await EmailQueue.updateMany(
{ campaign: campaign._id, status: { $in: ['pending', 'processing'] } },
{ $set: { status: 'cancelled' } }
);
await WhatsAppQueue.updateMany(
{ campaign: campaign._id, status: { $in: ['pending', 'processing'] } },
{ $set: { status: 'cancelled' } }
);
return h.response(success(campaign, 'Campaign stopped'));
};
module.exports = { list, create, get, update, remove, send, schedule, cancel, analytics };
const analytics = async (request, h) => {
const campaign = await MessagingCampaign.findById(request.params.id)
.select('stats name status sentAt stoppedAt event channel')
.lean();
if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404);
const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const out = { ...campaign };
if (campaign.channel === 'whatsapp') {
out.whatsappQueue = await WhatsAppQueue.find({ campaign: campaign._id })
.select('to status whatsappMessageId recipientWaId sentAt lastError')
.sort({ createdAt: 1 })
.lean();
}
return h.response(success(out));
};
const preview = async (request, h) => {
const campaign = await MessagingCampaign.findById(request.params.id).lean();
if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404);
if (campaign.channel === 'whatsapp') {
return h.response(error('VALIDATION_ERROR', 'Preview is not available for WhatsApp campaigns')).code(400);
}
const user = request.auth.credentials.user;
const { error: err } = await getEventForUser(campaign.event, user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const eventDoc = await Event.findById(campaign.event).lean();
const participantId = request.payload && request.payload.participantId;
let flat = {
firstName: 'Jane',
lastName: 'Participant',
email: 'preview@example.com',
organization: 'Example Co',
phone: '',
};
if (participantId && mongoose.Types.ObjectId.isValid(String(participantId))) {
const p = await Participant.findById(participantId).lean();
if (p) {
const { flattenParticipantForEvent } = require('../utils/participantMembership');
const f = flattenParticipantForEvent(p, campaign.event);
if (f) flat = f;
}
}
const { subject: subjTpl, body: bodyTpl } = await getTemplateBodies(campaign);
const subjResolved = resolveSubjectForSend(campaign, subjTpl);
const merged = mergeCampaignContent(subjResolved, bodyTpl, flat, eventDoc);
return h.response(success(merged));
};
module.exports = {
list,
listByOrg,
create,
get,
update,
remove,
send,
schedule,
cancel,
stop,
analytics,
preview,
};

View File

@ -0,0 +1,18 @@
const { success, error } = require('../utils/response');
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
const { listEventsWithAttendance } = require('../services/checkins.service');
const listEvents = async (request, h) => {
const user = request.auth.credentials.user;
const scoped = resolveScopedOrganizationId(user, request.query.organization);
if (scoped.error) {
return h.response(error(scoped.error.code, scoped.error.message)).code(400);
}
const events = await listEventsWithAttendance(user, scoped.organizationId);
return h.response(success({ events }));
};
module.exports = {
listEvents,
};

View File

@ -0,0 +1,439 @@
const mongoose = require('mongoose');
const ConciergeBot = require('../models/ConciergeBot');
const KnowledgeSource = require('../models/KnowledgeSource');
const ContextChunk = require('../models/ContextChunk');
const ChatSubscriber = require('../models/ChatSubscriber');
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const { getOrgWhatsAppCredentials, assertPhoneNumberOnWaba } = require('../utils/metaWhatsApp');
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
function enqueueConciergeIngest(data) {
const { enqueueConciergeIngest: enqueue } = require('../services/conciergeQueue');
return enqueue(data);
}
function userCanAccessOrganization(user, organizationId) {
if (user.role === 'super_admin') return true;
const scoped = resolveScopedOrganizationId(user, null);
if (scoped.error) return false;
return String(organizationId) === String(scoped.organizationId);
}
function buildListFilter(user, queryOrganization) {
if (queryOrganization) {
const scoped = resolveScopedOrganizationId(user, queryOrganization);
if (!scoped.error) return { organization: scoped.organizationId };
}
const scoped = resolveScopedOrganizationId(user, null);
if (!scoped.error) return { organization: scoped.organizationId };
if (user.role === 'super_admin') return {};
return null;
}
async function getBotForUser(botId, user) {
const bot = await ConciergeBot.findById(botId).populate('event', 'name startDate endDate');
if (!bot) return { err: { code: 'NOT_FOUND', message: 'Chatbot not found', status: 404 } };
if (!userCanAccessOrganization(user, bot.organization)) {
return { err: { code: 'FORBIDDEN', message: 'Access denied', status: 403 } };
}
return { bot };
}
async function botAggregates(eventId) {
const [sourceCount, stagingChunkCount, subscriberCount] = await Promise.all([
KnowledgeSource.countDocuments({ eventId }),
ContextChunk.countDocuments({ eventId, isActive: false }),
ChatSubscriber.countDocuments({ eventId }),
]);
return { sourceCount, stagingChunkCount, subscriberCount };
}
const list = async (request, h) => {
const user = request.auth.credentials.user;
const filter = buildListFilter(user, request.query.organization);
if (filter == null) {
return h
.response(
error(
'VALIDATION_ERROR',
'List is scoped by organisation. Pass ?organization=<id> or set organisation on your account.',
),
)
.code(400);
}
const bots = await ConciergeBot.find(filter)
.populate('event', 'name startDate endDate status')
.sort({ updatedAt: -1 })
.lean();
return h.response(success({ bots }));
};
const create = async (request, h) => {
const user = request.auth.credentials.user;
const { eventId, name, wabaPhoneNumberId, greeting, registration, knowledge, enabled } =
request.payload;
if (!mongoose.Types.ObjectId.isValid(String(eventId))) {
return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400);
}
const event = await Event.findById(eventId).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!userCanAccessOrganization(user, event.organization)) {
return h.response(error('FORBIDDEN', 'Event not in your organisation')).code(403);
}
const botOrganizationId = event.organization;
const existing = await ConciergeBot.findOne({ event: eventId });
if (existing) {
return h.response(error('VALIDATION_ERROR', 'This event already has a chatbot')).code(400);
}
const strictWaba =
String(process.env.CONCIERGE_STRICT_WABA_VALIDATION || '').trim().toLowerCase() === '1' ||
String(process.env.CONCIERGE_STRICT_WABA_VALIDATION || '').trim().toLowerCase() === 'true';
let wabaWarning = null;
try {
const creds = await getOrgWhatsAppCredentials(String(botOrganizationId));
if (creds?.accessToken && wabaPhoneNumberId) {
const chk = await assertPhoneNumberOnWaba(creds.wabaId, wabaPhoneNumberId, creds.accessToken);
if (!chk.ok) {
if (strictWaba) {
return h.response(error('VALIDATION_ERROR', chk.message)).code(400);
}
wabaWarning = chk.message;
console.warn('[concierge] create bot: phone WABA check skipped:', chk.message);
}
}
} catch (metaErr) {
console.warn('[concierge] create bot: Meta check error (continuing):', metaErr.message);
if (strictWaba) {
return h.response(error('VALIDATION_ERROR', metaErr.message || 'WhatsApp validation failed')).code(400);
}
wabaWarning = metaErr.message || 'WhatsApp validation could not be completed';
}
let bot;
try {
bot = await ConciergeBot.create({
organization: botOrganizationId,
event: eventId,
name: name || event.name,
wabaPhoneNumberId: String(wabaPhoneNumberId).trim(),
greeting,
registration,
knowledge,
enabled: Boolean(enabled),
status: enabled ? 'ACTIVE' : 'DRAFT',
});
} catch (e) {
if (e && e.code === 11000) {
const msg =
e.keyPattern?.wabaPhoneNumberId != null
? 'This WhatsApp phone number ID is already used by another chatbot.'
: 'This event already has a chatbot.';
return h.response(error('VALIDATION_ERROR', msg)).code(400);
}
console.error('[concierge] create bot failed:', e);
return h.response(error('INTERNAL_ERROR', e.message || 'Failed to create chatbot')).code(500);
}
console.log('[concierge] created bot', bot._id.toString(), 'event', eventId, 'org', botOrganizationId);
const populated = await ConciergeBot.findById(bot._id)
.populate('event', 'name startDate endDate status')
.lean();
return h
.response(
success(
{ bot: populated, ...(wabaWarning ? { warning: wabaWarning } : {}) },
'Chatbot created',
201,
),
)
.code(201);
};
const get = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const aggregates = await botAggregates(bot.event._id || bot.event);
return h.response(success({ bot, aggregates }));
};
const update = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const p = request.payload;
const strictWaba =
String(process.env.CONCIERGE_STRICT_WABA_VALIDATION || '').trim().toLowerCase() === '1' ||
String(process.env.CONCIERGE_STRICT_WABA_VALIDATION || '').trim().toLowerCase() === 'true';
if (p.name != null) bot.name = p.name;
if (p.wabaPhoneNumberId != null) {
const nextPhone = String(p.wabaPhoneNumberId).trim();
const phoneChanged = nextPhone !== String(bot.wabaPhoneNumberId || '').trim();
if (phoneChanged) {
try {
const creds = await getOrgWhatsAppCredentials(String(bot.organization));
if (creds?.accessToken && nextPhone) {
const chk = await assertPhoneNumberOnWaba(creds.wabaId, nextPhone, creds.accessToken);
if (!chk.ok) {
if (strictWaba) {
return h.response(error('VALIDATION_ERROR', chk.message)).code(400);
}
console.warn('[concierge] update bot: phone WABA check skipped:', chk.message);
}
}
} catch (metaErr) {
if (strictWaba) {
return h
.response(error('VALIDATION_ERROR', metaErr.message || 'WhatsApp validation failed'))
.code(400);
}
console.warn('[concierge] update bot: Meta check error (continuing):', metaErr.message);
}
bot.wabaPhoneNumberId = nextPhone;
}
}
if (p.greeting) bot.greeting = { ...bot.greeting.toObject?.() || bot.greeting, ...p.greeting };
if (p.registration) {
bot.registration = { ...bot.registration.toObject?.() || bot.registration, ...p.registration };
}
if (p.knowledge) bot.knowledge = { ...bot.knowledge.toObject?.() || bot.knowledge, ...p.knowledge };
if (p.enabled != null) {
bot.enabled = Boolean(p.enabled);
if (bot.enabled && bot.status !== 'ARCHIVED') bot.status = 'ACTIVE';
if (!bot.enabled && bot.status === 'ACTIVE') bot.status = 'DRAFT';
}
await bot.save();
const populated = await ConciergeBot.findById(bot._id).populate('event', 'name startDate endDate status').lean();
return h.response(success({ bot: populated }));
};
const remove = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
await ConciergeBot.deleteOne({ _id: bot._id });
return h.response(success({ deleted: true }));
};
const getByEvent = async (request, h) => {
const user = request.auth.credentials.user;
const { eventId } = request.params;
const event = await Event.findById(eventId).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!userCanAccessOrganization(user, event.organization)) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const bot = await ConciergeBot.findOne({ event: eventId }).populate('event', 'name').lean();
if (!bot) return h.response(error('NOT_FOUND', 'No chatbot for this event')).code(404);
const aggregates = await botAggregates(eventId);
return h.response(success({ bot, aggregates }));
};
const listSources = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const eventId = bot.event._id || bot.event;
const sources = await KnowledgeSource.find({ eventId }).sort({ createdAt: -1 }).lean();
return h.response(success({ sources }));
};
const createSource = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const eventId = bot.event._id || bot.event;
const { type, sourcePath, displayName, crawlScope: crawlScopeInput } = request.payload;
const doc = {
eventId,
type,
sourcePath,
displayName,
status: 'PENDING',
};
if (type === 'url') {
const { resolveCrawlScope } = require('../utils/conciergeUrlCrawl');
const requested =
crawlScopeInput === 'domain' || crawlScopeInput === 'path' ? crawlScopeInput : 'auto';
const scope = resolveCrawlScope(sourcePath, requested);
doc.crawlScope = scope.mode;
doc.crawlPathPrefix = scope.pathPrefix || null;
}
const source = await KnowledgeSource.create(doc);
return h.response(success(source, 'Source created', 201)).code(201);
};
const deleteSource = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const eventId = bot.event._id || bot.event;
const source = await KnowledgeSource.findOne({
_id: request.params.sourceId,
eventId,
});
if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404);
await ContextChunk.deleteMany({ sourceId: source._id });
await source.deleteOne();
return h.response(success({ deleted: true }));
};
const syncSource = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const eventId = bot.event._id || bot.event;
const source = await KnowledgeSource.findOne({
_id: request.params.sourceId,
eventId,
});
if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404);
source.status = 'PENDING';
source.errorMessage = null;
await source.save();
await enqueueConciergeIngest({
sourceId: String(source._id),
botId: String(bot._id),
eventId: String(eventId),
type: source.type,
});
return h.response(success(source, 'Sync queued'));
};
const listChunks = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const eventId = bot.event._id || bot.event;
const source = await KnowledgeSource.findOne({
_id: request.params.sourceId,
eventId,
});
if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404);
const q = { sourceId: source._id };
if (request.query.staging === 'true') q.isActive = false;
const chunks = await ContextChunk.find(q).sort({ createdAt: 1 }).lean();
return h.response(success({ chunks }));
};
const patchChunk = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const eventId = bot.event._id || bot.event;
const chunk = await ContextChunk.findOne({
_id: request.params.chunkId,
eventId,
});
if (!chunk) return h.response(error('NOT_FOUND', 'Chunk not found')).code(404);
if (request.payload.rawText != null) chunk.rawText = request.payload.rawText;
await chunk.save();
return h.response(success(chunk));
};
const deleteChunk = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const eventId = bot.event._id || bot.event;
const chunk = await ContextChunk.findOne({
_id: request.params.chunkId,
eventId,
});
if (!chunk) return h.response(error('NOT_FOUND', 'Chunk not found')).code(404);
await chunk.deleteOne();
return h.response(success({ deleted: true }));
};
const approveSource = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const eventId = bot.event._id || bot.event;
const source = await KnowledgeSource.findOne({
_id: request.params.sourceId,
eventId,
});
if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404);
const result = await ContextChunk.updateMany(
{ sourceId: source._id },
{ $set: { isActive: true } },
);
return h.response(success({ modified: result.modifiedCount }));
};
const clearKnowledge = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const eventId = bot.event._id || bot.event;
await ContextChunk.deleteMany({ eventId });
await KnowledgeSource.deleteMany({ eventId });
bot.lastKnowledgeClearedAt = new Date();
await bot.save();
return h.response(success({ cleared: true }));
};
const syncEventKnowledge = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
if (bot.knowledge?.includeEventDetails === false) {
return h
.response(
error(
'VALIDATION_ERROR',
'Event programme indexing is disabled for this bot. Enable "Include event programme in knowledge base" in settings first.',
),
)
.code(400);
}
const eventId = bot.event._id || bot.event;
const programmeDisplayName = 'Event programme & links';
let source = await KnowledgeSource.findOne({ eventId, type: 'event' });
if (!source) {
source = await KnowledgeSource.create({
eventId,
type: 'event',
sourcePath: String(eventId),
displayName: programmeDisplayName,
status: 'PENDING',
});
} else {
source.status = 'PENDING';
source.errorMessage = null;
source.displayName = programmeDisplayName;
await source.save();
}
bot.knowledge = bot.knowledge || {};
bot.knowledge.lastEventSnapshotAt = new Date();
await bot.save();
await enqueueConciergeIngest({
sourceId: String(source._id),
botId: String(bot._id),
eventId: String(eventId),
type: 'event',
});
return h.response(success(source, 'Event programme sync queued'));
};
module.exports = {
list,
create,
get,
update,
remove,
getByEvent,
listSources,
createSource,
deleteSource,
syncSource,
listChunks,
patchChunk,
deleteChunk,
approveSource,
clearKnowledge,
syncEventKnowledge,
};

View File

@ -1,16 +1,21 @@
const mongoose = require('mongoose');
const Event = require('../models/Event');
const Form = require('../models/Form');
const EventMicrosite = require('../models/EventMicrosite');
const User = require('../models/User');
const Track = require('../models/Track');
const Session = require('../models/Session');
const OrganizationUserMap = require('../models/OrganizationUserMap');
const { success, error } = require('../utils/response');
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
const { uploadBufferToS3AndGetCloudFrontUrl } = require('../utils/s3Media');
const { registrationUrlForForm } = require('../utils/micrositeBuild');
const { extractEventFieldsFromDescription } = require('../utils/bedrockExtractEvent');
const {
extractEventFieldsFromDescription,
EVENT_TYPES: AI_EVENT_TYPES,
EVENT_STATUS: AI_EVENT_STATUS,
LOCATION_TYPES: AI_LOCATION_TYPES,
} = require('../utils/bedrockExtractEvent');
mergeAiExtractedPayload,
computeQuickCreateGapsFromRawAi,
serializeMergedPreviewForApi,
} = require('../utils/aiEventMergeAndGaps');
function parseDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') return null;
@ -98,61 +103,180 @@ async function assignCreatorToEventStaff(creatorUserId, orgId, eventId) {
await map.save();
}
function defaultAiDateRange() {
const start = new Date();
start.setUTCDate(start.getUTCDate() + 7);
start.setUTCHours(10, 0, 0, 0);
const end = new Date(start.getTime());
end.setUTCHours(18, 0, 0, 0);
return { start, end };
/**
* One default track + opening session (agenda-ready).
* @param {object} eventDoc Mongoose event doc
* @param {string|object} titlePrefixOrOpts If string: legacy title prefix (e.g. "Opening session"). If object: { titlePrefix, trackName, sessionTitle, sessionUseFullEventWindow }
*/
async function createDefaultTrackAndOpeningSession(eventDoc, titlePrefixOrOpts) {
const opts =
typeof titlePrefixOrOpts === 'string'
? { titlePrefix: titlePrefixOrOpts }
: titlePrefixOrOpts && typeof titlePrefixOrOpts === 'object'
? titlePrefixOrOpts
: {};
const {
titlePrefix = 'Session',
trackName = 'Main programme',
sessionTitle: explicitSessionTitle,
sessionUseFullEventWindow = false,
} = opts;
const eid = eventDoc._id;
const track = await Track.create({
event: eid,
name: trackName,
sortOrder: 0,
});
const start = new Date(eventDoc.startDate);
let end;
if (sessionUseFullEventWindow) {
end = new Date(eventDoc.endDate);
if (end <= start) end = new Date(start.getTime() + 60 * 60 * 1000);
} else {
end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
const evEnd = new Date(eventDoc.endDate);
if (end > evEnd) end = new Date(evEnd.getTime());
if (end <= start) end = new Date(start.getTime() + 60 * 60 * 1000);
}
const titleBase = typeof eventDoc.name === 'string' ? eventDoc.name : 'Event';
const trimmedExplicit =
explicitSessionTitle != null && String(explicitSessionTitle).trim()
? String(explicitSessionTitle).trim().slice(0, 200)
: '';
const sessionTitle = trimmedExplicit || `${titlePrefix} — ${titleBase}`.slice(0, 200);
const sessionPayload = {
event: eid,
track: track._id,
title: sessionTitle,
type: 'keynote',
startTime: start,
endTime: end,
status: 'scheduled',
};
if (eventDoc.description) {
sessionPayload.description = String(eventDoc.description).slice(0, 500);
}
const lt = eventDoc.locationType;
if ((lt === 'physical' || lt === 'hybrid') && eventDoc.location) {
sessionPayload.location = eventDoc.location;
}
if (eventDoc.recordKind === 'activity') {
const sub = eventDoc.activitySubtype || 'qna';
if (sub === 'qna') {
sessionPayload.enableQnA = true;
sessionPayload.enableQnP = false;
} else {
sessionPayload.enableQnA = false;
sessionPayload.enableQnP = true;
}
}
try {
await Session.create(sessionPayload);
} catch (err) {
await Track.deleteOne({ _id: track._id });
throw err;
}
}
function mergeAiExtractedPayload(ai) {
const { start: ds, end: de } = defaultAiDateRange();
const norm = (v) => (typeof v === 'string' ? v.toLowerCase().trim() : '');
const type = AI_EVENT_TYPES.includes(norm(ai.type)) ? norm(ai.type) : 'conference';
const status = AI_EVENT_STATUS.includes(norm(ai.status)) ? norm(ai.status) : 'draft';
const locType = AI_LOCATION_TYPES.includes(norm(ai.locationType)) ? norm(ai.locationType) : 'physical';
async function createDefaultTrackAndSessionForAiEvent(eventDoc) {
return createDefaultTrackAndOpeningSession(eventDoc, 'Opening session');
}
let name = typeof ai.name === 'string' ? ai.name.trim() : '';
if (name.length < 3) {
const fromDesc = typeof ai.description === 'string' ? ai.description.trim().slice(0, 80) : '';
name = (fromDesc || 'New event').slice(0, 100);
if (name.length < 3) name = 'New event';
async function createDefaultTrackAndSessionForActivity(eventDoc) {
const name = typeof eventDoc.name === 'string' ? eventDoc.name.trim() : '';
return createDefaultTrackAndOpeningSession(eventDoc, {
trackName: 'Default',
sessionTitle: name ? name.slice(0, 200) : undefined,
titlePrefix: 'Activity session',
sessionUseFullEventWindow: true,
});
}
function toOrgObjectId(organizationId) {
if (!organizationId) return organizationId;
if (organizationId instanceof mongoose.Types.ObjectId) return organizationId;
const s = String(organizationId);
if (mongoose.Types.ObjectId.isValid(s) && String(new mongoose.Types.ObjectId(s)) === s) {
return new mongoose.Types.ObjectId(s);
}
return organizationId;
}
let startDate = ai.startDate ? new Date(ai.startDate) : ds;
let endDate = ai.endDate ? new Date(ai.endDate) : de;
if (Number.isNaN(startDate.getTime())) startDate = ds;
if (Number.isNaN(endDate.getTime())) endDate = de;
if (endDate <= startDate) {
endDate = new Date(startDate.getTime() + 8 * 60 * 60 * 1000);
}
async function attachActivityStats(organizationId, eventsLean) {
if (!eventsLean.length) return eventsLean;
const Question = require('../models/Question');
const QuizPoll = require('../models/QuizPoll');
const QuizPollResponse = require('../models/QuizPollResponse');
const Participant = require('../models/Participant');
const subtitle = typeof ai.subtitle === 'string' ? ai.subtitle : '';
const description = typeof ai.description === 'string' ? ai.description : '';
const location = typeof ai.location === 'string' ? ai.location : '';
const virtualLink = typeof ai.virtualLink === 'string' ? ai.virtualLink : '';
const tags = Array.isArray(ai.tags) ? ai.tags.filter((t) => typeof t === 'string') : [];
return {
name,
subtitle,
description,
type,
status,
startDate,
endDate,
location,
locationType: locType,
virtualLink,
tags,
const orgOid = toOrgObjectId(organizationId);
const eventIds = eventsLean.map((e) => e._id);
const notDeletedPoll = {
$or: [{ deletedAt: null }, { deletedAt: { $exists: false } }],
};
const [qAgg, pAgg, qpAgg, respAgg] = await Promise.all([
Question.aggregate([
{ $match: { event: { $in: eventIds } } },
{ $group: { _id: '$event', count: { $sum: 1 } } },
]),
Participant.aggregate([
{ $match: { tenantOrganization: orgOid } },
{ $unwind: '$eventMemberships' },
{ $match: { 'eventMemberships.event': { $in: eventIds } } },
{ $group: { _id: '$eventMemberships.event', count: { $sum: 1 } } },
]),
QuizPoll.aggregate([
{ $match: { event: { $in: eventIds }, ...notDeletedPoll } },
{ $group: { _id: '$event', count: { $sum: 1 } } },
]),
QuizPollResponse.aggregate([
{ $lookup: { from: 'quizpolls', localField: 'quizPoll', foreignField: '_id', as: 'qp' } },
{ $unwind: '$qp' },
{
$match: {
'qp.event': { $in: eventIds },
$or: [{ 'qp.deletedAt': null }, { 'qp.deletedAt': { $exists: false } }],
},
},
{ $group: { _id: '$qp.event', count: { $sum: 1 } } },
]),
]);
const toMap = (agg) =>
agg.reduce((m, r) => {
m[String(r._id)] = r.count;
return m;
}, {});
const qm = toMap(qAgg);
const pm = toMap(pAgg);
const qpm = toMap(qpAgg);
const rm = toMap(respAgg);
return eventsLean.map((ev) => ({
...ev,
activityStats: {
questionCount: qm[String(ev._id)] || 0,
participantCount: pm[String(ev._id)] || 0,
quizPollCount: qpm[String(ev._id)] || 0,
responseCount: rm[String(ev._id)] || 0,
},
}));
}
async function persistEventWithSideEffects(user, orgId, rest, metaIn) {
const payload = { ...rest };
if (payload.recordKind !== 'activity') {
delete payload.activitySubtype;
}
const event = await Event.create({
...rest,
...payload,
organization: orgId,
metadata: {
totalRevenue: 0,
@ -203,7 +327,23 @@ async function persistEventWithSideEffects(user, orgId, rest, metaIn) {
}
const list = async (request, h) => {
const { organization: organizationQuery, status, type, page = 1, limit = 20 } = request.query;
const {
organization: organizationQuery,
status,
type,
page: pageRaw = 1,
limit: limitRaw = 20,
recordKind: recordKindQuery,
activitySubtype,
enrich: enrichRaw,
} = request.query;
const page = parseInt(pageRaw, 10) || 1;
const limit = Math.min(parseInt(limitRaw, 10) || 20, 200);
const enrich =
enrichRaw === true ||
enrichRaw === '1' ||
enrichRaw === 'true';
const user = request.auth.credentials.user;
const scoped = resolveScopedOrganizationId(user, organizationQuery);
if (scoped.error) {
@ -213,13 +353,25 @@ const list = async (request, h) => {
if (status) filter.status = status;
if (type) filter.type = type;
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
if (recordKindQuery === 'activity') {
filter.recordKind = 'activity';
if (activitySubtype) filter.activitySubtype = activitySubtype;
} else {
filter.recordKind = { $ne: 'activity' };
}
const skip = (page - 1) * limit;
const [events, total] = await Promise.all([
Event.find(filter).populate('organization').sort({ startDate: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(),
Event.find(filter).populate('organization').sort({ startDate: -1 }).skip(skip).limit(limit).lean(),
Event.countDocuments(filter),
]);
return h.response(success({ events, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }))
.header('X-Total-Count', total);
let outEvents = events;
if (enrich && recordKindQuery === 'activity' && events.length > 0) {
outEvents = await attachActivityStats(scoped.organizationId, events);
}
return h.response(success({ events: outEvents, total, page, limit })).header('X-Total-Count', total);
};
const create = async (request, h) => {
@ -229,11 +381,83 @@ const create = async (request, h) => {
if (user.role !== 'super_admin' && (user.organization?._id?.toString() !== orgId?.toString())) {
return h.response(error('FORBIDDEN', 'Cannot create event for this organization')).code(403);
}
const { metadata: metaIn, ...rest } = request.payload;
const { metadata: metaIn, bootstrapAgenda, ...rest } = request.payload;
if (rest.recordKind === 'activity') {
if (!rest.type) rest.type = 'webinar';
if (!rest.locationType) rest.locationType = 'virtual';
}
const event = await persistEventWithSideEffects(user, orgId, rest, metaIn);
if (rest.recordKind === 'activity') {
try {
await createDefaultTrackAndSessionForActivity(event);
} catch (err) {
console.error('createDefaultTrackAndSessionForActivity', err);
try {
await Event.deleteOne({ _id: event._id });
} catch (delErr) {
console.error('rollback activity event', delErr);
}
return h
.response(
error(
'SERVER_ERROR',
'Could not finish creating the activity (default track or session failed). Please try again.',
),
)
.code(500);
}
} else if (bootstrapAgenda === true) {
try {
await createDefaultTrackAndSessionForAiEvent(event);
} catch (err) {
console.error('createDefaultTrackAndSessionForAiEvent (bootstrapAgenda)', err);
try {
await Event.deleteOne({ _id: event._id });
} catch (delErr) {
console.error('rollback event after bootstrapAgenda failure', delErr);
}
return h
.response(
error(
'SERVER_ERROR',
'Could not finish creating the event (default track or session failed). Please try again.',
),
)
.code(500);
}
}
return h.response(success(event, 'Event created', 201)).code(201);
};
/** Bedrock extract + gap list for quick-create flow; does not persist. */
const aiExtractPreview = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = request.payload.organization || (user.organization?._id || user.organization);
if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
if (user.role !== 'super_admin' && (user.organization?._id?.toString() !== orgId?.toString())) {
return h.response(error('FORBIDDEN', 'Cannot preview for this organization')).code(403);
}
const { description } = request.payload;
let extracted;
try {
extracted = await extractEventFieldsFromDescription(description);
} catch (err) {
console.error('extractEventFieldsFromDescription (aiExtractPreview)', err);
const msg = err.message || 'AI extraction failed';
return h.response(error('BEDROCK_ERROR', msg)).code(502);
}
const mergedPreview = mergeAiExtractedPayload(extracted);
const gaps = computeQuickCreateGapsFromRawAi(extracted);
return h.response(
success({
gaps,
mergedPreview: serializeMergedPreviewForApi(mergedPreview),
}),
);
};
const generateFromAi = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = request.payload.organization || (user.organization?._id || user.organization);
@ -254,6 +478,24 @@ const generateFromAi = async (request, h) => {
const merged = mergeAiExtractedPayload(extracted);
const event = await persistEventWithSideEffects(user, orgId, merged, undefined);
try {
await createDefaultTrackAndSessionForAiEvent(event);
} catch (err) {
console.error('createDefaultTrackAndSessionForAiEvent', err);
try {
await Event.deleteOne({ _id: event._id });
} catch (delErr) {
console.error('rollback AI event after track/session failure', delErr);
}
return h
.response(
error(
'SERVER_ERROR',
'Could not finish creating the event (default track or session failed). Please try again.',
),
)
.code(500);
}
return h.response(success(event, 'Event created', 201)).code(201);
};
@ -274,7 +516,7 @@ const update = async (request, h) => {
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const { metadata: metaIn, ...rest } = request.payload;
const { metadata: metaIn, bootstrapAgenda: _bootstrapIgnored, ...rest } = request.payload;
const orgId = event.organization?._id || event.organization;
const eventId = event._id?.toString?.() || event._id;
@ -298,6 +540,9 @@ const update = async (request, h) => {
}
Object.assign(event, rest);
if (event.recordKind !== 'activity') {
event.set('activitySubtype', undefined);
}
if (metaIn && typeof metaIn === 'object') {
const prev = event.metadata?.toObject?.() || event.metadata || {};
event.metadata = { ...prev, ...metaIn };
@ -440,9 +685,39 @@ const registrationLink = async (request, h) => {
return h.response(success({ url }));
};
/** Authenticated: forms + microsites with resolved public hrefs for emailer CTA pickers. */
const emailerLinkTargets = async (request, h) => {
const event = await Event.findById(request.params.id).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const base = (process.env.MICROSITE_PUBLIC_BASE_URL || 'https://web.eventbuzz.live').replace(/\/$/, '');
const formsRaw = await Form.find({ event: event._id }).select('_id name').sort({ name: 1 }).lean();
const forms = formsRaw.map((f) => ({
id: String(f._id),
name: f.name || 'Form',
href: registrationUrlForForm(event, event.website, f._id) || '',
}));
const mss = await EventMicrosite.find({ event: event._id }).select('name publishFolder').sort({ name: 1 }).lean();
const microsites = mss
.filter((m) => m.publishFolder && String(m.publishFolder).trim())
.map((m) => {
const folder = String(m.publishFolder).trim().toLowerCase();
return {
id: String(m._id),
name: m.name || folder || 'Microsite',
href: `${base}/${folder}/`,
};
});
return h.response(success({ forms, microsites }));
};
module.exports = {
list,
create,
aiExtractPreview,
generateFromAi,
get,
update,
@ -450,4 +725,5 @@ module.exports = {
analytics,
duplicate,
registrationLink,
emailerLinkTargets,
};

View File

@ -0,0 +1,147 @@
const mongoose = require('mongoose');
const EventTicket = require('../models/EventTicket');
const Event = require('../models/Event');
const Form = require('../models/Form');
const ParticipantList = require('../models/ParticipantList');
const Organization = require('../models/Organization');
const { success, error } = require('../utils/response');
async function assertEventAccess(eventId, user) {
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return { err: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) };
if (
user.role !== 'super_admin' &&
String(event.organization) !== String(user.organization?._id || user.organization || '')
) {
return { err: (h) => h.response(error('FORBIDDEN', 'Access denied')).code(403) };
}
return { event };
}
async function validateTicketRefs(organizationId, eventId, payload) {
const list = await ParticipantList.findById(payload.participantList).lean();
if (!list) return { err: 'Participant list not found' };
if (String(list.tenantOrganization) !== String(organizationId)) return { err: 'List belongs to another organisation' };
if (list.type !== 'fixed') return { err: 'Participant list must be a fixed list' };
if (payload.registrationForm) {
const form = await Form.findById(payload.registrationForm).lean();
if (!form) return { err: 'Registration form not found' };
if (String(form.tenantOrganization) !== String(organizationId)) return { err: 'Form belongs to another organisation' };
if (form.event && String(form.event) !== String(eventId)) return { err: 'Form is scoped to a different event' };
}
return { ok: true };
}
const list = async (request, h) => {
const { eventId } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.err) return r.err(h);
const tickets = await EventTicket.find({ event: eventId }).sort({ sortOrder: 1, name: 1 }).lean();
return h.response(success({ tickets }));
};
const create = async (request, h) => {
const { eventId } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.err) return r.err(h);
const event = await Event.findById(eventId).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const orgId = event.organization;
const orgDoc = await Organization.findById(orgId).lean();
if (!orgDoc) return h.response(error('NOT_FOUND', 'Organization not found')).code(404);
const p = request.payload;
const vr = await validateTicketRefs(orgId, eventId, {
participantList: p.participantList,
registrationForm: p.registrationForm || null,
});
if (vr.err) return h.response(error('VALIDATION_ERROR', vr.err)).code(400);
const ticket = await EventTicket.create({
event: eventId,
organization: orgId,
name: p.name,
description: p.description || '',
keyPoints: p.keyPoints || [],
basePriceMinor: p.basePriceMinor,
currency: p.currency || 'INR',
isActive: p.isActive !== false,
sortOrder: p.sortOrder ?? 0,
maxPerOrder: p.maxPerOrder ?? null,
inventoryCap: p.inventoryCap ?? null,
taxItems: p.taxItems || [],
registrationForm: p.registrationForm || null,
participantList: p.participantList,
});
return h.response(success(ticket, 'Ticket created', 201)).code(201);
};
const update = async (request, h) => {
const { eventId, id } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.err) return r.err(h);
const ticket = await EventTicket.findOne({ _id: id, event: eventId });
if (!ticket) return h.response(error('NOT_FOUND', 'Ticket not found')).code(404);
const event = await Event.findById(eventId).lean();
const orgId = event.organization;
const orgDoc = await Organization.findById(orgId).lean();
if (!assertFeature(orgDoc, 'payment_gateway_integration')) {
return h.response(error('FORBIDDEN', 'Ticketing requires payment gateway integration on your plan.')).code(403);
}
const p = request.payload;
if (p.participantList != null || p.registrationForm !== undefined) {
const vr = await validateTicketRefs(orgId, eventId, {
participantList: p.participantList ?? ticket.participantList,
registrationForm: p.registrationForm !== undefined ? p.registrationForm : ticket.registrationForm,
});
if (vr.err) return h.response(error('VALIDATION_ERROR', vr.err)).code(400);
}
const fields = [
'name',
'description',
'keyPoints',
'basePriceMinor',
'currency',
'isActive',
'sortOrder',
'maxPerOrder',
'inventoryCap',
'taxItems',
'registrationForm',
'participantList',
];
for (const f of fields) {
if (p[f] !== undefined) ticket[f] = p[f];
}
await ticket.save();
return h.response(success(ticket));
};
const remove = async (request, h) => {
const { eventId, id } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.err) return r.err(h);
const ticket = await EventTicket.findOne({ _id: id, event: eventId });
if (!ticket) return h.response(error('NOT_FOUND', 'Ticket not found')).code(404);
ticket.isActive = false;
await ticket.save();
return h.response(success(null, 'Ticket deactivated'));
};
module.exports = { list, create, update, remove };

View File

@ -0,0 +1,173 @@
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const { defaultManualEnablementFeeInrFromEnv } = require('../utils/manualTicketingFee');
const { manualQrFieldsSatisfied } = require('../utils/ticketingManualPayment');
const { createManualEnablementCheckoutOrder } = require('../services/ticketingManualEnablement.service');
async function assertEventAccess(eventId, user) {
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return { err: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) };
if (
user.role !== 'super_admin' &&
String(event.organization) !== String(user.organization?._id || user.organization || '')
) {
return { err: (h) => h.response(error('FORBIDDEN', 'Access denied')).code(403) };
}
return { event };
}
function shapeTicketingManualResponse(mp) {
const raw = mp && typeof mp === 'object' ? mp : {};
/** Legacy field; buyers are no longer charged a per-order Eventbuzz fee (enablement fee covers the organiser). */
const fee =
raw.platformFeeInr != null && Number.isFinite(Number(raw.platformFeeInr)) ? Number(raw.platformFeeInr) : 0;
const mode = raw.manualQrMode === 'dynamic' ? 'dynamic' : 'upload';
return {
enabled: Boolean(raw.enabled),
manualQrMode: mode,
upiQrImageUrl: raw.upiQrImageUrl || '',
upiId: raw.upiId || '',
payeeName: raw.payeeName || '',
instructions: raw.instructions || '',
platformFeeInr: fee,
platformFeeInrIsDefault: raw.platformFeeInr == null,
enablementFeeInr: defaultManualEnablementFeeInrFromEnv(),
enablementPaid: Boolean(raw.enablementFeePaidAt),
enablementPaidAt: raw.enablementFeePaidAt || null,
};
}
const patch = async (request, h) => {
const { eventId } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.err) return r.err(h);
const p = request.payload;
const doc = await Event.findById(eventId);
if (!doc) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const prev = doc.ticketingManualPayment && typeof doc.ticketingManualPayment === 'object' ? doc.ticketingManualPayment : {};
const next = { ...prev };
if (p.enabled !== undefined) next.enabled = Boolean(p.enabled);
if (p.manualQrMode !== undefined) {
next.manualQrMode = p.manualQrMode === 'dynamic' ? 'dynamic' : 'upload';
}
if (p.upiQrImageUrl !== undefined) next.upiQrImageUrl = String(p.upiQrImageUrl || '').trim();
if (p.upiId !== undefined) next.upiId = String(p.upiId || '').trim();
if (p.payeeName !== undefined) next.payeeName = String(p.payeeName || '').trim();
if (p.instructions !== undefined) next.instructions = String(p.instructions || '');
if (p.platformFeeInr !== undefined) {
if (p.platformFeeInr === null) next.platformFeeInr = null;
else {
const n = Number(p.platformFeeInr);
if (!Number.isFinite(n) || n < 0) {
return h.response(error('VALIDATION_ERROR', 'platformFeeInr must be a non-negative number or null')).code(400);
}
next.platformFeeInr = n;
}
}
const enablementOk = Boolean(next.enablementFeePaidAt || prev.enablementFeePaidAt);
if (next.enabled && !enablementOk) {
return h
.response(
error(
'PAYMENT_REQUIRED',
'Pay the manual UPI enablement fee for this event before enabling manual checkout. The paid transaction appears under Settings → Billing.',
),
)
.code(402);
}
if (next.enabled) {
const mode = next.manualQrMode === 'dynamic' ? 'dynamic' : 'upload';
if (mode === 'upload' && !String(next.upiQrImageUrl || '').trim()) {
return h.response(error('VALIDATION_ERROR', 'Add a UPI QR image or URL before enabling upload mode')).code(400);
}
if (mode === 'dynamic' && !String(next.upiId || '').trim()) {
return h.response(error('VALIDATION_ERROR', 'Enter a UPI ID (VPA) before enabling dynamic QR mode')).code(400);
}
}
if (next.enabled && !manualQrFieldsSatisfied(next)) {
return h.response(error('VALIDATION_ERROR', 'Complete QR settings before enabling')).code(400);
}
doc.ticketingManualPayment = next;
await doc.save();
return h.response(
success({
ticketingManualPayment: shapeTicketingManualResponse(doc.ticketingManualPayment),
}),
);
};
const get = async (request, h) => {
const { eventId } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.err) return r.err(h);
const doc = await Event.findById(eventId).select('ticketingManualPayment').lean();
if (!doc) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
return h.response(
success({
ticketingManualPayment: shapeTicketingManualResponse(doc.ticketingManualPayment || {}),
}),
);
};
const checkoutEnablementOrder = async (request, h) => {
const { eventId } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.err) return r.err(h);
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organisation')).code(400);
try {
const result = await createManualEnablementCheckoutOrder({
organizationId: orgId,
userId: user._id,
eventId,
});
return h
.response(
success({
keyId: result.keyId,
orderId: result.orderId,
amount: result.amount,
currency: result.currency,
transactionId: result.transaction._id.toString(),
checkoutSource: result.checkoutSource,
enablementFeeInr: defaultManualEnablementFeeInrFromEnv(),
}),
)
.code(201);
} catch (e) {
if (e.code === 'ALREADY_PAID') {
return h.response(error('VALIDATION_ERROR', e.message)).code(409);
}
if (e.code === 'INVALID_CONFIG') {
return h.response(error('VALIDATION_ERROR', e.message)).code(400);
}
if (
e.code === 'NOT_FOUND' ||
e.code === 'FORBIDDEN' ||
e.code === 'NO_GATEWAY' ||
e.code === 'PLATFORM_RAZORPAY_MISSING' ||
e.code === 'INVALID_AMOUNT' ||
e.code === 'RAZORPAY_SDK_MISSING'
) {
return h.response(error('VALIDATION_ERROR', e.message)).code(400);
}
throw e;
}
};
module.exports = { patch, get, checkoutEnablementOrder };

View File

@ -2,9 +2,11 @@ const mongoose = require('mongoose');
const Form = require('../models/Form');
const FormSubmission = require('../models/FormSubmission');
const Event = require('../models/Event');
const EventMicrosite = require('../models/EventMicrosite');
const Participant = require('../models/Participant');
const ParticipantList = require('../models/ParticipantList');
const { success, error } = require('../utils/response');
const { computeEventRatingsSummary } = require('../utils/eventRatingsSummary');
function normalizeSubmitData(raw) {
if (!raw) return {};
@ -12,6 +14,77 @@ function normalizeSubmitData(raw) {
return { ...raw };
}
/** Normalize form.event from lean() (ObjectId, string, or rare populated { _id }). */
function extractObjectIdString(ref) {
if (ref == null) return null;
if (ref instanceof mongoose.Types.ObjectId) return ref.toString();
if (Buffer.isBuffer(ref)) {
try {
return new mongoose.Types.ObjectId(ref).toString();
} catch {
return null;
}
}
if (typeof ref === 'object' && ref._id != null) {
return extractObjectIdString(ref._id);
}
if (typeof ref === 'string') {
const t = ref.trim();
if (t.length === 24 && mongoose.Types.ObjectId.isValid(t)) return t;
}
return null;
}
const EVENT_PUBLIC_SELECT =
'name subtitle type startDate endDate location locationType image bannerImage website.logo';
function buildPublicEventPayload(ev, micrositeLogo) {
const toIso = (d) => (d && !Number.isNaN(new Date(d).getTime()) ? new Date(d).toISOString() : null);
const siteLogo = ev.website?.logo && String(ev.website.logo).trim() ? String(ev.website.logo).trim() : null;
const msLogo = micrositeLogo && String(micrositeLogo).trim() ? String(micrositeLogo).trim() : null;
return {
name: ev.name,
subtitle: ev.subtitle || '',
eventType: ev.type,
startDate: toIso(ev.startDate),
endDate: toIso(ev.endDate),
location: ev.location ? String(ev.location) : '',
locationType: ev.locationType || 'physical',
logoUrl: siteLogo || msLogo || null,
imageUrl: (() => {
const a = ev.image && String(ev.image).trim();
const b = ev.bannerImage && String(ev.bannerImage).trim();
return a || b || null;
})(),
};
}
/** Resolve Event for a public form: direct form.event, then Event.website.registrationFormId, then EventMicrosite.registrationFormId. */
async function resolveEventForPublicForm(form) {
const directId = extractObjectIdString(form.event);
if (directId) {
const ev = await Event.findById(directId).select(EVENT_PUBLIC_SELECT).lean();
if (ev) return { ev, micrositeLogo: null };
}
const formIdStr = String(form._id);
let ev = await Event.findOne({
$or: [{ 'website.registrationFormId': formIdStr }, { 'website.registrationFormId': form._id }],
})
.select(EVENT_PUBLIC_SELECT)
.lean();
if (ev) return { ev, micrositeLogo: null };
const ms = await EventMicrosite.findOne({
$or: [{ registrationFormId: formIdStr }, { registrationFormId: form._id }],
})
.select('event logo')
.lean();
if (!ms || !ms.event) return { ev: null, micrositeLogo: null };
ev = await Event.findById(ms.event).select(EVENT_PUBLIC_SELECT).lean();
return { ev, micrositeLogo: ev ? ms.logo : null };
}
async function upsertParticipantFromRegistrationForm(form, tenantOrganization, dataObj) {
const fields = form.fields || [];
const orgOid =
@ -165,6 +238,7 @@ const listByOrganization = async (request, h) => {
const oid = new mongoose.Types.ObjectId(String(tenantOidRaw));
const forms = await Form.find({ tenantOrganization: oid })
.sort({ createdAt: -1 })
.populate('event', 'name')
.lean();
const formIds = forms.map((f) => f._id);
const counts = await FormSubmission.aggregate([
@ -208,24 +282,20 @@ const createByOrganization = async (request, h) => {
}
const toid = new mongoose.Types.ObjectId(String(tenantOidRaw));
let eventRef;
if (body.event) {
if (!mongoose.Types.ObjectId.isValid(String(body.event))) {
return h.response(error('VALIDATION_ERROR', 'Invalid event')).code(400);
}
const ev = await Event.findById(body.event).lean();
if (!ev) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (user.role !== 'super_admin' && ev.organization?.toString() !== toid.toString()) {
return h.response(error('FORBIDDEN', 'Event does not belong to your organization')).code(403);
}
eventRef = body.event;
if (!body.event || !mongoose.Types.ObjectId.isValid(String(body.event))) {
return h.response(error('VALIDATION_ERROR', 'Event is required')).code(400);
}
const ev = await Event.findById(body.event).lean();
if (!ev) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (user.role !== 'super_admin' && ev.organization?.toString() !== toid.toString()) {
return h.response(error('FORBIDDEN', 'Event does not belong to your organization')).code(403);
}
const { event: _e, tenantOrganization: _t, ...rest } = body;
const form = await Form.create({
...rest,
tenantOrganization: toid,
...(eventRef && { event: eventRef }),
event: body.event,
});
return h.response(success(form, 'Form created', 201)).code(201);
};
@ -241,13 +311,27 @@ const get = async (request, h) => {
const update = async (request, h) => {
const form = await Form.findById(request.params.id);
if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404);
const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user);
const user = request.auth.credentials.user;
const allowed = await formBelongsToUserOrg(form, user);
if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const payload = { ...request.payload };
delete payload.tenantOrganization;
Object.assign(form, payload);
const { event: eventId, ...rest } = payload;
Object.assign(form, rest);
if (eventId !== undefined && eventId !== null) {
if (!mongoose.Types.ObjectId.isValid(String(eventId))) {
return h.response(error('VALIDATION_ERROR', 'Invalid event')).code(400);
}
const ev = await Event.findById(eventId).lean();
if (!ev) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (user.role !== 'super_admin' && ev.organization?.toString() !== form.tenantOrganization?.toString()) {
return h.response(error('FORBIDDEN', 'Event does not belong to this form organization')).code(403);
}
form.event = eventId;
}
await form.save();
return h.response(success(form));
const updated = await Form.findById(form._id).populate('event', 'name');
return h.response(success(updated));
};
const remove = async (request, h) => {
@ -298,16 +382,147 @@ const submit = async (request, h) => {
participant: participantId,
data: dataObj,
});
if (eventRef) {
const summary = await computeEventRatingsSummary(eventRef.toString());
await Event.findByIdAndUpdate(eventRef, {
$set: {
'metadata.rating': summary.average,
'metadata.ratingCount': summary.count,
},
});
}
return h.response(success(submission, 'Submission received', 201)).code(201);
};
function dataMapToObject(data) {
if (!data) return {};
if (data instanceof Map) return Object.fromEntries(data);
return typeof data === 'object' ? { ...data } : {};
}
function submissionSearchBlob(sub) {
const parts = [];
const o = dataMapToObject(sub.data);
Object.values(o).forEach((v) => {
if (v != null && v !== '') parts.push(String(v));
});
const p = sub.participant;
if (p && typeof p === 'object') {
parts.push(p.email || '', p.firstName || '', p.lastName || '');
}
return parts.join(' ').toLowerCase();
}
/** Wildcard * splits segments; every segment must appear as substring (case-insensitive). */
function matchesSubmissionQuery(sub, qRaw) {
if (!qRaw || !String(qRaw).trim()) return true;
const q = String(qRaw).trim();
const segments = q.split('*').map((s) => s.trim().toLowerCase()).filter(Boolean);
if (segments.length === 0) return true;
const blob = submissionSearchBlob(sub);
return segments.every((seg) => blob.includes(seg));
}
function escapeCsvCell(val) {
const s = val == null ? '' : String(val);
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
function submissionsToCsv(form, rows) {
const labels = (form.fields || []).map((f) => f.label).filter(Boolean);
const header = ['Submitted At', 'Participant Email', ...labels];
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 row = [new Date(s.submittedAt).toISOString(), email, ...vals];
lines.push(row.map(escapeCsvCell).join(','));
}
return lines.join('\n');
}
const listSubmissions = async (request, h) => {
const form = await Form.findById(request.params.formId);
if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404);
const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user);
if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const submissions = await FormSubmission.find({ form: form._id }).populate('participant user').sort({ submittedAt: -1 }).lean();
return h.response(success({ submissions }));
const { q, dateFrom, dateTo, page: pageStr, limit: limitStr, export: exportStr } = request.query;
const exportCsv = exportStr === 'csv' || exportStr === '1';
const match = { form: form._id };
if (dateFrom) {
const d = new Date(dateFrom);
if (!Number.isNaN(d.getTime())) match.submittedAt = { $gte: d };
}
if (dateTo) {
const d = new Date(dateTo);
if (!Number.isNaN(d.getTime())) {
d.setHours(23, 59, 59, 999);
match.submittedAt = { ...match.submittedAt, $lte: d };
}
}
let all = await FormSubmission.find(match).populate('participant', 'firstName lastName email').sort({ submittedAt: -1 }).lean();
if (q && String(q).trim()) {
all = all.filter((s) => matchesSubmissionQuery(s, q));
}
if (exportCsv) {
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"`)
.code(200);
}
const page = Math.max(1, parseInt(String(pageStr || '1'), 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(String(limitStr || '20'), 10) || 20));
const total = all.length;
const skip = (page - 1) * limit;
const submissions = all.slice(skip, skip + limit);
return h.response(success({ submissions, total, page, limit }));
};
/** Public: form definition for rendering (no auth). Only when form accepts responses. */
const getPublicForm = async (request, h) => {
const form = await Form.findById(request.params.formId).lean();
if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404);
if (!form.settings?.isActive) {
return h.response(error('VALIDATION_ERROR', 'This form is not accepting responses.')).code(400);
}
const { ev: resolvedEvent, micrositeLogo } = await resolveEventForPublicForm(form);
const eventPublic = resolvedEvent ? buildPublicEventPayload(resolvedEvent, micrositeLogo) : null;
const safe = {
_id: form._id,
name: form.name,
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,
})),
settings: {
thankYouMessage: form.settings?.thankYouMessage,
showThankYou: form.settings?.showThankYou !== false,
collectEmail: !!form.settings?.collectEmail,
allowAnonymous: !!form.settings?.allowAnonymous,
},
};
return h.response(success(safe));
};
module.exports = {
@ -320,4 +535,5 @@ module.exports = {
remove,
submit,
listSubmissions,
getPublicForm,
};

View File

@ -3,6 +3,8 @@ const User = require('../models/User');
const OrganizationUserMap = require('../models/OrganizationUserMap');
const { success, error } = require('../utils/response');
const { ROLES } = require('../config/constants');
const { encrypt } = require('../utils/smtpCrypto');
const { clearOrgTransportCache, sendOrgMail } = require('../utils/mail');
const getOrganization = async (request, h) => {
const user = request.auth.credentials.user;
@ -36,9 +38,8 @@ const updateOrganization = async (request, h) => {
if (p.subscription != null && typeof p.subscription === 'object') {
const sub = org.subscription || {};
const incoming = p.subscription;
if (incoming.plan != null) sub.plan = incoming.plan;
/** Plan and billing period are set via Razorpay subscription checkout or platform admin APIs only. */
if (incoming.status != null) sub.status = incoming.status;
if (incoming.expiresAt !== undefined) sub.expiresAt = incoming.expiresAt;
if (incoming.maxEvents !== undefined) sub.maxEvents = incoming.maxEvents;
if (incoming.maxParticipants !== undefined) sub.maxParticipants = incoming.maxParticipants;
if (Array.isArray(incoming.featureOverrides)) sub.featureOverrides = incoming.featureOverrides.map(String);
@ -115,6 +116,160 @@ const updateUser = async (request, h) => {
return h.response(success(out));
};
function assertOrgAdmin(user) {
return user.role === 'super_admin' || user.role === 'admin';
}
const getEmailSettings = async (request, h) => {
const user = request.auth.credentials.user;
if (!assertOrgAdmin(user)) return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403);
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const org = await Organization.findById(orgId).lean();
if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404);
const s = org.smtp || {};
const pwd = s.passwordEnc ? String(s.passwordEnc) : '';
const userLast4 = s.user && String(s.user).length > 4 ? String(s.user).slice(-4) : s.user || '';
return h.response(
success({
enabled: Boolean(s.enabled),
host: s.host || '',
port: s.port || 587,
secure: Boolean(s.secure),
user: s.user || '',
userLast4,
hasPassword: pwd.length > 0,
fromName: s.fromName || '',
fromAddress: s.fromAddress || '',
})
);
};
const updateEmailSettings = async (request, h) => {
const user = request.auth.credentials.user;
if (!assertOrgAdmin(user)) return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403);
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const org = await Organization.findById(orgId);
if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404);
const p = request.payload;
org.smtp = org.smtp || {};
if (p.enabled !== undefined) org.smtp.enabled = Boolean(p.enabled);
if (p.host !== undefined) org.smtp.host = p.host;
if (p.port !== undefined) org.smtp.port = p.port;
if (p.secure !== undefined) org.smtp.secure = Boolean(p.secure);
if (p.user !== undefined) org.smtp.user = p.user;
if (p.fromName !== undefined) org.smtp.fromName = p.fromName;
if (p.fromAddress !== undefined) org.smtp.fromAddress = p.fromAddress;
if (p.password !== undefined && String(p.password).length > 0) {
org.smtp.passwordEnc = encrypt(String(p.password));
}
if (org.smtp.enabled) {
if (!org.smtp.host || !org.smtp.fromAddress) {
return h.response(error('VALIDATION_ERROR', 'When SMTP is enabled, host and fromAddress are required')).code(400);
}
}
await org.save();
clearOrgTransportCache(orgId);
const s = org.smtp;
return h.response(
success({
enabled: Boolean(s.enabled),
host: s.host || '',
port: s.port || 587,
secure: Boolean(s.secure),
user: s.user || '',
userLast4: s.user && String(s.user).length > 4 ? String(s.user).slice(-4) : s.user || '',
hasPassword: Boolean(s.passwordEnc),
fromName: s.fromName || '',
fromAddress: s.fromAddress || '',
})
);
};
const getWhatsAppSettings = async (request, h) => {
const { getOrgWhatsAppCredentials } = require('../utils/metaWhatsApp');
const user = request.auth.credentials.user;
if (!assertOrgAdmin(user)) return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403);
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const org = await Organization.findById(orgId).lean();
if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404);
const wc = org.whatsappCloud || {};
const orgHasOwnToken = Boolean(wc.enabled && wc.wabaId && wc.accessTokenEnc);
const creds = await getOrgWhatsAppCredentials(String(orgId));
const envWaba = (process.env.META_WHATSAPP_DEFAULT_WABA_ID || '').trim();
const envToken = (process.env.META_WHATSAPP_ACCESS_TOKEN || '').trim();
const usingServerDefaults = Boolean(creds) && !orgHasOwnToken && Boolean(envWaba && envToken);
const graphAccess = Boolean(creds && creds.wabaId && creds.accessToken);
const canSendMessages = Boolean(
creds && creds.accessToken && String(creds.phoneNumberId || '').trim() !== '',
);
return h.response(
success({
enabled: Boolean(wc.enabled),
wabaId: wc.wabaId || '',
phoneNumberId: wc.phoneNumberId || '',
hasAccessToken: Boolean(wc.accessTokenEnc),
usingServerDefaults,
graphAccess,
canSendMessages,
})
);
};
const updateWhatsAppSettings = async (request, h) => {
const user = request.auth.credentials.user;
if (!assertOrgAdmin(user)) return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403);
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const org = await Organization.findById(orgId);
if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404);
const p = request.payload;
org.whatsappCloud = org.whatsappCloud || {};
if (p.enabled !== undefined) org.whatsappCloud.enabled = Boolean(p.enabled);
if (p.wabaId !== undefined) org.whatsappCloud.wabaId = String(p.wabaId || '').trim();
if (p.phoneNumberId !== undefined) org.whatsappCloud.phoneNumberId = String(p.phoneNumberId || '').trim();
if (p.accessToken !== undefined && String(p.accessToken).length > 0) {
org.whatsappCloud.accessTokenEnc = encrypt(String(p.accessToken));
}
if (org.whatsappCloud.enabled) {
const wabaOk = org.whatsappCloud.wabaId?.trim() || (process.env.META_WHATSAPP_DEFAULT_WABA_ID || '').trim();
if (!wabaOk) {
return h.response(error('VALIDATION_ERROR', 'WABA ID is required when WhatsApp Cloud is enabled')).code(400);
}
const tokenOk = org.whatsappCloud.accessTokenEnc || (process.env.META_WHATSAPP_ACCESS_TOKEN || '').trim();
if (!tokenOk) {
return h.response(error('VALIDATION_ERROR', 'Access token is required when WhatsApp Cloud is enabled')).code(400);
}
}
await org.save();
return getWhatsAppSettings(request, h);
};
const testEmailSettings = async (request, h) => {
const user = request.auth.credentials.user;
if (!assertOrgAdmin(user)) return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403);
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const to = String(request.payload.to || '').trim();
if (!to) return h.response(error('VALIDATION_ERROR', 'to is required')).code(400);
const r = await sendOrgMail({
organizationId: orgId,
to,
subject: 'EventBuzz — SMTP test',
html: '<p>This is a test email from your EventBuzz organisation SMTP settings.</p>',
});
if (!r.ok && r.skipped) {
return h.response(error('VALIDATION_ERROR', 'No SMTP available — enable organisation SMTP or set MAIL_HOST on the server')).code(400);
}
if (!r.ok) return h.response(error('SEND_FAILED', r.error || 'Send failed')).code(502);
return h.response(success({ ok: true }));
};
const removeUser = async (request, h) => {
const target = await User.findById(request.params.id);
if (!target) return h.response(error('NOT_FOUND', 'User not found')).code(404);
@ -132,6 +287,11 @@ const removeUser = async (request, h) => {
module.exports = {
getOrganization,
updateOrganization,
getEmailSettings,
updateEmailSettings,
getWhatsAppSettings,
updateWhatsAppSettings,
testEmailSettings,
listUsers,
addUser,
updateUser,

View File

@ -5,6 +5,7 @@ const Participant = require('../models/Participant');
const { success, error } = require('../utils/response');
const { flattenParticipantForEvent } = require('../utils/participantMembership');
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
const { rulesToQuery } = require('../utils/participantListQuery');
function assertListOrg(list, user) {
if (user.role === 'super_admin') return true;
@ -19,103 +20,6 @@ async function resolveAllowedEventIds(user, queryOrganization) {
return { allowedIds: ids };
}
function mapUiStatusToApi(val) {
const s = String(val || '').toLowerCase().replace(/ /g, '_');
if (s === 'checked_in' || s === 'checkedin') return 'checked_in';
if (s === 'confirmed') return 'confirmed';
if (s === 'cancelled' || s === 'canceled') return 'cancelled';
if (s === 'pending') return 'pending';
return String(val || '').toLowerCase();
}
function rulesToQuery(rules, conjunction, allowedEventIds, listTenantOrg) {
if (!allowedEventIds.length) return { _id: { $in: [] } };
const oidList = allowedEventIds.map((id) =>
id instanceof mongoose.Types.ObjectId ? id : new mongoose.Types.ObjectId(String(id))
);
const clauses = (Array.isArray(rules) ? rules : [])
.map((rule) => {
const field = String(rule.field || '');
const op = String(rule.operator || 'is');
const val = String(rule.value || '').trim();
if (field === 'Status') {
const st = mapUiStatusToApi(val);
return {
eventMemberships: {
$elemMatch: {
event: { $in: oidList },
status: op === 'is not' ? { $ne: st } : st,
},
},
};
}
if (field === 'Tag') {
const inAllowedEvents = {
eventMemberships: { $elemMatch: { event: { $in: oidList } } },
};
const legacyMembershipTag = {
eventMemberships: {
$elemMatch: { event: { $in: oidList }, tags: val },
},
};
if (op === 'is not') {
return {
$and: [
inAllowedEvents,
{ tags: { $nin: [val] } },
{
eventMemberships: {
$not: {
$elemMatch: { event: { $in: oidList }, tags: val },
},
},
},
],
};
}
return {
$and: [inAllowedEvents, { $or: [{ tags: val }, legacyMembershipTag] }],
};
}
if (field === 'Event') {
if (!mongoose.Types.ObjectId.isValid(val)) return null;
const eid = new mongoose.Types.ObjectId(val);
if (!oidList.some((x) => String(x) === String(eid))) return null;
return {
eventMemberships: {
$elemMatch: {
event: op === 'is not' ? { $nin: [eid] } : eid,
},
},
};
}
if (field === 'Role' || field === 'Registration Date') {
return null;
}
return null;
})
.filter(Boolean);
const base = {
tenantOrganization:
listTenantOrg instanceof mongoose.Types.ObjectId
? listTenantOrg
: new mongoose.Types.ObjectId(String(listTenantOrg)),
};
if (!clauses.length) {
return {
...base,
eventMemberships: { $elemMatch: { event: { $in: oidList } } },
};
}
const combined = conjunction === 'OR' ? { $or: clauses } : { $and: clauses };
return { $and: [base, combined] };
}
const list = async (request, h) => {
const user = request.auth.credentials.user;
const scoped = resolveScopedOrganizationId(user, request.query.organization);

View File

@ -0,0 +1,371 @@
const Organization = require('../models/Organization');
const User = require('../models/User');
const OrganizationUserMap = require('../models/OrganizationUserMap');
const SubscriptionPlan = require('../models/SubscriptionPlan');
const { success, error } = require('../utils/response');
const { defaultFeatureValuesByPlanId } = require('../config/planFeatureMatrix');
const {
FEATURES,
DEFAULT_PLANS,
listFeatures,
listPlans,
invalidatePlanCache,
warmPlanCache,
} = require('../config/subscriptionCatalog');
function defaultPricingForPlan(planId) {
return (
DEFAULT_PLANS[planId]?.pricing || {
amount: null,
currency: 'INR',
interval: 'month',
contactSales: false,
}
);
}
/** Merge optional `pricing` payload with catalog defaults or existing document. */
function applyFeatureValuesPayload(incoming, existingRow, planId) {
const defaults = defaultFeatureValuesByPlanId(planId);
const base =
existingRow && existingRow.featureValues && typeof existingRow.featureValues === 'object'
? {
event_website: existingRow.featureValues.event_website ?? defaults.event_website,
email_outreach_campaigns: {
maxEmails:
existingRow.featureValues.email_outreach_campaigns &&
existingRow.featureValues.email_outreach_campaigns.maxEmails !== undefined
? existingRow.featureValues.email_outreach_campaigns.maxEmails
: defaults.email_outreach_campaigns.maxEmails,
},
}
: defaults;
if (!incoming || typeof incoming !== 'object') return base;
const maxFromIn =
incoming.email_outreach_campaigns &&
typeof incoming.email_outreach_campaigns === 'object' &&
incoming.email_outreach_campaigns.maxEmails !== undefined
? incoming.email_outreach_campaigns.maxEmails === null
? null
: Number(incoming.email_outreach_campaigns.maxEmails)
: undefined;
return {
event_website:
incoming.event_website !== undefined ? String(incoming.event_website) : base.event_website,
email_outreach_campaigns: {
maxEmails: maxFromIn !== undefined ? maxFromIn : base.email_outreach_campaigns.maxEmails,
},
};
}
function applyPricingPayload(incoming, existingRow, planId) {
const base =
existingRow && existingRow.pricing && typeof existingRow.pricing === 'object'
? {
amount: existingRow.pricing.amount,
currency: existingRow.pricing.currency,
interval: existingRow.pricing.interval,
contactSales: existingRow.pricing.contactSales,
}
: defaultPricingForPlan(planId);
if (!incoming || typeof incoming !== 'object') return base;
return {
amount:
incoming.amount !== undefined
? incoming.amount === null
? null
: Number(incoming.amount)
: base.amount,
currency:
incoming.currency !== undefined
? String(incoming.currency || '').trim() || 'INR'
: base.currency,
interval:
incoming.interval !== undefined
? String(incoming.interval || '').trim() || 'month'
: base.interval,
contactSales:
incoming.contactSales !== undefined ? Boolean(incoming.contactSales) : base.contactSales,
};
}
function escapeRegex(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function serializeOrgDoc(org, userCount) {
const o = typeof org.toObject === 'function' ? org.toObject() : { ...org };
o._id = o._id != null ? String(o._id) : o._id;
o.userCount = userCount;
return o;
}
async function listOrganizations(request, h) {
const page = Math.max(1, Number(request.query.page) || 1);
const limit = Math.min(100, Math.max(1, Number(request.query.limit) || 20));
const search = request.query.search ? String(request.query.search).trim() : '';
const q = {};
if (search) {
const rx = new RegExp(escapeRegex(search), 'i');
q.$or = [{ name: rx }, { email: rx }];
}
const skip = (page - 1) * limit;
const [orgs, total] = await Promise.all([
Organization.find(q).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
Organization.countDocuments(q),
]);
const organizations = await Promise.all(
orgs.map(async (org) => {
const userCount = await User.countDocuments({ organization: org._id });
return serializeOrgDoc(org, userCount);
}),
);
return h.response(success({ organizations, total, page, limit })).code(200);
}
/**
* Create a tenant organization and its first user (org role super_admin).
*/
async function createOrganization(request, h) {
const { organization: orgIn, admin } = request.payload;
const adminEmail = String(admin.email).trim();
const existingUser = await User.findOne({ email: adminEmail });
if (existingUser) {
return h.response(error('VALIDATION_ERROR', 'A user with this email already exists')).code(400);
}
const sub = {};
if (orgIn.subscription && typeof orgIn.subscription === 'object') {
if (orgIn.subscription.plan != null) sub.plan = orgIn.subscription.plan;
if (orgIn.subscription.status != null) sub.status = orgIn.subscription.status;
}
const str = (v) => (v == null ? undefined : String(v).trim());
const opt = (v) => {
const s = str(v);
return s === '' ? undefined : s;
};
const org = await Organization.create({
name: str(orgIn.name),
description: opt(orgIn.description),
email: opt(orgIn.email),
phone: opt(orgIn.phone),
website: opt(orgIn.website),
timezone: opt(orgIn.timezone),
...(Object.keys(sub).length ? { subscription: sub } : {}),
});
const user = await User.create({
email: adminEmail,
password: admin.password,
firstName: admin.firstName != null ? String(admin.firstName).trim() : undefined,
lastName: admin.lastName != null ? String(admin.lastName).trim() : undefined,
organization: org._id,
role: 'super_admin',
});
await OrganizationUserMap.create({
user: user._id,
organization: org._id,
role: 'super_admin',
eventAccess: [],
});
const userCount = await User.countDocuments({ organization: org._id });
const organization = serializeOrgDoc(await Organization.findById(org._id).lean(), userCount);
const adminOut = user.toObject();
delete adminOut.password;
return h
.response(
success({
organization,
adminUser: adminOut,
}),
)
.code(201);
}
async function getOrganization(request, h) {
const org = await Organization.findById(request.params.id).lean();
if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404);
const userCount = await User.countDocuments({ organization: org._id });
return h.response(success({ organization: serializeOrgDoc(org, userCount) })).code(200);
}
async function patchOrganizationSubscription(request, h) {
const org = await Organization.findById(request.params.id);
if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404);
const p = request.payload;
if (!org.subscription) org.subscription = {};
const sub = org.subscription;
if (p.plan != null) sub.plan = p.plan;
if (p.status != null) sub.status = p.status;
if (p.expiresAt !== undefined) sub.expiresAt = p.expiresAt ? new Date(p.expiresAt) : undefined;
if (p.maxEvents !== undefined) sub.maxEvents = p.maxEvents;
if (p.maxParticipants !== undefined) sub.maxParticipants = p.maxParticipants;
if (Array.isArray(p.featureOverrides)) sub.featureOverrides = p.featureOverrides.map(String);
org.subscription = sub;
org.markModified('subscription');
await org.save();
const userCount = await User.countDocuments({ organization: org._id });
return h.response(success({ organization: serializeOrgDoc(org, userCount) })).code(200);
}
async function listPermissions(request, h) {
return h.response(success({ features: listFeatures() })).code(200);
}
async function listPlansAdmin(request, h) {
const plans = await listPlans();
const withCounts = await Promise.all(
plans.map(async (p) => ({
...p,
organizationCount: await Organization.countDocuments({ 'subscription.plan': p.id }),
})),
);
return h.response(success({ plans: withCounts })).code(200);
}
async function createPlan(request, h) {
const { PLAN_ID_REGEX } = SubscriptionPlan;
const b = request.payload;
const planId = String(b.planId || '').trim();
if (!PLAN_ID_REGEX.test(planId)) {
return h
.response(
error(
'VALIDATION_ERROR',
'planId must be a lowercase slug: start with a letter, then letters, digits, or underscores (max 64 chars).',
),
)
.code(400);
}
const dup = await SubscriptionPlan.findOne({ planId });
if (dup) {
return h.response(error('VALIDATION_ERROR', 'A plan with this id already exists')).code(400);
}
const featureKeys = Array.isArray(b.featureKeys)
? [...new Set(b.featureKeys.filter((k) => typeof k === 'string' && FEATURES[k]))]
: [];
await SubscriptionPlan.create({
planId,
name: String(b.name).trim(),
tagline: b.tagline != null ? String(b.tagline) : '',
featureKeys,
defaultLimits:
b.defaultLimits && typeof b.defaultLimits === 'object'
? {
maxEvents: b.defaultLimits.maxEvents,
maxParticipants: b.defaultLimits.maxParticipants,
}
: { maxEvents: null, maxParticipants: null },
pricing: applyPricingPayload(b.pricing, null, planId),
featureValues: applyFeatureValuesPayload(b.featureValues, null, planId),
});
invalidatePlanCache();
await warmPlanCache();
const plans = await listPlans();
const withCounts = await Promise.all(
plans.map(async (p) => ({
...p,
organizationCount: await Organization.countDocuments({ 'subscription.plan': p.id }),
})),
);
return h.response(success({ plans: withCounts })).code(201);
}
async function putPlan(request, h) {
const { PLAN_ID_REGEX } = SubscriptionPlan;
const { planId } = request.params;
if (!PLAN_ID_REGEX.test(planId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid plan id')).code(400);
}
const b = request.payload;
const existing = await SubscriptionPlan.findOne({ planId }).lean();
if (!existing) {
return h.response(error('NOT_FOUND', 'Plan not found')).code(404);
}
const featureKeys = Array.isArray(b.featureKeys)
? [...new Set(b.featureKeys.filter((k) => typeof k === 'string' && FEATURES[k]))]
: undefined;
const set = {};
if (b.name != null) set.name = String(b.name).trim();
if (b.tagline !== undefined) set.tagline = String(b.tagline);
if (featureKeys !== undefined) set.featureKeys = featureKeys;
if (b.defaultLimits != null && typeof b.defaultLimits === 'object') {
set.defaultLimits = {
maxEvents: b.defaultLimits.maxEvents,
maxParticipants: b.defaultLimits.maxParticipants,
};
}
if (b.pricing !== undefined) {
set.pricing = applyPricingPayload(b.pricing, existing, planId);
}
if (b.featureValues !== undefined) {
set.featureValues = applyFeatureValuesPayload(b.featureValues, existing, planId);
}
if (Object.keys(set).length === 0) {
return h.response(error('VALIDATION_ERROR', 'No valid fields to update')).code(400);
}
const updated = await SubscriptionPlan.findOneAndUpdate({ planId }, { $set: set }, { new: true });
if (!updated) {
return h.response(error('NOT_FOUND', 'Plan not found')).code(404);
}
invalidatePlanCache();
await warmPlanCache();
const plans = await listPlans();
const withCounts = await Promise.all(
plans.map(async (p) => ({
...p,
organizationCount: await Organization.countDocuments({ 'subscription.plan': p.id }),
})),
);
return h.response(success({ plans: withCounts })).code(200);
}
async function deletePlan(request, h) {
const { PLAN_ID_REGEX } = SubscriptionPlan;
const { planId } = request.params;
if (!PLAN_ID_REGEX.test(planId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid plan id')).code(400);
}
const inUse = await Organization.countDocuments({ 'subscription.plan': planId });
if (inUse > 0) {
return h
.response(
error(
'CONFLICT',
`This plan is assigned to ${inUse} organization(s). Move them to another plan before deleting.`,
),
)
.code(409);
}
const removed = await SubscriptionPlan.findOneAndDelete({ planId });
if (!removed) {
return h.response(error('NOT_FOUND', 'Plan not found')).code(404);
}
invalidatePlanCache();
await warmPlanCache();
const plans = await listPlans();
const withCounts = await Promise.all(
plans.map(async (p) => ({
...p,
organizationCount: await Organization.countDocuments({ 'subscription.plan': p.id }),
})),
);
return h.response(success({ plans: withCounts })).code(200);
}
module.exports = {
listOrganizations,
createOrganization,
getOrganization,
patchOrganizationSubscription,
listPermissions,
listPlansAdmin,
createPlan,
putPlan,
deletePlan,
};

View File

@ -0,0 +1,88 @@
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const {
performCheckIn,
loadAttendanceByToken,
searchEventParticipantsForAttendance,
} = require('../services/attendanceCheckIn.service');
const getSession = async (request, h) => {
const { token } = request.params;
const r = await loadAttendanceByToken(token);
if (r.err) return h.response(r.err).code(404);
const attendance = r.attendance;
if (attendance.status !== 'active') {
return h.response(error('BAD_REQUEST', 'This attendance session is closed')).code(400);
}
const event = await Event.findById(attendance.event)
.select('name subtitle startDate endDate location locationType image')
.lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
return h.response(
success({
name: attendance.name,
description: attendance.description,
settings: {
allowDuplicateCheckIn: Boolean(attendance.settings?.allowDuplicateCheckIn),
publicLinkEnabled: Boolean(attendance.settings?.publicLinkEnabled),
},
status: attendance.status,
event: {
name: event.name,
subtitle: event.subtitle,
startDate: event.startDate,
endDate: event.endDate,
location: event.location,
locationType: event.locationType,
image: event.image,
},
})
);
};
const search = async (request, h) => {
const { token } = request.params;
const r = await loadAttendanceByToken(token);
if (r.err) return h.response(r.err).code(404);
const attendance = r.attendance;
if (attendance.status !== 'active') {
return h.response(error('BAD_REQUEST', 'This attendance session is closed')).code(400);
}
if (!attendance.settings?.publicLinkEnabled) {
return h.response(error('FORBIDDEN', 'Public check-in link is disabled')).code(403);
}
const q = String(request.query.q || '').trim();
const data = await searchEventParticipantsForAttendance({
eventId: attendance.event,
organizationId: attendance.organization,
q: q || undefined,
attendanceId: attendance._id,
limit: q ? 500 : 5000,
maxCap: 5000,
});
return h.response(success(data));
};
const checkIn = async (request, h) => {
const { token } = request.params;
const { participantId } = request.payload;
const outcome = await performCheckIn({
publicToken: token,
participantId,
source: 'public',
});
if (outcome.err) return h.response(outcome.err).code(outcome.statusCode || 400);
return h.response(success(outcome.result, 'Checked in'));
};
module.exports = {
getSession,
search,
checkIn,
};

View File

@ -0,0 +1,134 @@
const crypto = require('crypto');
const mongoose = require('mongoose');
const ConciergeBot = require('../models/ConciergeBot');
const Event = require('../models/Event');
const WebChatSession = require('../models/WebChatSession');
const { success, error } = require('../utils/response');
const { executeConciergeRag } = require('../services/conciergeRag.service');
const MAX_MESSAGE_LENGTH = 800;
const MAX_HISTORY_MESSAGES = 40;
// Basic in-memory rate limit (best-effort).
const ipBuckets = new Map();
const WINDOW_MS = 60_000;
const MAX_REQ_PER_WINDOW = 20;
function takeIpToken(ip) {
const now = Date.now();
const key = String(ip || 'unknown');
const row = ipBuckets.get(key) || { resetAt: now + WINDOW_MS, count: 0 };
if (now > row.resetAt) {
row.resetAt = now + WINDOW_MS;
row.count = 0;
}
row.count += 1;
ipBuckets.set(key, row);
return row.count <= MAX_REQ_PER_WINDOW;
}
async function loadBotOr404(botId) {
if (!mongoose.Types.ObjectId.isValid(String(botId))) return { err: error('VALIDATION_ERROR', 'Invalid bot') };
const bot = await ConciergeBot.findById(botId).lean();
if (!bot) return { err: error('NOT_FOUND', 'Chatbot not found') };
if (!bot.enabled || bot.status !== 'ACTIVE') return { err: error('FORBIDDEN', 'Chatbot is not enabled') };
const event = await Event.findById(bot.event).select('name').lean();
return { bot, event };
}
const config = async (request, h) => {
const { botId } = request.params;
const { bot, event, err } = await loadBotOr404(botId);
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
return h.response(
success({
botId: String(bot._id),
eventId: String(bot.event),
eventName: event?.name || 'Event',
greeting: bot.greeting?.message || '',
maxMessageLength: MAX_MESSAGE_LENGTH,
}),
);
};
const chat = async (request, h) => {
const ip = request.headers['x-forwarded-for'] || request.info.remoteAddress;
if (!takeIpToken(ip)) return h.response(error('RATE_LIMIT', 'Too many requests. Try again in a minute.')).code(429);
const { botId } = request.params;
const { bot, event, err } = await loadBotOr404(botId);
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
const msg = String(request.payload?.message || '').trim();
if (!msg) return h.response(error('VALIDATION_ERROR', 'Message is required')).code(400);
if (msg.length > MAX_MESSAGE_LENGTH) {
return h
.response(error('VALIDATION_ERROR', `Message too long (max ${MAX_MESSAGE_LENGTH} chars)`))
.code(400);
}
let sessionId = String(request.payload?.sessionId || '').trim();
if (!sessionId) sessionId = crypto.randomBytes(16).toString('hex');
let session = await WebChatSession.findOne({ botId: bot._id, sessionId });
if (!session) {
session = await WebChatSession.create({
botId: bot._id,
eventId: bot.event,
organization: bot.organization,
sessionId,
history: [],
lastSeenAt: new Date(),
});
}
session.history.push({
role: 'user',
text: msg,
ts: new Date(),
modelId: null,
inputTokens: 0,
outputTokens: 0,
});
if (session.history.length > MAX_HISTORY_MESSAGES) {
session.history = session.history.slice(-MAX_HISTORY_MESSAGES);
}
session.lastSeenAt = new Date();
const historyForPrompt = session.history.slice(-12).map((m) => ({ role: m.role, text: m.text }));
const includeEventDetails = bot.knowledge?.includeEventDetails !== false;
const ragResult = await executeConciergeRag(String(bot.event), msg, event?.name || 'Event', {
includeEventDetails,
history: historyForPrompt,
});
const answerText =
ragResult?.text ||
"I don't have that in my knowledge base yet. Please check with event staff or try another question.";
const reply = String(answerText || '').trim().slice(0, 1200);
const inTok = ragResult?.usage?.inputTokens || 0;
const outTok = ragResult?.usage?.outputTokens || 0;
session.inputTokensTotal = Number(session.inputTokensTotal || 0) + Number(inTok || 0);
session.outputTokensTotal = Number(session.outputTokensTotal || 0) + Number(outTok || 0);
session.history.push({
role: 'assistant',
text: reply,
ts: new Date(),
modelId: ragResult?.modelId || null,
inputTokens: inTok,
outputTokens: outTok,
});
if (session.history.length > MAX_HISTORY_MESSAGES) {
session.history = session.history.slice(-MAX_HISTORY_MESSAGES);
}
await session.save();
return h.response(success({ sessionId, reply, usage: ragResult?.usage || null }));
};
module.exports = {
config,
chat,
};

View File

@ -0,0 +1,378 @@
const mongoose = require('mongoose');
const Event = require('../models/Event');
const EventTicket = require('../models/EventTicket');
const Form = require('../models/Form');
const TicketManualOrder = require('../models/TicketManualOrder');
const { success, error } = require('../utils/response');
const { computeTicketQuote, ticketRequiresBillingState } = require('../utils/eventTicketPricing');
const { createPaymentOrderCore } = require('../services/payments/createOrderCore');
const { validateAndBuildTicketPurchaseContext } = require('../utils/publicTicketCheckoutShared');
const { organizationHasOnlineTicketCheckout } = require('../utils/eventTicketingGateway');
const { uploadBufferToS3AndGetCloudFrontUrl } = require('../utils/s3Media');
const { notifyTicketStakeholders } = require('../services/ticketSaleNotify');
const { isManualPaymentConfigured } = require('../utils/ticketingManualPayment');
function publicTicketShape(ticket, registrationFormFields) {
return {
_id: ticket._id,
name: ticket.name,
description: ticket.description,
keyPoints: ticket.keyPoints || [],
basePriceMinor: ticket.basePriceMinor,
currency: ticket.currency || 'INR',
maxPerOrder: ticket.maxPerOrder,
requiresBillingState: ticketRequiresBillingState(ticket),
registrationFormId: ticket.registrationForm || null,
registrationFormFields: registrationFormFields || null,
};
}
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,
}));
return fields;
}
const eventSummary = async (request, h) => {
const { eventId } = request.params;
if (!mongoose.Types.ObjectId.isValid(eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event')).code(400);
}
const event = await Event.findById(eventId)
.select('name subtitle startDate endDate location locationType image')
.lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
return h.response(
success({
name: event.name,
subtitle: event.subtitle,
startDate: event.startDate,
endDate: event.endDate,
location: event.location,
locationType: event.locationType,
image: event.image,
}),
);
};
const listCatalog = async (request, h) => {
const { eventId } = request.params;
if (!mongoose.Types.ObjectId.isValid(eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event')).code(400);
}
const event = await Event.findById(eventId).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const tickets = await EventTicket.find({ event: eventId, isActive: true }).sort({ sortOrder: 1, name: 1 }).lean();
const out = [];
for (const t of tickets) {
const regFields = t.registrationForm ? await loadFormFieldsForPublic(t.registrationForm) : null;
out.push(publicTicketShape(t, regFields));
}
return h.response(success({ tickets: out }));
};
const quote = async (request, h) => {
const { eventId } = request.params;
const { ticketId, quantity, billingStateCode } = request.payload;
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(ticketId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
const ticket = await EventTicket.findOne({ _id: ticketId, event: eventId, isActive: true }).lean();
if (!ticket) return h.response(error('NOT_FOUND', 'Ticket not found')).code(404);
const state = billingStateCode ? String(billingStateCode).trim().toUpperCase() : '';
if (ticketRequiresBillingState(ticket) && !state) {
return h.response(error('VALIDATION_ERROR', 'Billing state is required for this ticket')).code(400);
}
const qty = Math.min(100, Math.max(1, parseInt(quantity, 10) || 1));
if (ticket.maxPerOrder && qty > ticket.maxPerOrder) {
return h.response(error('VALIDATION_ERROR', `Maximum ${ticket.maxPerOrder} per order`)).code(400);
}
const q = computeTicketQuote(ticket, qty, state);
return h.response(
success({
ticketId: String(ticket._id),
quantity: qty,
billingStateCode: state || null,
baseMinor: q.baseMinor,
taxMinor: q.taxMinor,
totalMinor: q.totalMinor,
taxLines: q.taxLines,
currency: ticket.currency || 'INR',
}),
);
};
const checkoutOptions = async (request, h) => {
const { eventId } = request.params;
if (!mongoose.Types.ObjectId.isValid(eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event')).code(400);
}
const event = await Event.findById(eventId).select('name organization ticketingManualPayment').lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const onlineAvailable = await organizationHasOnlineTicketCheckout(event.organization);
const mp = event.ticketingManualPayment || {};
const manualEligible = Boolean(mp.enabled && isManualPaymentConfigured(mp));
const manualQrMode = mp.manualQrMode === 'dynamic' ? 'dynamic' : 'upload';
return h.response(
success({
onlineAvailable,
manualUpi: manualEligible
? {
manualQrMode,
eventName: event.name || '',
upiQrImageUrl: String(mp.upiQrImageUrl || '').trim(),
upiId: mp.upiId || '',
payeeName: mp.payeeName || '',
instructions: mp.instructions || '',
/** Always 0 — organiser covers Eventbuzz via manual UPI enablement fee; buyers pay ticket + tax only. */
platformFeeInr: 0,
}
: null,
}),
);
};
const manualQuote = async (request, h) => {
const { eventId } = request.params;
const { ticketId, quantity, billingStateCode } = request.payload;
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(ticketId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
const event = await Event.findById(eventId).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const mp = event.ticketingManualPayment || {};
if (!mp.enabled || !isManualPaymentConfigured(mp)) {
return h.response(error('VALIDATION_ERROR', 'Manual UPI is not enabled for this event')).code(400);
}
const ticket = await EventTicket.findOne({ _id: ticketId, event: eventId, isActive: true }).lean();
if (!ticket) return h.response(error('NOT_FOUND', 'Ticket not found')).code(404);
const state = billingStateCode ? String(billingStateCode).trim().toUpperCase() : '';
if (ticketRequiresBillingState(ticket) && !state) {
return h.response(error('VALIDATION_ERROR', 'Billing state is required for this ticket')).code(400);
}
const qty = Math.min(100, Math.max(1, parseInt(quantity, 10) || 1));
if (ticket.maxPerOrder && qty > ticket.maxPerOrder) {
return h.response(error('VALIDATION_ERROR', `Maximum ${ticket.maxPerOrder} per order`)).code(400);
}
const q = computeTicketQuote(ticket, qty, state);
return h.response(
success({
ticketId: String(ticket._id),
ticketName: ticket.name || '',
eventName: event.name || '',
quantity: qty,
billingStateCode: state || null,
baseMinor: q.baseMinor,
taxMinor: q.taxMinor,
platformFeeMinor: 0,
totalMinor: q.totalMinor,
taxLines: q.taxLines,
currency: ticket.currency || 'INR',
}),
);
};
function parseDataUrlImage(dataUrl) {
const s = String(dataUrl || '');
const m = s.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/);
if (!m) return null;
const contentType = m[1];
const buf = Buffer.from(m[2], 'base64');
if (!buf.length || buf.length > 5 * 1024 * 1024) return null;
return { buffer: buf, contentType };
}
const manualSubmit = async (request, h) => {
const { eventId } = request.params;
const p = request.payload;
const event = await Event.findById(eventId).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const mp = event.ticketingManualPayment || {};
if (!mp.enabled || !isManualPaymentConfigured(mp)) {
return h.response(error('VALIDATION_ERROR', 'Manual UPI is not enabled for this event')).code(400);
}
const v = await validateAndBuildTicketPurchaseContext(eventId, p);
if (!v.ok) {
const code = v.code === 'NOT_FOUND' ? 404 : 400;
return h.response(error(v.code, v.message)).code(code);
}
const q = v.quoteResult;
if (q.totalMinor !== Math.round(Number(p.expectedTotalMinor))) {
return h.response(error('VALIDATION_ERROR', 'Price changed — refresh and try again')).code(409);
}
const img = parseDataUrlImage(p.screenshotDataUrl);
if (!img) {
return h.response(error('VALIDATION_ERROR', 'Invalid or too large payment screenshot (use a base64 data URL image, max 5MB)')).code(400);
}
const orgId = String(event.organization);
const filename = `ticket-manual-${Date.now()}.${img.contentType.includes('png') ? 'png' : 'jpg'}`;
let screenshotUrl;
try {
const up = await uploadBufferToS3AndGetCloudFrontUrl({
buffer: img.buffer,
contentType: img.contentType,
orgId,
eventId: String(eventId),
module: 'ticket-manual-payments',
filename,
});
screenshotUrl = up.url;
} catch (e) {
console.error('[manualSubmit] S3 upload failed', e);
return h.response(error('SERVER_ERROR', 'Could not store screenshot')).code(500);
}
const registrationContext = {
...v.registrationContext,
quote: {
baseMinor: q.baseMinor,
taxMinor: q.taxMinor,
totalMinor: q.totalMinor,
taxLines: q.taxLines,
platformFeeMinor: 0,
quantity: v.qty,
},
};
const order = await TicketManualOrder.create({
event: eventId,
organization: event.organization,
ticket: p.ticketId,
purchaserEmail: registrationContext.purchaserEmail,
attendees: registrationContext.attendees,
quote: registrationContext.quote,
currency: v.currency,
billingStateCode: v.state || '',
screenshotUrl,
buyerNote: p.buyerNote ? String(p.buyerNote).trim().slice(0, 2000) : '',
status: 'pending_review',
});
void notifyTicketStakeholders({
kind: 'manual_submitted',
eventId: String(eventId),
organizationId: orgId,
ticketName: v.ticket.name,
purchaserEmail: registrationContext.purchaserEmail,
amountMinor: q.totalMinor,
currency: v.currency,
orderId: String(order._id),
});
return h
.response(
success({
orderId: String(order._id),
message: 'Submitted for review. You will be notified when it is approved.',
}),
)
.code(201);
};
const checkout = async (request, h) => {
const { eventId } = request.params;
const p = request.payload;
const event = await Event.findById(eventId).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const onlineAvailable = await organizationHasOnlineTicketCheckout(event.organization);
if (!onlineAvailable) {
return h.response(
error(
'VALIDATION_ERROR',
'Online ticket checkout requires the organiser’s connected Razorpay account. Use UPI / manual checkout for this event if it is enabled, or ask the organiser to add their payment keys.',
),
).code(400);
}
const v = await validateAndBuildTicketPurchaseContext(eventId, p);
if (!v.ok) {
const code = v.code === 'NOT_FOUND' ? 404 : 400;
return h.response(error(v.code, v.message)).code(code);
}
if (v.quoteResult.totalMinor !== Math.round(Number(p.expectedTotalMinor))) {
return h.response(error('VALIDATION_ERROR', 'Price changed — refresh and try again')).code(409);
}
try {
const result = await createPaymentOrderCore({
organizationId: event.organization,
eventId,
participantId: null,
amountSubtotalMinor: v.quoteResult.totalMinor,
currency: v.currency,
idempotencyKey: p.idempotencyKey || null,
registrationContext: v.registrationContext,
purpose: 'ticket_purchase',
});
return h
.response(
success({
keyId: result.keyId,
orderId: result.orderId,
amount: result.amount,
currency: result.currency,
transactionId: result.transaction._id.toString(),
checkoutSource: result.checkoutSource,
}),
)
.code(201);
} catch (e) {
if (e.code === 'IDEMPOTENCY_COMPLETE') {
return h.response(error('VALIDATION_ERROR', e.message)).code(409);
}
if (
e.code === 'GATEWAY_NOT_IMPLEMENTED' ||
e.code === 'PLATFORM_CHECKOUT_DISABLED' ||
e.code === 'PLATFORM_RAZORPAY_MISSING' ||
e.code === 'NO_GATEWAY' ||
e.code === 'TICKET_ORG_GATEWAY_REQUIRED' ||
e.code === 'BAD_GATEWAY_CREDENTIALS' ||
e.code === 'IDEMPOTENCY_MISMATCH' ||
e.code === 'INVALID_AMOUNT' ||
e.code === 'RAZORPAY_SDK_MISSING'
) {
return h.response(error('VALIDATION_ERROR', e.message)).code(400);
}
throw e;
}
};
module.exports = {
eventSummary,
listCatalog,
quote,
checkoutOptions,
manualQuote,
manualSubmit,
checkout,
};

View File

@ -1,5 +1,6 @@
const Question = require('../models/Question');
const Event = require('../models/Event');
const Session = require('../models/Session');
const { success, error } = require('../utils/response');
const {
syncSessionQuestionToFirebase,
@ -147,7 +148,16 @@ const answer = async (request, h) => {
};
const sessionQuestions = async (request, h) => {
const questions = await Question.find({ session: request.params.sessionId }).populate('participant user').lean();
const { sessionId } = request.params;
const session = await Session.findById(sessionId).lean();
if (!session) return h.response(error('NOT_FOUND', 'Session not found')).code(404);
const event = await Event.findById(session.event);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const questions = await Question.find({ session: sessionId }).populate('participant user').sort({ upvotes: -1, createdAt: -1 }).lean();
return h.response(success({ questions }));
};

View File

@ -1,12 +1,183 @@
const { success } = require('../utils/response');
const { listFeatures, listPlans } = require('../config/subscriptionCatalog');
const PaymentTransaction = require('../models/PaymentTransaction');
const Organization = require('../models/Organization');
const Event = require('../models/Event');
const User = require('../models/User');
const { success, error } = require('../utils/response');
const { sendContactSalesLeadEmail } = require('../utils/mail');
const { verifyPaymentCore, minorToMajor } = require('../services/payments/verifyPaymentCore');
const {
createSubscriptionCheckoutOrder,
buildSubscriptionInvoiceHtml,
buildManualEnablementInvoiceHtml,
} = require('../services/subscriptionPayment.service');
const { listFeatures, listPlans, getPlanForAccess } = require('../config/subscriptionCatalog');
const catalog = async (request, h) =>
h.response(
success({
features: listFeatures(),
plans: listPlans(),
plans: await listPlans(),
}),
);
module.exports = { catalog };
const checkoutOrder = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
const { targetPlanId } = request.payload;
try {
const result = await createSubscriptionCheckoutOrder({
organizationId: orgId,
userId: user._id,
targetPlanId: String(targetPlanId).trim(),
});
return h
.response(
success({
keyId: result.keyId,
orderId: result.orderId,
amount: result.amount,
currency: result.currency,
transactionId: result.transaction._id.toString(),
checkoutSource: result.checkoutSource,
}),
)
.code(201);
} catch (e) {
const code = e.code || 'ERROR';
const msg = e.message || 'Failed to create order';
if (['INVALID_PLAN', 'CONTACT_SALES', 'NO_CHARGE', 'PLAN_MISSING'].includes(code)) {
return h.response(error(code, msg)).code(400);
}
if (code === 'INVALID_AMOUNT') return h.response(error('VALIDATION_ERROR', msg)).code(400);
if (code === 'PLATFORM_RAZORPAY_MISSING' || code === 'NO_GATEWAY') {
return h.response(error('VALIDATION_ERROR', msg)).code(400);
}
throw e;
}
};
const checkoutVerify = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
const { orderId, paymentId, signature } = request.payload;
const out = await verifyPaymentCore({
orderId,
paymentId,
signature,
organizationId: orgId,
});
if (out.error) {
const status = out.error === 'NOT_FOUND' ? 404 : 400;
return h.response(error(out.error, out.message)).code(status);
}
return h.response(
success({
verified: true,
transactionId: out.transaction._id.toString(),
}),
);
};
const listInvoices = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
const rows = await PaymentTransaction.find({
organization: orgId,
purpose: { $in: ['saas_subscription', 'ticketing_manual_enablement'] },
status: 'paid',
})
.sort({ createdAt: -1 })
.limit(100)
.lean();
const invoices = rows.map((r) => {
const isEnablement = r.purpose === 'ticketing_manual_enablement';
return {
id: r._id.toString(),
createdAt: r.createdAt,
planId: isEnablement ? 'manual_upi_enablement' : r.subscriptionPlanId,
invoiceLabel: isEnablement ? 'Manual UPI ticketing — enablement fee' : r.subscriptionPlanId,
amountMajor: minorToMajor(r.amountGross, r.currency),
currency: r.currency,
status: r.status,
razorpayOrderId: r.externalOrderId,
razorpayPaymentId: r.externalPaymentId,
};
});
return h.response(success({ invoices }));
};
const contactSalesRequest = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('NO_ORG', 'No organisation')).code(400);
const { requirements, targetPlanId } = request.payload;
const org = await Organization.findById(orgId).lean();
const planKey = targetPlanId ? String(targetPlanId).trim() : 'enterprise';
const planRow = getPlanForAccess(planKey);
const admins = await User.find({ platform_admin: 'yes', isActive: { $ne: false } })
.select('email')
.lean();
const emails = admins.map((a) => a.email).filter(Boolean);
if (emails.length === 0) {
return h
.response(error('NO_PLATFORM_ADMIN', 'No platform administrators are configured to receive sales enquiries.'))
.code(503);
}
const requesterName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
const ok = await sendContactSalesLeadEmail({
to: emails,
requesterEmail: user.email,
requesterName: requesterName || undefined,
organizationName: org?.name,
planId: planRow.id || planKey,
planName: planRow.name,
requirements,
});
if (!ok) {
return h.response(error('MAIL_FAILED', 'Could not send message. Check server email configuration.')).code(502);
}
return h.response(success({ sent: true })).code(200);
};
const downloadInvoice = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
const { transactionId } = request.params;
const tx = await PaymentTransaction.findOne({
_id: transactionId,
organization: orgId,
purpose: { $in: ['saas_subscription', 'ticketing_manual_enablement'] },
status: 'paid',
}).lean();
if (!tx) return h.response(error('NOT_FOUND', 'Invoice not found')).code(404);
const org = await Organization.findById(orgId).lean();
let html;
if (tx.purpose === 'ticketing_manual_enablement') {
const ctx = tx.registrationContext && typeof tx.registrationContext === 'object' ? tx.registrationContext : {};
let eventName = '';
if (ctx.eventId) {
const ev = await Event.findById(ctx.eventId).select('name').lean();
eventName = ev?.name || '';
}
html = buildManualEnablementInvoiceHtml(org, tx, eventName);
} else {
html = buildSubscriptionInvoiceHtml(org, tx);
}
return h
.response(html)
.type('text/html; charset=utf-8')
.header('Content-Disposition', `attachment; filename="eventbuzz-invoice-${tx._id}.html"`);
};
module.exports = {
catalog,
checkoutOrder,
checkoutVerify,
contactSalesRequest,
listInvoices,
downloadInvoice,
};

View File

@ -0,0 +1,149 @@
const mongoose = require('mongoose');
const Event = require('../models/Event');
const TicketManualOrder = require('../models/TicketManualOrder');
const EventTicket = require('../models/EventTicket');
const { success, error } = require('../utils/response');
const { fulfillTicketPurchaseFromContext } = require('../services/ticketPurchaseFulfillment');
const { notifyTicketStakeholders } = require('../services/ticketSaleNotify');
async function assertEventAccess(eventId, user) {
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return { err: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) };
if (
user.role !== 'super_admin' &&
String(event.organization) !== String(user.organization?._id || user.organization || '')
) {
return { err: (h) => h.response(error('FORBIDDEN', 'Access denied')).code(403) };
}
return { event };
}
const list = async (request, h) => {
const { eventId } = request.params;
const status = request.query.status ? String(request.query.status) : 'pending_review';
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.err) return r.err(h);
const filter = { event: eventId };
if (status !== 'all' && ['pending_review', 'approved', 'rejected'].includes(status)) filter.status = status;
const orders = await TicketManualOrder.find(filter)
.sort({ createdAt: -1 })
.limit(200)
.populate('ticket', 'name')
.lean();
return h.response(
success({
orders: orders.map((o) => ({
_id: o._id,
status: o.status,
createdAt: o.createdAt,
purchaserEmail: o.purchaserEmail,
screenshotUrl: o.screenshotUrl,
buyerNote: o.buyerNote,
quote: o.quote,
currency: o.currency,
ticket: o.ticket ? { _id: o.ticket._id, name: o.ticket.name } : null,
reviewedAt: o.reviewedAt,
rejectReason: o.rejectReason,
fulfillmentParticipantIds: o.fulfillmentParticipantIds || [],
})),
}),
);
};
const approve = async (request, h) => {
const { eventId, orderId } = request.params;
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.err) return r.err(h);
if (!mongoose.Types.ObjectId.isValid(orderId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid order')).code(400);
}
const order = await TicketManualOrder.findOne({ _id: orderId, event: eventId });
if (!order) return h.response(error('NOT_FOUND', 'Order not found')).code(404);
if (order.status !== 'pending_review') {
return h.response(error('VALIDATION_ERROR', 'Order is not pending review')).code(400);
}
const ctx = {
kind: 'ticket_purchase',
ticketId: String(order.ticket),
eventId: String(order.event),
quantity: order.quote?.quantity || order.attendees?.length || 1,
billingStateCode: order.billingStateCode || null,
purchaserEmail: order.purchaserEmail,
attendees: order.attendees,
quote: order.quote,
};
const ids = await fulfillTicketPurchaseFromContext(ctx, {
paymentLine: {
amountGrossMinor: order.quote?.totalMinor ?? 0,
currency: (order.currency || 'INR').toUpperCase(),
externalId: `manual-upi:${order._id}`,
},
});
if (!ids.length) {
return h.response(error('SERVER_ERROR', 'Fulfillment failed — check ticket and list configuration')).code(500);
}
order.status = 'approved';
order.reviewedAt = new Date();
order.reviewedBy = user._id || user.id;
order.fulfillmentParticipantIds = ids.map((id) => String(id));
await order.save();
const ticket = await EventTicket.findById(order.ticket).select('name').lean();
void notifyTicketStakeholders({
kind: 'manual_approved',
eventId: String(eventId),
organizationId: String(order.organization),
ticketName: ticket?.name,
purchaserEmail: order.purchaserEmail,
amountMinor: order.quote?.totalMinor,
currency: order.currency,
});
return h.response(success({ ok: true, participantIds: order.fulfillmentParticipantIds }));
};
const reject = async (request, h) => {
const { eventId, orderId } = request.params;
const { reason } = request.payload || {};
const user = request.auth.credentials.user;
const r = await assertEventAccess(eventId, user);
if (r.err) return r.err(h);
if (!mongoose.Types.ObjectId.isValid(orderId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid order')).code(400);
}
const order = await TicketManualOrder.findOne({ _id: orderId, event: eventId });
if (!order) return h.response(error('NOT_FOUND', 'Order not found')).code(404);
if (order.status !== 'pending_review') {
return h.response(error('VALIDATION_ERROR', 'Order is not pending review')).code(400);
}
order.status = 'rejected';
order.reviewedAt = new Date();
order.reviewedBy = user._id || user.id;
order.rejectReason = reason ? String(reason).trim().slice(0, 2000) : '';
await order.save();
void notifyTicketStakeholders({
kind: 'manual_rejected',
eventId: String(eventId),
organizationId: String(order.organization),
rejectReason: order.rejectReason,
});
return h.response(success({ ok: true }));
};
module.exports = { list, approve, reject };

View File

@ -16,6 +16,7 @@ const { participantInEvent } = require('../utils/participantMembership');
const { mergeRules } = require('../utils/rewardRulesResolve');
const { isObjectiveQuestionCorrect } = require('../utils/quizLoyaltyScore');
const { awardIfNew } = require('../services/rewardAward');
const { computeEventRatingsSummary } = require('../utils/eventRatingsSummary');
/** All event fields for JSON except website, timestamps; adds logoUrl from website.logo || image. Never expose inviteCode. */
function publicEventLoginPayload(doc) {
@ -148,7 +149,13 @@ async function loginPreview(request, h) {
if (!event) {
return h.response(error('NOT_FOUND', 'Event not found')).code(404);
}
return h.response(success(publicEventLoginPayload(event)));
const ratings = await computeEventRatingsSummary(eventId);
return h.response(
success({
...publicEventLoginPayload(event),
ratings,
}),
);
}
/** GET …/visitor/events/{eventId}/tracks — public; tracks for agenda links */

View File

@ -0,0 +1,191 @@
const WhatsAppTemplate = require('../models/WhatsAppTemplate');
const { success, error } = require('../utils/response');
const {
createMessageTemplate,
getOrgWhatsAppCredentials,
validateTemplateComponentsForMeta,
resolveTemplateImageHeaderHandles,
} = require('../utils/metaWhatsApp');
const { syncOrganizationTemplates } = require('../services/whatsappTemplateMetaSync');
function organizationId(user) {
return user.organization?._id || user.organization;
}
function assertRead(user) {
if (!['super_admin', 'admin', 'moderator', 'viewer'].includes(user.role)) {
return { code: 'FORBIDDEN', message: 'Insufficient permissions', status: 403 };
}
return null;
}
function assertWrite(user) {
if (!['super_admin', 'admin'].includes(user.role)) {
return { code: 'FORBIDDEN', message: 'Only administrators can manage WhatsApp templates', status: 403 };
}
return null;
}
function normalizeLanguage(lang) {
return String(lang || '')
.trim()
.replace(/-/g, '_');
}
const list = async (request, h) => {
const user = request.auth.credentials.user;
const denied = assertRead(user);
if (denied) return h.response(error(denied.code, denied.message)).code(denied.status);
const oid = organizationId(user);
if (!oid) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const doSync = request.query.sync === '1' || request.query.sync === 'true';
if (doSync) {
try {
const r = await syncOrganizationTemplates(oid);
if (!r.ok) return h.response(error('VALIDATION_ERROR', r.error || 'Sync failed')).code(400);
} catch (err) {
return h.response(error('META_ERROR', err.message || 'Meta sync failed')).code(502);
}
}
const q = { organization: oid };
if (request.query.status) q.status = String(request.query.status).toUpperCase();
const templates = await WhatsAppTemplate.find(q).sort({ updatedAt: -1 }).lean();
return h.response(success({ templates }));
};
const get = async (request, h) => {
const user = request.auth.credentials.user;
const denied = assertRead(user);
if (denied) return h.response(error(denied.code, denied.message)).code(denied.status);
const oid = organizationId(user);
if (!oid) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const doc = await WhatsAppTemplate.findOne({ _id: request.params.id, organization: oid }).lean();
if (!doc) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
const doSync = request.query.sync === '1' || request.query.sync === 'true';
if (doSync) {
try {
await syncOrganizationTemplates(oid);
} catch {
/* ignore */
}
const fresh = await WhatsAppTemplate.findOne({ _id: request.params.id, organization: oid }).lean();
return h.response(success(fresh || doc));
}
return h.response(success(doc));
};
const create = async (request, h) => {
const user = request.auth.credentials.user;
const denied = assertWrite(user);
if (denied) return h.response(error(denied.code, denied.message)).code(denied.status);
const oid = organizationId(user);
if (!oid) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const creds = await getOrgWhatsAppCredentials(oid);
if (!creds) {
return h
.response(
error(
'VALIDATION_ERROR',
'WhatsApp Cloud is not configured. Add WABA ID and access token under Settings, or set META_WHATSAPP_DEFAULT_* on the server.',
),
)
.code(400);
}
const { name, language, category, components, variableBindings } = request.payload;
const lang = normalizeLanguage(language);
const metaBody = {
name,
language: lang,
category,
components: JSON.parse(JSON.stringify(components || [])),
};
try {
validateTemplateComponentsForMeta(metaBody.components);
} catch (err) {
return h.response(error('VALIDATION_ERROR', err.message || 'Invalid template')).code(400);
}
try {
await resolveTemplateImageHeaderHandles(metaBody.components, creds.accessToken);
} catch (err) {
if (err.code === 'WA_TEMPLATE_RULE') {
return h.response(error('VALIDATION_ERROR', err.message)).code(400);
}
const status = err.status && err.status < 500 ? 400 : 502;
const code = err.status && err.status < 500 ? 'META_VALIDATION' : 'META_ERROR';
return h.response(error(code, err.message || 'Meta media upload failed')).code(status);
}
let metaRes;
try {
metaRes = await createMessageTemplate(creds.wabaId, creds.accessToken, metaBody);
} catch (err) {
const code = err.status && err.status < 500 ? 'META_VALIDATION' : 'META_ERROR';
return h.response(error(code, err.message || 'Meta API error')).code(err.status && err.status < 500 ? 400 : 502);
}
try {
const vb = variableBindings && typeof variableBindings === 'object' ? variableBindings : {};
const doc = await WhatsAppTemplate.create({
organization: oid,
name,
language: lang,
category,
components,
variableBindings: {
header: Array.isArray(vb.header) ? vb.header.map((x) => String(x || '').trim()) : [],
body: Array.isArray(vb.body) ? vb.body.map((x) => String(x || '').trim()) : [],
url: Array.isArray(vb.url) ? vb.url.map((x) => String(x || '').trim()) : [],
},
metaTemplateId: metaRes.id != null ? String(metaRes.id) : '',
status: metaRes.status != null ? String(metaRes.status).toUpperCase() : 'PENDING',
rejectionReason: '',
lastSyncedAt: new Date(),
lastError: '',
createdBy: user._id,
});
return h.response(success(doc, 'Template submitted to Meta', 201)).code(201);
} catch (e) {
if (e.code === 11000) {
return h.response(error('VALIDATION_ERROR', 'A template with this name and language already exists')).code(400);
}
throw e;
}
};
const syncOne = async (request, h) => {
const user = request.auth.credentials.user;
const denied = assertWrite(user);
if (denied) return h.response(error(denied.code, denied.message)).code(denied.status);
const oid = organizationId(user);
if (!oid) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const exists = await WhatsAppTemplate.findOne({ _id: request.params.id, organization: oid }).lean();
if (!exists) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
try {
const r = await syncOrganizationTemplates(oid);
if (!r.ok) return h.response(error('VALIDATION_ERROR', r.error || 'Sync failed')).code(400);
} catch (err) {
return h.response(error('META_ERROR', err.message || 'Meta sync failed')).code(502);
}
const doc = await WhatsAppTemplate.findOne({ _id: request.params.id, organization: oid }).lean();
return h.response(success(doc));
};
const remove = async (request, h) => {
const user = request.auth.credentials.user;
const denied = assertWrite(user);
if (denied) return h.response(error(denied.code, denied.message)).code(denied.status);
const oid = organizationId(user);
if (!oid) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const r = await WhatsAppTemplate.deleteOne({ _id: request.params.id, organization: oid });
if (r.deletedCount === 0) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
return h.response(success(null, 'Template removed from dashboard'));
};
module.exports = { list, get, create, syncOne, remove };

View File

@ -0,0 +1,27 @@
const Organization = require('../models/Organization');
/**
* Downgrade organisations whose paid subscription period has ended.
* @returns {Promise<number>} modified count
*/
async function runSubscriptionExpiry() {
const now = new Date();
const result = await Organization.updateMany(
{
'subscription.plan': { $nin: [null, '', 'free'] },
'subscription.expiresAt': { $lte: now },
},
{
$set: {
'subscription.plan': 'free',
'subscription.status': 'active',
'subscription.expiresAt': null,
'subscription.maxEvents': null,
'subscription.maxParticipants': null,
},
},
);
return result.modifiedCount ?? 0;
}
module.exports = { runSubscriptionExpiry };

View File

@ -0,0 +1,26 @@
const WhatsAppTemplate = require('../models/WhatsAppTemplate');
const { syncOrganizationTemplates } = require('../services/whatsappTemplateMetaSync');
const PENDING_LIKE = ['PENDING', 'IN_APPEAL', 'PENDING_DELETION', 'PENDING_REVIEW'];
/**
* Poll Meta for templates that are still awaiting approval (or similar).
* @returns {number} number of organisations synced
*/
async function runWhatsAppTemplateSync() {
const orgIds = await WhatsAppTemplate.distinct('organization', {
status: { $in: PENDING_LIKE },
});
let n = 0;
for (const oid of orgIds) {
try {
const r = await syncOrganizationTemplates(oid);
if (r.ok) n += 1;
} catch (err) {
console.error('[cron whatsappTemplateSync]', String(oid), err.message);
}
}
return n;
}
module.exports = { runWhatsAppTemplateSync, PENDING_LIKE };

40
src/models/Attendance.js Normal file
View File

@ -0,0 +1,40 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
const attendanceSchema = new mongoose.Schema(
{
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
index: true,
},
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
name: { type: String, required: true, trim: true },
description: { type: String, default: '', trim: true },
settings: {
allowDuplicateCheckIn: { type: Boolean, default: false },
publicLinkEnabled: { type: Boolean, default: true },
},
publicToken: { type: String, required: true, unique: true, index: true },
status: {
type: String,
enum: ['active', 'archived'],
default: 'active',
},
},
{ timestamps: true }
);
attendanceSchema.index({ event: 1, createdAt: -1 });
attendanceSchema.statics.generatePublicToken = function generatePublicToken() {
return crypto.randomBytes(32).toString('hex');
};
module.exports = mongoose.model('Attendance', attendanceSchema, 'attendances');

View File

@ -0,0 +1,48 @@
const mongoose = require('mongoose');
const checkInEntrySchema = new mongoose.Schema(
{
checkedInAt: { type: Date, required: true, default: Date.now },
source: { type: String, enum: ['dashboard', 'public'], required: true },
checkedInBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
},
{ _id: false }
);
const attendanceParticipantSchema = new mongoose.Schema(
{
attendance: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Attendance',
required: true,
index: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
index: true,
},
participant: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Participant',
required: true,
index: true,
},
firstName: { type: String, trim: true },
lastName: { type: String, trim: true },
email: { type: String, trim: true, lowercase: true },
phone: { type: String, trim: true },
checkIns: { type: [checkInEntrySchema], default: [] },
},
{ timestamps: true }
);
attendanceParticipantSchema.index({ attendance: 1, participant: 1 }, { unique: true });
attendanceParticipantSchema.index({ attendance: 1, updatedAt: -1 });
module.exports = mongoose.model(
'AttendanceParticipant',
attendanceParticipantSchema,
'attendance_participants'
);

64
src/models/BadgeBatch.js Normal file
View File

@ -0,0 +1,64 @@
const mongoose = require('mongoose');
const badgeConfigSchema = new mongoose.Schema(
{
sizePreset: { type: String, default: 'a6' },
widthMm: { type: Number },
heightMm: { type: Number },
orientation: { type: String, enum: ['portrait', 'landscape'], default: 'portrait' },
includeFaculty: { type: Boolean, default: false },
includeParticipants: { type: Boolean, default: false },
participantScope: { type: String, enum: ['all', 'participant_list'], default: 'all' },
participantListId: { type: mongoose.Schema.Types.ObjectId, ref: 'ParticipantList' },
backgroundMode: { type: String, enum: ['image', 'color'], required: true },
backgroundImageUrl: { type: String, default: '' },
backgroundColor: { type: String, default: '#0B4DD1' },
fields: [{ type: String }],
outputFormat: { type: String, enum: ['pdf', 'png', 'jpeg'], required: true },
/** Display label for badge type tag when field badge_type is enabled */
badgeTypeLabel: { type: String, default: 'Attendee' },
},
{ _id: false },
);
const badgeBatchSchema = new mongoose.Schema(
{
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
index: true,
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
name: { type: String, required: true, trim: true },
status: {
type: String,
enum: ['pending', 'processing', 'ready', 'failed'],
default: 'pending',
index: true,
},
lastError: { type: String },
startedAt: Date,
completedAt: Date,
zipUrl: { type: String, trim: true },
zipS3Key: { type: String, trim: true },
itemCount: { type: Number },
config: { type: badgeConfigSchema, required: true },
},
{ timestamps: true },
);
badgeBatchSchema.index({ organization: 1, createdAt: -1 });
badgeBatchSchema.index({ status: 1, createdAt: 1 });
module.exports = mongoose.model('BadgeBatch', badgeBatchSchema);

View File

@ -0,0 +1,32 @@
const mongoose = require('mongoose');
const chatSubscriberSchema = new mongoose.Schema(
{
eventId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
index: true,
},
attendeeId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Participant',
default: null,
},
phoneNumber: { type: String, required: true, index: true },
sessionState: {
type: String,
enum: ['WELCOME_FORK', 'AWAITING_TICKET', 'MAIN_MENU', 'LOCAL_GUIDE_MODE'],
default: 'WELCOME_FORK',
},
metaContext: {
guestModeEnabled: { type: Boolean, default: false },
lastMenuInteraction: { type: Date, default: Date.now },
},
},
{ timestamps: true },
);
chatSubscriberSchema.index({ eventId: 1, phoneNumber: 1 }, { unique: true });
module.exports = mongoose.model('ChatSubscriber', chatSubscriberSchema);

View File

@ -0,0 +1,50 @@
const mongoose = require('mongoose');
const conciergeBotSchema = new mongoose.Schema(
{
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
name: { type: String, trim: true },
wabaPhoneNumberId: { type: String, required: true, trim: true },
enabled: { type: Boolean, default: false },
status: {
type: String,
enum: ['DRAFT', 'ACTIVE', 'ARCHIVED'],
default: 'DRAFT',
},
greeting: {
enabled: { type: Boolean, default: true },
message: {
type: String,
default:
'Hi {profileName}! Welcome to {eventName}. How would you like to continue?',
},
},
registration: {
promptIfUnregistered: { type: Boolean, default: true },
allowGuestSkip: { type: Boolean, default: true },
},
knowledge: {
includeEventDetails: { type: Boolean, default: true },
lastEventSnapshotAt: Date,
},
lastKnowledgeClearedAt: Date,
lastPurgedAt: Date,
},
{ timestamps: true },
);
conciergeBotSchema.index({ organization: 1, event: 1 }, { unique: true });
conciergeBotSchema.index({ wabaPhoneNumberId: 1 }, { unique: true });
conciergeBotSchema.index({ organization: 1, status: 1 });
module.exports = mongoose.model('ConciergeBot', conciergeBotSchema);

View File

@ -0,0 +1,27 @@
const mongoose = require('mongoose');
const contextChunkSchema = new mongoose.Schema(
{
eventId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
index: true,
},
sourceId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'KnowledgeSource',
required: true,
index: true,
},
rawText: { type: String, required: true },
embedding: { type: [Number], required: true },
embeddingModel: { type: String, trim: true },
isActive: { type: Boolean, default: false, index: true },
},
{ timestamps: true },
);
contextChunkSchema.index({ eventId: 1, isActive: 1 });
module.exports = mongoose.model('ContextChunk', contextChunkSchema);

View File

@ -30,6 +30,12 @@ const designTemplateSchema = new mongoose.Schema(
canDuplicate: { type: Boolean, default: true },
},
usedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Event' }],
/** Optional event this emailer is associated with (CTA resolution, default campaign event). */
associatedEvent: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: false,
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',

46
src/models/EmailQueue.js Normal file
View File

@ -0,0 +1,46 @@
const mongoose = require('mongoose');
const emailQueueSchema = new mongoose.Schema(
{
campaign: {
type: mongoose.Schema.Types.ObjectId,
ref: 'MessagingCampaign',
required: true,
index: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
participant: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Participant',
},
to: { type: String, required: true, trim: true, lowercase: true },
subject: { type: String, required: true },
body: { type: String, required: true },
status: {
type: String,
enum: ['pending', 'processing', 'sent', 'failed', 'cancelled'],
default: 'pending',
index: true,
},
processAfter: { type: Date, default: () => new Date(), index: true },
attempts: { type: Number, default: 0 },
lastError: String,
lockedAt: Date,
sentAt: Date,
},
{ timestamps: true }
);
emailQueueSchema.index({ status: 1, processAfter: 1, createdAt: 1 });
emailQueueSchema.index({ campaign: 1, status: 1 });
module.exports = mongoose.model('EmailQueue', emailQueueSchema);

View File

@ -17,6 +17,17 @@ const eventSchema = new mongoose.Schema(
enum: ['conference', 'workshop', 'summit', 'cme', 'webinar'],
required: true,
},
/** `event` = full conference-style row; `activity` = standalone Q&A / quiz / poll shell (still Event→Track→Session in DB). */
recordKind: {
type: String,
enum: ['event', 'activity'],
default: 'event',
},
/** When recordKind is `activity`: qna | quiz | poll */
activitySubtype: {
type: String,
enum: ['qna', 'quiz', 'poll'],
},
status: {
type: String,
enum: ['draft', 'upcoming', 'live', 'completed'],
@ -86,6 +97,24 @@ const eventSchema = new mongoose.Schema(
/** GrapesJS / visual editor main markup (injected at publish when set; canonical store for custom HTML). */
micrositeHtml: String,
},
/**
* UPI / manual proof checkout (alongside or instead of org Razorpay). Scoped to this event.
* Buyer totals are ticket + tax only; Eventbuzz is covered by ticketingManualPayment enablement (billing).
*/
ticketingManualPayment: {
enabled: { type: Boolean, default: false },
/** `upload` = static QR image URL; `dynamic` = build `upi://pay?...` at checkout from UPI ID + amount. */
manualQrMode: { type: String, enum: ['upload', 'dynamic'], default: 'upload' },
upiQrImageUrl: { type: String, trim: true, default: '' },
upiId: { type: String, trim: true, default: '' },
payeeName: { type: String, trim: true, default: '' },
instructions: { type: String, default: '' },
/** Legacy / unused — per-order buyer Eventbuzz fees are not applied; kept for older documents. */
platformFeeInr: { type: Number, min: 0, default: null },
/** Set when org pays EVENT_TICKETING_MANUAL_ENABLEMENT_FEE_INR (billing / invoices). Required before manual UPI can be enabled. */
enablementFeePaidAt: { type: Date, default: null },
enablementTransaction: { type: mongoose.Schema.Types.ObjectId, ref: 'PaymentTransaction', default: null },
},
metadata: {
totalRevenue: { type: Number, default: 0 },
rating: { type: Number, default: 0 },
@ -104,6 +133,7 @@ const eventSchema = new mongoose.Schema(
);
eventSchema.index({ organization: 1 });
eventSchema.index({ organization: 1, recordKind: 1 });
eventSchema.index({ status: 1 });
eventSchema.index({ startDate: 1 });
eventSchema.index({ 'website.folder': 1 });

58
src/models/EventTicket.js Normal file
View File

@ -0,0 +1,58 @@
const mongoose = require('mongoose');
/**
* Tax row: rateBps = basis points on pre-tax base (e.g. 1800 = 18%).
* appliesToStates: empty array = applies in all states; otherwise only listed state codes (e.g. KA, MH).
*/
const taxItemSchema = new mongoose.Schema(
{
label: { type: String, required: true, trim: true },
rateBps: { type: Number, required: true, min: 0, max: 100000 },
appliesToStates: { type: [String], default: [] },
},
{ _id: false },
);
const eventTicketSchema = new mongoose.Schema(
{
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
index: true,
},
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
name: { type: String, required: true, trim: true, maxlength: 200 },
description: { type: String, default: '' },
keyPoints: { type: [String], default: [] },
/** Pre-tax unit price, smallest currency unit (e.g. paise) */
basePriceMinor: { type: Number, required: true, min: 0 },
currency: { type: String, default: 'INR', uppercase: true, trim: true },
isActive: { type: Boolean, default: true },
sortOrder: { type: Number, default: 0 },
maxPerOrder: { type: Number, default: null, min: 1 },
inventoryCap: { type: Number, default: null, min: 1 },
taxItems: { type: [taxItemSchema], default: [] },
registrationForm: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Form',
default: null,
},
/** Fixed list: ticket buyers are $addToSet to participantIds after payment */
participantList: {
type: mongoose.Schema.Types.ObjectId,
ref: 'ParticipantList',
required: true,
},
},
{ timestamps: true },
);
eventTicketSchema.index({ event: 1, isActive: 1, sortOrder: 1 });
module.exports = mongoose.model('EventTicket', eventTicketSchema);

View File

@ -4,7 +4,7 @@ const formFieldSchema = new mongoose.Schema({
label: String,
type: {
type: String,
enum: ['text', 'email', 'phone', 'select', 'radio', 'checkbox', 'checkboxes', 'file'],
enum: ['text', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file'],
},
required: Boolean,
options: [String],

View File

@ -0,0 +1,40 @@
const mongoose = require('mongoose');
const knowledgeSourceSchema = new mongoose.Schema(
{
eventId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
index: true,
},
type: {
type: String,
enum: ['url', 'pdf', 'event'],
required: true,
},
sourcePath: { type: String, required: true },
displayName: { type: String, required: true },
status: {
type: String,
enum: ['PENDING', 'PROCESSING', 'READY', 'FAILED'],
default: 'PENDING',
},
errorMessage: { type: String, default: null },
lastSyncedAt: { type: Date, default: Date.now },
/** URL sources: domain-wide or path-prefix crawl */
crawlScope: { type: String, enum: ['domain', 'path'], default: null },
crawlPathPrefix: { type: String, default: null },
lastCrawlStats: {
pages: { type: Number, default: 0 },
assets: { type: Number, default: 0 },
skipped: { type: Number, default: 0 },
failed: { type: Number, default: 0 },
},
},
{ timestamps: true },
);
knowledgeSourceSchema.index({ eventId: 1, type: 1 });
module.exports = mongoose.model('KnowledgeSource', knowledgeSourceSchema);

View File

@ -3,10 +3,11 @@ const mongoose = require('mongoose');
const recipientsSchema = new mongoose.Schema({
type: {
type: String,
enum: ['all', 'filtered', 'custom'],
enum: ['all', 'filtered', 'custom', 'participant_list'],
},
filters: { type: Map, of: mongoose.Schema.Types.Mixed },
customList: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Participant' }],
participantList: { type: mongoose.Schema.Types.ObjectId, ref: 'ParticipantList' },
});
const contentSchema = new mongoose.Schema({
@ -14,6 +15,10 @@ const contentSchema = new mongoose.Schema({
body: String,
template: String,
variables: { type: Map, of: mongoose.Schema.Types.Mixed },
/** WhatsApp: ObjectId string of WhatsAppTemplate */
whatsappTemplateId: String,
waHeaderImageUrl: String,
waOverrides: { type: mongoose.Schema.Types.Mixed },
});
const campaignSchema = new mongoose.Schema(
@ -39,9 +44,10 @@ const campaignSchema = new mongoose.Schema(
content: contentSchema,
status: {
type: String,
enum: ['draft', 'scheduled', 'sending', 'sent', 'failed'],
enum: ['draft', 'scheduled', 'sending', 'sent', 'failed', 'stopped'],
default: 'draft',
},
stoppedAt: Date,
scheduledFor: Date,
sentAt: Date,
stats: {
@ -62,4 +68,5 @@ const campaignSchema = new mongoose.Schema(
);
campaignSchema.index({ event: 1 });
campaignSchema.index({ status: 1, scheduledFor: 1 });
module.exports = mongoose.model('MessagingCampaign', campaignSchema);

View File

@ -13,7 +13,8 @@ const organizationSchema = new mongoose.Schema(
dateFormat: { type: String, default: 'DD/MM/YYYY' },
language: { type: String, default: 'en-IN' },
subscription: {
plan: { type: String, enum: ['free', 'pro', 'enterprise'], default: 'free' },
/** Matches `SubscriptionPlan.planId` (slug). */
plan: { type: String, default: 'free', trim: true, maxlength: 64 },
status: {
type: String,
enum: ['active', 'trial', 'past_due', 'cancelled'],
@ -24,9 +25,48 @@ const organizationSchema = new mongoose.Schema(
maxParticipants: Number,
featureOverrides: [{ type: String }],
},
/** Bring-your-own SMTP (password stored encrypted — see smtpCrypto). */
smtp: {
enabled: { type: Boolean, default: false },
host: String,
port: Number,
secure: { type: Boolean, default: false },
user: String,
passwordEnc: String,
fromName: String,
fromAddress: String,
},
/** WhatsApp Cloud API (Meta) — token encrypted like SMTP. */
whatsappCloud: {
enabled: { type: Boolean, default: false },
wabaId: { type: String, trim: true },
phoneNumberId: { type: String, trim: true },
accessTokenEnc: { type: String },
},
},
{ timestamps: true }
);
organizationSchema.set('toJSON', {
transform(_doc, ret) {
if (ret.whatsappCloud && ret.whatsappCloud.accessTokenEnc) {
ret.whatsappCloud = {
enabled: Boolean(ret.whatsappCloud.enabled),
wabaId: ret.whatsappCloud.wabaId || '',
phoneNumberId: ret.whatsappCloud.phoneNumberId || '',
hasAccessToken: true,
};
} else if (ret.whatsappCloud) {
ret.whatsappCloud = {
enabled: Boolean(ret.whatsappCloud.enabled),
wabaId: ret.whatsappCloud.wabaId || '',
phoneNumberId: ret.whatsappCloud.phoneNumberId || '',
hasAccessToken: false,
};
}
return ret;
},
});
organizationSchema.index({ name: 1 });
module.exports = mongoose.model('Organization', organizationSchema);

View File

@ -43,6 +43,14 @@ const paymentTransactionSchema = new mongoose.Schema(
/** Razorpay key_id used when order was created — must match on verify after resolve */
ledgerKeyId: { type: String, default: '' },
registrationContext: { type: mongoose.Schema.Types.Mixed, default: null },
/** `ticketing_manual_enablement` = one-time fee to unlock per-event manual UPI (billing). */
purpose: {
type: String,
enum: ['registration', 'saas_subscription', 'ticket_purchase', 'ticketing_manual_enablement'],
default: 'registration',
},
subscriptionPlanId: { type: String, default: '' },
purchasedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null },
},
{ timestamps: true }
);
@ -53,5 +61,6 @@ paymentTransactionSchema.index({ event: 1 });
paymentTransactionSchema.index({ participant: 1 });
paymentTransactionSchema.index({ status: 1 });
paymentTransactionSchema.index({ externalPaymentId: 1 });
paymentTransactionSchema.index({ organization: 1, purpose: 1, createdAt: -1 });
module.exports = mongoose.model('PaymentTransaction', paymentTransactionSchema);

View File

@ -42,6 +42,10 @@ const sessionSchema = new mongoose.Schema(
audienceFeedbackEnabled: { type: Boolean, default: true },
/** When true (default), session page can surface ratings if the event has ratings enabled */
audienceRatingsEnabled: { type: Boolean, default: true },
/** Dashboard: show Questions moderation (activity sessions default from activitySubtype). */
enableQnA: { type: Boolean, default: true },
/** Dashboard: show Quiz & Poll management (activity sessions default from activitySubtype). */
enableQnP: { type: Boolean, default: true },
/** Optional Form (type feedback) for custom feedback; null = default EventBuzz feedback UI */
audienceFeedbackForm: { type: mongoose.Schema.Types.ObjectId, ref: 'Form' },
/** Optional Form (type feedback) for custom ratings flow; null = default EventBuzz ratings UI */

View File

@ -0,0 +1,38 @@
const mongoose = require('mongoose');
/** Lowercase slug: a-z start, then a-z, 0-9, underscore. Max 64 chars. */
const PLAN_ID_REGEX = /^[a-z][a-z0-9_]{0,62}$/;
const subscriptionPlanSchema = new mongoose.Schema(
{
planId: {
type: String,
required: true,
unique: true,
trim: true,
maxlength: 64,
match: PLAN_ID_REGEX,
},
name: { type: String, required: true },
/** Pricing / positioning line(s) for catalog & billing UI (e.g. "₹0\\nforever · …"). */
tagline: { type: String, default: '' },
featureKeys: [{ type: String }],
defaultLimits: {
maxEvents: { type: mongoose.Schema.Types.Mixed, default: null },
maxParticipants: { type: mongoose.Schema.Types.Mixed, default: null },
},
/** Structured price for future payment integration (amount = major units, e.g. INR rupees). */
pricing: {
amount: { type: mongoose.Schema.Types.Mixed, default: null },
currency: { type: String, default: 'INR' },
interval: { type: String, default: 'month' },
contactSales: { type: Boolean, default: false },
},
/** Non-boolean feature config (event website tier, email caps). Synced from CSV defaults in catalog. */
featureValues: { type: mongoose.Schema.Types.Mixed, default: {} },
},
{ timestamps: true }
);
module.exports = mongoose.model('SubscriptionPlan', subscriptionPlanSchema);
module.exports.PLAN_ID_REGEX = PLAN_ID_REGEX;

View File

@ -0,0 +1,35 @@
const mongoose = require('mongoose');
/**
* UPI / manual proof flow when org has no online gateway. Awaiting admin approval.
*/
const ticketManualOrderSchema = new mongoose.Schema(
{
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true, index: true },
ticket: { type: mongoose.Schema.Types.ObjectId, ref: 'EventTicket', required: true },
status: {
type: String,
enum: ['pending_review', 'approved', 'rejected'],
default: 'pending_review',
index: true,
},
purchaserEmail: { type: String, required: true, trim: true, lowercase: true },
attendees: { type: [mongoose.Schema.Types.Mixed], default: [] },
quote: { type: mongoose.Schema.Types.Mixed, default: null },
currency: { type: String, default: 'INR', trim: true, uppercase: true },
billingStateCode: { type: String, default: '' },
screenshotUrl: { type: String, required: true, trim: true },
buyerNote: { type: String, default: '', trim: true },
reviewedAt: Date,
reviewedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
rejectReason: { type: String, default: '' },
/** Set after approval when fulfillment runs */
fulfillmentParticipantIds: [{ type: String }],
},
{ timestamps: true },
);
ticketManualOrderSchema.index({ event: 1, status: 1, createdAt: -1 });
module.exports = mongoose.model('TicketManualOrder', ticketManualOrderSchema);

View File

@ -37,6 +37,12 @@ const userSchema = new mongoose.Schema(
enum: ['super_admin', 'admin', 'moderator', 'viewer'],
default: 'viewer',
},
/** EventBuzz platform staff only — not part of `role`. Access to `/api/platform-admin/*` and Super Admin UI when `"yes"`. */
platform_admin: {
type: String,
enum: ['yes', 'no'],
default: 'no',
},
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
profileImage: String,
phone: String,

View File

@ -0,0 +1,32 @@
const mongoose = require('mongoose');
const messageSchema = new mongoose.Schema(
{
role: { type: String, enum: ['user', 'assistant'], required: true },
text: { type: String, required: true },
ts: { type: Date, default: Date.now },
modelId: { type: String, default: null },
inputTokens: { type: Number, default: 0 },
outputTokens: { type: Number, default: 0 },
},
{ _id: false },
);
const webChatSessionSchema = new mongoose.Schema(
{
botId: { type: mongoose.Schema.Types.ObjectId, ref: 'ConciergeBot', required: true, index: true },
eventId: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true, index: true },
sessionId: { type: String, required: true, index: true },
history: { type: [messageSchema], default: [] },
lastSeenAt: { type: Date, default: Date.now, index: true },
inputTokensTotal: { type: Number, default: 0 },
outputTokensTotal: { type: Number, default: 0 },
},
{ timestamps: true },
);
webChatSessionSchema.index({ botId: 1, sessionId: 1 }, { unique: true });
module.exports = mongoose.model('WebChatSession', webChatSessionSchema);

View File

@ -0,0 +1,52 @@
const mongoose = require('mongoose');
const whatsAppQueueSchema = new mongoose.Schema(
{
campaign: {
type: mongoose.Schema.Types.ObjectId,
ref: 'MessagingCampaign',
required: true,
index: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
participant: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Participant',
},
/** E.164-like digits, no + */
to: { type: String, required: true, trim: true, index: true },
/**
* WhatsApp Cloud API message id from send response (messages[0].id), e.g. wamid…
* Webhook status updates reference this id.
*/
whatsappMessageId: { type: String, trim: true, sparse: true, index: true },
/** contacts[0].wa_id from send response (digits), optional helper for webhooks */
recipientWaId: { type: String, trim: true },
status: {
type: String,
enum: ['pending', 'processing', 'sent', 'failed', 'cancelled'],
default: 'pending',
index: true,
},
processAfter: { type: Date, default: () => new Date(), index: true },
attempts: { type: Number, default: 0 },
lastError: String,
lockedAt: Date,
sentAt: Date,
},
{ timestamps: true }
);
whatsAppQueueSchema.index({ status: 1, processAfter: 1, createdAt: 1 });
whatsAppQueueSchema.index({ campaign: 1, status: 1 });
module.exports = mongoose.model('WhatsAppQueue', whatsAppQueueSchema);

View File

@ -0,0 +1,50 @@
const mongoose = require('mongoose');
const whatsAppTemplateSchema = new mongoose.Schema(
{
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
/** Meta template name: lowercase letters, numbers, underscores */
name: { type: String, required: true, trim: true },
language: { type: String, required: true, trim: true },
category: {
type: String,
required: true,
enum: ['MARKETING', 'UTILITY', 'AUTHENTICATION'],
},
/** Full Meta `components` array as stored/submitted */
components: { type: mongoose.Schema.Types.Mixed, required: true },
metaTemplateId: { type: String, trim: true },
/** Meta template status (PENDING, APPROVED, …) — stored as returned by Graph API */
status: { type: String, default: 'PENDING', trim: true },
rejectionReason: { type: String, default: '' },
lastSyncedAt: Date,
lastError: { type: String, default: '' },
/**
* Maps Meta positional vars {{1}}, {{2}}… to campaign merge keys (e.g. contact.firstName).
* Arrays align with parameter order per component: header (TEXT), body, url (button).
* Not sent to Meta; used when sending template messages later.
*/
variableBindings: {
header: { type: [String], default: [] },
body: { type: [String], default: [] },
url: { type: [String], default: [] },
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
},
{ timestamps: true }
);
whatsAppTemplateSchema.index({ organization: 1, name: 1, language: 1 }, { unique: true });
whatsAppTemplateSchema.index({ organization: 1, status: 1 });
whatsAppTemplateSchema.index({ metaTemplateId: 1 }, { sparse: true });
module.exports = mongoose.model('WhatsAppTemplate', whatsAppTemplateSchema);

View File

@ -0,0 +1,58 @@
const Joi = require('joi');
const controller = require('../controllers/attendance.controller');
const settingsSchema = Joi.object({
allowDuplicateCheckIn: Joi.boolean(),
publicLinkEnabled: Joi.boolean(),
});
const createPayload = Joi.object({
name: Joi.string().trim().required(),
description: Joi.string().allow('').optional(),
settings: settingsSchema.optional(),
});
const updatePayload = Joi.object({
name: Joi.string().trim().optional(),
description: Joi.string().allow('').optional(),
status: Joi.string().valid('active', 'archived').optional(),
settings: settingsSchema.optional(),
});
const checkInPayload = Joi.object({
participantId: Joi.string().required(),
});
module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/attendances', options: { handler: controller.list } },
{
method: 'POST',
path: '/api/events/{eventId}/attendances',
options: { validate: { payload: createPayload }, handler: controller.create },
},
{ method: 'GET', path: '/api/events/{eventId}/attendances/{id}', options: { handler: controller.get } },
{
method: 'PUT',
path: '/api/events/{eventId}/attendances/{id}',
options: { validate: { payload: updatePayload }, handler: controller.update },
},
{ method: 'DELETE', path: '/api/events/{eventId}/attendances/{id}', options: { handler: controller.remove } },
{
method: 'POST',
path: '/api/events/{eventId}/attendances/{id}/regenerate-token',
options: { handler: controller.regenerateToken },
},
{ method: 'GET', path: '/api/events/{eventId}/attendances/{id}/check-ins', options: { handler: controller.listCheckIns } },
{ method: 'GET', path: '/api/events/{eventId}/attendances/{id}/search-participants', options: { handler: controller.searchParticipants } },
{
method: 'GET',
path: '/api/events/{eventId}/attendances/{id}/participants/{participantId}',
options: { handler: controller.getParticipantPreview },
},
{
method: 'POST',
path: '/api/events/{eventId}/attendances/{id}/check-in',
options: { validate: { payload: checkInPayload }, handler: controller.checkIn },
},
{ method: 'GET', path: '/api/events/{eventId}/attendances/{id}/export', options: { handler: controller.exportCsv } },
];

View File

@ -107,7 +107,7 @@ module.exports = [
validate: {
payload: Joi.object({
idToken: Joi.string().required(),
teamName: Joi.string().min(1).required(),
teamName: Joi.string().min(1).allow('', null).optional(),
firstName: Joi.string().allow('', null),
lastName: Joi.string().allow('', null),
phone: Joi.string().allow('', null),

View File

@ -0,0 +1,63 @@
const Joi = require('joi');
const controller = require('../controllers/badgeBatch.controller');
const { requireFeaturePre } = require('../services/featureAccess');
const FIELD_IDS = ['name', 'title', 'org', 'badge_type', 'qr', 'photo'];
const createPayload = Joi.object({
name: Joi.string().required().trim(),
organization: Joi.string().hex().length(24),
event: Joi.string().hex().length(24).required(),
sizePreset: Joi.string().valid('a6', 'a7', '4x3in', '4x6in', 'custom').default('a6'),
widthMm: Joi.number().positive().when('sizePreset', { is: 'custom', then: Joi.required(), otherwise: Joi.optional() }),
heightMm: Joi.number().positive().when('sizePreset', { is: 'custom', then: Joi.required(), otherwise: Joi.optional() }),
orientation: Joi.string().valid('portrait', 'landscape').default('portrait'),
includeFaculty: Joi.boolean().default(false),
includeParticipants: Joi.boolean().default(false),
participantScope: Joi.string().valid('all', 'participant_list').default('all'),
participantListId: Joi.string().hex().length(24).allow(null, ''),
backgroundMode: Joi.string().valid('image', 'color').required(),
backgroundImageUrl: Joi.string().allow(''),
backgroundColor: Joi.string().pattern(/^#[0-9A-Fa-f]{6}$/).default('#0B4DD1'),
fields: Joi.array().items(Joi.string().valid(...FIELD_IDS)).min(1).required(),
outputFormat: Joi.string().valid('pdf', 'png', 'jpeg').required(),
badgeTypeLabel: Joi.string().allow('').max(80),
});
module.exports = [
{
method: 'GET',
path: '/api/badge-batches',
options: {
validate: {
query: Joi.object({
organization: Joi.string().hex().length(24),
event: Joi.string().hex().length(24),
page: Joi.number().integer().min(1),
limit: Joi.number().integer().min(1).max(100),
}),
},
handler: controller.list,
},
},
{
method: 'GET',
path: '/api/badge-batches/{id}',
options: {
validate: {
params: Joi.object({ id: Joi.string().hex().length(24).required() }),
query: Joi.object({ organization: Joi.string().hex().length(24) }),
},
handler: controller.get,
},
},
{
method: 'POST',
path: '/api/badge-batches',
options: {
pre: [{ method: requireFeaturePre('custom_badge_and_print_file_creation') }],
validate: { payload: createPayload },
handler: controller.create,
},
},
];

View File

@ -2,31 +2,79 @@ const Joi = require('joi');
const controller = require('../controllers/campaign.controller');
const { CAMPAIGN_CHANNELS, CAMPAIGN_TYPES } = require('../config/constants');
const recipientsSchema = Joi.object({
type: Joi.string().valid('all', 'filtered', 'custom', 'participant_list'),
filters: Joi.object(),
customList: Joi.array().items(Joi.string()),
participantList: Joi.string(),
});
const waOverrideSlot = Joi.alternatives().try(
Joi.object({ kind: Joi.string().valid('merge').required(), key: Joi.string().allow('') }),
Joi.object({ kind: Joi.string().valid('fixed').required(), value: Joi.string().allow('') }),
);
const contentSchema = Joi.object({
subject: Joi.string().allow(''),
body: Joi.string().allow(''),
template: Joi.string().allow(''),
variables: Joi.object(),
/** WhatsApp campaigns: Meta template doc in DB */
whatsappTemplateId: Joi.string().allow(''),
/** Public https URL for template IMAGE header (send time) */
waHeaderImageUrl: Joi.string().allow(''),
/** Per-section {{n}} overrides: { header|body|url: { "1": { kind, key|value } } } */
waOverrides: Joi.object({
header: Joi.object().pattern(Joi.string(), waOverrideSlot),
body: Joi.object().pattern(Joi.string(), waOverrideSlot),
url: Joi.object().pattern(Joi.string(), waOverrideSlot),
}),
});
const campaignPayload = Joi.object({
name: Joi.string().required(),
channel: Joi.string().valid(...CAMPAIGN_CHANNELS).required(),
type: Joi.string().valid(...CAMPAIGN_TYPES).required(),
triggerEvent: Joi.string(),
recipients: Joi.object({
type: Joi.string().valid('all', 'filtered', 'custom'),
filters: Joi.object(),
customList: Joi.array().items(Joi.string()),
}),
content: Joi.object({
subject: Joi.string(),
body: Joi.string(),
template: Joi.string(),
variables: Joi.object(),
}),
triggerEvent: Joi.string().allow(''),
recipients: recipientsSchema,
content: contentSchema,
});
const campaignUpdatePayload = Joi.object({
name: Joi.string(),
channel: Joi.string().valid(...CAMPAIGN_CHANNELS),
type: Joi.string().valid(...CAMPAIGN_TYPES),
triggerEvent: Joi.string().allow(''),
recipients: recipientsSchema,
content: contentSchema,
}).min(1);
/** Optional fields applied to a draft immediately before enqueue (avoids PUT+POST race). */
const sendApplyDraftSchema = Joi.object({
name: Joi.string(),
recipients: recipientsSchema,
content: contentSchema,
});
module.exports = [
{
method: 'GET',
path: '/api/campaigns',
options: {
validate: { query: Joi.object({ channel: Joi.string().valid(...CAMPAIGN_CHANNELS) }) },
handler: controller.listByOrg,
},
},
{ method: 'GET', path: '/api/events/{eventId}/campaigns', options: { handler: controller.list } },
{ method: 'POST', path: '/api/events/{eventId}/campaigns', options: { validate: { payload: campaignPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/campaigns/{id}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/campaigns/{id}', options: { validate: { payload: campaignPayload }, handler: controller.update } },
{ method: 'PUT', path: '/api/campaigns/{id}', options: { validate: { payload: campaignUpdatePayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/campaigns/{id}', options: { handler: controller.remove } },
{ method: 'POST', path: '/api/campaigns/{id}/send', options: { handler: controller.send } },
{
method: 'POST',
path: '/api/campaigns/{id}/send',
options: { validate: { payload: sendApplyDraftSchema }, handler: controller.send },
},
{
method: 'POST',
path: '/api/campaigns/{id}/schedule',
@ -36,5 +84,7 @@ module.exports = [
},
},
{ method: 'POST', path: '/api/campaigns/{id}/cancel', options: { handler: controller.cancel } },
{ method: 'POST', path: '/api/campaigns/{id}/stop', options: { handler: controller.stop } },
{ method: 'POST', path: '/api/campaigns/{id}/preview', options: { handler: controller.preview } },
{ method: 'GET', path: '/api/campaigns/{id}/analytics', options: { handler: controller.analytics } },
];

View File

@ -0,0 +1,5 @@
const controller = require('../controllers/checkins.controller');
module.exports = [
{ method: 'GET', path: '/api/checkins/events', options: { handler: controller.listEvents } },
];

View File

@ -0,0 +1,68 @@
const Joi = require('joi');
const controller = require('../controllers/conciergeBot.controller');
const greetingSchema = Joi.object({
enabled: Joi.boolean(),
message: Joi.string().allow(''),
});
const registrationSchema = Joi.object({
promptIfUnregistered: Joi.boolean(),
allowGuestSkip: Joi.boolean(),
});
const knowledgeSchema = Joi.object({
includeEventDetails: Joi.boolean(),
});
const createPayload = Joi.object({
eventId: Joi.string().required(),
name: Joi.string().allow(''),
wabaPhoneNumberId: Joi.string().required(),
greeting: greetingSchema,
registration: registrationSchema,
knowledge: knowledgeSchema,
enabled: Joi.boolean(),
});
const updatePayload = Joi.object({
name: Joi.string().allow(''),
wabaPhoneNumberId: Joi.string(),
greeting: greetingSchema,
registration: registrationSchema,
knowledge: knowledgeSchema,
enabled: Joi.boolean(),
}).min(1);
const sourcePayload = Joi.object({
type: Joi.string().valid('url', 'pdf').required(),
sourcePath: Joi.string().required(),
displayName: Joi.string().required(),
crawlScope: Joi.string().valid('auto', 'domain', 'path'),
});
module.exports = [
{
method: 'GET',
path: '/api/concierge-bots',
options: {
validate: { query: Joi.object({ organization: Joi.string() }) },
handler: controller.list,
},
},
{ method: 'POST', path: '/api/concierge-bots', options: { validate: { payload: createPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/concierge-bots/{botId}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/concierge-bots/{botId}', options: { validate: { payload: updatePayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/concierge-bots/{botId}', options: { handler: controller.remove } },
{ method: 'GET', path: '/api/events/{eventId}/concierge-bot', options: { handler: controller.getByEvent } },
{ method: 'GET', path: '/api/concierge-bots/{botId}/sources', options: { handler: controller.listSources } },
{ method: 'POST', path: '/api/concierge-bots/{botId}/sources', options: { validate: { payload: sourcePayload }, handler: controller.createSource } },
{ method: 'DELETE', path: '/api/concierge-bots/{botId}/sources/{sourceId}', options: { handler: controller.deleteSource } },
{ method: 'POST', path: '/api/concierge-bots/{botId}/sources/{sourceId}/sync', options: { handler: controller.syncSource } },
{ method: 'GET', path: '/api/concierge-bots/{botId}/sources/{sourceId}/chunks', options: { validate: { query: Joi.object({ staging: Joi.string() }) }, handler: controller.listChunks } },
{ method: 'PATCH', path: '/api/concierge-bots/{botId}/chunks/{chunkId}', options: { validate: { payload: Joi.object({ rawText: Joi.string().required() }) }, handler: controller.patchChunk } },
{ method: 'DELETE', path: '/api/concierge-bots/{botId}/chunks/{chunkId}', options: { handler: controller.deleteChunk } },
{ method: 'POST', path: '/api/concierge-bots/{botId}/sources/{sourceId}/approve', options: { handler: controller.approveSource } },
{ method: 'POST', path: '/api/concierge-bots/{botId}/knowledge/clear', options: { handler: controller.clearKnowledge } },
{ method: 'POST', path: '/api/concierge-bots/{botId}/knowledge/sync-event', options: { handler: controller.syncEventKnowledge } },
];

Some files were not shown because too many files have changed in this diff Show More