EthicsComputerCourses
Join training +91 90532 10052

Project: Store Pickup & Order Tracking API

MongoDB · Lesson 40 of 40 Project 10 min read Updated

The shop is opening pickup counters across Pune. Customers will find the nearest counter in the mobile app, place an order for pickup, and get an SMS when it is ready. Your job is the backend: a small REST API of the kind you would build in your first months at a product company or agency.

This project brings together most of the course: geospatial queries, Mongoose models and validation, transactions, safe status changes, range pagination, a reporting pipeline with a window function, and a change stream worker. Every step explains why the code is written that way, because those reasons are what interviewers and code reviewers ask about.

What you will build

EndpointWhat it doesCourse topic
GET /api/stores/nearbyOpen counters near a location, with distancesGeospatial Queries: Nearby Stores & Zones
POST /api/ordersPlaces an order, reserves stock, issues an order numberTransactions
PATCH /api/orders/:id/statusMoves an order through allowed status changesConditional updates
GET /api/customers/:id/ordersA customer's orders, newest first, page by pageProjection, Sort, Limit & Skip
GET /api/reports/dailyDaily sales with a running total in Indian time$setWindowFields, $bucket & $merge

A separate worker process, watcher.js, sends the "ready for pickup" SMS using a change stream.

Step 1: set up the project

You need Node.js 20.19 or newer and a MongoDB deployment that supports transactions and change streams: an Atlas cluster, a local single-member replica set, or atlas local setup from MongoDB Atlas at Work. Load the practice data with load('shop-data.js') as in The Practice Shop Database, then create the project:

Terminal
mkdir pickup-api
cd pickup-api
npm init -y
npm pkg set type=module
npm install express mongoose

Put your connection string in .env (and add .env to .gitignore), for example MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0 for a local replica set or your mongodb+srv:// string for Atlas.

Step 2: the models

Create models.js. It reuses the shop's products and orders collections and adds stores and a counters collection for order numbers:

JavaScript
import mongoose from 'mongoose';
const { Schema, model } = mongoose;

// every Mongoose call inside connection.transaction() joins the transaction automatically
mongoose.set('transactionAsyncLocalStorage', true);

const storeSchema = new Schema({
  _id: Number,
  name: { type: String, required: true },
  open: { type: Boolean, default: true },
  location: {
    type: { type: String, enum: ['Point'], required: true },
    coordinates: { type: [Number], required: true }   // [longitude, latitude]
  }
}, { versionKey: false });
storeSchema.index({ location: '2dsphere' });

const productSchema = new Schema({
  _id: Number,
  name: String,
  category: String,
  price: { type: Number, min: 1 },
  stock: { type: Number, min: 0 }
}, { versionKey: false });

const itemSchema = new Schema({
  productId: { type: Number, required: true },
  name: String,
  price: { type: Number, required: true },
  qty: { type: Number, required: true, min: 1 }
}, { _id: false });

const orderSchema = new Schema({
  _id: Number,
  customerId: { type: Number, required: true },
  storeId: Number,
  date: { type: Date, default: Date.now },
  status: { type: String, enum: ['pending', 'ready', 'collected', 'shipped', 'delivered', 'cancelled'], default: 'pending' },
  payment: { type: String, enum: ['UPI', 'card', 'COD'], default: 'UPI' },
  items: { type: [itemSchema], validate: [(v) => v.length > 0, 'An order needs at least one item'] },
  total: Number,
  history: [{ _id: false, status: String, at: Date }]
}, { versionKey: false });
orderSchema.index({ customerId: 1, _id: -1 });
orderSchema.pre('save', function () {
  this.total = this.items.reduce((sum, i) => sum + i.price * i.qty, 0);
});

const counterSchema = new Schema({ _id: String, seq: Number }, { versionKey: false });

export const Store = model('Store', storeSchema);
export const Product = model('Product', productSchema);
export const Order = model('Order', orderSchema);
export const Counter = model('Counter', counterSchema);

