First Version

This commit is contained in:
Ravi Gulhane 2026-04-03 15:19:50 +05:30
parent 6c558c162e
commit 7b1777c4ba
131 changed files with 23334 additions and 68 deletions

44
.env.example Normal file
View File

@ -0,0 +1,44 @@
NODE_ENV=development
PORT=3001
# Max JSON body size (bytes). Default 25MB if unset — needed for event create with base64 logo/banner.
# PAYLOAD_MAX_BYTES=26214400
MONGODB_URI=mongodb://localhost:27017/eventbuzz
JWT_SECRET=your-super-secret-jwt-key-change-this
JWT_REFRESH_SECRET=your-refresh-secret-key-change-this
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# For Vite dev server use http://localhost:5173; for other frontends adjust (comma-separated for multiple).
# In NODE_ENV!==production, http://localhost:* and http://127.0.0.1:* are also allowed automatically.
FRONTEND_URL=http://localhost:5173
CORS_ORIGIN=http://localhost:5173
# Optional: comma-separated origins for the GrapesJS microsite builder (iframe) calling the API
# WEBSITE_BUILDER_CORS_ORIGINS=http://localhost:5173,https://builder.example.com
# Google Sign-In (dashboard). Web client ID(s), comma-separated if multiple.
# Frontend: VITE_GOOGLE_CLIENT_ID must match the same OAuth Web client.
GOOGLE_CLIENT_ID=
# Email
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
# Payment (optional)
RAZORPAY_KEY_ID=
RAZORPAY_KEY_SECRET=
AWS_ACCESS_KEY_ID=AKIAWY2EEQXF5K4LDCI2
AWS_SECRET_ACCESS_KEY=ZpIxby1vbCLGB7CPWZsAltZBEqkhwzgBpFOKDGj8
AWS_DEFAULT_REGION=ap-south-1
AWS_BUCKET=rapportapp
AWS_URL=https://did94hhhcgq1d.cloudfront.net
# Public API base URL injected into published microsite HTML for contact forms (e.g. https://api.example.com/api or http://localhost:3001/api)
MICROSITE_API_BASE_URL=http://localhost:3001/api
# Firebase Realtime Database (optional — quiz/poll sync). Paths relative to backend/ when starting the API from backend/
FIREBASE_DATABASE_URL=https://eventbuzz-live-default-rtdb.firebaseio.com
GOOGLE_APPLICATION_CREDENTIALS=./eventbuzz-live-firebase-adminsdk-fbsvc-bb3dcfd679.json
# Or if JSON lives in repo root: ../eventbuzz-live-firebase-adminsdk-fbsvc-bb3dcfd679.json
# Or: FIREBASE_SERVICE_ACCOUNT_PATH= / FIREBASE_SERVICE_ACCOUNT_JSON= (see firebase.js)

54
.gitignore vendored
View File

@ -1,50 +1,8 @@
# These are some examples of commonly ignored file patterns.
# You should customize this list as applicable to your project.
# Learn more about .gitignore:
# https://www.atlassian.com/git/tutorials/saving-changes/gitignore
# Node artifact files
node_modules/
dist/
# Compiled Java class files
*.class
# Compiled Python bytecode
*.py[cod]
# Log files
.env
*-firebase-adminsdk-*.json
**/firebase-adminsdk*.json
uploads/
*.log
# Package files
*.jar
# Maven
target/
dist/
# JetBrains IDE
.idea/
# Unit test reports
TEST*.xml
# Generated by MacOS
.DS_Store
# Generated by Windows
Thumbs.db
# Applications
*.app
*.exe
*.war
# Large media files
*.mp4
*.tiff
*.avi
*.flv
*.mov
*.wmv
coverage/
.nyc_output/

View File

@ -1,29 +1,74 @@
# README #
# EventBuzz Backend
This README would normally document whatever steps are necessary to get your application up and running.
Node.js backend for EventBuzz built with **Hapi.js** and **MongoDB** (Mongoose), as per `FULL_STACK_MIGRATION_PLAN.md`.
### What is this repository for? ###
## Stack
* Quick summary
* Version
* [Learn Markdown](https://bitbucket.org/tutorials/markdowndemo)
- **Runtime:** Node.js (v18+)
- **Framework:** Hapi.js
- **Database:** MongoDB with Mongoose ODM
- **Auth:** JWT (hapi-auth-jwt2)
- **Validation:** Joi
### How do I get set up? ###
## Setup
* Summary of set up
* Configuration
* Dependencies
* Database configuration
* How to run tests
* Deployment instructions
1. **Install dependencies**
```bash
cd backend && npm install
```
### Contribution guidelines ###
2. **Environment**
- Copy `.env.example` to `.env`
- Set `MONGODB_URI` (e.g. `mongodb://localhost:27017/eventbuzz`)
- Set `JWT_SECRET` and `JWT_REFRESH_SECRET` for production
- Optional **Google SSO:** set `GOOGLE_CLIENT_ID` to your OAuth 2.0 Web client ID (same value as the dashboard’s `VITE_GOOGLE_CLIENT_ID`). Add authorized JavaScript origins for each app URL in Google Cloud Console.
* Writing tests
* Code review
* Other guidelines
3. **Run**
```bash
npm run dev
```
Server runs at `http://localhost:3001` (or `PORT` from `.env`).
### Who do I talk to? ###
4. **Multi-tenant backfill (after pulling schema that requires org on forms/speakers)**
Run once against your database before restart if you have legacy rows:
```bash
npm run backfill:tenant-organization
```
* Repo owner or admin
* Other community or team contact
## API Overview
- **Auth:** `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/google`, `POST /api/auth/google/register`, `GET /api/auth/me`, etc.
- **Events:** `GET/POST /api/events`, `GET/PUT/DELETE /api/events/:id`
- **Participants:** `GET/POST /api/events/:eventId/participants`, `POST /api/participants/:id/check-in`
- **Sessions:** `GET/POST /api/events/:eventId/sessions`, `GET/PUT/DELETE /api/sessions/:id`
- **Speakers:** `GET/POST /api/speakers`, `GET /api/events/:eventId/speakers`
- **Questions:** `GET/POST /api/events/:eventId/questions`, `POST /api/questions/:id/upvote`, `POST /api/questions/:id/answer`
- **Quiz/Polls:** `GET/POST /api/events/:eventId/quiz-polls`, `POST /api/quiz-polls/:quizPollId/responses`
- **Campaigns:** `GET/POST /api/events/:eventId/campaigns`, `POST /api/campaigns/:id/send`
- **Notifications:** `GET /api/notifications`, `PUT /api/notifications/:id/read`
- **Forms:** `GET/POST /api/events/:eventId/forms`, `POST /api/forms/:formId/submit`
- **Design templates:** `GET/POST /api/design-templates`, `POST /api/design-templates/:id/use/:eventId`
- **Reward points:** `GET/POST /api/events/:eventId/reward-points`, `GET /api/events/:eventId/leaderboard`
- **Event pages:** `GET/POST /api/events/:eventId/pages`, `GET /api/pages/public/:eventSlug/:pageSlug`
- **Media:** `GET /api/media`, `POST /api/media/upload`
- **Payment:** `GET/POST /api/payment/config`, `POST /api/payment/create-order`, `POST /api/payment/verify`
- **Organization:** `GET/PUT /api/organization`, `GET/POST/PUT/DELETE /api/users`
Protected routes expect header: `Authorization: Bearer <token>`.
## Project structure
```
backend/
├── src/
│ ├── config/ # database, constants
│ ├── models/ # Mongoose models (User, Event, Participant, etc.)
│ ├── controllers/ # Route handlers
│ ├── routes/ # Hapi route definitions
│ ├── middleware/ # Auth helpers
│ ├── utils/ # response helpers
│ └── app.js # Server setup
├── server.js
├── package.json
└── .env.example
```

8800
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "eventbuzz-backend",
"version": "1.0.0",
"description": "EventBuzz Node.js backend with Hapi and MongoDB",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"dev:nodemon": "npx nodemon server.js",
"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"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1012.0",
"@aws-sdk/lib-storage": "^3.1012.0",
"@hapi/hapi": "^21.3.0",
"bcrypt": "^5.1.1",
"dotenv": "^16.3.1",
"firebase-admin": "^13.0.0",
"google-auth-library": "^9.14.0",
"hapi-auth-jwt2": "^10.2.0",
"joi": "^17.13.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.0.0",
"razorpay": "^2.9.6",
"otplib": "^12.0.1",
"nodemailer": "^6.9.7",
"winston": "^3.11.0"
},
"devDependencies": {
"jest": "^29.7.0",
"nodemon": "^3.0.2",
"supertest": "^6.3.3"
}
}

View File

@ -0,0 +1,45 @@
/**
* One-off: set joinCode on all sessions and tracks (same algorithm as eventBuzzCode util).
* Run: cd backend && npm run backfill:join-codes
*/
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const mongoose = require('mongoose');
const Session = require(path.join(__dirname, '..', 'src', 'models', 'Session'));
const Track = require(path.join(__dirname, '..', 'src', 'models', 'Track'));
const { eventBuzzCodeFor } = require(path.join(__dirname, '..', 'src', 'utils', 'eventBuzzCode'));
async function run() {
const uri = process.env.MONGODB_URI;
if (!uri) {
console.error('MONGODB_URI is not set');
process.exit(1);
}
await mongoose.connect(uri);
const sessions = await Session.find({}).select('_id').lean();
let ns = 0;
for (const s of sessions) {
const jc = eventBuzzCodeFor(s._id.toString(), 'session');
await Session.updateOne({ _id: s._id }, { $set: { joinCode: jc } });
ns += 1;
}
console.log(`Backfilled joinCode for ${ns} session(s).`);
const tracks = await Track.find({}).select('_id').lean();
let nt = 0;
for (const t of tracks) {
const jc = eventBuzzCodeFor(t._id.toString(), 'track');
await Track.updateOne({ _id: t._id }, { $set: { joinCode: jc } });
nt += 1;
}
console.log(`Backfilled joinCode for ${nt} track(s).`);
await mongoose.disconnect();
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,33 @@
/**
* One-off: set Session.joinCode for all existing sessions (same algorithm as dashboard eventBuzzCode).
* Run: cd backend && node scripts/backfill-session-join-codes.js
*/
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const mongoose = require('mongoose');
const Session = require(path.join(__dirname, '..', 'src', 'models', 'Session'));
const { eventBuzzCodeFor } = require(path.join(__dirname, '..', 'src', 'utils', 'eventBuzzCode'));
async function run() {
const uri = process.env.MONGODB_URI;
if (!uri) {
console.error('MONGODB_URI is not set');
process.exit(1);
}
await mongoose.connect(uri);
const sessions = await Session.find({}).select('_id').lean();
let n = 0;
for (const s of sessions) {
const jc = eventBuzzCodeFor(s._id.toString(), 'session');
await Session.updateOne({ _id: s._id }, { $set: { joinCode: jc } });
n += 1;
}
console.log(`Backfilled joinCode for ${n} session(s).`);
await mongoose.disconnect();
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,89 @@
/**
* Backfill tenant fields for multi-tenant consistency (run BEFORE deploying required schema).
* - Form.tenantOrganization from linked Event.organization
* - FormSubmission.tenantOrganization from parent Form
* - Logs Speaker rows missing organization (fix manually or delete)
*
* Run: cd backend && npm run backfill:tenant-organization
*/
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const mongoose = require('mongoose');
const Form = require(path.join(__dirname, '..', 'src', 'models', 'Form'));
const FormSubmission = require(path.join(__dirname, '..', 'src', 'models', 'FormSubmission'));
const Event = require(path.join(__dirname, '..', 'src', 'models', 'Event'));
const Speaker = require(path.join(__dirname, '..', 'src', 'models', 'Speaker'));
async function run() {
const uri = process.env.MONGODB_URI;
if (!uri) {
console.error('MONGODB_URI is not set');
process.exit(1);
}
await mongoose.connect(uri);
const formsNeedingTenant = await Form.find({
$or: [{ tenantOrganization: { $exists: false } }, { tenantOrganization: null }],
})
.select('_id event')
.lean();
let formsUpdated = 0;
for (const f of formsNeedingTenant) {
if (!f.event) {
console.warn(`Form ${f._id} has no event and no tenantOrganization — skipped`);
continue;
}
const ev = await Event.findById(f.event).select('organization').lean();
if (!ev?.organization) {
console.warn(`Form ${f._id} event ${f.event} has no organization — skipped`);
continue;
}
await Form.updateOne({ _id: f._id }, { $set: { tenantOrganization: ev.organization } });
formsUpdated += 1;
}
console.log(`Backfilled tenantOrganization on ${formsUpdated} form(s).`);
const subsNeedingTenant = await FormSubmission.find({
$or: [{ tenantOrganization: { $exists: false } }, { tenantOrganization: null }],
})
.select('_id form')
.lean();
let subsUpdated = 0;
for (const s of subsNeedingTenant) {
const form = await Form.findById(s.form).select('tenantOrganization').lean();
if (!form?.tenantOrganization) {
console.warn(`Submission ${s._id} form ${s.form} has no tenantOrganization — skipped`);
continue;
}
await FormSubmission.updateOne({ _id: s._id }, { $set: { tenantOrganization: form.tenantOrganization } });
subsUpdated += 1;
}
console.log(`Backfilled tenantOrganization on ${subsUpdated} form submission(s).`);
const orphanSpeakers = await Speaker.countDocuments({
$or: [{ organization: { $exists: false } }, { organization: null }],
});
if (orphanSpeakers > 0) {
console.warn(
`${orphanSpeakers} speaker(s) have no organization — assign org or delete before schema requires it.`,
);
const sample = await Speaker.find({ $or: [{ organization: { $exists: false } }, { organization: null }] })
.select('_id email firstName lastName')
.limit(10)
.lean();
console.warn('Sample:', JSON.stringify(sample, null, 2));
} else {
console.log('All speakers have an organization.');
}
await mongoose.disconnect();
console.log('Done.');
}
run().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,146 @@
/**
* Copy legacy `Event.website` microsite configuration into a default `EventMicrosite` row per event.
* Sets `sourcePageId` to the event's Website Builder `home` page when present.
*
* Run: cd backend && node scripts/migrate-event-website-to-microsites.js
*/
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const mongoose = require('mongoose');
const Event = require(path.join(__dirname, '..', 'src', 'models', 'Event'));
const EventPage = require(path.join(__dirname, '..', 'src', 'models', 'EventPage'));
const EventMicrosite = require(path.join(__dirname, '..', 'src', 'models', 'EventMicrosite'));
const User = require(path.join(__dirname, '..', 'src', 'models', 'User'));
function normalizeFolder(raw) {
return String(raw || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '');
}
function registrationFormObjectId(raw) {
if (raw == null || raw === '') return undefined;
const s = String(raw).trim();
if (!mongoose.Types.ObjectId.isValid(s)) return undefined;
return new mongoose.Types.ObjectId(s);
}
function websiteHasMicrositeContent(w) {
if (!w || typeof w !== 'object') return false;
if (normalizeFolder(w.folder)) return true;
if (w.enabled === true) return true;
if (typeof w.micrositeHtml === 'string' && w.micrositeHtml.trim()) return true;
if (Array.isArray(w.micrositeSections) && w.micrositeSections.length > 0) return true;
if (w.registrationFormId != null && String(w.registrationFormId).trim()) return true;
if (typeof w.title === 'string' && w.title.trim()) return true;
if (typeof w.logo === 'string' && w.logo.trim()) return true;
if (Array.isArray(w.banners) && w.banners.some(Boolean)) return true;
if (typeof w.primaryColor === 'string' && w.primaryColor.trim()) return true;
return false;
}
async function pickCreatedBy(organizationId) {
const u = await User.findOne({ organization: organizationId }).sort({ createdAt: 1 }).select('_id').lean();
return u?._id || null;
}
async function uniquePublishFolder(base) {
let candidate = base || 'microsite';
let n = 0;
// eslint-disable-next-line no-await-in-loop
while (await EventMicrosite.findOne({ publishFolder: candidate }).select('_id').lean()) {
n += 1;
candidate = `${base || 'microsite'}-${n}`;
}
return candidate;
}
async function run() {
const uri = process.env.MONGODB_URI;
if (!uri) {
console.error('MONGODB_URI is not set');
process.exit(1);
}
await mongoose.connect(uri);
const events = await Event.find({}).lean();
let created = 0;
let skipped = 0;
for (const ev of events) {
const w = ev.website;
if (!websiteHasMicrositeContent(w)) {
skipped += 1;
continue;
}
// eslint-disable-next-line no-await-in-loop
const existing = await EventMicrosite.countDocuments({ event: ev._id });
if (existing > 0) {
skipped += 1;
continue;
}
// eslint-disable-next-line no-await-in-loop
const createdBy = await pickCreatedBy(ev.organization);
if (!createdBy) {
console.warn(`Event ${ev._id}: no user found for organization — skipped`);
skipped += 1;
continue;
}
let publishFolder = normalizeFolder(w.folder);
if (!publishFolder) {
publishFolder = await uniquePublishFolder(`e${String(ev._id).slice(-8)}`);
} else {
// eslint-disable-next-line no-await-in-loop
publishFolder = await uniquePublishFolder(publishFolder);
}
// eslint-disable-next-line no-await-in-loop
const home = await EventPage.findOne({ event: ev._id, type: 'home' }).select('_id').lean();
const doc = new EventMicrosite({
event: ev._id,
name: 'Default',
publishFolder,
enabled: !!w.enabled,
logo: w.logo,
banners: Array.isArray(w.banners) ? w.banners : [],
favicon: w.favicon,
title: w.title,
seoDescription: w.seoDescription,
description: w.description,
keywords: Array.isArray(w.keywords) ? w.keywords : [],
templateId: w.templateId,
layoutTemplate: w.layoutTemplate || 'conference',
micrositeSections: Array.isArray(w.micrositeSections) ? w.micrositeSections : [],
primaryColor: w.primaryColor,
accentColor: w.accentColor,
fontPair: w.fontPair,
registrationUrl: w.registrationUrl,
registrationFormId: registrationFormObjectId(w.registrationFormId),
micrositeHtml: w.micrositeHtml,
sourcePageId: home?._id,
createdBy,
});
try {
// eslint-disable-next-line no-await-in-loop
await doc.save();
created += 1;
console.log(`Created EventMicrosite ${doc._id} for event ${ev._id} (folder=${publishFolder})`);
} catch (e) {
console.error(`Event ${ev._id}: save failed`, e.message || e);
}
}
console.log(`Done. Created ${created} microsite template(s), skipped ${skipped} event row(s) (no content or already had templates).`);
await mongoose.disconnect();
}
run().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,180 @@
/**
* One-time migration: legacy Participant (single `event` ref) → tenantOrganization + eventMemberships[].
* Merges legacy rows that share the same (organization + email) into one document.
*
* Usage (from backend/): node scripts/migrate-participants-multi-event.js
*/
require('dotenv').config();
const mongoose = require('mongoose');
const Event = require('../src/models/Event');
const LEGACY_COLLECTION = 'participants';
function pickIdentity(doc) {
const keys = [
'email',
'firstName',
'lastName',
'displayName',
'phone',
'organization',
'designation',
'customFields',
'payment',
'notes',
'emailVerified',
];
const out = {};
for (const k of keys) {
if (doc[k] !== undefined) out[k] = doc[k];
}
return out;
}
function mergeIdentity(base, next) {
const out = { ...base };
for (const [k, v] of Object.entries(next)) {
if (v === undefined || v === null) continue;
if (typeof v === 'string' && !v.trim()) continue;
if (out[k] === undefined || out[k] === null || out[k] === '') {
out[k] = v;
}
}
return out;
}
async function main() {
const uri = process.env.MONGODB_URI || process.env.MONGO_URI;
if (!uri) {
console.error('Set MONGODB_URI or MONGO_URI');
process.exit(1);
}
await mongoose.connect(uri);
const coll = mongoose.connection.db.collection(LEGACY_COLLECTION);
const legacy = await coll
.find({
event: { $exists: true, $ne: null },
$or: [{ eventMemberships: { $exists: false } }, { eventMemberships: { $size: 0 } }],
})
.toArray();
console.log(`Found ${legacy.length} legacy participant documents`);
/** @type {Map<string, { keepId: any, docIds: any[], memberships: any[], identity: object }>} */
const emailGroups = new Map();
const singles = [];
for (const doc of legacy) {
const ev = await Event.findById(doc.event).lean();
if (!ev?.organization) {
console.warn('Skipping participant (no event org)', String(doc._id));
continue;
}
const membership = {
event: doc.event,
status: doc.status || 'pending',
checkedIn: Boolean(doc.checkedIn),
checkedInAt: doc.checkedInAt || undefined,
guestToken: doc.guestToken || undefined,
registrationDate: doc.registrationDate || doc.createdAt || new Date(),
tags: Array.isArray(doc.tags) ? [...doc.tags] : [],
};
const email = String(doc.email || '').trim().toLowerCase();
if (!email) {
singles.push({
docId: doc._id,
tenantOrganization: ev.organization,
membership,
identity: pickIdentity(doc),
});
continue;
}
const key = `${String(ev.organization)}|${email}`;
if (!emailGroups.has(key)) {
emailGroups.set(key, {
keepId: doc._id,
docIds: [],
memberships: [],
identity: pickIdentity(doc),
});
}
const g = emailGroups.get(key);
g.docIds.push(doc._id);
g.memberships.push(membership);
g.identity = mergeIdentity(g.identity, pickIdentity(doc));
if (String(doc._id) < String(g.keepId)) g.keepId = doc._id;
}
for (const [, g] of emailGroups) {
const ev0 = await Event.findById(g.memberships[0].event).lean();
const tenantOrganization = ev0?.organization;
if (!tenantOrganization) continue;
const byEvent = new Map();
for (const m of g.memberships) {
byEvent.set(String(m.event), m);
}
const eventMemberships = [...byEvent.values()];
await coll.updateOne(
{ _id: g.keepId },
{
$set: {
...g.identity,
email: String(g.identity.email || '').trim().toLowerCase(),
tenantOrganization,
eventMemberships,
},
$unset: {
event: '',
status: '',
checkedIn: '',
checkedInAt: '',
guestToken: '',
registrationDate: '',
tags: '',
},
}
);
const others = g.docIds.filter((id) => String(id) !== String(g.keepId));
if (others.length) {
await coll.deleteMany({ _id: { $in: others } });
console.log('Merged duplicates:', others.length, 'removed; kept', String(g.keepId));
}
}
for (const s of singles) {
await coll.updateOne(
{ _id: s.docId },
{
$set: {
...s.identity,
tenantOrganization: s.tenantOrganization,
eventMemberships: [s.membership],
},
$unset: {
event: '',
status: '',
checkedIn: '',
checkedInAt: '',
guestToken: '',
registrationDate: '',
tags: '',
},
}
);
}
console.log('Done. Email groups:', emailGroups.size, 'guest rows:', singles.length);
await mongoose.disconnect();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

14
server.js Normal file
View File

@ -0,0 +1,14 @@
require('dotenv').config();
const start = async () => {
const app = require('./src/app');
try {
await app.initialize();
await app.start();
console.log(`Server running at: ${app.info.uri}`);
} catch (err) {
console.error('Failed to start server:', err);
process.exit(1);
}
};
start();

247
src/app.js Normal file
View File

@ -0,0 +1,247 @@
const Hapi = require('@hapi/hapi');
const HapiJwt = require('hapi-auth-jwt2');
const { connectDB } = require('./config/database');
const authRoutes = require('./routes/auth.routes');
const eventRoutes = require('./routes/event.routes');
const participantRoutes = require('./routes/participant.routes');
const participantListRoutes = require('./routes/participant-list.routes');
const sessionRoutes = require('./routes/session.routes');
const trackRoutes = require('./routes/track.routes');
const speakerRoutes = require('./routes/speaker.routes');
const questionRoutes = require('./routes/question.routes');
const quizPollRoutes = require('./routes/quizPoll.routes');
const campaignRoutes = require('./routes/campaign.routes');
const notificationRoutes = require('./routes/notification.routes');
const formRoutes = require('./routes/form.routes');
const designTemplateRoutes = require('./routes/designTemplate.routes');
const rewardPointRoutes = require('./routes/rewardPoint.routes');
const pageRoutes = require('./routes/page.routes');
const mediaRoutes = require('./routes/media.routes');
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 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 createServer = async () => {
const fromEnv = (process.env.CORS_ORIGIN || process.env.FRONTEND_URL || '')
.split(',')
.map((s) => s.trim().replace(/\/$/, ''))
.filter(Boolean);
/** Local Vite / dashboard ports — keep in sync with eventbuzz (5174) and main frontend (5173). */
const builderExtras = (process.env.WEBSITE_BUILDER_CORS_ORIGINS || '')
.split(',')
.map((s) => s.trim().replace(/\/$/, ''))
.filter(Boolean);
const localDevOrigins = [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:5175',
'http://localhost:8080',
'http://127.0.0.1:5173',
'http://127.0.0.1:5174',
'http://127.0.0.1:5175',
'http://127.0.0.1:8080',
];
const isProd = process.env.NODE_ENV === 'production';
// In dev, merge env with local defaults so CORS_ORIGIN=http://localhost:5173 still allows eventbuzz on :5174.
const originList = isProd
? fromEnv.length > 0
? fromEnv
: localDevOrigins
: fromEnv.length > 0
? [...new Set([...localDevOrigins, ...fromEnv])]
: 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])];
/** In development, allow any http://localhost or http://127.0.0.1 port (Vite may use 5175, 5176, …). Production: whitelist only. */
function resolveCorsAllowOrigin(rawOrigin) {
if (!rawOrigin) return null;
const origin = String(rawOrigin).trim().replace(/\/$/, '');
if (allowedOrigins.includes(origin)) return origin;
if (isProd) return null;
try {
const u = new URL(origin);
if (u.protocol !== 'http:') return null;
if (u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') return null;
return origin;
} catch {
return null;
}
}
/** Hapi default is 1MB; event logo/banner are sent as base64 in JSON and easily exceed that. Override via PAYLOAD_MAX_BYTES. */
const payloadMaxBytes = (() => {
const n = Number(process.env.PAYLOAD_MAX_BYTES);
return Number.isFinite(n) && n > 0 ? n : 25 * 1024 * 1024;
})();
const server = Hapi.server({
port: process.env.PORT || 3001,
host: process.env.HOST || 'localhost',
routes: {
payload: {
maxBytes: payloadMaxBytes,
},
validate: {
failAction: (request, h, err) => {
throw err;
},
},
},
});
server.route({
method: 'OPTIONS',
path: '/{p*}',
options: {
auth: false,
handler: (request, h) => {
const allowOrigin = resolveCorsAllowOrigin(request.headers.origin);
const res = h.response().code(204);
if (allowOrigin) {
res.header('Access-Control-Allow-Origin', allowOrigin);
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.header('Access-Control-Max-Age', '86400');
}
return res;
},
},
});
await server.register(HapiJwt);
const User = require('./models/User');
const jwtSecret = process.env.JWT_SECRET || 'default-secret-change-in-production';
const attendeeSecret = process.env.ATTENDEE_JWT_SECRET || jwtSecret;
server.auth.strategy('jwt', 'jwt', {
key: jwtSecret,
validate: async (decoded, request, h) => {
if (decoded.scope === '2fa_pending') return { isValid: false };
const user = await User.findById(decoded.id).select('-password').populate('organization');
if (!user || !user.isActive) return { isValid: false };
return { isValid: true, credentials: { user, id: user._id.toString() } };
},
verifyOptions: { algorithms: ['HS256'] },
});
const Participant = require('./models/Participant');
server.auth.strategy('jwt-attendee', 'jwt', {
key: attendeeSecret,
validate: async (decoded, request, h) => {
if (decoded.aud !== 'attendee' || !decoded.pid) return { isValid: false };
const participant = await Participant.findById(decoded.pid).lean();
if (!participant) return { isValid: false };
return { isValid: true, credentials: { participant, attendee: decoded } };
},
verifyOptions: { algorithms: ['HS256'] },
});
server.auth.default('jwt');
server.route({
method: 'GET',
path: '/',
handler: () => ({ name: 'EventBuzz API', version: '1.0.0', status: 'ok' }),
options: { auth: false },
});
server.route({
method: 'GET',
path: '/health',
handler: () => ({ status: 'ok', timestamp: new Date().toISOString() }),
options: { auth: false },
});
server.route(visitorRoutes);
server.route(authRoutes);
server.route(eventRoutes);
server.route(participantRoutes);
server.route(participantListRoutes);
server.route(sessionRoutes);
server.route(trackRoutes);
server.route(speakerRoutes);
server.route(questionRoutes);
server.route(quizPollRoutes);
server.route(campaignRoutes);
server.route(notificationRoutes);
server.route(formRoutes);
server.route(designTemplateRoutes);
server.route(rewardPointRoutes);
server.route(pageRoutes);
server.route(mediaRoutes);
server.route(contentCollectionRoutes);
server.route(paymentRoutes);
server.route(organizationRoutes);
server.route(subscriptionRoutes);
server.route(adminRequestRoutes);
server.route(eventStaffRoutes);
server.route(websiteSettingsRoutes);
server.route(eventMicrositeRoutes);
server.ext('onPreResponse', (request, h) => {
const response = request.response;
const allowOrigin = resolveCorsAllowOrigin(request.headers.origin);
if (response.isBoom && response.output) {
const statusCode = response.output.statusCode;
const payload = {
success: false,
error: {
code: response.output.payload.error || 'ERROR',
message: response.message,
details: response.details || [],
},
timestamp: new Date().toISOString(),
};
const res = h.response(payload).code(statusCode);
if (allowOrigin) {
res.header('Access-Control-Allow-Origin', allowOrigin);
res.header('Access-Control-Allow-Credentials', 'true');
}
return res;
}
if (allowOrigin && typeof response.header === 'function') {
response.header('Access-Control-Allow-Origin', allowOrigin);
response.header('Access-Control-Allow-Credentials', 'true');
}
return h.continue;
});
return server;
};
const app = {
_server: null,
async initialize() {
await connectDB();
this._server = await createServer();
},
async start() {
if (!this._server) await this.initialize();
await this._server.start();
},
get info() {
return this._server ? this._server.info : null;
},
};
module.exports = app;

65
src/config/constants.js Normal file
View File

@ -0,0 +1,65 @@
const ROLES = ['super_admin', 'admin', 'moderator', 'viewer'];
const EVENT_TYPES = ['conference', 'workshop', 'summit', 'cme', 'webinar'];
const EVENT_STATUS = ['draft', 'upcoming', 'live', 'completed'];
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'];
/** Registration field → Participant property mapping (or `custom` + customMapsKey). */
const FORM_FIELD_MAPS_TO = [
'firstName',
'lastName',
'email',
'phone',
'organization',
'designation',
'notes',
'displayName',
'custom',
];
const QUIZ_POLL_TYPES = ['quiz', 'poll', 'survey'];
const QUESTION_TYPES = ['single', 'multiple', 'text', 'rating'];
const CAMPAIGN_CHANNELS = ['email', 'whatsapp', 'sms'];
const CAMPAIGN_TYPES = ['broadcast', 'trigger', 'scheduled'];
const PAGE_TYPES = ['home', 'agenda', 'speakers', 'registration', 'sponsors', 'custom'];
const WEBSITE_SECTION_TYPES = [
'header',
'banner',
'contact_form',
'agenda',
'register_links',
'speakers',
'footer',
'sponsors',
'cta',
'custom_html',
'countdown',
'gallery',
'about',
'venue',
'faq',
'tickets',
];
const DESIGN_TEMPLATE_TYPES = ['brochure', 'microsite', 'badge', 'emailer', 'certificate'];
const REWARD_SOURCES = ['registration', 'attendance', 'feedback', 'quiz', 'referral', 'manual'];
module.exports = {
ROLES,
EVENT_TYPES,
EVENT_STATUS,
PARTICIPANT_STATUS,
SESSION_TYPES,
SESSION_STATUS,
FORM_TYPES,
FIELD_TYPES,
FORM_FIELD_MAPS_TO,
QUIZ_POLL_TYPES,
QUESTION_TYPES,
CAMPAIGN_CHANNELS,
CAMPAIGN_TYPES,
PAGE_TYPES,
WEBSITE_SECTION_TYPES,
DESIGN_TEMPLATE_TYPES,
REWARD_SOURCES,
};

13
src/config/database.js Normal file
View File

@ -0,0 +1,13 @@
const mongoose = require('mongoose');
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);
}
};
module.exports = { connectDB };

View File

@ -0,0 +1,59 @@
/**
* Code-first subscription catalog. Plans reference feature keys from FEATURES.
* Align Organization.subscription.plan with PLAN ids (free | pro | enterprise).
*/
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 PLANS = {
free: {
id: 'free',
name: 'Starter',
featureKeys: ['basic_analytics', 'email_support', 'content_library'],
defaultLimits: { maxEvents: 3, maxParticipants: 500 },
},
pro: {
id: 'pro',
name: 'Pro',
featureKeys: [
'basic_analytics',
'advanced_analytics',
'custom_forms',
'email_support',
'priority_support',
'content_library',
],
defaultLimits: { maxEvents: 20, maxParticipants: 5000 },
},
enterprise: {
id: 'enterprise',
name: 'Enterprise',
featureKeys: Object.keys(FEATURES),
defaultLimits: { maxEvents: null, maxParticipants: null },
},
};
function listFeatures() {
return Object.values(FEATURES);
}
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),
}));
}
module.exports = { FEATURES, PLANS, listFeatures, listPlans };

View File

@ -0,0 +1,24 @@
const AdminRequest = require('../models/AdminRequest');
const { success, error } = require('../utils/response');
function userOrgId(user) {
return user.organization?._id || user.organization;
}
const create = async (request, h) => {
const user = request.auth.credentials.user;
if (user.role !== 'super_admin') {
return h.response(error('FORBIDDEN', 'Only super administrators can submit these requests')).code(403);
}
const { type, message, metadata } = request.payload;
const doc = await AdminRequest.create({
type,
requestedBy: user._id,
organization: userOrgId(user) || undefined,
message: (message || '').trim(),
metadata: metadata && typeof metadata === 'object' ? metadata : {},
});
return h.response(success(doc, 'Request submitted', 201)).code(201);
};
module.exports = { create };

View File

@ -0,0 +1,450 @@
const jwt = require('jsonwebtoken');
let authenticator;
try {
({ authenticator } = require('otplib'));
} catch {
authenticator = null;
}
const User = require('../models/User');
const Organization = require('../models/Organization');
const OrganizationUserMap = require('../models/OrganizationUserMap');
const { verifyGoogleIdToken } = require('../utils/googleIdToken');
const { success, error } = require('../utils/response');
const JWT_SECRET = process.env.JWT_SECRET || 'default-secret';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'default-refresh-secret';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m';
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '7d';
const register = async (request, h) => {
try {
const { email, password, firstName, lastName, teamName } = 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);
}
const existing = await User.findOne({ email });
if (existing) {
return h.response(error('VALIDATION_ERROR', 'Email already registered')).code(400);
}
const org = await Organization.create({ name: String(organizationName).trim() });
const user = await User.create({
email,
password,
firstName,
lastName,
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');
return h.response(success({
user: populated,
token,
refreshToken,
expiresIn: JWT_EXPIRES_IN,
}, 'Registration successful', 201)).code(201);
} catch (err) {
if (err.name === 'ValidationError') {
return h.response(error('VALIDATION_ERROR', err.message, Object.values(err.errors).map(e => e.message))).code(400);
}
throw err;
}
};
async function issueSessionForUser(h, userDoc) {
userDoc.lastLogin = new Date();
await userDoc.save({ validateBeforeSave: false });
const token = jwt.sign({ id: userDoc._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
const refreshToken = jwt.sign({ id: userDoc._id }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN });
const out = userDoc.toObject ? userDoc.toObject() : { ...userDoc };
delete out.password;
return h.response(success({
user: out,
token,
refreshToken,
expiresIn: JWT_EXPIRES_IN,
})).code(200);
}
const googleLogin = async (request, h) => {
try {
let g;
try {
g = await verifyGoogleIdToken(request.payload.idToken);
} catch (e) {
if (e.code === 'GOOGLE_NOT_CONFIGURED') {
return h.response(error('NOT_CONFIGURED', e.message)).code(503);
}
return h.response(error('UNAUTHORIZED', e.message || 'Invalid Google credential')).code(401);
}
let user = await User.findOne({ googleId: g.sub }).populate('organization');
if (!user) {
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);
}
if (user.googleId && user.googleId !== g.sub) {
return h.response(error('ACCOUNT_MISMATCH', 'This email is linked to a different Google account.')).code(400);
}
if (!user.googleId) {
user.googleId = g.sub;
if (g.picture && !user.profileImage) user.profileImage = g.picture;
await user.save();
user = await User.findById(user._id).populate('organization');
}
if (!user.isActive) {
return h.response(error('FORBIDDEN', 'Account is deactivated')).code(403);
}
if (user.twoFactorEnabled) {
if (!authenticator) {
return h.response(error('NOT_IMPLEMENTED', 'Two-factor dependencies not installed')).code(501);
}
const twoFactorToken = jwt.sign(
{ id: user._id.toString(), scope: '2fa_pending' },
JWT_SECRET,
{ expiresIn: '5m' },
);
return h.response(success({
requiresTwoFactor: true,
twoFactorToken,
email: user.email,
})).code(200);
}
return issueSessionForUser(h, user);
} catch (err) {
throw err;
}
};
const googleRegister = async (request, h) => {
try {
let g;
try {
g = await verifyGoogleIdToken(request.payload.idToken);
} catch (e) {
if (e.code === 'GOOGLE_NOT_CONFIGURED') {
return h.response(error('NOT_CONFIGURED', e.message)).code(503);
}
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);
}
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');
return h.response(success({
user: populated,
token,
refreshToken,
expiresIn: JWT_EXPIRES_IN,
}, 'Registration successful', 201)).code(201);
} catch (err) {
if (err.name === 'ValidationError') {
return h.response(error('VALIDATION_ERROR', err.message, Object.values(err.errors).map(e => e.message))).code(400);
}
throw err;
}
};
const login = async (request, h) => {
try {
const { email, password } = request.payload;
const user = await User.findOne({ email }).populate('organization');
if (!user || !(await user.comparePassword(password))) {
return h.response(error('UNAUTHORIZED', 'Invalid email or password')).code(401);
}
if (!user.isActive) {
return h.response(error('FORBIDDEN', 'Account is deactivated')).code(403);
}
if (user.twoFactorEnabled) {
if (!authenticator) {
return h.response(error('NOT_IMPLEMENTED', 'Two-factor dependencies not installed')).code(501);
}
const twoFactorToken = jwt.sign(
{ id: user._id.toString(), scope: '2fa_pending' },
JWT_SECRET,
{ expiresIn: '5m' },
);
return h.response(success({
requiresTwoFactor: true,
twoFactorToken,
email: user.email,
})).code(200);
}
return issueSessionForUser(h, user);
} catch (err) {
throw err;
}
};
const login2fa = async (request, h) => {
try {
if (!authenticator) {
return h.response(error('NOT_IMPLEMENTED', 'Two-factor dependencies not installed')).code(501);
}
const { twoFactorToken, token: totp } = request.payload;
let decoded;
try {
decoded = jwt.verify(twoFactorToken, JWT_SECRET);
} catch {
return h.response(error('UNAUTHORIZED', 'Invalid or expired verification session. Please sign in again.')).code(401);
}
if (decoded.scope !== '2fa_pending' || !decoded.id) {
return h.response(error('UNAUTHORIZED', 'Invalid verification session')).code(401);
}
const user = await User.findById(decoded.id).select('+twoFactorSecret').populate('organization');
if (!user || !user.isActive || !user.twoFactorEnabled || !user.twoFactorSecret) {
return h.response(error('UNAUTHORIZED', 'Invalid verification session')).code(401);
}
const ok = authenticator.verify({
token: String(totp).replace(/\s/g, ''),
secret: user.twoFactorSecret,
});
if (!ok) {
return h.response(error('VALIDATION_ERROR', 'Invalid authenticator code')).code(400);
}
user.lastLogin = new Date();
await user.save({ validateBeforeSave: false });
const accessToken = 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 out = user.toObject();
delete out.password;
delete out.twoFactorSecret;
delete out.twoFactorTempSecret;
return h.response(success({
user: out,
token: accessToken,
refreshToken,
expiresIn: JWT_EXPIRES_IN,
})).code(200);
} catch (err) {
throw err;
}
};
const refresh = async (request, h) => {
try {
const { refreshToken: rt } = request.payload;
if (!rt) return h.response(error('VALIDATION_ERROR', 'Refresh token required')).code(400);
const decoded = jwt.verify(rt, JWT_REFRESH_SECRET);
const user = await User.findById(decoded.id).select('-password').populate('organization');
if (!user || !user.isActive) {
return h.response(error('UNAUTHORIZED', 'Invalid refresh token')).code(401);
}
const token = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
return h.response(success({ token, expiresIn: JWT_EXPIRES_IN })).code(200);
} catch (err) {
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
return h.response(error('UNAUTHORIZED', 'Invalid or expired refresh token')).code(401);
}
throw err;
}
};
const me = async (request, h) => {
const user = request.auth.credentials.user;
const memberships = await OrganizationUserMap.find({ user: user._id })
.populate('organization', 'name')
.select('organization role eventAccess')
.lean();
const out = user.toObject ? user.toObject() : { ...user };
delete out.twoFactorSecret;
delete out.twoFactorTempSecret;
out.twoFactorEnabled = Boolean(out.twoFactorEnabled);
out.organizationMemberships = memberships;
return h.response(success(out)).code(200);
};
const updateMe = async (request, h) => {
const cred = request.auth.credentials.user;
const user = await User.findById(cred._id);
if (!user) return h.response(error('NOT_FOUND', 'User not found')).code(404);
const p = request.payload;
if (p.firstName != null) user.firstName = p.firstName;
if (p.lastName != null) user.lastName = p.lastName;
if (p.phone != null) user.phone = p.phone;
if (p.profileImage != null) user.profileImage = p.profileImage;
if (p.bio != null) user.bio = String(p.bio);
if (p.notificationSettings != null && typeof p.notificationSettings === 'object') {
const cur = user.notificationSettings?.toObject?.() ?? user.notificationSettings ?? {};
user.notificationSettings = { ...cur, ...p.notificationSettings };
user.notificationPreferencesSavedAt = new Date();
}
await user.save();
const populated = await User.findById(user._id).select('-password').populate('organization');
const memberships = await OrganizationUserMap.find({ user: user._id })
.populate('organization', 'name')
.select('organization role eventAccess')
.lean();
const out = populated.toObject();
out.organizationMemberships = memberships;
return h.response(success(out)).code(200);
};
const changePassword = async (request, h) => {
const cred = request.auth.credentials.user;
const { currentPassword, newPassword } = request.payload;
const user = await User.findById(cred._id);
if (!user) {
return h.response(error('NOT_FOUND', 'User not found')).code(404);
}
if (!user.password) {
return h.response(error('VALIDATION_ERROR', 'Password sign-in is not set for this account. Sign in with Google, or use forgot password to set one.')).code(400);
}
if (!(await user.comparePassword(currentPassword))) {
return h.response(error('VALIDATION_ERROR', 'Current password is incorrect')).code(400);
}
user.password = newPassword;
await user.save();
return h.response(success(null, 'Password updated')).code(200);
};
const setup2fa = async (request, h) => {
if (!authenticator) {
return h.response(error('NOT_IMPLEMENTED', 'Two-factor dependencies not installed')).code(501);
}
const cred = request.auth.credentials.user;
const user = await User.findById(cred._id).select('+twoFactorSecret +twoFactorTempSecret');
if (!user) return h.response(error('NOT_FOUND', 'User not found')).code(404);
if (user.twoFactorEnabled) {
return h.response(error('VALIDATION_ERROR', '2FA is already enabled')).code(400);
}
const secret = authenticator.generateSecret();
user.twoFactorTempSecret = secret;
user.twoFactorSecret = '';
await user.save();
const otpauthUrl = authenticator.keyuri(user.email, 'EventBuzz', secret);
return h.response(success({ otpauthUrl, manualEntryKey: secret })).code(200);
};
const verify2faSetup = async (request, h) => {
if (!authenticator) {
return h.response(error('NOT_IMPLEMENTED', 'Two-factor dependencies not installed')).code(501);
}
const cred = request.auth.credentials.user;
const { token } = request.payload;
const user = await User.findById(cred._id).select('+twoFactorTempSecret +twoFactorSecret');
if (!user || !user.twoFactorTempSecret) {
return h.response(error('VALIDATION_ERROR', 'Start setup first')).code(400);
}
const ok = authenticator.verify({ token: String(token).replace(/\s/g, ''), secret: user.twoFactorTempSecret });
if (!ok) return h.response(error('VALIDATION_ERROR', 'Invalid code')).code(400);
user.twoFactorSecret = user.twoFactorTempSecret;
user.twoFactorTempSecret = '';
user.twoFactorEnabled = true;
await user.save();
return h.response(success({ twoFactorEnabled: true })).code(200);
};
const disable2fa = async (request, h) => {
if (!authenticator) {
return h.response(error('NOT_IMPLEMENTED', 'Two-factor dependencies not installed')).code(501);
}
const cred = request.auth.credentials.user;
const { currentPassword, token } = request.payload;
const user = await User.findById(cred._id).select('+password +twoFactorSecret +twoFactorTempSecret');
if (!user) {
return h.response(error('NOT_FOUND', 'User not found')).code(404);
}
const hasPassword = Boolean(user.password);
if (hasPassword && !(await user.comparePassword(currentPassword))) {
return h.response(error('VALIDATION_ERROR', 'Current password is incorrect')).code(400);
}
if (!hasPassword) {
if (!user.twoFactorEnabled || !user.twoFactorSecret) {
return h.response(error('VALIDATION_ERROR', 'Set a password in your account before changing this security option, or contact support.')).code(400);
}
const ok = token && authenticator.verify({ token: String(token).replace(/\s/g, ''), secret: user.twoFactorSecret });
if (!ok) return h.response(error('VALIDATION_ERROR', 'Invalid authenticator code')).code(400);
} else if (user.twoFactorEnabled && user.twoFactorSecret) {
const ok = token && authenticator.verify({ token: String(token).replace(/\s/g, ''), secret: user.twoFactorSecret });
if (!ok) return h.response(error('VALIDATION_ERROR', 'Invalid authenticator code')).code(400);
}
user.twoFactorEnabled = false;
user.twoFactorSecret = '';
user.twoFactorTempSecret = '';
await user.save();
return h.response(success(null, 'Two-factor authentication disabled')).code(200);
};
const forgotPassword = async (request, h) => {
const { email } = request.payload;
const user = await User.findOne({ email });
if (!user) {
return h.response(success({ message: 'If the email exists, a reset link will be sent.' })).code(200);
}
const resetToken = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: '1h' });
// TODO: send email with reset link using nodemailer
return h.response(success({ message: 'If the email exists, a reset link will be sent.', resetToken })).code(200);
};
const resetPassword = async (request, h) => {
const { token, newPassword } = request.payload;
try {
const decoded = jwt.verify(token, JWT_SECRET);
const user = await User.findById(decoded.id);
if (!user) return h.response(error('UNAUTHORIZED', 'Invalid token')).code(401);
user.password = newPassword;
await user.save();
return h.response(success(null, 'Password reset successful')).code(200);
} catch (err) {
return h.response(error('UNAUTHORIZED', 'Invalid or expired token')).code(401);
}
};
const logout = async (request, h) => {
return h.response(success(null, 'Logged out')).code(200);
};
module.exports = {
register,
login,
googleLogin,
googleRegister,
login2fa,
refresh,
me,
updateMe,
changePassword,
setup2fa,
verify2faSetup,
disable2fa,
forgotPassword,
resetPassword,
logout,
};

View File

@ -0,0 +1,113 @@
const MessagingCampaign = require('../models/MessagingCampaign');
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
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 campaigns = await MessagingCampaign.find({ event: eventId }).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 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,
createdBy: user._id,
});
return h.response(success(campaign, 'Campaign created', 201)).code(201);
};
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);
}
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);
}
if (campaign.status === 'sent') {
return h.response(error('VALIDATION_ERROR', 'Cannot update sent campaign')).code(400);
}
Object.assign(campaign, request.payload);
await campaign.save();
return h.response(success(campaign));
};
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);
}
await campaign.deleteOne();
return h.response(success(null, 'Campaign deleted')).code(200);
};
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 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);
campaign.status = 'scheduled';
campaign.scheduledFor = request.payload.scheduledFor;
await campaign.save();
return h.response(success(campaign, 'Campaign scheduled'));
};
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);
if (campaign.status !== 'scheduled') {
return h.response(error('VALIDATION_ERROR', 'Only scheduled campaigns can be cancelled')).code(400);
}
campaign.status = 'draft';
campaign.scheduledFor = 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');
if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404);
return h.response(success(campaign));
};
module.exports = { list, create, get, update, remove, send, schedule, cancel, analytics };

View File

@ -0,0 +1,154 @@
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const ContentCollection = require('../models/ContentCollection');
const LibraryContent = require('../models/LibraryContent');
const { success, error } = require('../utils/response');
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
function userOrgId(user) {
return user.organization?._id || user.organization;
}
function assertCollectionOrg(doc, user) {
if (user.role === 'super_admin') return true;
const uo = userOrgId(user);
return uo && doc.organization?.toString() === uo.toString();
}
const list = 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 filter = { organization: scoped.organizationId };
const { page = 1, limit = 50 } = request.query;
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const [collections, total] = await Promise.all([
ContentCollection.find(filter).sort({ createdAt: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(),
ContentCollection.countDocuments(filter),
]);
return h.response(success({ collections, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }));
};
const create = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = userOrgId(user);
if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
const p = request.payload;
let passwordHash = '';
if (p.passwordProtected && p.password && String(p.password).trim()) {
passwordHash = await bcrypt.hash(String(p.password).trim(), 10);
}
const contentIds = (p.contentIds || [])
.map((id) => (mongoose.Types.ObjectId.isValid(id) ? new mongoose.Types.ObjectId(id) : null))
.filter(Boolean);
if (contentIds.length) {
const n = await LibraryContent.countDocuments({
_id: { $in: contentIds },
organization: orgId,
});
if (n !== contentIds.length) {
return h.response(error('VALIDATION_ERROR', 'One or more library files are invalid or not in your organization')).code(400);
}
}
const sharedWith = (p.sharedWith || [])
.map((id) => (mongoose.Types.ObjectId.isValid(id) ? new mongoose.Types.ObjectId(id) : null))
.filter(Boolean);
const doc = await ContentCollection.create({
organization: orgId,
name: p.name.trim(),
description: (p.description || '').trim(),
collectionType: p.collectionType || '',
coverIcon: p.coverIcon || '',
tags: Array.isArray(p.tags) ? p.tags.map((t) => String(t).trim()).filter(Boolean) : [],
contentIds,
visibility: p.visibility || 'private',
sharedWith,
passwordProtected: Boolean(p.passwordProtected),
passwordHash,
expiryEnabled: Boolean(p.expiryEnabled),
expiryDate: p.expiryDate ? new Date(p.expiryDate) : undefined,
notifyOnAdd: p.notifyOnAdd !== false,
notifyOnShare: p.notifyOnShare !== false,
createdBy: user._id,
});
return h.response(success(doc, 'Collection created', 201)).code(201);
};
const get = async (request, h) => {
const doc = await ContentCollection.findById(request.params.id).lean();
if (!doc) return h.response(error('NOT_FOUND', 'Collection not found')).code(404);
const user = request.auth.credentials.user;
if (!assertCollectionOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const out = { ...doc };
delete out.passwordHash;
return h.response(success(out));
};
const update = async (request, h) => {
const doc = await ContentCollection.findById(request.params.id);
if (!doc) return h.response(error('NOT_FOUND', 'Collection not found')).code(404);
const user = request.auth.credentials.user;
if (!assertCollectionOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const p = request.payload;
const orgId = doc.organization;
if (p.name != null) doc.name = String(p.name).trim();
if (p.description != null) doc.description = String(p.description).trim();
if (p.collectionType != null) doc.collectionType = p.collectionType;
if (p.coverIcon != null) doc.coverIcon = p.coverIcon;
if (Array.isArray(p.tags)) doc.tags = p.tags.map((t) => String(t).trim()).filter(Boolean);
if (p.visibility) doc.visibility = p.visibility;
if (Array.isArray(p.contentIds)) {
const contentIds = p.contentIds
.map((id) => (mongoose.Types.ObjectId.isValid(id) ? new mongoose.Types.ObjectId(id) : null))
.filter(Boolean);
if (contentIds.length) {
const n = await LibraryContent.countDocuments({ _id: { $in: contentIds }, organization: orgId });
if (n !== contentIds.length) {
return h.response(error('VALIDATION_ERROR', 'Invalid library file reference')).code(400);
}
}
doc.contentIds = contentIds;
}
if (Array.isArray(p.sharedWith)) {
doc.sharedWith = p.sharedWith
.map((id) => (mongoose.Types.ObjectId.isValid(id) ? new mongoose.Types.ObjectId(id) : null))
.filter(Boolean);
}
if (p.passwordProtected != null) doc.passwordProtected = Boolean(p.passwordProtected);
if (p.passwordProtected && p.password && String(p.password).trim()) {
doc.passwordHash = await bcrypt.hash(String(p.password).trim(), 10);
}
if (p.passwordProtected === false) doc.passwordHash = '';
if (p.expiryEnabled != null) doc.expiryEnabled = Boolean(p.expiryEnabled);
if (p.expiryDate != null) doc.expiryDate = p.expiryDate ? new Date(p.expiryDate) : undefined;
if (p.notifyOnAdd != null) doc.notifyOnAdd = Boolean(p.notifyOnAdd);
if (p.notifyOnShare != null) doc.notifyOnShare = Boolean(p.notifyOnShare);
await doc.save();
const lean = doc.toObject();
delete lean.passwordHash;
return h.response(success(lean));
};
const remove = async (request, h) => {
const doc = await ContentCollection.findById(request.params.id);
if (!doc) return h.response(error('NOT_FOUND', 'Collection not found')).code(404);
const user = request.auth.credentials.user;
if (!assertCollectionOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
await doc.deleteOne();
return h.response(success(null, 'Collection deleted')).code(200);
};
module.exports = { list, create, get, update, remove };

View File

@ -0,0 +1,92 @@
const DesignTemplate = require('../models/DesignTemplate');
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
const list = 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 filter = { organization: scoped.organizationId };
if (request.query.type) filter.type = request.query.type;
const { page = 1, limit = 20 } = request.query;
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const [templates, total] = await Promise.all([
DesignTemplate.find(filter).skip(skip).limit(parseInt(limit, 10)).lean(),
DesignTemplate.countDocuments(filter),
]);
return h.response(success({ templates, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }))
.header('X-Total-Count', total);
};
const create = async (request, h) => {
const user = request.auth.credentials.user;
if (user.role !== 'super_admin') return h.response(error('FORBIDDEN', 'Only super_admin can create design templates')).code(403);
const orgId = request.payload.organization || (user.organization?._id || user.organization);
if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
const template = await DesignTemplate.create({
...request.payload,
organization: orgId,
createdBy: user._id,
});
return h.response(success(template, 'Template created', 201)).code(201);
};
const get = async (request, h) => {
const template = await DesignTemplate.findById(request.params.id).populate('organization createdBy');
if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && template.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
return h.response(success(template));
};
const update = async (request, h) => {
const user = request.auth.credentials.user;
if (user.role !== 'super_admin') return h.response(error('FORBIDDEN', 'Only super_admin can update design templates')).code(403);
const template = await DesignTemplate.findById(request.params.id);
if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
Object.assign(template, request.payload);
await template.save();
return h.response(success(template));
};
const remove = async (request, h) => {
const user = request.auth.credentials.user;
if (user.role !== 'super_admin') return h.response(error('FORBIDDEN', 'Only super_admin can delete design templates')).code(403);
const template = await DesignTemplate.findById(request.params.id);
if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
await template.deleteOne();
return h.response(success(null, 'Template deleted')).code(200);
};
const duplicate = async (request, h) => {
const user = request.auth.credentials.user;
if (user.role !== 'super_admin') return h.response(error('FORBIDDEN', 'Only super_admin can duplicate design templates')).code(403);
const template = await DesignTemplate.findById(request.params.id);
if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
const doc = template.toObject();
delete doc._id;
delete doc.createdAt;
delete doc.updatedAt;
doc.name = `${doc.name} (Copy)`;
const created = await DesignTemplate.create(doc);
return h.response(success(created, 'Template duplicated', 201)).code(201);
};
const useForEvent = async (request, h) => {
const { id, eventId } = request.params;
const template = await DesignTemplate.findById(id);
if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
const event = await Event.findById(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!template.usedBy) template.usedBy = [];
if (!template.usedBy.some(e => e.toString() === eventId)) {
template.usedBy.push(event._id);
await template.save();
}
return h.response(success({ template, event }, 'Template applied to event'));
};
module.exports = { list, create, get, update, remove, duplicate, useForEvent };

View File

@ -0,0 +1,358 @@
const Event = require('../models/Event');
const User = require('../models/User');
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');
function parseDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') return null;
const trimmed = dataUrl.trim();
const m = /^data:([^;]+);base64,(.+)$/.exec(trimmed);
if (!m) return null;
const mimeType = m[1];
const base64 = m[2];
return {
mimeType,
buffer: Buffer.from(base64, 'base64'),
};
}
function extensionFromMimeType(mimeType) {
const mt = (mimeType || '').toLowerCase();
if (mt.includes('png')) return 'png';
if (mt.includes('jpeg') || mt.includes('jpg')) return 'jpg';
if (mt.includes('webp')) return 'webp';
if (mt.includes('gif')) return 'gif';
if (mt.includes('svg')) return 'svg';
return 'bin';
}
async function maybeUploadImageValueToCloudFront({
value,
orgId,
eventId,
module,
filenamePrefix,
}) {
if (value == null) return value;
if (typeof value !== 'string') return value;
const trimmed = value.trim();
if (!trimmed) return trimmed;
// Already a URL.
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed;
// Only upload base64 data URLs.
const parsed = parseDataUrl(trimmed);
if (!parsed) return trimmed;
const filename = `${filenamePrefix}-${Date.now()}.${extensionFromMimeType(parsed.mimeType)}`;
const uploaded = await uploadBufferToS3AndGetCloudFrontUrl({
buffer: parsed.buffer,
contentType: parsed.mimeType,
orgId,
eventId,
module,
filename,
});
return uploaded.url;
}
/** Event-specific label in Permissions UI (see frontend ROLES). */
const CREATOR_EVENT_ROLE = 'Co-organiser';
/**
* Put the creating user on the event staff list (OrganizationUserMap.eventAccess).
* No-op if the user does not belong to the same organization as the event (e.g. some super_admin cases).
*/
async function assignCreatorToEventStaff(creatorUserId, orgId, eventId) {
if (!creatorUserId || !orgId || !eventId) return;
const uid = creatorUserId._id || creatorUserId;
const target = await User.findById(uid).select('organization role').lean();
if (!target?.organization || String(target.organization) !== String(orgId)) return;
let map = await OrganizationUserMap.findOne({ user: uid, organization: orgId });
if (!map) {
map = await OrganizationUserMap.create({
user: uid,
organization: orgId,
role: target.role || 'viewer',
eventAccess: [],
});
}
const eid = String(eventId);
if (map.eventAccess.some((e) => String(e.event) === eid)) return;
map.eventAccess.push({ event: eventId, sessionIds: [], eventRole: CREATOR_EVENT_ROLE });
await map.save();
}
const list = async (request, h) => {
const { organization: organizationQuery, status, type, page = 1, limit = 20 } = request.query;
const user = request.auth.credentials.user;
const scoped = resolveScopedOrganizationId(user, organizationQuery);
if (scoped.error) {
return h.response(error(scoped.error.code, scoped.error.message)).code(400);
}
const filter = { organization: scoped.organizationId };
if (status) filter.status = status;
if (type) filter.type = type;
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const [events, total] = await Promise.all([
Event.find(filter).populate('organization').sort({ startDate: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(),
Event.countDocuments(filter),
]);
return h.response(success({ events, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }))
.header('X-Total-Count', total);
};
const create = 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 create event for this organization')).code(403);
}
const { metadata: metaIn, ...rest } = request.payload;
const event = await Event.create({
...rest,
organization: orgId,
metadata: {
totalRevenue: 0,
rating: 0,
ratingCount: 0,
...(metaIn && typeof metaIn === 'object' ? metaIn : {}),
},
});
// Convert base64 images to CloudFront URLs.
const eventId = event._id?.toString?.() || event._id;
let didUpdate = false;
if (rest.image != null) {
const next = await maybeUploadImageValueToCloudFront({
value: rest.image,
orgId,
eventId,
module: 'event/logo',
filenamePrefix: 'event-logo',
});
if (next !== rest.image) {
event.image = next;
didUpdate = true;
}
}
if (rest.bannerImage != null) {
const next = await maybeUploadImageValueToCloudFront({
value: rest.bannerImage,
orgId,
eventId,
module: 'event/banner',
filenamePrefix: 'event-banner',
});
if (next !== rest.bannerImage) {
event.bannerImage = next;
didUpdate = true;
}
}
if (didUpdate) await event.save();
try {
await assignCreatorToEventStaff(user._id || user.id, orgId, event._id);
} catch (err) {
console.error('assignCreatorToEventStaff', err);
}
return h.response(success(event, 'Event created', 201)).code(201);
};
const get = async (request, h) => {
const event = await Event.findById(request.params.id).populate('organization');
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);
}
return h.response(success(event));
};
const update = async (request, h) => {
const event = await Event.findById(request.params.id);
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 { metadata: metaIn, ...rest } = request.payload;
const orgId = event.organization?._id || event.organization;
const eventId = event._id?.toString?.() || event._id;
if (rest.image != null) {
rest.image = await maybeUploadImageValueToCloudFront({
value: rest.image,
orgId,
eventId,
module: 'event/logo',
filenamePrefix: 'event-logo',
});
}
if (rest.bannerImage != null) {
rest.bannerImage = await maybeUploadImageValueToCloudFront({
value: rest.bannerImage,
orgId,
eventId,
module: 'event/banner',
filenamePrefix: 'event-banner',
});
}
Object.assign(event, rest);
if (metaIn && typeof metaIn === 'object') {
const prev = event.metadata?.toObject?.() || event.metadata || {};
event.metadata = { ...prev, ...metaIn };
event.markModified('metadata');
}
await event.save();
return h.response(success(event));
};
const remove = async (request, h) => {
const event = await Event.findById(request.params.id);
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);
}
await event.deleteOne();
return h.response(success(null, 'Event deleted')).code(200);
};
const analytics = async (request, h) => {
const event = await Event.findById(request.params.id);
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 Participant = require('../models/Participant');
const Session = require('../models/Session');
const QuizPoll = require('../models/QuizPoll');
const RewardPoint = require('../models/RewardPoint');
const orgId = event.organization;
const eid = event._id;
const participantBase = [
{ $match: { tenantOrganization: orgId } },
{ $unwind: '$eventMemberships' },
{ $match: { 'eventMemberships.event': eid } },
];
const [
statusAgg,
participantCountArr,
sessionCount,
upcomingSessionCount,
quizPollCount,
rewardByType,
] = await Promise.all([
Participant.aggregate([...participantBase, { $group: { _id: '$eventMemberships.status', count: { $sum: 1 } } }]),
Participant.aggregate([...participantBase, { $count: 'total' }]),
Session.countDocuments({ event: eid }),
Session.countDocuments({
event: eid,
startTime: { $gt: new Date() },
status: { $ne: 'cancelled' },
}),
QuizPoll.countDocuments({ event: eid }),
RewardPoint.aggregate([
{ $match: { event: eid } },
{ $group: { _id: '$type', total: { $sum: '$points' } } },
]),
]);
const statusCounts = statusAgg.reduce((acc, c) => {
acc[c._id] = c.count;
return acc;
}, {});
const participantCount = participantCountArr[0]?.total ?? 0;
const rewardMap = rewardByType.reduce((acc, r) => {
acc[r._id] = r.total;
return acc;
}, {});
const pointsAwarded = Math.round(rewardMap.earned || 0);
const pointsRedeemed = Math.round(rewardMap.redeemed || 0);
const meta =
event.metadata && typeof event.metadata.toObject === 'function'
? event.metadata.toObject()
: event.metadata || {};
const analyticsData = {
...meta,
statusCounts,
participantCount,
sessionCount,
upcomingSessionCount,
quizPollCount,
pointsAwarded,
pointsRedeemed,
};
return h.response(success(analyticsData));
};
const duplicate = async (request, h) => {
const event = await Event.findById(request.params.id);
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 doc = event.toObject();
delete doc._id;
delete doc.createdAt;
delete doc.updatedAt;
doc.name = `${doc.name} (Copy)`;
doc.status = 'draft';
const created = await Event.create(doc);
try {
await assignCreatorToEventStaff(user._id || user.id, created.organization, created._id);
} catch (err) {
console.error('assignCreatorToEventStaff (duplicate)', err);
}
return h.response(success(created, 'Event duplicated', 201)).code(201);
};
/** Authenticated: resolve microsite registration URL for a chosen form (visual builder). */
const registrationLink = 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 formId = request.query.formId;
if (!formId || !String(formId).trim()) {
return h.response(error('VALIDATION_ERROR', 'formId query parameter is required')).code(400);
}
const url = registrationUrlForForm(event, event.website, formId);
if (!url) {
return h
.response(
error(
'VALIDATION_ERROR',
'Could not build registration URL. Set MICROSITE_REGISTRATION_URL_TEMPLATE on the server, or set a fallback registration URL on the event website.',
),
)
.code(400);
}
return h.response(success({ url }));
};
module.exports = { list, create, get, update, remove, analytics, duplicate, registrationLink };

View File

@ -0,0 +1,328 @@
const mongoose = require('mongoose');
const Event = require('../models/Event');
const EventMicrosite = require('../models/EventMicrosite');
const EventPage = require('../models/EventPage');
const { success, error } = require('../utils/response');
const { WEBSITE_SECTION_TYPES } = require('../config/constants');
const {
buildMicrositeDocumentHtml,
getConferenceCanvasStylePayload,
templateToWebsiteBag,
} = require('../utils/micrositeBuild');
const { normalizePublishFolder, publishFolderAvailability } = require('../utils/publishFolderAvailability');
const { runPublishToDisk } = require('../utils/runMicrositePublish');
function orgMismatch(event, user) {
if (user.role === 'super_admin') return false;
return event.organization?.toString() !== (user.organization?._id || user.organization)?.toString();
}
async function loadEventForUser(eventId, user) {
const event = await Event.findById(eventId);
if (!event) return { event: null, forbidden: false };
if (orgMismatch(event, user)) return { event: null, forbidden: true };
return { event, forbidden: false };
}
async function loadMicrositeForEvent(micrositeId, eventId, user) {
const { event, forbidden } = await loadEventForUser(eventId, user);
if (!event) return { event: null, microsite: null, forbidden };
if (!mongoose.Types.ObjectId.isValid(micrositeId)) {
return { event, microsite: null, forbidden: false };
}
const microsite = await EventMicrosite.findOne({ _id: micrositeId, event: event._id });
return { event, microsite, forbidden: false };
}
function userMayAccessOrganization(user, organizationId) {
if (user.role === 'super_admin') return true;
const oid = (user.organization?._id || user.organization)?.toString();
return oid === organizationId;
}
/** All `EventMicrosite` rows for events in an organization (portfolio / Design → Microsite list). */
const listForOrganization = async (request, h) => {
const organizationId = request.query.organization;
if (!organizationId || !mongoose.Types.ObjectId.isValid(String(organizationId))) {
return h.response(error('VALIDATION_ERROR', 'Valid organization id is required')).code(400);
}
const oid = String(organizationId);
const user = request.auth.credentials.user;
if (!userMayAccessOrganization(user, oid)) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const events = await Event.find({ organization: oid }).select('_id name').lean();
const eventIds = events.map((e) => e._id);
const nameByEventId = Object.fromEntries(events.map((e) => [e._id.toString(), e.name]));
const rows =
eventIds.length === 0
? []
: await EventMicrosite.find({ event: { $in: eventIds } })
.sort({ event: 1, createdAt: 1 })
.populate('sourcePageId', 'title slug type')
.lean();
const enriched = rows.map((r) => ({
...r,
eventName: nameByEventId[r.event.toString()] || null,
}));
return h.response(success(enriched));
};
const list = async (request, h) => {
const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const rows = await EventMicrosite.find({ event: event._id })
.sort({ createdAt: 1 })
.populate('sourcePageId', 'title slug type')
.lean();
return h.response(success(rows));
};
const getById = async (request, h) => {
const { event, microsite, forbidden } = await loadMicrositeForEvent(
request.params.micrositeId,
request.params.eventId,
request.auth.credentials.user,
);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!microsite) return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404);
return h.response(success(microsite));
};
const create = async (request, h) => {
const user = request.auth.credentials.user;
const { event, forbidden } = await loadEventForUser(request.params.eventId, user);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const payload = request.payload;
const folder = normalizePublishFolder(payload.publishFolder || '');
if (!folder) {
return h.response(error('VALIDATION_ERROR', 'publishFolder (URL slug) is required')).code(400);
}
const { available, message } = await publishFolderAvailability(folder, event._id.toString(), null);
if (!available) {
return h.response(error('VALIDATION_ERROR', message || 'Folder not available')).code(400);
}
let sourcePageId = payload.sourcePageId || undefined;
if (sourcePageId) {
const page = await EventPage.findOne({ _id: sourcePageId, event: event._id }).select('_id').lean();
if (!page) {
return h.response(error('VALIDATION_ERROR', 'sourcePageId must belong to this event')).code(400);
}
}
const doc = new EventMicrosite({
event: event._id,
name: payload.name,
publishFolder: folder,
enabled: !!payload.enabled,
logo: payload.logo,
banners: Array.isArray(payload.banners) ? payload.banners : [],
favicon: payload.favicon,
title: payload.title,
description: payload.description,
keywords: Array.isArray(payload.keywords) ? payload.keywords : [],
templateId: payload.templateId,
layoutTemplate: 'conference',
micrositeSections: Array.isArray(payload.micrositeSections)
? payload.micrositeSections.filter((t) => typeof t === 'string' && WEBSITE_SECTION_TYPES.includes(t))
: [],
primaryColor: payload.primaryColor,
accentColor: payload.accentColor,
fontPair: payload.fontPair,
registrationUrl: payload.registrationUrl,
registrationFormId: payload.registrationFormId || undefined,
seoDescription: payload.seoDescription,
micrositeHtml: payload.micrositeHtml,
sourcePageId: sourcePageId || undefined,
createdBy: user._id || user.id,
});
try {
await doc.save();
} catch (e) {
if (e && e.code === 11000) {
return h.response(error('VALIDATION_ERROR', 'This folder is already in use')).code(400);
}
throw e;
}
const lean = await EventMicrosite.findById(doc._id).populate('sourcePageId', 'title slug type').lean();
return h.response(success(lean)).code(201);
};
const update = async (request, h) => {
const user = request.auth.credentials.user;
const { event, microsite, forbidden } = await loadMicrositeForEvent(
request.params.micrositeId,
request.params.eventId,
user,
);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!microsite) return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404);
const payload = request.payload;
const ok = new Set(WEBSITE_SECTION_TYPES);
if (payload.publishFolder !== undefined) {
const folder = normalizePublishFolder(payload.publishFolder || '');
if (!folder) {
return h.response(error('VALIDATION_ERROR', 'publishFolder cannot be empty')).code(400);
}
const { available, message } = await publishFolderAvailability(
folder,
event._id.toString(),
microsite._id.toString(),
);
if (!available) {
return h.response(error('VALIDATION_ERROR', message || 'Folder not available')).code(400);
}
microsite.publishFolder = folder;
}
if (payload.name !== undefined) microsite.name = payload.name;
if (payload.enabled !== undefined) microsite.enabled = !!payload.enabled;
if (payload.logo !== undefined) microsite.logo = payload.logo;
if (payload.banners !== undefined) microsite.banners = Array.isArray(payload.banners) ? payload.banners : [];
if (payload.favicon !== undefined) microsite.favicon = payload.favicon;
if (payload.title !== undefined) microsite.title = payload.title;
if (payload.description !== undefined) microsite.description = payload.description;
if (payload.keywords !== undefined) microsite.keywords = Array.isArray(payload.keywords) ? payload.keywords : [];
if (payload.templateId !== undefined) microsite.templateId = payload.templateId;
if (payload.layoutTemplate !== undefined) microsite.layoutTemplate = 'conference';
if (payload.micrositeSections !== undefined) {
microsite.micrositeSections = Array.isArray(payload.micrositeSections)
? payload.micrositeSections.filter((t) => typeof t === 'string' && ok.has(t))
: [];
}
if (payload.primaryColor !== undefined) microsite.primaryColor = payload.primaryColor;
if (payload.accentColor !== undefined) microsite.accentColor = payload.accentColor;
if (payload.fontPair !== undefined) microsite.fontPair = payload.fontPair;
if (payload.registrationUrl !== undefined) microsite.registrationUrl = payload.registrationUrl;
if (payload.registrationFormId !== undefined) {
microsite.registrationFormId =
payload.registrationFormId && String(payload.registrationFormId).trim()
? payload.registrationFormId
: undefined;
}
if (payload.seoDescription !== undefined) microsite.seoDescription = payload.seoDescription;
if (payload.micrositeHtml !== undefined) microsite.micrositeHtml = payload.micrositeHtml;
if (payload.sourcePageId !== undefined) {
if (!payload.sourcePageId) {
microsite.sourcePageId = undefined;
} else {
const page = await EventPage.findOne({ _id: payload.sourcePageId, event: event._id }).select('_id').lean();
if (!page) {
return h.response(error('VALIDATION_ERROR', 'sourcePageId must belong to this event')).code(400);
}
microsite.sourcePageId = payload.sourcePageId;
}
}
try {
await microsite.save();
} catch (e) {
if (e && e.code === 11000) {
return h.response(error('VALIDATION_ERROR', 'This folder is already in use')).code(400);
}
throw e;
}
const lean = await EventMicrosite.findById(microsite._id).populate('sourcePageId', 'title slug type').lean();
return h.response(success(lean));
};
const remove = async (request, h) => {
const { event, microsite, forbidden } = await loadMicrositeForEvent(
request.params.micrositeId,
request.params.eventId,
request.auth.credentials.user,
);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!microsite) return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404);
await EventMicrosite.deleteOne({ _id: microsite._id });
return h.response(success({ ok: true }));
};
const publish = async (request, h) => {
const { event, microsite, forbidden } = await loadMicrositeForEvent(
request.params.micrositeId,
request.params.eventId,
request.auth.credentials.user,
);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!microsite) return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404);
try {
const lean = microsite.toObject();
const result = await runPublishToDisk(event, lean, request.payload);
if (result.err) {
return h.response(error(result.err.code, result.err.message)).code(result.err.status);
}
return h.response(success(result.data));
} catch (err) {
const msg = err && err.message ? err.message : 'Publish failed';
return h.response(error('SERVER_ERROR', msg)).code(500);
}
};
const previewHtml = async (request, h) => {
const { event, microsite, forbidden } = await loadMicrositeForEvent(
request.params.micrositeId,
request.params.eventId,
request.auth.credentials.user,
);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!microsite) return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404);
try {
const lean = microsite.toObject();
const website = templateToWebsiteBag(lean);
const regUrl =
typeof website.registrationUrl === 'string' ? website.registrationUrl.trim() : '';
const [docResult, canvasStyles] = await Promise.all([
buildMicrositeDocumentHtml(event, {
previewOnly: true,
applyVisualMainOverride: false,
registrationUrl: regUrl || undefined,
website,
sourcePageId: lean.sourcePageId,
}),
getConferenceCanvasStylePayload(website),
]);
return h.response(success({ html: docResult.html, canvasStyles }));
} catch (err) {
const msg = err && err.message ? err.message : 'Preview failed';
return h.response(error('SERVER_ERROR', msg)).code(400);
}
};
const checkFolder = async (request, h) => {
const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const folder = normalizePublishFolder(request.query.folder || '');
if (!folder) {
return h.response(success({ available: false, message: 'Folder is required' }));
}
let excludeId = request.query.micrositeId || null;
if (excludeId && !mongoose.Types.ObjectId.isValid(excludeId)) excludeId = null;
const { available, message } = await publishFolderAvailability(folder, event._id.toString(), excludeId);
return h.response(success({ available, message }));
};
module.exports = {
listForOrganization,
list,
getById,
create,
update,
remove,
publish,
previewHtml,
checkFolder,
};

View File

@ -0,0 +1,413 @@
const crypto = require('crypto');
const Event = require('../models/Event');
const User = require('../models/User');
const Organization = require('../models/Organization');
const OrganizationUserMap = require('../models/OrganizationUserMap');
const EventInvite = require('../models/EventInvite');
const Session = require('../models/Session');
const jwt = require('jsonwebtoken');
const { success, error } = require('../utils/response');
const { sendEventInviteEmail } = require('../utils/mail');
const JWT_SECRET = process.env.JWT_SECRET || 'default-secret';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'default-refresh-secret';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m';
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '7d';
async function getEventOr404(eventId) {
return Event.findById(eventId).populate('organization');
}
function canManageStaff(user, event) {
if (!event) return false;
if (user.role === 'super_admin') return true;
const orgId = user.organization?._id || user.organization;
if (!orgId || event.organization._id?.toString() !== orgId.toString()) return false;
return ['admin', 'moderator'].includes(user.role);
}
function staffListFromMaps(maps, eventId) {
const eid = eventId.toString();
const members = [];
for (const m of maps) {
const u = m.user;
if (!u || !u._id) continue;
const ea = (m.eventAccess || []).find((x) => x.event && x.event.toString() === eid);
if (!ea) continue;
members.push({
userId: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
role: u.role,
sessionIds: (ea.sessionIds || []).map((id) => id.toString()),
eventRole: ea.eventRole || 'Viewer',
});
}
return members;
}
const listStaff = async (request, h) => {
const { eventId } = request.params;
const user = request.auth.credentials.user;
const event = await getEventOr404(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const orgId = event.organization._id || event.organization;
const maps = await OrganizationUserMap.find({ organization: orgId }).populate('user', '-password').lean();
const members = staffListFromMaps(maps, eventId);
const invites = await EventInvite.find({ event: eventId, status: 'pending' })
.select('email eventRole sessionIds createdAt expiresAt')
.lean();
return h.response(
success({
members,
invites: invites.map((i) => ({
id: i._id,
email: i.email,
eventRole: i.eventRole,
sessionIds: (i.sessionIds || []).map((id) => id.toString()),
expiresAt: i.expiresAt,
createdAt: i.createdAt,
})),
})
);
};
const listAvailableUsers = async (request, h) => {
const { eventId } = request.params;
const user = request.auth.credentials.user;
const event = await getEventOr404(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const orgId = event.organization._id || event.organization;
const maps = await OrganizationUserMap.find({ organization: orgId }).populate('user', '-password').lean();
const assigned = new Set(staffListFromMaps(maps, eventId).map((m) => m.userId.toString()));
const pendingEmails = new Set(
(await EventInvite.find({ event: eventId, status: 'pending' }).distinct('email')).map((e) => String(e).toLowerCase())
);
const orgUsers = await User.find({ organization: orgId }).select('-password').lean();
const available = orgUsers.filter((u) => !assigned.has(u._id.toString()) && !pendingEmails.has(String(u.email).toLowerCase()));
return h.response(success({ users: available }));
};
const addStaff = async (request, h) => {
const { eventId } = request.params;
const { userIds, sessionIds = [], eventRole = 'Viewer' } = request.payload;
const user = request.auth.credentials.user;
const event = await getEventOr404(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const orgId = event.organization._id || event.organization;
const sid = Array.isArray(sessionIds) ? sessionIds.filter(Boolean) : [];
for (const uid of userIds || []) {
const target = await User.findById(uid);
if (!target || target.organization?.toString() !== orgId.toString()) continue;
let map = await OrganizationUserMap.findOne({ user: uid, organization: orgId });
if (!map) {
map = await OrganizationUserMap.create({
user: uid,
organization: orgId,
role: target.role || 'viewer',
eventAccess: [],
});
}
const idx = map.eventAccess.findIndex((e) => e.event.toString() === eventId);
if (idx >= 0) {
map.eventAccess[idx].sessionIds = sid;
map.eventAccess[idx].eventRole = eventRole;
} else {
map.eventAccess.push({ event: eventId, sessionIds: sid, eventRole });
}
await map.save();
}
return h.response(success(null, 'Users added to event'));
};
const updateStaff = async (request, h) => {
const { eventId, userId } = request.params;
const { sessionIds = [], eventRole } = request.payload;
const user = request.auth.credentials.user;
const event = await getEventOr404(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const orgId = event.organization._id || event.organization;
const map = await OrganizationUserMap.findOne({ user: userId, organization: orgId });
if (!map) return h.response(error('NOT_FOUND', 'User not on this event')).code(404);
const idx = map.eventAccess.findIndex((e) => e.event.toString() === eventId);
if (idx < 0) return h.response(error('NOT_FOUND', 'User not on this event')).code(404);
map.eventAccess[idx].sessionIds = Array.isArray(sessionIds) ? sessionIds.filter(Boolean) : [];
if (eventRole != null) map.eventAccess[idx].eventRole = eventRole;
await map.save();
return h.response(success(null, 'Updated'));
};
const removeStaff = async (request, h) => {
const { eventId, userId } = request.params;
const user = request.auth.credentials.user;
const event = await getEventOr404(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const orgId = event.organization._id || event.organization;
const map = await OrganizationUserMap.findOne({ user: userId, organization: orgId });
if (!map) return h.response(success(null));
map.eventAccess = map.eventAccess.filter((e) => e.event.toString() !== eventId);
await map.save();
return h.response(success(null, 'Removed from event'));
};
const sendInvites = async (request, h) => {
const { eventId } = request.params;
const { invites: inviteList, sessionIds = [] } = request.payload;
const user = request.auth.credentials.user;
const event = await getEventOr404(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const orgId = event.organization._id || event.organization;
const org = await Organization.findById(orgId);
const orgName = org?.name || '';
const sid = Array.isArray(sessionIds) ? sessionIds.filter(Boolean) : [];
const baseUrl = (process.env.FRONTEND_URL || process.env.CORS_ORIGIN || 'http://localhost:5173').split(',')[0].trim();
let sent = 0;
const errors = [];
for (const row of inviteList || []) {
const email = String(row.email || '').trim().toLowerCase();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.push({ email, message: 'Invalid email' });
continue;
}
const existingUser = await User.findOne({ email });
if (existingUser && existingUser.organization?.toString() === orgId.toString()) {
const maps = await OrganizationUserMap.find({ organization: orgId }).populate('user').lean();
if (staffListFromMaps(maps, eventId).some((m) => m.userId.toString() === existingUser._id.toString())) {
errors.push({ email, message: 'Already on this event' });
continue;
}
}
await EventInvite.updateMany({ event: eventId, email, status: 'pending' }, { status: 'cancelled' });
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
await EventInvite.create({
email,
event: eventId,
organization: orgId,
invitedBy: user._id,
token,
sessionIds: sid,
eventRole: row.eventRole || 'Viewer',
expiresAt,
});
const acceptUrl = `${baseUrl.replace(/\/$/, '')}/invite/event?token=${token}`;
const ok = await sendEventInviteEmail({
to: email,
acceptUrl,
eventName: event.name,
orgName,
role: row.eventRole || 'Viewer',
});
if (ok) sent += 1;
else errors.push({ email, message: 'Email could not be sent (check SMTP or logs)' });
}
return h.response(success({ sent, errors }));
};
const cancelInvite = async (request, h) => {
const { eventId, inviteId } = request.params;
const user = request.auth.credentials.user;
const event = await getEventOr404(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
await EventInvite.updateOne({ _id: inviteId, event: eventId }, { status: 'cancelled' });
return h.response(success(null));
};
const resendInvite = async (request, h) => {
const { eventId, inviteId } = request.params;
const user = request.auth.credentials.user;
const event = await getEventOr404(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!canManageStaff(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const inv = await EventInvite.findOne({ _id: inviteId, event: eventId, status: 'pending' });
if (!inv) return h.response(error('NOT_FOUND', 'Invite not found')).code(404);
const org = await Organization.findById(event.organization);
const baseUrl = (process.env.FRONTEND_URL || process.env.CORS_ORIGIN || 'http://localhost:5173').split(',')[0].trim();
const acceptUrl = `${baseUrl.replace(/\/$/, '')}/invite/event?token=${inv.token}`;
await sendEventInviteEmail({
to: inv.email,
acceptUrl,
eventName: event.name,
orgName: org?.name,
role: inv.eventRole,
});
return h.response(success(null, 'Invitation resent'));
};
const invitePreview = async (request, h) => {
const { token } = request.params;
const inv = await EventInvite.findOne({ token, status: 'pending' }).populate('event', 'name').populate('organization', 'name');
if (!inv || inv.expiresAt < new Date()) {
return h.response(error('NOT_FOUND', 'Invalid or expired invitation')).code(404);
}
const existing = await User.findOne({ email: inv.email });
return h.response(
success({
email: inv.email,
eventName: inv.event?.name,
organizationName: inv.organization?.name || (await Organization.findById(inv.organization))?.name,
eventRole: inv.eventRole,
accountExists: !!existing,
sameOrganization: existing ? existing.organization?.toString() === inv.organization.toString() : false,
})
);
};
const inviteAccept = async (request, h) => {
const { token, password, firstName, lastName } = request.payload;
const inv = await EventInvite.findOne({ token, status: 'pending' }).populate('event');
if (!inv || inv.expiresAt < new Date()) {
return h.response(error('NOT_FOUND', 'Invalid or expired invitation')).code(400);
}
const orgId = inv.organization;
let user = await User.findOne({ email: inv.email });
if (user) {
if (user.organization?.toString() !== orgId.toString()) {
return h.response(error('FORBIDDEN', 'This email belongs to another organization. Use the email you were invited with.')).code(403);
}
if (!password || !(await user.comparePassword(password))) {
return h.response(error('UNAUTHORIZED', 'Invalid password')).code(401);
}
} else {
if (!password || String(password).length < 6) {
return h.response(error('VALIDATION_ERROR', 'Password must be at least 6 characters')).code(400);
}
user = await User.create({
email: inv.email,
password,
firstName: firstName || '',
lastName: lastName || '',
organization: orgId,
role: 'viewer',
});
await OrganizationUserMap.create({
user: user._id,
organization: orgId,
role: 'viewer',
eventAccess: [],
});
}
let map = await OrganizationUserMap.findOne({ user: user._id, organization: orgId });
if (!map) {
map = await OrganizationUserMap.create({
user: user._id,
organization: orgId,
role: user.role,
eventAccess: [],
});
}
const eid = inv.event._id.toString();
const idx = map.eventAccess.findIndex((e) => e.event.toString() === eid);
const entry = {
event: inv.event._id,
sessionIds: inv.sessionIds || [],
eventRole: inv.eventRole || 'Viewer',
};
if (idx >= 0) map.eventAccess[idx] = entry;
else map.eventAccess.push(entry);
await map.save();
inv.status = 'accepted';
await inv.save();
const populated = await User.findById(user._id).select('-password').populate('organization');
const accessToken = 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 });
return h.response(
success({
user: populated,
token: accessToken,
refreshToken,
expiresIn: JWT_EXPIRES_IN,
}, 'Welcome! You now have access to this event.')
);
};
const agendaTree = async (request, h) => {
const { eventId } = request.params;
const user = request.auth.credentials.user;
const event = await Event.findById(eventId);
if (!event) return 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 h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const Track = require('../models/Track');
const tracks = await Track.find({ event: eventId }).sort({ sortOrder: 1, name: 1 }).lean();
const sessions = await Session.find({ event: eventId }).sort({ startTime: 1 }).lean();
const byTrack = new Map();
tracks.forEach((t) => byTrack.set(t._id.toString(), { trackId: t._id, name: t.name, sessions: [] }));
const unassigned = { trackId: null, name: 'Unassigned', sessions: [] };
sessions.forEach((s) => {
const tid = s.track ? s.track.toString() : null;
const row = {
sessionId: s._id,
title: s.title,
startTime: s.startTime,
};
if (tid && byTrack.has(tid)) byTrack.get(tid).sessions.push(row);
else unassigned.sessions.push(row);
});
const trackList = [...byTrack.values()];
if (unassigned.sessions.length) trackList.push(unassigned);
return h.response(success({ tracks: trackList }));
};
module.exports = {
listStaff,
listAvailableUsers,
addStaff,
updateStaff,
removeStaff,
sendInvites,
cancelInvite,
resendInvite,
invitePreview,
inviteAccept,
agendaTree,
};

View File

@ -0,0 +1,323 @@
const mongoose = require('mongoose');
const Form = require('../models/Form');
const FormSubmission = require('../models/FormSubmission');
const Event = require('../models/Event');
const Participant = require('../models/Participant');
const ParticipantList = require('../models/ParticipantList');
const { success, error } = require('../utils/response');
function normalizeSubmitData(raw) {
if (!raw) return {};
if (raw instanceof Map) return Object.fromEntries(raw);
return { ...raw };
}
async function upsertParticipantFromRegistrationForm(form, tenantOrganization, dataObj) {
const fields = form.fields || [];
const orgOid =
tenantOrganization instanceof mongoose.Types.ObjectId
? tenantOrganization
: new mongoose.Types.ObjectId(String(tenantOrganization));
function throwRegValidation(message, code) {
const err = new Error(message);
err.code = code;
throw err;
}
const requireMappedValue = (mapsTo, emptyMessage, missingConfigMessage) => {
const fld = fields.find((f) => f.mapsTo === mapsTo);
if (!fld?.label) throwRegValidation(missingConfigMessage, 'FORM_FIELD_CONFIG');
const raw = dataObj[fld.label.trim()];
const val = raw != null ? String(raw).trim() : '';
if (!val) throwRegValidation(emptyMessage, 'REGISTRATION_REQUIRED');
};
const emailField = fields.find((f) => f.mapsTo === 'email');
if (!emailField?.label) {
throwRegValidation('Form missing email field mapping', 'FORM_FIELD_CONFIG');
}
const emailRaw = dataObj[emailField.label.trim()];
const email = emailRaw != null ? String(emailRaw).trim().toLowerCase() : '';
if (!email) {
throwRegValidation('Email is required', 'EMAIL_REQUIRED');
}
requireMappedValue('firstName', 'First name is required', 'Form missing first name field mapping');
requireMappedValue('lastName', 'Last name is required', 'Form missing last name field mapping');
const updates = {};
const customKv = {};
const allowed = ['firstName', 'lastName', 'email', 'phone', 'organization', 'designation', 'notes', 'displayName'];
for (const f of fields) {
if (!f.label) continue;
const key = f.label.trim();
const val = dataObj[key];
if (val === undefined || val === null || String(val).trim() === '') continue;
const map = f.mapsTo;
if (!map) continue;
if (map === 'custom') {
const ck = f.customMapsKey?.trim();
if (ck) customKv[ck] = val;
continue;
}
if (!allowed.includes(map)) continue;
updates[map] = map === 'email' ? String(val).trim().toLowerCase() : String(val).trim();
}
let p = await Participant.findOne({ tenantOrganization: orgOid, email });
if (!p) {
p = await Participant.create({
tenantOrganization: orgOid,
email,
firstName: updates.firstName,
lastName: updates.lastName,
phone: updates.phone,
organization: updates.organization,
designation: updates.designation,
notes: updates.notes,
displayName: updates.displayName,
customFields: Object.keys(customKv).length ? new Map(Object.entries(customKv)) : undefined,
});
} else {
if (updates.firstName) p.firstName = updates.firstName;
if (updates.lastName) p.lastName = updates.lastName;
if (updates.phone) p.phone = updates.phone;
if (updates.organization) p.organization = updates.organization;
if (updates.designation) p.designation = updates.designation;
if (updates.notes) p.notes = updates.notes;
if (updates.displayName) p.displayName = updates.displayName;
if (Object.keys(customKv).length) {
if (!p.customFields) p.customFields = new Map();
for (const [k, v] of Object.entries(customKv)) p.customFields.set(k, v);
}
await p.save();
}
const tagList = (form.settings?.onSubmitAddTags || [])
.map((t) => String(t).trim())
.filter(Boolean);
if (tagList.length) {
p.tags = [...new Set([...(p.tags || []).map(String), ...tagList])];
await p.save();
}
const listId = form.settings?.onSubmitParticipantListId;
if (listId && mongoose.Types.ObjectId.isValid(String(listId))) {
const list = await ParticipantList.findById(listId).lean();
if (list && list.type === 'fixed' && String(list.tenantOrganization) === String(orgOid)) {
await ParticipantList.updateOne({ _id: listId }, { $addToSet: { participantIds: p._id } });
}
}
return p._id;
}
function userOrgId(user) {
return user.organization?._id || user.organization;
}
async function formBelongsToUserOrg(formDoc, user) {
if (user.role === 'super_admin') return true;
const uo = userOrgId(user);
if (!uo) return false;
const uos = uo.toString();
if (formDoc.tenantOrganization && formDoc.tenantOrganization.toString() === uos) return true;
if (!formDoc.event) return false;
const ev = await Event.findById(formDoc.event).select('organization').lean();
return ev && ev.organization?.toString() === uos;
}
/** List forms for an event (legacy). */
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() !== userOrgId(user)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const forms = await Form.find({ event: eventId }).sort({ createdAt: -1 }).lean();
const formIds = forms.map((f) => f._id);
const counts = await FormSubmission.aggregate([
{ $match: { form: { $in: formIds } } },
{ $group: { _id: '$form', count: { $sum: 1 } } },
]);
const countByForm = Object.fromEntries(counts.map((c) => [c._id.toString(), c.count]));
const formsWithCount = forms.map((f) => ({ ...f, submissionCount: countByForm[f._id.toString()] ?? 0 }));
return h.response(success({ forms: formsWithCount }));
};
/** Portfolio: all forms for the signed-in user's organization. */
const listByOrganization = async (request, h) => {
const user = request.auth.credentials.user;
let tenantOidRaw =
user.role === 'super_admin' && request.query.organization
? request.query.organization
: userOrgId(user);
if (!tenantOidRaw) {
return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
}
if (!mongoose.Types.ObjectId.isValid(String(tenantOidRaw))) {
return h.response(error('VALIDATION_ERROR', 'Invalid organization')).code(400);
}
const oid = new mongoose.Types.ObjectId(String(tenantOidRaw));
const forms = await Form.find({ tenantOrganization: oid })
.sort({ createdAt: -1 })
.lean();
const formIds = forms.map((f) => f._id);
const counts = await FormSubmission.aggregate([
{ $match: { form: { $in: formIds } } },
{ $group: { _id: '$form', count: { $sum: 1 } } },
]);
const countByForm = Object.fromEntries(counts.map((c) => [c._id.toString(), c.count]));
const formsWithCount = forms.map((f) => ({ ...f, submissionCount: countByForm[f._id.toString()] ?? 0 }));
return h.response(success({ forms: formsWithCount }));
};
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 user = request.auth.credentials.user;
if (user.role !== 'super_admin' && event.organization?.toString() !== userOrgId(user)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const form = await Form.create({
...request.payload,
event: eventId,
tenantOrganization: event.organization,
});
return h.response(success(form, 'Form created', 201)).code(201);
};
/** Create form at organization scope (optional event link). */
const createByOrganization = async (request, h) => {
const user = request.auth.credentials.user;
const body = request.payload;
let tenantOidRaw =
user.role === 'super_admin' && body.tenantOrganization
? body.tenantOrganization
: userOrgId(user);
if (!tenantOidRaw) {
return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
}
if (!mongoose.Types.ObjectId.isValid(String(tenantOidRaw))) {
return h.response(error('VALIDATION_ERROR', 'Invalid organization')).code(400);
}
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;
}
const { event: _e, tenantOrganization: _t, ...rest } = body;
const form = await Form.create({
...rest,
tenantOrganization: toid,
...(eventRef && { event: eventRef }),
});
return h.response(success(form, 'Form created', 201)).code(201);
};
const get = async (request, h) => {
const form = await Form.findById(request.params.id).populate('event');
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);
return h.response(success(form));
};
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);
if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const payload = { ...request.payload };
delete payload.tenantOrganization;
Object.assign(form, payload);
await form.save();
return h.response(success(form));
};
const remove = 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);
if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
await form.deleteOne();
return h.response(success(null, 'Form deleted')).code(200);
};
const submit = async (request, h) => {
const { formId } = request.params;
const form = await Form.findById(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', 'Form is not accepting submissions')).code(400);
const user = request.auth?.credentials?.user;
const tenantOrganization = form.tenantOrganization;
const eventRef = form.event;
if (!tenantOrganization) {
return h.response(error('VALIDATION_ERROR', 'Form is not configured for submissions (missing organization)')).code(400);
}
const dataObj = normalizeSubmitData(request.payload.data);
let participantId = request.payload.participantId;
if (form.type === 'registration') {
try {
participantId = await upsertParticipantFromRegistrationForm(form, tenantOrganization, dataObj);
} catch (err) {
if (
err.code === 'EMAIL_REQUIRED' ||
err.code === 'REGISTRATION_REQUIRED' ||
err.code === 'FORM_FIELD_CONFIG'
) {
return h.response(error('VALIDATION_ERROR', err.message || 'Validation failed')).code(400);
}
throw err;
}
}
const submission = await FormSubmission.create({
form: formId,
tenantOrganization,
event: eventRef || undefined,
user: user?._id,
participant: participantId,
data: dataObj,
});
return h.response(success(submission, 'Submission received', 201)).code(201);
};
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 }));
};
module.exports = {
list,
listByOrganization,
create,
createByOrganization,
get,
update,
remove,
submit,
listSubmissions,
};

View File

@ -0,0 +1,250 @@
const mongoose = require('mongoose');
const Content = require('../models/Content');
const LibraryContent = require('../models/LibraryContent');
const ContentCollection = require('../models/ContentCollection');
const LIBRARY_MODULE = 'content-library';
const { success, error } = require('../utils/response');
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
const { uploadBufferToS3AndGetCloudFrontUrl, deleteS3ObjectByKey } = require('../utils/s3Media');
function parseTagsFromPayload(payload) {
const raw = payload.tags;
if (raw == null) return [];
if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean);
if (typeof raw === 'string') {
const s = raw.trim();
if (!s) return [];
try {
const j = JSON.parse(s);
if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean);
} catch {
/* comma-separated */
}
return s.split(/[,;\n]+/).map((t) => t.trim()).filter(Boolean);
}
return [];
}
function inferContentType(mimeType, payloadType) {
if (payloadType && ['image', 'pdf', 'video', 'document', 'other'].includes(payloadType)) return payloadType;
const m = String(mimeType || '').toLowerCase();
if (m.startsWith('image/')) return 'image';
if (m.startsWith('video/')) return 'video';
if (m === 'application/pdf') return 'pdf';
if (m.startsWith('text/') || m.includes('document') || m.includes('msword')) return 'document';
return 'document';
}
function normalizeVisibility(v) {
const s = String(v || '').toLowerCase();
if (s === 'public') return 'public';
return 'private';
}
/**
* Hapi route payload.output only allows data|stream|file. With output:'data', binary file parts are often
* a raw Buffer on payload.file (see @hapi/subtext internals.part). Some clients may send { _data } or { payload }.
*/
function extractUploadFile(payload) {
const file = payload.file;
if (!file) return null;
if (Buffer.isBuffer(file)) {
return {
buffer: file,
filename: (typeof payload.name === 'string' && payload.name.trim()) || 'file',
mimeType: 'application/octet-stream',
};
}
if (typeof file === 'object' && file.payload && Buffer.isBuffer(file.payload)) {
return {
buffer: file.payload,
filename: file.filename || (typeof payload.name === 'string' && payload.name) || 'file',
mimeType: file.headers?.['content-type'] || 'application/octet-stream',
};
}
if (typeof file === 'object' && file._data && Buffer.isBuffer(file._data)) {
return {
buffer: file._data,
filename: file.filename || (typeof payload.name === 'string' && payload.name) || 'file',
mimeType: file.headers?.['content-type'] || 'application/octet-stream',
};
}
return null;
}
function listOrgFilter(user, query) {
const scoped = resolveScopedOrganizationId(user, query.organization);
if (scoped.error) return { error: scoped.error };
return { filter: { organization: scoped.organizationId } };
}
/** Non-library uploads (events, speakers, etc.) — `Content` collection. */
const list = async (request, h) => {
const user = request.auth.credentials.user;
const orgRes = listOrgFilter(user, request.query);
if (orgRes.error) return h.response(error(orgRes.error.code, orgRes.error.message)).code(400);
const filter = { ...orgRes.filter };
if (request.query.type) filter.type = request.query.type;
if (request.query.collectionName) filter.collectionName = request.query.collectionName;
const { page = 1, limit = 20 } = request.query;
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const [media, total] = await Promise.all([
Content.find(filter).skip(skip).limit(parseInt(limit, 10)).lean(),
Content.countDocuments(filter),
]);
return h.response(success({ media, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }))
.header('X-Total-Count', total);
};
/** Portfolio → Library only — `LibraryContent` collection. */
const listLibrary = async (request, h) => {
const user = request.auth.credentials.user;
const orgRes = listOrgFilter(user, request.query);
if (orgRes.error) return h.response(error(orgRes.error.code, orgRes.error.message)).code(400);
const filter = { ...orgRes.filter };
if (request.query.type) filter.type = request.query.type;
const { page = 1, limit = 20 } = request.query;
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const [media, total] = await Promise.all([
LibraryContent.find(filter).sort({ createdAt: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(),
LibraryContent.countDocuments(filter),
]);
return h.response(success({ media, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }))
.header('X-Total-Count', total);
};
const upload = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
const payload = request.payload;
const extracted = extractUploadFile(payload);
// Required by your storage scheme: eventbuzz/<orgId>/<eventId>/<module>/...
const eventId = payload.eventId || payload.event_id || payload.event || '';
const module = payload.module || payload.collectionName || 'general';
const isLibraryUpload = String(module) === LIBRARY_MODULE;
let url = payload.url || '';
let size = 0;
let mimeType = '';
let s3Key = '';
if (extracted) {
const { buffer, filename: origName, mimeType: ct } = extracted;
const filename = `${Date.now()}-${(origName || 'file').replace(/[^a-zA-Z0-9.-]/g, '_')}`;
mimeType = ct || 'application/octet-stream';
const uploaded = await uploadBufferToS3AndGetCloudFrontUrl({
buffer,
contentType: mimeType,
orgId,
eventId,
module,
filename,
});
url = uploaded.url;
s3Key = uploaded.s3Key;
size = buffer.length;
} else if (!payload.url) {
return h
.response(
error(
'VALIDATION_ERROR',
'No file received. Send multipart/form-data with a field named "file" (binary). Do not use JSON body for uploads.',
),
)
.code(400);
}
const collRaw = payload.contentCollectionId || payload.content_collection_id;
if (
isLibraryUpload &&
collRaw &&
mongoose.Types.ObjectId.isValid(String(collRaw))
) {
const collId = new mongoose.Types.ObjectId(String(collRaw));
const collOk = await ContentCollection.exists({ _id: collId, organization: orgId });
if (!collOk) {
return h.response(error('VALIDATION_ERROR', 'Invalid content collection for this organization')).code(400);
}
}
const type = inferContentType(mimeType, payload.type);
const tags = parseTagsFromPayload(payload);
const description = typeof payload.description === 'string' ? payload.description.trim() : '';
const visibility = normalizeVisibility(payload.visibility);
const baseDoc = {
organization: orgId,
name: payload.name || extracted?.filename || 'Untitled',
description,
visibility,
type,
url,
size,
mimeType,
tags,
collectionName: payload.collectionName,
s3Key,
uploadedBy: user._id,
};
const media = isLibraryUpload
? await LibraryContent.create(baseDoc)
: await Content.create(baseDoc);
if (collRaw && mongoose.Types.ObjectId.isValid(String(collRaw)) && isLibraryUpload) {
const collId = new mongoose.Types.ObjectId(String(collRaw));
await ContentCollection.updateOne({ _id: collId, organization: orgId }, { $addToSet: { contentIds: media._id } });
}
return h.response(success(media, 'File uploaded', 201)).code(201);
};
const get = async (request, h) => {
let media = await Content.findById(request.params.id);
if (!media) media = await LibraryContent.findById(request.params.id);
if (!media) return h.response(error('NOT_FOUND', 'File not found')).code(404);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && media.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
return h.response(success(media));
};
const remove = async (request, h) => {
let media = await Content.findById(request.params.id);
if (!media) media = await LibraryContent.findById(request.params.id);
if (!media) return h.response(error('NOT_FOUND', 'File not found')).code(404);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && media.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
// Prefer S3 deletion if we have the key, otherwise fall back to legacy local /uploads deletion.
if (media.s3Key) {
await deleteS3ObjectByKey({ s3Key: media.s3Key });
} else if (media.url && media.url.startsWith('/uploads/')) {
// Legacy fallback (optional): local file deletion if still present.
try {
const path = require('path');
const fs = require('fs');
const filepath = path.join(process.cwd(), media.url);
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
} catch {}
}
const id = media._id;
await media.deleteOne();
await ContentCollection.updateMany({ contentIds: id }, { $pull: { contentIds: id } });
return h.response(success(null, 'File deleted')).code(200);
};
module.exports = { list, listLibrary, upload, get, remove };

View File

@ -0,0 +1,55 @@
const Notification = require('../models/Notification');
const { success, error } = require('../utils/response');
const list = async (request, h) => {
const user = request.auth.credentials.user;
const { page = 1, limit = 20, status } = request.query;
const filter = { user: user._id };
if (status) filter.status = status;
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const [notifications, total] = await Promise.all([
Notification.find(filter).sort({ createdAt: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(),
Notification.countDocuments(filter),
]);
return h.response(success({ notifications, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }))
.header('X-Total-Count', total);
};
const unread = async (request, h) => {
const user = request.auth.credentials.user;
const notifications = await Notification.find({ user: user._id, status: 'unread' }).sort({ createdAt: -1 }).lean();
const count = notifications.length;
return h.response(success({ notifications, count }));
};
const markRead = async (request, h) => {
const notification = await Notification.findOne({
_id: request.params.id,
user: request.auth.credentials.user._id,
});
if (!notification) return h.response(error('NOT_FOUND', 'Notification not found')).code(404);
notification.status = 'read';
notification.readAt = new Date();
await notification.save();
return h.response(success(notification));
};
const markAllRead = async (request, h) => {
await Notification.updateMany(
{ user: request.auth.credentials.user._id, status: 'unread' },
{ status: 'read', readAt: new Date() }
);
return h.response(success(null, 'All marked as read'));
};
const remove = async (request, h) => {
const notification = await Notification.findOne({
_id: request.params.id,
user: request.auth.credentials.user._id,
});
if (!notification) return h.response(error('NOT_FOUND', 'Notification not found')).code(404);
await notification.deleteOne();
return h.response(success(null, 'Notification deleted')).code(200);
};
module.exports = { list, unread, markRead, markAllRead, remove };

View File

@ -0,0 +1,139 @@
const Organization = require('../models/Organization');
const User = require('../models/User');
const OrganizationUserMap = require('../models/OrganizationUserMap');
const { success, error } = require('../utils/response');
const { ROLES } = require('../config/constants');
const getOrganization = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(success(null));
const org = await Organization.findById(orgId);
if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404);
return h.response(success(org));
};
const updateOrganization = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
if (user.role !== 'super_admin') {
return h.response(error('FORBIDDEN', 'Only super administrators can update organisation settings')).code(403);
}
const org = await Organization.findById(orgId);
if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404);
const p = request.payload;
if (p.name != null) org.name = p.name;
if (p.description != null) org.description = p.description;
if (p.email != null) org.email = p.email;
if (p.phone != null) org.phone = p.phone;
if (p.website != null) org.website = p.website;
if (p.address != null) org.address = p.address;
if (p.logo != null) org.logo = p.logo;
if (p.timezone != null) org.timezone = p.timezone;
if (p.dateFormat != null) org.dateFormat = p.dateFormat;
if (p.language != null) org.language = p.language;
if (p.subscription != null && typeof p.subscription === 'object') {
const sub = org.subscription || {};
const incoming = p.subscription;
if (incoming.plan != null) sub.plan = incoming.plan;
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);
org.subscription = sub;
}
await org.save();
return h.response(success(org));
};
const listUsers = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(success({ users: [] }));
if (user.role !== 'super_admin' && user.role !== 'admin') {
return h.response(error('FORBIDDEN', 'Only admin can list users')).code(403);
}
const users = await User.find({ organization: orgId }).select('-password').populate('organization').lean();
return h.response(success({ users }));
};
const addUser = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
if (user.role !== 'super_admin' && user.role !== 'admin') {
return h.response(error('FORBIDDEN', 'Only admin can add users')).code(403);
}
const existing = await User.findOne({ email: request.payload.email });
if (existing) return h.response(error('VALIDATION_ERROR', 'Email already registered')).code(400);
const role = request.payload.role || 'viewer';
const newUser = await User.create({
...request.payload,
organization: orgId,
role,
});
const eventIds = request.payload.assignedEvents || [];
await OrganizationUserMap.create({
user: newUser._id,
organization: orgId,
role,
eventAccess: eventIds.map((id) => ({ event: id })),
});
const out = newUser.toObject();
delete out.password;
return h.response(success(out, 'User added', 201)).code(201);
};
const updateUser = async (request, h) => {
const target = await User.findById(request.params.id);
if (!target) return h.response(error('NOT_FOUND', 'User not found')).code(404);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && user.role !== 'admin') {
return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403);
}
if (user.role === 'admin' && (target.organization?.toString() !== (user.organization?._id || user.organization)?.toString())) {
return h.response(error('FORBIDDEN', 'Cannot edit user from another organization')).code(403);
}
if (user.role === 'admin' && request.payload.role && ['super_admin', 'admin'].includes(request.payload.role)) {
return h.response(error('FORBIDDEN', 'Cannot assign this role')).code(403);
}
const { password, assignedEvents, ...rest } = request.payload;
Object.assign(target, rest);
if (password) target.password = password;
await target.save();
if (assignedEvents !== undefined) {
const map = await OrganizationUserMap.findOne({ user: target._id });
if (map) {
map.eventAccess = Array.isArray(assignedEvents) ? assignedEvents.map((id) => ({ event: id })) : [];
await map.save();
}
}
const out = target.toObject();
delete out.password;
return h.response(success(out));
};
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);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && user.role !== 'admin') {
return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403);
}
if (user._id.toString() === target._id.toString()) {
return h.response(error('VALIDATION_ERROR', 'Cannot remove yourself')).code(400);
}
await target.deleteOne();
return h.response(success(null, 'User removed')).code(200);
};
module.exports = {
getOrganization,
updateOrganization,
listUsers,
addUser,
updateUser,
removeUser,
};

View File

@ -0,0 +1,133 @@
const EventPage = require('../models/EventPage');
const Event = require('../models/Event');
const Session = require('../models/Session');
const Speaker = require('../models/Speaker');
const { success, error } = require('../utils/response');
const { buildPageHtml } = require('../utils/websiteHtml');
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 pages = await EventPage.find({ event: eventId }).sort({ 'settings.menuOrder': 1 }).lean();
return h.response(success({ pages }));
};
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 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 slug = request.payload.slug || request.payload.title?.toLowerCase().replace(/\s+/g, '-');
const page = await EventPage.create({
...request.payload,
slug,
event: eventId,
createdBy: user._id,
});
return h.response(success(page, 'Page created', 201)).code(201);
};
const get = async (request, h) => {
const page = await EventPage.findById(request.params.id).populate('event');
if (!page) return h.response(error('NOT_FOUND', 'Page not found')).code(404);
const event = page.event && typeof page.event === 'object' && page.event._id
? page.event
: await Event.findById(page.event);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const user = request.auth.credentials.user;
const orgId = event.organization?.toString?.() || (event.organization && event.organization._id && event.organization._id.toString());
if (user.role !== 'super_admin' && orgId !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
return h.response(success(page));
};
const update = async (request, h) => {
const page = await EventPage.findById(request.params.id);
if (!page) return h.response(error('NOT_FOUND', 'Page not found')).code(404);
const event = await Event.findById(page.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);
}
Object.assign(page, request.payload);
await page.save();
return h.response(success(page));
};
const remove = async (request, h) => {
const page = await EventPage.findById(request.params.id);
if (!page) return h.response(error('NOT_FOUND', 'Page not found')).code(404);
const event = await Event.findById(page.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);
}
await page.deleteOne();
return h.response(success(null, 'Page deleted')).code(200);
};
const publish = async (request, h) => {
const page = await EventPage.findById(request.params.id);
if (!page) return h.response(error('NOT_FOUND', 'Page not found')).code(404);
page.settings = page.settings || {};
page.settings.isPublished = true;
await page.save();
return h.response(success(page, 'Page published'));
};
const publicPage = async (request, h) => {
const { eventSlug, pageSlug } = request.params;
const event = await Event.findOne({ name: new RegExp(eventSlug, 'i') });
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const page = await EventPage.findOne({ event: event._id, slug: pageSlug, 'settings.isPublished': true });
if (!page) return h.response(error('NOT_FOUND', 'Page not found')).code(404);
page.views = (page.views || 0) + 1;
page.lastViewed = new Date();
await page.save({ validateBeforeSave: false });
return h.response(success(page)).code(200);
};
const exportPageHtml = async (request, h) => {
const page = await EventPage.findById(request.params.id);
if (!page) return h.response(error('NOT_FOUND', 'Page not found')).code(404);
const event = await Event.findById(page.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 [sessions, speakerList] = await Promise.all([
Session.find({ event: event._id }).populate('track').sort({ startTime: 1 }).lean(),
(async () => {
const eventSessions = await Session.find({ event: event._id }).select('speakers').lean();
const speakerIds = [...new Set(eventSessions.flatMap((s) => (s.speakers || []).map((id) => id.toString())))];
if (speakerIds.length === 0) return [];
return Speaker.find({ _id: { $in: speakerIds }, profileVisibility: 'public' }).lean();
})(),
]);
const agendaItems = sessions.map((s) => ({
title: s.title,
time: s.startTime ? new Date(s.startTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) : '',
track: s.track?.name || s.track?.title || '',
}));
const speakers = speakerList.map((s) => ({
name: [s.firstName, s.lastName].filter(Boolean).join(' '),
designation: s.title || s.organizationName || '',
photoUrl: s.photo || '',
}));
const registrationUrl = typeof request.query.registrationUrl === 'string' ? request.query.registrationUrl : '';
const layoutTemplate = (event.website && event.website.layoutTemplate) || 'conference';
const html = buildPageHtml(page, event, { agendaItems, speakers, registrationUrl, layoutTemplate });
return h.response(success({ html }));
};
module.exports = { list, create, get, update, remove, publish, publicPage, exportPageHtml };

View File

@ -0,0 +1,966 @@
const mongoose = require('mongoose');
const Participant = require('../models/Participant');
const Event = require('../models/Event');
const ParticipantList = require('../models/ParticipantList');
const { success, error } = require('../utils/response');
const { parseParticipantCsv, splitTags, escapeCsvField } = require('../utils/csvParticipant');
const { getMembership, flattenParticipantForEvent, baseQueryForEvent } = require('../utils/participantMembership');
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
const EXPORT_HEADERS_FIXED = [
'firstName',
'lastName',
'email',
'phone',
'organization',
'designation',
'status',
'tags',
'notes',
'registrationDate',
'eventId',
'eventName',
];
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 };
}
function assertParticipantOrgAccess(participant, user) {
if (user.role === 'super_admin') return true;
const oid = user.organization?._id || user.organization;
return participant.tenantOrganization?.toString() === oid?.toString();
}
async function resolveCheckInEventId(participant, queryEventId) {
if (queryEventId && mongoose.Types.ObjectId.isValid(queryEventId)) {
if (!getMembership(participant, queryEventId)) return { err: error('BAD_REQUEST', 'Participant is not registered for this event') };
return { eventId: String(queryEventId) };
}
const m = participant.eventMemberships || [];
if (m.length === 1) return { eventId: String(m[0].event) };
return { err: error('BAD_REQUEST', 'event_id query is required when participant has multiple events') };
}
/** Tenant-only $match for portfolio participant lists (never unscoped). */
function listAllTenantOnlyMatch(user, queryOrganization, payloadOrganization) {
const scoped = resolveScopedOrganizationId(user, queryOrganization ?? payloadOrganization);
if (scoped.error) return { error: scoped.error };
return { tenantMatch: { tenantOrganization: scoped.organizationId } };
}
function listAllCommonStages(query, options = {}) {
const { skipSort = false } = options;
const { q, tag } = query;
const stages = [];
stages.push({
$addFields: {
_allTags: {
$setUnion: [
{ $ifNull: ['$tags', []] },
{
$reduce: {
input: { $ifNull: ['$eventMemberships', []] },
initialValue: [],
in: { $setUnion: ['$$value', { $ifNull: ['$$this.tags', []] }] },
},
},
],
},
_primary: {
$reduce: {
input: { $ifNull: ['$eventMemberships', []] },
initialValue: null,
in: {
$cond: [
{
$or: [
{ $eq: ['$$value', null] },
{
$and: [
{ $ne: ['$$this.registrationDate', null] },
{
$or: [
{ $eq: ['$$value.registrationDate', null] },
{ $gt: ['$$this.registrationDate', '$$value.registrationDate'] },
],
},
],
},
],
},
'$$this',
'$$value',
],
},
},
},
},
});
if (tag && String(tag).trim()) {
stages.push({ $match: { _allTags: String(tag).trim() } });
}
if (q && String(q).trim()) {
const search = String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(search, 'i');
stages.push({
$match: {
$or: [
{ firstName: re },
{ lastName: re },
{ email: re },
{ phone: re },
{ organization: re },
{ designation: re },
],
},
});
}
if (!skipSort) stages.push({ $sort: { updatedAt: -1 } });
return stages;
}
function listAllProjectStages() {
return [
{
$lookup: {
from: 'events',
localField: '_primary.event',
foreignField: '_id',
as: '_evPrimary',
},
},
{
$addFields: {
_eventDoc: { $arrayElemAt: ['$_evPrimary', 0] },
},
},
{
$project: {
_id: 1,
firstName: 1,
lastName: 1,
email: 1,
phone: 1,
organization: 1,
designation: 1,
notes: 1,
registrationDate: '$_primary.registrationDate',
status: '$_primary.status',
checkedIn: '$_primary.checkedIn',
checkedInAt: '$_primary.checkedInAt',
tags: '$_allTags',
customFields: 1,
event: {
$cond: [
{ $ne: ['$_eventDoc', null] },
{ _id: '$_eventDoc._id', name: '$_eventDoc.name' },
null,
],
},
},
},
];
}
const BULK_MAX = 500;
const listDistinctTags = async (request, h) => {
const user = request.auth.credentials.user;
const r = listAllTenantOnlyMatch(user, request.query.organization);
if (r.error) return h.response(error(r.error.code, r.error.message)).code(400);
const { tenantMatch } = r;
const rows = await Participant.aggregate([
{ $match: tenantMatch },
{
$addFields: {
_allTags: {
$setUnion: [
{ $ifNull: ['$tags', []] },
{
$reduce: {
input: { $ifNull: ['$eventMemberships', []] },
initialValue: [],
in: { $setUnion: ['$$value', { $ifNull: ['$$this.tags', []] }] },
},
},
],
},
},
},
{ $unwind: { path: '$_allTags', preserveNullAndEmptyArrays: false } },
{ $group: { _id: '$_allTags' } },
{ $sort: { _id: 1 } },
{ $limit: 200 },
]);
const tags = rows.map((r) => r._id).filter((t) => t != null && String(t).trim() !== '');
return h.response(success({ tags }));
};
const listMatchingIds = async (request, h) => {
const user = request.auth.credentials.user;
const r = listAllTenantOnlyMatch(user, request.query.organization);
if (r.error) return h.response(error(r.error.code, r.error.message)).code(400);
const { tenantMatch } = r;
const { q, tag } = request.query;
const stages = [
{ $match: tenantMatch },
...listAllCommonStages({ q, tag }, { skipSort: true }),
{ $project: { _id: 1 } },
{ $limit: BULK_MAX + 1 },
];
const rows = await Participant.aggregate(stages);
const capped = rows.length > BULK_MAX;
const trimmed = capped ? rows.slice(0, BULK_MAX) : rows;
const ids = trimmed.map((r) => String(r._id));
return h.response(success({ ids, total: ids.length, capped }));
};
function assertFixedListOrgAccess(list, user) {
if (user.role === 'super_admin') return true;
const oid = user.organization?._id || user.organization;
return list.tenantOrganization?.toString() === oid?.toString();
}
const bulkParticipants = async (request, h) => {
const user = request.auth.credentials.user;
const {
action,
participantIds: idsRaw,
selectAllMatching,
q,
tag: filterTag,
tags: tagPayload,
eventIds: evIdsRaw,
listId,
} = request.payload || {};
let participantIds = [...new Set((idsRaw || []).map((x) => String(x)))].filter((id) => mongoose.Types.ObjectId.isValid(id));
if (selectAllMatching) {
const r = listAllTenantOnlyMatch(user, request.query.organization, request.payload?.organization);
if (r.error) return h.response(error(r.error.code, r.error.message)).code(400);
const { tenantMatch } = r;
const stages = [
{ $match: tenantMatch },
...listAllCommonStages({ q, tag: filterTag }, { skipSort: true }),
{ $project: { _id: 1 } },
{ $limit: BULK_MAX + 1 },
];
const rows = await Participant.aggregate(stages);
if (rows.length > BULK_MAX) {
return h
.response(error('VALIDATION_ERROR', `Too many participants match (max ${BULK_MAX}). Narrow filters or select fewer rows.`))
.code(400);
}
participantIds = rows.map((r) => String(r._id));
}
if (!participantIds.length) {
return h.response(error('VALIDATION_ERROR', 'No participants selected')).code(400);
}
if (participantIds.length > BULK_MAX) {
return h.response(error('VALIDATION_ERROR', `At most ${BULK_MAX} participants per action`)).code(400);
}
const oids = participantIds.map((id) => new mongoose.Types.ObjectId(id));
const participants = await Participant.find({ _id: { $in: oids } });
if (participants.length !== participantIds.length) {
return h.response(error('BAD_REQUEST', 'Some participants were not found')).code(400);
}
for (const p of participants) {
if (!assertParticipantOrgAccess(p, user)) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
}
if (action === 'delete') {
await Participant.deleteMany({ _id: { $in: oids } });
return h.response(success({ deleted: participants.length }));
}
if (action === 'add_tags' || action === 'remove_tags') {
const tagList = (tagPayload || []).map((t) => String(t).trim()).filter(Boolean);
if (!tagList.length) return h.response(error('VALIDATION_ERROR', 'tags required')).code(400);
const removeSet = new Set(tagList);
for (const p of participants) {
if (action === 'add_tags') {
p.tags = [...new Set([...(p.tags || []), ...tagList])];
} else {
p.tags = (p.tags || []).filter((t) => !removeSet.has(t));
}
await p.save();
}
return h.response(success({ updated: participants.length }));
}
if (action === 'assign_events') {
const eventIds = [...new Set((evIdsRaw || []).filter((id) => id && mongoose.Types.ObjectId.isValid(String(id))))].map(
(id) => String(id)
);
if (!eventIds.length) return h.response(error('VALIDATION_ERROR', 'eventIds required')).code(400);
for (const eid of eventIds) {
const r = await assertEventAccess(eid, user);
if (r.error) return r.error(h);
}
let membershipsAdded = 0;
for (const p of participants) {
for (const eid of eventIds) {
if (!getMembership(p, eid)) {
p.eventMemberships.push({
event: eid,
status: 'pending',
registrationDate: new Date(),
});
membershipsAdded += 1;
}
}
await p.save();
}
return h.response(success({ updated: participants.length, membershipsAdded }));
}
if (action === 'add_to_fixed_list') {
if (!listId || !mongoose.Types.ObjectId.isValid(String(listId))) {
return h.response(error('VALIDATION_ERROR', 'listId required')).code(400);
}
const list = await ParticipantList.findById(listId);
if (!list) return h.response(error('NOT_FOUND', 'List not found')).code(404);
if (list.type !== 'fixed') return h.response(error('BAD_REQUEST', 'Only fixed lists accept member ids')).code(400);
if (!assertFixedListOrgAccess(list, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const seen = new Set((list.participantIds || []).map((x) => String(x)));
for (const pid of participantIds) {
if (!seen.has(pid)) {
list.participantIds.push(new mongoose.Types.ObjectId(pid));
seen.add(pid);
}
}
await list.save();
return h.response(success({ updated: participants.length, listSize: list.participantIds.length }));
}
return h.response(error('BAD_REQUEST', 'Unknown action')).code(400);
};
const listAll = async (request, h) => {
const user = request.auth.credentials.user;
const r = listAllTenantOnlyMatch(user, request.query.organization);
if (r.error) return h.response(error(r.error.code, r.error.message)).code(400);
const { tenantMatch } = r;
const { page = 1, limit = 20, q, tag } = 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 preFacet = [{ $match: tenantMatch }, ...listAllCommonStages({ q, tag })];
const facet = await Participant.aggregate([
...preFacet,
{
$facet: {
meta: [{ $count: 'total' }],
rows: [{ $skip: skip }, { $limit: limitNum }, ...listAllProjectStages()],
},
},
]);
const total = facet[0]?.meta[0]?.total ?? 0;
const participants = facet[0]?.rows ?? [];
return h
.response(success({ participants, total, page: pageNum, limit: limitNum }))
.header('X-Total-Count', String(total));
};
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 orgId = event.organization;
const { page = 1, limit = 20, status, q, designation } = request.query;
const eid = new mongoose.Types.ObjectId(String(eventId));
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 preStages = [{ $match: { tenantOrganization: orgId } }, { $unwind: '$eventMemberships' }, { $match: { 'eventMemberships.event': eid } }];
if (status) preStages.push({ $match: { 'eventMemberships.status': status } });
if (designation) preStages.push({ $match: { designation } });
if (q && String(q).trim()) {
const search = String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(search, 'i');
preStages.push({
$match: {
$or: [
{ firstName: re },
{ lastName: re },
{ email: re },
{ phone: re },
{ organization: re },
{ designation: re },
],
},
});
}
const facet = await Participant.aggregate([
...preStages,
{
$facet: {
meta: [{ $count: 'total' }],
rows: [
{ $sort: { 'eventMemberships.registrationDate': -1 } },
{ $skip: skip },
{ $limit: limitNum },
{
$project: {
_id: 1,
firstName: 1,
lastName: 1,
email: 1,
phone: 1,
organization: 1,
designation: 1,
notes: 1,
customFields: 1,
payment: 1,
emailVerified: 1,
event: { $literal: eventId },
status: '$eventMemberships.status',
checkedIn: '$eventMemberships.checkedIn',
checkedInAt: '$eventMemberships.checkedInAt',
guestToken: '$eventMemberships.guestToken',
tags: {
$setUnion: [{ $ifNull: ['$tags', []] }, { $ifNull: ['$eventMemberships.tags', []] }],
},
registrationDate: '$eventMemberships.registrationDate',
},
},
],
},
},
]);
const total = facet[0]?.meta[0]?.total ?? 0;
const participants = facet[0]?.rows ?? [];
return h.response(success({ participants, total, page: pageNum, limit: limitNum })).header('X-Total-Count', String(total));
};
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 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 orgId = event.organization;
const payload = { ...request.payload };
const tags = Array.isArray(payload.tags) ? payload.tags : [];
delete payload.tags;
const existing = await Participant.findOne({
tenantOrganization: orgId,
email: String(payload.email || '').trim().toLowerCase(),
});
if (existing) {
if (getMembership(existing, eventId)) {
return h.response(error('CONFLICT', 'Participant already exists for this event')).code(409);
}
existing.eventMemberships.push({
event: eventId,
status: 'pending',
registrationDate: new Date(),
});
Object.assign(existing, payload);
if (tags.length) {
existing.tags = [...new Set([...(existing.tags || []), ...tags])];
}
await existing.save();
const flat = flattenParticipantForEvent(existing.toObject(), eventId);
return h.response(success(flat, 'Participant added', 201)).code(201);
}
const doc = await Participant.create({
...payload,
tenantOrganization: orgId,
tags,
eventMemberships: [
{
event: eventId,
status: 'pending',
registrationDate: new Date(),
},
],
});
const flat = flattenParticipantForEvent(doc.toObject(), eventId);
return h.response(success(flat, 'Participant added', 201)).code(201);
};
const get = async (request, h) => {
const participant = await Participant.findById(request.params.id).lean();
if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404);
const user = request.auth.credentials.user;
if (!assertParticipantOrgAccess(participant, user)) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const eventId = request.query.event_id;
if (eventId) {
const flat = flattenParticipantForEvent(participant, eventId);
if (!flat) return h.response(error('NOT_FOUND', 'No membership for this event')).code(404);
const ev = await Event.findById(eventId).select('name').lean();
return h.response(success({ ...flat, event: ev }));
}
return h.response(success(participant));
};
const update = async (request, h) => {
const participant = await Participant.findById(request.params.id);
if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404);
const user = request.auth.credentials.user;
if (!assertParticipantOrgAccess(participant, user)) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const eventId = request.query.event_id;
const payload = { ...request.payload };
const tags = payload.tags;
delete payload.tags;
const assignable = ['email', 'firstName', 'lastName', 'phone', 'organization', 'designation', 'notes'];
for (const key of assignable) {
if (Object.prototype.hasOwnProperty.call(payload, key) && payload[key] !== undefined) {
participant[key] = payload[key];
}
}
if (tags !== undefined) {
participant.tags = Array.isArray(tags) ? tags : [];
}
await participant.save();
const out = eventId ? flattenParticipantForEvent(participant.toObject(), eventId) : participant.toObject();
return h.response(success(out));
};
const remove = async (request, h) => {
const participant = await Participant.findById(request.params.id);
if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404);
const user = request.auth.credentials.user;
if (!assertParticipantOrgAccess(participant, user)) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const eventId = request.query.event_id;
if (eventId) {
participant.eventMemberships = participant.eventMemberships.filter((m) => String(m.event) !== String(eventId));
if (participant.eventMemberships.length === 0) {
await participant.deleteOne();
} else {
await participant.save();
}
} else {
await participant.deleteOne();
}
return h.response(success(null, 'Participant removed')).code(200);
};
const checkIn = async (request, h) => {
const participant = await Participant.findById(request.params.id);
if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404);
const user = request.auth.credentials.user;
if (!assertParticipantOrgAccess(participant, user)) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const { eventId, err } = await resolveCheckInEventId(participant, request.query.event_id);
if (err) return h.response(err).code(400);
const m = getMembership(participant, eventId);
m.checkedIn = true;
m.checkedInAt = new Date();
m.status = 'checked_in';
participant.markModified('eventMemberships');
await participant.save();
const flat = flattenParticipantForEvent(participant.toObject(), eventId);
return h.response(success(flat, 'Checked in'));
};
const exportCsv = 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 raw = await Participant.find({
tenantOrganization: r.event.organization,
...baseQueryForEvent(eventId),
})
.sort({ updatedAt: -1 })
.lean();
const ev = await Event.findById(eventId).select('name').lean();
const eventName = ev?.name ?? '';
const headerLine = EXPORT_HEADERS_FIXED.join(',');
const dataLines = raw.map((p) => {
const flat = flattenParticipantForEvent(p, eventId);
if (!flat) return null;
return EXPORT_HEADERS_FIXED.map((key) => {
let v = flat[key];
if (key === 'tags' && Array.isArray(v)) v = v.join('; ');
if (key === 'registrationDate' && v) v = new Date(v).toISOString();
if (key === 'eventId') v = String(eventId);
if (key === 'eventName') v = eventName;
return escapeCsvField(v);
}).join(',');
}).filter(Boolean);
const csv = `\uFEFF${[headerLine, ...dataLines].join('\n')}`;
return h
.response(csv)
.type('text/csv; charset=utf-8')
.header('Content-Disposition', 'attachment; filename="participants-export.csv"')
.code(200);
};
const importCsv = 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 orgId = r.event.organization;
const { csv } = request.payload;
const { rows } = parseParticipantCsv(csv);
if (rows.length === 0) {
return h.response(error('VALIDATION_ERROR', 'No data rows found in CSV')).code(400);
}
let created = 0;
let skippedDuplicate = 0;
const rowErrors = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const lineNum = i + 2;
const email = String(row.email || '').trim().toLowerCase();
const firstName = String(row.firstName || row.firstname || '').trim();
const lastName = String(row.lastName || row.lastname || '').trim();
if (!email) {
rowErrors.push({ line: lineNum, message: 'Email is required' });
continue;
}
if (!firstName) {
rowErrors.push({ line: lineNum, message: 'First name is required' });
continue;
}
if (!lastName) {
rowErrors.push({ line: lineNum, message: 'Last name is required' });
continue;
}
const phone = String(row.phone || '').trim() || undefined;
const organization = String(row.organization || '').trim() || undefined;
const designation = String(row.designation || '').trim() || undefined;
const notes = String(row.notes || '').trim() || undefined;
const tags = splitTags(row.tags);
try {
let p = await Participant.findOne({ tenantOrganization: orgId, email });
if (p) {
if (getMembership(p, eventId)) {
skippedDuplicate += 1;
continue;
}
p.eventMemberships.push({
event: eventId,
status: 'pending',
registrationDate: new Date(),
});
if (tags.length) {
p.tags = [...new Set([...(p.tags || []), ...tags])];
}
await p.save();
created += 1;
} else {
await Participant.create({
tenantOrganization: orgId,
email,
firstName,
lastName,
phone,
organization,
designation,
notes,
tags: tags.length ? tags : [],
eventMemberships: [
{
event: eventId,
status: 'pending',
registrationDate: new Date(),
},
],
});
created += 1;
}
} catch (e) {
if (e && e.code === 11000) {
skippedDuplicate += 1;
} else {
rowErrors.push({ line: lineNum, message: e.message || 'Create failed' });
}
}
}
return h.response(
success({
created,
skippedDuplicate,
failed: rowErrors.length,
errors: rowErrors.slice(0, 50),
})
);
};
const exportAllCsv = 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 tenantMatch = { tenantOrganization: scoped.organizationId };
const eventDocs = await Event.find({ organization: scoped.organizationId }).select('_id name').lean();
const eventMap = Object.fromEntries(eventDocs.map((e) => [String(e._id), e.name ?? '']));
const { q, tag } = request.query;
const stages = [{ $match: tenantMatch }, ...listAllCommonStages({ q, tag }), ...listAllProjectStages()];
const rows = await Participant.aggregate(stages);
const headerLine = EXPORT_HEADERS_FIXED.join(',');
const dataLines = rows.map((flat) =>
EXPORT_HEADERS_FIXED.map((key) => {
let v = flat[key];
if (key === 'tags' && Array.isArray(v)) v = v.join('; ');
if (key === 'registrationDate' && v) v = new Date(v).toISOString();
if (key === 'status' && (v === undefined || v === null)) v = '';
if (key === 'eventId') v = flat.event?._id ? String(flat.event._id) : '';
if (key === 'eventName') {
v = flat.event?.name ?? (flat.event?._id ? eventMap[String(flat.event._id)] ?? '' : '');
}
return escapeCsvField(v);
}).join(',')
);
const csv = `\uFEFF${[headerLine, ...dataLines].join('\n')}`;
return h
.response(csv)
.type('text/csv; charset=utf-8')
.header('Content-Disposition', 'attachment; filename="all-participants-export.csv"')
.code(200);
};
const importGlobalCsv = async (request, h) => {
const user = request.auth.credentials.user;
const {
csv,
eventIds: eventIdsRaw,
createCustomList,
listName,
listDescription,
} = request.payload || {};
const orgRaw = user.organization?._id || user.organization;
if (!orgRaw || !mongoose.Types.ObjectId.isValid(String(orgRaw))) {
return h
.response(error('VALIDATION_ERROR', 'Your account must belong to an organization to import participants'))
.code(400);
}
const tenantOrganization = orgRaw instanceof mongoose.Types.ObjectId ? orgRaw : new mongoose.Types.ObjectId(String(orgRaw));
if (createCustomList && (!listName || !String(listName).trim())) {
return h.response(error('VALIDATION_ERROR', 'List name is required when creating a custom list')).code(400);
}
const { rows } = parseParticipantCsv(csv || '');
if (rows.length === 0) {
return h.response(error('VALIDATION_ERROR', 'No data rows found in CSV')).code(400);
}
const eventIds = (Array.isArray(eventIdsRaw) ? eventIdsRaw : [])
.filter((id) => id && mongoose.Types.ObjectId.isValid(String(id)))
.map((id) => String(id));
for (const eid of eventIds) {
const r = await assertEventAccess(eid, user);
if (r.error) return r.error(h);
}
let participantsCreated = 0;
let membershipsAdded = 0;
let profilesUpdated = 0;
let skippedDuplicate = 0;
const rowErrors = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const lineNum = i + 2;
const email = String(row.email || '').trim().toLowerCase();
const firstName = String(row.firstName || row.firstname || '').trim();
const lastName = String(row.lastName || row.lastname || '').trim();
if (!email) {
rowErrors.push({ line: lineNum, message: 'Email is required' });
continue;
}
if (!firstName || !lastName) {
rowErrors.push({ line: lineNum, message: 'First and last name required' });
continue;
}
const phone = String(row.phone || '').trim() || undefined;
const organization = String(row.organization || '').trim() || undefined;
const designation = String(row.designation || '').trim() || undefined;
const notes = String(row.notes || '').trim() || undefined;
const tags = splitTags(row.tags);
try {
let p = await Participant.findOne({ tenantOrganization, email });
if (!p) {
await Participant.create({
tenantOrganization,
email,
firstName,
lastName,
phone,
organization,
designation,
notes,
tags: tags.length ? tags : [],
eventMemberships: eventIds.map((eid) => ({
event: eid,
status: 'pending',
registrationDate: new Date(),
})),
});
participantsCreated += 1;
continue;
}
Object.assign(p, { firstName, lastName, phone, organization, designation, notes });
if (tags.length) {
p.tags = [...new Set([...(p.tags || []), ...tags])];
}
let newMemberships = 0;
for (const eid of eventIds) {
if (getMembership(p, eid)) {
skippedDuplicate += 1;
continue;
}
p.eventMemberships.push({
event: eid,
status: 'pending',
registrationDate: new Date(),
});
newMemberships += 1;
}
await p.save();
if (newMemberships > 0) {
membershipsAdded += newMemberships;
} else {
profilesUpdated += 1;
}
} catch (e) {
if (e && e.code === 11000) skippedDuplicate += 1;
else rowErrors.push({ line: lineNum, message: e.message || 'failed' });
}
}
let listId;
let listLinked = 0;
if (createCustomList) {
const orderedIds = [];
const seenId = new Set();
for (const row of rows) {
const em = String(row.email || '').trim().toLowerCase();
if (!em) continue;
const found = await Participant.findOne({ tenantOrganization, email: em }).select('_id').lean();
if (found) {
const sid = String(found._id);
if (!seenId.has(sid)) {
seenId.add(sid);
orderedIds.push(found._id);
listLinked += 1;
}
}
}
const list = await ParticipantList.create({
name: String(listName).trim(),
description: listDescription ? String(listDescription).trim() : '',
tenantOrganization,
createdBy: user._id,
visibility: 'private',
type: 'fixed',
autoUpdate: false,
participantIds: orderedIds,
});
listId = String(list._id);
}
return h.response(
success({
participantsCreated,
membershipsAdded,
profilesUpdated,
skippedDuplicate,
failed: rowErrors.length,
errors: rowErrors.slice(0, 50),
listId,
listRows: createCustomList ? rows.length : undefined,
listLinked: createCustomList ? listLinked : undefined,
})
);
};
module.exports = {
listAll,
listDistinctTags,
listMatchingIds,
bulkParticipants,
list,
create,
get,
update,
remove,
checkIn,
exportCsv,
importCsv,
exportAllCsv,
importGlobalCsv,
};

View File

@ -0,0 +1,285 @@
const mongoose = require('mongoose');
const Event = require('../models/Event');
const ParticipantList = require('../models/ParticipantList');
const Participant = require('../models/Participant');
const { success, error } = require('../utils/response');
const { flattenParticipantForEvent } = require('../utils/participantMembership');
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
function assertListOrg(list, user) {
if (user.role === 'super_admin') return true;
const oid = user.organization?._id || user.organization;
return list.tenantOrganization?.toString() === oid?.toString();
}
async function resolveAllowedEventIds(user, queryOrganization) {
const scoped = resolveScopedOrganizationId(user, queryOrganization);
if (scoped.error) return { error: scoped.error };
const ids = (await Event.find({ organization: scoped.organizationId }).select('_id').lean()).map((e) => e._id);
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);
if (scoped.error) return h.response(error(scoped.error.code, scoped.error.message)).code(400);
const filter = { tenantOrganization: scoped.organizationId };
const lists = await ParticipantList.find(filter).sort({ updatedAt: -1 }).lean();
return h.response(success({ lists }));
};
const create = async (request, h) => {
const user = request.auth.credentials.user;
const tenantOrganization =
user.role === 'super_admin' && request.payload.tenantOrganization
? request.payload.tenantOrganization
: user.organization?._id || user.organization;
if (!tenantOrganization) {
return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
}
const { name, description, visibility, type, autoUpdate, rules, participantIds: pidsRaw } = request.payload;
let participantIds = [];
if (type === 'fixed' && Array.isArray(pidsRaw) && pidsRaw.length) {
participantIds = pidsRaw
.filter((id) => id && mongoose.Types.ObjectId.isValid(String(id)))
.map((id) => new mongoose.Types.ObjectId(String(id)));
}
const doc = await ParticipantList.create({
name,
description,
visibility,
type,
autoUpdate: autoUpdate !== false,
rules: type === 'rule_based' ? rules : null,
tenantOrganization,
createdBy: user._id,
participantIds,
});
return h.response(success(doc, 'List created', 201)).code(201);
};
const get = async (request, h) => {
const user = request.auth.credentials.user;
const doc = await ParticipantList.findById(request.params.id).lean();
if (!doc) return h.response(error('NOT_FOUND', 'List not found')).code(404);
if (!assertListOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
return h.response(success(doc));
};
const update = async (request, h) => {
const user = request.auth.credentials.user;
const doc = await ParticipantList.findById(request.params.id);
if (!doc) return h.response(error('NOT_FOUND', 'List not found')).code(404);
if (!assertListOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const { name, description, visibility, type, autoUpdate, rules, participantIds } = request.payload;
if (name !== undefined) doc.name = name;
if (description !== undefined) doc.description = description;
if (visibility !== undefined) doc.visibility = visibility;
if (type !== undefined) doc.type = type;
if (autoUpdate !== undefined) doc.autoUpdate = autoUpdate;
if (rules !== undefined) doc.rules = rules;
if (participantIds !== undefined && Array.isArray(participantIds) && doc.type === 'fixed') {
doc.participantIds = participantIds
.filter((id) => id && mongoose.Types.ObjectId.isValid(String(id)))
.map((id) => new mongoose.Types.ObjectId(String(id)));
}
await doc.save();
return h.response(success(doc.toObject()));
};
const remove = async (request, h) => {
const user = request.auth.credentials.user;
const doc = await ParticipantList.findById(request.params.id);
if (!doc) return h.response(error('NOT_FOUND', 'List not found')).code(404);
if (!assertListOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
await doc.deleteOne();
return h.response(success(null, 'List deleted'));
};
const listMembers = async (request, h) => {
const user = request.auth.credentials.user;
const doc = await ParticipantList.findById(request.params.id).lean();
if (!doc) return h.response(error('NOT_FOUND', 'List not found')).code(404);
if (!assertListOrg(doc, user)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
const { page = 1, limit = 50 } = request.query;
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
const skip = (pageNum - 1) * limitNum;
if (doc.type === 'fixed') {
const idList = (doc.participantIds || []).map((id) => String(id));
const total = idList.length;
const slice = idList.slice(skip, skip + limitNum);
const oids = slice.map((id) => new mongoose.Types.ObjectId(id));
const raw = oids.length ? await Participant.find({ _id: { $in: oids } }).lean() : [];
const byId = new Map(raw.map((p) => [String(p._id), p]));
const ev = await resolveAllowedEventIds(user, request.query.organization);
if (ev.error) return h.response(error(ev.error.code, ev.error.message)).code(400);
const allowedIds = ev.allowedIds;
const members = slice.map((idStr) => {
const p = byId.get(idStr);
if (!p) {
return {
kind: 'participant',
participantId: idStr,
name: '—',
email: '',
organization: '',
eventId: undefined,
status: undefined,
tags: [],
};
}
const m = (p.eventMemberships || []).find((em) => allowedIds.some((e) => String(e) === String(em.event)));
const ev = m ? String(m.event) : null;
const flat = ev ? flattenParticipantForEvent(p, ev) : { ...p, tags: p.tags || [], status: undefined };
const name = [flat.firstName, flat.lastName].filter(Boolean).join(' ').trim() || '—';
return {
kind: 'participant',
participantId: String(p._id),
name,
email: flat.email ?? '',
organization: flat.organization ?? '',
eventId: ev ?? undefined,
status: flat.status,
tags: flat.tags,
};
});
return h.response(success({ members, total, page: pageNum, limit: limitNum }));
}
const ev = await resolveAllowedEventIds(user, request.query.organization);
if (ev.error) return h.response(error(ev.error.code, ev.error.message)).code(400);
const allowedIds = ev.allowedIds;
const rulesPayload = doc.rules && typeof doc.rules === 'object' ? doc.rules : {};
const rules = rulesPayload.rules || [];
const conjunction = rulesPayload.conjunction || 'AND';
const match = rulesToQuery(rules, conjunction, allowedIds, doc.tenantOrganization);
const [raw, total] = await Promise.all([
Participant.find(match).sort({ updatedAt: -1 }).skip(skip).limit(limitNum).lean(),
Participant.countDocuments(match),
]);
const members = raw.map((p) => {
const m = (p.eventMemberships || []).find((em) => allowedIds.some((e) => String(e) === String(em.event)));
const ev = m ? String(m.event) : null;
const flat = ev ? flattenParticipantForEvent(p, ev) : { ...p };
const name = [flat.firstName, flat.lastName].filter(Boolean).join(' ').trim() || '—';
return {
kind: 'participant',
participantId: String(p._id),
name,
email: flat.email ?? '',
organization: flat.organization ?? '',
eventId: ev,
status: flat.status,
tags: flat.tags,
};
});
return h.response(success({ members, total, page: pageNum, limit: limitNum }));
};
module.exports = { list, create, get, update, remove, listMembers };

View File

@ -0,0 +1,356 @@
const Event = require('../models/Event');
const Participant = require('../models/Participant');
const PaymentTransaction = require('../models/PaymentTransaction');
const { success, error } = require('../utils/response');
const { createPaymentOrderCore } = require('../services/payments/createOrderCore');
const { verifyPaymentCore, applyPaidStatus } = require('../services/payments/verifyPaymentCore');
const { resolveForPaymentTransaction } = require('../services/payments/resolver');
const { razorpay: razorpayAdapter } = require('../services/payments/adapters');
const participantMembership = require('../utils/participantMembership');
const listTransactions = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return hResponseEmpty(h);
const { q, eventId, status, from, to, page, limit } = request.query;
const filter = { organization: orgId };
if (eventId) filter.event = eventId;
if (status) filter.status = status;
if (from || to) {
filter.createdAt = {};
if (from) filter.createdAt.$gte = new Date(from);
if (to) filter.createdAt.$lte = new Date(to);
}
if (q && String(q).trim()) {
const rx = new RegExp(String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
filter.$or = [
{ externalOrderId: rx },
{ externalPaymentId: rx },
];
}
const p = Math.max(1, parseInt(page, 10) || 1);
const l = Math.min(100, Math.max(1, parseInt(limit, 10) || 20));
const skip = (p - 1) * l;
const [rows, total] = await Promise.all([
PaymentTransaction.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(l)
.populate('event', 'name')
.populate('participant', 'email firstName lastName')
.lean(),
PaymentTransaction.countDocuments(filter),
]);
return h.response(success({ transactions: rows, total, page: p, limit: l }));
};
function hResponseEmpty(h) {
return h.response(success({ transactions: [], total: 0, page: 1, limit: 20 }));
}
function csvEscape(s) {
const x = String(s ?? '');
if (/[",\n]/.test(x)) return `"${x.replace(/"/g, '""')}"`;
return x;
}
const exportTransactions = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) {
return h
.response('createdAt,status\n')
.type('text/csv')
.header('Content-Disposition', 'attachment; filename="payment-transactions.csv"');
}
const { q, eventId, status, from, to } = request.query;
const filter = { organization: orgId };
if (eventId) filter.event = eventId;
if (status) filter.status = status;
if (from || to) {
filter.createdAt = {};
if (from) filter.createdAt.$gte = new Date(from);
if (to) filter.createdAt.$lte = new Date(to);
}
if (q && String(q).trim()) {
const rx = new RegExp(String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
filter.$or = [{ externalOrderId: rx }, { externalPaymentId: rx }];
}
const txs = await PaymentTransaction.find(filter)
.sort({ createdAt: -1 })
.limit(5000)
.populate('event', 'name')
.populate('participant', 'email firstName lastName')
.lean();
const headers = [
'createdAt',
'status',
'currency',
'amountSubtotal',
'commissionAmount',
'amountGross',
'amountNet',
'gatewayProvider',
'checkoutSource',
'externalOrderId',
'externalPaymentId',
'eventName',
'participantEmail',
];
const lines = [headers.join(',')];
for (const tx of txs) {
const ev = tx.event && typeof tx.event === 'object' ? tx.event.name || '' : '';
const em =
tx.participant && typeof tx.participant === 'object' ? tx.participant.email || '' : '';
lines.push(
[
tx.createdAt ? new Date(tx.createdAt).toISOString() : '',
tx.status,
tx.currency,
tx.amountSubtotal,
tx.commissionAmount,
tx.amountGross,
tx.amountNet,
tx.gatewayProvider,
tx.checkoutSource,
csvEscape(tx.externalOrderId),
csvEscape(tx.externalPaymentId),
csvEscape(ev),
csvEscape(em),
].join(','),
);
}
return h
.response(`${lines.join('\n')}\n`)
.type('text/csv; charset=utf-8')
.header('Content-Disposition', 'attachment; filename="payment-transactions.csv"');
};
const createOrder = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const { amountSubtotal, currency, eventId, participantId, idempotencyKey, formId } = request.payload;
try {
const result = await createPaymentOrderCore({
organizationId: orgId,
eventId: eventId || null,
participantId: participantId || null,
amountSubtotalMinor: amountSubtotal,
currency,
idempotencyKey,
registrationContext: formId ? { formId } : null,
});
return h
.response(
success({
keyId: result.keyId,
orderId: result.orderId,
amount: result.amount,
currency: result.currency,
transactionId: result.transaction._id.toString(),
checkoutSource: result.checkoutSource,
commissionAmount: result.commissionAmount,
amountSubtotal: result.amountSubtotal,
amountGross: result.amountGross,
amountNet: result.amountNet,
}),
)
.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 === 'IDEMPOTENCY_MISMATCH' || e.code === 'INVALID_AMOUNT' || e.code === 'RAZORPAY_SDK_MISSING') {
return h.response(error('VALIDATION_ERROR', e.message)).code(e.code === 'GATEWAY_NOT_IMPLEMENTED' ? 501 : 400);
}
throw e;
}
};
const verify = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const { orderId, paymentId, signature } = request.payload;
const out = await verifyPaymentCore({
orderId,
paymentId,
signature,
organizationId: orgId,
});
if (out.error) {
const code = out.error === 'NOT_FOUND' ? 404 : 400;
return h.response(error(out.error, out.message)).code(code);
}
return h.response(success({ verified: true, transaction: out.transaction }));
};
const publicCreateOrder = async (request, h) => {
const { eventId } = request.params;
const event = await Event.findById(eventId).select('organization');
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const orgId = event.organization;
const {
amountSubtotal,
currency,
participantId,
guestToken,
email,
idempotencyKey,
} = request.payload;
let participant = null;
if (participantId) {
participant = await Participant.findOne({
_id: participantId,
tenantOrganization: orgId,
});
} else if (guestToken && email) {
const q = participantMembership.membershipGuestQuery(orgId, eventId, guestToken);
participant = await Participant.findOne(q);
const em = String(email).trim().toLowerCase();
if (participant && participant.email && participant.email.toLowerCase() !== em) {
participant = null;
}
}
if (!participant || !participantMembership.participantInEvent(participant, eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid participant for this event')).code(400);
}
try {
const result = await createPaymentOrderCore({
organizationId: orgId,
eventId,
participantId: participant._id,
amountSubtotalMinor: amountSubtotal,
currency,
idempotencyKey,
});
return h
.response(
success({
keyId: result.keyId,
orderId: result.orderId,
amount: result.amount,
currency: result.currency,
transactionId: result.transaction._id.toString(),
checkoutSource: result.checkoutSource,
commissionAmount: result.commissionAmount,
amountSubtotal: result.amountSubtotal,
amountGross: result.amountGross,
amountNet: result.amountNet,
}),
)
.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 === 'IDEMPOTENCY_MISMATCH' || e.code === 'INVALID_AMOUNT' || e.code === 'RAZORPAY_SDK_MISSING') {
return h.response(error('VALIDATION_ERROR', e.message)).code(e.code === 'GATEWAY_NOT_IMPLEMENTED' ? 501 : 400);
}
throw e;
}
};
const publicVerify = async (request, h) => {
const { eventId } = request.params;
const event = await Event.findById(eventId).select('organization');
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const orgId = event.organization;
const { orderId, paymentId, signature } = request.payload;
const out = await verifyPaymentCore({
orderId,
paymentId,
signature,
organizationId: orgId,
});
if (out.error) {
const code = out.error === 'NOT_FOUND' ? 404 : 400;
return h.response(error(out.error, out.message)).code(code);
}
return h.response(success({ verified: true, transaction: out.transaction }));
};
const razorpayWebhook = async (request, h) => {
const raw = request.payload;
if (!Buffer.isBuffer(raw)) {
return h.response(error('VALIDATION_ERROR', 'Invalid body')).code(400).takeover();
}
let body;
try {
body = JSON.parse(raw.toString('utf8'));
} catch {
return h.response(error('VALIDATION_ERROR', 'Invalid JSON')).code(400).takeover();
}
const event = body.event;
const paymentEntity = body.payload?.payment?.entity;
const orderId = paymentEntity?.order_id;
const paymentId = paymentEntity?.id;
if (!orderId) return h.response({ ok: true, ignored: true });
const tx = await PaymentTransaction.findOne({ externalOrderId: orderId });
if (!tx) return h.response({ ok: true, ignored: true });
let ctx;
try {
ctx = await resolveForPaymentTransaction(tx);
} catch {
return h.response(error('SERVER_ERROR', 'Cannot resolve payment context')).code(500).takeover();
}
const sig = request.headers['x-razorpay-signature'];
if (!razorpayAdapter.verifyWebhookSignature(raw, sig, ctx.webhookSecret || ctx.keySecret)) {
return h.response(error('UNAUTHORIZED', 'Invalid webhook signature')).code(401).takeover();
}
if (tx.status === 'paid') return h.response({ ok: true, duplicate: true });
if (event === 'payment.captured' || event === 'order.paid') {
if (paymentId) await applyPaidStatus(tx, paymentId);
return h.response({ ok: true });
}
return h.response({ ok: true, ignored: event });
};
const getTransaction = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('NOT_FOUND', 'Transaction not found')).code(404);
const tx = await PaymentTransaction.findOne({
organization: orgId,
_id: request.params.id,
})
.populate('event', 'name')
.populate('participant', 'email firstName lastName');
if (!tx) return h.response(error('NOT_FOUND', 'Transaction not found')).code(404);
return h.response(success(tx));
};
module.exports = {
listTransactions,
exportTransactions,
createOrder,
verify,
publicCreateOrder,
publicVerify,
razorpayWebhook,
getTransaction,
};

View File

@ -0,0 +1,184 @@
const OrganizationPaymentProfile = require('../models/OrganizationPaymentProfile');
const { success, error } = require('../utils/response');
const { encryptObject, decryptObject } = require('../utils/paymentCrypto');
const {
getOrCreateProfile,
getProfileForApi,
} = require('../services/payments/organizationPaymentProfileService');
const { publicGatewayShape } = require('../services/payments/maskCredentials');
const { GATEWAY_PROVIDERS } = require('../models/OrganizationPaymentProfile');
function enrichGatewayRead(base, g) {
try {
const cred = decryptObject(g.credentialsEnc);
base.credentialsPublic = publicGatewayShape(g.provider, cred);
} catch {
base.credentialsPublic = {};
}
}
function assertSuperAdmin(user) {
return user.role === 'super_admin';
}
const getOrgPaymentProfile = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(success(null));
const plain = await getProfileForApi(orgId, enrichGatewayRead);
return h.response(success(plain));
};
const updateOrgPaymentProfile = async (request, h) => {
if (!assertSuperAdmin(request.auth.credentials.user)) {
return h.response(error('FORBIDDEN', 'Only super administrators can update payment profile')).code(403);
}
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const profile = await getOrCreateProfile(orgId);
const p = request.payload;
if (p.checkoutMode != null) {
if (!['platform_razorpay', 'custom'].includes(p.checkoutMode)) {
return h.response(error('VALIDATION_ERROR', 'Invalid checkoutMode')).code(400);
}
profile.checkoutMode = p.checkoutMode;
}
if (p.platformCommissionBps !== undefined) {
profile.platformCommissionBps = p.platformCommissionBps == null ? null : Number(p.platformCommissionBps);
}
if (p.defaultCurrency != null) profile.defaultCurrency = String(p.defaultCurrency).toUpperCase();
if (p.taxRate !== undefined) profile.taxRate = p.taxRate;
if (p.taxType != null) profile.taxType = p.taxType;
await profile.save();
const plain = await getProfileForApi(orgId, enrichGatewayRead);
return h.response(success(plain));
};
function mergeCredentials(provider, existingEnc, incoming) {
let cur = {};
try {
if (existingEnc) cur = decryptObject(existingEnc);
} catch {
cur = {};
}
const next = { ...cur };
if (incoming && typeof incoming === 'object') {
Object.keys(incoming).forEach((k) => {
const v = incoming[k];
if (v != null && String(v).trim() !== '') next[k] = typeof v === 'string' ? v.trim() : v;
});
}
if (provider !== 'razorpay') return encryptObject(next);
if (!next.keyId || !next.keySecret) {
const err = new Error('Razorpay requires keyId and keySecret');
err.code = 'BAD_CREDENTIALS';
throw err;
}
return encryptObject(next);
}
const addGateway = async (request, h) => {
if (!assertSuperAdmin(request.auth.credentials.user)) {
return h.response(error('FORBIDDEN', 'Only super administrators can add payment gateways')).code(403);
}
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const { provider, label, credentials, isPrimary, testMode, isActive } = request.payload;
if (!GATEWAY_PROVIDERS.includes(provider)) {
return h.response(error('VALIDATION_ERROR', 'Invalid provider')).code(400);
}
const profile = await getOrCreateProfile(orgId);
try {
const credentialsEnc = mergeCredentials(provider, '', credentials);
if (isPrimary) {
profile.gateways.forEach((g) => {
g.isPrimary = false;
});
}
profile.gateways.push({
provider,
label: label || provider,
isActive: isActive !== false,
isPrimary: Boolean(isPrimary),
testMode: testMode !== false,
credentialsEnc,
});
await profile.save();
} catch (e) {
if (e.code === 'BAD_CREDENTIALS') {
return h.response(error('VALIDATION_ERROR', e.message)).code(400);
}
throw e;
}
const plain = await getProfileForApi(orgId, enrichGatewayRead);
return h.response(success(plain, 'Gateway added', 201)).code(201);
};
const patchGateway = async (request, h) => {
if (!assertSuperAdmin(request.auth.credentials.user)) {
return h.response(error('FORBIDDEN', 'Only super administrators can update payment gateways')).code(403);
}
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const { id } = request.params;
const profile = await getOrCreateProfile(orgId);
const g = profile.gateways.id(id);
if (!g) return h.response(error('NOT_FOUND', 'Gateway not found')).code(404);
const p = request.payload;
if (p.label != null) g.label = p.label;
if (p.isActive != null) g.isActive = Boolean(p.isActive);
if (p.testMode != null) g.testMode = Boolean(p.testMode);
if (p.isPrimary === true) {
profile.gateways.forEach((gw) => {
gw.isPrimary = String(gw._id) === String(id);
});
}
if (p.credentials && typeof p.credentials === 'object') {
try {
g.credentialsEnc = mergeCredentials(g.provider, g.credentialsEnc, p.credentials);
} catch (e) {
if (e.code === 'BAD_CREDENTIALS') {
return h.response(error('VALIDATION_ERROR', e.message)).code(400);
}
throw e;
}
}
await profile.save();
const plain = await getProfileForApi(orgId, enrichGatewayRead);
return h.response(success(plain));
};
const deleteGateway = async (request, h) => {
if (!assertSuperAdmin(request.auth.credentials.user)) {
return h.response(error('FORBIDDEN', 'Only super administrators can remove payment gateways')).code(403);
}
const user = request.auth.credentials.user;
const orgId = user.organization?._id || user.organization;
if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400);
const { id } = request.params;
const profile = await getOrCreateProfile(orgId);
const g = profile.gateways.id(id);
if (!g) return h.response(error('NOT_FOUND', 'Gateway not found')).code(404);
g.deleteOne();
await profile.save();
const plain = await getProfileForApi(orgId, enrichGatewayRead);
return h.response(success(plain));
};
module.exports = {
getOrgPaymentProfile,
updateOrgPaymentProfile,
addGateway,
patchGateway,
deleteGateway,
};

View File

@ -0,0 +1,66 @@
const PlatformPaymentConfig = require('../models/PlatformPaymentConfig');
const { success, error } = require('../utils/response');
const { encryptObject, encryptString, decryptObject, decryptString } = require('../utils/paymentCrypto');
const { maskKeyId } = require('../services/payments/maskCredentials');
const getPlatformPaymentConfig = async (request, h) => {
if (request.auth.credentials.user.role !== 'super_admin') {
return h.response(error('FORBIDDEN', 'Only super administrators can view platform payment settings')).code(403);
}
let doc = await PlatformPaymentConfig.findOne({ key: 'default' });
if (!doc) {
doc = await PlatformPaymentConfig.create({ key: 'default' });
}
const out = doc.toObject();
const webhookConfigured = Boolean(doc.webhookSecretEnc && String(doc.webhookSecretEnc).length > 4);
let cred = {};
try {
if (out.razorpayCredentialsEnc) cred = decryptObject(out.razorpayCredentialsEnc);
} catch {
cred = {};
}
delete out.razorpayCredentialsEnc;
delete out.webhookSecretEnc;
out.razorpayKeyIdMasked = cred.keyId ? maskKeyId(cred.keyId) : '';
out.webhookConfigured = webhookConfigured;
return h.response(success(out));
};
const updatePlatformPaymentConfig = async (request, h) => {
if (request.auth.credentials.user.role !== 'super_admin') {
return h.response(error('FORBIDDEN', 'Only super administrators can update platform payment settings')).code(403);
}
let doc = await PlatformPaymentConfig.findOne({ key: 'default' });
if (!doc) doc = await PlatformPaymentConfig.create({ key: 'default' });
const p = request.payload;
if (p.enabled != null) doc.enabled = Boolean(p.enabled);
if (p.testMode != null) doc.testMode = Boolean(p.testMode);
if (p.defaultCommissionBps != null) doc.defaultCommissionBps = Number(p.defaultCommissionBps);
if (p.razorpayKeyId != null || p.razorpayKeySecret != null) {
let cur = {};
try {
if (doc.razorpayCredentialsEnc) cur = decryptObject(doc.razorpayCredentialsEnc);
} catch {
cur = {};
}
const next = { ...cur };
if (p.razorpayKeyId != null) next.keyId = String(p.razorpayKeyId).trim();
if (p.razorpayKeySecret != null && String(p.razorpayKeySecret).length > 0) {
next.keySecret = String(p.razorpayKeySecret).trim();
}
if (next.keyId && next.keySecret) {
doc.razorpayCredentialsEnc = encryptObject(next);
}
}
if (p.webhookSecret != null && String(p.webhookSecret).trim().length > 0) {
doc.webhookSecretEnc = encryptString(String(p.webhookSecret).trim());
}
await doc.save();
return getPlatformPaymentConfig(request, h);
};
module.exports = { getPlatformPaymentConfig, updatePlatformPaymentConfig };

View File

@ -0,0 +1,130 @@
const Question = require('../models/Question');
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const {
syncSessionQuestionToFirebase,
removeSessionQuestionFromFirebase,
} = require('../utils/sessionQuestionFirebaseSync');
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 questions = await Question.find({ event: eventId })
.populate('participant user answers.answeredBy')
.sort({ createdAt: -1 })
.lean();
return h.response(success({ questions }));
};
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 user = request.auth.credentials.user;
const payload = { ...request.payload, event: eventId };
if (user) {
payload.user = user._id;
}
const question = await Question.create(payload);
if (payload.session) {
const lean = await Question.findById(question._id).populate('participant').lean();
await syncSessionQuestionToFirebase(lean);
}
return h.response(success(question, 'Question posted', 201)).code(201);
};
const get = async (request, h) => {
const question = await Question.findById(request.params.id).populate('event participant user answers.answeredBy');
if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404);
const event = await Event.findById(question.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);
}
return h.response(success(question));
};
const update = async (request, h) => {
const question = await Question.findById(request.params.id);
if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404);
const event = await Event.findById(question.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);
}
Object.assign(question, request.payload);
await question.save();
if (question.session) {
const lean = await Question.findById(question._id).populate('participant').lean();
await syncSessionQuestionToFirebase(lean);
}
return h.response(success(question));
};
const remove = async (request, h) => {
const question = await Question.findById(request.params.id);
if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404);
const event = await Event.findById(question.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 sessionId = question.session ? String(question.session) : null;
const qid = String(question._id);
await question.deleteOne();
if (sessionId) await removeSessionQuestionFromFirebase(sessionId, qid);
return h.response(success(null, 'Question deleted')).code(200);
};
const upvote = async (request, h) => {
const question = await Question.findById(request.params.id);
if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404);
const userId = request.auth.credentials.id;
if (question.upvotedBy.some(id => id.toString() === userId)) {
question.upvotedBy = question.upvotedBy.filter(id => id.toString() !== userId);
question.upvotes = Math.max(0, question.upvotes - 1);
} else {
question.upvotedBy.push(userId);
question.upvotes += 1;
}
await question.save();
if (question.session) {
const lean = await Question.findById(question._id).populate('participant').lean();
await syncSessionQuestionToFirebase(lean);
}
return h.response(success(question));
};
const answer = async (request, h) => {
const question = await Question.findById(request.params.id);
if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404);
const event = await Event.findById(question.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);
}
question.answers.push({
answeredBy: user._id,
answer: request.payload.answer,
isOfficial: request.payload.isOfficial || false,
});
question.status = 'answered';
await question.save();
if (question.session) {
const lean = await Question.findById(question._id).populate('participant').lean();
await syncSessionQuestionToFirebase(lean);
}
return h.response(success(question, 'Answer added', 201)).code(201);
};
const sessionQuestions = async (request, h) => {
const questions = await Question.find({ session: request.params.sessionId }).populate('participant user').lean();
return h.response(success({ questions }));
};
module.exports = { list, create, get, update, remove, upvote, answer, sessionQuestions };

View File

@ -0,0 +1,138 @@
const QuizPoll = require('../models/QuizPoll');
const QuizPollResponse = require('../models/QuizPollResponse');
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const { syncQuizPollToFirebase } = require('../utils/quizPollFirebaseSync');
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 items = await QuizPoll.find({ event: eventId, deletedAt: null }).sort({ createdAt: -1 }).lean();
return h.response(success({ quizPolls: items }));
};
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 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 item = await QuizPoll.create({ ...request.payload, event: eventId });
await syncQuizPollToFirebase(item);
return h.response(success(item, 'Quiz/Poll created', 201)).code(201);
};
const get = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null }).populate('event session').lean();
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
const event = await Event.findById(item.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);
}
return h.response(success(item));
};
const update = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
const event = await Event.findById(item.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 previousSessionId = item.session ? String(item.session) : null;
Object.assign(item, request.payload);
await item.save();
await syncQuizPollToFirebase(item, previousSessionId);
return h.response(success(item));
};
const remove = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
const event = await Event.findById(item.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);
}
item.deletedAt = new Date();
await item.save();
await syncQuizPollToFirebase(item);
return h.response(success(null, 'Quiz/Poll deleted')).code(200);
};
const publish = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
item.settings = item.settings || {};
item.settings.isLive = true;
await item.save();
await syncQuizPollToFirebase(item);
return h.response(success(item, 'Published'));
};
const close = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
item.settings = item.settings || {};
item.settings.isLive = false;
await item.save();
await syncQuizPollToFirebase(item);
return h.response(success(item, 'Closed'));
};
const results = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
const event = await Event.findById(item.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 responses = await QuizPollResponse.find({ quizPoll: item._id }).populate('participant user').lean();
const summary = responses.reduce((acc, r) => {
acc.totalResponses = (acc.totalResponses || 0) + 1;
acc.avgScore = ((acc.avgScore || 0) * (acc.totalResponses - 1) + (r.totalScore || 0)) / acc.totalResponses;
return acc;
}, {});
return h.response(success({ quizPoll: item, responses, summary }));
};
const submitResponse = async (request, h) => {
const { quizPollId } = request.params;
const quizPoll = await QuizPoll.findOne({ _id: quizPollId, deletedAt: null });
if (!quizPoll) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
const user = request.auth.credentials.user;
const response = await QuizPollResponse.create({
quizPoll: quizPollId,
participant: request.payload.participantId,
user: user?._id,
responses: request.payload.responses,
totalScore: request.payload.totalScore,
timeSpent: request.payload.timeSpent,
});
return h.response(success(response, 'Response submitted', 201)).code(201);
};
const listResponses = async (request, h) => {
const responses = await QuizPollResponse.find({ quizPoll: request.params.quizPollId }).populate('participant user').lean();
return h.response(success({ responses }));
};
const sessionQuizPolls = async (request, h) => {
const items = await QuizPoll.find({ session: request.params.sessionId, deletedAt: null }).lean();
return h.response(success({ quizPolls: items }));
};
module.exports = {
list, create, get, update, remove, publish, close, results,
submitResponse, listResponses, sessionQuizPolls,
};

View File

@ -0,0 +1,116 @@
const RewardPoint = require('../models/RewardPoint');
const Event = require('../models/Event');
const Participant = require('../models/Participant');
const { success, error } = require('../utils/response');
const { REWARD_SOURCES } = require('../config/constants');
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 points = await RewardPoint.find({ event: eventId }).populate('participant createdBy').sort({ createdAt: -1 }).lean();
return h.response(success({ rewardPoints: points }));
};
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 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 reward = await RewardPoint.create({
...request.payload,
event: eventId,
createdBy: user._id,
});
return h.response(success(reward, 'Reward points added', 201)).code(201);
};
const get = async (request, h) => {
const reward = await RewardPoint.findById(request.params.id).populate('participant event createdBy');
if (!reward) return h.response(error('NOT_FOUND', 'Reward point record not found')).code(404);
const event = await Event.findById(reward.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);
}
return h.response(success(reward));
};
const update = async (request, h) => {
const reward = await RewardPoint.findById(request.params.id);
if (!reward) return h.response(error('NOT_FOUND', 'Reward point record not found')).code(404);
const event = await Event.findById(reward.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);
}
Object.assign(reward, request.payload);
await reward.save();
return h.response(success(reward));
};
const remove = async (request, h) => {
const reward = await RewardPoint.findById(request.params.id);
if (!reward) return h.response(error('NOT_FOUND', 'Reward point record not found')).code(404);
const event = await Event.findById(reward.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);
}
await reward.deleteOne();
return h.response(success(null, 'Reward points deleted')).code(200);
};
const byParticipant = async (request, h) => {
const points = await RewardPoint.find({ participant: request.params.participantId }).populate('event').lean();
const total = points.filter(p => p.type === 'earned').reduce((s, p) => s + (p.points || 0), 0)
- points.filter(p => p.type === 'redeemed').reduce((s, p) => s + (p.points || 0), 0);
return h.response(success({ rewardPoints: points, total }));
};
const leaderboard = 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 aggregated = await RewardPoint.aggregate([
{ $match: { event: event._id, type: 'earned' } },
{ $group: { _id: '$participant', total: { $sum: '$points' } } },
{ $sort: { total: -1 } },
{ $limit: 100 },
{ $lookup: { from: 'participants', localField: '_id', foreignField: '_id', as: 'p' } },
{ $unwind: '$p' },
{ $project: { participantId: '$_id', total: 1, firstName: '$p.firstName', lastName: '$p.lastName', email: '$p.email' } },
]);
return h.response(success({ leaderboard: aggregated }));
};
const bulk = 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 created = await RewardPoint.insertMany(
(request.payload.entries || []).map(e => ({
...e,
event: eventId,
createdBy: user._id,
}))
);
return h.response(success({ created: created.length }, 'Bulk reward points added', 201)).code(201);
};
module.exports = { list, create, get, update, remove, byParticipant, leaderboard, bulk };

View File

@ -0,0 +1,102 @@
const Session = require('../models/Session');
const Event = require('../models/Event');
const Track = require('../models/Track');
const { success, error } = require('../utils/response');
function normalizeSessionPayload(payload, eventId) {
const body = { ...payload };
if (!body.track || String(body.track).trim() === '') {
delete body.track;
}
return body;
}
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 sessions = await Session.find({ event: eventId }).populate('speakers moderator track').sort({ startTime: 1 }).lean();
return h.response(success({ sessions }));
};
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 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 body = normalizeSessionPayload(request.payload, eventId);
if (body.track) {
const tr = await Track.findOne({ _id: body.track, event: eventId });
if (!tr) return h.response(error('VALIDATION_ERROR', 'Track does not belong to this event')).code(400);
}
const session = await Session.create({ ...body, event: eventId });
const populated = await Session.findById(session._id).populate('speakers moderator track').lean();
return h.response(success(populated, 'Session created', 201)).code(201);
};
const get = async (request, h) => {
const session = await Session.findById(request.params.id).populate('speakers moderator event');
if (!session) return h.response(error('NOT_FOUND', 'Session not found')).code(404);
const event = await Event.findById(session.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);
}
return h.response(success(session));
};
const update = async (request, h) => {
const session = await Session.findById(request.params.id);
if (!session) return h.response(error('NOT_FOUND', 'Session not found')).code(404);
const event = await Event.findById(session.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 raw = { ...request.payload };
const trackVal = raw.track && String(raw.track).trim() ? raw.track : null;
if (trackVal) {
const tr = await Track.findOne({ _id: trackVal, event: session.event });
if (!tr) return h.response(error('VALIDATION_ERROR', 'Track does not belong to this event')).code(400);
}
delete raw.track;
Object.assign(session, raw);
session.track = trackVal;
await session.save();
const populated = await Session.findById(session._id).populate('speakers moderator track').lean();
return h.response(success(populated));
};
const remove = async (request, h) => {
const session = await Session.findById(request.params.id);
if (!session) return h.response(error('NOT_FOUND', 'Session not found')).code(404);
const event = await Event.findById(session.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);
}
await session.deleteOne();
return h.response(success(null, 'Session deleted')).code(200);
};
const updateStatus = async (request, h) => {
const session = await Session.findById(request.params.id);
if (!session) return h.response(error('NOT_FOUND', 'Session not found')).code(404);
const event = await Event.findById(session.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);
}
session.status = request.payload.status;
await session.save();
return h.response(success(session));
};
module.exports = { list, create, get, update, remove, updateStatus };

View File

@ -0,0 +1,132 @@
const mongoose = require('mongoose');
const Speaker = require('../models/Speaker');
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
function userOrgId(user) {
return user.organization?._id || user.organization;
}
function normalizeSpeakerPayload(body) {
if (!body || typeof body !== 'object') return body;
const p = { ...body };
if (p.profileVisibility != null && String(p.profileVisibility).trim() !== '') {
const v = String(p.profileVisibility).toLowerCase();
if (v === 'public' || v === 'private') p.profileVisibility = v;
else delete p.profileVisibility;
} else {
delete p.profileVisibility;
}
if (p.logistics && typeof p.logistics === 'object') {
const L = { ...p.logistics };
if (L.honorariumAmount != null && L.honorariumAmount !== '') {
L.honorariumAmount = String(L.honorariumAmount);
}
p.logistics = L;
}
return p;
}
const list = async (request, h) => {
const user = request.auth.credentials.user;
const filter = {};
if (user.role === 'super_admin') {
const orgIdRaw = request.query.organization || userOrgId(user);
if (!orgIdRaw) {
return h.response(error('VALIDATION_ERROR', 'Organization is required (query ?organization= or assign user to an org)')).code(400);
}
if (!mongoose.Types.ObjectId.isValid(String(orgIdRaw))) {
return h.response(error('VALIDATION_ERROR', 'Invalid organization id')).code(400);
}
filter.organization = orgIdRaw;
} else {
const oid = userOrgId(user);
if (!oid) {
return h.response(error('VALIDATION_ERROR', 'User has no organization')).code(400);
}
filter.organization = oid;
}
const { page = 1, limit = 20 } = request.query;
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const [speakers, total] = await Promise.all([
Speaker.find(filter).skip(skip).limit(parseInt(limit, 10)).lean(),
Speaker.countDocuments(filter),
]);
return h.response(success({ speakers, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }))
.header('X-Total-Count', total);
};
const create = async (request, h) => {
const user = request.auth.credentials.user;
const orgIdRaw =
user.role === 'super_admin' && request.payload.organization
? request.payload.organization
: userOrgId(user);
if (!orgIdRaw) {
return h.response(error('VALIDATION_ERROR', 'Organization required to create a speaker')).code(400);
}
if (!mongoose.Types.ObjectId.isValid(String(orgIdRaw))) {
return h.response(error('VALIDATION_ERROR', 'Invalid organization id')).code(400);
}
const orgId = new mongoose.Types.ObjectId(String(orgIdRaw));
const payload = normalizeSpeakerPayload(request.payload);
delete payload.organization;
const speaker = await Speaker.create({ ...payload, organization: orgId });
return h.response(success(speaker, 'Speaker added', 201)).code(201);
};
const get = async (request, h) => {
const speaker = await Speaker.findById(request.params.id).populate('sessions');
if (!speaker) return h.response(error('NOT_FOUND', 'Speaker not found')).code(404);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && speaker.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
return h.response(success(speaker));
};
const update = async (request, h) => {
const speaker = await Speaker.findById(request.params.id);
if (!speaker) return h.response(error('NOT_FOUND', 'Speaker not found')).code(404);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && speaker.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const payload = normalizeSpeakerPayload(request.payload);
if (user.role === 'super_admin' && payload.organization != null && String(payload.organization).trim() !== '') {
if (!mongoose.Types.ObjectId.isValid(String(payload.organization))) {
return h.response(error('VALIDATION_ERROR', 'Invalid organization id')).code(400);
}
speaker.organization = payload.organization;
}
delete payload.organization;
Object.assign(speaker, payload);
if (!speaker.organization) {
return h.response(error('VALIDATION_ERROR', 'Speaker must belong to an organization')).code(400);
}
await speaker.save();
return h.response(success(speaker));
};
const remove = async (request, h) => {
const speaker = await Speaker.findById(request.params.id);
if (!speaker) return h.response(error('NOT_FOUND', 'Speaker not found')).code(404);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && speaker.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
await speaker.deleteOne();
return h.response(success(null, 'Speaker removed')).code(200);
};
const byEvent = async (request, h) => {
const event = await Event.findById(request.params.eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const Session = require('../models/Session');
const sessions = await Session.find({ event: event._id }).populate('speakers');
const speakerIds = [...new Set(sessions.flatMap(s => (s.speakers || []).map(sp => sp._id || sp)))];
const speakers = await Speaker.find({ _id: { $in: speakerIds } }).lean();
return h.response(success({ speakers }));
};
module.exports = { list, create, get, update, remove, byEvent };

View File

@ -0,0 +1,12 @@
const { success } = require('../utils/response');
const { listFeatures, listPlans } = require('../config/subscriptionCatalog');
const catalog = async (request, h) =>
h.response(
success({
features: listFeatures(),
plans: listPlans(),
}),
);
module.exports = { catalog };

View File

@ -0,0 +1,55 @@
const Track = require('../models/Track');
const Session = require('../models/Session');
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
async function assertEventAccess(eventId, user) {
const event = await Event.findById(eventId);
if (!event) return { err: 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 { err: h => h.response(error('FORBIDDEN', 'Access denied')).code(403) };
}
return { event };
}
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 tracks = await Track.find({ event: eventId }).sort({ sortOrder: 1, name: 1 }).lean();
return h.response(success({ tracks }));
};
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 track = await Track.create({ ...request.payload, event: eventId });
return h.response(success(track, 'Track created', 201)).code(201);
};
const update = async (request, h) => {
const track = await Track.findById(request.params.id);
if (!track) return h.response(error('NOT_FOUND', 'Track not found')).code(404);
const user = request.auth.credentials.user;
const r = await assertEventAccess(track.event.toString(), user);
if (r.err) return r.err(h);
Object.assign(track, request.payload);
await track.save();
return h.response(success(track));
};
const remove = async (request, h) => {
const track = await Track.findById(request.params.id);
if (!track) return h.response(error('NOT_FOUND', 'Track not found')).code(404);
const user = request.auth.credentials.user;
const r = await assertEventAccess(track.event.toString(), user);
if (r.err) return r.err(h);
await Session.updateMany({ track: track._id }, { $unset: { track: 1 } });
await track.deleteOne();
return h.response(success(null, 'Track deleted')).code(200);
};
module.exports = { list, create, update, remove };

View File

@ -0,0 +1,793 @@
const mongoose = require('mongoose');
const Session = require('../models/Session');
const Track = require('../models/Track');
const Event = require('../models/Event');
const EventMicrosite = require('../models/EventMicrosite');
const Question = require('../models/Question');
const RewardPoint = require('../models/RewardPoint');
const QuizPoll = require('../models/QuizPoll');
const QuizPollResponse = require('../models/QuizPollResponse');
const SessionBookmark = require('../models/SessionBookmark');
const { success, error } = require('../utils/response');
const { sendMicrositeContactEmail } = require('../utils/mail');
const { syncSessionQuestionToFirebase } = require('../utils/sessionQuestionFirebaseSync');
const { participantInEvent } = require('../utils/participantMembership');
/** All event fields for JSON except website, timestamps; adds logoUrl from website.logo || image. Never expose inviteCode. */
function publicEventLoginPayload(doc) {
if (!doc) return null;
const {
website,
createdAt,
updatedAt,
_id,
organization,
settings,
...rest
} = doc;
const logoUrl =
(doc.image && String(doc.image).trim()) ||
(website?.logo && String(website.logo).trim()) ||
'';
let safeSettings = settings;
if (settings && typeof settings === 'object' && !Array.isArray(settings)) {
safeSettings = { ...settings };
delete safeSettings.inviteCode;
}
const out = {
_id: _id != null ? String(_id) : _id,
organization: organization != null ? String(organization) : organization,
...rest,
...(safeSettings !== undefined ? { settings: safeSettings } : {}),
logoUrl,
};
if (Object.prototype.hasOwnProperty.call(out, '__v')) delete out.__v;
return out;
}
function normalizeFolderSlug(input) {
return String(input || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '');
}
function normalizeJoinCode(input) {
return String(input || '').trim().toUpperCase();
}
async function resolveCode(request, h) {
const raw = request.payload?.code;
const code = String(raw ?? '').trim();
if (!code) {
return h.response(error('VALIDATION_ERROR', 'Code is required')).code(400);
}
const sessionCode = normalizeJoinCode(code);
if (sessionCode.length > 0) {
const session = await Session.findOne({ joinCode: sessionCode }).lean();
if (session) {
const event = await Event.findById(session.event).lean();
if (!event) {
return h.response(error('NOT_FOUND', 'Invalid code')).code(404);
}
return h.response(
success({
type: 'session',
sessionId: session._id.toString(),
eventId: session.event.toString(),
}),
);
}
const track = await Track.findOne({ joinCode: sessionCode }).lean();
if (track) {
const ev = await Event.findById(track.event).lean();
if (!ev) {
return h.response(error('NOT_FOUND', 'Invalid code')).code(404);
}
return h.response(
success({
type: 'track',
trackId: track._id.toString(),
eventId: track.event.toString(),
}),
);
}
}
const folder = normalizeFolderSlug(code);
if (!folder) {
return h.response(error('NOT_FOUND', 'Invalid code')).code(404);
}
const microsite = await EventMicrosite.findOne({ publishFolder: folder }).select('event').lean();
if (microsite && microsite.event) {
return h.response(
success({
type: 'event',
eventId: microsite.event.toString(),
micrositeId: microsite._id.toString(),
}),
);
}
const event = await Event.findOne({ 'website.folder': folder }).lean();
if (!event) {
return h.response(error('NOT_FOUND', 'Invalid code')).code(404);
}
return h.response(
success({
type: 'event',
eventId: event._id.toString(),
}),
);
}
async function loginPreview(request, h) {
const { eventId } = request.params;
if (!eventId || !mongoose.Types.ObjectId.isValid(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);
}
return h.response(success(publicEventLoginPayload(event)));
}
/** GET …/visitor/events/{eventId}/tracks — public; tracks for agenda links */
async function listVisitorEventTracks(request, h) {
const { eventId } = request.params;
if (!eventId || !mongoose.Types.ObjectId.isValid(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);
}
const tracks = await Track.find({ event: eventId }).sort({ name: 1 }).lean();
return h.response(success({ tracks: tracks.map((t) => publicTrackPreview(t)) }));
}
/** GET …/visitor/events/{eventId}/agenda — public; all non-cancelled sessions for schedule UI */
async function listVisitorEventAgenda(request, h) {
const { eventId } = request.params;
if (!eventId || !mongoose.Types.ObjectId.isValid(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);
}
const sessions = await Session.find({
event: eventId,
status: { $ne: 'cancelled' },
})
.populate({
path: 'speakers',
populate: { path: 'organization', select: 'name' },
})
.populate({
path: 'moderator',
populate: { path: 'organization', select: 'name' },
})
.populate('track')
.sort({ startTime: 1 })
.lean();
return h.response(success({ sessions: sessions.map((s) => publicSessionPreview(s)) }));
}
/** GET …/visitor/events/{eventId}/quiz-polls — public; session-scoped items for deep links */
async function listVisitorEventQuizPolls(request, h) {
const { eventId } = request.params;
if (!eventId || !mongoose.Types.ObjectId.isValid(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);
}
const items = await QuizPoll.find({
event: eventId,
deletedAt: null,
session: { $exists: true, $ne: null },
$or: [{ 'settings.status': { $exists: false } }, { 'settings.status': 'active' }],
})
.select('title type session')
.sort({ title: 1 })
.lean();
const quizPolls = items
.filter((q) => q.session)
.map((q) => ({
_id: String(q._id),
title: q.title != null ? q.title : '',
type: q.type != null ? q.type : 'poll',
session: String(q.session),
}));
return h.response(success({ quizPolls }));
}
function publicSpeaker(doc) {
if (!doc || typeof doc !== 'object' || doc._id == null) return null;
const isPrivate = doc.profileVisibility === 'private';
const expertise = Array.isArray(doc.expertise)
? doc.expertise.map((e) => String(e || '').trim()).filter(Boolean)
: [];
const bioRaw = doc.bio != null ? String(doc.bio).trim() : '';
const out = {
_id: String(doc._id),
firstName: doc.firstName,
lastName: doc.lastName,
title: doc.title,
photo: doc.photo,
speakerRole: doc.speakerRole,
...(bioRaw ? { bio: bioRaw } : {}),
...(expertise.length ? { expertise } : {}),
};
if (!isPrivate) {
const orgFromRef =
doc.organization && typeof doc.organization === 'object' && doc.organization.name != null
? String(doc.organization.name).trim()
: '';
const orgFromField = doc.organizationName != null ? String(doc.organizationName).trim() : '';
const organizationName = orgFromField || orgFromRef;
if (organizationName) {
out.organizationName = organizationName;
}
const sl = doc.socialLinks && typeof doc.socialLinks === 'object' ? doc.socialLinks : {};
const socialLinks = {};
for (const key of ['linkedin', 'twitter', 'website']) {
const v = sl[key];
if (v != null && String(v).trim()) {
socialLinks[key] = String(v).trim();
}
}
if (Object.keys(socialLinks).length) {
out.socialLinks = socialLinks;
}
}
return out;
}
function publicSessionPreview(session) {
const speakers = (session.speakers || []).map(publicSpeaker).filter(Boolean);
const moderator = publicSpeaker(session.moderator);
let track = null;
if (session.track) {
const t = session.track;
if (t && typeof t === 'object' && t._id) {
track = {
_id: String(t._id),
name: t.name,
hall: t.hall,
trackType: t.trackType,
};
}
}
return {
_id: String(session._id),
event: String(session.event),
track,
title: session.title,
description: session.description,
type: session.type,
startTime: session.startTime,
endTime: session.endTime,
location: session.location,
capacity: session.capacity,
status: session.status,
joinCode: session.joinCode,
speakers,
moderator,
};
}
/** Public session details for Eventbuzz; session must belong to event_id query. */
async function sessionPreview(request, h) {
const { sessionId } = request.params;
const eventId = request.query.event_id;
if (!eventId || !mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(sessionId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event or session id')).code(400);
}
const session = await Session.findById(sessionId)
.populate({
path: 'speakers',
populate: { path: 'organization', select: 'name' },
})
.populate({
path: 'moderator',
populate: { path: 'organization', select: 'name' },
})
.populate('track')
.lean();
if (!session || String(session.event) !== String(eventId)) {
return h.response(error('NOT_FOUND', 'Session not found')).code(404);
}
return h.response(success(publicSessionPreview(session)));
}
function publicTrackPreview(track) {
return {
_id: String(track._id),
event: String(track.event),
name: track.name,
hall: track.hall,
trackType: track.trackType,
status: track.status,
joinCode: track.joinCode,
};
}
/** Public track details; track must belong to event_id query. */
async function trackPreview(request, h) {
const { trackId } = request.params;
const eventId = request.query.event_id;
if (!eventId || !mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(trackId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event or track id')).code(400);
}
const track = await Track.findById(trackId).lean();
if (!track || String(track.event) !== String(eventId)) {
return h.response(error('NOT_FOUND', 'Track not found')).code(404);
}
return h.response(success(publicTrackPreview(track)));
}
function visitorQuestionAuthor(q) {
if (q.isAnonymous) return 'Anonymous';
const p = q.participant;
if (!p || typeof p !== 'object') return 'Anonymous';
if (p.displayName && String(p.displayName).trim()) return String(p.displayName).trim();
const n = [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
if (n) return n;
if (p.email && String(p.email).trim()) return String(p.email).split('@')[0] || 'Guest';
return 'Guest';
}
function publicVisitorQuestion(q) {
return {
_id: String(q._id),
author: visitorQuestionAuthor(q),
text: q.question,
up: q.upvotes ?? 0,
down: q.downvotes ?? 0,
createdAt: q.createdAt,
};
}
/** GET …/visitor/sessions/{id}/questions?event_id= */
async function listSessionQuestions(request, h) {
const { sessionId } = request.params;
const eventId = request.query.event_id;
if (!mongoose.Types.ObjectId.isValid(sessionId) || !mongoose.Types.ObjectId.isValid(eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event or session id')).code(400);
}
const session = await Session.findById(sessionId).lean();
if (!session || String(session.event) !== String(eventId)) {
return h.response(error('NOT_FOUND', 'Session not found')).code(404);
}
const rows = await Question.find({ session: sessionId, event: eventId })
.populate('participant')
.sort({ createdAt: -1 })
.lean();
return h.response(success({ questions: rows.map(publicVisitorQuestion) }));
}
/** POST …/visitor/sessions/{id}/questions?event_id= (optional jwt-attendee) */
async function createSessionQuestion(request, h) {
const { sessionId } = request.params;
const eventId = request.query.event_id;
const { question: text, isAnonymous } = request.payload;
if (!mongoose.Types.ObjectId.isValid(sessionId) || !mongoose.Types.ObjectId.isValid(eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event or session id')).code(400);
}
const session = await Session.findById(sessionId).lean();
if (!session || String(session.event) !== String(eventId)) {
return h.response(error('NOT_FOUND', 'Session not found')).code(404);
}
let participantId;
const auth = request.auth;
if (auth.isAuthenticated && auth.credentials?.participant && !isAnonymous) {
const p = auth.credentials.participant;
if (participantInEvent(p, eventId)) {
participantId = p._id;
}
}
const doc = await Question.create({
event: eventId,
session: sessionId,
participant: participantId,
question: text,
isAnonymous: Boolean(isAnonymous),
});
const populated = await Question.findById(doc._id).populate('participant').lean();
await syncSessionQuestionToFirebase(populated);
return h.response(success({ question: publicVisitorQuestion(populated) }, 'Question submitted', 201)).code(201);
}
function applyAudienceVoteDoc(question, voterKey, direction) {
const raw = question.audienceVotes;
const votes = Array.isArray(raw) ? raw.map((v) => ({ key: String(v.key), dir: v.dir === 'down' ? 'down' : 'up' })) : [];
const idx = votes.findIndex((v) => v.key === voterKey);
const prev = idx >= 0 ? votes[idx].dir : null;
if (direction === 'up') {
if (prev === 'up') {
return;
}
if (prev === 'down') {
votes[idx].dir = 'up';
question.upvotes = (question.upvotes || 0) + 1;
question.downvotes = Math.max(0, (question.downvotes || 0) - 1);
} else {
votes.push({ key: voterKey, dir: 'up' });
question.upvotes = (question.upvotes || 0) + 1;
}
} else if (direction === 'down') {
if (prev === 'down') {
return;
}
if (prev === 'up') {
votes[idx].dir = 'down';
question.downvotes = (question.downvotes || 0) + 1;
question.upvotes = Math.max(0, (question.upvotes || 0) - 1);
} else {
votes.push({ key: voterKey, dir: 'down' });
question.downvotes = (question.downvotes || 0) + 1;
}
}
question.audienceVotes = votes;
}
function audienceYourVote(question, voterKey) {
const v = (question.audienceVotes || []).find((x) => String(x.key) === voterKey);
return v ? v.dir : null;
}
/** POST …/visitor/sessions/{sessionId}/questions/{questionId}/vote?event_id= */
async function voteSessionQuestion(request, h) {
const { sessionId, questionId } = request.params;
const eventId = request.query.event_id;
const { voterKey, direction } = request.payload;
if (
!mongoose.Types.ObjectId.isValid(sessionId) ||
!mongoose.Types.ObjectId.isValid(eventId) ||
!mongoose.Types.ObjectId.isValid(questionId)
) {
return h.response(error('VALIDATION_ERROR', 'Invalid ids')).code(400);
}
const session = await Session.findById(sessionId).lean();
if (!session || String(session.event) !== String(eventId)) {
return h.response(error('NOT_FOUND', 'Session not found')).code(404);
}
const question = await Question.findOne({
_id: questionId,
session: sessionId,
event: eventId,
});
if (!question) {
return h.response(error('NOT_FOUND', 'Question not found')).code(404);
}
const auth = request.auth;
let credParticipant = null;
if (auth && auth.isAuthenticated && auth.credentials && auth.credentials.participant) {
const p = auth.credentials.participant;
if (participantInEvent(p, eventId)) {
credParticipant = p;
}
}
const audienceKey = credParticipant
? String(credParticipant._id)
: String(voterKey).trim();
applyAudienceVoteDoc(question, audienceKey, direction);
await question.save();
const populated = await Question.findById(question._id).populate('participant').lean();
await syncSessionQuestionToFirebase(populated);
const yv = audienceYourVote(question, audienceKey);
return h.response(
success({
questionId: String(question._id),
up: question.upvotes ?? 0,
down: question.downvotes ?? 0,
yourVote: yv,
}),
);
}
/** POST …/visitor/events/{eventId}/quiz-polls/{quizPollId}/responses — attendee JWT */
async function submitVisitorQuizPollResponse(request, h) {
const { eventId, quizPollId } = request.params;
const participant = request.auth.credentials.participant;
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(quizPollId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
if (!participantInEvent(participant, eventId)) {
return h.response(error('FORBIDDEN', 'Event mismatch')).code(403);
}
const quizPoll = await QuizPoll.findOne({ _id: quizPollId, deletedAt: null }).lean();
if (!quizPoll) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
if (String(quizPoll.event) !== String(eventId)) {
return h.response(error('FORBIDDEN', 'This poll does not belong to this event')).code(403);
}
const settings = quizPoll.settings || {};
if (settings.allowMultipleAttempts === false) {
const existing = await QuizPollResponse.findOne({
quizPoll: quizPollId,
participant: participant._id,
}).lean();
if (existing) {
return h.response(error('CONFLICT', 'You have already submitted a response')).code(409);
}
}
const doc = await QuizPollResponse.create({
quizPoll: quizPollId,
participant: participant._id,
responses: request.payload.responses || [],
totalScore: request.payload.totalScore,
timeSpent: request.payload.timeSpent,
});
return h.response(success(doc, 'Response submitted', 201)).code(201);
}
function normAnswerVal(ans) {
if (ans == null) return '';
if (typeof ans === 'object' && ans !== null && typeof ans.toString === 'function') {
return String(ans.toString());
}
return String(ans);
}
/**
* Aggregate all stored responses for poll/survey questions (percent = share of respondents).
* @param {object} quizPoll lean doc
* @param {object[]} allResponses QuizPollResponse lean docs
*/
function buildVisitorPollResultsSummary(quizPoll, allResponses) {
const questions = quizPoll.questions || [];
const totalVoters = allResponses.length;
return questions.map((q, questionIndex) => {
const qType = String(q.type || 'single').toLowerCase();
const base = {
questionIndex,
questionId: q._id != null ? String(q._id) : '',
type: q.type || 'single',
question: q.question != null ? q.question : '',
totalResponses: totalVoters,
};
const countForOptionBuckets = (buckets) => {
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item) continue;
const id = normAnswerVal(item.answer);
const b = buckets.find((x) => x.optionId === id);
if (b) b.count += 1;
}
buckets.forEach((b) => {
b.percent = totalVoters > 0 ? Math.round((b.count / totalVoters) * 1000) / 10 : 0;
});
return buckets;
};
if (qType === 'text') {
let answered = 0;
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item) continue;
const t = normAnswerVal(item.answer).trim();
if (t.length > 0) answered += 1;
}
return {
...base,
kind: 'text',
answeredCount: answered,
percentAnswered: totalVoters > 0 ? Math.round((answered / totalVoters) * 1000) / 10 : 0,
};
}
if (qType === 'rating') {
const buckets = [1, 2, 3, 4, 5].map((n) => ({
optionId: String(n),
text: String(n),
count: 0,
percent: 0,
}));
countForOptionBuckets(buckets);
return { ...base, kind: 'rating', options: buckets };
}
if (qType === 'multiple') {
const opts = (q.options || []).map((o) => ({
optionId: o._id != null ? String(o._id) : '',
text: o.text != null ? o.text : '',
count: 0,
percent: 0,
}));
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item || !Array.isArray(item.answer)) continue;
for (const rawId of item.answer) {
const id = normAnswerVal(rawId);
const o = opts.find((x) => x.optionId === id);
if (o) o.count += 1;
}
}
opts.forEach((o) => {
o.percent = totalVoters > 0 ? Math.round((o.count / totalVoters) * 1000) / 10 : 0;
});
return { ...base, kind: 'multiple', options: opts };
}
const opts = (q.options || []).map((o) => ({
optionId: o._id != null ? String(o._id) : '',
text: o.text != null ? o.text : '',
count: 0,
percent: 0,
}));
countForOptionBuckets(opts);
return { ...base, kind: 'single', options: opts };
});
}
/** GET …/visitor/events/{eventId}/quiz-polls/{quizPollId}/results-summary — poll/survey aggregates; attendee JWT */
async function getVisitorQuizPollResultsSummary(request, h) {
const { eventId, quizPollId } = request.params;
const participant = request.auth.credentials.participant;
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(quizPollId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
if (!participantInEvent(participant, eventId)) {
return h.response(error('FORBIDDEN', 'Event mismatch')).code(403);
}
const quizPoll = await QuizPoll.findOne({ _id: quizPollId, deletedAt: null }).lean();
if (!quizPoll) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
if (String(quizPoll.event) !== String(eventId)) {
return h.response(error('FORBIDDEN', 'This poll does not belong to this event')).code(403);
}
const settings = quizPoll.settings || {};
if (settings.showResults === false) {
return h.response(error('FORBIDDEN', 'Results are not available for this activity')).code(403);
}
const t = String(quizPoll.type || '').toLowerCase();
if (t !== 'poll' && t !== 'survey') {
return h.response(error('BAD_REQUEST', 'Aggregated results are only available for polls and surveys')).code(400);
}
const all = await QuizPollResponse.find({ quizPoll: quizPollId }).lean();
const questions = buildVisitorPollResultsSummary(quizPoll, all);
return h.response(success({ totalSubmissions: all.length, questions }));
}
/** GET …/visitor/events/{eventId}/session-bookmarks — attendee JWT */
async function listVisitorSessionBookmarks(request, h) {
const { eventId } = request.params;
const participant = request.auth.credentials.participant;
if (!mongoose.Types.ObjectId.isValid(eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400);
}
if (!participantInEvent(participant, eventId)) {
return h.response(error('FORBIDDEN', 'Event mismatch')).code(403);
}
const rows = await SessionBookmark.find({ participant: participant._id, event: eventId })
.select('session')
.lean();
const sessionIds = rows.map((r) => String(r.session));
return h.response(success({ sessionIds }));
}
/** PUT …/visitor/events/{eventId}/session-bookmarks — attendee JWT; body { sessionId, bookmarked } */
async function putVisitorSessionBookmark(request, h) {
const { eventId } = request.params;
const { sessionId, bookmarked } = request.payload;
const participant = request.auth.credentials.participant;
if (
!mongoose.Types.ObjectId.isValid(eventId) ||
!mongoose.Types.ObjectId.isValid(sessionId)
) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
if (!participantInEvent(participant, eventId)) {
return h.response(error('FORBIDDEN', 'Event mismatch')).code(403);
}
const session = await Session.findById(sessionId).select('event').lean();
if (!session || String(session.event) !== String(eventId)) {
return h.response(error('NOT_FOUND', 'Session not found')).code(404);
}
if (bookmarked) {
await SessionBookmark.findOneAndUpdate(
{ participant: participant._id, session: sessionId },
{
$set: {
event: eventId,
participant: participant._id,
session: sessionId,
},
},
{ upsert: true, new: true },
);
} else {
await SessionBookmark.deleteOne({ participant: participant._id, session: sessionId });
}
const rows = await SessionBookmark.find({ participant: participant._id, event: eventId })
.select('session')
.lean();
const sessionIds = rows.map((r) => String(r.session));
return h.response(success({ sessionIds, bookmarked, sessionId: String(sessionId) }));
}
/** GET …/visitor/events/{eventId}/reward-balance — attendee JWT; total points for this event only */
async function visitorEventRewardBalance(request, h) {
const { eventId } = request.params;
const participant = request.auth.credentials.participant;
if (!mongoose.Types.ObjectId.isValid(eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400);
}
if (!participantInEvent(participant, eventId)) {
return h.response(error('FORBIDDEN', 'Event mismatch')).code(403);
}
const points = await RewardPoint.find({ participant: participant._id, event: eventId }).lean();
const earned = points.filter((p) => p.type === 'earned').reduce((s, p) => s + (p.points || 0), 0);
const redeemed = points.filter((p) => p.type === 'redeemed').reduce((s, p) => s + (p.points || 0), 0);
const total = earned - redeemed;
return h.response(success({ total }));
}
async function submitMicrositeContact(request, h) {
const p = request.payload;
if (p._gotcha) return h.response(error('VALIDATION_ERROR', 'Invalid request')).code(400);
const event = await Event.findById(p.eventId).select('name').lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const sent = await sendMicrositeContactEmail({
to: p.notifyEmail,
eventName: event.name,
visitorEmail: p.email,
firstName: p.firstName,
lastName: p.lastName,
message: p.message,
});
if (!sent) {
return h.response(error('SERVER_ERROR', 'Could not send message. Try again later.')).code(500);
}
return h.response(success({ ok: true }, 'Message sent', 201)).code(201);
}
module.exports = {
resolveCode,
loginPreview,
listVisitorEventTracks,
listVisitorEventAgenda,
listVisitorEventQuizPolls,
sessionPreview,
trackPreview,
listSessionQuestions,
createSessionQuestion,
voteSessionQuestion,
submitVisitorQuizPollResponse,
getVisitorQuizPollResultsSummary,
listVisitorSessionBookmarks,
putVisitorSessionBookmark,
visitorEventRewardBalance,
submitMicrositeContact,
};

View File

@ -0,0 +1,637 @@
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const mongoose = require('mongoose');
const Event = require('../models/Event');
const Participant = require('../models/Participant');
const VisitorAuthToken = require('../models/VisitorAuthToken');
const { success, error } = require('../utils/response');
const { sendVisitorOtpEmail, sendVisitorMagicLinkEmail } = require('../utils/mail');
const { getMembership, membershipGuestQuery } = require('../utils/participantMembership');
const ATTENDEE_SECRET =
process.env.ATTENDEE_JWT_SECRET || process.env.JWT_SECRET || 'default-secret-change-in-production';
const OTP_TTL_MS = 10 * 60 * 1000;
const LINK_TTL_MS = 15 * 60 * 1000;
const rateBuckets = new Map();
function rateLimit(key, max, windowMs) {
const now = Date.now();
let b = rateBuckets.get(key);
if (!b || now > b.resetAt) {
b = { count: 0, resetAt: now + windowMs };
rateBuckets.set(key, b);
}
b.count += 1;
return b.count <= max;
}
function clientKey(request) {
return (
request.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
request.info.remoteAddress ||
'unknown'
);
}
function getSettings(event) {
return event.settings || {};
}
function deadlinePassed(event) {
const d = getSettings(event).registrationDeadline;
if (!d) return false;
return new Date(d).getTime() <= Date.now();
}
function allowsNewRegistration(event) {
const s = getSettings(event);
if (s.registrationOpen === false) return false;
if (deadlinePassed(event)) return false;
return true;
}
function registrationMode(event) {
return String(getSettings(event).registrationMode || 'Open').trim();
}
function checkInviteForNewReg(event, inviteToken) {
const mode = registrationMode(event);
if (mode !== 'Invite Only') return { ok: true };
const expected = String(getSettings(event).inviteCode || '').trim();
if (!expected) {
return {
ok: false,
err: error('INVITE_NOT_CONFIGURED', 'Invite-only registration is not configured for this event'),
};
}
if (String(inviteToken || '').trim() !== expected) {
return {
ok: false,
err: error('REGISTRATION_INVITE_REQUIRED', 'A valid invite code is required'),
};
}
return { ok: true };
}
function defaultParticipantStatus(event) {
const s = getSettings(event);
if (s.requiresApproval === true) return 'pending';
if (registrationMode(event) === 'Approval Required') return 'pending';
return 'confirmed';
}
function authTypeFromEvent(event) {
const v = String(getSettings(event).authenticationType || 'otp_email').trim();
if (['otp_email', 'link_email', 'none'].includes(v)) return v;
return 'otp_email';
}
async function assertCapacity(event) {
const max = getSettings(event).maxParticipants;
if (!max || max <= 0) return { ok: true };
const n = await Participant.countDocuments({
eventMemberships: { $elemMatch: { event: event._id } },
});
if (n >= max) {
return {
ok: false,
err: error('REGISTRATION_FULL', 'This event has reached maximum participants'),
};
}
return { ok: true };
}
function normalizeEmail(e) {
return String(e || '').trim().toLowerCase();
}
function hashOtp(otp, eventId, email) {
const pepper = process.env.OTP_PEPPER || 'eventbuzz-otp';
return crypto.createHash('sha256').update(`${pepper}|${otp}|${eventId}|${email}`).digest('hex');
}
function hashLinkToken(raw) {
return crypto.createHash('sha256').update(raw).digest('hex');
}
function generateOtp() {
return String(Math.floor(100000 + Math.random() * 900000));
}
function signAttendeeJwt(participant, eventId, joinType, sessionId, trackId) {
return jwt.sign(
{
aud: 'attendee',
pid: String(participant._id),
eid: String(eventId),
scope: joinType || 'event',
sid: sessionId ? String(sessionId) : undefined,
tid: trackId ? String(trackId) : undefined,
},
ATTENDEE_SECRET,
{ expiresIn: '30d', algorithm: 'HS256' }
);
}
function customFieldsToObject(raw) {
if (!raw || typeof raw !== 'object') return {};
if (raw instanceof Map) return Object.fromEntries(raw);
return { ...raw };
}
function sanitizeParticipant(p, eventId) {
if (!p) return null;
const o = p.toObject ? p.toObject() : { ...p };
const m = getMembership(o, eventId);
const customFields = customFieldsToObject(o.customFields);
return {
_id: String(o._id),
event: String(eventId),
email: o.email,
firstName: o.firstName,
lastName: o.lastName,
displayName: o.displayName,
phone: o.phone,
guestToken: m?.guestToken,
status: m?.status,
emailVerified: o.emailVerified,
customFields: Object.keys(customFields).length ? customFields : undefined,
};
}
function customRegistrationFieldsIncomplete(event, participant) {
const defs = event.metadata && event.metadata.registrationCustomFields;
if (!Array.isArray(defs) || defs.length === 0) return false;
const po = participant.toObject ? participant.toObject() : participant;
const map = customFieldsToObject(po.customFields);
for (const def of defs) {
const code = String(def.code || '').trim();
if (!code) continue;
const v = map[code];
const ft = String(def.fieldType || 'text');
if (ft === 'checkboxes') {
if (!Array.isArray(v) || v.length === 0) return true;
continue;
}
if (v === undefined || v === null) return true;
if (typeof v === 'string' && !v.trim()) return true;
if (typeof v === 'number' && Number.isNaN(v)) return true;
}
return false;
}
function computeNeedsProfile(event, participant) {
if (customRegistrationFieldsIncomplete(event, participant)) return true;
const base = String(getSettings(event).registrationBaseFields || 'name_email');
const po = participant.toObject ? participant.toObject() : participant;
if (base === 'name_email') {
return !(String(po.firstName || '').trim() && String(po.lastName || '').trim());
}
if (base === 'name_email_phone') {
return !(
String(po.firstName || '').trim() &&
String(po.lastName || '').trim() &&
String(po.phone || '').trim()
);
}
if (base === 'anonymous_name') {
return !String(po.displayName || '').trim();
}
return false;
}
function oidStr(id) {
if (!id) return undefined;
const s = typeof id === 'string' ? id : id.toString();
if (!mongoose.Types.ObjectId.isValid(s)) return undefined;
return s;
}
function authPayload(participant, event, eventId, joinType, sessionId, trackId) {
const token = signAttendeeJwt(participant, eventId, joinType, oidStr(sessionId), oidStr(trackId));
const m = getMembership(participant, eventId);
return {
token,
participant: sanitizeParticipant(participant, eventId),
needsProfile: computeNeedsProfile(event, participant),
pendingApproval: m?.status === 'pending',
};
}
async function loadEvent(eventId) {
if (!mongoose.Types.ObjectId.isValid(eventId)) return null;
return Event.findById(eventId).lean();
}
async function startOtp(request, h) {
const { eventId } = request.params;
const { email, joinType = 'event', sessionId, trackId, inviteToken } = request.payload || {};
const em = normalizeEmail(email);
if (!em) return h.response(error('VALIDATION_ERROR', 'Email is required')).code(400);
if (!rateLimit(`visitorAuth:${clientKey(request)}`, 60, 60 * 60 * 1000)) {
return h.response(error('RATE_LIMIT', 'Too many requests. Try again later.')).code(429);
}
const event = await loadEvent(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (authTypeFromEvent(event) !== 'otp_email') {
return h.response(error('AUTH_FLOW_MISMATCH', 'This event does not use email OTP sign-in')).code(400);
}
const orgId = event.organization;
const existing = await Participant.findOne({ tenantOrganization: orgId, email: em }).lean();
const inEvent = existing && getMembership(existing, eventId);
if (!inEvent && !allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
}
if (!inEvent) {
const inv = checkInviteForNewReg(event, inviteToken);
if (!inv.ok) return h.response(inv.err).code(403);
const cap = await assertCapacity(event);
if (!cap.ok) return h.response(cap.err).code(403);
}
const otp = generateOtp();
const otpHash = hashOtp(otp, eventId, em);
const expiresAt = new Date(Date.now() + OTP_TTL_MS);
await VisitorAuthToken.deleteMany({ eventId, email: em, kind: 'otp' });
await VisitorAuthToken.create({
kind: 'otp',
eventId,
email: em,
otpHash,
joinType,
sessionId:
sessionId && mongoose.Types.ObjectId.isValid(sessionId)
? new mongoose.Types.ObjectId(sessionId)
: undefined,
trackId:
trackId && mongoose.Types.ObjectId.isValid(trackId)
? new mongoose.Types.ObjectId(trackId)
: undefined,
expiresAt,
});
await sendVisitorOtpEmail({ to: em, otp, eventName: event.name });
return h.response(success({ ok: true }));
}
async function verifyOtp(request, h) {
const { eventId } = request.params;
const { email, otp, inviteToken, firstName, lastName, phone, displayName } = request.payload || {};
const em = normalizeEmail(email);
const code = String(otp || '').replace(/\D/g, '').slice(0, 6);
if (!em || code.length !== 6) {
return h.response(error('VALIDATION_ERROR', 'Valid email and 6-digit OTP are required')).code(400);
}
const event = await loadEvent(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (authTypeFromEvent(event) !== 'otp_email') {
return h.response(error('AUTH_FLOW_MISMATCH', 'This event does not use email OTP sign-in')).code(400);
}
const doc = await VisitorAuthToken.findOne({
eventId,
email: em,
kind: 'otp',
expiresAt: { $gt: new Date() },
}).sort({ createdAt: -1 });
if (!doc || doc.otpHash !== hashOtp(code, eventId, em)) {
return h.response(error('OTP_INVALID', 'Invalid or expired OTP')).code(400);
}
await VisitorAuthToken.deleteOne({ _id: doc._id });
const orgId = event.organization;
let participant = await Participant.findOne({ tenantOrganization: orgId, email: em });
const inEvent = participant && getMembership(participant, eventId);
if (!inEvent) {
if (!allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
}
const inv = checkInviteForNewReg(event, inviteToken);
if (!inv.ok) return h.response(inv.err).code(403);
const cap = await assertCapacity(event);
if (!cap.ok) return h.response(cap.err).code(403);
const status = defaultParticipantStatus(event);
if (!participant) {
participant = await Participant.create({
tenantOrganization: orgId,
email: em,
firstName: firstName ? String(firstName).trim() : undefined,
lastName: lastName ? String(lastName).trim() : undefined,
phone: phone ? String(phone).trim() : undefined,
displayName: displayName ? String(displayName).trim() : undefined,
emailVerified: true,
eventMemberships: [
{
event: eventId,
status,
registrationDate: new Date(),
},
],
});
} else {
participant.eventMemberships.push({
event: eventId,
status,
registrationDate: new Date(),
});
participant.emailVerified = true;
if (firstName) participant.firstName = String(firstName).trim();
if (lastName) participant.lastName = String(lastName).trim();
if (phone) participant.phone = String(phone).trim();
if (displayName) participant.displayName = String(displayName).trim();
await participant.save();
}
} else {
participant.emailVerified = true;
if (firstName) participant.firstName = String(firstName).trim();
if (lastName) participant.lastName = String(lastName).trim();
if (phone) participant.phone = String(phone).trim();
if (displayName) participant.displayName = String(displayName).trim();
await participant.save();
}
const joinType = doc.joinType || 'event';
const sid = doc.sessionId;
const tid = doc.trackId;
return h.response(success(authPayload(participant, event, eventId, joinType, sid, tid)));
}
async function startLink(request, h) {
const { eventId } = request.params;
const { email, joinType = 'event', sessionId, trackId, inviteToken } = request.payload || {};
const em = normalizeEmail(email);
if (!em) return h.response(error('VALIDATION_ERROR', 'Email is required')).code(400);
if (!rateLimit(`visitorAuth:${clientKey(request)}`, 60, 60 * 60 * 1000)) {
return h.response(error('RATE_LIMIT', 'Too many requests. Try again later.')).code(429);
}
const event = await loadEvent(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (authTypeFromEvent(event) !== 'link_email') {
return h.response(error('AUTH_FLOW_MISMATCH', 'This event does not use magic link sign-in')).code(400);
}
const orgId = event.organization;
const existing = await Participant.findOne({ tenantOrganization: orgId, email: em }).lean();
const inEvent = existing && getMembership(existing, eventId);
if (!inEvent && !allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
}
if (!inEvent) {
const inv = checkInviteForNewReg(event, inviteToken);
if (!inv.ok) return h.response(inv.err).code(403);
const cap = await assertCapacity(event);
if (!cap.ok) return h.response(cap.err).code(403);
}
const rawToken = crypto.randomBytes(32).toString('hex');
const tokenHash = hashLinkToken(rawToken);
const expiresAt = new Date(Date.now() + LINK_TTL_MS);
await VisitorAuthToken.create({
kind: 'link',
eventId,
email: em,
tokenHash,
joinType,
sessionId:
sessionId && mongoose.Types.ObjectId.isValid(sessionId)
? new mongoose.Types.ObjectId(sessionId)
: undefined,
trackId:
trackId && mongoose.Types.ObjectId.isValid(trackId)
? new mongoose.Types.ObjectId(trackId)
: undefined,
expiresAt,
});
const baseUrl = process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174';
const magicLinkUrl = `${String(baseUrl).replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(rawToken)}`;
await sendVisitorMagicLinkEmail({ to: em, magicLinkUrl, eventName: event.name });
return h.response(success({ ok: true }));
}
async function exchangeLink(request, h) {
const { token } = request.payload || {};
const raw = String(token || '').trim();
if (!raw) return h.response(error('VALIDATION_ERROR', 'Token is required')).code(400);
const tokenHash = hashLinkToken(raw);
const doc = await VisitorAuthToken.findOne({
kind: 'link',
tokenHash,
expiresAt: { $gt: new Date() },
});
if (!doc) return h.response(error('LINK_INVALID', 'Invalid or expired link')).code(400);
const eventId = doc.eventId.toString();
const event = await loadEvent(eventId);
if (!event) {
await VisitorAuthToken.deleteOne({ _id: doc._id });
return h.response(error('NOT_FOUND', 'Event not found')).code(404);
}
if (authTypeFromEvent(event) !== 'link_email') {
await VisitorAuthToken.deleteOne({ _id: doc._id });
return h.response(error('AUTH_FLOW_MISMATCH', 'This event does not use magic link sign-in')).code(400);
}
const em = doc.email;
await VisitorAuthToken.deleteOne({ _id: doc._id });
const orgId = event.organization;
let participant = await Participant.findOne({ tenantOrganization: orgId, email: em });
const inEvent = participant && getMembership(participant, eventId);
if (!inEvent) {
if (!allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
}
const cap = await assertCapacity(event);
if (!cap.ok) return h.response(cap.err).code(403);
const status = defaultParticipantStatus(event);
if (!participant) {
participant = await Participant.create({
tenantOrganization: orgId,
email: em,
emailVerified: true,
eventMemberships: [
{
event: eventId,
status,
registrationDate: new Date(),
},
],
});
} else {
participant.eventMemberships.push({
event: eventId,
status,
registrationDate: new Date(),
});
participant.emailVerified = true;
await participant.save();
}
} else {
participant.emailVerified = true;
await participant.save();
}
const joinType = doc.joinType || 'event';
return h.response(
success(authPayload(participant, event, eventId, joinType, doc.sessionId, doc.trackId))
);
}
async function guestStart(request, h) {
const { eventId } = request.params;
const { joinType = 'event', sessionId, trackId, inviteToken, guestToken, displayName } =
request.payload || {};
const event = await loadEvent(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (authTypeFromEvent(event) !== 'none') {
return h.response(error('AUTH_FLOW_MISMATCH', 'This event requires email sign-in')).code(400);
}
const orgId = event.organization;
const gt = String(guestToken || '').trim();
if (gt) {
const existing = await Participant.findOne(membershipGuestQuery(orgId, eventId, gt));
if (existing) {
const out = authPayload(
existing,
event,
eventId,
joinType,
oidStr(sessionId),
oidStr(trackId)
);
const m = getMembership(existing, eventId);
return h.response(success({ ...out, guestToken: m?.guestToken }));
}
}
if (!allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
}
const inv = checkInviteForNewReg(event, inviteToken);
if (!inv.ok) return h.response(inv.err).code(403);
const cap = await assertCapacity(event);
if (!cap.ok) return h.response(cap.err).code(403);
const newGuestToken = crypto.randomBytes(16).toString('hex');
const status = defaultParticipantStatus(event);
const participant = await Participant.create({
tenantOrganization: orgId,
displayName: displayName ? String(displayName).trim() : undefined,
emailVerified: false,
eventMemberships: [
{
event: eventId,
guestToken: newGuestToken,
status,
registrationDate: new Date(),
},
],
});
const out = authPayload(
participant,
event,
eventId,
joinType,
oidStr(sessionId),
oidStr(trackId)
);
return h.response(success({ ...out, guestToken: newGuestToken }));
}
async function registrationStatus(request, h) {
const { eventId } = request.params;
const email = normalizeEmail(request.query.email);
if (!email) return h.response(error('VALIDATION_ERROR', 'email query parameter is required')).code(400);
const event = await loadEvent(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const p = await Participant.findOne({ tenantOrganization: event.organization, email }).lean();
const m = p && getMembership(p, eventId);
if (!m) return h.response(success({ registered: false }));
return h.response(success({ registered: true, status: m.status }));
}
async function updateProfile(request, h) {
const { participant: credP } = request.auth.credentials;
const pid = credP._id || credP.id;
const { firstName, lastName, phone, displayName, customFields: incomingCustom } = request.payload || {};
const participant = await Participant.findById(pid);
if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404);
const eventIdCtx = request.auth.credentials.attendee?.eid;
const event = await loadEvent(eventIdCtx);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (firstName !== undefined) participant.firstName = String(firstName).trim() || undefined;
if (lastName !== undefined) participant.lastName = String(lastName).trim() || undefined;
if (phone !== undefined) participant.phone = String(phone).trim() || undefined;
if (displayName !== undefined) participant.displayName = String(displayName).trim() || undefined;
if (incomingCustom && typeof incomingCustom === 'object' && !Array.isArray(incomingCustom)) {
const defs = (event.metadata && event.metadata.registrationCustomFields) || [];
const allowed = new Set(defs.map((d) => String(d.code || '').trim()).filter(Boolean));
if (!participant.customFields || !(participant.customFields instanceof Map)) {
participant.customFields = new Map();
}
for (const [key, val] of Object.entries(incomingCustom)) {
const k = String(key).trim();
if (!allowed.has(k)) continue;
if (val === null || val === undefined || val === '') {
participant.customFields.delete(k);
continue;
}
if (Array.isArray(val)) {
participant.customFields.set(
k,
val.map((x) => String(x)).filter((s) => s.length > 0)
);
} else if (typeof val === 'boolean' || typeof val === 'number') {
participant.customFields.set(k, val);
} else {
participant.customFields.set(k, String(val).trim());
}
}
participant.markModified('customFields');
}
await participant.save();
const decoded = request.auth.credentials.attendee;
return h.response(
success(
authPayload(
participant,
event,
decoded.eid,
decoded.scope,
decoded.sid,
decoded.tid
)
)
);
}
module.exports = {
startOtp,
verifyOtp,
startLink,
exchangeLink,
guestStart,
registrationStatus,
updateProfile,
};

View File

@ -0,0 +1,242 @@
const mongoose = require('mongoose');
const Event = require('../models/Event');
const EventMicrosite = require('../models/EventMicrosite');
const { success, error } = require('../utils/response');
const { WEBSITE_SECTION_TYPES } = require('../config/constants');
const {
buildMicrositeHtml,
buildMicrositeDocumentHtml,
getConferenceCanvasStylePayload,
templateToWebsiteBag,
} = require('../utils/micrositeBuild');
const { normalizePublishFolder, publishFolderAvailability } = require('../utils/publishFolderAvailability');
const { runPublishToDisk } = require('../utils/runMicrositePublish');
const get = async (request, h) => {
const event = await Event.findById(request.params.eventId).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const user = request.auth.credentials.user;
const orgId = (event.organization && event.organization._id ? event.organization._id : event.organization)?.toString();
if (user.role !== 'super_admin' && orgId !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
return h.response(success(event.website || {}));
};
const update = async (request, h) => {
const event = await Event.findById(request.params.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 payload = request.payload;
if (payload.folder !== undefined) {
const folder = (payload.folder || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
if (folder) {
const existing = await Event.findOne({
'website.folder': folder,
_id: { $ne: event._id },
});
if (existing) {
return h.response(error('VALIDATION_ERROR', 'Another event already uses this website folder')).code(400);
}
payload.folder = folder;
} else {
payload.folder = undefined;
}
}
const allowed = [
'enabled',
'folder',
'logo',
'banners',
'favicon',
'title',
'description',
'keywords',
'templateId',
'layoutTemplate',
'primaryColor',
'accentColor',
'fontPair',
'registrationUrl',
'registrationFormId',
'seoDescription',
'micrositeHtml',
];
event.website = event.website || {};
allowed.forEach((key) => {
if (payload[key] !== undefined) event.website[key] = payload[key];
});
if (payload.layoutTemplate !== undefined) {
event.website.layoutTemplate = 'conference';
}
if (payload.micrositeSections !== undefined) {
const ok = new Set(WEBSITE_SECTION_TYPES);
event.website.micrositeSections = Array.isArray(payload.micrositeSections)
? payload.micrositeSections.filter((t) => typeof t === 'string' && ok.has(t))
: [];
}
await event.save();
return h.response(success(event.website));
};
const publishMicrosite = async (request, h) => {
const event = await Event.findById(request.params.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 micrositeId = request.payload && request.payload.micrositeId;
if (micrositeId && mongoose.Types.ObjectId.isValid(String(micrositeId))) {
try {
const ms = await EventMicrosite.findOne({ _id: micrositeId, event: event._id }).lean();
if (!ms) {
return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404);
}
const result = await runPublishToDisk(event, ms, request.payload);
if (result.err) {
return h.response(error(result.err.code, result.err.message)).code(result.err.status);
}
return h.response(success(result.data));
} catch (err) {
const msg = err && err.message ? err.message : 'Publish failed';
return h.response(error('SERVER_ERROR', msg)).code(500);
}
}
const staticRoot = process.env.MICROSITE_STATIC_ROOT;
if (!staticRoot || !String(staticRoot).trim()) {
return h
.response(error('NOT_CONFIGURED', 'Microsite publish target is not configured (MICROSITE_STATIC_ROOT).'))
.code(503);
}
const website = event.website || {};
const folder = (website.folder || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
if (!folder) {
return h.response(error('VALIDATION_ERROR', 'Set a URL slug (website folder) before publishing.')).code(400);
}
try {
const regUrl = typeof website.registrationUrl === 'string' ? website.registrationUrl.trim() : '';
const base = (process.env.MICROSITE_PUBLIC_BASE_URL || 'https://web.eventbuzz.live').replace(/\/$/, '');
const targets = request.payload && request.payload.targets;
const draftFolder = `${folder}_draft`;
const runOnce = async (publishFolderOverride) => {
return buildMicrositeHtml(event, {
staticRoot: String(staticRoot).trim(),
registrationUrl: regUrl || undefined,
publishFolderOverride,
});
};
if (targets === 'draft') {
const { diskPath, publishFolder } = await runOnce(draftFolder);
return h.response(
success({ diskPath, publicUrl: `${base}/${publishFolder}/`, folder: publishFolder, targets: 'draft' }),
);
}
if (targets === 'live') {
const { diskPath, publishFolder } = await runOnce(folder);
return h.response(
success({ diskPath, publicUrl: `${base}/${publishFolder}/`, folder: publishFolder, targets: 'live' }),
);
}
if (targets === 'both') {
await runOnce(draftFolder);
const { diskPath, publishFolder } = await runOnce(folder);
return h.response(
success({
diskPath,
publicUrl: `${base}/${publishFolder}/`,
draftPublicUrl: `${base}/${draftFolder}/`,
folder: publishFolder,
draftFolder,
targets: 'both',
}),
);
}
const { diskPath, publishFolder } = await buildMicrositeHtml(event, {
staticRoot: String(staticRoot).trim(),
registrationUrl: regUrl || undefined,
});
const publicUrl = `${base}/${publishFolder}/`;
return h.response(success({ diskPath, publicUrl, folder: publishFolder }));
} catch (err) {
const msg = err && err.message ? err.message : 'Publish failed';
return h.response(error('SERVER_ERROR', msg)).code(500);
}
};
const previewMicrositeHtml = async (request, h) => {
const event = await Event.findById(request.params.eventId).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);
}
try {
const qMs = request.query.micrositeId;
if (qMs && mongoose.Types.ObjectId.isValid(String(qMs))) {
const ms = await EventMicrosite.findOne({ _id: qMs, event: event._id }).lean();
if (!ms) {
return h.response(error('NOT_FOUND', 'Microsite template not found')).code(404);
}
const website = templateToWebsiteBag(ms);
const regUrl = typeof website.registrationUrl === 'string' ? website.registrationUrl.trim() : '';
const [docResult, canvasStyles] = await Promise.all([
buildMicrositeDocumentHtml(event, {
previewOnly: true,
applyVisualMainOverride: false,
registrationUrl: regUrl || undefined,
website,
sourcePageId: ms.sourcePageId,
}),
getConferenceCanvasStylePayload(website),
]);
return h.response(success({ html: docResult.html, canvasStyles }));
}
const regUrl = typeof event.website?.registrationUrl === 'string' ? event.website.registrationUrl.trim() : '';
const [docResult, canvasStyles] = await Promise.all([
buildMicrositeDocumentHtml(event, {
previewOnly: true,
applyVisualMainOverride: false,
registrationUrl: regUrl || undefined,
}),
getConferenceCanvasStylePayload(event.website || {}),
]);
return h.response(success({ html: docResult.html, canvasStyles }));
} catch (err) {
const msg = err && err.message ? err.message : 'Preview failed';
return h.response(error('SERVER_ERROR', msg)).code(400);
}
};
const checkFolder = async (request, h) => {
const event = await Event.findById(request.params.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 folder = normalizePublishFolder(request.query.folder || '');
if (!folder) {
return h.response(success({ available: false, message: 'Folder is required' }));
}
let excludeMicrositeId = request.query.micrositeId || null;
if (excludeMicrositeId && !mongoose.Types.ObjectId.isValid(String(excludeMicrositeId))) {
excludeMicrositeId = null;
}
const { available, message } = await publishFolderAvailability(
folder,
event._id.toString(),
excludeMicrositeId,
);
return h.response(success({ available, message }));
};
module.exports = { get, update, checkFolder, publishMicrosite, previewMicrositeHtml };

58
src/middleware/auth.js Normal file
View File

@ -0,0 +1,58 @@
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const { error } = require('../utils/response');
const JWT_SECRET = process.env.JWT_SECRET || 'default-secret';
const authMiddleware = async (request, h) => {
try {
const authHeader = request.headers.authorization;
const token = authHeader && authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: request.state?.token;
if (!token) {
return h.response(error('UNAUTHORIZED', 'No token provided')).code(401).takeover();
}
const decoded = jwt.verify(token, JWT_SECRET);
const user = await User.findById(decoded.id).select('-password').populate('organization');
if (!user || !user.isActive) {
return h.response(error('UNAUTHORIZED', 'User not found or inactive')).code(401).takeover();
}
request.auth = { credentials: { user, id: user._id.toString() } };
return h.continue;
} catch (err) {
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
return h.response(error('UNAUTHORIZED', 'Invalid or expired token')).code(401).takeover();
}
throw err;
}
};
const roleMiddleware = (allowedRoles) => ({
async method(request, h) {
const user = request.auth?.credentials?.user;
if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401).takeover();
if (!allowedRoles.includes(user.role)) {
return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403).takeover();
}
return h.continue;
},
});
const organizationMiddleware = async (request, h) => {
const user = request.auth?.credentials?.user;
if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401).takeover();
const orgId = request.params.organizationId || request.params.eventId
? (await require('../models/Event').findById(request.params.eventId).select('organization'))?.organization
: request.payload?.organization;
if (user.role === 'super_admin') return h.continue;
if (!user.organization || user.organization._id?.toString() !== orgId?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied to this organization')).code(403).takeover();
}
return h.continue;
};
module.exports = { authMiddleware, roleMiddleware, organizationMiddleware };

View File

@ -0,0 +1,25 @@
const mongoose = require('mongoose');
const adminRequestSchema = new mongoose.Schema(
{
type: {
type: String,
enum: ['data_export', 'account_deletion'],
required: true,
},
requestedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
status: {
type: String,
enum: ['pending', 'done', 'rejected'],
default: 'pending',
},
message: { type: String, default: '' },
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
},
{ timestamps: true }
);
adminRequestSchema.index({ status: 1, createdAt: -1 });
adminRequestSchema.index({ organization: 1, type: 1 });
module.exports = mongoose.model('AdminRequest', adminRequestSchema);

37
src/models/Content.js Normal file
View File

@ -0,0 +1,37 @@
const mongoose = require('mongoose');
const contentSchema = new mongoose.Schema(
{
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
name: String,
description: { type: String, default: '' },
visibility: {
type: String,
enum: ['private', 'public'],
default: 'private',
},
type: {
type: String,
enum: ['image', 'pdf', 'video', 'document', 'other'],
},
url: String,
size: Number,
mimeType: String,
tags: [String],
collectionName: String,
// Used when media is stored on S3.
s3Key: String,
uploadedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
uploadedAt: { type: Date, default: Date.now },
metadata: { type: Map, of: mongoose.Schema.Types.Mixed },
},
{ timestamps: true }
);
contentSchema.index({ organization: 1 });
contentSchema.index({ type: 1 });
module.exports = mongoose.model('Content', contentSchema);

View File

@ -0,0 +1,35 @@
const mongoose = require('mongoose');
const contentCollectionSchema = new mongoose.Schema(
{
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
name: { type: String, required: true },
description: { type: String, default: '' },
collectionType: { type: String, default: '' },
coverIcon: { type: String, default: '' },
tags: [{ type: String }],
contentIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'LibraryContent' }],
visibility: {
type: String,
enum: ['private', 'shared', 'public'],
default: 'private',
},
sharedWith: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
passwordProtected: { type: Boolean, default: false },
passwordHash: { type: String, default: '' },
expiryEnabled: { type: Boolean, default: false },
expiryDate: Date,
notifyOnAdd: { type: Boolean, default: true },
notifyOnShare: { type: Boolean, default: true },
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
},
{ timestamps: true }
);
contentCollectionSchema.index({ organization: 1, createdAt: -1 });
module.exports = mongoose.model('ContentCollection', contentCollectionSchema);

View File

@ -0,0 +1,43 @@
const mongoose = require('mongoose');
const variableSchema = new mongoose.Schema({
name: String,
type: String,
defaultValue: String,
required: Boolean,
});
const designTemplateSchema = new mongoose.Schema(
{
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
name: { type: String, required: true },
type: {
type: String,
enum: ['brochure', 'microsite', 'badge', 'emailer', 'certificate'],
required: true,
},
category: String,
thumbnail: String,
content: mongoose.Schema.Types.Mixed,
variables: [variableSchema],
settings: {
isPublic: { type: Boolean, default: false },
isActive: { type: Boolean, default: true },
canDuplicate: { type: Boolean, default: true },
},
usedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Event' }],
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
},
{ timestamps: true }
);
designTemplateSchema.index({ organization: 1, type: 1 });
module.exports = mongoose.model('DesignTemplate', designTemplateSchema);

104
src/models/Event.js Normal file
View File

@ -0,0 +1,104 @@
const mongoose = require('mongoose');
const eventSchema = new mongoose.Schema(
{
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
name: { type: String, required: true },
subtitle: String,
description: String,
type: {
type: String,
enum: ['conference', 'workshop', 'summit', 'cme', 'webinar'],
required: true,
},
status: {
type: String,
enum: ['draft', 'upcoming', 'live', 'completed'],
default: 'draft',
},
category: String,
startDate: { type: Date, required: true },
endDate: { type: Date, required: true },
location: String,
locationType: {
type: String,
enum: ['physical', 'virtual', 'hybrid'],
default: 'physical',
},
virtualLink: String,
tags: [String],
image: String,
settings: {
registrationOpen: { type: Boolean, default: true },
maxParticipants: Number,
requiresApproval: { type: Boolean, default: false },
registrationMode: String,
verifyEmail: { type: Boolean, default: true },
registrationDeadline: Date,
enableFeedback: { type: Boolean, default: true },
enableRatings: { type: Boolean, default: false },
/** otp_email | link_email | none */
authenticationType: String,
/** name_email | email_only | name_email_phone | anonymous_name | anonymous */
registrationBaseFields: String,
/** Optional shared code for Invite Only registration (visitor must pass inviteToken) */
inviteCode: String,
},
bannerImage: String,
website: {
enabled: { type: Boolean, default: false },
folder: { type: String, trim: true, lowercase: true }, // URL slug, e.g. "napcon-2026"
logo: String,
banners: [String],
favicon: String,
title: String,
/** Meta description for published HTML (search snippet) */
seoDescription: String,
description: String,
keywords: [String],
templateId: { type: String, default: 'default' },
/** Microsite shell (conference only; legacy values tolerated until re-saved) */
layoutTemplate: {
type: String,
default: 'conference',
},
/** Ordered section types for generated static microsite (WEBSITE_SECTION_TYPES) */
micrositeSections: [{ type: String }],
/** Conference microsite theme (generated inline :root on publish) */
primaryColor: String,
accentColor: String,
/** Label from CreateMicrositePanel FONT_PAIRS, e.g. "Inter + Noto Sans" */
fontPair: String,
/** Fallback for register_links CTAs when no registrationFormId / template */
registrationUrl: String,
/** Registration Form _id; link built with MICROSITE_REGISTRATION_URL_TEMPLATE */
registrationFormId: String,
/** GrapesJS / visual editor main markup (injected at publish when set; canonical store for custom HTML). */
micrositeHtml: String,
},
metadata: {
totalRevenue: { type: Number, default: 0 },
rating: { type: Number, default: 0 },
ratingCount: { type: Number, default: 0 },
registrationCustomFields: [
{
label: String,
code: String,
fieldType: String,
options: [{ value: String, label: String }],
},
],
},
},
{ timestamps: true }
);
eventSchema.index({ organization: 1 });
eventSchema.index({ status: 1 });
eventSchema.index({ startDate: 1 });
eventSchema.index({ 'website.folder': 1 });
module.exports = mongoose.model('Event', eventSchema);

19
src/models/EventInvite.js Normal file
View File

@ -0,0 +1,19 @@
const mongoose = require('mongoose');
const eventInviteSchema = new mongoose.Schema(
{
email: { type: String, required: true, lowercase: true, trim: true },
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true, index: true },
invitedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
token: { type: String, required: true, unique: true, index: true },
sessionIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Session' }],
eventRole: { type: String, default: 'Viewer' },
expiresAt: { type: Date, required: true },
status: { type: String, enum: ['pending', 'accepted', 'cancelled'], default: 'pending' },
},
{ timestamps: true }
);
eventInviteSchema.index({ event: 1, email: 1, status: 1 });
module.exports = mongoose.model('EventInvite', eventInviteSchema);

View File

@ -0,0 +1,51 @@
const mongoose = require('mongoose');
const eventMicrositeSchema = new mongoose.Schema(
{
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
index: true,
},
/** Admin-only label (not published). */
name: { type: String, trim: true },
/** Public URL segment / static output folder (global uniqueness). */
publishFolder: {
type: String,
trim: true,
lowercase: true,
sparse: true,
},
enabled: { type: Boolean, default: false },
logo: String,
banners: [String],
favicon: String,
title: String,
seoDescription: String,
description: String,
keywords: [String],
templateId: { type: String, default: 'default' },
layoutTemplate: { type: String, default: 'conference' },
micrositeSections: [{ type: String }],
primaryColor: String,
accentColor: String,
fontPair: String,
registrationUrl: String,
registrationFormId: { type: mongoose.Schema.Types.ObjectId, ref: 'Form' },
micrositeHtml: String,
/** Optional EventPage whose `sections` supply block configs for this template. */
sourcePageId: { type: mongoose.Schema.Types.ObjectId, ref: 'EventPage' },
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
},
{ timestamps: true },
);
eventMicrositeSchema.index({ event: 1, createdAt: 1 });
eventMicrositeSchema.index({ publishFolder: 1 }, { unique: true, sparse: true });
module.exports = mongoose.model('EventMicrosite', eventMicrositeSchema);

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

@ -0,0 +1,58 @@
const mongoose = require('mongoose');
const seoSchema = new mongoose.Schema({
title: String,
description: String,
keywords: [String],
image: String,
});
const sectionSchema = new mongoose.Schema(
{
id: { type: String, required: true },
type: { type: String, required: true }, // WEBSITE_SECTION_TYPES
order: { type: Number, default: 0 },
config: mongoose.Schema.Types.Mixed,
},
{ _id: false }
);
const eventPageSchema = new mongoose.Schema(
{
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
title: { type: String, required: true },
slug: { type: String, required: true },
type: {
type: String,
enum: ['home', 'agenda', 'speakers', 'registration', 'sponsors', 'custom'],
required: true,
},
sections: [sectionSchema],
/** Main-column HTML from the GrapesJS microsite visual editor (injected at publish as EB_INJECT_MAIN when present on Home). */
micrositeHtml: { type: String },
content: mongoose.Schema.Types.Mixed,
seo: seoSchema,
settings: {
isPublished: { type: Boolean, default: false },
showInMenu: { type: Boolean, default: true },
menuOrder: Number,
requireLogin: { type: Boolean, default: false },
},
views: { type: Number, default: 0 },
lastViewed: Date,
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
},
{ timestamps: true }
);
eventPageSchema.index({ event: 1 });
eventPageSchema.index({ slug: 1 }, { unique: true });
module.exports = mongoose.model('EventPage', eventPageSchema);

62
src/models/Form.js Normal file
View File

@ -0,0 +1,62 @@
const mongoose = require('mongoose');
const formFieldSchema = new mongoose.Schema({
label: String,
type: {
type: String,
enum: ['text', 'email', 'phone', 'select', 'radio', 'checkbox', 'checkboxes', 'file'],
},
required: Boolean,
options: [String],
placeholder: String,
/** Registration: which Participant field this collects (`custom` uses customMapsKey → customFields). */
mapsTo: String,
customMapsKey: String,
isFixed: { type: Boolean, default: false },
});
const formSchema = new mongoose.Schema(
{
tenantOrganization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: false,
},
name: { type: String, required: true },
description: { type: String, default: '' },
type: {
type: String,
enum: ['registration', 'feedback', 'survey', 'custom', 'other'],
required: true,
},
fields: [formFieldSchema],
settings: {
isActive: { type: Boolean, default: true },
allowMultiple: { type: Boolean, default: false },
submissionLimit: Number,
thankYouMessage: String,
responseDeadline: Date,
showThankYou: { type: Boolean, default: true },
collectEmail: { type: Boolean, default: false },
allowAnonymous: { type: Boolean, default: false },
paymentEnabled: { type: Boolean, default: false },
paymentAmount: String,
sendConfirmationEmail: { type: Boolean, default: true },
/** Registration: tags applied to participant after each submission. */
onSubmitAddTags: { type: [String], default: [] },
/** Registration: fixed participant list to add submitter to. */
onSubmitParticipantListId: { type: mongoose.Schema.Types.ObjectId, ref: 'ParticipantList' },
},
},
{ timestamps: true }
);
formSchema.index({ event: 1 });
formSchema.index({ tenantOrganization: 1, createdAt: -1 });
module.exports = mongoose.model('Form', formSchema);

View File

@ -0,0 +1,40 @@
const mongoose = require('mongoose');
const formSubmissionSchema = new mongoose.Schema(
{
form: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Form',
required: true,
},
tenantOrganization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: false,
},
data: { type: Map, of: mongoose.Schema.Types.Mixed },
status: {
type: String,
enum: ['submitted', 'reviewed', 'approved', 'rejected'],
default: 'submitted',
},
submittedAt: { type: Date, default: Date.now },
reviewedAt: Date,
reviewedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
notes: String,
},
{ timestamps: true }
);
formSubmissionSchema.index({ form: 1 });
formSubmissionSchema.index({ participant: 1 });
formSubmissionSchema.index({ tenantOrganization: 1 });
module.exports = mongoose.model('FormSubmission', formSubmissionSchema);

View File

@ -0,0 +1,37 @@
const mongoose = require('mongoose');
/** Org-scoped files shown only in Portfolio → Library (uploads with module content-library). */
const libraryContentSchema = new mongoose.Schema(
{
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
name: String,
description: { type: String, default: '' },
visibility: {
type: String,
enum: ['private', 'public'],
default: 'private',
},
type: {
type: String,
enum: ['image', 'pdf', 'video', 'document', 'other'],
},
url: String,
size: Number,
mimeType: String,
tags: [String],
collectionName: String,
s3Key: String,
uploadedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
uploadedAt: { type: Date, default: Date.now },
metadata: { type: Map, of: mongoose.Schema.Types.Mixed },
},
{ timestamps: true }
);
libraryContentSchema.index({ organization: 1 });
libraryContentSchema.index({ type: 1 });
module.exports = mongoose.model('LibraryContent', libraryContentSchema);

View File

@ -0,0 +1,65 @@
const mongoose = require('mongoose');
const recipientsSchema = new mongoose.Schema({
type: {
type: String,
enum: ['all', 'filtered', 'custom'],
},
filters: { type: Map, of: mongoose.Schema.Types.Mixed },
customList: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Participant' }],
});
const contentSchema = new mongoose.Schema({
subject: String,
body: String,
template: String,
variables: { type: Map, of: mongoose.Schema.Types.Mixed },
});
const campaignSchema = new mongoose.Schema(
{
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
name: { type: String, required: true },
channel: {
type: String,
enum: ['email', 'whatsapp', 'sms'],
required: true,
},
type: {
type: String,
enum: ['broadcast', 'trigger', 'scheduled'],
required: true,
},
triggerEvent: String,
recipients: recipientsSchema,
content: contentSchema,
status: {
type: String,
enum: ['draft', 'scheduled', 'sending', 'sent', 'failed'],
default: 'draft',
},
scheduledFor: Date,
sentAt: Date,
stats: {
total: Number,
sent: Number,
delivered: Number,
opened: Number,
clicked: Number,
failed: Number,
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
},
{ timestamps: true }
);
campaignSchema.index({ event: 1 });
module.exports = mongoose.model('MessagingCampaign', campaignSchema);

View File

@ -0,0 +1,41 @@
const mongoose = require('mongoose');
const notificationSchema = new mongoose.Schema(
{
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event' },
type: {
type: String,
enum: ['system', 'event', 'message', 'reminder', 'alert'],
},
title: { type: String, required: true },
message: String,
data: { type: Map, of: mongoose.Schema.Types.Mixed },
priority: {
type: String,
enum: ['low', 'medium', 'high', 'urgent'],
default: 'medium',
},
channel: {
type: String,
enum: ['in_app', 'email', 'push', 'sms'],
default: 'in_app',
},
status: {
type: String,
enum: ['unread', 'read', 'archived'],
default: 'unread',
},
readAt: Date,
actionUrl: String,
expiresAt: Date,
},
{ timestamps: true }
);
notificationSchema.index({ user: 1, status: 1 });
module.exports = mongoose.model('Notification', notificationSchema);

View File

@ -0,0 +1,32 @@
const mongoose = require('mongoose');
const organizationSchema = new mongoose.Schema(
{
name: { type: String, required: true },
description: String,
email: String,
phone: String,
website: String,
address: String,
logo: String,
timezone: { type: String, default: 'Asia/Kolkata' },
dateFormat: { type: String, default: 'DD/MM/YYYY' },
language: { type: String, default: 'en-IN' },
subscription: {
plan: { type: String, enum: ['free', 'pro', 'enterprise'], default: 'free' },
status: {
type: String,
enum: ['active', 'trial', 'past_due', 'cancelled'],
default: 'active',
},
expiresAt: Date,
maxEvents: Number,
maxParticipants: Number,
featureOverrides: [{ type: String }],
},
},
{ timestamps: true }
);
organizationSchema.index({ name: 1 });
module.exports = mongoose.model('Organization', organizationSchema);

View File

@ -0,0 +1,48 @@
const mongoose = require('mongoose');
const GATEWAY_PROVIDERS = ['razorpay', 'payu', 'ccavenue', 'cashfree', 'stripe', 'paytm'];
const orgGatewaySchema = new mongoose.Schema(
{
provider: {
type: String,
enum: GATEWAY_PROVIDERS,
required: true,
},
label: { type: String, default: '' },
isActive: { type: Boolean, default: true },
isPrimary: { type: Boolean, default: false },
testMode: { type: Boolean, default: true },
/** AES-GCM encrypted JSON — provider-specific secrets */
credentialsEnc: { type: String, required: true },
},
{ timestamps: true }
);
const organizationPaymentProfileSchema = new mongoose.Schema(
{
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
unique: true,
},
checkoutMode: {
type: String,
enum: ['platform_razorpay', 'custom'],
default: 'platform_razorpay',
},
/** Override platform default when using platform checkout */
platformCommissionBps: { type: Number, default: null },
defaultCurrency: { type: String, default: 'INR' },
taxRate: { type: Number, default: null },
taxType: { type: String, default: '' },
gateways: { type: [orgGatewaySchema], default: [] },
},
{ timestamps: true }
);
organizationPaymentProfileSchema.index({ organization: 1 });
module.exports = mongoose.model('OrganizationPaymentProfile', organizationPaymentProfileSchema);
module.exports.GATEWAY_PROVIDERS = GATEWAY_PROVIDERS;

View File

@ -0,0 +1,31 @@
const mongoose = require('mongoose');
const eventAccessSchema = new mongoose.Schema({
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true },
sessionIds: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Session' }],
/** Event-specific role label (Moderator, Presenter, etc.) */
eventRole: { type: String, default: 'Viewer' },
}, { _id: false });
const organizationUserMapSchema = new mongoose.Schema(
{
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true },
role: {
type: String,
enum: ['super_admin', 'admin', 'moderator', 'viewer'],
required: true,
},
eventAccess: {
type: [eventAccessSchema],
default: [],
},
},
{ timestamps: true }
);
organizationUserMapSchema.index({ user: 1, organization: 1 }, { unique: true });
organizationUserMapSchema.index({ organization: 1 });
organizationUserMapSchema.index({ user: 1 });
module.exports = mongoose.model('OrganizationUserMap', organizationUserMapSchema);

73
src/models/Participant.js Normal file
View File

@ -0,0 +1,73 @@
const mongoose = require('mongoose');
const eventMembershipSchema = new mongoose.Schema(
{
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
status: {
type: String,
enum: ['pending', 'confirmed', 'checked_in', 'cancelled'],
default: 'pending',
},
checkedIn: { type: Boolean, default: false },
checkedInAt: Date,
guestToken: { type: String, trim: true },
registrationDate: { type: Date, default: Date.now },
/** @deprecated Prefer root-level `Participant.tags`. Loaded for legacy rows only. */
tags: [String],
},
{ _id: false }
);
const participantSchema = new mongoose.Schema(
{
/** Dashboard tenant (organization that owns this person record). */
tenantOrganization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
email: { type: String, trim: true, lowercase: true },
firstName: { type: String, trim: true },
lastName: { type: String, trim: true },
displayName: { type: String, trim: true },
phone: String,
/** Employer / company (string). Not the tenant org id. */
organization: String,
designation: String,
customFields: { type: Map, of: mongoose.Schema.Types.Mixed },
payment: {
amount: Number,
currency: { type: String, default: 'INR' },
status: {
type: String,
enum: ['pending', 'paid', 'failed', 'refunded'],
},
transactionId: String,
paidAt: Date,
},
notes: String,
emailVerified: { type: Boolean, default: false },
/** Admin labels (VIP, Speaker, …) — organization-wide on the person, not per-event. */
tags: { type: [String], default: [] },
eventMemberships: { type: [eventMembershipSchema], default: [] },
},
{ timestamps: true }
);
participantSchema.index({ tenantOrganization: 1, status: 1 });
participantSchema.index(
{ tenantOrganization: 1, email: 1 },
{
unique: true,
partialFilterExpression: {
email: { $exists: true, $type: 'string', $gt: '' },
},
}
);
participantSchema.index({ 'eventMemberships.event': 1, 'eventMemberships.status': 1 });
module.exports = mongoose.model('Participant', participantSchema);

View File

@ -0,0 +1,26 @@
const mongoose = require('mongoose');
const participantListSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true },
description: { type: String, trim: true, default: '' },
tenantOrganization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
visibility: { type: String, enum: ['private', 'shared'], default: 'private' },
type: { type: String, enum: ['fixed', 'rule_based'], required: true },
autoUpdate: { type: Boolean, default: true },
/** UI rule rows: { field, operator, value, id? }; conjunction: AND | OR */
rules: { type: mongoose.Schema.Types.Mixed, default: null },
/** Fixed lists: ordered participant ids (import / manual). Rule-based lists ignore this. */
participantIds: { type: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Participant' }], default: [] },
},
{ timestamps: true }
);
participantListSchema.index({ tenantOrganization: 1, name: 1 });
module.exports = mongoose.model('ParticipantList', participantListSchema);

View File

@ -0,0 +1,27 @@
const mongoose = require('mongoose');
const paymentConfigSchema = new mongoose.Schema(
{
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
country: { type: String, required: true },
gateway: {
type: String,
enum: ['razorpay', 'stripe', 'paypal', 'manual'],
required: true,
},
credentials: { type: Map, of: String },
currency: { type: String, default: 'INR' },
taxRate: Number,
taxType: String,
isActive: { type: Boolean, default: false },
testMode: { type: Boolean, default: true },
},
{ timestamps: true }
);
paymentConfigSchema.index({ organization: 1 }, { unique: true });
module.exports = mongoose.model('PaymentConfig', paymentConfigSchema);

View File

@ -0,0 +1,57 @@
const mongoose = require('mongoose');
const paymentTransactionSchema = new mongoose.Schema(
{
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
default: null,
},
participant: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Participant',
default: null,
},
formId: { type: mongoose.Schema.Types.ObjectId, default: null },
checkoutSource: {
type: String,
enum: ['platform_razorpay', 'custom_gateway'],
required: true,
},
gatewayProvider: { type: String, required: true },
orgGatewayId: { type: mongoose.Schema.Types.ObjectId, default: null },
currency: { type: String, default: 'INR' },
/** Amounts in smallest currency unit (e.g. paise) */
amountSubtotal: { type: Number, required: true },
commissionAmount: { type: Number, default: 0 },
amountGross: { type: Number, required: true },
amountNet: { type: Number, required: true },
feeEstimate: { type: Number, default: null },
externalOrderId: { type: String, default: '' },
externalPaymentId: { type: String, default: '' },
status: {
type: String,
enum: ['pending', 'paid', 'failed', 'refunded'],
default: 'pending',
},
idempotencyKey: { type: String, default: '' },
/** 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 },
},
{ timestamps: true }
);
paymentTransactionSchema.index({ organization: 1, createdAt: -1 });
paymentTransactionSchema.index({ organization: 1, idempotencyKey: 1 }, { unique: true, partialFilterExpression: { idempotencyKey: { $gt: '' } } });
paymentTransactionSchema.index({ event: 1 });
paymentTransactionSchema.index({ participant: 1 });
paymentTransactionSchema.index({ status: 1 });
paymentTransactionSchema.index({ externalPaymentId: 1 });
module.exports = mongoose.model('PaymentTransaction', paymentTransactionSchema);

View File

@ -0,0 +1,18 @@
const mongoose = require('mongoose');
/** Singleton platform Razorpay + default commission template (editable by super_admin). */
const platformPaymentConfigSchema = new mongoose.Schema(
{
key: { type: String, default: 'default', unique: true, immutable: true },
enabled: { type: Boolean, default: false },
testMode: { type: Boolean, default: true },
/** Encrypted JSON: { keyId, keySecret } */
razorpayCredentialsEnc: { type: String, default: '' },
webhookSecretEnc: { type: String, default: '' },
/** Default platform commission for orgs on platform checkout (basis points, 100 = 1%) */
defaultCommissionBps: { type: Number, default: 200 },
},
{ timestamps: true }
);
module.exports = mongoose.model('PlatformPaymentConfig', platformPaymentConfigSchema);

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

@ -0,0 +1,46 @@
const mongoose = require('mongoose');
const answerSchema = new mongoose.Schema({
answeredBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
answer: String,
answeredAt: { type: Date, default: Date.now },
isOfficial: { type: Boolean, default: false },
});
const questionSchema = new mongoose.Schema(
{
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
session: { type: mongoose.Schema.Types.ObjectId, ref: 'Session' },
participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
question: { type: String, required: true },
isAnonymous: { type: Boolean, default: false },
status: {
type: String,
enum: ['pending', 'answered', 'archived'],
default: 'pending',
},
upvotes: { type: Number, default: 0 },
downvotes: { type: Number, default: 0 },
/**
* Audience votes: `key` is browser voterKey (anonymous) or participant ObjectId string (logged-in visitor).
*/
audienceVotes: [
{
key: { type: String, required: true },
dir: { type: String, enum: ['up', 'down'], required: true },
},
],
upvotedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
answers: [answerSchema],
},
{ timestamps: true }
);
questionSchema.index({ event: 1 });
questionSchema.index({ session: 1 });
module.exports = mongoose.model('Question', questionSchema);

60
src/models/QuizPoll.js Normal file
View File

@ -0,0 +1,60 @@
const mongoose = require('mongoose');
const questionOptionSchema = new mongoose.Schema({
text: String,
isCorrect: Boolean,
imageUrl: String,
});
const quizQuestionSchema = new mongoose.Schema({
question: { type: String, required: true },
imageUrl: String,
type: {
type: String,
enum: ['single', 'multiple', 'text', 'rating'],
default: 'single',
},
options: [questionOptionSchema],
points: { type: Number, default: 1 },
required: { type: Boolean, default: true },
});
const quizPollSchema = new mongoose.Schema(
{
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
session: { type: mongoose.Schema.Types.ObjectId, ref: 'Session' },
title: { type: String, required: true },
type: {
type: String,
enum: ['quiz', 'poll', 'survey'],
required: true,
},
description: String,
questions: [quizQuestionSchema],
settings: {
/** Editorial lifecycle: draft vs active (separate from live broadcast flag). */
status: { type: String, enum: ['draft', 'active'], default: 'active' },
/** Live / on-air for participants (publish/close toggles this). */
isLive: { type: Boolean },
displayMode: { type: String },
startTime: Date,
endTime: Date,
showResults: { type: Boolean, default: true },
allowMultipleAttempts: { type: Boolean, default: false },
randomizeQuestions: { type: Boolean, default: false },
timeRestricted: { type: Boolean, default: false },
timeLimit: { type: Number, default: 0, min: 0 },
},
deletedAt: { type: Date, default: null },
},
{ timestamps: true }
);
quizPollSchema.index({ event: 1 });
quizPollSchema.index({ session: 1 });
quizPollSchema.index({ deletedAt: 1 });
module.exports = mongoose.model('QuizPoll', quizPollSchema);

View File

@ -0,0 +1,31 @@
const mongoose = require('mongoose');
const responseItemSchema = new mongoose.Schema({
questionIndex: Number,
answer: mongoose.Schema.Types.Mixed,
isCorrect: Boolean,
pointsEarned: Number,
});
const quizPollResponseSchema = new mongoose.Schema(
{
quizPoll: {
type: mongoose.Schema.Types.ObjectId,
ref: 'QuizPoll',
required: true,
},
participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
responses: [responseItemSchema],
totalScore: Number,
completedAt: { type: Date, default: Date.now },
timeSpent: Number,
ipAddress: String,
userAgent: String,
},
{ timestamps: true }
);
quizPollResponseSchema.index({ quizPoll: 1 });
quizPollResponseSchema.index({ participant: 1 });
module.exports = mongoose.model('QuizPollResponse', quizPollResponseSchema);

36
src/models/RewardPoint.js Normal file
View File

@ -0,0 +1,36 @@
const mongoose = require('mongoose');
const rewardPointSchema = new mongoose.Schema(
{
participant: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Participant',
required: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
points: { type: Number, required: true },
type: {
type: String,
enum: ['earned', 'redeemed', 'adjusted'],
required: true,
},
source: {
type: String,
enum: ['registration', 'attendance', 'feedback', 'quiz', 'referral', 'manual'],
},
sourceId: mongoose.Schema.Types.Mixed,
description: String,
expiresAt: Date,
metadata: { type: Map, of: mongoose.Schema.Types.Mixed },
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
},
{ timestamps: true }
);
rewardPointSchema.index({ participant: 1, event: 1 });
rewardPointSchema.index({ event: 1 });
module.exports = mongoose.model('RewardPoint', rewardPointSchema);

57
src/models/Session.js Normal file
View File

@ -0,0 +1,57 @@
const mongoose = require('mongoose');
const { eventBuzzCodeFor } = require('../utils/eventBuzzCode');
const sessionSchema = new mongoose.Schema(
{
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
track: { type: mongoose.Schema.Types.ObjectId, ref: 'Track', index: true },
title: { type: String, required: true },
description: String,
type: {
type: String,
enum: ['keynote', 'workshop', 'panel', 'breakout', 'networking'],
default: 'keynote',
},
startTime: { type: Date, required: true },
endTime: { type: Date, required: true },
location: String,
speakers: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Speaker' }],
moderator: { type: mongoose.Schema.Types.ObjectId, ref: 'Speaker' },
capacity: Number,
status: {
type: String,
enum: ['scheduled', 'ongoing', 'completed', 'cancelled'],
default: 'scheduled',
},
resources: [
{
name: String,
type: String,
url: String,
uploadedAt: { type: Date, default: Date.now },
},
],
joinCode: { type: String, trim: true, uppercase: true, unique: true, sparse: true },
},
{ timestamps: true }
);
sessionSchema.index({ event: 1, startTime: 1 });
sessionSchema.pre('save', function assignSessionJoinCode(next) {
try {
const missing = !this.joinCode || !String(this.joinCode).trim();
if (missing && this._id) {
this.joinCode = eventBuzzCodeFor(this._id.toString(), 'session');
}
next();
} catch (err) {
next(err);
}
});
module.exports = mongoose.model('Session', sessionSchema);

View File

@ -0,0 +1,28 @@
const mongoose = require('mongoose');
const sessionBookmarkSchema = new mongoose.Schema(
{
participant: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Participant',
required: true,
index: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
index: true,
},
session: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Session',
required: true,
},
},
{ timestamps: true }
);
sessionBookmarkSchema.index({ participant: 1, session: 1 }, { unique: true });
module.exports = mongoose.model('SessionBookmark', sessionBookmarkSchema);

45
src/models/Speaker.js Normal file
View File

@ -0,0 +1,45 @@
const mongoose = require('mongoose');
const speakerSchema = new mongoose.Schema(
{
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
firstName: { type: String, required: true },
lastName: { type: String, required: true },
email: { type: String, required: true },
phone: String,
title: String,
organizationName: String,
bio: String,
photo: String,
socialLinks: {
linkedin: String,
twitter: String,
website: String,
},
expertise: [String],
sessions: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Session' }],
isFeatured: { type: Boolean, default: false },
/** Programme role (Speaker, Keynote Speaker, Moderator, etc.) */
speakerRole: String,
/** public | private — shown on event website when public */
profileVisibility: { type: String, enum: ['public', 'private'], default: 'public' },
logistics: {
honorarium: { type: Boolean, default: false },
honorariumAmount: String,
travelRequired: { type: Boolean, default: false },
accommodation: { type: Boolean, default: false },
dietaryRequirements: String,
internalNotes: String,
},
},
{ timestamps: true }
);
speakerSchema.index({ email: 1 });
speakerSchema.index({ organization: 1 });
module.exports = mongoose.model('Speaker', speakerSchema);

31
src/models/Track.js Normal file
View File

@ -0,0 +1,31 @@
const mongoose = require('mongoose');
const { eventBuzzCodeFor } = require('../utils/eventBuzzCode');
const trackSchema = new mongoose.Schema(
{
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true },
name: { type: String, required: true, trim: true },
hall: String,
trackType: { type: String, enum: ['general', 'workshop', 'plenary', 'symposium'], default: 'general' },
status: { type: String, enum: ['active', 'inactive'], default: 'active' },
sortOrder: { type: Number, default: 0 },
joinCode: { type: String, trim: true, uppercase: true, unique: true, sparse: true },
},
{ timestamps: true }
);
trackSchema.index({ event: 1, name: 1 });
trackSchema.pre('save', function assignTrackJoinCode(next) {
try {
const missing = !this.joinCode || !String(this.joinCode).trim();
if (missing && this._id) {
this.joinCode = eventBuzzCodeFor(this._id.toString(), 'track');
}
next();
} catch (err) {
next(err);
}
});
module.exports = mongoose.model('Track', trackSchema);

71
src/models/User.js Normal file
View File

@ -0,0 +1,71 @@
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const notificationSettingsSchema = new mongoose.Schema(
{
emailRegistration: { type: Boolean, default: true },
emailFeedback: { type: Boolean, default: true },
emailPayment: { type: Boolean, default: true },
emailSummary: { type: Boolean, default: false },
pushRegistration: { type: Boolean, default: true },
pushSession: { type: Boolean, default: true },
pushMessage: { type: Boolean, default: false },
digestFrequency: {
type: String,
enum: ['None', 'Daily', 'Weekly', 'Monthly'],
default: 'Daily',
},
},
{ _id: false }
);
const userSchema = new mongoose.Schema(
{
email: { type: String, required: true, unique: true },
password: {
type: String,
required: function passwordRequired() {
return !this.googleId;
},
},
googleId: { type: String, unique: true, sparse: true },
firstName: String,
lastName: String,
bio: { type: String, default: '' },
role: {
type: String,
enum: ['super_admin', 'admin', 'moderator', 'viewer'],
default: 'viewer',
},
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' },
profileImage: String,
phone: String,
isActive: { type: Boolean, default: true },
lastLogin: Date,
notificationSettings: { type: notificationSettingsSchema, default: () => ({}) },
/** Set when user saves notification preferences (onboarding / Settings). */
notificationPreferencesSavedAt: Date,
twoFactorEnabled: { type: Boolean, default: false },
twoFactorSecret: { type: String, select: false, default: '' },
twoFactorTempSecret: { type: String, select: false, default: '' },
},
{ timestamps: true }
);
userSchema.index({ organization: 1 });
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
if (!this.password) {
return next();
}
this.password = await bcrypt.hash(this.password, 10);
next();
});
userSchema.methods.comparePassword = function (candidate) {
if (!this.password) return Promise.resolve(false);
return bcrypt.compare(candidate, this.password);
};
module.exports = mongoose.model('User', userSchema);

View File

@ -0,0 +1,22 @@
const mongoose = require('mongoose');
/** OTP and magic-link tokens; TTL on expiresAt cleans up automatically. */
const visitorAuthTokenSchema = new mongoose.Schema(
{
kind: { type: String, enum: ['otp', 'link'], required: true },
eventId: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true },
email: { type: String, lowercase: true, trim: true },
otpHash: String,
tokenHash: String,
joinType: { type: String, enum: ['event', 'session', 'track'], default: 'event' },
sessionId: { type: mongoose.Schema.Types.ObjectId },
trackId: { type: mongoose.Schema.Types.ObjectId },
expiresAt: { type: Date, required: true },
},
{ timestamps: true }
);
visitorAuthTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
visitorAuthTokenSchema.index({ eventId: 1, email: 1, kind: 1 });
module.exports = mongoose.model('VisitorAuthToken', visitorAuthTokenSchema);

View File

@ -0,0 +1,19 @@
const Joi = require('joi');
const controller = require('../controllers/adminRequest.controller');
const payloadSchema = Joi.object({
type: Joi.string().valid('data_export', 'account_deletion').required(),
message: Joi.string().allow('', null),
metadata: Joi.object().unknown(true),
});
module.exports = [
{
method: 'POST',
path: '/api/admin-requests',
options: {
validate: { payload: payloadSchema },
handler: controller.create,
},
},
];

191
src/routes/auth.routes.js Normal file
View File

@ -0,0 +1,191 @@
const Joi = require('joi');
const controller = require('../controllers/auth.controller');
const authPayload = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(6).required(),
teamName: Joi.string().min(1).required(),
firstName: Joi.string(),
lastName: Joi.string(),
organizationName: Joi.string(),
});
const refreshPayload = Joi.object({
refreshToken: Joi.string().required(),
});
const forgotPayload = Joi.object({
email: Joi.string().email().required(),
});
const resetPayload = Joi.object({
token: Joi.string().required(),
newPassword: Joi.string().min(6).required(),
});
const notificationSettingsPayload = Joi.object({
emailRegistration: Joi.boolean(),
emailFeedback: Joi.boolean(),
emailPayment: Joi.boolean(),
emailSummary: Joi.boolean(),
pushRegistration: Joi.boolean(),
pushSession: Joi.boolean(),
pushMessage: Joi.boolean(),
digestFrequency: Joi.string().valid('None', 'Daily', 'Weekly', 'Monthly'),
});
const updateMePayload = Joi.object({
firstName: Joi.string().allow('', null),
lastName: Joi.string().allow('', null),
phone: Joi.string().allow('', null),
profileImage: Joi.string().allow('', null),
bio: Joi.string().allow('', null),
notificationSettings: notificationSettingsPayload,
}).min(1);
const changePasswordPayload = Joi.object({
currentPassword: Joi.string().required(),
newPassword: Joi.string().min(8).required(),
});
const tokenPayload = Joi.object({
token: Joi.string().required(),
});
const disable2faPayload = Joi.object({
currentPassword: Joi.string().required(),
token: Joi.string().allow('', null),
});
const login2faPayload = Joi.object({
twoFactorToken: Joi.string().required(),
token: Joi.string().required(),
});
module.exports = [
{
method: 'POST',
path: '/api/auth/register',
options: {
auth: false,
validate: { payload: authPayload },
handler: controller.register,
},
},
{
method: 'POST',
path: '/api/auth/login',
options: {
auth: false,
validate: {
payload: Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required(),
}),
},
handler: controller.login,
},
},
{
method: 'POST',
path: '/api/auth/google',
options: {
auth: false,
validate: {
payload: Joi.object({
idToken: Joi.string().required(),
}),
},
handler: controller.googleLogin,
},
},
{
method: 'POST',
path: '/api/auth/google/register',
options: {
auth: false,
validate: {
payload: Joi.object({
idToken: Joi.string().required(),
teamName: Joi.string().min(1).required(),
firstName: Joi.string().allow('', null),
lastName: Joi.string().allow('', null),
phone: Joi.string().allow('', null),
organizationName: Joi.string(),
}),
},
handler: controller.googleRegister,
},
},
{
method: 'POST',
path: '/api/auth/login/2fa',
options: {
auth: false,
validate: { payload: login2faPayload },
handler: controller.login2fa,
},
},
{
method: 'POST',
path: '/api/auth/refresh',
options: {
auth: false,
validate: { payload: refreshPayload },
handler: controller.refresh,
},
},
{
method: 'POST',
path: '/api/auth/logout',
options: { handler: controller.logout },
},
{
method: 'POST',
path: '/api/auth/forgot-password',
options: {
auth: false,
validate: { payload: forgotPayload },
handler: controller.forgotPassword,
},
},
{
method: 'POST',
path: '/api/auth/reset-password',
options: {
auth: false,
validate: { payload: resetPayload },
handler: controller.resetPassword,
},
},
{
method: 'GET',
path: '/api/auth/me',
options: { handler: controller.me },
},
{
method: 'PUT',
path: '/api/auth/me',
options: { validate: { payload: updateMePayload }, handler: controller.updateMe },
},
{
method: 'POST',
path: '/api/auth/change-password',
options: { validate: { payload: changePasswordPayload }, handler: controller.changePassword },
},
{
method: 'POST',
path: '/api/auth/2fa/setup',
options: { handler: controller.setup2fa },
},
{
method: 'POST',
path: '/api/auth/2fa/verify-setup',
options: { validate: { payload: tokenPayload }, handler: controller.verify2faSetup },
},
{
method: 'POST',
path: '/api/auth/2fa/disable',
options: { validate: { payload: disable2faPayload }, handler: controller.disable2fa },
},
];

View File

@ -0,0 +1,40 @@
const Joi = require('joi');
const controller = require('../controllers/campaign.controller');
const { CAMPAIGN_CHANNELS, CAMPAIGN_TYPES } = require('../config/constants');
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(),
}),
});
module.exports = [
{ 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: '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}/schedule',
options: {
validate: { payload: Joi.object({ scheduledFor: Joi.date().iso().required() }) },
handler: controller.schedule,
},
},
{ method: 'POST', path: '/api/campaigns/{id}/cancel', options: { handler: controller.cancel } },
{ method: 'GET', path: '/api/campaigns/{id}/analytics', options: { handler: controller.analytics } },
];

View File

@ -0,0 +1,52 @@
const Joi = require('joi');
const controller = require('../controllers/contentCollection.controller');
const createPayload = Joi.object({
name: Joi.string().required(),
description: Joi.string().allow('', null),
collectionType: Joi.string().allow('', null),
coverIcon: Joi.string().allow('', null),
tags: Joi.array().items(Joi.string()),
contentIds: Joi.array().items(Joi.string()).min(1).required(),
visibility: Joi.string().valid('private', 'shared', 'public').required(),
sharedWith: Joi.array().items(Joi.string()),
passwordProtected: Joi.boolean(),
password: Joi.string().allow('', null),
expiryEnabled: Joi.boolean(),
expiryDate: Joi.alternatives().try(Joi.date(), Joi.string().allow('', null), Joi.valid(null)),
notifyOnAdd: Joi.boolean(),
notifyOnShare: Joi.boolean(),
});
const updatePayload = Joi.object({
name: Joi.string(),
description: Joi.string().allow('', null),
collectionType: Joi.string().allow('', null),
coverIcon: Joi.string().allow('', null),
tags: Joi.array().items(Joi.string()),
contentIds: Joi.array().items(Joi.string()),
visibility: Joi.string().valid('private', 'shared', 'public'),
sharedWith: Joi.array().items(Joi.string()),
passwordProtected: Joi.boolean(),
password: Joi.string().allow('', null),
expiryEnabled: Joi.boolean(),
expiryDate: Joi.alternatives().try(Joi.date(), Joi.string().allow('', null), Joi.valid(null)),
notifyOnAdd: Joi.boolean(),
notifyOnShare: Joi.boolean(),
}).min(1);
module.exports = [
{ method: 'GET', path: '/api/content-collections', options: { handler: controller.list } },
{
method: 'POST',
path: '/api/content-collections',
options: { validate: { payload: createPayload }, handler: controller.create },
},
{ method: 'GET', path: '/api/content-collections/{id}', options: { handler: controller.get } },
{
method: 'PUT',
path: '/api/content-collections/{id}',
options: { validate: { payload: updatePayload }, handler: controller.update },
},
{ method: 'DELETE', path: '/api/content-collections/{id}', options: { handler: controller.remove } },
];

View File

@ -0,0 +1,35 @@
const Joi = require('joi');
const controller = require('../controllers/designTemplate.controller');
const { DESIGN_TEMPLATE_TYPES } = require('../config/constants');
const variableSchema = Joi.object({
name: Joi.string(),
type: Joi.string(),
defaultValue: Joi.string(),
required: Joi.boolean(),
});
const templatePayload = Joi.object({
name: Joi.string().required(),
type: Joi.string().valid(...DESIGN_TEMPLATE_TYPES).required(),
category: Joi.string(),
thumbnail: Joi.string(),
content: Joi.any(),
variables: Joi.array().items(variableSchema),
organization: Joi.string(),
settings: Joi.object({
isPublic: Joi.boolean(),
isActive: Joi.boolean(),
canDuplicate: Joi.boolean(),
}),
});
module.exports = [
{ method: 'GET', path: '/api/design-templates', options: { handler: controller.list } },
{ method: 'POST', path: '/api/design-templates', options: { validate: { payload: templatePayload }, handler: controller.create } },
{ method: 'GET', path: '/api/design-templates/{id}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/design-templates/{id}', options: { validate: { payload: templatePayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/design-templates/{id}', options: { handler: controller.remove } },
{ method: 'POST', path: '/api/design-templates/{id}/duplicate', options: { handler: controller.duplicate } },
{ method: 'POST', path: '/api/design-templates/{id}/use/{eventId}', options: { handler: controller.useForEvent } },
];

View File

@ -0,0 +1,79 @@
const Joi = require('joi');
const controller = require('../controllers/event.controller');
const { EVENT_TYPES, EVENT_STATUS } = require('../config/constants');
const AUTHENTICATION_TYPES = ['otp_email', 'link_email', 'none'];
const REGISTRATION_BASE_FIELDS = [
'name_email',
'email_only',
'name_email_phone',
'anonymous_name',
'anonymous',
];
const fieldOptionSchema = Joi.object({
value: Joi.string().allow('', null),
label: Joi.string().allow('', null),
});
const registrationFieldSchema = Joi.object({
label: Joi.string().allow('', null),
code: Joi.string().allow('', null),
fieldType: Joi.string().allow('', null),
options: Joi.array().items(fieldOptionSchema),
});
const eventPayload = Joi.object({
name: Joi.string().min(3).max(100).required(),
subtitle: Joi.string().allow('', null),
description: Joi.string().allow('', null),
type: Joi.string().valid(...EVENT_TYPES).required(),
status: Joi.string().valid(...EVENT_STATUS),
category: Joi.string().allow('', null),
startDate: Joi.date().iso().required(),
endDate: Joi.date().iso().required(),
location: Joi.string().allow('', null),
locationType: Joi.string().valid('physical', 'virtual', 'hybrid'),
virtualLink: Joi.string().allow('', null),
tags: Joi.array().items(Joi.string()),
image: Joi.string().allow('', null),
bannerImage: Joi.string().allow('', null),
organization: Joi.string(),
settings: Joi.object({
registrationOpen: Joi.boolean(),
maxParticipants: Joi.number(),
requiresApproval: Joi.boolean(),
registrationMode: Joi.string().allow('', null),
verifyEmail: Joi.boolean(),
registrationDeadline: Joi.date().iso().allow(null),
enableFeedback: Joi.boolean(),
enableRatings: Joi.boolean(),
authenticationType: Joi.string().valid(...AUTHENTICATION_TYPES).allow('', null),
registrationBaseFields: Joi.string().valid(...REGISTRATION_BASE_FIELDS).allow('', null),
inviteCode: Joi.string().allow('', null),
}),
metadata: Joi.object({
registrationCustomFields: Joi.array().items(registrationFieldSchema),
}),
});
module.exports = [
{ method: 'GET', path: '/api/events', options: { handler: controller.list } },
{ method: 'POST', path: '/api/events', options: { validate: { payload: eventPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/events/{id}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/events/{id}', options: { validate: { payload: eventPayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/events/{id}', options: { handler: controller.remove } },
{ method: 'GET', path: '/api/events/{id}/analytics', options: { handler: controller.analytics } },
{
method: 'GET',
path: '/api/events/{id}/registration-link',
options: {
validate: {
params: Joi.object({ id: Joi.string().required() }),
query: Joi.object({ formId: Joi.string().required() }),
},
handler: controller.registrationLink,
},
},
{ method: 'POST', path: '/api/events/{id}/duplicate', options: { handler: controller.duplicate } },
];

View File

@ -0,0 +1,143 @@
const Joi = require('joi');
const controller = require('../controllers/eventMicrosite.controller');
const { WEBSITE_SECTION_TYPES } = require('../config/constants');
const mongoId = Joi.string().length(24).hex().required();
const optionalMongoId = Joi.string().length(24).hex().allow('', null);
const micrositeBody = {
name: Joi.string().allow('', null),
publishFolder: Joi.string().allow('', null),
enabled: Joi.boolean(),
logo: Joi.string().allow('', null),
banners: Joi.array().items(Joi.string()),
favicon: Joi.string().allow('', null),
title: Joi.string().allow('', null),
description: Joi.string().allow('', null),
keywords: Joi.array().items(Joi.string()),
templateId: Joi.string().allow('', null),
layoutTemplate: Joi.string().valid('conference'),
seoDescription: Joi.string().allow('', null),
micrositeSections: Joi.array().items(Joi.string().valid(...WEBSITE_SECTION_TYPES)),
primaryColor: Joi.string().max(32).allow('', null),
accentColor: Joi.string().max(32).allow('', null),
fontPair: Joi.string().allow('', null),
registrationUrl: Joi.string().max(2048).allow('', null),
registrationFormId: optionalMongoId,
micrositeHtml: Joi.string().allow('').max(2000000),
sourcePageId: optionalMongoId,
};
const createPayload = Joi.object({
name: micrositeBody.name,
publishFolder: Joi.string().required(),
enabled: micrositeBody.enabled,
logo: micrositeBody.logo,
banners: micrositeBody.banners,
favicon: micrositeBody.favicon,
title: micrositeBody.title,
description: micrositeBody.description,
keywords: micrositeBody.keywords,
templateId: micrositeBody.templateId,
layoutTemplate: micrositeBody.layoutTemplate,
seoDescription: micrositeBody.seoDescription,
micrositeSections: micrositeBody.micrositeSections,
primaryColor: micrositeBody.primaryColor,
accentColor: micrositeBody.accentColor,
fontPair: micrositeBody.fontPair,
registrationUrl: micrositeBody.registrationUrl,
registrationFormId: micrositeBody.registrationFormId,
micrositeHtml: micrositeBody.micrositeHtml,
sourcePageId: micrositeBody.sourcePageId,
});
const updatePayload = Joi.object(micrositeBody).min(1);
const publishPayload = Joi.object({
targets: Joi.string().valid('draft', 'live', 'both'),
});
module.exports = [
{
method: 'GET',
path: '/api/event-microsites',
options: {
validate: {
query: Joi.object({
organization: Joi.string().length(24).hex().required(),
}),
},
handler: controller.listForOrganization,
},
},
{
method: 'GET',
path: '/api/events/{eventId}/microsites/check-folder',
options: {
validate: {
params: Joi.object({ eventId: mongoId }),
query: Joi.object({
folder: Joi.string().required(),
micrositeId: Joi.string().length(24).hex().allow('', null),
}),
},
handler: controller.checkFolder,
},
},
{
method: 'GET',
path: '/api/events/{eventId}/microsites',
options: { validate: { params: Joi.object({ eventId: mongoId }) }, handler: controller.list },
},
{
method: 'POST',
path: '/api/events/{eventId}/microsites',
options: {
validate: { params: Joi.object({ eventId: mongoId }), payload: createPayload },
handler: controller.create,
},
},
{
method: 'GET',
path: '/api/events/{eventId}/microsites/{micrositeId}',
options: {
validate: { params: Joi.object({ eventId: mongoId, micrositeId: mongoId }) },
handler: controller.getById,
},
},
{
method: 'PUT',
path: '/api/events/{eventId}/microsites/{micrositeId}',
options: {
validate: { params: Joi.object({ eventId: mongoId, micrositeId: mongoId }), payload: updatePayload },
handler: controller.update,
},
},
{
method: 'DELETE',
path: '/api/events/{eventId}/microsites/{micrositeId}',
options: {
validate: { params: Joi.object({ eventId: mongoId, micrositeId: mongoId }) },
handler: controller.remove,
},
},
{
method: 'POST',
path: '/api/events/{eventId}/microsites/{micrositeId}/publish',
options: {
validate: {
params: Joi.object({ eventId: mongoId, micrositeId: mongoId }),
payload: publishPayload,
},
handler: controller.publish,
},
},
{
method: 'GET',
path: '/api/events/{eventId}/microsites/{micrositeId}/preview-html',
options: {
validate: { params: Joi.object({ eventId: mongoId, micrositeId: mongoId }) },
handler: controller.previewHtml,
},
},
];

View File

@ -0,0 +1,67 @@
const Joi = require('joi');
const controller = require('../controllers/eventStaff.controller');
const addStaffPayload = Joi.object({
userIds: Joi.array().items(Joi.string()).min(1).required(),
sessionIds: Joi.array().items(Joi.string()),
eventRole: Joi.string().allow('', null),
});
const sendInvitesPayload = Joi.object({
invites: Joi.array()
.items(
Joi.object({
email: Joi.string().email().required(),
eventRole: Joi.string().allow('', null),
})
)
.min(1)
.required(),
sessionIds: Joi.array().items(Joi.string()),
});
const updateStaffPayload = Joi.object({
sessionIds: Joi.array().items(Joi.string()),
eventRole: Joi.string().allow('', null),
});
const acceptPayload = Joi.object({
token: Joi.string().required(),
password: Joi.string().required(),
firstName: Joi.string().allow('', null),
lastName: Joi.string().allow('', null),
});
module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/staff', options: { handler: controller.listStaff } },
{ method: 'GET', path: '/api/events/{eventId}/staff/available-users', options: { handler: controller.listAvailableUsers } },
{ method: 'GET', path: '/api/events/{eventId}/agenda-tree', options: { handler: controller.agendaTree } },
{
method: 'POST',
path: '/api/events/{eventId}/staff/invites',
options: { validate: { payload: sendInvitesPayload }, handler: controller.sendInvites },
},
{ method: 'DELETE', path: '/api/events/{eventId}/staff/invites/{inviteId}', options: { handler: controller.cancelInvite } },
{ method: 'POST', path: '/api/events/{eventId}/staff/invites/{inviteId}/resend', options: { handler: controller.resendInvite } },
{
method: 'POST',
path: '/api/events/{eventId}/staff',
options: { validate: { payload: addStaffPayload }, handler: controller.addStaff },
},
{
method: 'PUT',
path: '/api/events/{eventId}/staff/{userId}',
options: { validate: { payload: updateStaffPayload }, handler: controller.updateStaff },
},
{ method: 'DELETE', path: '/api/events/{eventId}/staff/{userId}', options: { handler: controller.removeStaff } },
{
method: 'GET',
path: '/api/event-invites/{token}',
options: { auth: false, handler: controller.invitePreview },
},
{
method: 'POST',
path: '/api/event-invites/accept',
options: { auth: false, validate: { payload: acceptPayload }, handler: controller.inviteAccept },
},
];

78
src/routes/form.routes.js Normal file
View File

@ -0,0 +1,78 @@
const Joi = require('joi');
const controller = require('../controllers/form.controller');
const { FORM_TYPES, FIELD_TYPES, FORM_FIELD_MAPS_TO } = require('../config/constants');
const fieldSchema = Joi.object({
label: Joi.string(),
type: Joi.string().valid(...FIELD_TYPES),
required: Joi.boolean(),
options: Joi.array().items(Joi.string()),
placeholder: Joi.string(),
mapsTo: Joi.string().valid(...FORM_FIELD_MAPS_TO).allow('', null),
customMapsKey: Joi.string().allow('', null),
isFixed: Joi.boolean(),
});
const settingsSchema = Joi.object({
isActive: Joi.boolean(),
allowMultiple: Joi.boolean(),
submissionLimit: Joi.number(),
thankYouMessage: Joi.string().allow('', null),
responseDeadline: Joi.alternatives().try(Joi.date(), Joi.string().allow('', null), Joi.valid(null)),
showThankYou: Joi.boolean(),
collectEmail: Joi.boolean(),
allowAnonymous: Joi.boolean(),
paymentEnabled: Joi.boolean(),
paymentAmount: Joi.alternatives().try(Joi.string(), Joi.number()).allow('', null),
sendConfirmationEmail: Joi.boolean(),
onSubmitAddTags: Joi.array().items(Joi.string()),
onSubmitParticipantListId: Joi.string().allow(null, ''),
});
const formPayload = Joi.object({
name: Joi.string().required(),
type: Joi.string().valid(...FORM_TYPES).required(),
description: Joi.string().allow('', null),
fields: Joi.array().items(fieldSchema),
settings: settingsSchema,
});
/** tenantOrganization: super_admin may set explicitly; others get it from JWT in the controller. */
const formOrgCreatePayload = Joi.object({
name: Joi.string().required(),
type: Joi.string().valid(...FORM_TYPES).required(),
description: Joi.string().allow('', null),
fields: Joi.array().items(fieldSchema),
settings: settingsSchema,
event: Joi.string().allow(null, ''),
tenantOrganization: Joi.alternatives().try(
Joi.string().hex().length(24),
Joi.string().allow(null, ''),
Joi.valid(null)
),
});
const submitPayload = Joi.object({
participantId: Joi.string(),
data: Joi.object().pattern(Joi.string(), Joi.any()),
});
module.exports = [
{ method: 'GET', path: '/api/forms', options: { handler: controller.listByOrganization } },
{
method: 'POST',
path: '/api/forms',
options: { validate: { payload: formOrgCreatePayload }, handler: controller.createByOrganization },
},
{ method: 'GET', path: '/api/events/{eventId}/forms', options: { handler: controller.list } },
{ method: 'POST', path: '/api/events/{eventId}/forms', options: { validate: { payload: formPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/forms/{id}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/forms/{id}', options: { validate: { payload: formPayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/forms/{id}', options: { handler: controller.remove } },
{
method: 'POST',
path: '/api/forms/{formId}/submit',
options: { auth: false, validate: { payload: submitPayload }, handler: controller.submit },
},
{ method: 'GET', path: '/api/forms/{formId}/submissions', options: { handler: controller.listSubmissions } },
];

View File

@ -0,0 +1,23 @@
const controller = require('../controllers/media.controller');
module.exports = [
{ method: 'GET', path: '/api/media', options: { handler: controller.list } },
{ method: 'GET', path: '/api/library/media', options: { handler: controller.listLibrary } },
{
method: 'POST',
path: '/api/media/upload',
options: {
payload: {
maxBytes: 50 * 1024 * 1024,
// Hapi only allows output: data | stream | file. Binary multipart parts arrive as raw Buffer on payload.file.
output: 'data',
parse: true,
multipart: true,
allow: ['multipart/form-data', 'application/json'],
},
handler: controller.upload,
},
},
{ method: 'GET', path: '/api/media/{id}', options: { handler: controller.get } },
{ method: 'DELETE', path: '/api/media/{id}', options: { handler: controller.remove } },
];

View File

@ -0,0 +1,9 @@
const controller = require('../controllers/notification.controller');
module.exports = [
{ method: 'GET', path: '/api/notifications', options: { handler: controller.list } },
{ method: 'GET', path: '/api/notifications/unread', options: { handler: controller.unread } },
{ method: 'PUT', path: '/api/notifications/{id}/read', options: { handler: controller.markRead } },
{ method: 'PUT', path: '/api/notifications/read-all', options: { handler: controller.markAllRead } },
{ method: 'DELETE', path: '/api/notifications/{id}', options: { handler: controller.remove } },
];

View File

@ -0,0 +1,43 @@
const Joi = require('joi');
const controller = require('../controllers/organization.controller');
const { ROLES } = require('../config/constants');
const orgPayload = Joi.object({
name: Joi.string(),
description: Joi.string(),
email: Joi.string().email(),
phone: Joi.string(),
website: Joi.string(),
address: Joi.string(),
logo: Joi.string(),
timezone: Joi.string(),
dateFormat: Joi.string(),
language: Joi.string(),
subscription: Joi.object({
plan: Joi.string().valid('free', 'pro', 'enterprise'),
status: Joi.string().valid('active', 'trial', 'past_due', 'cancelled'),
expiresAt: Joi.date().allow(null),
maxEvents: Joi.number().allow(null),
maxParticipants: Joi.number().allow(null),
featureOverrides: Joi.array().items(Joi.string()),
}),
});
const userPayload = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(6),
firstName: Joi.string(),
lastName: Joi.string(),
role: Joi.string().valid(...ROLES),
phone: Joi.string(),
assignedEvents: Joi.array().items(Joi.string()).optional(),
});
module.exports = [
{ method: 'GET', path: '/api/organization', options: { handler: controller.getOrganization } },
{ method: 'PUT', path: '/api/organization', options: { validate: { payload: orgPayload }, handler: controller.updateOrganization } },
{ method: 'GET', path: '/api/users', options: { handler: controller.listUsers } },
{ method: 'POST', path: '/api/users', options: { validate: { payload: userPayload }, handler: controller.addUser } },
{ method: 'PUT', path: '/api/users/{id}', options: { validate: { payload: userPayload }, handler: controller.updateUser } },
{ method: 'DELETE', path: '/api/users/{id}', options: { handler: controller.removeUser } },
];

65
src/routes/page.routes.js Normal file
View File

@ -0,0 +1,65 @@
const Joi = require('joi');
const controller = require('../controllers/page.controller');
const { PAGE_TYPES } = require('../config/constants');
const mongoId = Joi.string().length(24).hex().required();
const seoSchema = Joi.object({
title: Joi.string(),
description: Joi.string(),
keywords: Joi.array().items(Joi.string()),
image: Joi.string(),
});
const sectionSchema = Joi.object({
id: Joi.string().required(),
type: Joi.string().required(),
order: Joi.number(),
config: Joi.any(),
});
const pagePayload = Joi.object({
title: Joi.string().required(),
slug: Joi.string(),
type: Joi.string().valid(...PAGE_TYPES).required(),
sections: Joi.array().items(sectionSchema),
content: Joi.any(),
seo: seoSchema,
settings: Joi.object({
isPublished: Joi.boolean(),
showInMenu: Joi.boolean(),
menuOrder: Joi.number(),
requireLogin: Joi.boolean(),
}),
});
const pageUpdatePayload = Joi.object({
title: Joi.string(),
slug: Joi.string(),
type: Joi.string().valid(...PAGE_TYPES),
sections: Joi.array().items(sectionSchema),
micrositeHtml: Joi.string().allow('').max(2000000),
content: Joi.any(),
seo: seoSchema,
settings: Joi.object({
isPublished: Joi.boolean(),
showInMenu: Joi.boolean(),
menuOrder: Joi.number(),
requireLogin: Joi.boolean(),
}),
}).min(1);
module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/pages', options: { validate: { params: Joi.object({ eventId: mongoId }) }, handler: controller.list } },
{ method: 'POST', path: '/api/events/{eventId}/pages', options: { validate: { params: Joi.object({ eventId: mongoId }), payload: pagePayload }, handler: controller.create } },
{ method: 'GET', path: '/api/pages/{id}', options: { validate: { params: Joi.object({ id: mongoId }) }, handler: controller.get } },
{ method: 'PUT', path: '/api/pages/{id}', options: { validate: { params: Joi.object({ id: mongoId }), payload: pageUpdatePayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/pages/{id}', options: { validate: { params: Joi.object({ id: mongoId }) }, handler: controller.remove } },
{ method: 'POST', path: '/api/pages/{id}/publish', options: { validate: { params: Joi.object({ id: mongoId }) }, handler: controller.publish } },
{
method: 'GET',
path: '/api/pages/public/{eventSlug}/{pageSlug}',
options: { auth: false, handler: controller.publicPage },
},
{ method: 'GET', path: '/api/pages/{id}/export-html', options: { validate: { params: Joi.object({ id: mongoId }) }, handler: controller.exportPageHtml } },
];

View File

@ -0,0 +1,46 @@
const Joi = require('joi');
const controller = require('../controllers/participantList.controller');
const listCreatePayload = Joi.object({
name: Joi.string().required(),
description: Joi.string().allow(''),
visibility: Joi.string().valid('private', 'shared'),
type: Joi.string().valid('fixed', 'rule_based').required(),
autoUpdate: Joi.boolean(),
rules: Joi.object({
rules: Joi.array().items(Joi.object()),
conjunction: Joi.string().valid('AND', 'OR'),
}).allow(null),
tenantOrganization: Joi.string(), // super_admin only
participantIds: Joi.array().items(Joi.string()),
});
const listUpdatePayload = Joi.object({
name: Joi.string(),
description: Joi.string().allow(''),
visibility: Joi.string().valid('private', 'shared'),
type: Joi.string().valid('fixed', 'rule_based'),
autoUpdate: Joi.boolean(),
rules: Joi.object({
rules: Joi.array().items(Joi.object()),
conjunction: Joi.string().valid('AND', 'OR'),
}).allow(null),
participantIds: Joi.array().items(Joi.string()),
}).min(1);
module.exports = [
{ method: 'GET', path: '/api/participant-lists', options: { handler: controller.list } },
{
method: 'POST',
path: '/api/participant-lists',
options: { validate: { payload: listCreatePayload }, handler: controller.create },
},
{ method: 'GET', path: '/api/participant-lists/{id}', options: { handler: controller.get } },
{
method: 'PUT',
path: '/api/participant-lists/{id}',
options: { validate: { payload: listUpdatePayload }, handler: controller.update },
},
{ method: 'DELETE', path: '/api/participant-lists/{id}', options: { handler: controller.remove } },
{ method: 'GET', path: '/api/participant-lists/{id}/members', options: { handler: controller.listMembers } },
];

View File

@ -0,0 +1,76 @@
const Joi = require('joi');
const controller = require('../controllers/participant.controller');
const participantPayload = Joi.object({
email: Joi.string().email().required(),
firstName: Joi.string().required(),
lastName: Joi.string().required(),
phone: Joi.string(),
organization: Joi.string(),
designation: Joi.string(),
tags: Joi.array().items(Joi.string()),
notes: Joi.string(),
});
const participantUpdatePayload = Joi.object({
email: Joi.string().email(),
firstName: Joi.string().allow(''),
lastName: Joi.string().allow(''),
phone: Joi.string().allow('', null),
organization: Joi.string().allow('', null),
designation: Joi.string().allow('', null),
tags: Joi.array().items(Joi.string()),
notes: Joi.string().allow('', null),
}).min(1);
const bulkPayload = Joi.object({
action: Joi.string()
.valid('add_tags', 'remove_tags', 'assign_events', 'add_to_fixed_list', 'delete')
.required(),
participantIds: Joi.array().items(Joi.string()).default([]),
selectAllMatching: Joi.boolean().default(false),
q: Joi.string().allow(''),
tag: Joi.string().allow(''),
tags: Joi.array().items(Joi.string()),
eventIds: Joi.array().items(Joi.string()),
listId: Joi.string(),
/** Tenant scope for select-all / super_admin (also accepts ?organization= on the request). */
organization: Joi.string(),
});
const importPayload = Joi.object({
csv: Joi.string().required().max(8 * 1024 * 1024),
});
const globalImportPayload = Joi.object({
csv: Joi.string().required().max(8 * 1024 * 1024),
eventIds: Joi.array().items(Joi.string()).default([]),
createCustomList: Joi.boolean().default(false),
listName: Joi.string().allow(''),
listDescription: Joi.string().allow(''),
});
module.exports = [
{ method: 'GET', path: '/api/participants/export', options: { handler: controller.exportAllCsv } },
{
method: 'POST',
path: '/api/participants/import',
options: { validate: { payload: globalImportPayload }, handler: controller.importGlobalCsv },
},
{ method: 'GET', path: '/api/events/{eventId}/participants/export', options: { handler: controller.exportCsv } },
{ method: 'POST', path: '/api/events/{eventId}/participants/import', options: { validate: { payload: importPayload }, handler: controller.importCsv } },
{ method: 'GET', path: '/api/events/{eventId}/participants', options: { handler: controller.list } },
{ method: 'POST', path: '/api/events/{eventId}/participants', options: { validate: { payload: participantPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/participants/tags', options: { handler: controller.listDistinctTags } },
{ method: 'GET', path: '/api/participants/matching-ids', options: { handler: controller.listMatchingIds } },
{
method: 'POST',
path: '/api/participants/bulk',
options: { validate: { payload: bulkPayload }, handler: controller.bulkParticipants },
},
{ method: 'GET', path: '/api/participants', options: { handler: controller.listAll } },
{ method: 'GET', path: '/api/participants/{id}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/participants/{id}', options: { validate: { payload: participantUpdatePayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/participants/{id}', options: { handler: controller.remove } },
{ method: 'POST', path: '/api/participants/{id}/check-in', options: { handler: controller.checkIn } },
];

View File

@ -0,0 +1,184 @@
const Joi = require('joi');
const payment = require('../controllers/payment.controller');
const platform = require('../controllers/platformPayment.controller');
const profile = require('../controllers/paymentProfile.controller');
const platformUpdatePayload = Joi.object({
enabled: Joi.boolean(),
testMode: Joi.boolean(),
defaultCommissionBps: Joi.number().min(0).max(10000),
razorpayKeyId: Joi.string().allow('', null),
razorpayKeySecret: Joi.string().allow('', null),
webhookSecret: Joi.string().allow('', null),
}).min(1);
const orgProfilePayload = Joi.object({
checkoutMode: Joi.string().valid('platform_razorpay', 'custom'),
platformCommissionBps: Joi.number().min(0).max(10000).allow(null),
defaultCurrency: Joi.string(),
taxRate: Joi.number().allow(null),
taxType: Joi.string().allow('', null),
}).min(1);
const addGatewayPayload = Joi.object({
provider: Joi.string()
.valid('razorpay', 'payu', 'ccavenue', 'cashfree', 'stripe', 'paytm')
.required(),
label: Joi.string().allow('', null),
credentials: Joi.object().default({}),
isPrimary: Joi.boolean(),
testMode: Joi.boolean(),
isActive: Joi.boolean(),
});
const patchGatewayPayload = Joi.object({
label: Joi.string().allow('', null),
isActive: Joi.boolean(),
testMode: Joi.boolean(),
isPrimary: Joi.boolean(),
credentials: Joi.object(),
}).min(1);
const orderPayload = Joi.object({
amountSubtotal: Joi.number().integer().positive().required(),
currency: Joi.string().length(3).uppercase(),
eventId: Joi.string().allow('', null),
participantId: Joi.string().allow('', null),
idempotencyKey: Joi.string().max(200).allow('', null),
formId: Joi.string().allow('', null),
});
const verifyPayload = Joi.object({
orderId: Joi.string().required(),
paymentId: Joi.string().required(),
signature: Joi.string().required(),
});
const publicOrderPayload = Joi.alternatives().try(
Joi.object({
amountSubtotal: Joi.number().integer().positive().required(),
currency: Joi.string().length(3).uppercase(),
participantId: Joi.string().required(),
idempotencyKey: Joi.string().max(200).allow('', null),
}),
Joi.object({
amountSubtotal: Joi.number().integer().positive().required(),
currency: Joi.string().length(3).uppercase(),
guestToken: Joi.string().required(),
email: Joi.string().email().required(),
idempotencyKey: Joi.string().max(200).allow('', null),
}),
);
module.exports = [
{ method: 'GET', path: '/api/platform/payment-config', options: { handler: platform.getPlatformPaymentConfig } },
{
method: 'PUT',
path: '/api/platform/payment-config',
options: { validate: { payload: platformUpdatePayload }, handler: platform.updatePlatformPaymentConfig },
},
{ method: 'GET', path: '/api/organization/payment-profile', options: { handler: profile.getOrgPaymentProfile } },
{
method: 'PUT',
path: '/api/organization/payment-profile',
options: { validate: { payload: orgProfilePayload }, handler: profile.updateOrgPaymentProfile },
},
{
method: 'POST',
path: '/api/organization/payment-profile/gateways',
options: { validate: { payload: addGatewayPayload }, handler: profile.addGateway },
},
{
method: 'PATCH',
path: '/api/organization/payment-profile/gateways/{id}',
options: { validate: { payload: patchGatewayPayload, params: Joi.object({ id: Joi.string().required() }) }, handler: profile.patchGateway },
},
{
method: 'DELETE',
path: '/api/organization/payment-profile/gateways/{id}',
options: { validate: { params: Joi.object({ id: Joi.string().required() }) }, handler: profile.deleteGateway },
},
{
method: 'GET',
path: '/api/payment/transactions',
options: {
validate: {
query: Joi.object({
q: Joi.string().allow(''),
eventId: Joi.string().allow(''),
status: Joi.string().valid('pending', 'paid', 'failed', 'refunded', ''),
from: Joi.string().allow(''),
to: Joi.string().allow(''),
page: Joi.number().integer().min(1),
limit: Joi.number().integer().min(1).max(100),
}),
},
handler: payment.listTransactions,
},
},
{
method: 'GET',
path: '/api/payment/transactions/export',
options: {
validate: {
query: Joi.object({
q: Joi.string().allow(''),
eventId: Joi.string().allow(''),
status: Joi.string().allow(''),
from: Joi.string().allow(''),
to: Joi.string().allow(''),
}),
},
handler: payment.exportTransactions,
},
},
{ method: 'GET', path: '/api/payment/transactions/{id}', options: { handler: payment.getTransaction } },
{
method: 'POST',
path: '/api/payment/create-order',
options: { validate: { payload: orderPayload }, handler: payment.createOrder },
},
{
method: 'POST',
path: '/api/payment/verify',
options: { validate: { payload: verifyPayload }, handler: payment.verify },
},
{
method: 'POST',
path: '/api/public/events/{eventId}/payment/create-order',
options: {
auth: false,
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
payload: publicOrderPayload,
},
handler: payment.publicCreateOrder,
},
},
{
method: 'POST',
path: '/api/public/events/{eventId}/payment/verify',
options: {
auth: false,
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
payload: verifyPayload,
},
handler: payment.publicVerify,
},
},
{
method: 'POST',
path: '/api/payment/webhooks/razorpay',
options: {
auth: false,
payload: {
output: 'data',
parse: false,
allow: 'application/json',
maxBytes: 1048576,
},
handler: payment.razorpayWebhook,
},
},
];

View File

@ -0,0 +1,25 @@
const Joi = require('joi');
const controller = require('../controllers/question.controller');
const questionPayload = Joi.object({
session: Joi.string(),
participant: Joi.string(),
question: Joi.string().required(),
isAnonymous: Joi.boolean(),
});
const answerPayload = Joi.object({
answer: Joi.string().required(),
isOfficial: Joi.boolean(),
});
module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/questions', options: { handler: controller.list } },
{ method: 'POST', path: '/api/events/{eventId}/questions', options: { validate: { payload: questionPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/questions/{id}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/questions/{id}', options: { validate: { payload: questionPayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/questions/{id}', options: { handler: controller.remove } },
{ method: 'POST', path: '/api/questions/{id}/upvote', options: { handler: controller.upvote } },
{ method: 'POST', path: '/api/questions/{id}/answer', options: { validate: { payload: answerPayload }, handler: controller.answer } },
{ method: 'GET', path: '/api/sessions/{sessionId}/questions', options: { handler: controller.sessionQuestions } },
];

View File

@ -0,0 +1,62 @@
const Joi = require('joi');
const controller = require('../controllers/quizPoll.controller');
const { QUIZ_POLL_TYPES, QUESTION_TYPES } = require('../config/constants');
const questionOptionSchema = Joi.object({
text: Joi.string().allow(''),
isCorrect: Joi.boolean(),
imageUrl: Joi.string().allow('', null),
});
const quizQuestionSchema = Joi.object({
question: Joi.string().required(),
imageUrl: Joi.string().allow('', null),
type: Joi.string().valid(...QUESTION_TYPES),
options: Joi.array().items(questionOptionSchema),
points: Joi.number(),
required: Joi.boolean(),
});
const quizPollPayload = Joi.object({
session: Joi.string(),
title: Joi.string().required(),
type: Joi.string().valid(...QUIZ_POLL_TYPES).required(),
description: Joi.string(),
questions: Joi.array().items(quizQuestionSchema),
settings: Joi.object({
status: Joi.string().valid('draft', 'active'),
isLive: Joi.boolean(),
displayMode: Joi.string().valid('always_live', 'always_sched', 'manual', 'quiz_master'),
startTime: Joi.date().allow(null),
endTime: Joi.date().allow(null),
showResults: Joi.boolean(),
allowMultipleAttempts: Joi.boolean(),
randomizeQuestions: Joi.boolean(),
timeRestricted: Joi.boolean(),
timeLimit: Joi.number().integer().min(0),
}),
});
const responsePayload = Joi.object({
participantId: Joi.string(),
responses: Joi.array().items(Joi.object({
questionIndex: Joi.number(),
answer: Joi.any(),
isCorrect: Joi.boolean(),
pointsEarned: Joi.number(),
})),
totalScore: Joi.number(),
timeSpent: Joi.number(),
});
module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/quiz-polls', options: { handler: controller.list } },
{ method: 'POST', path: '/api/events/{eventId}/quiz-polls', options: { validate: { payload: quizPollPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/quiz-polls/{id}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/quiz-polls/{id}', options: { validate: { payload: quizPollPayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/quiz-polls/{id}', options: { handler: controller.remove } },
{ method: 'POST', path: '/api/quiz-polls/{id}/publish', options: { handler: controller.publish } },
{ method: 'POST', path: '/api/quiz-polls/{id}/close', options: { handler: controller.close } },
{ method: 'GET', path: '/api/quiz-polls/{id}/results', options: { handler: controller.results } },
{ method: 'POST', path: '/api/quiz-polls/{quizPollId}/responses', options: { validate: { payload: responsePayload }, handler: controller.submitResponse } },
{ method: 'GET', path: '/api/quiz-polls/{quizPollId}/responses', options: { handler: controller.listResponses } },
{ method: 'GET', path: '/api/sessions/{sessionId}/quiz-polls', options: { handler: controller.sessionQuizPolls } },
];

View File

@ -0,0 +1,28 @@
const Joi = require('joi');
const controller = require('../controllers/rewardPoint.controller');
const { REWARD_SOURCES } = require('../config/constants');
const rewardPayload = Joi.object({
participant: Joi.string().required(),
points: Joi.number().required(),
type: Joi.string().valid('earned', 'redeemed', 'adjusted').required(),
source: Joi.string().valid(...REWARD_SOURCES),
sourceId: Joi.any(),
description: Joi.string(),
expiresAt: Joi.date(),
});
const bulkPayload = Joi.object({
entries: Joi.array().items(rewardPayload).min(1),
});
module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/reward-points', options: { handler: controller.list } },
{ method: 'POST', path: '/api/events/{eventId}/reward-points', options: { validate: { payload: rewardPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/reward-points/{id}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/reward-points/{id}', options: { validate: { payload: rewardPayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/reward-points/{id}', options: { handler: controller.remove } },
{ method: 'GET', path: '/api/participants/{participantId}/reward-points', options: { handler: controller.byParticipant } },
{ method: 'GET', path: '/api/events/{eventId}/leaderboard', options: { handler: controller.leaderboard } },
{ method: 'POST', path: '/api/events/{eventId}/reward-points/bulk', options: { validate: { payload: bulkPayload }, handler: controller.bulk } },
];

View File

@ -0,0 +1,34 @@
const Joi = require('joi');
const controller = require('../controllers/session.controller');
const { SESSION_TYPES, SESSION_STATUS } = require('../config/constants');
const sessionPayload = Joi.object({
title: Joi.string().required(),
description: Joi.string().allow('', null),
type: Joi.string().valid(...SESSION_TYPES),
startTime: Joi.date().iso().required(),
endTime: Joi.date().iso().required(),
location: Joi.string().allow('', null),
track: Joi.string().allow('', null),
speakers: Joi.array().items(Joi.string()),
moderator: Joi.string().allow('', null),
capacity: Joi.number(),
status: Joi.string().valid(...SESSION_STATUS),
resources: Joi.array().items(Joi.object({ name: Joi.string(), type: Joi.string(), url: Joi.string() })),
});
module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/sessions', options: { handler: controller.list } },
{ method: 'POST', path: '/api/events/{eventId}/sessions', options: { validate: { payload: sessionPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/sessions/{id}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/sessions/{id}', options: { validate: { payload: sessionPayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/sessions/{id}', options: { handler: controller.remove } },
{
method: 'PUT',
path: '/api/sessions/{id}/status',
options: {
validate: { payload: Joi.object({ status: Joi.string().valid(...SESSION_STATUS).required() }) },
handler: controller.updateStatus,
},
},
];

View File

@ -0,0 +1,38 @@
const Joi = require('joi');
const controller = require('../controllers/speaker.controller');
const logisticsSchema = Joi.object({
honorarium: Joi.boolean(),
honorariumAmount: Joi.alternatives().try(Joi.string(), Joi.number()).allow('', null),
travelRequired: Joi.boolean(),
accommodation: Joi.boolean(),
dietaryRequirements: Joi.string().allow('', null),
internalNotes: Joi.string().allow('', null),
});
const speakerPayload = Joi.object({
firstName: Joi.string().required(),
lastName: Joi.string().required(),
email: Joi.string().email().required(),
phone: Joi.string().allow('', null),
title: Joi.string().allow('', null),
organizationName: Joi.string().allow('', null),
bio: Joi.string().allow('', null),
photo: Joi.string().allow('', null),
socialLinks: Joi.object({ linkedin: Joi.string(), twitter: Joi.string(), website: Joi.string() }),
expertise: Joi.array().items(Joi.string()),
isFeatured: Joi.boolean(),
organization: Joi.string(),
speakerRole: Joi.string().allow('', null),
profileVisibility: Joi.string().valid('public', 'private', 'Public', 'Private').allow('', null),
logistics: logisticsSchema.allow(null),
});
module.exports = [
{ method: 'GET', path: '/api/speakers', options: { handler: controller.list } },
{ method: 'POST', path: '/api/speakers', options: { validate: { payload: speakerPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/speakers/{id}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/speakers/{id}', options: { validate: { payload: speakerPayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/speakers/{id}', options: { handler: controller.remove } },
{ method: 'GET', path: '/api/events/{eventId}/speakers', options: { handler: controller.byEvent } },
];

View File

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

View File

@ -0,0 +1,21 @@
const Joi = require('joi');
const controller = require('../controllers/track.controller');
const trackPayload = Joi.object({
name: Joi.string().min(1).max(200).required(),
hall: Joi.string().allow('', null),
trackType: Joi.string().valid('general', 'workshop', 'plenary', 'symposium'),
status: Joi.string().valid('active', 'inactive'),
sortOrder: Joi.number(),
});
module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/tracks', options: { handler: controller.list } },
{
method: 'POST',
path: '/api/events/{eventId}/tracks',
options: { validate: { payload: trackPayload }, handler: controller.create },
},
{ method: 'PUT', path: '/api/tracks/{id}', options: { validate: { payload: trackPayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/tracks/{id}', options: { handler: controller.remove } },
];

View File

@ -0,0 +1,361 @@
const Joi = require('joi');
const controller = require('../controllers/visitor.controller');
const visitorAuth = require('../controllers/visitorAuth.controller');
const joinPayload = Joi.object({
email: Joi.string().email().required(),
joinType: Joi.string().valid('event', 'session', 'track').default('event'),
sessionId: Joi.string().allow('', null),
trackId: Joi.string().allow('', null),
inviteToken: Joi.string().allow('', null),
});
const verifyOtpPayload = Joi.object({
email: Joi.string().email().required(),
otp: Joi.string().min(4).max(8).required(),
inviteToken: Joi.string().allow('', null),
firstName: Joi.string().allow('', null),
lastName: Joi.string().allow('', null),
phone: Joi.string().allow('', null),
displayName: Joi.string().allow('', null),
});
const exchangePayload = Joi.object({
token: Joi.string().required(),
});
const guestPayload = Joi.object({
joinType: Joi.string().valid('event', 'session', 'track').default('event'),
sessionId: Joi.string().allow('', null),
trackId: Joi.string().allow('', null),
inviteToken: Joi.string().allow('', null),
guestToken: Joi.string().allow('', null),
displayName: Joi.string().allow('', null),
});
const customFieldValueSchema = Joi.alternatives().try(
Joi.string().allow(''),
Joi.number(),
Joi.boolean(),
Joi.array().items(Joi.string())
);
const profilePayload = Joi.object({
firstName: Joi.string().allow('', null),
lastName: Joi.string().allow('', null),
phone: Joi.string().allow('', null),
displayName: Joi.string().allow('', null),
customFields: Joi.object().pattern(Joi.string().max(120), customFieldValueSchema).max(40),
});
const visitorQuizPollResponsePayload = Joi.object({
responses: Joi.array()
.items(
Joi.object({
questionIndex: Joi.number().integer().min(0).required(),
answer: Joi.any().allow(null),
isCorrect: Joi.boolean(),
pointsEarned: Joi.number(),
}),
)
.default([]),
totalScore: Joi.number(),
timeSpent: Joi.number(),
});
const micrositeContactPayload = Joi.object({
eventId: Joi.string().required(),
notifyEmail: Joi.string().email().required(),
firstName: Joi.string().allow('', null),
lastName: Joi.string().allow('', null),
email: Joi.string().email().required(),
message: Joi.string().allow('', null),
/** Honeypot — must be empty */
_gotcha: Joi.string().allow('', null),
});
module.exports = [
{
method: 'POST',
path: '/api/visitor/resolve-code',
options: {
auth: false,
validate: {
payload: Joi.object({
code: Joi.string().trim().min(1).required(),
}),
},
handler: controller.resolveCode,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/login-preview',
options: {
auth: false,
validate: {
params: Joi.object({
eventId: Joi.string().required(),
}),
},
handler: controller.loginPreview,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/tracks',
options: {
auth: false,
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
},
handler: controller.listVisitorEventTracks,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/agenda',
options: {
auth: false,
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
},
handler: controller.listVisitorEventAgenda,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/quiz-polls',
options: {
auth: false,
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
},
handler: controller.listVisitorEventQuizPolls,
},
},
{
method: 'GET',
path: '/api/visitor/sessions/{sessionId}/preview',
options: {
auth: false,
validate: {
params: Joi.object({ sessionId: Joi.string().required() }),
query: Joi.object({ event_id: Joi.string().required() }),
},
handler: controller.sessionPreview,
},
},
{
method: 'GET',
path: '/api/visitor/tracks/{trackId}/preview',
options: {
auth: false,
validate: {
params: Joi.object({ trackId: Joi.string().required() }),
query: Joi.object({ event_id: Joi.string().required() }),
},
handler: controller.trackPreview,
},
},
{
method: 'GET',
path: '/api/visitor/sessions/{sessionId}/questions',
options: {
auth: false,
validate: {
params: Joi.object({ sessionId: Joi.string().required() }),
query: Joi.object({ event_id: Joi.string().required() }),
},
handler: controller.listSessionQuestions,
},
},
{
method: 'POST',
path: '/api/visitor/sessions/{sessionId}/questions',
options: {
auth: { mode: 'optional', strategy: 'jwt-attendee' },
validate: {
params: Joi.object({ sessionId: Joi.string().required() }),
query: Joi.object({ event_id: Joi.string().required() }),
payload: Joi.object({
question: Joi.string().trim().min(1).max(4000).required(),
isAnonymous: Joi.boolean().default(false),
}),
},
handler: controller.createSessionQuestion,
},
},
{
method: 'POST',
path: '/api/visitor/sessions/{sessionId}/questions/{questionId}/vote',
options: {
auth: { mode: 'optional', strategy: 'jwt-attendee' },
validate: {
params: Joi.object({
sessionId: Joi.string().required(),
questionId: Joi.string().required(),
}),
query: Joi.object({ event_id: Joi.string().required() }),
payload: Joi.object({
voterKey: Joi.string().trim().min(8).max(128).required(),
direction: Joi.string().valid('up', 'down').required(),
}),
},
handler: controller.voteSessionQuestion,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/registration-status',
options: {
auth: false,
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
query: Joi.object({ email: Joi.string().email().required() }),
},
handler: visitorAuth.registrationStatus,
},
},
{
method: 'POST',
path: '/api/visitor/events/{eventId}/auth/start-otp',
options: {
auth: false,
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
payload: joinPayload,
},
handler: visitorAuth.startOtp,
},
},
{
method: 'POST',
path: '/api/visitor/events/{eventId}/auth/verify-otp',
options: {
auth: false,
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
payload: verifyOtpPayload,
},
handler: visitorAuth.verifyOtp,
},
},
{
method: 'POST',
path: '/api/visitor/events/{eventId}/auth/start-link',
options: {
auth: false,
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
payload: joinPayload,
},
handler: visitorAuth.startLink,
},
},
{
method: 'POST',
path: '/api/visitor/auth/exchange-link',
options: {
auth: false,
validate: { payload: exchangePayload },
handler: visitorAuth.exchangeLink,
},
},
{
method: 'POST',
path: '/api/visitor/events/{eventId}/auth/guest',
options: {
auth: false,
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
payload: guestPayload,
},
handler: visitorAuth.guestStart,
},
},
{
method: 'PATCH',
path: '/api/visitor/me/profile',
options: {
auth: 'jwt-attendee',
validate: { payload: profilePayload },
handler: visitorAuth.updateProfile,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/session-bookmarks',
options: {
auth: 'jwt-attendee',
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
},
handler: controller.listVisitorSessionBookmarks,
},
},
{
method: 'PUT',
path: '/api/visitor/events/{eventId}/session-bookmarks',
options: {
auth: 'jwt-attendee',
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
payload: Joi.object({
sessionId: Joi.string().required(),
bookmarked: Joi.boolean().required(),
}),
},
handler: controller.putVisitorSessionBookmark,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/reward-balance',
options: {
auth: 'jwt-attendee',
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
},
handler: controller.visitorEventRewardBalance,
},
},
{
method: 'POST',
path: '/api/visitor/events/{eventId}/quiz-polls/{quizPollId}/responses',
options: {
auth: 'jwt-attendee',
validate: {
params: Joi.object({
eventId: Joi.string().required(),
quizPollId: Joi.string().required(),
}),
payload: visitorQuizPollResponsePayload,
},
handler: controller.submitVisitorQuizPollResponse,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/quiz-polls/{quizPollId}/results-summary',
options: {
auth: 'jwt-attendee',
validate: {
params: Joi.object({
eventId: Joi.string().required(),
quizPollId: Joi.string().required(),
}),
},
handler: controller.getVisitorQuizPollResultsSummary,
},
},
{
method: 'POST',
path: '/api/visitor/microsite/contact',
options: {
auth: false,
validate: { payload: micrositeContactPayload },
handler: controller.submitMicrositeContact,
},
},
];

View File

@ -0,0 +1,73 @@
const Joi = require('joi');
const controller = require('../controllers/websiteSettings.controller');
const { WEBSITE_SECTION_TYPES } = require('../config/constants');
const mongoId = Joi.string().length(24).hex().required();
const optionalMongoId = Joi.string().length(24).hex().allow('', null);
const websiteSettingsPayload = Joi.object({
enabled: Joi.boolean(),
folder: Joi.string().allow('', null),
logo: Joi.string().allow('', null),
banners: Joi.array().items(Joi.string()),
favicon: Joi.string().allow('', null),
title: Joi.string().allow('', null),
description: Joi.string().allow('', null),
keywords: Joi.array().items(Joi.string()),
templateId: Joi.string().allow('', null),
layoutTemplate: Joi.string().valid('conference'),
seoDescription: Joi.string().allow('', null),
micrositeSections: Joi.array().items(Joi.string().valid(...WEBSITE_SECTION_TYPES)),
primaryColor: Joi.string().max(32).allow('', null),
accentColor: Joi.string().max(32).allow('', null),
fontPair: Joi.string().allow('', null),
registrationUrl: Joi.string().max(2048).allow('', null),
registrationFormId: optionalMongoId,
micrositeHtml: Joi.string().allow('').max(2000000),
});
module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/website-settings', options: { validate: { params: Joi.object({ eventId: mongoId }) }, handler: controller.get } },
{
method: 'GET',
path: '/api/events/{eventId}/website-settings/microsite-preview-html',
options: {
validate: {
params: Joi.object({ eventId: mongoId }),
query: Joi.object({
micrositeId: Joi.string().length(24).hex().allow('', null),
}),
},
handler: controller.previewMicrositeHtml,
},
},
{ method: 'PUT', path: '/api/events/{eventId}/website-settings', options: { validate: { params: Joi.object({ eventId: mongoId }), payload: websiteSettingsPayload }, handler: controller.update } },
{
method: 'GET',
path: '/api/events/{eventId}/website-settings/check-folder',
options: {
validate: {
params: Joi.object({ eventId: mongoId }),
query: Joi.object({
folder: Joi.string().required(),
micrositeId: Joi.string().length(24).hex().allow('', null),
}),
},
handler: controller.checkFolder,
},
},
{
method: 'POST',
path: '/api/events/{eventId}/website/publish-microsite',
options: {
validate: {
params: Joi.object({ eventId: mongoId }),
payload: Joi.object({
targets: Joi.string().valid('draft', 'live', 'both'),
micrositeId: Joi.string().length(24).hex().allow('', null),
}),
},
handler: controller.publishMicrosite,
},
},
];

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