DEV Community

Cover image for Building Scalable Support Ticket System with Node.js, Express & MongoDB – Part 3
AbhiJeet Sachan
AbhiJeet Sachan

Posted on

Building Scalable Support Ticket System with Node.js, Express & MongoDB – Part 3

HelpMe – Part 3: Expanding Backend Features with Admin & Agent Routes

In the previous part of this series, we built the foundation of ticket management: creating tickets, listing user tickets, and retrieving single tickets.
Now, we’ll extend the backend to handle real-world workflows by introducing admin and agent capabilities, closing tickets, and improving our schema.

This post is a continuation of Part 2 – if you haven’t read it, I recommend checking that out first.

Goals of Part 3

By the end of this post, we will:

Update the Ticket schema to include timestamps and new fields.

Add routes for:

  1. Admin: assign tickets, view tickets by user
  2. Agent: view assigned tickets, close tickets
  3. Strengthen role-based access control using middleware.
  4. Add threaded replies so tickets have conversations.
  5. Allow agents and users to reply to tickets.
  6. Build admin dashboard analytics routes.
  7. Add user profile APIs (view/update profile).
  8. Discuss minor updates to our previous code where necessary.

These steps bring us closer to a multi-role support system, where tickets can flow seamlessly between users, agents, and admins.


1. Updating the Ticket Schema

Why?

  1. To track ticket history with createdAt/updatedAt.
  2. To record which agent is assigned to a ticket.
  3. To manage ticket lifecycle statuses (open, in-progress, closed).

File:models/Ticket.js

import mongoose from 'mongoose';

const ticketSchema = new mongoose.Schema({
  title: "{ type: String, required: true },"
  description: "String,"
  priority: { type: String, enum: ['low', 'medium', 'high'], default: 'low' },
  status: { type: String, enum: ['open', 'in-progress', 'closed'], default: 'open' },
  createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  assignedTo: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }
}, {
  timestamps: true
});

export default mongoose.model('Ticket', ticketSchema);

Enter fullscreen mode Exit fullscreen mode

Note: timestamps: true automatically adds createdAt and updatedAt fields.

Mongoose Methods You’ll See Often
Before we jump into the routes, let’s clarify common Mongoose methods:

1. find(query):
Fetches all documents matching the query.
Example:

Ticket.find({ createdBy: userId })

Enter fullscreen mode Exit fullscreen mode

Returns an array of tickets.

  • findById(id):
    Finds a document by its unique MongoDB _id.
    Returns a single document or null.

  • findByIdAndUpdate(id, update, options):
    Finds a document by _id, updates it, and (if { new: true } is passed) returns the updated document.

  • populate(field, select):
    Replaces an ID stored in a field with the actual referenced document.
    Example:

.populate('assignedTo', 'username')
Enter fullscreen mode Exit fullscreen mode

This will include the assigned agent's username in the result.

  • sort({ field: -1 }):
  • Sorts the query results. -1 means descending order, 1 means ascending.

These methods are crucial to querying MongoDB effectively.

2. Building Admin and Agent Features

We’ll add new controller functions in controllers/ticketController.js.

Admin: Assign Tickets
Why?
Admins should be able to assign a ticket to an agent, moving its status to in-progress.

Route: POST /api/tickets/assign/:ticketId

Controller Code:

export const assignTicket = async (req, res) => {
  try {
    const { agentId } = req.body;
    const ticket = await Ticket.findByIdAndUpdate(
      req.params.ticketId,
      { assignedTo: agentId, status: 'in-progress' },
      { new: true }
    ).populate('assignedTo', 'username');

    if (!ticket) return res.status(404).json({ message: 'Ticket not found' });

    res.json(ticket);
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Error assigning ticket' });
  }
};
Enter fullscreen mode Exit fullscreen mode

How it works:

We use findByIdAndUpdate() to update the assignedTo field and set the status to in-progress.

  • { new: true } ensures we get the updated document back.
  • .populate() fetches agent details so you don’t need another query.

Admin: View Tickets by User
Why?
Admins often need to see all tickets created by a specific user for auditing or follow-up.

Route: GET /api/tickets/user/:userId

Controller Code:

export const getTicketsByUser = async (req, res) => {
  try {
    const tickets = await Ticket.find({ createdBy: req.params.userId })
      .sort({ createdAt: -1 })
      .populate('assignedTo', 'username');

    res.json(tickets);
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Error fetching user tickets' });
  }
};

Enter fullscreen mode Exit fullscreen mode

Agent: View Assigned Tickets
Why?
Agents need a dedicated list of tickets assigned to them.

Route: GET /api/tickets/assigned
Controller Code:

export const getAssignedTickets = async (req, res) => {
  try {
    const tickets = await Ticket.find({ assignedTo: req.user.id })
      .sort({ updatedAt: -1 });
    res.json(tickets);
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Error fetching assigned tickets' });
  }
};

Enter fullscreen mode Exit fullscreen mode

Close a Ticket
Why?
Agents (for their own tickets) and admins should be able to close tickets, changing their status to “closed.”

Route: PUT /api/tickets/:ticketId/close
Controller Code:

export const closeTicket = async (req, res) => {
  try {
    const ticket = await Ticket.findById(req.params.ticketId);
    if (!ticket) return res.status(404).json({ message: 'Ticket not found' });

    const { role, id } = req.user;

    // Only assigned agent or any admin can close
    if (role === 'agent' && ticket.assignedTo?.toString() !== id) {
      return res.status(403).json({ message: 'Agents can only close their own tickets' });
    }

    ticket.status = 'closed';
    await ticket.save();
    res.json(ticket);
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Error closing ticket' });
  }
};

Enter fullscreen mode Exit fullscreen mode

3. Securing Routes with Middleware

We use roleMiddleware to protect these routes:

import { roleMiddleware } from '../middleware/roleMiddleware.js';

router.post('/assign/:ticketId', roleMiddleware(['admin']), assignTicket);
router.get('/user/:userId', roleMiddleware(['admin']), getTicketsByUser);
router.put('/:ticketId/close', roleMiddleware(['admin', 'agent']), closeTicket);
router.get('/assigned', roleMiddleware(['agent']), getAssignedTickets);

Enter fullscreen mode Exit fullscreen mode

This ensures:

  1. Only admins can assign or view all tickets.
  2. Agents can only view their assigned tickets.
  3. Agents can close only their own tickets.

** Testing**

Use Postman to test these routes:

  1. Assign a ticket: POST /api/tickets/assign/:ticketId with agentId in JSON body.
  2. View tickets by user: GET /api/tickets/user/:userId
  3. Close a ticket: PUT /api/tickets/:ticketId/close
  4. View assigned tickets (as agent): GET /api/tickets/assigned

4.1. Why Replies Are Important

So far, a ticket was just a title and description.
But in a real support system, tickets involve conversation threads between the user and the agent.
We need a way to store these replies.

4.2. Adding a Message Schema

Why a separate schema?
Keeping messages in their own collection makes it easier to:

  • Scale conversations
  • Query by ticket
  • Manage who sent each message

Code: models/Message.js

import mongoose from 'mongoose';

const messageSchema = new mongoose.Schema({
  ticket: { type: mongoose.Schema.Types.ObjectId, ref: 'Ticket', required: true },
  sender: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  content: { type: String, required: true }
}, { timestamps: true });

export default mongoose.model('Message', messageSchema);
Enter fullscreen mode Exit fullscreen mode

Key points:

  • ticket links the message to a ticket.
  • sender is the user or agent who sent the reply.
  • timestamps lets us track the conversation history in orde r.

5. Reply Routes

Update Needed in Old Code
In routes/ticketRoutes.js, we need to add verifyToken middleware (if not already added) so only logged-in users/agents can reply.

POST /api/tickets/:ticketId/replies
Purpose: Add a reply to a ticket.

Controller (controllers/messageController.js):

import Message from '../models/Message.js';
import Ticket from '../models/Ticket.js';