Design decisions worth noticing:

  • Order items copy name and price (the extended reference pattern), so an order always shows what the customer paid.
  • The total is calculated on the server in a pre('save') hook; the client never sends prices or totals.
  • history is a small, bounded array of status changes with times. An order changes status a handful of times, so embedding is safe.
  • The index { customerId: 1, _id: -1 } serves "my orders, newest first" exactly, following the ESR rule from Indexing Strategy in Depth.
  • The existing shop statuses (shipped, delivered) stay valid, so the old data still passes validation.

Step 3: seed stores and the order counter

Create seed.js. It inserts five counters, starts the order counter after the highest existing order number and builds the schema's indexes on purpose, rather than leaving that to application start-up:

JavaScript
import mongoose from 'mongoose';
import { Store, Order, Counter } from './models.js';

await mongoose.connect(process.env.MONGODB_URI, { dbName: 'shop' });
try {
  const point = (lng, lat) => ({ type: 'Point', coordinates: [lng, lat] });
  await Store.deleteMany({});
  await Store.insertMany([
    { _id: 1, name: 'Kothrud', location: point(73.8077, 18.5074) },
    { _id: 2, name: 'Shivajinagar', location: point(73.8478, 18.5308) },
    { _id: 3, name: 'Viman Nagar', location: point(73.9143, 18.5679) },
    { _id: 4, name: 'Hadapsar', location: point(73.9260, 18.5089) },
    { _id: 5, name: 'Hinjewadi', open: false, location: point(73.7389, 18.5913) }
  ]);
  const last = await Order.findOne().sort({ _id: -1 }).select('_id').lean();
  const lastNo = last?._id ?? 1000;
  await Counter.updateOne({ _id: 'orderNo' }, { $set: { seq: lastNo } }, { upsert: true });
  await Store.syncIndexes();
  await Order.syncIndexes();
  console.log(`Seeded ${await Store.countDocuments()} stores; next order number ${lastNo + 1}`);
} finally {
  await mongoose.disconnect();
}
Terminal
node --env-file=.env seed.js
Output
Seeded 5 stores; next order number 1013

Step 4: the API, part 1: setup and nearby stores

Create app.js. The top of the file sets up Express, a small error class and a helper that turns input into a whole number or NaN. Converting every input to the type you expect is the first defence against NoSQL injection: an object such as { "$ne": null } can never survive Number().

JavaScript
import express from 'express';
import mongoose from 'mongoose';
import { Store, Product, Order, Counter } from './models.js';

class HttpError extends Error {
  constructor(status, message) { super(message); this.status = status; }
}
const toInt = (v) => (v !== undefined && v !== '' && Number.isInteger(Number(v)) ? Number(v) : NaN);

const app = express();
app.use(express.json({ limit: '10kb' }));

app.get('/api/stores/nearby', async (req, res) => {
  const lng = Number(req.query.lng);
  const lat = Number(req.query.lat);
  const km = Math.min(Number(req.query.km) || 5, 20);
  if (!(lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90)) throw new HttpError(400, 'lng and lat are required');
  const stores = await Store.aggregate([
    { $geoNear: {
        near: { type: 'Point', coordinates: [lng, lat] },
        distanceField: 'metres',
        maxDistance: km * 1000,
        query: { open: true },
        spherical: true
    } },
    { $project: { name: 1, km: { $round: [{ $divide: ['$metres', 1000] }, 1] } } }
  ]);
  res.json({ stores });
});

In Express 5, an error thrown inside an async handler is passed to the error handler automatically, so routes can simply throw. The search radius is capped at 20 km so that one request can never sort every store in the country.

Step 5: part 2: placing an order in a transaction

Add the order route below the nearby route. Three things must succeed or fail together: every item's stock is reduced, the next order number is taken, and the order is saved:

