DEV Community

Akhil Baraik
Akhil Baraik

Posted on

Building an After-Sales Service System for an Electrical Manufacturer with Next.js, Express and MongoDB

A lot of our work at Crework Tech is marketing-facing: websites, SEO and campaigns. So when Seekoji Electric, an electrical manufacturing and service company here in Ranchi, asked us for a system to run their after-sales service, it was a different kind of project. Nobody would see it on Google. Its whole job was to keep products, customers, technicians and paperwork from drifting out of sync.

This post walks through how we thought about it and a few patterns that I think are useful for anyone building a similar ERP-style app. The code below is simplified and generic. It shows the shape of the approach, not a copy of the production codebase.

What the system needed to do

The scope came down to ten modules:

  • Dashboard and analytics
  • Customer management
  • Service request management
  • Repair workflow
  • Product testing workflow (assign products for testing and update results)
  • Technician assignment
  • User and role management (RBAC)
  • Reports and analytics
  • Status tracking
  • Search and filters

Every product that comes in for service moves through six statuses: pending, under testing, received, under repair, repaired and delivered.

The stack was Next.js and Tailwind CSS on the front end, Node.js and Express.js for REST APIs, MongoDB for storage, and JWT authentication with role-based access control.

If you look at that module list, almost all of it hangs off one thing: the status of a repair. The dashboard counts by status. Reports group by status. Technicians filter by status. Customers ask "what's the status?" So that is where I'll start.

1. Treat status as a state machine, not a dropdown

The easiest way to build a status field is a <select> that lets anyone pick any value. The easiest way to break a service workflow is the same thing. A product jumps from "pending" to "delivered", and now your history says something that never happened.

The fix is to define the allowed transitions in one place on the server and refuse everything else.

// lib/repairStatus.js
const STATUS = Object.freeze({
  PENDING: 'pending',
  UNDER_TESTING: 'under_testing',
  RECEIVED: 'received',
  UNDER_REPAIR: 'under_repair',
  REPAIRED: 'repaired',
  DELIVERED: 'delivered',
});

// Example transition map. The real edges are a business decision,
// so agree on them with the people who run the service desk.
const TRANSITIONS = {
  [STATUS.PENDING]: [STATUS.UNDER_TESTING],
  [STATUS.UNDER_TESTING]: [STATUS.RECEIVED],
  [STATUS.RECEIVED]: [STATUS.UNDER_REPAIR],
  [STATUS.UNDER_REPAIR]: [STATUS.REPAIRED],
  [STATUS.REPAIRED]: [STATUS.DELIVERED],
  [STATUS.DELIVERED]: [],
};

function canTransition(from, to) {
  return (TRANSITIONS[from] || []).includes(to);
}

module.exports = { STATUS, TRANSITIONS, canTransition };
Enter fullscreen mode Exit fullscreen mode

Two things I like about keeping this as plain data:

  1. The front end can import the same map (or fetch it from an endpoint) and only show the buttons that are valid for the current status. The UI stops offering mistakes.
  2. When the business changes its process, you change one object and the tests that cover it.

Checking canTransition in a route is not enough on its own, though. If two people update the same record at nearly the same moment, a read-then-write can let both through. Put the expected current status in the update filter so MongoDB does the check atomically:

// services/repairService.js
const Repair = require('../models/Repair');
const { canTransition } = require('../lib/repairStatus');

async function changeStatus(repairId, from, to, userId, note) {
  if (!canTransition(from, to)) {
    const err = new Error(`Cannot move from ${from} to ${to}`);
    err.status = 422;
    throw err;
  }

  const updated = await Repair.findOneAndUpdate(
    { _id: repairId, status: from },          // only if still in "from"
    {
      $set: { status: to },
      $push: { history: { from, to, by: userId, note, at: new Date() } },
    },
    { new: true }
  );

  if (!updated) {
    const err = new Error('Status changed by someone else. Reload and try again.');
    err.status = 409;
    throw err;
  }
  return updated;
}

module.exports = { changeStatus };
Enter fullscreen mode Exit fullscreen mode

The history push in the same update is what gives you a complete service history for free. Every status change records who made it and when, and it can never get out of step with the status itself.

2. JWT authentication plus RBAC middleware in Express

A service system has very different users. Someone at the front desk creates service requests. A technician updates test results and repairs. An admin manages users and roles. Role-based access control keeps each of them to what their job needs.

I prefer two small middlewares: one that verifies the token, and one that checks permissions. Mapping roles to permissions (instead of checking role names inside routes) keeps things flexible when a new role shows up later.

// middleware/auth.js
const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: 'Missing token' });

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Example roles and permissions. Adjust to the client's real org chart.
const PERMISSIONS = {
  admin: ['users:manage', 'repairs:read', 'repairs:write', 'repairs:assign', 'repairs:status', 'reports:read'],
  manager: ['repairs:read', 'repairs:write', 'repairs:assign', 'repairs:status', 'reports:read'],
  technician: ['repairs:read', 'repairs:test', 'repairs:status'],
};

