Build a Full-Stack Ticket Booking System

Build a MERN app with real-time seat selection, payments, and AI features.

Introduction

30 Second Summary

Every time you book a movie ticket or concert seat online, you interact with real-time seat grids, instant payment processing, and automated ticket delivery. Behind that seamless two-second experience is a full-stack architecture coordinating WebSockets, payment gateways, and email services.

In this project, you will build a Universal Ticket Booking System from scratch. You'll create a platform where users browse events, select seats in real time, pay with Stripe, and receive PDF tickets via email.

What You'll Build

A full-stack MERN application with real-time seat selection via Socket.IO, Stripe payment processing, PDF ticket generation with QR codes, email delivery, role-based dashboards, and AI-powered recommendations.

By the end of this project, you'll have:

  • A working Express.js API with JWT authentication, Google OAuth, and role-based access control for Users, Vendors, and Admins.
  • Real-time seat selection powered by WebSockets, Stripe payment integration, and automated PDF ticket delivery via email.
  • An admin analytics dashboard with Recharts visualizations, a recommendation engine, and dynamic pricing algorithms.
  • Secret Mission: A no-show prediction model that analyzes booking patterns to forecast attendance probability.

Are there any prerequisites?

This project assumes intermediate JavaScript knowledge, basic React and Node.js experience, and familiarity with REST APIs. You should be comfortable creating components in React and setting up basic Express routes.

Set Up the Project and Environment

A full-stack ticket booking system has many moving parts. You need a backend API, a frontend interface, a database, payment processing, and email delivery all working together.

Before writing any feature code, you need a solid foundation. In this step, you will scaffold a monorepo with separate backend and frontend projects, install all required packages, configure your MongoDB Atlas database cluster, and wire up your environment variables.

In this step, get ready to:
  • Initialize a monorepo with backend and frontend projects and install all dependencies.
  • Create a MongoDB Atlas Free Cluster and configure database access.
  • Create an environment file with all service credentials.
Initialize the monorepo structure