JavaScript
app.post('/api/orders', async (req, res) => {
  const customerId = toInt(req.body?.customerId);
  const storeId = toInt(req.body?.storeId);
  const lines = (Array.isArray(req.body?.items) ? req.body.items : [])
    .map((i) => ({ productId: toInt(i?.productId), qty: toInt(i?.qty) }));
  if (!(customerId > 0) || !(storeId > 0) || lines.length === 0 || lines.length > 10 ||
      lines.some((l) => !(l.productId > 0 && l.qty > 0))) {
    throw new HttpError(400, 'customerId, storeId and 1 to 10 items with productId and qty are required');
  }

  const order = await mongoose.connection.transaction(async () => {
    const store = await Store.findOne({ _id: storeId, open: true }).lean();
    if (!store) throw new HttpError(404, `Store ${storeId} is not open`);
    const items = [];
    for (const { productId, qty } of lines) {
      const p = await Product.findOneAndUpdate(
        { _id: productId, stock: { $gte: qty } },
        { $inc: { stock: -qty } },
        { returnDocument: 'after' }
      ).lean();
      if (!p) throw new HttpError(409, `Product ${productId} is out of stock or does not exist`);
      items.push({ productId, name: p.name, price: p.price, qty });
    }
    const counter = await Counter.findOneAndUpdate(
      { _id: 'orderNo' }, { $inc: { seq: 1 } }, { returnDocument: 'after' }
    ).lean();
    const now = new Date();
    return Order.create({ _id: counter.seq, customerId, storeId, date: now, items, history: [{ status: 'pending', at: now }] });
  });
  res.status(201).json({ orderNo: order._id, total: order.total, status: order.status });
});

The stock check and the decrement are one atomic findOneAndUpdate() with stock: { $gte: qty } in the filter, so two customers can never both buy the last webcam. If any item fails, the thrown error aborts the transaction: stock already reserved for earlier items is put back, and the counter increment is undone too, so order numbers have no gaps. The price comes from the database, never from the request.

Step 6: part 3: safe status changes

Orders follow a fixed life cycle. A cancelled order must never become "ready", and two staff members clicking at the same moment must not both succeed. Add this route:

JavaScript
const transitions = { pending: ['ready', 'shipped', 'cancelled'], ready: ['collected', 'cancelled'], shipped: ['delivered'] };

app.patch('/api/orders/:id/status', async (req, res) => {
  const id = toInt(req.params.id);
  const next = String(req.body?.status ?? '');
  const current = await Order.findById(id).select('status').lean();
  if (!current) throw new HttpError(404, 'Order not found');
  if (!(transitions[current.status] ?? []).includes(next)) {
    throw new HttpError(409, `Cannot change status from ${current.status} to ${next}`);
  }
  const updated = await Order.findOneAndUpdate(
    { _id: id, status: current.status },
    { $set: { status: next }, $push: { history: { status: next, at: new Date() } } },
    { returnDocument: 'after' }
  ).select('status').lean();
  if (!updated) throw new HttpError(409, 'Order was changed by someone else, please retry');
  res.json({ orderNo: updated._id, status: updated.status });
});

The update's filter includes the status that was just read. If someone else changed the order in between, the filter matches nothing and the second click gets a clear 409 instead of silently overwriting. This is optimistic concurrency without a transaction: one atomic single-document update does the job.

Step 7: part 4: order history and the daily report

Add the last two routes, the error handler and the start-up code:

JavaScript
app.get('/api/customers/:id/orders', async (req, res) => {
  const customerId = toInt(req.params.id);
  const limit = Math.min(toInt(req.query.limit) || 10, 50);
  const filter = { customerId };
  if (req.query.before) filter._id = { $lt: toInt(req.query.before) };
  const orders = await Order.find(filter).sort({ _id: -1 }).limit(limit).select('date status total').lean();
  const next = orders.length === limit ? orders[orders.length - 1]._id : null;
  res.json({ orders, next });
});

app.get('/api/reports/daily', async (req, res) => {
  const from = new Date(`${req.query.from}T00:00:00+05:30`);
  const to = new Date(`${req.query.to}T00:00:00+05:30`);
  if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
    throw new HttpError(400, 'from and to must be dates like 2026-10-01');
  }
  to.setUTCDate(to.getUTCDate() + 1); // include the whole "to" day
  const days = await Order.aggregate([
    { $match: { status: { $ne: 'cancelled' }, date: { $gte: from, $lt: to } } },
    { $group: {
        _id: { $dateToString: { date: '$date', format: '%Y-%m-%d', timezone: 'Asia/Kolkata' } },
        orders: { $sum: 1 },
        revenue: { $sum: '$total' }
    } },
    { $setWindowFields: {
        sortBy: { _id: 1 },
        output: { runningTotal: { $sum: '$revenue', window: { documents: ['unbounded', 'current'] } } }
    } },
    { $sort: { _id: 1 } },
    { $project: { _id: 0, day: '$_id', orders: '$orders', revenue: '$revenue', runningTotal: '$runningTotal' } }
  ]);
  res.json({ days });
});