export const addReply = async (req, res) => {
  try {
    const { content } = req.body;
    const ticket = await Ticket.findById(req.params.ticketId);

    if (!ticket) {
      return res.status(404).json({ message: 'Ticket not found' });
    }

    // Only admin, agent, or ticket creator can reply
    if (
      req.user.role === 'agent' &&
      ticket.assignedTo?.toString() !== req.user.id
    ) {
      return res.status(403).json({ message: 'Agents can only reply to their assigned tickets' });
    }

    if (
      req.user.role === 'user' &&
      ticket.createdBy.toString() !== req.user.id
    ) {
      return res.status(403).json({ message: 'Users can only reply to their own tickets' });
    }

    const message = await Message.create({
      ticket: ticket._id,
      sender: req.user.id,
      content
    });

    res.status(201).json(message);
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Error adding reply' });
  }
};
Enter fullscreen mode Exit fullscreen mode

GET /api/tickets/:ticketId/replies
Purpose: Fetch all replies for a given ticket.

Create a controller getReply, try the logic yourselves.
_Hint: It will use this query- Message.find({ ticket: req.params.ticketId }) _

Adding Routes
In routes/messageRoutes.js:

import express from 'express';
import { verifyToken } from '../middleware/authMiddleware.js';
import { addReply, getReplies } from '../controllers/messageController.js';

const router = express.Router();

router.post('/:ticketId/replies', verifyToken, addReply);
router.get('/:ticketId/replies', verifyToken, getReplies);

export default router;
Enter fullscreen mode Exit fullscreen mode

6. Admin Dashboard Analytics

Why?

Admins need insights like:

  • How many tickets are open/closed
  • Which agents have the most assigned tickets
  • Ticket resolution rates

Controller (controllers/adminController.js
We’ll use MongoDB aggregation to compute statistics.

import Ticket from '../models/Ticket.js';

export const getDashboardStats = async (req, res) => {
  try {
    const totalTickets = await Ticket.countDocuments();
    const openCount = await Ticket.countDocuments({ status: 'open' });
    const closedCount = await Ticket.countDocuments({ status: 'closed' });
    const inProgressCount = await Ticket.countDocuments({ status: 'in-progress' });

    // Count tickets grouped by agent
    const agentWorkload = await Ticket.aggregate([
      { $match: { assignedTo: { $ne: null } } },
      { $group: { _id: '$assignedTo', count: { $sum: 1 } } },
      { $sort: { count: -1 } }
    ]);

    res.json({
      totalTickets,
      openCount,
      inProgressCount,
      closedCount,
      agentWorkload
    });
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Error fetching dashboard stats' });
  }
};
Enter fullscreen mode Exit fullscreen mode

Now, Register these routes in your routes/adminRoutes.js.

7. User Profile Routes

Users should be able to:

  • View their profile
  • Update their username or email (but not their role)

Controller (controllers/userController.js)

import User from '../models/User.js';

export const getProfile = async (req, res) => {
  try {
    const user = await User.findById(req.user.id).select('-password');
    res.json(user);
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Error fetching profile' });
  }
};

export const updateProfile = async (req, res) => {
  try {
    const { username, email } = req.body;
    const user = await User.findById(req.user.id);

    if (username) user.username = username;
    if (email) user.email = email;

    await user.save();
    res.json({ message: 'Profile updated', user });
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Error updating profile' });
  }
};
Enter fullscreen mode Exit fullscreen mode

What We Achieved in Part 3

  1. We extended our backend to handle multi-role workflows:
  2. Admins can assign tickets and audit users.
  3. Agents can view and close tickets assigned to them.
  4. We explained important Mongoose methods for beginners.
  5. We enforced strict role-based access control.
  6. Threaded replies: A new Message schema and routes for conversations.
  7. Reply permissions: Users and assigned agents can reply; no one else.
  8. Analytics: Admin dashboard stats with MongoDB aggregation.
  9. User profile APIs: Users can view and update their details securely.

GitHub Repo: github
If you did not read the last part-part-2

This concludes Part 3 of the series. If you are new to backend development, take time to understand the query methods and role-based design—they are essential for building secure, scalable systems.
We now have a full conversation layer and analytics capabilities built into our backend!


What’s Next
In the next post, we will:

  • Integrate Cloudinary for attachments.
  • Add AI-powered sentiment analysis & auto-tagging.
  • Begin frontend work with React/Next.js to bring everything together visually.

Top comments (0)