A monorepo keeps your backend and frontend code in one top-level directory. This makes it easy to share configuration and run both servers during development.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open a terminal in VS Code by pressing Ctrl+` (backtick key, above Tab) on both macOS and Windows.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir universal-ticket-booking
cd universal-ticket-booking
  • Initialize the backend project by running these commands:
mkdir server
cd server
npm init -y

What does npm init -y do?

This creates a package.json file with default values. The -y flag skips all the interactive prompts and accepts defaults.

  • Install all backend dependencies by running this command:
npm install express mongoose jsonwebtoken bcryptjs cors dotenv socket.io stripe nodemailer pdfkit qrcode passport passport-google-oauth20 cookie-parser

What are all these packages?

Here is a quick summary of what each package does:

  • express is the web server framework for your API routes.
  • mongoose connects your app to MongoDB and defines data models.
  • jsonwebtoken and bcryptjs handle authentication (tokens and password hashing).
  • socket.io enables real-time WebSocket communication for live seat updates.
  • stripe processes payments through the Stripe API.
  • nodemailer sends emails (booking confirmations).
  • pdfkit and qrcode generate PDF tickets with QR codes.
  • passport and passport-google-oauth20 handle Google OAuth login.
  • Confirm the install succeeded by running:
ls node_modules

You should see a long list of package folder names in the output.

Seeing permission errors or EACCES?

This usually means your Node.js installation has a permissions issue. Try prefixing the command with sudo on macOS/Linux, or run your terminal as Administrator on Windows.

help me fix npm permission errors on my machine

  • Now initialize the frontend. Move back to the project root and scaffold a React app with Vite by running:
cd ..
npm create vite@latest client -- --template react

What is Vite?

Vite is a modern build tool that starts a development server almost instantly. It is the recommended way to scaffold new React projects.

  • Move into the new client folder and install the scaffolded dependencies by running:
cd client
npm install
  • Install the additional frontend dependencies your project needs by running:
npm install socket.io-client axios react-router-dom recharts react-is @stripe/stripe-js @stripe/react-stripe-js
  • Verify your frontend packages installed by running:
ls node_modules | head -20

You should see a list of package folders. The exact names will vary, but seeing output confirms the install worked.

Vite command not recognized?

Make sure you are running Node.js 18 or higher. Run node --version to check. If you see a version below 18, update Node.js from the official website.

help me fix the Vite scaffolding command

  • Return to the project root by running:
cd ..
  • Verify your monorepo structure by running:
ls

You should see both client and server listed in the output.

✔️ I see client and server folders

Your monorepo structure is ready. Both projects are initialized with all their dependencies.

ⓧ I only see one folder or neither

Make sure you are in the universal-ticket-booking directory. Run pwd to check your current location. If you are inside client or server, run cd .. to go up one level and try ls again.

Configure MongoDB Atlas Free Cluster

Your application needs a database to store users, events, and bookings. MongoDB Atlas provides a free cloud-hosted cluster that works perfectly for development.

  • Sign in to your MongoDB Atlas account at cloud.mongodb.com.
  • Click Build a Database (or Create if you already have a project).
  • Select the M0 Free tier.
  • Choose any cloud provider and region close to you.
  • Name your cluster Cluster0 (or keep the default name).
  • Click Create Deployment.

What is an M0 Free Cluster?

The M0 tier gives you 512 MB of storage and up to 500 connections at no cost. It never expires and is more than enough for development and demo purposes.

While your cluster deploys, set up database access credentials.

  • In the left sidebar, click Database Access under the Security section.
  • Click Add New Database User.
  • Choose Password authentication.
  • Enter a username and a strong password. Save these somewhere safe. You will need them for your connection string.
  • Set the role to Read and write to any database.
  • Click Add User.

Next, whitelist your IP address so your local machine can connect to the cluster.

  • In the left sidebar, click Network Access under Security.
  • Click Add IP Address.
  • Click Allow Access from Anywhere to set the address to 0.0.0.0/0.
  • Click Confirm.

Why allow access from anywhere?

Setting 0.0.0.0/0 means any IP can connect. This is fine for development because your database is still protected by the username and password. For a production app, you would restrict this to specific server IPs.

Now grab your connection string.

  • Go back to Database in the left sidebar.
  • Click Connect on your cluster.
  • Select Drivers as the connection method.
  • Copy the connection string. It looks like this:
mongodb+srv://<db_username>:<db_password>@cluster0.mongodb.net/?retryWrites=true&w=majority

Keep this string handy. You will paste it into your environment file in the next substep.

Create the environment file

Environment variables keep sensitive credentials out of your source code. You store them in a .env file that is never committed to version control.

  • In your terminal, move into the server folder by running:
cd server
  • Create the .env file by running:
touch .env
  • Open .env in VS Code. In the Explorer sidebar, expand the server folder and click .env.
  • Paste the following template into the file:
# MongoDB connection string (replace <user> and <password> with your Atlas credentials)
MONGO_URI=mongodb+srv://<user>:<password>@cluster0.mongodb.net/ticketdb

# Secret key for signing JWT tokens (any random string)
JWT_SECRET=your_jwt_secret

# Stripe API keys from your Stripe dashboard
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...

# Gmail credentials for sending emails
GMAIL_USER=you@gmail.com
GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx

# Google OAuth credentials from Google Cloud Console
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret

# App configuration
CLIENT_URL=http://localhost:3000
PORT=5000

What does each variable do?

  • MONGO_URI connects your server to MongoDB Atlas.
  • JWT_SECRET is used to sign authentication tokens. Pick any random string.
  • STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY are your Stripe test-mode keys from the Stripe Dashboard.
  • GMAIL_USER and GMAIL_APP_PASSWORD let Nodemailer send emails through your Gmail.
  • GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET enable Google OAuth login.
  • Replace each placeholder value with your actual credentials:
  • For MONGO_URI: paste your Atlas connection string and replace <user> and <password> with the database user credentials you just created. Keep /ticketdb at the end as your database name.
  • For STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY: copy your test keys from the Stripe Dashboard. They start with sk_test_ and pk_test_.
  • For GMAIL_USER: enter your Gmail address.
  • For GMAIL_APP_PASSWORD: paste the 16-character App Password you generated earlier.
  • For GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET: paste the OAuth credentials from your Google Cloud Console project.

Never commit your .env file

Your .env file contains secrets. If you ever push this project to GitHub, add .env to your .gitignore file so it is never committed.

  • Save the file.

✔️ Awesome, I've got everything!

Great. Double check you have saved .env with all your actual credentials filled in.

ⓧ I'd like to double check the full code

Your server/.env file should look like this (with your real values in place of the placeholders):

MONGO_URI=mongodb+srv://<user>:<password>@cluster0.mongodb.net/ticketdb
JWT_SECRET=your_jwt_secret
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
GMAIL_USER=you@gmail.com
GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
CLIENT_URL=http://localhost:3000
PORT=5000

Not sure where to find your Stripe keys?

In your Stripe Dashboard, make sure you are in test mode (toggle at the top). Your keys are on the main Developers page. The publishable key starts with pk_test_ and the secret key starts with sk_test_.

help me find my Stripe test API keys

Your project foundation is solid. You have a monorepo with both backend and frontend initialized, all dependencies installed, a live MongoDB cluster, and credentials wired up. Next, you will build the authentication system that lets users register, log in, and manage their accounts.

Build the User Authentication System

Your project structure and database are ready. Now you need a way for users to create accounts, log in, and prove their identity on every request.

In this step, you will build a complete authentication system with JWT tokens for session management and Google OAuth as an alternative login method. You will also add password reset via email and protected profile routes.

In this step, get ready to:
  • Create the User model with bcrypt password hashing and role-based access.
  • Build JWT authentication routes and middleware for protected endpoints.
  • Add Google OAuth login and password reset functionality with email delivery.
Build the User model and auth middleware

Every authentication system starts with a data model that defines what a user looks like and middleware that verifies identity on each request.

  • Create the server/models/ directory, then create a file called User.js inside it:
mkdir server/models
touch server/models/User.js
  • Open server/models/User.js and add the User schema by pasting this code:
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  password: { type: String },
  role: { type: String, enum: ['user','vendor','admin'], default: 'user' },
  profilePicture: { type: String },
  walletBalance: { type: Number, default: 100 },
  bookings: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Booking' }],
  resetPasswordToken: String,
  resetPasswordExpires: Date
}, { timestamps: true });

// Hash password before saving if it was modified
userSchema.pre('save', async function(next) {
  if (!this.isModified('password') || !this.password) return next();
  this.password = await bcrypt.hash(this.password, 10);
  next();
});

module.exports = mongoose.model('User', userSchema);

What does this code do?

The schema defines all the fields a user can have: name, email, a hashed password, a role (user, vendor, or admin), and a wallet balance that starts at 100 credits.

The pre('save') hook automatically hashes the password with bcryptjs before saving to the database. This means you never store plain-text passwords.

  • Save server/models/User.js.
  • Create the server/middleware/ directory and a file called auth.js inside it:
mkdir server/middleware
touch server/middleware/auth.js
  • Open server/middleware/auth.js and add the JWT verification middleware:
const jwt = require('jsonwebtoken');
const User = require('../models/User');

// Verify JWT token and attach user to request
exports.protect = async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ message: 'Not authorized' });
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = await User.findById(decoded.id).select('-password');
    next();
  } catch (err) {
    res.status(401).json({ message: 'Token invalid' });
  }
};

// Restrict access to specific roles
exports.requireRole = (...roles) => (req, res, next) => {
  if (!roles.includes(req.user.role)) return res.status(403).json({ message: 'Forbidden' });
  next();
};

What does this code do?

The protect middleware extracts the JWT from the Authorization header, verifies it, and attaches the full user object to req.user. Any route using this middleware requires a valid token.

The requireRole middleware checks that the authenticated user has one of the allowed roles. This powers role-based access control for vendor and admin routes later.

  • Save server/middleware/auth.js.

You won't see a visible change yet. These foundational files are used by the routes and server entry point you will create next.

Add Google OAuth with Passport.js and create auth routes

Passport.js handles the OAuth handshake with Google so you do not need to manually exchange authorization codes for tokens.

What is OAuth?

OAuth lets users log in with their existing Google account instead of creating a new password. Your server redirects the user to Google, Google authenticates them, then sends them back to your callback URL with their profile info.

  • In the Google Cloud Console, navigate to APIs & Services, then Credentials.
  • Click Create Credentials and select OAuth client ID.
  • Set the application type to Web application.
  • Under Authorized redirect URIs, add http://localhost:5000/api/auth/google/callback.
  • Copy the Client ID and Client Secret into your server/.env file as GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.
  • Create the server/config/ directory and passport.js inside it:
mkdir server/config
touch server/config/passport.js
  • Open server/config/passport.js and add the Google strategy:
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const User = require('../models/User');

passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  callbackURL: 'http://localhost:5000/api/auth/google/callback'
}, async (accessToken, refreshToken, profile, done) => {
  try {
    // Check if user already exists with this email
    let user = await User.findOne({ email: profile.emails[0].value });
    if (!user) {
      // Create new user from Google profile
      user = await User.create({ name: profile.displayName, email: profile.emails[0].value, profilePicture: profile.photos[0].value, role: 'user' });
    }
    done(null, user);
  } catch (err) { done(err, null); }
}));

What does this code do?

The GoogleStrategy tells Passport how to verify a Google login. When a user authenticates through Google, the callback checks if they already exist in your database by email.

If they are new, it creates an account using their Google profile data. If they already exist, it returns the existing user. Either way, the user object is passed to the route handler.

  • Save server/config/passport.js.
  • Create the server/services/ directory and an emailService.js file inside it:
mkdir server/services
touch server/services/emailService.js
  • Open server/services/emailService.js and add the email transporter for password resets:
const nodemailer = require('nodemailer');

// Configure Gmail transporter with App Password
const transporter = nodemailer.createTransport({
  service: 'gmail',
  auth: {
    user: process.env.GMAIL_USER,
    pass: process.env.GMAIL_APP_PASSWORD
  }
});

// Send password reset email with a tokenized link
exports.sendResetEmail = async (email, token) => {
  const resetUrl = `${process.env.CLIENT_URL}/reset-password/${token}`;
  await transporter.sendMail({
    from: process.env.GMAIL_USER,
    to: email,
    subject: 'Password Reset',
    html: `<p>Click <a href="${resetUrl}">here</a> to reset your password. Link expires in 1 hour.</p>`
  });
};

What does this code do?

Nodemailer connects to Gmail using the App Password you configured in Step 1. The sendResetEmail function generates a reset URL containing a unique token and emails it to the user.

  • Save server/services/emailService.js.
  • Create the server/routes/ directory and a file called auth.js inside it:
mkdir server/routes
touch server/routes/auth.js
  • Open server/routes/auth.js and add all authentication routes:
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const passport = require('passport');
const User = require('../models/User');
const { sendResetEmail } = require('../services/emailService');

// Register a new user with name, email, password, and role
router.post('/register', async (req, res) => {
  const { name, email, password, role } = req.body;
  try {
    const user = await User.create({ name, email, password, role });
    const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
    res.json({ token, user: { id: user._id, name, email, role } });
  } catch (err) { res.status(400).json({ message: err.message }); }
});

// Login with email and password, return JWT
router.post('/login', async (req, res) => {
  const { email, password } = req.body;
  try {
    const user = await User.findOne({ email });
    if (!user || !await bcrypt.compare(password, user.password)) return res.status(401).json({ message: 'Invalid credentials' });
    const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
    res.json({ token, user: { id: user._id, name: user.name, email, role: user.role } });
  } catch (err) { res.status(500).json({ message: err.message }); }
});

// Send password reset email with a unique token
router.post('/forgot-password', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });
  if (!user) return res.status(404).json({ message: 'User not found' });
  const token = crypto.randomBytes(20).toString('hex');
  user.resetPasswordToken = token;
  user.resetPasswordExpires = Date.now() + 3600000;
  await user.save();
  await sendResetEmail(user.email, token);
  res.json({ message: 'Reset email sent' });
});

// Reset password using the emailed token
router.post('/reset-password/:token', async (req, res) => {
  const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } });
  if (!user) return res.status(400).json({ message: 'Invalid or expired token' });
  user.password = req.body.password;
  user.resetPasswordToken = undefined;
  user.resetPasswordExpires = undefined;
  await user.save();
  res.json({ message: 'Password reset successful' });
});

// Redirect user to Google for authentication
router.get('/google', passport.authenticate('google', { scope: ['profile','email'] }));
router.get('/google/callback', passport.authenticate('google', { session: false }), (req, res) => {
  const token = jwt.sign({ id: req.user._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
  res.redirect(`${process.env.CLIENT_URL}/oauth-success?token=${token}`);
});

module.exports = router;

What does this code do?

This file contains all authentication endpoints. The /register route creates a user (the password is auto-hashed by the model's pre-save hook) and returns a JWT. The /login route compares the password hash and issues a token.

The /forgot-password route generates a random token, saves it on the user document with a 1-hour expiry, and emails a reset link. The /reset-password/:token route validates the token and updates the password.

The Google OAuth routes redirect the user to Google's consent screen, then handle the callback by issuing a JWT and redirecting back to the frontend.

  • Save server/routes/auth.js.

Seeing a module not found error?

Make sure your directory structure matches: server/models/User.js, server/services/emailService.js, and server/config/passport.js must all exist. Node resolves require() paths relative to the current file.

help me debug a module not found error in my Express auth routes

Implement profile routes and the server entry point

With the authentication logic in place, you need user profile routes and an Express server that ties everything together.

  • Create server/routes/users.js for profile management:
touch server/routes/users.js
  • Open server/routes/users.js and add the profile routes:
const express = require('express');
const router = express.Router();
const { protect } = require('../middleware/auth');
const User = require('../models/User');

// Get the authenticated user's profile
router.get('/profile', protect, async (req, res) => {
  res.json(req.user);
});

// Update the authenticated user's profile
router.put('/profile', protect, async (req, res) => {
  const { name, profilePicture, password } = req.body;
  const user = await User.findById(req.user._id);
  if (name) user.name = name;
  if (profilePicture) user.profilePicture = profilePicture;
  if (password) user.password = password;
  await user.save();
  res.json({ message: 'Profile updated' });
});

module.exports = router;

What does this code do?

Both routes use the protect middleware, which means only users with a valid JWT can access them. The GET route returns the user object (without the password field, since protect uses .select('-password')).

The PUT route allows updating name, profile picture, or password. If the password field is sent, the model's pre('save') hook automatically hashes it before storing.

  • Save server/routes/users.js.
  • Create the server entry point by running:
touch server/index.js
  • Open server/index.js and add the Express server setup:
require('dotenv').config();
const express = require('express');
const http = require('http');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const mongoose = require('mongoose');
const passport = require('passport');
require('./config/passport');

const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');

const app = express();
const server = http.createServer(app);

// Middleware setup
app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
app.use(express.json());
app.use(cookieParser());
app.use(passport.initialize());

// Mount routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);

// Connect to MongoDB and start server
mongoose.connect(process.env.MONGO_URI).then(() => {
  server.listen(process.env.PORT || 5000, () => console.log('Server running'));
});

What does this code do?

This is the application entry point. It loads environment variables, configures middleware (CORS, JSON parsing, cookies, Passport), mounts the auth and user route files, then connects to MongoDB Atlas before starting the HTTP server on port 5000.

The http.createServer(app) pattern wraps Express in a raw HTTP server. You will attach Socket.IO to this same server instance in a later step for real-time seat selection.

  • Save server/index.js.
  • Start the server by running this command from inside the server/ directory:
cd server
npm start
  • You should see Server running printed in the terminal. This confirms Express started and MongoDB connected successfully.

Server crashes on startup?

Check that your MONGO_URI in server/.env has the correct username, password, and cluster name. Also verify your IP address is whitelisted in MongoDB Atlas under Network Access.

If you see a module not found error, confirm every file is saved in the correct directory path.

help me debug why my Express server won't start

  • Now create the frontend auth pages. Inside your client/src/ directory, create a pages/ folder:
mkdir client/src/pages
touch client/src/pages/Login.jsx client/src/pages/Register.jsx client/src/pages/ForgotPassword.jsx
  • Open client/src/pages/Login.jsx and build the login page:
// Login page with email/password form and Google OAuth button
// Calls POST /api/auth/login, stores JWT in localStorage, redirects by role
  • Open client/src/pages/Register.jsx and build the registration page:
// Register page with name, email, password, role selector
// Calls POST /api/auth/register
  • Open client/src/pages/ForgotPassword.jsx and build the password reset request page:
// Forgot password page, calls POST /api/auth/forgot-password

What should these pages include?

The Login page needs an email/password form that sends a POST request to /api/auth/login using axios. On success, store the JWT in localStorage and redirect based on the user's role. Include a button that links to /api/auth/google for Google OAuth.

The Register page needs fields for name, email, password, and a role dropdown (user or vendor). It calls POST /api/auth/register. The ForgotPassword page needs an email field that calls POST /api/auth/forgot-password.

Use react-router-dom to set up protected routes that check for a JWT in localStorage before allowing access to authenticated pages.

  • Save all three files.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files in the correct directories.

ⓧ I'd like to double check the full code

Here is the complete server/index.js:

require('dotenv').config();
const express = require('express');
const http = require('http');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const mongoose = require('mongoose');
const passport = require('passport');
require('./config/passport');

const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');

const app = express();
const server = http.createServer(app);

app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
app.use(express.json());
app.use(cookieParser());
app.use(passport.initialize());

app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);

mongoose.connect(process.env.MONGO_URI).then(() => {
  server.listen(process.env.PORT || 5000, () => console.log('Server running'));
});

Here is the complete server/models/User.js:

const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  password: { type: String },
  role: { type: String, enum: ['user','vendor','admin'], default: 'user' },
  profilePicture: { type: String },
  walletBalance: { type: Number, default: 100 },
  bookings: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Booking' }],
  resetPasswordToken: String,
  resetPasswordExpires: Date
}, { timestamps: true });

userSchema.pre('save', async function(next) {
  if (!this.isModified('password') || !this.password) return next();
  this.password = await bcrypt.hash(this.password, 10);
  next();
});

module.exports = mongoose.model('User', userSchema);

Here is the complete server/middleware/auth.js:

const jwt = require('jsonwebtoken');
const User = require('../models/User');

exports.protect = async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ message: 'Not authorized' });
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = await User.findById(decoded.id).select('-password');
    next();
  } catch (err) {
    res.status(401).json({ message: 'Token invalid' });
  }
};

exports.requireRole = (...roles) => (req, res, next) => {
  if (!roles.includes(req.user.role)) return res.status(403).json({ message: 'Forbidden' });
  next();
};

Here is the complete server/config/passport.js:

const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const User = require('../models/User');

passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  callbackURL: 'http://localhost:5000/api/auth/google/callback'
}, async (accessToken, refreshToken, profile, done) => {
  try {
    let user = await User.findOne({ email: profile.emails[0].value });
    if (!user) {
      user = await User.create({ name: profile.displayName, email: profile.emails[0].value, profilePicture: profile.photos[0].value, role: 'user' });
    }
    done(null, user);
  } catch (err) { done(err, null); }
}));

Here is the complete server/routes/auth.js:

const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const passport = require('passport');
const User = require('../models/User');
const { sendResetEmail } = require('../services/emailService');

router.post('/register', async (req, res) => {
  const { name, email, password, role } = req.body;
  try {
    const user = await User.create({ name, email, password, role });
    const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
    res.json({ token, user: { id: user._id, name, email, role } });
  } catch (err) { res.status(400).json({ message: err.message }); }
});

router.post('/login', async (req, res) => {
  const { email, password } = req.body;
  try {
    const user = await User.findOne({ email });
    if (!user || !await bcrypt.compare(password, user.password)) return res.status(401).json({ message: 'Invalid credentials' });
    const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
    res.json({ token, user: { id: user._id, name: user.name, email, role: user.role } });
  } catch (err) { res.status(500).json({ message: err.message }); }
});

router.post('/forgot-password', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });
  if (!user) return res.status(404).json({ message: 'User not found' });
  const token = crypto.randomBytes(20).toString('hex');
  user.resetPasswordToken = token;
  user.resetPasswordExpires = Date.now() + 3600000;
  await user.save();
  await sendResetEmail(user.email, token);
  res.json({ message: 'Reset email sent' });
});

router.post('/reset-password/:token', async (req, res) => {
  const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } });
  if (!user) return res.status(400).json({ message: 'Invalid or expired token' });
  user.password = req.body.password;
  user.resetPasswordToken = undefined;
  user.resetPasswordExpires = undefined;
  await user.save();
  res.json({ message: 'Password reset successful' });
});

router.get('/google', passport.authenticate('google', { scope: ['profile','email'] }));
router.get('/google/callback', passport.authenticate('google', { session: false }), (req, res) => {
  const token = jwt.sign({ id: req.user._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
  res.redirect(`${process.env.CLIENT_URL}/oauth-success?token=${token}`);
});

module.exports = router;

Here is the complete server/routes/users.js:

const express = require('express');
const router = express.Router();
const { protect } = require('../middleware/auth');
const User = require('../models/User');

router.get('/profile', protect, async (req, res) => {
  res.json(req.user);
});

router.put('/profile', protect, async (req, res) => {
  const { name, profilePicture, password } = req.body;
  const user = await User.findById(req.user._id);
  if (name) user.name = name;
  if (profilePicture) user.profilePicture = profilePicture;
  if (password) user.password = password;
  await user.save();
  res.json({ message: 'Profile updated' });
});

module.exports = router;

Your authentication system is fully wired up. Users can register, log in with email or Google, reset their password via email, and manage their profile. Next up, you will build the event management system so vendors can create events with seat layouts for booking.

Build Event Management for Vendors and Admins

Your authentication system handles who can log in. Now you need to define what they can do once inside.

In this step, you'll build the event management layer that gives vendors the power to create and manage events, and gives admins oversight over the entire platform. You'll also build the public-facing frontend that lets users browse and search for events.

In this step, get ready to:
  • Create the Event model and vendor CRUD routes.
  • Build admin panel API endpoints for platform management.
  • Build the event listing frontend with category filters and search.
Build the Event model and vendor CRUD routes

Events are the core data object in your booking platform. Each event needs to store everything from basic info (title, date, venue) to a full seat layout that the real-time system will use later.

  • Create a new file called Event.js inside the server/models/ folder.
  • Paste the following Event schema into it:
const mongoose = require('mongoose');

// Each seat tracks its position, availability, and who selected it
const seatSchema = new mongoose.Schema({
  seatId: String,
  row: Number,
  col: Number,
  status: { type: String, enum: ['available','selected','booked'], default: 'available' },
  selectedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  selectedAt: Date
});

// Events hold all details for movies, concerts, or trains
const eventSchema = new mongoose.Schema({
  title: { type: String, required: true },
  description: String,
  category: { type: String, enum: ['movie','concert','train'], required: true },
  date: { type: Date, required: true },
  time: String,
  venue: String,
  source: String,
  destination: String,
  price: { type: Number, required: true },
  totalSeats: { type: Number, required: true },
  availableSeats: { type: Number, required: true },
  seatLayout: [[seatSchema]],
  vendor: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  image: String,
  dynamicPricingEnabled: { type: Boolean, default: false },
  approved: { type: Boolean, default: false }
}, { timestamps: true });

module.exports = mongoose.model('Event', eventSchema);

What does this schema do?

The seatSchema is embedded inside each event as a 2D array. Each seat knows its row/column position, whether it's available, and who temporarily selected it. This structure powers the real-time seat selection grid you'll build in a later step.

The source and destination fields are only used for train events. The approved field lets admins gate which events appear publicly.

  • Save server/models/Event.js.

Now you need routes that let vendors create, update, and delete their events. Users also need a public endpoint to browse events.

  • Create a new file called events.js inside the server/routes/ folder.
  • Paste the following route handlers into it:
const express = require('express');
const router = express.Router();
const { protect, requireRole } = require('../middleware/auth');
const Event = require('../models/Event');

// Public: list events with optional category and search filters
router.get('/', async (req, res) => {
  const { category, search } = req.query;
  const filter = { approved: true };
  if (category && category !== 'all') filter.category = category;
  if (search) filter.title = { $regex: search, $options: 'i' };
  const events = await Event.find(filter).populate('vendor', 'name');
  res.json(events);
});

// Public: get single event by ID
router.get('/:id', async (req, res) => {
  const event = await Event.findById(req.params.id).populate('vendor', 'name');
  if (!event) return res.status(404).json({ message: 'Event not found' });
  res.json(event);
});

// Vendor: create a new event
router.post('/', protect, requireRole('vendor'), async (req, res) => {
  const event = await Event.create({ ...req.body, vendor: req.user._id });
  res.status(201).json(event);
});

// Vendor: update their own event
router.put('/:id', protect, requireRole('vendor'), async (req, res) => {
  const event = await Event.findOneAndUpdate({ _id: req.params.id, vendor: req.user._id }, req.body, { new: true });
  if (!event) return res.status(404).json({ message: 'Not found or unauthorized' });
  res.json(event);
});

// Vendor: delete their own event
router.delete('/:id', protect, requireRole('vendor'), async (req, res) => {
  await Event.findOneAndDelete({ _id: req.params.id, vendor: req.user._id });
  res.json({ message: 'Event deleted' });
});

module.exports = router;

What does this code do?

The GET routes are public. Anyone can browse events or view details. The POST, PUT, and DELETE routes use protect (requires login) and requireRole('vendor') (restricts to vendors only).

The PUT and DELETE routes use findOneAndUpdate and findOneAndDelete with a vendor filter. This ensures a vendor can only modify their own events.

  • Save server/routes/events.js.

Seeing a module not found error?

Make sure your file path matches exactly: server/routes/events.js. The require path in the route file references ../middleware/auth and ../models/Event. Double check these files exist in the correct folders.

Help me debug a module not found error in my Express routes

Build the admin panel API

Admins need visibility into every user, vendor, event, and booking on the platform. They also need the power to suspend users and approve events.

Before creating admin routes, the User model needs a suspended field so admins can disable accounts.

  • In server/models/User.js, find this line inside the schema definition:
  resetPasswordToken: String,
  • Add the following line directly above resetPasswordToken:
  suspended: { type: Boolean, default: false },
  • Save server/models/User.js.

Now create the admin routes. These endpoints are all protected by both the protect middleware and requireRole('admin') applied at the router level.

  • Create a new file called admin.js inside the server/routes/ folder.
  • Paste the following admin route handlers into it:
const express = require('express');
const router = express.Router();
const { protect, requireRole } = require('../middleware/auth');
const User = require('../models/User');
const Event = require('../models/Event');
const Booking = require('../models/Booking');

// All admin routes require authentication and admin role
router.use(protect, requireRole('admin'));

// List all events with vendor info
router.get('/events', async (req, res) => {
  const events = await Event.find().populate('vendor', 'name email');
  res.json(events);
});

// Approve a vendor's event for public listing
router.put('/events/:id/approve', async (req, res) => {
  const event = await Event.findByIdAndUpdate(req.params.id, { approved: true }, { new: true });
  res.json(event);
});

// List all regular users
router.get('/users', async (req, res) => {
  const users = await User.find({ role: 'user' });
  res.json(users);
});

// List all vendors
router.get('/vendors', async (req, res) => {
  const vendors = await User.find({ role: 'vendor' });
  res.json(vendors);
});

// Suspend a user account
router.put('/users/:id/suspend', async (req, res) => {
  const user = await User.findByIdAndUpdate(req.params.id, { suspended: true }, { new: true });
  res.json(user);
});

// Delete a user account permanently
router.delete('/users/:id', async (req, res) => {
  await User.findByIdAndDelete(req.params.id);
  res.json({ message: 'User deleted' });
});

// List all bookings with user and event details
router.get('/bookings', async (req, res) => {
  const bookings = await Booking.find().populate('user', 'name email').populate('event', 'title date');
  res.json(bookings);
});

// Aggregation: top events by booking count and revenue
router.get('/analytics/top-events', async (req, res) => {
  const topEvents = await Booking.aggregate([
    { $group: { _id: '$event', count: { $sum: 1 }, revenue: { $sum: '$totalAmount' } } },
    { $sort: { count: -1 } },
    { $limit: 10 },
    { $lookup: { from: 'events', localField: '_id', foreignField: '_id', as: 'event' } }
  ]);
  res.json(topEvents);
});

module.exports = router;

What does this code do?

The router.use(protect, requireRole('admin')) line applies both middlewares to every route in this file. No admin route works without a valid JWT belonging to a user with the admin role.

The /analytics/top-events endpoint uses a MongoDB aggregation pipeline. It groups bookings by event, counts them, sums revenue, sorts by popularity, and joins the event details back in.

Why does admin.js reference a Booking model that doesn't exist yet?

The Booking model will be created in a later step when you build the payment flow. The routes that reference it (bookings list and analytics) will return empty arrays until then. This is intentional. You're building the admin API surface now so the admin dashboard has endpoints ready when bookings start flowing in.

  • Save server/routes/admin.js.

Both route files exist but the Express app doesn't know about them yet. You need to register them in your main server file.

  • In server/index.js, find the require statements for your existing routes:
const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
  • Add the following two lines directly below them:
const eventRoutes = require('./routes/events');
const adminRoutes = require('./routes/admin');
  • Now find the existing app.use lines for routes:
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
  • Add the following two lines directly below them:
app.use('/api/events', eventRoutes);
app.use('/api/admin', adminRoutes);
  • Save server/index.js.
  • Restart your server. You should see Server running in the terminal with no errors.

Server crashing on startup?

If you see an error about the Booking model not being found, that's expected. The admin.js file requires ../models/Booking which doesn't exist yet. Create a placeholder file at server/models/Booking.js with a basic schema to prevent the crash. You'll flesh it out in the payment step.

Help me fix my server crash related to the Booking model

Build the event listing frontend

With the API ready, users need a way to browse events. You'll create three React components: a reusable event card, a list page with category filters and search, and a detail page with a booking button.

  • Create a new folder called components inside client/src/ if it doesn't already exist.
  • Create a new file called EventCard.jsx inside client/src/components/.
  • Add the following scaffold to the file:
// Displays event image, title, date, price, available seats
// Links to EventDetailPage

What should EventCard render?

This component receives an event object as a prop and renders a card showing the event image, title, formatted date, ticket price, and available seat count. It wraps everything in a link (using React Router's Link or useNavigate) that takes users to the event detail page.

  • Save client/src/components/EventCard.jsx.
  • Create a new file called EventListPage.jsx inside client/src/pages/.
  • Add the following scaffold to the file:
// Lists events with category filter tabs (All, Movies, Concerts, Trains) and search bar
// Fetches from GET /api/events?category=&search=
// Renders EventCard components

What should EventListPage do?

This page fetches events from GET /api/events with query parameters for category and search. It renders category filter tabs (All, Movies, Concerts, Trains) at the top, a search input field, and a grid of EventCard components below.

Use useState for the active category and search term. Use useEffect to refetch events whenever either filter changes.

  • Save client/src/pages/EventListPage.jsx.
  • Create a new file called EventDetailPage.jsx inside client/src/pages/.
  • Add the following scaffold to the file:
// Shows full event info, Book Now button navigates to seat selection

What should EventDetailPage do?

This page fetches a single event by ID from GET /api/events/:id using the route parameter. It displays the full event details: title, description, date, time, venue (or source/destination for trains), price, and available seats. A Book Now button navigates to the seat selection page you'll build in the next step.

  • Save client/src/pages/EventDetailPage.jsx.

Wire up your React Router

Remember to add routes for these new pages in your app's router configuration. You'll need a route for the event list (e.g. /events) and a dynamic route for event details (e.g. /events/:id).

Events not appearing on the list page?

The GET endpoint filters by approved: true. If you created test events through the vendor route, they default to approved: false. Use the admin approve endpoint (PUT /api/admin/events/:id/approve) to approve them first, or temporarily remove the approved filter while testing.

Help me debug why my events aren't showing up

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here's the full code for each file you created or modified in this step.

const mongoose = require('mongoose');

const seatSchema = new mongoose.Schema({
  seatId: String,
  row: Number,
  col: Number,
  status: { type: String, enum: ['available','selected','booked'], default: 'available' },
  selectedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  selectedAt: Date
});

const eventSchema = new mongoose.Schema({
  title: { type: String, required: true },
  description: String,
  category: { type: String, enum: ['movie','concert','train'], required: true },
  date: { type: Date, required: true },
  time: String,
  venue: String,
  source: String,
  destination: String,
  price: { type: Number, required: true },
  totalSeats: { type: Number, required: true },
  availableSeats: { type: Number, required: true },
  seatLayout: [[seatSchema]],
  vendor: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  image: String,
  dynamicPricingEnabled: { type: Boolean, default: false },
  approved: { type: Boolean, default: false }
}, { timestamps: true });

module.exports = mongoose.model('Event', eventSchema);
const express = require('express');
const router = express.Router();
const { protect, requireRole } = require('../middleware/auth');
const Event = require('../models/Event');

router.get('/', async (req, res) => {
  const { category, search } = req.query;
  const filter = { approved: true };
  if (category && category !== 'all') filter.category = category;
  if (search) filter.title = { $regex: search, $options: 'i' };
  const events = await Event.find(filter).populate('vendor', 'name');
  res.json(events);
});

router.get('/:id', async (req, res) => {
  const event = await Event.findById(req.params.id).populate('vendor', 'name');
  if (!event) return res.status(404).json({ message: 'Event not found' });
  res.json(event);
});

router.post('/', protect, requireRole('vendor'), async (req, res) => {
  const event = await Event.create({ ...req.body, vendor: req.user._id });
  res.status(201).json(event);
});

router.put('/:id', protect, requireRole('vendor'), async (req, res) => {
  const event = await Event.findOneAndUpdate({ _id: req.params.id, vendor: req.user._id }, req.body, { new: true });
  if (!event) return res.status(404).json({ message: 'Not found or unauthorized' });
  res.json(event);
});

router.delete('/:id', protect, requireRole('vendor'), async (req, res) => {
  await Event.findOneAndDelete({ _id: req.params.id, vendor: req.user._id });
  res.json({ message: 'Event deleted' });
});

module.exports = router;
const express = require('express');
const router = express.Router();
const { protect, requireRole } = require('../middleware/auth');
const User = require('../models/User');
const Event = require('../models/Event');
const Booking = require('../models/Booking');

router.use(protect, requireRole('admin'));

router.get('/events', async (req, res) => {
  const events = await Event.find().populate('vendor', 'name email');
  res.json(events);
});

router.put('/events/:id/approve', async (req, res) => {
  const event = await Event.findByIdAndUpdate(req.params.id, { approved: true }, { new: true });
  res.json(event);
});

router.get('/users', async (req, res) => {
  const users = await User.find({ role: 'user' });
  res.json(users);
});

router.get('/vendors', async (req, res) => {
  const vendors = await User.find({ role: 'vendor' });
  res.json(vendors);
});

router.put('/users/:id/suspend', async (req, res) => {
  const user = await User.findByIdAndUpdate(req.params.id, { suspended: true }, { new: true });
  res.json(user);
});

router.delete('/users/:id', async (req, res) => {
  await User.findByIdAndDelete(req.params.id);
  res.json({ message: 'User deleted' });
});

router.get('/bookings', async (req, res) => {
  const bookings = await Booking.find().populate('user', 'name email').populate('event', 'title date');
  res.json(bookings);
});

router.get('/analytics/top-events', async (req, res) => {
  const topEvents = await Booking.aggregate([
    { $group: { _id: '$event', count: { $sum: 1 }, revenue: { $sum: '$totalAmount' } } },
    { $sort: { count: -1 } },
    { $limit: 10 },
    { $lookup: { from: 'events', localField: '_id', foreignField: '_id', as: 'event' } }
  ]);
  res.json(topEvents);
});

module.exports = router;
require('dotenv').config();
const express = require('express');
const http = require('http');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const mongoose = require('mongoose');
const passport = require('passport');
require('./config/passport');

const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const eventRoutes = require('./routes/events');
const adminRoutes = require('./routes/admin');

const app = express();
const server = http.createServer(app);

app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
app.use(express.json());
app.use(cookieParser());
app.use(passport.initialize());

app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/admin', adminRoutes);

mongoose.connect(process.env.MONGO_URI).then(() => {
  server.listen(process.env.PORT || 5000, () => console.log('Server running'));
});
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  password: { type: String },
  role: { type: String, enum: ['user','vendor','admin'], default: 'user' },
  profilePicture: { type: String },
  walletBalance: { type: Number, default: 100 },
  bookings: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Booking' }],
  suspended: { type: Boolean, default: false },
  resetPasswordToken: String,
  resetPasswordExpires: Date
}, { timestamps: true });

userSchema.pre('save', async function(next) {
  if (!this.isModified('password') || !this.password) return next();
  this.password = await bcrypt.hash(this.password, 10);
  next();
});

module.exports = mongoose.model('User', userSchema);

Your platform now has a complete event management backend and a frontend skeleton for browsing events. Next up, you'll bring the seat selection grid to life with real-time WebSocket updates so users can see seats being claimed in real time.

Implement Real-Time Seat Selection with Socket.IO

When multiple users browse the same event, they all need to see seat availability update in real time. Without live updates, two users could try to book the same seat simultaneously.

In this step, you will build a real-time seat selection system using Socket.IO. The server broadcasts seat changes to everyone viewing the same event, keeping the grid in sync across all browsers.

In this step, get ready to:
  • Set up Socket.IO on the Express server with room-based architecture.
  • Implement seat selection, deselection, and auto-release logic.
  • Build the SeatGrid component with real-time updates and color-coded seats.
Set up Socket.IO server with room-based architecture

Socket.IO uses "rooms" to group connections. Each event gets its own room, so seat updates only broadcast to users viewing that specific event.

What are Socket.IO rooms?

A room is a server-side channel that sockets can join and leave. When a user opens the seat selection page for an event, their socket joins the room named after that event's ID.

Any seat change for that event broadcasts only to sockets in that room, not to every connected client. This keeps network traffic focused and efficient.

  • In server/index.js, update the file to import Socket.IO and call the socket initialization function. Replace the entire file content with this:
require('dotenv').config();
const express = require('express');
const http = require('http');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const mongoose = require('mongoose');
const passport = require('passport');
const { Server } = require('socket.io');
require('./config/passport');
const { initSocket } = require('./socket');

const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const eventRoutes = require('./routes/events');
const adminRoutes = require('./routes/admin');

const app = express();
const server = http.createServer(app);

app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
app.use(express.json());
app.use(cookieParser());
app.use(passport.initialize());

app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/admin', adminRoutes);

initSocket(server);

mongoose.connect(process.env.MONGO_URI).then(() => {
  server.listen(process.env.PORT || 5000, () => console.log('Server running'));
});

What changed in this file?

  • const { Server } = require('socket.io') imports the Socket.IO server class.
  • const { initSocket } = require('./socket') imports the socket setup function you are about to create.
  • initSocket(server) passes the HTTP server to Socket.IO so it can listen for WebSocket connections on the same port.
  • Save server/index.js.

The server will show an error because ./socket does not exist yet. You will create it next.

  • Create a new file called socket.js inside the server/ folder. This file holds all real-time seat logic. Paste the following code:
const { Server } = require('socket.io');
const Event = require('./models/Event');

const SEAT_TIMEOUT_MS = 5 * 60 * 1000;

function initSocket(server) {
  const io = new Server(server, { cors: { origin: process.env.CLIENT_URL, methods: ['GET','POST'] } });

  setInterval(async () => {
    const cutoff = new Date(Date.now() - SEAT_TIMEOUT_MS);
    const events = await Event.find({ 'seatLayout.status': 'selected', 'seatLayout.selectedAt': { $lt: cutoff } });
    for (const event of events) {
      let changed = false;
      event.seatLayout.forEach(row => row.forEach(seat => {
        if (seat.status === 'selected' && seat.selectedAt < cutoff) {
          seat.status = 'available'; seat.selectedBy = null; seat.selectedAt = null; changed = true;
        }
      }));
      if (changed) { await event.save(); io.to(event._id.toString()).emit('seat-status-update', event.seatLayout); }
    }
  }, 60000);

  io.on('connection', (socket) => {
    socket.on('join-event', (eventId) => socket.join(eventId));

    socket.on('select-seat', async ({ eventId, seatId, userId }) => {
      const event = await Event.findById(eventId);
      let seat;
      event.seatLayout.forEach(row => row.forEach(s => { if (s.seatId === seatId) seat = s; }));
      if (!seat || seat.status !== 'available') return;
      seat.status = 'selected'; seat.selectedBy = userId; seat.selectedAt = new Date();
      await event.save();
      io.to(eventId).emit('seat-status-update', event.seatLayout);
    });

    socket.on('deselect-seat', async ({ eventId, seatId, userId }) => {
      const event = await Event.findById(eventId);
      let seat;
      event.seatLayout.forEach(row => row.forEach(s => { if (s.seatId === seatId) seat = s; }));
      if (!seat || seat.selectedBy?.toString() !== userId) return;
      seat.status = 'available'; seat.selectedBy = null; seat.selectedAt = null;
      await event.save();
      io.to(eventId).emit('seat-status-update', event.seatLayout);
    });
  });
}

module.exports = { initSocket };

What does this code do?

The initSocket function sets up the entire real-time seat system in one module:

  • It creates a Socket.IO server with CORS configured for your React app's origin.
  • The setInterval runs every 60 seconds, checking for seats that have been selected for more than 5 minutes without payment. Those seats revert to available.
  • On connection, clients emit join-event to enter a room scoped to a specific event ID.
  • The select-seat handler validates the seat is available, marks it as selected with the user's ID and timestamp, then broadcasts the updated layout to the room.
  • The deselect-seat handler verifies ownership before releasing the seat back to available.
  • Save server/socket.js.
  • Restart your server. You should see Server running in the terminal with no errors about the missing module.

Seeing a module not found error?

  • Check that socket.js is in the server/ root folder, not inside a subfolder like routes/.
  • Verify the import path in index.js is ./socket (no file extension needed).

Help me debug my Socket.IO server setup

Understand the seat selection and auto-release logic

The socket handlers you just created implement a lock-and-release pattern. A selected seat is temporarily reserved for one user, then automatically freed if they do not complete payment.

Why auto-release seats after 5 minutes?

Without a timeout, a user who selects seats and then closes their browser would permanently block those seats from other buyers. The 5-minute window gives users enough time to complete payment without holding seats hostage indefinitely.

The setInterval scans the database every 60 seconds for stale selections. When it finds expired seats, it resets them to available and broadcasts the change so all viewers see the freed seats instantly.

How does the room broadcast work?

When a seat changes state, the server calls io.to(eventId).emit('seat-status-update', event.seatLayout). This sends the updated layout to every socket in that event's room.

Clients that are viewing a different event never receive this message. This keeps the system efficient even with thousands of connected users across many events.

Build the seat selection grid UI

The frontend needs a component that connects to the Socket.IO server, displays the seat grid, and lets users click to select or deselect seats. This component renders different layouts depending on the event category.

  • Create a new file called SeatGrid.jsx inside client/src/components/. Add the following scaffold:
// Renders 2D seat grid with color coding
// green=available, yellow=selected by current user, red=booked, gray=selected by others
// Connects to Socket.IO, joins event room, listens for seat-status-update
// On seat click: emits select-seat or deselect-seat
// Shows selected seat count and total price below grid
// For trains: renders coach/berth layout instead

What should SeatGrid implement?

  • Import io from socket.io-client and connect to your server URL on mount.
  • Emit join-event with the event ID so the server adds your socket to the correct room.
  • Listen for seat-status-update events and update local state with the new seat layout.
  • On seat click, emit select-seat (if available) or deselect-seat (if already yours) with the event ID, seat ID, and user ID.
  • Color code seats: green for available, yellow for selected by the current user, red for booked, gray for selected by another user.
  • Below the grid, display the count of seats the user has selected and the total price.
  • For train events (category === 'train'), render a coach/berth selection layout instead of the cinema-style grid.

Socket.IO cleanup in React

Always disconnect the socket in your useEffect cleanup function. If you skip this, stale connections accumulate each time the component re-mounts.

  • Save client/src/components/SeatGrid.jsx.
  • Implement the component following the scaffold comments above. Test by opening two browser tabs on the same event and selecting a seat in one. The other tab should show the seat turn gray in real time.

Seats not updating in the second tab?

  • Check that both tabs emit join-event with the same event ID on mount.
  • Verify your Socket.IO client connects to http://localhost:5000 (or whatever port your server runs on), not the React dev server port.
  • Open the browser DevTools Network tab and filter by WS to confirm the WebSocket connection is established.

Help me debug my Socket.IO React connection for the seat grid

✔️ Awesome, I've got everything!

Great. Double check you have saved both server/index.js and server/socket.js.

ⓧ I'd like to double check the full code

Here is the complete server/socket.js:

const { Server } = require('socket.io');
const Event = require('./models/Event');

const SEAT_TIMEOUT_MS = 5 * 60 * 1000;

function initSocket(server) {
  const io = new Server(server, { cors: { origin: process.env.CLIENT_URL, methods: ['GET','POST'] } });

  setInterval(async () => {
    const cutoff = new Date(Date.now() - SEAT_TIMEOUT_MS);
    const events = await Event.find({ 'seatLayout.status': 'selected', 'seatLayout.selectedAt': { $lt: cutoff } });
    for (const event of events) {
      let changed = false;
      event.seatLayout.forEach(row => row.forEach(seat => {
        if (seat.status === 'selected' && seat.selectedAt < cutoff) {
          seat.status = 'available'; seat.selectedBy = null; seat.selectedAt = null; changed = true;
        }
      }));
      if (changed) { await event.save(); io.to(event._id.toString()).emit('seat-status-update', event.seatLayout); }
    }
  }, 60000);

  io.on('connection', (socket) => {
    socket.on('join-event', (eventId) => socket.join(eventId));

    socket.on('select-seat', async ({ eventId, seatId, userId }) => {
      const event = await Event.findById(eventId);
      let seat;
      event.seatLayout.forEach(row => row.forEach(s => { if (s.seatId === seatId) seat = s; }));
      if (!seat || seat.status !== 'available') return;
      seat.status = 'selected'; seat.selectedBy = userId; seat.selectedAt = new Date();
      await event.save();
      io.to(eventId).emit('seat-status-update', event.seatLayout);
    });

    socket.on('deselect-seat', async ({ eventId, seatId, userId }) => {
      const event = await Event.findById(eventId);
      let seat;
      event.seatLayout.forEach(row => row.forEach(s => { if (s.seatId === seatId) seat = s; }));
      if (!seat || seat.selectedBy?.toString() !== userId) return;
      seat.status = 'available'; seat.selectedBy = null; seat.selectedAt = null;
      await event.save();
      io.to(eventId).emit('seat-status-update', event.seatLayout);
    });
  });
}

module.exports = { initSocket };

And the complete server/index.js:

require('dotenv').config();
const express = require('express');
const http = require('http');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const mongoose = require('mongoose');
const passport = require('passport');
const { Server } = require('socket.io');
require('./config/passport');
const { initSocket } = require('./socket');

const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const eventRoutes = require('./routes/events');
const adminRoutes = require('./routes/admin');

const app = express();
const server = http.createServer(app);

app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
app.use(express.json());
app.use(cookieParser());
app.use(passport.initialize());

app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/admin', adminRoutes);

initSocket(server);

mongoose.connect(process.env.MONGO_URI).then(() => {
  server.listen(process.env.PORT || 5000, () => console.log('Server running'));
});

Your seat selection system is now live. Users can see seats lock and unlock in real time across multiple browsers. Next up, you will connect Stripe to process payments when users confirm their seat selections.

Integrate Stripe Payments

Your users can now select seats in real time, but there's no way to actually pay for them. Without a payment flow, selected seats just time out after five minutes and revert to available.

In this step, you'll connect Stripe's Checkout Sessions API to turn seat selections into confirmed bookings. Stripe handles the entire payment UI in test mode, so you can simulate purchases without touching real money.

In this step, get ready to:
  • Create a Booking model and Stripe Checkout Session endpoint on the backend.
  • Handle payment confirmation through a Stripe webhook.
  • Build a BookingSummary page and success page on the frontend.
Create the Stripe Checkout Session on the backend

Before you can charge users, you need a data model to track each booking's lifecycle. The Booking model stores which user booked which seats, the payment status, and the Stripe session reference.

  • Create a new file server/models/Booking.js and add the following schema:
const mongoose = require('mongoose');

// Tracks each booking through its lifecycle: pending -> confirmed or cancelled
const bookingSchema = new mongoose.Schema({
  user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true },
  seats: [String],
  totalAmount: { type: Number, required: true },
  status: { type: String, enum: ['pending','confirmed','cancelled'], default: 'pending' },
  stripeSessionId: String,
  paymentMethod: { type: String, enum: ['stripe','wallet'], default: 'stripe' },
  ticketGenerated: { type: Boolean, default: false }
}, { timestamps: true });

module.exports = mongoose.model('Booking', bookingSchema);

What does this model do?

Each booking starts with status pending when the user initiates payment. Once Stripe confirms the charge, the webhook flips it to confirmed.

The stripeSessionId field links this booking to the Stripe Checkout Session so the webhook knows which booking to update. The seats array stores seatId strings matching the ones in your Event's seatLayout.

  • Save server/models/Booking.js.
  • Confirm the file appears in your server/models/ folder alongside User.js and Event.js.

Now you need a route that creates a Stripe Checkout Session from the user's selected seats. This endpoint builds line items (one per seat), asks Stripe to host the payment page, and saves a pending booking.

  • Create a new file server/routes/payments.js and add the following code:
const express = require('express');
const router = express.Router();
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { protect } = require('../middleware/auth');
const Booking = require('../models/Booking');
const Event = require('../models/Event');

// Creates a Stripe Checkout Session from the user's selected seats
router.post('/create-checkout-session', protect, async (req, res) => {
  const { eventId, seats } = req.body;
  const event = await Event.findById(eventId);

  // Build one line item per seat with the event price in cents
  const lineItems = seats.map(seatId => ({
    price_data: { currency: 'usd', product_data: { name: `${event.title} - Seat ${seatId}` }, unit_amount: Math.round(event.price * 100) },
    quantity: 1
  }));

  // Create the Checkout Session on Stripe
  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    line_items: lineItems,
    mode: 'payment',
    success_url: `${process.env.CLIENT_URL}/booking-success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.CLIENT_URL}/events/${eventId}`
  });

  // Store a pending booking linked to this Stripe session
  const booking = await Booking.create({ user: req.user._id, event: eventId, seats, totalAmount: event.price * seats.length, stripeSessionId: session.id });
  await require('../models/User').findByIdAndUpdate(req.user._id, { $push: { bookings: booking._id } });

  res.json({ url: session.url });
});

module.exports = router;

What does this code do?

The protect middleware ensures only logged-in users can create a session. Each seat becomes its own line item so Stripe shows a clear breakdown on the checkout page.

The unit_amount must be in cents (Stripe's smallest currency unit), so you multiply the dollar price by 100. After Stripe creates the session, you store a pending Booking and return the hosted checkout URL to the frontend.

  • Save server/routes/payments.js.

Seeing a Stripe initialization error?

Make sure your .env file has STRIPE_SECRET_KEY set to your test secret key (starts with sk_test_). If the variable is missing, require('stripe')(undefined) throws immediately on server start.

help me debug my Stripe secret key setup

Handle payment confirmation with webhooks

When a user completes payment on Stripe's hosted page, Stripe sends a webhook event to your server. This is how your backend knows a payment succeeded without trusting the client.

Why webhooks instead of checking the redirect?

A user could manipulate the success redirect URL or their browser could crash mid-redirect. Webhooks are server-to-server, so they're tamper-proof. Stripe guarantees delivery with automatic retries.

  • Create a new file server/routes/webhooks.js and add the following code:
const express = require('express');
const router = express.Router();
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const Booking = require('../models/Booking');
const Event = require('../models/Event');
const { generateAndSendTicket } = require('../services/ticketService');

// Stripe sends payment events here after checkout completes
router.post('/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-webhook-signature'];
  let event;

  // Verify the webhook signature to confirm it came from Stripe
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) { return res.status(400).send(`Webhook Error: ${err.message}`); }

  // Handle successful checkout: confirm booking and mark seats as booked
  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    const booking = await Booking.findOneAndUpdate({ stripeSessionId: session.id }, { status: 'confirmed' }, { new: true });
    if (booking) {
      const evt = await Event.findById(booking.event);
      evt.seatLayout.forEach(row => row.forEach(seat => {
        if (booking.seats.includes(seat.seatId)) { seat.status = 'booked'; seat.selectedBy = null; }
      }));
      evt.availableSeats -= booking.seats.length;
      await evt.save();
      await generateAndSendTicket(booking._id);
    }
  }

  res.json({ received: true });
});

module.exports = router;

What does this code do?

The express.raw({ type: 'application/json' }) middleware gives Stripe access to the raw request body, which is required for signature verification. Without it, express.json() would parse the body first and break the signature check.

On checkout.session.completed, the handler finds the matching pending booking, flips its status to confirmed, and marks each booked seat in the event's seatLayout. The generateAndSendTicket call is a placeholder for the next step where you'll build the PDF ticket and email service.

  • Save server/routes/webhooks.js.

The ticket service doesn't exist yet

The require('../services/ticketService') import will throw until you build it in the next step. To avoid a crash while testing payments, you can temporarily comment out that line and the await generateAndSendTicket(booking._id) call.

Now you need to register both new route files in your Express server. The webhook route has a critical placement requirement.

  • In server/index.js, find the line that says:
const adminRoutes = require('./routes/admin');
  • Add these two new imports directly below it:
const paymentRoutes = require('./routes/payments');
const webhookRoutes = require('./routes/webhooks');
  • Now find this line:
app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
  • Add the webhook route directly above the CORS line, so it looks like this:
app.use('/api/webhooks', webhookRoutes);

app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));

Webhook route MUST come before express.json()

Stripe's signature verification needs the raw request body. If express.json() parses the body first, the signature check fails every time. Placing the webhook route above all body-parsing middleware solves this.

  • Finally, find this line:
app.use('/api/admin', adminRoutes);
  • Add the payments route directly below it:
app.use('/api/payments', paymentRoutes);
  • Add STRIPE_WEBHOOK_SECRET to your server/.env file:
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here

Where do I get the webhook secret?

For local testing, you'll use the Stripe CLI to forward events to your server. When you run the Stripe CLI's listen command, it prints a webhook signing secret that starts with whsec_. Copy that value into your .env file.

  • Save server/index.js and server/.env.
  • Restart your server. You should see Server running in the terminal with no errors (assuming you commented out the ticket service import temporarily).

Server crashing on start?

If you see an error about ticketService, comment out the require line and the generateAndSendTicket call in webhooks.js for now. If you see a Stripe error, verify STRIPE_SECRET_KEY is set in your .env file.

help me debug my server startup crash after adding payment routes

✔️ Awesome, I've got everything!

Great. Make sure you've saved both files and your server restarts cleanly.

ⓧ I'd like to double check the full code

Here is the complete server/index.js at this point:

require('dotenv').config();
const express = require('express');
const http = require('http');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const mongoose = require('mongoose');
const passport = require('passport');
require('./config/passport');
const { initSocket } = require('./socket');

const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const eventRoutes = require('./routes/events');
const adminRoutes = require('./routes/admin');
const paymentRoutes = require('./routes/payments');
const webhookRoutes = require('./routes/webhooks');

const app = express();
const server = http.createServer(app);

app.use('/api/webhooks', webhookRoutes);

app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
app.use(express.json());
app.use(cookieParser());
app.use(passport.initialize());

app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/payments', paymentRoutes);

initSocket(server);

mongoose.connect(process.env.MONGO_URI).then(() => {
  server.listen(process.env.PORT || 5000, () => console.log('Server running'));
});
Build the payment flow UI

The backend is ready. Now you need two frontend pages: one that shows the booking summary before payment, and one that confirms success after.

  • Create a new file client/src/pages/BookingSummary.jsx and add the following code:
import { useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import axios from 'axios';

// Shows selected event, date, seats, total price
// Pay with Stripe button calls POST /api/payments/create-checkout-session, redirects to Stripe
function BookingSummary() {
  const { eventId } = useParams();
  const location = useLocation();
  const { event, selectedSeats } = location.state || {};
  const [loading, setLoading] = useState(false);

  // Calculate total from seat count and event price
  const totalPrice = selectedSeats ? selectedSeats.length * event.price : 0;

  // Call backend to create Stripe Checkout Session and redirect
  const handlePayment = async () => {
    setLoading(true);
    try {
      const token = localStorage.getItem('token');
      const res = await axios.post('/api/payments/create-checkout-session', {
        eventId,
        seats: selectedSeats
      }, { headers: { Authorization: `Bearer ${token}` } });
      window.location.href = res.data.url;
    } catch (err) {
      console.error('Payment error:', err);
      setLoading(false);
    }
  };

  if (!event || !selectedSeats) return <p>No booking details found.</p>;

  return (
    <div className="booking-summary">
      <h2>Booking Summary</h2>
      <div className="event-details">
        <h3>{event.title}</h3>
        <p>Date: {new Date(event.date).toLocaleDateString()}</p>
        <p>Venue: {event.venue}</p>
      </div>
      <div className="seat-details">
        <h4>Selected Seats</h4>
        <p>{selectedSeats.join(', ')}</p>
        <p>Total: ${totalPrice.toFixed(2)}</p>
      </div>
      <button onClick={handlePayment} disabled={loading}>
        {loading ? 'Redirecting...' : 'Pay with Stripe'}
      </button>
    </div>
  );
}

export default BookingSummary;

What does this component do?

The component reads the selected event and seats from React Router's location state (passed from the SeatGrid page). When the user clicks Pay with Stripe, it sends the seat IDs to your backend, which returns a Stripe-hosted checkout URL.

The window.location.href redirect takes the user to Stripe's payment page. After they pay, Stripe redirects them to your success URL.

  • Save client/src/pages/BookingSummary.jsx.

Now create the page users land on after a successful payment.

  • Create a new file client/src/pages/BookingSuccess.jsx and add the following code:
import { useSearchParams, Link } from 'react-router-dom';

// Shown after successful Stripe payment
// Displays booking confirmation message, triggers PDF download link
function BookingSuccess() {
  const [searchParams] = useSearchParams();
  const sessionId = searchParams.get('session_id');

  return (
    <div className="booking-success">
      <h2>Booking Confirmed!</h2>
      <p>Your payment was successful and your tickets are booked.</p>
      <p>Reference: {sessionId}</p>
      <Link to="/my-bookings">View My Bookings</Link>
    </div>
  );
}

export default BookingSuccess;

What does this component do?

After Stripe redirects the user, the URL contains a session_id query parameter. This page reads that parameter and displays a confirmation message. The link to /my-bookings points to the booking history page you'll build in the next step.

  • Save client/src/pages/BookingSuccess.jsx.
  • Add routes for both pages in your React Router configuration (/booking-summary/:eventId and /booking-success).
  • Start your React dev server and navigate to an event's seat selection page.
  • Select some seats, then navigate to the BookingSummary page. You should see the event title, selected seats listed, and the total price with a Pay with Stripe button.

Testing with Stripe's test card

When Stripe's checkout page appears, use card number 4242 4242 4242 4242 with any future expiry date and any 3-digit CVC. This simulates a successful payment in test mode without charging real money.

Pay with Stripe button not working?

Check that your backend is running and the STRIPE_SECRET_KEY environment variable is set. Verify the axios request URL matches your backend address (you may need to set a proxy or use the full URL like http://localhost:5000/api/payments/create-checkout-session).

help me debug my Stripe checkout session creation

Your payment pipeline is connected end to end. Users can select seats, pay through Stripe, and have their bookings confirmed automatically via webhooks. Next up, you'll generate PDF tickets with QR codes and email them to confirmed bookers.

Generate PDF Tickets and Send Email Confirmations

Your payment system now confirms bookings and marks seats. But the learner still has no tangible proof of their purchase.

In this step, you will build the post-payment experience. You will generate a professional PDF ticket with an embedded QR code, email it automatically to the user, and give them a dashboard to view, download, and cancel their bookings.

In this step, get ready to:
  • Create a ticket generation service that builds PDF documents with QR codes using PDFKit.
  • Build an email service that sends booking confirmations with PDF attachments using Nodemailer.
  • Add a user bookings page with ticket download and cancellation features.
Generate PDF tickets with QR codes

Each confirmed booking needs a downloadable ticket. The ticket service will encode booking data into a QR code for venue verification, then render it alongside event details in a formatted PDF.

  • Create a new file at server/services/ticketService.js.
  • Add the following code for the generateTicketPDF function:
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const crypto = require('crypto');
const Booking = require('../models/Booking');
const Event = require('../models/Event');
const User = require('../models/User');
const { sendBookingConfirmation } = require('./emailService');

async function generateTicketPDF(bookingId) {
  // Fetch booking with related event and user data
  const booking = await Booking.findById(bookingId).populate('event').populate('user');

  // Create a verification hash from bookingId + secret
  const verificationHash = crypto.createHash('sha256').update(bookingId.toString() + process.env.JWT_SECRET).digest('hex').slice(0,16);

  // Encode booking reference + hash into a QR code data URL
  const qrData = `BOOKING:${bookingId}:${verificationHash}`;
  const qrDataURL = await QRCode.toDataURL(qrData);
  const qrBuffer = Buffer.from(qrDataURL.split(',')[1], 'base64');

  return new Promise((resolve, reject) => {
    const doc = new PDFDocument({ size: 'A4', margin: 50 });
    const buffers = [];
    doc.on('data', chunk => buffers.push(chunk));
    doc.on('end', () => resolve(Buffer.concat(buffers)));
    doc.on('error', reject);

    // Header
    doc.fontSize(24).text('Universal Ticket Booking System', { align: 'center' });
    doc.moveDown();

    // Event title
    doc.fontSize(18).text(booking.event.title, { align: 'center' });
    doc.moveDown();

    // Booking details
    doc.fontSize(12)
      .text(`Date: ${new Date(booking.event.date).toDateString()}`)
      .text(`Venue: ${booking.event.venue || booking.event.source + ' \u2192 ' + booking.event.destination}`)
      .text(`Passenger: ${booking.user.name}`)
      .text(`Email: ${booking.user.email}`)
      .text(`Seats: ${booking.seats.join(', ')}`)
      .text(`Total Paid: $${booking.totalAmount}`)
      .text(`Booking Ref: ${bookingId}`);
    doc.moveDown();

    // Embed QR code image
    doc.image(qrBuffer, { width: 150, align: 'center' });
    doc.end();
  });
}

What does this code do?

This function fetches the full booking data (with event and user populated), then builds a unique QR code containing the booking ID and a verification hash derived from your JWT_SECRET. Venue staff can scan this QR code to verify ticket authenticity.

The PDF is built using PDFDocument streams. Data chunks are collected in an array, then concatenated into a single Buffer when the document ends. The doc.image() call accepts a Buffer directly, so the QR code data URL is decoded from base64 before embedding.

  • Below the generateTicketPDF function in the same file, add the generateAndSendTicket function and the module exports:
async function generateAndSendTicket(bookingId) {
  // Generate the PDF buffer
  const pdfBuffer = await generateTicketPDF(bookingId);

  // Fetch booking with user email for sending
  const booking = await Booking.findById(bookingId).populate('user');

  // Send confirmation email with PDF attachment
  await sendBookingConfirmation(booking.user.email, booking, pdfBuffer);

  // Mark ticket as generated in the database
  await Booking.findByIdAndUpdate(bookingId, { ticketGenerated: true });
  return pdfBuffer;
}

module.exports = { generateTicketPDF, generateAndSendTicket };

What does this code do?

The generateAndSendTicket function orchestrates the full post-payment workflow. It generates the PDF, emails it to the user, and sets ticketGenerated: true on the booking. This is the function your Stripe webhook calls after a successful payment.

  • Save server/services/ticketService.js. The file will show an import error for ./emailService until you create that file in the next substep.

Seeing a "Cannot find module" error?

This is expected. The emailService.js file does not exist yet. You will create it in the next substep. Do not restart your server until both files are in place.

If you see other errors, double-check that your models/Booking.js and models/Event.js paths are correct.

help me debug my ticketService.js import paths

✔️ Awesome, I've got everything!

Great. Make sure you have saved server/services/ticketService.js before moving on.

ⓧ I'd like to double check the full code

const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const crypto = require('crypto');
const Booking = require('../models/Booking');
const Event = require('../models/Event');
const User = require('../models/User');
const { sendBookingConfirmation } = require('./emailService');

async function generateTicketPDF(bookingId) {
  const booking = await Booking.findById(bookingId).populate('event').populate('user');
  const verificationHash = crypto.createHash('sha256').update(bookingId.toString() + process.env.JWT_SECRET).digest('hex').slice(0,16);
  const qrData = `BOOKING:${bookingId}:${verificationHash}`;
  const qrDataURL = await QRCode.toDataURL(qrData);
  const qrBuffer = Buffer.from(qrDataURL.split(',')[1], 'base64');

  return new Promise((resolve, reject) => {
    const doc = new PDFDocument({ size: 'A4', margin: 50 });
    const buffers = [];
    doc.on('data', chunk => buffers.push(chunk));
    doc.on('end', () => resolve(Buffer.concat(buffers)));
    doc.on('error', reject);

    doc.fontSize(24).text('Universal Ticket Booking System', { align: 'center' });
    doc.moveDown();
    doc.fontSize(18).text(booking.event.title, { align: 'center' });
    doc.moveDown();
    doc.fontSize(12)
      .text(`Date: ${new Date(booking.event.date).toDateString()}`)
      .text(`Venue: ${booking.event.venue || booking.event.source + ' \u2192 ' + booking.event.destination}`)
      .text(`Passenger: ${booking.user.name}`)
      .text(`Email: ${booking.user.email}`)
      .text(`Seats: ${booking.seats.join(', ')}`)
      .text(`Total Paid: $${booking.totalAmount}`)
      .text(`Booking Ref: ${bookingId}`);
    doc.moveDown();
    doc.image(qrBuffer, { width: 150, align: 'center' });
    doc.end();
  });
}

async function generateAndSendTicket(bookingId) {
  const pdfBuffer = await generateTicketPDF(bookingId);
  const booking = await Booking.findById(bookingId).populate('user');
  await sendBookingConfirmation(booking.user.email, booking, pdfBuffer);
  await Booking.findByIdAndUpdate(bookingId, { ticketGenerated: true });
  return pdfBuffer;
}

module.exports = { generateTicketPDF, generateAndSendTicket };
Send booking confirmation email with PDF attachment

The email service uses Nodemailer with your Gmail App Password to deliver booking confirmations. Each email includes the generated PDF as an attachment so the user has their ticket immediately.

  • Create a new file at server/services/emailService.js.
  • Add the transporter configuration and sendBookingConfirmation function:
const nodemailer = require('nodemailer');

// Configure Gmail transporter with App Password
const transporter = nodemailer.createTransport({
  service: 'gmail',
  auth: { user: process.env.GMAIL_USER, pass: process.env.GMAIL_APP_PASSWORD }
});

async function sendBookingConfirmation(to, booking, pdfBuffer) {
  // Send HTML email with PDF ticket attached
  await transporter.sendMail({
    from: process.env.GMAIL_USER,
    to,
    subject: `Booking Confirmed: ${booking.event?.title || 'Your Ticket'}`,
    html: `<h2>Booking Confirmed!</h2><p>Your booking reference is <strong>${booking._id}</strong>.</p><p>Please find your ticket attached.</p>`,
    attachments: [{ filename: 'ticket.pdf', content: pdfBuffer, contentType: 'application/pdf' }]
  });
}

What does this code do?

The transporter object connects to Gmail using the service: 'gmail' shortcut and authenticates with your App Password from the .env file.

The sendBookingConfirmation function composes an HTML email with the booking reference and attaches the PDF buffer directly. Nodemailer accepts Buffers in the attachments array, so no file system write is needed.

  • Below the sendBookingConfirmation function, add the sendResetEmail function and exports:
async function sendResetEmail(to, token) {
  // Send password reset link with 1-hour expiration
  await transporter.sendMail({
    from: process.env.GMAIL_USER,
    to,
    subject: 'Password Reset Request',
    html: `<p>Click <a href="${process.env.CLIENT_URL}/reset-password/${token}">here</a> to reset your password. Link expires in 1 hour.</p>`
  });
}

module.exports = { sendBookingConfirmation, sendResetEmail };

Why include a reset email function here?

The sendResetEmail function centralizes all email-sending logic in one service. Your forgot-password route from Step 2 can import this instead of configuring its own transporter. One transporter, one place to update credentials.

  • Save server/services/emailService.js.
  • Restart your server. Both services should load without errors now.

Server crashing on startup?

Make sure your .env file contains GMAIL_USER and GMAIL_APP_PASSWORD. The transporter initializes when the file is required, so missing credentials cause an immediate error.

Verify that your Gmail App Password is the 16-character code from your Google Account security settings (not your regular Gmail password).

help me debug my Nodemailer Gmail configuration

✔️ Awesome, I've got everything!

Great. Make sure you have saved server/services/emailService.js and your server restarts cleanly.

ⓧ I'd like to double check the full code

const nodemailer = require('nodemailer');

const transporter = nodemailer.createTransport({
  service: 'gmail',
  auth: { user: process.env.GMAIL_USER, pass: process.env.GMAIL_APP_PASSWORD }
});

async function sendBookingConfirmation(to, booking, pdfBuffer) {
  await transporter.sendMail({
    from: process.env.GMAIL_USER,
    to,
    subject: `Booking Confirmed: ${booking.event?.title || 'Your Ticket'}`,
    html: `<h2>Booking Confirmed!</h2><p>Your booking reference is <strong>${booking._id}</strong>.</p><p>Please find your ticket attached.</p>`,
    attachments: [{ filename: 'ticket.pdf', content: pdfBuffer, contentType: 'application/pdf' }]
  });
}

async function sendResetEmail(to, token) {
  await transporter.sendMail({
    from: process.env.GMAIL_USER,
    to,
    subject: 'Password Reset Request',
    html: `<p>Click <a href="${process.env.CLIENT_URL}/reset-password/${token}">here</a> to reset your password. Link expires in 1 hour.</p>`
  });
}

module.exports = { sendBookingConfirmation, sendResetEmail };
Add ticket download to user dashboard

Users need a way to access their bookings after payment. You will create a bookings route that streams the PDF to the browser and allows cancellation, then build a frontend page to display everything.

  • Create a new file at server/routes/bookings.js.
  • Add the imports and the two GET routes for listing bookings and downloading tickets:
const express = require('express');
const router = express.Router();
const { protect } = require('../middleware/auth');
const Booking = require('../models/Booking');
const Event = require('../models/Event');
const { generateTicketPDF } = require('../services/ticketService');

// List all bookings for the authenticated user
router.get('/my', protect, async (req, res) => {
  const bookings = await Booking.find({ user: req.user._id }).populate('event');
  res.json(bookings);
});

// Stream PDF ticket to client for download
router.get('/:id/ticket', protect, async (req, res) => {
  const pdfBuffer = await generateTicketPDF(req.params.id);
  res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': 'attachment; filename=ticket.pdf' });
  res.send(pdfBuffer);
});

What does this code do?

The GET /my route returns all bookings belonging to the logged-in user, with the event details populated. The GET /:id/ticket route regenerates the PDF on demand and sends it as a downloadable file using the Content-Disposition: attachment header.

  • Below the GET routes in the same file, add the cancellation route and the module export:
// Cancel a booking and release seats back to available
router.put('/:id/cancel', protect, async (req, res) => {
  const booking = await Booking.findOne({ _id: req.params.id, user: req.user._id });
  if (!booking) return res.status(404).json({ message: 'Booking not found' });

  // Update booking status
  booking.status = 'cancelled';
  await booking.save();

  // Release seats on the event
  const event = await Event.findById(booking.event);
  event.seatLayout.forEach(row => row.forEach(seat => {
    if (booking.seats.includes(seat.seatId)) { seat.status = 'available'; seat.selectedBy = null; }
  }));
  event.availableSeats += booking.seats.length;
  await event.save();

  res.json({ message: 'Booking cancelled' });
});

module.exports = router;

What does this code do?

The cancel route first verifies the booking belongs to the requesting user. It then marks the booking as cancelled and iterates through the event's seat layout to release those seats back to available. The event's availableSeats counter is incremented so new users can book those seats.

  • Save server/routes/bookings.js.

✔️ Awesome, I've got everything!

Great. Make sure you have saved server/routes/bookings.js before moving on.

ⓧ I'd like to double check the full code

const express = require('express');
const router = express.Router();
const { protect } = require('../middleware/auth');
const Booking = require('../models/Booking');
const Event = require('../models/Event');
const { generateTicketPDF } = require('../services/ticketService');

router.get('/my', protect, async (req, res) => {
  const bookings = await Booking.find({ user: req.user._id }).populate('event');
  res.json(bookings);
});

router.get('/:id/ticket', protect, async (req, res) => {
  const pdfBuffer = await generateTicketPDF(req.params.id);
  res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': 'attachment; filename=ticket.pdf' });
  res.send(pdfBuffer);
});

router.put('/:id/cancel', protect, async (req, res) => {
  const booking = await Booking.findOne({ _id: req.params.id, user: req.user._id });
  if (!booking) return res.status(404).json({ message: 'Booking not found' });
  booking.status = 'cancelled';
  await booking.save();
  const event = await Event.findById(booking.event);
  event.seatLayout.forEach(row => row.forEach(seat => {
    if (booking.seats.includes(seat.seatId)) { seat.status = 'available'; seat.selectedBy = null; }
  }));
  event.availableSeats += booking.seats.length;
  await event.save();
  res.json({ message: 'Booking cancelled' });
});

module.exports = router;
  • In server/index.js, find this line near the top of your route imports:
const webhookRoutes = require('./routes/webhooks');
  • Add the bookings route import directly below it, so the line now reads:
const webhookRoutes = require('./routes/webhooks');
const bookingRoutes = require('./routes/bookings');
  • Now find the line that registers the payment routes:
app.use('/api/payments', paymentRoutes);
  • Add the bookings route registration directly below it:
app.use('/api/payments', paymentRoutes);
app.use('/api/bookings', bookingRoutes);
  • Save server/index.js and restart your server.
  • Test the endpoint by making a GET request to http://localhost:5000/api/bookings/my with a valid JWT token. You should see an array of your bookings (or an empty array if none exist yet).

Getting a 404 on /api/bookings/my?

Make sure you added both the require import AND the app.use registration. Missing either one means the route is not mounted.

Check that app.use('/api/bookings', bookingRoutes) appears AFTER the express.json() middleware line. Routes registered before the JSON parser cannot read request bodies.

help me debug my bookings route registration in index.js

Now build the frontend page so users can see their bookings, download tickets, and cancel if needed.

  • Create a new file at client/src/pages/UserBookings.jsx.
  • Add the following component code:
import { useState, useEffect } from 'react';
import axios from 'axios';

// Lists user's bookings with status indicators
// Download Ticket button calls GET /api/bookings/:id/ticket
// Cancel button calls PUT /api/bookings/:id/cancel
// Shows transaction history with dates, amounts, statuses
function UserBookings() {
  const [bookings, setBookings] = useState([]);

  useEffect(() => {
    fetchBookings();
  }, []);

  // Fetch all bookings for the logged-in user
  async function fetchBookings() {
    const res = await axios.get('/api/bookings/my');
    setBookings(res.data);
  }

  // Cancel a booking and refresh the list
  async function handleCancel(bookingId) {
    await axios.put(`/api/bookings/${bookingId}/cancel`);
    fetchBookings();
  }

  // Open PDF ticket in a new tab for download
  function handleDownload(bookingId) {
    window.open(`/api/bookings/${bookingId}/ticket`);
  }

  return (
    <div className="user-bookings">
      <h2>My Bookings</h2>
      {bookings.map(booking => (
        <div key={booking._id} className="booking-card">
          <h3>{booking.event?.title}</h3>
          <p>Date: {new Date(booking.event?.date).toDateString()}</p>
          <p>Seats: {booking.seats.join(', ')}</p>
          <p>Amount: ${booking.totalAmount}</p>
          <span className={`status ${booking.status}`}>{booking.status}</span>
          {booking.status === 'confirmed' && (
            <>
              <button onClick={() => handleDownload(booking._id)}>Download Ticket</button>
              <button onClick={() => handleCancel(booking._id)}>Cancel Booking</button>
            </>
          )}
          <p className="booking-date">Booked: {new Date(booking.createdAt).toLocaleDateString()}</p>
        </div>
      ))}
    </div>
  );
}

export default UserBookings;

What does this code do?

The component fetches all bookings on mount and renders each as a card. Status indicators show whether a booking is confirmed, pending, or cancelled.

The Download Ticket button opens the GET /api/bookings/:id/ticket endpoint in a new tab, which triggers a PDF download. The Cancel button calls the PUT endpoint and refreshes the list to reflect the updated status. Transaction history (dates, amounts) is shown for each booking via the createdAt timestamp and totalAmount fields.

  • Save client/src/pages/UserBookings.jsx.
  • Add a route for this page in your React router (e.g., /my-bookings pointing to the UserBookings component).
  • Navigate to the page in your browser. You should see the heading "My Bookings" and any confirmed bookings with Download Ticket and Cancel Booking buttons.

Bookings page shows nothing?

Confirm you have at least one confirmed booking in your database. If you have not completed a Stripe test payment yet, the bookings array will be empty.

Check your browser's Network tab for the /api/bookings/my request. A 401 response means your auth token is not being sent. Make sure axios is configured to include credentials.

help me debug why my UserBookings page is not showing data

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files and your app compiles without errors.

ⓧ I'd like to double check the full code

import { useState, useEffect } from 'react';
import axios from 'axios';

// Lists user's bookings with status indicators
// Download Ticket button calls GET /api/bookings/:id/ticket
// Cancel button calls PUT /api/bookings/:id/cancel
// Shows transaction history with dates, amounts, statuses
function UserBookings() {
  const [bookings, setBookings] = useState([]);

  useEffect(() => {
    fetchBookings();
  }, []);

  async function fetchBookings() {
    const res = await axios.get('/api/bookings/my');
    setBookings(res.data);
  }

  async function handleCancel(bookingId) {
    await axios.put(`/api/bookings/${bookingId}/cancel`);
    fetchBookings();
  }

  function handleDownload(bookingId) {
    window.open(`/api/bookings/${bookingId}/ticket`);
  }

  return (
    <div className="user-bookings">
      <h2>My Bookings</h2>
      {bookings.map(booking => (
        <div key={booking._id} className="booking-card">
          <h3>{booking.event?.title}</h3>
          <p>Date: {new Date(booking.event?.date).toDateString()}</p>
          <p>Seats: {booking.seats.join(', ')}</p>
          <p>Amount: ${booking.totalAmount}</p>
          <span className={`status ${booking.status}`}>{booking.status}</span>
          {booking.status === 'confirmed' && (
            <>
              <button onClick={() => handleDownload(booking._id)}>Download Ticket</button>
              <button onClick={() => handleCancel(booking._id)}>Cancel Booking</button>
            </>
          )}
          <p className="booking-date">Booked: {new Date(booking.createdAt).toLocaleDateString()}</p>
        </div>
      ))}
    </div>
  );
}

export default UserBookings;

Your booking system now delivers the full experience. Users pay, receive a PDF ticket by email, and can manage their bookings from a dedicated dashboard. Next up, you will build the admin analytics dashboard to visualize revenue, popular events, and user activity across the platform.

Build the Admin Analytics Dashboard

Your booking system is generating transactions, but admins have no way to see how the platform is performing. Without visibility into revenue trends, popular events, and user activity, an admin is flying blind.

In this step, you will build MongoDB aggregation pipelines that crunch booking data into useful analytics. Then you will create a React dashboard with Recharts visualizations and a full admin management panel for users, vendors, events, and bookings.

In this step, get ready to:
  • Build analytics aggregation endpoints for revenue, popular events, active users, and category breakdown.
  • Build the analytics dashboard UI with Recharts charts and summary cards.
  • Complete the admin panel UI with user management, event moderation, and booking filters.
Build analytics aggregation endpoints

MongoDB's aggregation framework lets you group, sort, and compute values across entire collections in a single query. You will use it to build four analytics endpoints that power the admin dashboard charts.

  • Open server/routes/admin.js in VS Code.
  • Add the revenue-by-date analytics route below your existing routes by pasting the following code at the bottom of the file, before module.exports = router;:
// Aggregate confirmed bookings by date, summing revenue
router.get('/analytics/revenue-by-date', async (req, res) => {
  const { startDate, endDate } = req.query;
  const match = { status: 'confirmed' };
  if (startDate || endDate) match.createdAt = {};
  if (startDate) match.createdAt.$gte = new Date(startDate);
  if (endDate) match.createdAt.$lte = new Date(endDate);
  const data = await Booking.aggregate([
    { $match: match },
    { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, revenue: { $sum: '$totalAmount' }, count: { $sum: 1 } } },
    { $sort: { _id: 1 } }
  ]);
  res.json(data);
});

What does this code do?

The $match stage filters to only confirmed bookings within the optional date range.

The $group stage uses $dateToString to bucket bookings by calendar date, then sums totalAmount for daily revenue and counts bookings per day.

The $sort stage orders results chronologically so the frontend can render a line chart directly.

  • Below the revenue-by-date route, add the active-users analytics endpoint:
// Find users with the most bookings in the last 30 days
router.get('/analytics/active-users', async (req, res) => {
  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
  const data = await Booking.aggregate([
    { $match: { status: 'confirmed', createdAt: { $gte: thirtyDaysAgo } } },
    { $group: { _id: '$user', count: { $sum: 1 } } },
    { $sort: { count: -1 } }, { $limit: 10 },
    { $lookup: { from: 'users', localField: '_id', foreignField: '_id', as: 'user' } },
    { $unwind: '$user' }
  ]);
  res.json(data);
});

What does this code do?

This pipeline finds the top 10 most active bookers in the past 30 days. The $lookup stage joins the users collection to include each user's name and email in the response.

  • Add the category-breakdown endpoint below the active-users route:
// Group confirmed bookings by event category (movie, concert, train)
router.get('/analytics/category-breakdown', async (req, res) => {
  const data = await Booking.aggregate([
    { $match: { status: 'confirmed' } },
    { $lookup: { from: 'events', localField: 'event', foreignField: '_id', as: 'event' } },
    { $unwind: '$event' },
    { $group: { _id: '$event.category', count: { $sum: 1 }, revenue: { $sum: '$totalAmount' } } }
  ]);
  res.json(data);
});

What does this code do?

This pipeline joins each booking to its event, then groups by the event's category field (movie, concert, or train). The result gives a count and total revenue per category, which powers the pie chart.

  • Finally, add the event approval endpoint for admin moderation. Paste this above the analytics routes:
// Allow admin to approve or reject vendor events
router.put('/events/:id/approve', async (req, res) => {
  const event = await Event.findByIdAndUpdate(req.params.id, { approved: req.body.approved }, { new: true });
  res.json(event);
});

What does this code do?

This route lets an admin approve or reject a vendor's event by setting approved to true or false in the request body. The { new: true } option returns the updated event document.

  • Save server/routes/admin.js.
  • Test the revenue-by-date endpoint by visiting http://localhost:5000/api/admin/analytics/revenue-by-date in your browser (logged in as admin). You should see a JSON array of date-grouped revenue objects.

Getting an empty array?

This is normal if you have no confirmed bookings in your database yet. The aggregation pipelines only include bookings with status: 'confirmed'. Complete a test booking through Stripe (using card 4242 4242 4242 4242) to generate test data.

help me debug empty analytics responses

✔️ Awesome, I've got everything!

Great. Double check you have saved server/routes/admin.js.

ⓧ I'd like to double check the full code

const express = require('express');
const router = express.Router();
const { protect, requireRole } = require('../middleware/auth');
const User = require('../models/User');
const Event = require('../models/Event');
const Booking = require('../models/Booking');

router.use(protect, requireRole('admin'));

router.get('/events', async (req, res) => {
  const events = await Event.find().populate('vendor', 'name email');
  res.json(events);
});

router.put('/events/:id/approve', async (req, res) => {
  const event = await Event.findByIdAndUpdate(req.params.id, { approved: req.body.approved }, { new: true });
  res.json(event);
});

router.get('/users', async (req, res) => {
  const users = await User.find({ role: 'user' });
  res.json(users);
});

router.get('/vendors', async (req, res) => {
  const vendors = await User.find({ role: 'vendor' });
  res.json(vendors);
});

router.put('/users/:id/suspend', async (req, res) => {
  const user = await User.findByIdAndUpdate(req.params.id, { suspended: true }, { new: true });
  res.json(user);
});

router.delete('/users/:id', async (req, res) => {
  await User.findByIdAndDelete(req.params.id);
  res.json({ message: 'User deleted' });
});

router.get('/bookings', async (req, res) => {
  const { eventName, userEmail, startDate, endDate } = req.query;
  const filter = {};
  if (startDate || endDate) filter.createdAt = {};
  if (startDate) filter.createdAt.$gte = new Date(startDate);
  if (endDate) filter.createdAt.$lte = new Date(endDate);
  const bookings = await Booking.find(filter).populate('user', 'name email').populate('event', 'title date category');
  res.json(bookings);
});

router.get('/analytics/top-events', async (req, res) => {
  const topEvents = await Booking.aggregate([
    { $match: { status: 'confirmed' } },
    { $group: { _id: '$event', count: { $sum: 1 }, revenue: { $sum: '$totalAmount' } } },
    { $sort: { count: -1 } }, { $limit: 10 },
    { $lookup: { from: 'events', localField: '_id', foreignField: '_id', as: 'event' } },
    { $unwind: '$event' }
  ]);
  res.json(topEvents);
});

router.get('/analytics/revenue-by-date', async (req, res) => {
  const { startDate, endDate } = req.query;
  const match = { status: 'confirmed' };
  if (startDate || endDate) match.createdAt = {};
  if (startDate) match.createdAt.$gte = new Date(startDate);
  if (endDate) match.createdAt.$lte = new Date(endDate);
  const data = await Booking.aggregate([
    { $match: match },
    { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, revenue: { $sum: '$totalAmount' }, count: { $sum: 1 } } },
    { $sort: { _id: 1 } }
  ]);
  res.json(data);
});

router.get('/analytics/active-users', async (req, res) => {
  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
  const data = await Booking.aggregate([
    { $match: { status: 'confirmed', createdAt: { $gte: thirtyDaysAgo } } },
    { $group: { _id: '$user', count: { $sum: 1 } } },
    { $sort: { count: -1 } }, { $limit: 10 },
    { $lookup: { from: 'users', localField: '_id', foreignField: '_id', as: 'user' } },
    { $unwind: '$user' }
  ]);
  res.json(data);
});

router.get('/analytics/category-breakdown', async (req, res) => {
  const data = await Booking.aggregate([
    { $match: { status: 'confirmed' } },
    { $lookup: { from: 'events', localField: 'event', foreignField: '_id', as: 'event' } },
    { $unwind: '$event' },
    { $group: { _id: '$event.category', count: { $sum: 1 }, revenue: { $sum: '$totalAmount' } } }
  ]);
  res.json(data);
});

module.exports = router;
Build the analytics dashboard UI with Recharts

Recharts is a composable charting library built on React components. You compose charts from building blocks like LineChart, XAxis, and Tooltip rather than passing complex config objects.

  • Create a new file client/src/pages/AdminDashboard.jsx inside your client/src/pages folder.
  • Add the imports, state declarations, and data-fetching logic by pasting the following code:
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import {
  LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
  XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
} from 'recharts';

// Color palette for pie chart segments
const COLORS = ['#0088FE', '#00C49F', '#FFBB28'];

export default function AdminDashboard() {
  // Analytics data state
  const [revenueData, setRevenueData] = useState([]);
  const [topEvents, setTopEvents] = useState([]);
  const [categoryData, setCategoryData] = useState([]);
  const [activeUsers, setActiveUsers] = useState([]);

  // Summary card totals
  const [totalRevenue, setTotalRevenue] = useState(0);
  const [totalBookings, setTotalBookings] = useState(0);
  const [totalUsers, setTotalUsers] = useState(0);
  const [totalEvents, setTotalEvents] = useState(0);

  // Date range filter state
  const [startDate, setStartDate] = useState('');
  const [endDate, setEndDate] = useState('');

  // Admin management state
  const [users, setUsers] = useState([]);
  const [vendors, setVendors] = useState([]);
  const [events, setEvents] = useState([]);
  const [bookings, setBookings] = useState([]);

  // Fetch analytics data on mount and when date range changes
  useEffect(() => {
    fetchAnalytics();
    fetchManagementData();
  }, [startDate, endDate]);

  async function fetchAnalytics() {
    const params = {};
    if (startDate) params.startDate = startDate;
    if (endDate) params.endDate = endDate;

    const [revRes, topRes, catRes, activeRes] = await Promise.all([
      axios.get('/api/admin/analytics/revenue-by-date', { params }),
      axios.get('/api/admin/analytics/top-events'),
      axios.get('/api/admin/analytics/category-breakdown'),
      axios.get('/api/admin/analytics/active-users')
    ]);

    setRevenueData(revRes.data);
    setTopEvents(topRes.data);
    setCategoryData(catRes.data);
    setActiveUsers(activeRes.data);

    // Calculate summary totals from revenue data
    setTotalRevenue(revRes.data.reduce((sum, d) => sum + d.revenue, 0));
    setTotalBookings(revRes.data.reduce((sum, d) => sum + d.count, 0));
  }

  async function fetchManagementData() {
    const [usersRes, vendorsRes, eventsRes, bookingsRes] = await Promise.all([
      axios.get('/api/admin/users'),
      axios.get('/api/admin/vendors'),
      axios.get('/api/admin/events'),
      axios.get('/api/admin/bookings')
    ]);
    setUsers(usersRes.data);
    setVendors(vendorsRes.data);
    setEvents(eventsRes.data);
    setBookings(bookingsRes.data);
    setTotalUsers(usersRes.data.length);
    setTotalEvents(eventsRes.data.length);
  }

What does this code do?

The component sets up state for both analytics data (charts) and management data (tables). The useEffect hook fires on mount and whenever the date filter changes.

All four analytics endpoints are called in parallel with Promise.all for faster loading. Summary card totals are computed client-side from the revenue response.

  • Below the fetchManagementData function, add the handler functions for admin actions:
  // Suspend a user account
  async function handleSuspend(id) {
    await axios.put(`/api/admin/users/${id}/suspend`);
    fetchManagementData();
  }

  // Delete a user account permanently
  async function handleDelete(id) {
    await axios.delete(`/api/admin/users/${id}`);
    fetchManagementData();
  }

  // Approve or reject a vendor event
  async function handleApprove(id, approved) {
    await axios.put(`/api/admin/events/${id}/approve`, { approved });
    fetchManagementData();
  }

What does this code do?

Each handler calls the corresponding admin API endpoint, then refreshes the management data so the UI reflects the change immediately. The handleApprove function sends a boolean in the request body to approve (true) or reject (false) an event.

  • Now add the JSX return statement with summary cards, date filter, and Recharts visualizations:
  return (
    <div style={{ padding: '2rem' }}>
      <h1>Admin Dashboard</h1>

      {/* Summary Cards */}
      <div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem' }}>
        <div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '8px', flex: 1 }}>
          <h3>Total Revenue</h3>
          <p style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>${totalRevenue}</p>
        </div>
        <div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '8px', flex: 1 }}>
          <h3>Total Bookings</h3>
          <p style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>{totalBookings}</p>
        </div>
        <div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '8px', flex: 1 }}>
          <h3>Active Users</h3>
          <p style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>{totalUsers}</p>
        </div>
        <div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '8px', flex: 1 }}>
          <h3>Total Events</h3>
          <p style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>{totalEvents}</p>
        </div>
      </div>

      {/* Date Range Filter */}
      <div style={{ marginBottom: '2rem' }}>
        <label>Start: <input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} /></label>
        <label style={{ marginLeft: '1rem' }}>End: <input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} /></label>
      </div>

      {/* Revenue Over Time - Line Chart */}
      <h2>Revenue Over Time</h2>
      <ResponsiveContainer width="100%" height={300}>
        <LineChart data={revenueData}>
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="_id" />
          <YAxis />
          <Tooltip />
          <Legend />
          <Line type="monotone" dataKey="revenue" stroke="#8884d8" />
        </LineChart>
      </ResponsiveContainer>

      {/* Top Events - Bar Chart */}
      <h2>Top Performing Events</h2>
      <ResponsiveContainer width="100%" height={300}>
        <BarChart data={topEvents}>
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="event.title" />
          <YAxis />
          <Tooltip />
          <Bar dataKey="count" fill="#82ca9d" />
        </BarChart>
      </ResponsiveContainer>

      {/* Category Breakdown - Pie Chart */}
      <h2>Category Breakdown</h2>
      <ResponsiveContainer width="100%" height={300}>
        <PieChart>
          <Pie data={categoryData} dataKey="count" nameKey="_id" label>
            {categoryData.map((entry, index) => (
              <Cell key={index} fill={COLORS[index % COLORS.length]} />
            ))}
          </Pie>
          <Tooltip />
          <Legend />
        </PieChart>
      </ResponsiveContainer>

How do Recharts components work here?

Each chart is wrapped in a ResponsiveContainer that stretches to fit the parent width. You compose charts by nesting axis, grid, tooltip, and data series components inside the chart wrapper.

The dataKey prop tells each component which field in your data array to read. For example, dataKey="revenue" maps the Line to the revenue values from the aggregation response.

The PieChart uses Cell components to assign a different color to each slice from the COLORS palette.

  • Save client/src/pages/AdminDashboard.jsx (you will complete the rest of this file in the next substep).

Charts not rendering?

Make sure recharts and react-is are installed in your client folder. If not, run npm install recharts react-is from the client/ directory.

Also check that your admin user's JWT token is being sent with requests (axios must include credentials or an Authorization header).

help me debug my Recharts charts not appearing

Complete the admin panel UI

The dashboard needs management capabilities beyond just charts. Admins need to suspend bad actors, moderate vendor events, filter bookings, and track vendor performance.

  • Return to client/src/pages/AdminDashboard.jsx and add the admin management tables below the charts JSX you just added (still inside the main return statement):
      {/* User Management Table */}
      <h2>User Management</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '2rem' }}>
        <thead>
          <tr>
            <th>Name</th><th>Email</th><th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {users.map(u => (
            <tr key={u._id} style={{ borderBottom: '1px solid #eee' }}>
              <td>{u.name}</td>
              <td>{u.email}</td>
              <td>
                <button onClick={() => handleSuspend(u._id)}>Suspend</button>
                <button onClick={() => handleDelete(u._id)} style={{ marginLeft: '0.5rem', color: 'red' }}>Delete</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      {/* Vendor Performance Table */}
      <h2>Vendor Performance</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '2rem' }}>
        <thead>
          <tr>
            <th>Vendor</th><th>Email</th><th>Events Listed</th>
          </tr>
        </thead>
        <tbody>
          {vendors.map(v => (
            <tr key={v._id} style={{ borderBottom: '1px solid #eee' }}>
              <td>{v.name}</td>
              <td>{v.email}</td>
              <td>{events.filter(e => e.vendor?._id === v._id).length}</td>
            </tr>
          ))}
        </tbody>
      </table>

What does this code do?

The User Management table lists all users with action buttons to suspend or delete accounts. Each button calls the corresponding handler you wrote earlier, which hits the admin API and refreshes the table.

The Vendor Performance table shows each vendor's name and how many events they have listed. It computes the count client-side by filtering the events array by vendor ID.

  • Add the event moderation and booking list sections below the vendor table:
      {/* Event Moderation */}
      <h2>Event Moderation</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '2rem' }}>
        <thead>
          <tr>
            <th>Title</th><th>Category</th><th>Vendor</th><th>Status</th><th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {events.map(ev => (
            <tr key={ev._id} style={{ borderBottom: '1px solid #eee' }}>
              <td>{ev.title}</td>
              <td>{ev.category}</td>
              <td>{ev.vendor?.name}</td>
              <td>{ev.approved ? 'Approved' : 'Pending'}</td>
              <td>
                <button onClick={() => handleApprove(ev._id, true)}>Approve</button>
                <button onClick={() => handleApprove(ev._id, false)} style={{ marginLeft: '0.5rem' }}>Reject</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      {/* Booking List with Filters */}
      <h2>All Bookings</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr>
            <th>User</th><th>Event</th><th>Category</th><th>Date</th><th>Amount</th><th>Status</th>
          </tr>
        </thead>
        <tbody>
          {bookings.map(b => (
            <tr key={b._id} style={{ borderBottom: '1px solid #eee' }}>
              <td>{b.user?.name}</td>
              <td>{b.event?.title}</td>
              <td>{b.event?.category}</td>
              <td>{new Date(b.createdAt).toLocaleDateString()}</td>
              <td>${b.totalAmount}</td>
              <td>{b.status}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

What does this code do?

The Event Moderation table shows every vendor-created event with Approve and Reject buttons. Clicking either sends a PUT request to /api/admin/events/:id/approve with the appropriate boolean.

The All Bookings table shows every booking across the platform with user name, event title, category, date, amount, and current status. The date range filter at the top of the page already controls which bookings the backend returns.

  • Save client/src/pages/AdminDashboard.jsx.
  • Add a route for the admin dashboard in your React router (e.g. /admin path pointing to AdminDashboard).
  • Log in as an admin user and navigate to http://localhost:3000/admin. You should see summary cards, charts, and management tables.

Seeing a blank page or 403 errors?

A 403 response means your user does not have the admin role. Check your user document in MongoDB Atlas and set role: 'admin' manually if needed.

If the page loads but tables are empty, make sure your backend server is running and the axios base URL is correct (matching your Express server port).

help me fix my admin dashboard access issues

✔️ Awesome, I've got everything!

Great. Double check you have saved client/src/pages/AdminDashboard.jsx and that your route is registered in your React router.

ⓧ I'd like to double check the full code

import React, { useState, useEffect } from 'react';
import axios from 'axios';
import {
  LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
  XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
} from 'recharts';

// Color palette for pie chart segments
const COLORS = ['#0088FE', '#00C49F', '#FFBB28'];

export default function AdminDashboard() {
  // Analytics data state
  const [revenueData, setRevenueData] = useState([]);
  const [topEvents, setTopEvents] = useState([]);
  const [categoryData, setCategoryData] = useState([]);
  const [activeUsers, setActiveUsers] = useState([]);

  // Summary card totals
  const [totalRevenue, setTotalRevenue] = useState(0);
  const [totalBookings, setTotalBookings] = useState(0);
  const [totalUsers, setTotalUsers] = useState(0);
  const [totalEvents, setTotalEvents] = useState(0);

  // Date range filter state
  const [startDate, setStartDate] = useState('');
  const [endDate, setEndDate] = useState('');

  // Admin management state
  const [users, setUsers] = useState([]);
  const [vendors, setVendors] = useState([]);
  const [events, setEvents] = useState([]);
  const [bookings, setBookings] = useState([]);

  // Fetch analytics data on mount and when date range changes
  useEffect(() => {
    fetchAnalytics();
    fetchManagementData();
  }, [startDate, endDate]);

  async function fetchAnalytics() {
    const params = {};
    if (startDate) params.startDate = startDate;
    if (endDate) params.endDate = endDate;

    const [revRes, topRes, catRes, activeRes] = await Promise.all([
      axios.get('/api/admin/analytics/revenue-by-date', { params }),
      axios.get('/api/admin/analytics/top-events'),
      axios.get('/api/admin/analytics/category-breakdown'),
      axios.get('/api/admin/analytics/active-users')
    ]);

    setRevenueData(revRes.data);
    setTopEvents(topRes.data);
    setCategoryData(catRes.data);
    setActiveUsers(activeRes.data);

    // Calculate summary totals from revenue data
    setTotalRevenue(revRes.data.reduce((sum, d) => sum + d.revenue, 0));
    setTotalBookings(revRes.data.reduce((sum, d) => sum + d.count, 0));
  }

  async function fetchManagementData() {
    const [usersRes, vendorsRes, eventsRes, bookingsRes] = await Promise.all([
      axios.get('/api/admin/users'),
      axios.get('/api/admin/vendors'),
      axios.get('/api/admin/events'),
      axios.get('/api/admin/bookings')
    ]);
    setUsers(usersRes.data);
    setVendors(vendorsRes.data);
    setEvents(eventsRes.data);
    setBookings(bookingsRes.data);
    setTotalUsers(usersRes.data.length);
    setTotalEvents(eventsRes.data.length);
  }

  // Suspend a user account
  async function handleSuspend(id) {
    await axios.put(`/api/admin/users/${id}/suspend`);
    fetchManagementData();
  }

  // Delete a user account permanently
  async function handleDelete(id) {
    await axios.delete(`/api/admin/users/${id}`);
    fetchManagementData();
  }

  // Approve or reject a vendor event
  async function handleApprove(id, approved) {
    await axios.put(`/api/admin/events/${id}/approve`, { approved });
    fetchManagementData();
  }

  return (
    <div style={{ padding: '2rem' }}>
      <h1>Admin Dashboard</h1>

      {/* Summary Cards */}
      <div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem' }}>
        <div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '8px', flex: 1 }}>
          <h3>Total Revenue</h3>
          <p style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>${totalRevenue}</p>
        </div>
        <div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '8px', flex: 1 }}>
          <h3>Total Bookings</h3>
          <p style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>{totalBookings}</p>
        </div>
        <div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '8px', flex: 1 }}>
          <h3>Active Users</h3>
          <p style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>{totalUsers}</p>
        </div>
        <div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '8px', flex: 1 }}>
          <h3>Total Events</h3>
          <p style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>{totalEvents}</p>
        </div>
      </div>

      {/* Date Range Filter */}
      <div style={{ marginBottom: '2rem' }}>
        <label>Start: <input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} /></label>
        <label style={{ marginLeft: '1rem' }}>End: <input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} /></label>
      </div>

      {/* Revenue Over Time - Line Chart */}
      <h2>Revenue Over Time</h2>
      <ResponsiveContainer width="100%" height={300}>
        <LineChart data={revenueData}>
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="_id" />
          <YAxis />
          <Tooltip />
          <Legend />
          <Line type="monotone" dataKey="revenue" stroke="#8884d8" />
        </LineChart>
      </ResponsiveContainer>

      {/* Top Events - Bar Chart */}
      <h2>Top Performing Events</h2>
      <ResponsiveContainer width="100%" height={300}>
        <BarChart data={topEvents}>
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="event.title" />
          <YAxis />
          <Tooltip />
          <Bar dataKey="count" fill="#82ca9d" />
        </BarChart>
      </ResponsiveContainer>

      {/* Category Breakdown - Pie Chart */}
      <h2>Category Breakdown</h2>
      <ResponsiveContainer width="100%" height={300}>
        <PieChart>
          <Pie data={categoryData} dataKey="count" nameKey="_id" label>
            {categoryData.map((entry, index) => (
              <Cell key={index} fill={COLORS[index % COLORS.length]} />
            ))}
          </Pie>
          <Tooltip />
          <Legend />
        </PieChart>
      </ResponsiveContainer>

      {/* User Management Table */}
      <h2>User Management</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '2rem' }}>
        <thead>
          <tr>
            <th>Name</th><th>Email</th><th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {users.map(u => (
            <tr key={u._id} style={{ borderBottom: '1px solid #eee' }}>
              <td>{u.name}</td>
              <td>{u.email}</td>
              <td>
                <button onClick={() => handleSuspend(u._id)}>Suspend</button>
                <button onClick={() => handleDelete(u._id)} style={{ marginLeft: '0.5rem', color: 'red' }}>Delete</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      {/* Vendor Performance Table */}
      <h2>Vendor Performance</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '2rem' }}>
        <thead>
          <tr>
            <th>Vendor</th><th>Email</th><th>Events Listed</th>
          </tr>
        </thead>
        <tbody>
          {vendors.map(v => (
            <tr key={v._id} style={{ borderBottom: '1px solid #eee' }}>
              <td>{v.name}</td>
              <td>{v.email}</td>
              <td>{events.filter(e => e.vendor?._id === v._id).length}</td>
            </tr>
          ))}
        </tbody>
      </table>

      {/* Event Moderation */}
      <h2>Event Moderation</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '2rem' }}>
        <thead>
          <tr>
            <th>Title</th><th>Category</th><th>Vendor</th><th>Status</th><th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {events.map(ev => (
            <tr key={ev._id} style={{ borderBottom: '1px solid #eee' }}>
              <td>{ev.title}</td>
              <td>{ev.category}</td>
              <td>{ev.vendor?.name}</td>
              <td>{ev.approved ? 'Approved' : 'Pending'}</td>
              <td>
                <button onClick={() => handleApprove(ev._id, true)}>Approve</button>
                <button onClick={() => handleApprove(ev._id, false)} style={{ marginLeft: '0.5rem' }}>Reject</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      {/* Booking List with Filters */}
      <h2>All Bookings</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr>
            <th>User</th><th>Event</th><th>Category</th><th>Date</th><th>Amount</th><th>Status</th>
          </tr>
        </thead>
        <tbody>
          {bookings.map(b => (
            <tr key={b._id} style={{ borderBottom: '1px solid #eee' }}>
              <td>{b.user?.name}</td>
              <td>{b.event?.title}</td>
              <td>{b.event?.category}</td>
              <td>{new Date(b.createdAt).toLocaleDateString()}</td>
              <td>${b.totalAmount}</td>
              <td>{b.status}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Your admin dashboard is fully operational with real-time analytics, data visualizations, and complete platform management capabilities. Next up, you will add AI-powered features including a recommendation engine, dynamic pricing, and a chatbot assistant.

Add AI-Powered Recommendations, Dynamic Pricing, and Chatbot

Your admin analytics dashboard is live, giving you and your admin users full visibility into how the platform is performing.

Now it's time to make the platform intelligent. In this step, you'll add three AI-powered features: a recommendation engine that suggests events based on each user's booking history, a dynamic pricing algorithm that adjusts ticket prices based on demand, and a chatbot assistant that helps users search events and check bookings through natural language.

In this step, get ready to:
  • Build a recommendation engine using collaborative filtering and content-based filtering.
  • Implement a dynamic pricing algorithm that adjusts prices based on demand signals.
  • Create a rule-based chatbot assistant for event search and booking help.
Build the recommendation engine

A recommendation engine gives each user personalized event suggestions. Your engine will combine two approaches: collaborative filtering (finding users with similar booking patterns) and content-based filtering (suggesting events in the user's favorite category).

  • Create the recommendation service file by creating server/services/recommendationService.js and adding this code:
const Booking = require('../models/Booking');
const Event = require('../models/Event');

async function getRecommendations(userId) {
  // Get the user's confirmed bookings with event details
  const userBookings = await Booking.find({ user: userId, status: 'confirmed' }).populate('event');
  const bookedEventIds = userBookings.map(b => b.event._id.toString());

  // Count bookings per category to find the user's favorite
  const categoryCount = {};
  userBookings.forEach(b => {
    const cat = b.event.category;
    categoryCount[cat] = (categoryCount[cat] || 0) + 1;
  });
  const favoriteCategory = Object.entries(categoryCount).sort((a,b) => b[1]-a[1])[0]?.[0];

  // Collaborative filtering: find users who booked same events
  const similarUserBookings = await Booking.find({ event: { $in: bookedEventIds }, user: { $ne: userId }, status: 'confirmed' });
  const similarUserIds = [...new Set(similarUserBookings.map(b => b.user.toString()))];
  const collaborativeEvents = await Booking.find({ user: { $in: similarUserIds }, event: { $nin: bookedEventIds }, status: 'confirmed' }).distinct('event');

  // Content-based: same category, not already booked
  const contentEvents = await Event.find({ category: favoriteCategory, _id: { $nin: bookedEventIds }, approved: true, date: { $gte: new Date() } }).limit(5);

  // Combine both recommendation sources and deduplicate
  const allRecommendedIds = [...new Set([...collaborativeEvents.map(id => id.toString()), ...contentEvents.map(e => e._id.toString())])];
  const recommended = await Event.find({ _id: { $in: allRecommendedIds }, approved: true, date: { $gte: new Date() } }).limit(10);
  return recommended;
}

module.exports = { getRecommendations };

What does this code do?

This service implements two recommendation strategies that work together.

  • Collaborative filtering finds other users who booked the same events as the current user, then recommends events those similar users have booked that the current user hasn't seen yet.
  • Content-based filtering looks at the user's most-booked category (movies, concerts, or trains) and suggests upcoming events in that category.
  • Both sets of results are deduplicated and combined into a single list of up to 10 recommendations.
  • Save server/services/recommendationService.js.
  • Create the recommendations route file by creating server/routes/recommendations.js and adding this code:
const express = require('express');
const router = express.Router();
const { protect } = require('../middleware/auth');
const { getRecommendations } = require('../services/recommendationService');

// GET /api/recommendations/:userId - returns personalized event list
router.get('/:userId', protect, async (req, res) => {
  const events = await getRecommendations(req.params.userId);
  res.json(events);
});

module.exports = router;

What does this route do?

This route exposes the recommendation engine through a protected endpoint. Only authenticated users can fetch recommendations, and the :userId parameter lets the service look up that specific user's booking history.

  • Save server/routes/recommendations.js.
  • In server/index.js, add the recommendation route import alongside your other route imports:
const recommendationRoutes = require('./routes/recommendations');
  • Then register the route with the app, below your existing route registrations:
app.use('/api/recommendations', recommendationRoutes);
  • Save server/index.js.

Now connect the recommendation data to the frontend homepage.

  • Create or update client/src/pages/HomePage.jsx with a 'Recommended for You' section:
// Shows 'Recommended for You' section with personalized event cards
// Fetches from GET /api/recommendations/:userId
// Event cards show 🔥 High Demand badge when isHighDemand=true from GET /api/events/:id/price

What does the HomePage need?

Your homepage fetches recommendations from GET /api/recommendations/:userId when the user is logged in. For each recommended event card, it also calls GET /api/events/:id/price to check if the event is high-demand.

When isHighDemand is true in the price response, the event card displays a 🔥 High Demand badge to signal dynamic pricing is active.

  • Save client/src/pages/HomePage.jsx.
  • Restart your server and refresh the frontend. If you have existing bookings in your database, you should see personalized event recommendations on the homepage.

Not seeing recommendations?

The recommendation engine needs booking history to work. If you have no confirmed bookings, it returns an empty array. Create a few test bookings across different categories first.

Also confirm your server/index.js has both the import and the app.use registration for the recommendations route.

help me debug my recommendation engine

Implement the dynamic pricing algorithm

Dynamic pricing adjusts ticket prices in real time based on demand. When seats are selling fast or the event is nearly sold out, the price goes up. When demand is low, the price drops to attract more buyers.

  • Create the pricing service by creating server/services/pricingService.js and adding this code:
const Booking = require('../models/Booking');

async function getDynamicPrice(event) {
  // Return base price if dynamic pricing is disabled for this event
  if (!event.dynamicPricingEnabled) return event.price;

  // Calculate demand signals
  const soldRatio = (event.totalSeats - event.availableSeats) / event.totalSeats;
  const hoursUntilEvent = (new Date(event.date) - Date.now()) / (1000 * 60 * 60);
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
  const recentBookings = await Booking.countDocuments({ event: event._id, createdAt: { $gte: oneHourAgo }, status: 'confirmed' });
  const bookingVelocity = recentBookings;

  // Apply multipliers based on demand signals
  let multiplier = 1.0;
  if (soldRatio > 0.8) multiplier *= 1.3;
  else if (soldRatio < 0.2) multiplier *= 0.85;
  if (hoursUntilEvent < 24 && hoursUntilEvent > 0) multiplier *= 1.2;
  if (bookingVelocity > 10) multiplier *= 1.1;

  // Round to 2 decimal places
  return Math.round(event.price * multiplier * 100) / 100;
}

module.exports = { getDynamicPrice };

How does the pricing formula work?

The algorithm applies multipliers to the base price based on four demand signals:

  • Seats sold ratio: >80% sold applies a 1.3x surge. <20% sold applies a 0.85x discount.
  • Time urgency: less than 24 hours until the event applies a 1.2x multiplier.
  • Booking velocity: more than 10 bookings in the last hour applies a 1.1x multiplier.
  • These stack multiplicatively. A nearly sold-out event happening tomorrow with high velocity could reach 1.3 * 1.2 * 1.1 = 1.716x the base price.
  • Save server/services/pricingService.js.

Now expose the dynamic price through the events API.

  • In server/routes/events.js, add the import for the pricing service at the top of the file, below the Event model import:
const { getDynamicPrice } = require('../services/pricingService');
  • Then add the dynamic price endpoint. Place this route below the GET /:id route:
router.get('/:id/price', async (req, res) => {
  const event = await Event.findById(req.params.id);
  if (!event) return res.status(404).json({ message: 'Event not found' });
  const price = await getDynamicPrice(event);
  const soldRatio = (event.totalSeats - event.availableSeats) / event.totalSeats;
  res.json({ price, isHighDemand: soldRatio > 0.8 });
});

What does this endpoint return?

The /:id/price endpoint returns two fields: the calculated price (after dynamic multipliers) and an isHighDemand boolean. The frontend uses isHighDemand to decide whether to show the 🔥 High Demand badge on the event card.

  • Save server/routes/events.js.
  • Restart your server. Test the endpoint by visiting http://localhost:5000/api/events/<event-id>/price in your browser (replace <event-id> with an actual event ID from your database).
  • You should see a JSON response with price and isHighDemand fields.

Admin toggle for dynamic pricing

The pricing service checks event.dynamicPricingEnabled before applying multipliers. Add a dynamicPricingEnabled Boolean field to your Event model (default false) so vendors and admins can toggle it per event through the admin panel.

Price endpoint returning the base price?

If the dynamic price equals the base price, check that the event has dynamicPricingEnabled: true set in the database. Without this flag, the service returns the base price immediately.

help me debug why dynamic pricing isn't applying multipliers

Build the chatbot assistant

A chatbot gives users a conversational interface to search events, check their booking status, or get recommendations. You'll build a rule-based chatbot that recognizes intents through keyword matching. This approach is more impressive in interviews than calling an external AI API because it shows you understand NLP concepts without hiding behind a service.

  • Create the chatbot service by creating server/services/chatbotService.js and adding this code:
const Event = require('../models/Event');
const Booking = require('../models/Booking');

async function processMessage(message, userId) {
  const lower = message.toLowerCase();

  // Intent: search events by category
  if (lower.includes('concert') || lower.includes('movie') || lower.includes('train')) {
    const category = lower.includes('concert') ? 'concert' : lower.includes('movie') ? 'movie' : 'train';
    const events = await Event.find({ category, approved: true, date: { $gte: new Date() } }).limit(5);
    return { intent: 'search', events, message: `Here are upcoming ${category}s:` };
  }

  // Intent: check booking status
  if (lower.includes('booking') || lower.includes('status')) {
    const bookings = await Booking.find({ user: userId }).populate('event').sort({ createdAt: -1 }).limit(3);
    return { intent: 'booking_status', bookings, message: 'Your recent bookings:' };
  }

  // Intent: get recommendations
  if (lower.includes('recommend') || lower.includes('suggest')) {
    const { getRecommendations } = require('./recommendationService');
    const events = await getRecommendations(userId);
    return { intent: 'recommendations', events, message: 'Recommended for you:' };
  }

  // Intent: help/FAQ
  if (lower.includes('help') || lower.includes('faq')) {
    return { intent: 'faq', message: 'I can help you: search events ("show me concerts"), check bookings ("my booking status"), or get recommendations ("suggest events for me").' };
  }

  // Fallback for unrecognized input
  return { intent: 'unknown', message: "I didn't understand that. Try: 'show me movies', 'my booking status', or 'recommend events'." };
}

module.exports = { processMessage };

How does the intent recognition work?

The chatbot uses keyword matching to classify user messages into intents. Each intent triggers a different database query and response format:

  • Search intent: detects category keywords (concert, movie, train) and returns upcoming events in that category.
  • Booking status intent: detects 'booking' or 'status' keywords and returns the user's recent bookings.
  • Recommendation intent: detects 'recommend' or 'suggest' and reuses your recommendation engine.
  • FAQ intent: provides a help message explaining what the chatbot can do.
  • Save server/services/chatbotService.js.
  • Create the chatbot route by creating server/routes/chatbot.js and adding this code:
const express = require('express');
const router = express.Router();
const { protect } = require('../middleware/auth');
const { processMessage } = require('../services/chatbotService');

// POST /api/chatbot/message - processes a user chat message
router.post('/message', protect, async (req, res) => {
  const response = await processMessage(req.body.message, req.user._id);
  res.json(response);
});

module.exports = router;
  • Save server/routes/chatbot.js.
  • In server/index.js, add the chatbot route import alongside your other route imports:
const chatbotRoutes = require('./routes/chatbot');
  • Then register the chatbot route with the app, below your other route registrations:
app.use('/api/chatbot', chatbotRoutes);
  • Save server/index.js.

Now build the chat interface on the frontend.

  • Create client/src/components/ChatBot.jsx and add the chat UI:
// Floating chat button in bottom-right corner
// Opens chat panel with message bubbles and quick-reply buttons
// Calls POST /api/chatbot/message, displays structured responses (event cards, booking list)

What does the ChatBot component need?

Your ChatBot component renders a floating button in the bottom-right corner. Clicking it opens a chat panel with message bubbles (one style for user messages, another for bot responses).

Each user message is sent to POST /api/chatbot/message. The response includes an intent field that determines how to render the reply. For search or recommendation intents, display event cards. For booking status, display a booking list. Add quick-reply buttons like 'Show concerts' or 'My bookings' for easy interaction.

  • Save client/src/components/ChatBot.jsx.
  • Import and render the ChatBot component in your app's root layout so it appears on every page.
  • Restart your server and refresh the frontend. Click the floating chat button and try typing 'show me concerts' or 'my booking status'.
  • The chatbot should respond with relevant event cards or your recent bookings.

Chatbot returning 401 Unauthorized?

The chatbot route uses the protect middleware, so requests must include a valid JWT token. Make sure your frontend's axios instance includes the Authorization header with the stored token.

help me fix my chatbot authentication issue

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files and that your server restarts without errors.

ⓧ I'd like to double check the full code

Here are the key backend files for this step:

const Booking = require('../models/Booking');
const Event = require('../models/Event');

async function getRecommendations(userId) {
  const userBookings = await Booking.find({ user: userId, status: 'confirmed' }).populate('event');
  const bookedEventIds = userBookings.map(b => b.event._id.toString());
  const categoryCount = {};
  userBookings.forEach(b => {
    const cat = b.event.category;
    categoryCount[cat] = (categoryCount[cat] || 0) + 1;
  });
  const favoriteCategory = Object.entries(categoryCount).sort((a,b) => b[1]-a[1])[0]?.[0];

  // Collaborative filtering: find users who booked same events
  const similarUserBookings = await Booking.find({ event: { $in: bookedEventIds }, user: { $ne: userId }, status: 'confirmed' });
  const similarUserIds = [...new Set(similarUserBookings.map(b => b.user.toString()))];
  const collaborativeEvents = await Booking.find({ user: { $in: similarUserIds }, event: { $nin: bookedEventIds }, status: 'confirmed' }).distinct('event');

  // Content-based: same category, not already booked
  const contentEvents = await Event.find({ category: favoriteCategory, _id: { $nin: bookedEventIds }, approved: true, date: { $gte: new Date() } }).limit(5);

  const allRecommendedIds = [...new Set([...collaborativeEvents.map(id => id.toString()), ...contentEvents.map(e => e._id.toString())])];
  const recommended = await Event.find({ _id: { $in: allRecommendedIds }, approved: true, date: { $gte: new Date() } }).limit(10);
  return recommended;
}

module.exports = { getRecommendations };
const Booking = require('../models/Booking');

async function getDynamicPrice(event) {
  if (!event.dynamicPricingEnabled) return event.price;
  const soldRatio = (event.totalSeats - event.availableSeats) / event.totalSeats;
  const hoursUntilEvent = (new Date(event.date) - Date.now()) / (1000 * 60 * 60);
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
  const recentBookings = await Booking.countDocuments({ event: event._id, createdAt: { $gte: oneHourAgo }, status: 'confirmed' });
  const bookingVelocity = recentBookings;

  let multiplier = 1.0;
  if (soldRatio > 0.8) multiplier *= 1.3;
  else if (soldRatio < 0.2) multiplier *= 0.85;
  if (hoursUntilEvent < 24 && hoursUntilEvent > 0) multiplier *= 1.2;
  if (bookingVelocity > 10) multiplier *= 1.1;

  return Math.round(event.price * multiplier * 100) / 100;
}

module.exports = { getDynamicPrice };
const Event = require('../models/Event');
const Booking = require('../models/Booking');

async function processMessage(message, userId) {
  const lower = message.toLowerCase();

  if (lower.includes('concert') || lower.includes('movie') || lower.includes('train')) {
    const category = lower.includes('concert') ? 'concert' : lower.includes('movie') ? 'movie' : 'train';
    const events = await Event.find({ category, approved: true, date: { $gte: new Date() } }).limit(5);
    return { intent: 'search', events, message: `Here are upcoming ${category}s:` };
  }

  if (lower.includes('booking') || lower.includes('status')) {
    const bookings = await Booking.find({ user: userId }).populate('event').sort({ createdAt: -1 }).limit(3);
    return { intent: 'booking_status', bookings, message: 'Your recent bookings:' };
  }

  if (lower.includes('recommend') || lower.includes('suggest')) {
    const { getRecommendations } = require('./recommendationService');
    const events = await getRecommendations(userId);
    return { intent: 'recommendations', events, message: 'Recommended for you:' };
  }

  if (lower.includes('help') || lower.includes('faq')) {
    return { intent: 'faq', message: 'I can help you: search events ("show me concerts"), check bookings ("my booking status"), or get recommendations ("suggest events for me").' };
  }

  return { intent: 'unknown', message: "I didn't understand that. Try: 'show me movies', 'my booking status', or 'recommend events'." };
}

module.exports = { processMessage };
const express = require('express');
const router = express.Router();
const { protect } = require('../middleware/auth');
const { getRecommendations } = require('../services/recommendationService');

router.get('/:userId', protect, async (req, res) => {
  const events = await getRecommendations(req.params.userId);
  res.json(events);
});

module.exports = router;
const express = require('express');
const router = express.Router();
const { protect } = require('../middleware/auth');
const { processMessage } = require('../services/chatbotService');

router.post('/message', protect, async (req, res) => {
  const response = await processMessage(req.body.message, req.user._id);
  res.json(response);
});

module.exports = router;
const express = require('express');
const router = express.Router();
const { protect, requireRole } = require('../middleware/auth');
const Event = require('../models/Event');
const { getDynamicPrice } = require('../services/pricingService');

router.get('/', async (req, res) => {
  const { category, search } = req.query;
  const filter = { approved: true };
  if (category && category !== 'all') filter.category = category;
  if (search) filter.title = { $regex: search, $options: 'i' };
  const events = await Event.find(filter).populate('vendor', 'name');
  res.json(events);
});

router.get('/:id', async (req, res) => {
  const event = await Event.findById(req.params.id).populate('vendor', 'name');
  if (!event) return res.status(404).json({ message: 'Event not found' });
  res.json(event);
});

router.get('/:id/price', async (req, res) => {
  const event = await Event.findById(req.params.id);
  if (!event) return res.status(404).json({ message: 'Event not found' });
  const price = await getDynamicPrice(event);
  const soldRatio = (event.totalSeats - event.availableSeats) / event.totalSeats;
  res.json({ price, isHighDemand: soldRatio > 0.8 });
});

router.post('/', protect, requireRole('vendor'), async (req, res) => {
  const event = await Event.create({ ...req.body, vendor: req.user._id });
  res.status(201).json(event);
});

router.put('/:id', protect, requireRole('vendor'), async (req, res) => {
  const event = await Event.findOneAndUpdate({ _id: req.params.id, vendor: req.user._id }, req.body, { new: true });
  if (!event) return res.status(404).json({ message: 'Not found or unauthorized' });
  res.json(event);
});

router.delete('/:id', protect, requireRole('vendor'), async (req, res) => {
  await Event.findOneAndDelete({ _id: req.params.id, vendor: req.user._id });
  res.json({ message: 'Event deleted' });
});

module.exports = router;
require('dotenv').config();
const express = require('express');
const http = require('http');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const mongoose = require('mongoose');
const passport = require('passport');
require('./config/passport');
const { initSocket } = require('./socket');

const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const eventRoutes = require('./routes/events');
const adminRoutes = require('./routes/admin');
const paymentRoutes = require('./routes/payments');
const webhookRoutes = require('./routes/webhooks');
const bookingRoutes = require('./routes/bookings');
const recommendationRoutes = require('./routes/recommendations');
const chatbotRoutes = require('./routes/chatbot');

const app = express();
const server = http.createServer(app);

app.use('/api/webhooks', webhookRoutes);

app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
app.use(express.json());
app.use(cookieParser());
app.use(passport.initialize());

app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/payments', paymentRoutes);
app.use('/api/bookings', bookingRoutes);
app.use('/api/recommendations', recommendationRoutes);
app.use('/api/chatbot', chatbotRoutes);

initSocket(server);

mongoose.connect(process.env.MONGO_URI).then(() => {
  server.listen(process.env.PORT || 5000, () => console.log('Server running'));
});

Your platform is now intelligent. Users get personalized recommendations, prices respond to demand in real time, and anyone can search events or check bookings through the chatbot. Next up, the Secret Mission challenges you to build a no-show prediction model that forecasts attendance probability.

Secret mission

No-Show Prediction Model

What if you could predict which booked users will actually show up? Build a no-show prediction system that analyzes booking patterns and scores attendance probability. Use it to power overbooking decisions for train events.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with free-tier cloud services, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local project files (client/ and server/ directories).
  • MongoDB Atlas Free Cluster (M0).
  • Google Cloud Console OAuth 2.0 credentials.
  • Gmail App Password.

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the project further.

  • Your local project files remain on your machine wherever you created them.
  • Your MongoDB Atlas Free Cluster stays active at no cost. It auto-pauses after 30 days of inactivity with zero connections.
  • Your Stripe account remains in test mode with no charges.
  • Your Google Cloud OAuth credentials and Gmail App Password remain configured.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and cloud resources remain intact for when you return.

  • Stop the Node.js server by pressing Ctrl+C in the terminal where it is running.
  • Stop the React dev server by pressing Ctrl+C in its terminal as well.
  • Your MongoDB Atlas Free Cluster will auto-pause after 30 days of inactivity. No manual action needed.
  • Your local files, Stripe account, OAuth credentials, and Gmail App Password all persist safely until you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to clean up everything.

Delete local project files

On macOS or Linux, open your terminal and navigate to the parent directory containing your project folder. Then run:

rm -rf client/ server/

On Windows, open PowerShell, navigate to the parent directory containing your project folder, and run:

rmdir /s /q client
rmdir /s /q server
Terminate your MongoDB Atlas cluster
  • Log in to your MongoDB Atlas account at mongodb.com.
  • Navigate to Database in the left sidebar to see your clusters.
  • Click the three-dot menu on your Free Cluster (M0) and select Terminate.
  • Confirm the termination when prompted. This permanently deletes your cluster and all stored data.
Delete Google Cloud OAuth credentials
  • Go to the Google Cloud Console at console.cloud.google.com.
  • Navigate to APIs & Services, then Credentials.
  • Find the OAuth 2.0 Client ID you created for this project.
  • Click the delete icon (trash can) next to it and confirm deletion.
Revoke your Gmail App Password
  • Go to your Google Account at myaccount.google.com.
  • Navigate to Security.
  • Under the 2-Step Verification section, find App passwords.
  • Revoke the App Password you generated for this project.

What about your Stripe account?

Your Stripe account is in test mode and incurs no charges. You can leave it as-is or close it from your Stripe Dashboard under Settings if you no longer need it.

Nice Work!

Nice Work!

You just built a production-grade Universal Ticket Booking System from scratch. This is a full-stack application that covers real-time communication, payment processing, document generation, AI-powered features, and role-based access control.

You've learned how to:

  • Architect a full-stack MERN application with JWT and Google OAuth authentication, role-based access control, real-time WebSocket seat selection with Socket.IO, and Stripe payment processing with webhook confirmation.
  • Generate PDF tickets with embedded QR codes using PDFKit, and deliver automated booking confirmation emails with attachments through Nodemailer.
  • Design an admin analytics dashboard with Recharts visualizations, an AI-powered recommendation engine with dynamic pricing algorithms, and a rule-based chatbot assistant for interactive event search.
  • Secret Mission: Implement a no-show prediction model that scores booking attendance probability using a weighted heuristic and provides overbooking suggestions for train events.

Ready to quiz yourself?