first commit
This commit is contained in:
parent
a257817f8c
commit
2a5629c2ae
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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; }
|
||||
|
||||
|
|
@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
|
|
@ -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');
|
||||
92
src/app.js
92
src/app.js
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
@ -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 };
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "abstract_management",
|
||||
"label": "Abstract management",
|
||||
"description": "",
|
||||
"category": "agenda"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "agenda_builder",
|
||||
"label": "Agenda builder",
|
||||
"description": "",
|
||||
"category": "agenda"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "ai_engagement_insights",
|
||||
"label": "AI Engagement insights",
|
||||
"description": "",
|
||||
"category": "ai"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "ai_summarization",
|
||||
"label": "AI Summarization",
|
||||
"description": "",
|
||||
"category": "ai"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "bring_your_wa_number",
|
||||
"label": "Bring your WA number",
|
||||
"description": "",
|
||||
"category": "comms"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "collect_payment_using_your_upi_qr",
|
||||
"label": "Collect Payment using your UPI QR",
|
||||
"description": "",
|
||||
"category": "payments"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "content_management",
|
||||
"label": "Content management",
|
||||
"description": "",
|
||||
"category": "content"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "custom_badge_and_print_file_creation",
|
||||
"label": "Custom Badge and print file creation",
|
||||
"description": "",
|
||||
"category": "design"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "custom_domain",
|
||||
"label": "Custom Domain",
|
||||
"description": "",
|
||||
"category": "website"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "custom_registration_workflow",
|
||||
"label": "Custom Registration workflow",
|
||||
"description": "",
|
||||
"category": "registration"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "custom_template_for_wa_messaging",
|
||||
"label": "Custom template for WA messaging",
|
||||
"description": "",
|
||||
"category": "comms"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "custom_theme",
|
||||
"label": "Custom Theme",
|
||||
"description": "",
|
||||
"category": "branding"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "customise_event_website",
|
||||
"label": "Customise event website",
|
||||
"description": "",
|
||||
"category": "website"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "email_confirmation_reminders",
|
||||
"label": "Email Confirmation & Reminders",
|
||||
"description": "",
|
||||
"category": "comms"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "feedback_forms_and_surveys",
|
||||
"label": "Feedback forms & Surveys",
|
||||
"description": "",
|
||||
"category": "feedback"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "gamification",
|
||||
"label": "Gamification",
|
||||
"description": "",
|
||||
"category": "engagement"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "invites_rsvp",
|
||||
"label": "Invites +RSVP",
|
||||
"description": "",
|
||||
"category": "registration"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "live_polls",
|
||||
"label": "Live Polls",
|
||||
"description": "",
|
||||
"category": "engagement"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "multi_track_agenda",
|
||||
"label": "Multi track agenda",
|
||||
"description": "",
|
||||
"category": "agenda"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "multiple_quiz_modes",
|
||||
"label": "Multiple Quiz modes",
|
||||
"description": "",
|
||||
"category": "engagement"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "payment_gateway_integration",
|
||||
"label": "Payment gateway integration",
|
||||
"description": "",
|
||||
"category": "payments"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "push_notifications",
|
||||
"label": "Push Notifications",
|
||||
"description": "",
|
||||
"category": "comms"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "qna_with_moderation",
|
||||
"label": "QnA with moderation",
|
||||
"description": "",
|
||||
"category": "engagement"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "ratings_for_sessions_and_speakers",
|
||||
"label": "Ratings for sessions and speakers",
|
||||
"description": "",
|
||||
"category": "feedback"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "registration_forms_with_custom_fields",
|
||||
"label": "Registration forms with Custom fields",
|
||||
"description": "",
|
||||
"category": "registration"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "speaker_management",
|
||||
"label": "Speaker management",
|
||||
"description": "",
|
||||
"category": "agenda"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "ticketing_free_and_paid",
|
||||
"label": "Ticketing free & paid",
|
||||
"description": "",
|
||||
"category": "payments"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "whatsapp_confirmation_reminders",
|
||||
"label": "WhatsApp confirmation & reminders",
|
||||
"description": "",
|
||||
"category": "comms"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"key": "word_cloud",
|
||||
"label": "Word Cloud",
|
||||
"description": "",
|
||||
"category": "engagement"
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 }));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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');
|
||||
|
|
@ -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'
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
const controller = require('../controllers/checkins.controller');
|
||||
|
||||
module.exports = [
|
||||
{ method: 'GET', path: '/api/checkins/events', options: { handler: controller.listEvents } },
|
||||
];
|
||||
|
|
@ -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
Loading…
Reference in New Issue