function authorize(...required) {
  return (req, res, next) => {
    const granted = PERMISSIONS[req.user?.role] || [];
    const ok = required.every((p) => granted.includes(p));
    if (!ok) return res.status(403).json({ error: 'Forbidden' });
    next();
  };
}

module.exports = { authenticate, authorize };
Enter fullscreen mode Exit fullscreen mode

Signing the token at login is the usual jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '8h' }). Then routes read almost like documentation:

// routes/repairs.js
const router = require('express').Router();
const { authenticate, authorize } = require('../middleware/auth');
const ctrl = require('../controllers/repairs');

router.use(authenticate);

router.get('/', authorize('repairs:read'), ctrl.list);
router.post('/', authorize('repairs:write'), ctrl.create);
router.patch('/:id/assign', authorize('repairs:assign'), ctrl.assignTechnician);
router.patch('/:id/test-result', authorize('repairs:test'), ctrl.recordTestResult);
router.patch('/:id/status', authorize('repairs:status'), ctrl.changeStatus);

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

One note: route-level permissions are the first gate, not the only one. Inside changeStatus it is worth also checking that, for example, a technician can only move repairs assigned to them. Row-level rules like that live in the service layer.

3. A MongoDB schema sketch for repairs

Here is the rough shape of a repair document. Customers and users live in their own collections and are referenced by id. Test results and history are embedded, because they belong to one repair and are always read with it.

// models/Repair.js
const { Schema, model, Types } = require('mongoose');
const { STATUS } = require('../lib/repairStatus');

const TestResultSchema = new Schema({
  result: { type: String, enum: ['pass', 'fail'], required: true },
  notes: String,
  testedBy: { type: Types.ObjectId, ref: 'User' },
  testedAt: { type: Date, default: Date.now },
}, { _id: false });

const HistorySchema = new Schema({
  from: String,
  to: String,
  by: { type: Types.ObjectId, ref: 'User' },
  note: String,
  at: { type: Date, default: Date.now },
}, { _id: false });

const RepairSchema = new Schema({
  ticketNo: { type: String, unique: true, required: true },
  customer: { type: Types.ObjectId, ref: 'Customer', required: true },
  product: {
    name: String,
    model: String,
    serialNo: String,
  },
  complaint: String,
  status: {
    type: String,
    enum: Object.values(STATUS),
    default: STATUS.PENDING,
    index: true,
  },
  assignedTechnician: { type: Types.ObjectId, ref: 'User', index: true },
  testResults: [TestResultSchema],
  history: [HistorySchema],
}, { timestamps: true });

// Supports the common "my open jobs" and dashboard queries
RepairSchema.index({ assignedTechnician: 1, status: 1, updatedAt: -1 });
// Supports the search box
RepairSchema.index({ ticketNo: 'text', 'product.serialNo': 'text', 'product.model': 'text' });

module.exports = model('Repair', RepairSchema);
Enter fullscreen mode Exit fullscreen mode

Design your indexes from the screens, not the other way round. Search and filters, the technician's work queue and the dashboard each turn into a query, and each query should hit an index.

4. Dashboard numbers from one aggregation

Once status is reliable, the dashboard is mostly a $group:

const counts = await Repair.aggregate([
  { $group: { _id: '$status', count: { $sum: 1 } } },
]);
Enter fullscreen mode Exit fullscreen mode

The Next.js dashboard calls a REST endpoint that returns this, and Tailwind takes care of turning it into readable cards. Reports are the same idea with a date range and more grouping keys.

The outcome, in plain terms

The benefits we set out for this project are all about the day-to-day work: faster repair processing, centralized service records, better technician productivity, a complete service history, real-time status tracking, less manual paperwork, better operational visibility and an improved experience for their customers. We've written up the project in more detail in our Seekoji Electric ERP case study.

What I'd pass on

If you are building something similar for a manufacturer or service business:

  • Model the workflow before the screens. Sit with the people who handle the products and write the status transitions down. That object becomes the spine of the app.
  • Enforce transitions on the server, atomically. The UI can guide people, but the database filter is what keeps data honest.
  • Write history in the same update as the status. You will need it the first time a customer disputes what happened to their product.
  • Use permissions, not role checks, in routes. New roles stop being a refactor.
  • Index for the screens people actually use. Search, filters and work queues should never scan the whole collection.

None of this is new or clever. It is just the stuff that is easy to skip when a project starts as "a simple form to track repairs", and painful to add later.


Akhil Baraik is the founder of Crework Tech, a digital marketing and IT agency based in Ranchi, Jharkhand. You can find him on LinkedIn.

Top comments (0)