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:
- Admin: assign tickets, view tickets by user
- Agent: view assigned tickets, close tickets
- Strengthen role-based access control using middleware.
- Add threaded replies so tickets have conversations.
- Allow agents and users to reply to tickets.
- Build admin dashboard analytics routes.
- Add user profile APIs (view/update profile).
- 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?
- To track ticket history with createdAt/updatedAt.
- To record which agent is assigned to a ticket.
- 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);
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 })
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')
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' });
}
};
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' });
}
};
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' });
}
};
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' });
}
};
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);
This ensures:
- Only admins can assign or view all tickets.
- Agents can only view their assigned tickets.
- Agents can close only their own tickets.
** Testing**
Use Postman to test these routes:
- Assign a ticket: POST /api/tickets/assign/:ticketId with agentId in JSON body.
- View tickets by user: GET /api/tickets/user/:userId
- Close a ticket: PUT /api/tickets/:ticketId/close
- 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);
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' });
}
};
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;
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' });
}
};
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' });
}
};
What We Achieved in Part 3
- We extended our backend to handle multi-role workflows:
- Admins can assign tickets and audit users.
- Agents can view and close tickets assigned to them.
- We explained important Mongoose methods for beginners.
- We enforced strict role-based access control.
- Threaded replies: A new Message schema and routes for conversations.
- Reply permissions: Users and assigned agents can reply; no one else.
- Analytics: Admin dashboard stats with MongoDB aggregation.
- 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)