app.use((err, req, res, next) => {
  if (err instanceof HttpError) return res.status(err.status).json({ error: err.message });
  if (err instanceof mongoose.Error.ValidationError) return res.status(400).json({ error: err.message });
  if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'Invalid JSON' });
  console.error(err);
  res.status(500).json({ error: 'Something went wrong' });
});

await mongoose.connect(process.env.MONGODB_URI, { dbName: 'shop', appName: 'pickup-api', serverSelectionTimeoutMS: 5000 });
const server = app.listen(3000, () => console.log('Pickup API on http://localhost:3000'));
process.on('SIGTERM', () => server.close(() => mongoose.disconnect()));

The order history uses range pagination: the client sends the last order number it saw as before, and the query continues from there using the index, instead of skip(), which gets slower on every page. lean() and select() keep the response small and fast. The report turns the dates the user typed into midnight in India, groups by Indian calendar day and adds a running total. Unknown errors are logged on the server and the client sees only a generic message.

Step 8: the SMS worker

Create watcher.js. It runs as its own process, so a slow SMS gateway never slows down the API:

JavaScript
import mongoose from 'mongoose';
import { Store } from './models.js';

await mongoose.connect(process.env.MONGODB_URI, { dbName: 'shop', appName: 'pickup-watcher' });
const orders = mongoose.connection.db.collection('orders');   // the driver's own collection object

const stream = orders.watch(
  [{ $match: { operationType: 'update', 'updateDescription.updatedFields.status': 'ready' } }],
  { fullDocument: 'updateLookup' }
);
console.log('Watching for orders that are ready for pickup...');
process.on('SIGINT', async () => {
  await stream.close();
  await mongoose.disconnect();
});

for await (const change of stream) {
  const order = change.fullDocument;
  if (!order) continue;
  const store = await Store.findById(order.storeId).select('name').lean();
  console.log(`SMS to customer ${order.customerId}: order ${order._id} is ready for pickup at ${store?.name ?? 'the store'}`);
}

The console.log stands in for a call to your SMS provider. For production, save the resume token after each message as shown in Change Streams: React to Data Changes.

Run and test it

Start the API in one terminal with node --env-file=.env app.js and the worker in another with node --env-file=.env watcher.js. Then test from a third terminal (on Windows, use Git Bash so the quotes below work). Each command prints one response; -w '\n' just adds a line break:

Terminal
curl -s -w '\n' "http://localhost:3000/api/stores/nearby?lng=73.8410&lat=18.5167&km=5"
curl -s -w '\n' -X POST http://localhost:3000/api/orders -H 'Content-Type: application/json' \
  -d '{"customerId": 8, "storeId": 2, "items": [{"productId": 101, "qty": 2}, {"productId": 104, "qty": 1}]}'
curl -s -w '\n' -X POST http://localhost:3000/api/orders -H 'Content-Type: application/json' \
  -d '{"customerId": 8, "storeId": 2, "items": [{"productId": 106, "qty": 1}]}'
curl -s -w '\n' -X PATCH http://localhost:3000/api/orders/1013/status -H 'Content-Type: application/json' -d '{"status": "ready"}'
curl -s -w '\n' -X PATCH http://localhost:3000/api/orders/1013/status -H 'Content-Type: application/json' -d '{"status": "pending"}'
Output
{"stores":[{"_id":2,"name":"Shivajinagar","km":1.7},{"_id":1,"name":"Kothrud","km":3.7}]}
{"orderNo":1013,"total":1897,"status":"pending"}
{"error":"Product 106 is out of stock or does not exist"}
{"orderNo":1013,"status":"ready"}
{"error":"Cannot change status from ready to pending"}

Isha's order costs 2 × ₹799 + ₹299 = ₹1,897. The Laptop Stand has no stock, so the second order fails with status 409 and the counter stays at 1013; the next successful order will be 1014. As soon as order 1013 becomes ready, the worker terminal prints:

Output
SMS to customer 8: order 1013 is ready for pickup at Shivajinagar

Finally, page through Aarav's orders and ask for the October report:

Terminal
curl -s -w '\n' "http://localhost:3000/api/customers/1/orders?limit=2"
curl -s -w '\n' "http://localhost:3000/api/customers/1/orders?limit=2&before=1003"
curl -s -w '\n' "http://localhost:3000/api/reports/daily?from=2026-10-01&to=2026-10-31"
Output
{"orders":[{"_id":1011,"date":"2026-10-19T20:25:00.000Z","status":"pending","total":3499},{"_id":1003,"date":"2026-08-15T09:05:00.000Z","status":"delivered","total":6999}],"next":1003}
{"orders":[{"_id":1001,"date":"2026-08-03T10:15:00.000Z","status":"delivered","total":1397}],"next":null}
{"days":[{"day":"2026-10-04","orders":1,"revenue":4998,"runningTotal":4998},{"day":"2026-10-11","orders":1,"revenue":2098,"runningTotal":7096},{"day":"2026-10-20","orders":1,"revenue":3499,"runningTotal":10595},{"day":"2026-10-24","orders":1,"revenue":2148,"runningTotal":12743}]}

The first page returns two orders and next: 1003; the second page continues below 1003 and returns null because no more orders exist. In the report, order 1011 counts on 20 October because it was placed at 01:55 IST, and the running total ends at ₹12,743, the same October revenue you calculated in The Aggregation Pipeline. Today's order 1013 falls outside the October range.

Ideas to take it further

  • Add login with hashed passwords and let customers see only their own orders.
  • Save the change stream's resume token and record smsSentAt so a restart never sends a message twice.
  • Add a $jsonSchema validator to orders so other scripts cannot write bad data.
  • Write integration tests that run against a disposable local Atlas deployment.
  • Return Retry-After with 503 responses when the database is unreachable, as in Production Connections: Pools, Timeouts & Retries.

Key points

  • Convert and check every input before it reaches a query; never pass request objects straight into filters.
  • Use one atomic findOneAndUpdate() with the condition in the filter for stock checks and status changes.
  • Wrap work that spans several documents (stock, counter, order) in one transaction so it all succeeds or all rolls back.
  • Index for your real queries, such as { customerId: 1, _id: -1 } for order history, and paginate by range, not skip().
  • Do reporting in aggregation pipelines with explicit time zones, and slow side effects such as SMS in a separate change stream worker.

Exercise

Add a POST /api/orders/:id/cancel route. It may cancel only pending or ready orders, must put the stock of every item back, and must record the change in history. Make sure a double click cannot return the stock twice.

Show solution
JavaScript
app.post('/api/orders/:id/cancel', async (req, res) => {
  const id = toInt(req.params.id);
  const order = await mongoose.connection.transaction(async () => {
    const cancelled = await Order.findOneAndUpdate(
      { _id: id, status: { $in: ['pending', 'ready'] } },
      { $set: { status: 'cancelled' }, $push: { history: { status: 'cancelled', at: new Date() } } },
      { returnDocument: 'after' }
    ).lean();
    if (!cancelled) throw new HttpError(409, 'Only pending or ready orders can be cancelled');
    for (const item of cancelled.items) {
      await Product.updateOne({ _id: item.productId }, { $inc: { stock: item.qty } });
    }
    return cancelled;
  });
  res.json({ orderNo: order._id, status: order.status });
});

The status condition sits in the update's filter, so only one of two simultaneous clicks can match; the second gets a 409 and returns no stock. The status change and the stock returns share one transaction, so a failure part-way leaves everything as it was. Cancelling order 1013 returns {"orderNo":1013,"status":"cancelled"} and adds 2 mice and 1 cable back to stock.

Quick Quiz

1. Why does the order route reduce stock with findOneAndUpdate({ _id, stock: { $gte: qty } }, { $inc: { stock: -qty } })?

2. What happens to the order counter when an item in a new order is out of stock?

3. Why does the status route include the current status in the update filter?

4. Why does the order history use before instead of skip()?

Learn MongoDB with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call