First Version
This commit is contained in:
parent
6c558c162e
commit
7b1777c4ba
|
|
@ -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)
|
||||
|
|
@ -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/
|
||||
|
|
|
|||
85
README.md
85
README.md
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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 },
|
||||
},
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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 },
|
||||
},
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
const controller = require('../controllers/subscription.controller');
|
||||
|
||||
module.exports = [{ method: 'GET', path: '/api/subscription/catalog', options: { handler: controller.catalog } }];
|
||||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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
Loading…
Reference in New Issue