Building a ShelfLife Household Inventory Tracker - Backend

I'm Shubham (@shubhamsinghbundela), I'm a Software Engineer, a Full-stack developer, a tech enthusiast, and a technical writer here on @Hashnode. I have a strong zeal to share my acquired knowledge and I am also willing to learn from others.
In many shared households, managing groceries becomes messy over time.
Multiple people live together,
Everyone buys food
Items get stored in different places
Expiry dates are ignored
Food gets wasted
The real problem isn’t buying food — it’s tracking and managing it efficiently as a group.
What are we building?
ShelfLife is a collaborative expiry tracking application designed for shared households.
It helps roommates:
Track groceries in one place
Get notified before items expire
Collaborate within a household
Compete to reduce food waste
Goal
To reduce food wastage by bringing visibility, accountability, and collaboration into everyday household management.
Instead of:
“Who bought this?”
“Is this still good?”
“Why is this expired again?”
We move to:
“We know what we have.”
“We know when it expires.”
“We waste less.”
Core Features
Authentication & User Management
Every user starts here:Users can sign up with credentials
Users can log in securely
This ensures every action is tied to an authenticated user.
Household System
Why household?
Because this is collaborative, not individual.
One user creates a household
Gets a 6-char invite code
Others join using code
User can view all other household members.
Any member has the flexibility to leave the household at any time.
Inventory Management
Users can manage grocery items within a household:
Add items with:
name
category
expiry date
quantity
The system automatically assigns a status to each item:
Fresh → expiry date is far away
Expiring Soon → within 3 days
Expired → past expiry date
Users can:
Edit items
Delete items
Automated Status Updates (Cron Job)
Item status changes over time, so it cannot be static.
Example:
Today → Fresh
After 2 days → Expiring Soon
After expiry → Expired
To handle this automatically:
A daily cron job runs in the background
It checks all items in the database
Updates their status based on the current date and expiry date
This ensures:
Status is always accurate
No manual updates are required
Users get real-time visibility of item conditions
Step 1: Initialize Project
npm init -y
It Creates package.json
Step 2: Install Dependencies
npm install express
npm install --save-dev nodemon
npm install dotenv // This package is used to load environment variables from the .env file.
Step 3: Setup App Entry (src/app.js)
Create src/app.js
import express from "express";
const app = express();
app.use(express.json());
export default app;
This file is responsible for:
Initializing express app
Adding global middlewares
Step 4: Environment Variables
Create .env file:
PORT=3000
NODE_ENV=development
MONGODB_URI=your_mongodb_connection_string
Step 5: Server Entry Point (server.js)
create server.js
import "dotenv/config"; //This automatically loads .env variables into process.env
import app from "./src/app.js";
const PORT = process.env.PORT || 3000;
const start = async () => {
app.listen(PORT, () => {
console.log(`Server is running at \({PORT} in \){process.env.NODE_ENV} mode`);
});
};
start().catch((err) => {
console.error("Failed to start server", err);
process.exit(1);
});
This file is responsible for:
Starting the server
Handling startup errors
Step 6: Scripts Setup
Update package.json:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
Use:
npm run dev
Step 7: Setup Database Connection
Create:
src/common/config/db.js
import mongoose from "mongoose";
import dns from "dns";
dns.setServers(["1.1.1.1", "8.8.8.8"]);
const connectDB = async () => {
const conn = await mongoose.connect(process.env.MONGODB_URI);
console.log(`MongoDB connected: ${conn.connection.host}`);
};
export default connectDB;
Initially, I tried connecting to MongoDB (cloud) but faced a DNS issue
Problem:
MongoDB connection was failing due to DNS resolution.
Fix:
After watching a YouTube video: , I added this:
const dns = require("dns");
dns.setServers(["1.1.1.1", "8.8.8.8"]);
This fixed the issue and I was able to connect to cloud MongoDB.
Update server.js :
import "dotenv/config"; //This automatically loads .env variables into process.env
import app from "./src/app.js";
import connectDB from "./src/common/config/db.js"
const PORT = process.env.PORT || 3000;
const start = async () => {
await connectDB();
app.listen(PORT, () => {
console.log(`Server is running at \({PORT} in \){process.env.NODE_ENV} mode`);
});
};
start().catch((err) => {
console.error("Failed to start server", err);
process.exit(1);
});
Step 8: Standard API Response Structure
Create:
src/common/utils/api-response.js
class ApiResponse {
static ok(res, message, data = null) {
return res.status(200).json({
success: true,
message,
data
});
}
}
export default ApiResponse;
Why This is Important
Instead of writing:
res.status(200).json({ message: "Success" });
To avoid repeating response logic across multiple APIs, I implemented a reusable utility using the DRY (Don't Repeat Yourself) principle:
ApiResponse.ok(res, "Success", data);
Step 9: Standard API Error Structure
While building APIs, handling errors properly is as important as handling success responses.
- Create Custom Error Class
src/common/utils/api-error.js
class ApiError extends Error {
// Inherit from built-in JavaScript Error
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
// Capture clean stack trace
Error.captureStackTrace(this, this.constructor);
}
static badRequest(message = "Bad Request") {
return new ApiError(400, message);
}
static forbidden(message = "Forbidden") {
return new ApiError(403, message);
}
static notFound(message = "Not Found") {
return new ApiError(404, message);
}
}
export default ApiError;
Why Extend Error?
JavaScript provides built-in errors like:
ErrorTypeErrorReferenceError
But they don’t include HTTP status codes, which are required in APIs.
2. Global Error Middleware
Create:
src/common/middleware/error.middleware.js
import ApiError from "../utils/api-error.js";
const errorHandler = (err, req, res, next) => {
// Handle known (custom) errors
if (err instanceof ApiError) {
return res.status(err.statusCode).json({
success: false,
message: err.message
});
}
// Handle unknown errors
return res.status(500).json({
success: false,
message: "Internal Server Error"
});
};
export default errorHandler;
3. Register Middleware in App
Update src/app.js:
import express from "express";
import authRoute from "./modules/auth/auth.routes.js";
import errorHandler from "./common/middleware/error.middleware.js";
const app = express();
app.use(express.json());
app.use(errorHandler);
export default app;
This must be the last middleware in the app.
Final Error Flow
Request → Route → Controller → Service → throws ApiError → Passes to errorHandler middleware → JSON response
Step 10: Authentication Module (Modular Structure)
Now that we have:
Clean folder structure
Database setup
Standard response & error handling
Next step is to build a modular authentication system
Module Structure
Inside src/modules/auth/, I created:
auth/
├── auth.controller.js
├── auth.middleware.js
├── auth.model.js
├── auth.routes.js
├── auth.service.js
Why This Structure?
Each layer has a clear responsibility:
Model → Database schema
Routes → Define API endpoints
Controller → Handle request/response
Service → Business logic
Middleware → Authentication / validation
This makes code clean, maintainable, and scalable
Create User Schema (auth.model.js)
import mongoose from "mongoose";
const userSchema = new mongoose.Schema(
{
firstName: {
type: String,
required: true,
trim: true,
},
lastName: {
type: String,
required: true,
trim: true,
},
username: {
type: String,
required: true,
unique: true,
trim: true,
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
},
phoneNumber: {
type: String,
required: true,
trim: true,
},
password: {
type: String,
required: true,
},
},
{ timestamps: true },
);
const userModel = mongoose.model("users", userSchema);
export default userModel;
timestamps automatically adds:
createdAtupdatedAt
Register Auth Routes in App.js
Update src/app.js:
import express from "express";
import authRoute from "./modules/auth/auth.routes.js";
import errorMiddleware from "./common/middleware/error.middleware.js";
const app = express();
app.use(express.json());
// Register routes
app.use("/api/auth", authRoute);
// Global error handler
app.use(errorMiddleware);
export default app;
All auth APIs will now be available under:
/api/auth/*
All auth APIs will now be available under:
/api/auth/*
Define Routes (auth.routes.js)
import { Router } from "express";
import * as controller from "./auth.controller.js";
const router = Router();
router.post("/register", controller.signup);
export default router;
Controller Layer (auth.controller.js)
import ApiResponse from "../../common/utils/api-response.js";
import * as authService from "./auth.service.js";
const register = async (req, res, next) => {
try {
const user = await authService.register(req.body);
ApiResponse.ok(res, "User get Created", user);
} catch (error) {
next(error); // pass error to global error middleware
}
};
export { register };
Responsibility of Controller
Calls service layer
Sends response using
ApiResponseKeeps logic minimal
Business Logic (auth.service.js)
import bcrypt from 'bcrypt';
import userModel from "./auth.model.js";
import ApiError from "../../common/utils/api-error.js";
const register = async ({
firstName,
lastName,
username,
email,
phoneNumber,
password,
}) => {
const userExist = await userModel.findOne({
email,
});
if (userExist) {
throw ApiError.forbidden("User Already Exists");
}
const hashedPassword = await bcrypt.hash(password, 10);
const newUser = await userModel.create({
firstName,
lastName,
username,
email,
phoneNumber,
password: hashedPassword,
});
return {
userId: newUser._id,
firstName: newUser.firstName,
lastName: newUser.lastName,
username: newUser.username,
email: newUser.email,
phoneNumber: newUser.phoneNumber,
};
};
Final Result: Testing /api/auth/register API Using Postman
Request Flow
POST /api/auth/register
↓
auth.routes.js
↓
auth.controller.js
↓
auth.service.js
↓
MongoDB
Step 11: Implement Login
Now that the register API is ready, the next step is to allow users to login securely using JWT-based authentication.
Instead of using a single token, we improve security by implementing:
Access Token
Refresh Token
This follows modern authentication practices used in production applications.
Why Use Access Token + Refresh Token?
Previously, authentication systems often used a single JWT token.
Problem:
If that token gets stolen, the attacker can access protected APIs until the token expires.
To improve security, authentication is usually split into two tokens:
| Token | Purpose | Expiry |
|---|---|---|
| Access Token | Access protected APIs | Short-lived (10–15 mins) |
| Refresh Token | Generate new access tokens | Long-lived (7–30 days) |
Why Access Token Is Short-Lived
Even if an attacker steals the access token:
it expires quickly
attacker loses access soon
But refresh tokens live much longer, so they require stronger protection.
JWT Utility Functions
Instead of writing JWT logic multiple times, I created reusable utility functions.
Create:
src/common/utils/jwt.utils.js
import jwt from "jsonwebtoken";
const generateAccessToken = (payload) => {
return jwt.sign(payload, process.env.JWT_ACCESS_SECRET, {
expiresIn: process.env.JWT_ACCESS_EXPIRES_IN || "15m",
});
};
const generateRefreshToken = (payload) => {
return jwt.sign(payload, process.env.JWT_REFRESH_SECRET, {
expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
});
};
const verifyAccessToken = (token) => {
return jwt.verify(token, process.env.JWT_ACCESS_SECRET);
};
export {
generateAccessToken,
generateRefreshToken,
verifyAccessToken,
};
Update Environment Variables
Update .env
JWT_ACCESS_SECRET=your_access_secret
JWT_REFRESH_SECRET=your_refresh_secret
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
Define Routes
Inside auth.route.js, we define the login route:
import { Router } from "express";
import * as controller from "./auth.controller.js";
const router = Router();
router.post("/register", controller.register);
router.post("/login", controller.login)
export default router;
This creates an endpoint:
POST /api/auth/login
Controller Layer
Now we handle the request in the controller auth.controller.js.
import ApiResponse from "../../common/utils/api-response.js";
import * as authService from "./auth.service.js";
const login = async (req, res, next) => {
try {
const { accessToken, refreshToken, user } = await authService.login(
req.body,
);
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
ApiResponse.ok(res, "Login Successful", { accessToken, user });
} catch (error) {
next(error);
}
};
export { login };
What Are Cookies?
Cookies are small pieces of data stored by the browser for a website.
Why Cookies Exist
HTTP is stateless.
That means every request is independent.
Example:
Request 1 → Login
Request 2 → Fetch Profile
Normally, the server cannot remember that both requests came from the same user.
Cookies solve this problem.
How Cookies Work :
Step 1: Server Sends Cookie
Backend response:
Set-Cookie: refreshToken=abc123
Browser stores it automatically.
Step 2: Browser Sends It Automatically
Next request:
Cookie: refreshToken=abc123
Now the server can identify the user.
Important Cookie Security Options
1. httpOnly
httpOnly: true
JavaScript cannot access the cookie.
Protects against XSS attacks.
2. secure
secure: true
if secure: true Cookie is sent only over HTTPS and if secure: false cookies is send over HTTP
3. sameSite
sameSite: "strict"
Controls cross-site cookie sharing.
Helps prevent CSRF attacks.
Responsibility of Controller
Receive request data (
req.body)Call service layer
Store refresh token in cookies
Send access token in response
Pass errors to global error handler
Service Layer (Business Logic)
Now the actual authentication logic lives in auth.service.js:
import bcrypt from "bcrypt";
import userModel from "./auth.model.js";
import ApiError from "../../common/utils/api-error.js";
import {
generateAccessToken,
generateRefreshToken,
} from "../../common/utils/jwt.utils.js";
const login = async ({ email, password }) => {
const userExist = await userModel.findOne({
email: email,
});
if (!userExist) {
throw ApiError.forbidden("User Not Found");
}
const correctPassword = await bcrypt.compare(password, userExist.password);
if (correctPassword) {
const accessToken = generateAccessToken({ userId: userExist._id });
const refreshToken = generateRefreshToken({ userId: userExist._id });
return {
accessToken,
refreshToken,
user: {
userId: userExist._id,
firstName: userExist.firstName,
lastName: userExist.lastName,
username: userExist.username,
email: userExist.email,
phoneNumber: userExist.phoneNumber,
},
};
} else {
throw ApiError.forbidden("Password is invalid");
}
};
export {
login
};
Final Result: Testing /api/auth/login API Using Postman
Step 12: Refresh Access Token
Previously, during login:
Access Token was returned in the response
Refresh Token was stored securely in HTTP-only cookies
But access tokens are short-lived.
Example:
Access Token → expires in 15 minutes
Refresh Token → expires in 7 days
So after 15 minutes, protected APIs stop working because the access token becomes invalid.
Instead of asking the user to log in again, we use the refresh token to generate a new access token automatically.
Why Refresh Tokens Are Important
Without refresh tokens:
User would need to login repeatedly
Poor user experience
More authentication requests
With refresh tokens:
Access tokens remain short-lived (more secure)
Users stay logged in smoothly
Better production-level authentication flow
Authentication Flow
Login
↓
Access Token generated
↓
Refresh Token stored in cookie
↓
Access Token expires
↓
Frontend calls /refresh
↓
Backend verifies refresh token
↓
New Access Token generated
Update JWT Utility Functions
Previously, we only added:
generateAccessToken()
generateRefreshToken()
Now we also need:
- verifyRefreshToken()
Update:
src/common/utils/jwt.utils.js
import jwt from "jsonwebtoken";
const generateAccessToken = (payload) => {
return jwt.sign(
payload,
process.env.JWT_ACCESS_SECRET,
{
expiresIn:
process.env.JWT_ACCESS_EXPIRES_IN || "15m",
}
);
};
const generateRefreshToken = (payload) => {
return jwt.sign(
payload,
process.env.JWT_REFRESH_SECRET,
{
expiresIn:
process.env.JWT_REFRESH_EXPIRES_IN || "7d",
}
);
};
const verifyAccessToken = (token) => {
return jwt.verify(
token,
process.env.JWT_ACCESS_SECRET
);
};
const verifyRefreshToken = (token) => {
return jwt.verify(
token,
process.env.JWT_REFRESH_SECRET
);
};
export {
generateAccessToken,
generateRefreshToken,
verifyAccessToken,
verifyRefreshToken,
};
Define Refresh Route
Update:
src/modules/auth/auth.routes.js
import { Router } from "express";
import * as controller from "./auth.controller.js";
const router = Router();
router.post("/register", controller.register);
router.post("/login", controller.login);
router.post("/refresh", controller.refresh);
export default router;
This creates:
POST /api/auth/refresh
Controller Layer
Now we handle refresh requests inside:
src/modules/auth/auth.controller.js
const refresh = async (req, res, next) => {
try {
const { accessToken } =
await authService.refresh(
req.cookies.refreshToken
);
ApiResponse.ok(
res,
"Token refreshed successfully",
{ accessToken }
);
} catch (error) {
next(error);
}
};
Why Read Token From Cookies?
Because refresh tokens are sensitive.
Storing them inside:
httpOnly cookies
prevents JavaScript from accessing them.
This protects against:
XSS attacks
Token theft through frontend scripts
Service Layer (Business Logic)
Now the actual refresh logic lives inside:
src/modules/auth/auth.service.js
const refresh = async (token) => {
if (!token) {
throw ApiError.unauthorized(
"Refresh token missing"
);
}
const decoded =
verifyRefreshToken(token);
const userExists =
await userModel.findOne({
_id: decoded.userId,
});
if (!userExists) {
throw ApiError.notFound(
"User Not found"
);
}
const accessToken =
generateAccessToken({
userId: userExists._id,
});
return { accessToken };
};
Final Refresh Flow
Frontend Request
↓
/api/auth/refresh
↓
Read refreshToken cookie
↓
Verify refresh token
↓
Find user
↓
Generate new access token
↓
Send response
Step 13: Get Current User & Logout APIs
After implementing:
Register
Login
Refresh Token
the next important step is handling:
Fetching logged-in user details
Logging users out securely
These are common authentication features used in almost every production application.
Why Do We Need GetMe API?
After login, the frontend often needs user information like:
Name
Email
Username
Phone number
Instead of storing all user data in frontend state permanently, we create a dedicated API:
GET /api/auth/getme
This endpoint:
Verifies the access token
Identifies the logged-in user
Returns fresh user details
This helps keep frontend authentication state synchronized with the backend.
Why Do We Need Logout?
Even though JWT authentication is stateless, users still need a secure way to end their session.
During login:
Access token is stored on frontend
Refresh token is stored inside cookies
If logout is not implemented:
Refresh token remains active
User session can continue unintentionally
So during logout we:
Clear refresh token cookie
End user session safely
Define Routes
Update:
src/modules/auth/auth.routes.js
import { Router } from "express";
import authMiddleware from "./auth.middleware.js";
import * as controller from "./auth.controller.js";
const router = Router();
router.post("/register", controller.register);
router.post("/login", controller.login);
router.post("/refresh", controller.refresh);
router.get(
"/getme",
authMiddleware,
controller.getMe
);
router.get(
"/logout",
authMiddleware,
controller.logout
);
export default router;
Controller Layer
Update:
src/modules/auth/auth.controller.js
const getMe = async (req, res, next) => {
try {
const userId = req.userId;
console.log("userId", userId);
const { user } =
await authService.getMe(userId);
ApiResponse.ok(
res,
"User get successfully",
{ user }
);
} catch (error) {
next(error);
}
};
const logout = async (req, res, next) => {
try {
res.clearCookie("refreshToken");
ApiResponse.ok(
res,
"Logout Success"
);
} catch (error) {
next(error);
}
};
What Does clearCookie() Do?
res.clearCookie("refreshToken");
This removes the refresh token from browser cookies.
Result:
User can no longer refresh access tokens
Session is effectively terminated
Service Layer (Business Logic)
Update:
src/modules/auth/auth.service.js
const getMe = async (userId) => {
if (!userId) {
throw ApiError.notFound(
"user not found"
);
}
const userExist =
await userModel.findOne({
_id: userId,
});
if (!userExist) {
throw ApiError.notFound(
"User Not found"
);
}
return {
user: {
userId: userExist._id,
firstName: userExist.firstName,
lastName: userExist.lastName,
username: userExist.username,
email: userExist.email,
phoneNumber:
userExist.phoneNumber,
},
};
};
Step 14: Create household inside households Module
Now that authentication is complete, the next step is to allow users to create household
Requirements
After logging in, a user should be able to:
Create a new household by providing:
a valid household name
a 6-character invite code (chosen by the user)
Only authenticated users are allowed to create a household.
Module Structure
Inside src/modules/, create a new folder:
src/modules/households/
├── households.controller.js
├── households.middleware.js
├── households.model.js
├── households.routes.js
├── households.service.js
Create HouseHold Schema
Create:
src/modules/households/households.model.js
import mongoose from "mongoose";
const houseHoldSchema = new mongoose.Schema({
name: String,
inviteCode: String,
members: [mongoose.Types.ObjectId],
createdBy: mongoose.Types.ObjectId
}, { timestamps: true })
const houseHoldModel = mongoose.model("households", houseHoldSchema)
export default houseHoldModel;
Authentication Middleware
To ensure only logged-in users can create organisations:
Inside:
src/modules/auth/auth.middleware.js
import jwt from 'jsonwebtoken';
const authMiddleware = async (req, res, next) => {
try{
const token = req.headers.token;
const decode = jwt.verify(token, process.env.JWT_SECRET);
const userExists = await userModel.findOne({
_id: decode.userId
})
if(!userExists){
throw ApiError.notFound("User Not found");
}
req.userId = decode.userId;
next();
} catch (error){
next(error)
}
};
export default authMiddleware;
Update Global Error Middleware
Update your error middleware to handle JWT-specific errors:
src/common/middleware/error-middleware.js
import ApiError from "../utils/api-error.js";
const errorHandler = (err, req, res, next) => {
// Handle known (custom) errors
if (err instanceof ApiError) {
return res.status(err.statusCode).json({
success: false,
message: err.message
});
}
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
message: err.message
});
}
// Handle unknown errors
return res.status(500).json({
success: false,
message: "Internal Server Error"
});
};
export default errorHandler;
Register Household Routes
Update src/app.js:
import householdsRoute from './modules/households/households.routes.js'
app.use("/api/households", orgRoute);
Now all households APIs will be available under:
/api/households/*
Define Routes
Create:
src/modules/households/households.routes.js
import { Router } from "express";
import authMiddleware from "../auth/auth.middleware.js";
import * as controller from './households.controller.js';
const router = Router();
router.post('/', authMiddleware, controller.createHouseHold);
export default router;
Controller Layer
Create:
src/modules/households/households.controller.js
import ApiResponse from "../../common/utils/api-response.js";
import * as householdService from './households.service.js'
const createHouseHold = async (req, res, next) => {
try {
const data = await householdService.createHouseHold(req);
ApiResponse.ok(res, "Household Created Successfully", data);
} catch (err) {
next(err);
}
}
export {
createHouseHold
}
Responsibility of Controller
Calls service layer
Sends success response
Passes errors to global middleware
Business Logic (Service Layer)
Create:
src/modules/households/households.service.js
const createHouseHold = async (req) => {
const householdName = req.body.householdName;
const inviteCode = req.body.inviteCode;
const createdBy = req.userId;
// CHECK IF INVITE CODE ALREADY EXISTS
const inviteCodeExists = await houseHoldModel.findOne({
inviteCode,
});
if (inviteCodeExists) {
throw ApiError.badRequest("Invite code already exists");
}
const members = [req.userId];
const newHouseHold = await houseHoldModel.create({
householdName,
inviteCode,
members,
createdBy,
});
// UPDATE USER WITH HOUSEHOLD ID
await userModel.findByIdAndUpdate(createdBy, {
householdId: newHouseHold._id,
});
return {
householdId: newHouseHold._id,
name: newHouseHold.name,
members: newHouseHold.members,
inviteCode: newHouseHold.inviteCode,
};
};
Request Flow
POST /api/households/
↓
auth.middleware.js (verify token)
↓
households.controller.js
↓
households.service.js
↓
MongoDB
↓
Response
Final Result: Testing api/households/ API Using Postman
Step 14: Join Household Feature
After implementing household creation, the next step is to allow users to join an existing household using an invite code.
Define Routes
Create:
src/modules/households/households.routes.js
import { Router } from "express";
import authMiddleware from "../auth/auth.middleware.js";
import * as controller from './households.controller.js';
const router = Router();
router.post('/', authMiddleware, controller.createHouseHold);
router.post('/join', authMiddleware, controller.joinHouseHold);
export default router;
Controller Layer
Create:
src/modules/households/households.controller.js
import ApiResponse from "../../common/utils/api-response.js";
import * as householdService from './households.service.js'
const joinHouseHold = async (req, res, next) => {
try {
const data = await householdService.joinHouseHold(req);
ApiResponse.ok(res, "Household Joined Successfully", data);
} catch (err) {
next (err);
}
}
export {
joinHouseHold
}
Responsibility of Controller
Calls service layer
Sends success response
Passes errors to global middleware
Business Logic (Service Layer)
Create:
src/modules/households/households.service.js
const joinHouseHold = async (req) => {
const { inviteCode } = req.body;
const houseHoldExists = await houseHoldModel.findOne({
inviteCode,
});
if (!houseHoldExists) {
throw ApiError.badRequest("Invalid Invite Code");
}
const newMember = req.userId;
const memberExists = houseHoldExists?.members?.includes(newMember);
if (memberExists) {
throw ApiError.badRequest("Member already exists in household");
}
houseHoldExists.members.push(newMember);
await houseHoldExists.save();
// UPDATE USER
await userModel.findByIdAndUpdate(newMember, {
householdId: houseHoldExists._id,
});
return {
householdId: houseHoldExists._id,
name: houseHoldExists.name,
members: houseHoldExists.members,
};
};
Request Flow
POST /api/households/join
↓
auth.middleware.js (verify token)
↓
households.controller.js
↓
households.service.js
↓
MongoDB
↓
Response
Final Result: Testing api/households/join API Using Postman
Step 15: Get All Household Members
After implementing join functionality, the next step is to allow users to view all members of a household.
Update Household Model (Add Reference)
To fetch full user details, we need to define a reference in the schema.
Update:
src/modules/households/households.model.js
import mongoose from "mongoose";
const houseHoldSchema = new mongoose.Schema({
name: String,
inviteCode: String,
members: [{
type: mongoose.Types.ObjectId,
ref: "users" // reference to users collection
}],
createdBy: mongoose.Types.ObjectId
}, { timestamps: true });
const houseHoldModel = mongoose.model("households", houseHoldSchema);
export default houseHoldModel;
Why Add ref?
MongoDB stores only ObjectIds in
membersref: "users"tells Mongoose:“These IDs belong to the users collection”
This enables the use of populate (explained below)
Define Routes
Update:
src/modules/households/households.routes.js
import { Router } from "express";
import authMiddleware from "../auth/auth.middleware.js";
import * as controller from './households.controller.js';
const router = Router();
router.post('/', authMiddleware, controller.createHouseHold);
router.post('/join', authMiddleware, controller.joinHouseHold);
router.get('/:id/members', authMiddleware, controller.getAllMembers)
export default router;
Controller Layer
Create / Update:
src/modules/households/households.controller.js
const getAllMembers = async (req, res, next) => {
try {
const data = await householdService.getAllMembers(req);
ApiResponse.ok(res, "All Members", data);
} catch (err) {
next(err);
}
};
Service Layer (Business Logic)
Create / Update:
src/modules/households/households.service.js
const getAllMembers = async (req) => {
const houseHoldId = req.params.id;
const houseHoldExists = await houseHoldModel
.findById(houseHoldId)
.populate('members', 'name email');
if (!houseHoldExists) {
throw ApiError.notFound("Household not found");
}
return houseHoldExists.members;
};
What is .populate()?
In MongoDB, we store references like this:
members: [ObjectId("1234"), ObjectId("5678")]
If we fetch data without populate:
{
"members": ["1234", "5678"]
}
This is not useful for the frontend.
Using Populate
.populate('members', 'name email')
This tells Mongoose:
Replace member IDs with actual user data (only name and email)
Output After Populate
{
"members": [
{
"_id": "64abc...",
"name": "Shubham",
"email": "shubham@gmail.com"
}
]
}
Why Use Populate?
Converts ObjectIds → real data
Works similar to JOIN in SQL
Reduces extra API calls from frontend
Request Flow
GET /api/households/:id/members
↓
auth.middleware.js (verify token)
↓
households.controller.js
↓
households.service.js
↓
MongoDB (findById + populate)
↓
Response
Final Result: Testing /api/households/:id/members API Using Postman
Step 16: Leave Household Feature
After implementing the ability to join a household, the next important feature is to allow users to leave a household.
Define Routes
Update:
src/modules/households/households.routes.js
import { Router } from "express";
import authMiddleware from "../auth/auth.middleware.js";
import * as controller from './households.controller.js';
const router = Router();
router.post('/', authMiddleware, controller.createHouseHold);
router.post('/join', authMiddleware, controller.joinHouseHold);
router.get('/:id/members', authMiddleware, controller.getAllMembers)
// Leave household
router.delete('/:id/leave', authMiddleware, controller.leaveHouseHold);
export default router;
Controller Layer
Update:
src/modules/households/households.controller.js
const leaveHouseHold = async (req, res, next) => {
try {
const data = await householdService.leaveHouseHold(req);
ApiResponse.ok(res, "Left Household Successfully", data);
} catch (err) {
next(err);
}
};
Service Layer (Business Logic)
Update:
src/modules/households/households.service.js
const leaveHouseHold = async (req) => {
const houseHoldId = req.params.id;
const userId = req.userId;
const houseHold = await houseHoldModel.findById(houseHoldId);
if (!houseHold) {
throw ApiError.notFound("Household not found");
}
const isMember = houseHold.members.some(
(member) => member.toString() === userId
);
if (!isMember) {
throw ApiError.badRequest("User is not a member of this household");
}
houseHold.members = houseHold.members.filter(
(member) => member.toString() !== userId
);
//If creator/admin leaves
if (houseHold.createdBy.toString() === userId) {
if (houseHold.members.length === 0) {
await houseHold.deleteOne();
return { message: "Household deleted as no members left" };
}else{
houseHold.createdBy = houseHold.members[0];
}
}
await houseHold.save();
return {
householdId: houseHold._id,
members: houseHold.members
};
}
Request Flow
DELETE /api/households/:id/leave
↓
auth.middleware.js (verify token)
↓
households.controller.js
↓
households.service.js
↓
MongoDB (find → update → save/delete)
↓
Response
Final Result: Testing /api/households/:id/leave API Using Postman
Step 17: Add Items Feature (Inventory Management)
Now that households are implemented, the next step is to allow users to add grocery items inside a household.
This is the core functionality of the application.
Requirement
A logged-in user should be able to:
Add items with:
name
category
quantity
expiry date
The system should automatically assign a status:
freshexpiring-soon(≤ 3 days)expired
Module Structure
Created a new module:
src/modules/items/
├── items.controller.js
├── items.middleware.js
├── items.model.js
├── items.routes.js
├── items.service.js
Item Schema
Create:
src/modules/items/items.model.js
import mongoose from "mongoose";
const itemsSchema = new mongoose.Schema({
householdId: {
type: mongoose.Types.ObjectId,
ref: "households"
},
addedBy: {
type: mongoose.Types.ObjectId,
ref: "users"
},
name: {
type: String,
required: true
},
category: {
type: String,
enum: ['produce', 'dairy', 'meat', 'pantry', 'frozen']
},
quantity: Number,
expiryDate: Date,
status: {
type: String,
enum: ['fresh', 'expiring-soon', 'expired', 'used', 'wasted']
}
}, { timestamps: true });
const itemsModel = mongoose.model("items", itemsSchema);
export default itemsModel;
Why Enum?
We use enum to restrict values.
Example:
Category must be one of:
produce,dairy,meat,pantry,frozen
If any other value is passed → Mongoose throws a validation error.
Household Middleware
Before adding an item, we must ensure:
The user belongs to a household
Create:
src/modules/households/households.middleware.js
import ApiError from '../../common/utils/api-error.js';
import houseHoldModel from './households.model.js';
const housholdMiddleware = async (req, res, next) => {
try {
const userId = req.userId;
const householdExists = await houseHoldModel.findOne({
members: userId
});
if (!householdExists) {
throw ApiError.notFound("HouseHold Not found");
}
req.householdId = householdExists._id.toString();
next();
} catch (error) {
next(error);
}
};
export default housholdMiddleware;
Why This Middleware?
Ensures user is part of a household
Prevents unauthorized item creation
Attaches
householdIdto request
Routes
Create:
src/modules/items/items.routes.js
import { Router } from "express";
import authMiddleware from "../auth/auth.middleware.js";
import housholdMiddleware from "../households/households.middleware.js";
import * as controller from './items.controller.js';
const router = Router();
router.post('/', authMiddleware, housholdMiddleware, controller.createItem);
export default router;
Controller Layer
Create:
src/modules/items/items.controller.js
import ApiResponse from "../../common/utils/api-response.js";
import * as itemService from './items.service.js';
const createItem = async (req, res, next) => {
try {
const data = await itemService.createItem(req);
ApiResponse.ok(res, "Item Created Successfully", data);
} catch (err) {
next(err);
}
};
export {
createItem
};
Responsibility of Controller
Calls service layer
Sends success response
Passes errors to middleware
Service Layer (Business Logic)
Create:
src/modules/items/items.service.js
import ApiError from "../../common/utils/api-error.js";
import itemsModel from "./items.model.js";
const createItem = async (req) => {
const { name, category, quantity, expiryDate } = req.body;
const userId = req.userId;
const householdId = req.householdId;
if (!name || !category || !quantity || !expiryDate) {
throw ApiError.badRequest("All fields are required");
}
const today = new Date();
const expiry = new Date(expiryDate);
// Normalize time (important for correct comparison)
//Removes time part → avoids wrong comparisons
today.setHours(0, 0, 0, 0);
expiry.setHours(0, 0, 0, 0);
const diffTime = expiry - today;
// 1000 → milliseconds in 1 second
// 60 → seconds in 1 minute
// 60 → minutes in 1 hour
// 24 → hours in 1 day
//1 day = 86,400,000 milliseconds
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
let status = "fresh";
if (diffDays < 0) {
status = "expired";
} else if (diffDays <= 3) {
status = "expiring-soon";
}
const newItem = await itemsModel.create({
householdId,
addedBy: userId,
name,
category,
quantity,
expiryDate,
status
});
return {
_id: newItem._id,
name: newItem.name,
status: newItem.status
};
}
export {
createItem
}
Request Flow
POST /api/items/
↓
auth.middleware.js
↓
household.middleware.js
↓
items.controller.js
↓
items.service.js
↓
MongoDB
↓
Response
Final Result: Testing /api/items/ API Using Postman
Step 18: Automated Status Updates (Using Cron Job)
Now that item creation is implemented, the next improvement is making item status dynamic instead of static.
Because expiry status changes over time, we use a background cron job.
Why This is Needed?
When an item is created:
It may be fresh today
But before 2 days of expiry date it becomes expiring-soon
After expiry → expired
So status must update automatically without user action.
Solution :
To solve this, we introduce a background cron job.
A cron job runs at a fixed interval and updates item status automatically based on current date.
We used:
node-cron
Cron Job Setup (Testing Mode)
Initially, I tested the cron job by running it every minute.
Create:
src/modules/items/items.cron.js
import cron from "node-cron";
cron.schedule("* * * * *", () => {
console.log(
"Cron job running every minute at",
new Date().toLocaleTimeString()
);
});
Understanding the Cron Expression
"* * * * *"
This means:
minute hour day month weekday
So the above expression runs:
- Every minute
For production, this can later be changed to:
0 6 * * *
Which means:
- Run every day at 6 AM
For more details checkout node-cron documentation: https://www.nodecron.com/getting-started.html
Implementing Status Update Logic
Now the next step is to update item statuses automatically.
Inside the service layer, we created updateItemStatuses function to implement the logic:
Fetch all items from database(item collection)
Compare expiry date with current date
Calculate difference in days
Assign correct status
Rules:
diffDays < 0 → expired
diffDays <= 3 → expiring-soon
else → fresh
src/modules/items/items.service.js
const updateItemStatuses = async () => {
try {
const items = await itemsModel.find({});
const today = new Date();
today.setHours(0, 0, 0, 0);
const bulkOperations = [];
const expiringItems = [];
for (const item of items) {
const expiry = new Date(item.expiryDate);
expiry.setHours(0, 0, 0, 0);
const diffTime = expiry - today;
const diffDays = Math.ceil(
diffTime / (1000 * 60 * 60 * 24)
);
let newStatus = "fresh";
if (diffDays < 0) {
newStatus = "expired";
} else if (diffDays <= 3) {
newStatus = "expiring-soon";
expiringItems.push(item);
}
// Update only if status changed
if (item.status !== newStatus) {
bulkOperations.push({
updateOne: {
filter: { _id: item._id },
update: {
$set: {
status: newStatus,
},
},
},
});
}
}
// Bulk update optimization
if (bulkOperations.length > 0) {
await itemsModel.bulkWrite(bulkOperations);
console.log(
`Updated ${bulkOperations.length} items`
);
}
return expiringItems;
} catch (error) {
console.error("Cron job failed:", error);
}
};
Why Use bulkWrite (mongoDB) ?
Instead of updating items one by one:
await item.save();
We use:
itemsModel.bulkWrite(bulkOperations);
Benefits:
Fewer database calls
Better performance
More scalable approach
Check out mongodb documentation on bulkWrite: https://www.mongodb.com/docs/manual/reference/method/db.collection.bulkWrite/ )
Collecting Expiring Items
While updating statuses, we also track items that are close to expiry.
These items are stored separately:
expiringItems.push(item);
This allows us to later notify users about upcoming expirations.
Connecting Cron with Service
Now we connect the cron scheduler with the status update logic.
Update:
src/modules/items/items.cron.js
import cron from "node-cron";
import { updateItemStatuses } from "./items.service.js";
cron.schedule("* * * * *", async () => {
console.log("Cron job started...");
await updateItemStatuses();
});
Final Cron Flow
Cron Trigger
↓
Fetch all items
↓
Calculate expiry difference
↓
Update statuses
↓
Bulk update MongoDB
↓
Collect expiring items
Step 19: Email Notification System for Expiring Items
Now that item statuses update automatically using a cron job, the next step is to notify household members when items are about to expire.
Problem
Even if the database status updates correctly:
Users may never open the app regularly
Expiring items can still go unnoticed
Food waste can still happen
So we need a notification mechanism.
Solution
Whenever the cron job finds items that are:
expiring-soon
The system automatically sends email notifications to all household members.
This ensures everyone in the household is informed in advance.
Setting Up Email Service (Nodemailer)
To send emails, I used:
nodemailer
Checkout nodemailer documentation: https://nodemailer.com/
Email Configuration
Create:
src/common/config/email.js
import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({
host: "smtp.gmail.com",
// SMTP port for secure connection
port: 465,
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const sendMail = async (to, subject, html) => {
await transporter.sendMail({
from: process.env.SMTP_FROM_EMAIL,
to,
subject,
html,
});
};
export { sendMail };
Environment Variables
Update .env
SMTP_USER=your_email@gmail.com
SMTP_PASS=your_app_password
SMTP_FROM_EMAIL=your_email@gmail.com
Why App Password?
For Gmail SMTP:
Normal Gmail password does not work
Google requires App Passwords for secure SMTP access
What is App Password?
It is a special 16-digit password generated by Google that allows apps like Nodemailer to send emails safely.
How to get App password?
1. Enable 2-Step Verification
Go to: https://myaccount.google.com/security
Turn ON:
- 2-Step Verification
2. Generate App Password
After enabling 2FA:
Go to: https://myaccount.google.com/apppasswords
You will get something like:
abcd efgh ijkl mnop
3. Use it in Nodemailer
auth: {
user: process.env.SMTP_USER, // your gmail address
pass: process.env.SMTP_PASS, // app password (NOT normal password)
}
Notification Flow
The notification system works like this:
Cron Job
↓
Update item statuses
↓
Collect expiring items
↓
Group items by household
↓
Fetch household members
↓
Send emails
Grouping Expiring Items by Household
Inside the cron job schedular code:
const grouped = {};
for (const item of expiringItems) {
const key = item.householdId.toString();
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push(item);
}
Why Group by Household?
Because:
Multiple households exist
Each household has different members
Notifications should only go to relevant users
Fetching Household Members
Now we fetch all members of a household.
const household = await houseHoldModel
.findById(householdId)
.populate("members", "email");
Using populate() gives access to member emails directly.
Generating Email Content
Next, we generate
the email body dynamically.
const itemsList = grouped[householdId]
.map(
(i) =>
`<li>
${i.name} - expires on
${new Date(i.expiryDate).toDateString()}
</li>`
)
.join("");
Final Email Template
const html = `
<h2>Expiring Items Alert</h2>
<p> The following items will expire soon: </p>
<ul> ${itemsList} </ul>
`;
Sending Emails
Finally, emails are sent to all household members.
for (const email of emails) {
await sendMail(
email,
"Items Expiring Soon",
html
);
Final Cron Implementation
Update:
src/modules/items/items.cron.js
import cron from "node-cron";
import { updateItemStatuses } from "./items.service.js";
import houseHoldModel from "../households/households.model.js";
import { sendMail } from "../../common/config/email.js";
cron.schedule("* * * * *", async () => {
try {
console.log("cronJob started...");
const expiringItems =
await updateItemStatuses();
if (!expiringItems.length) {
console.log("No expiring items");
return;
}
// Group items by household
const grouped = {};
for (const item of expiringItems) {
const key = item.householdId.toString();
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push(item);
}
// Send notifications
for (const householdId of Object.keys(grouped)) {
const household = await houseHoldModel
.findById(householdId)
.populate("members", "email");
const emails = household.members.map(
(u) => u.email
);
const itemsList = grouped[householdId]
.map(
(i) =>
`<li>
${i.name} - expires on
${new Date(i.expiryDate).toDateString()}
</li>`
)
.join("");
const html = `
<h2>Expiring Items Alert</h2>
<p>
The following items will expire soon:
</p>
<ul>
${itemsList}</ul>
`;
for (const email of emails) {
await sendMail(
email,
"Items Expiring Soon",
html
);
}
}
} catch (err) {
console.error("Cron failed:", err);
}
});
Outcome
With this implementation:
Household members receive automated expiry alerts
Expiring items are tracked proactively
Users can consume items before they expire
Food wastage is reduced significantly
Step 20: Get All Items & Update Item APIs
After implementing item creation and automated expiry tracking, the next step was building inventory management APIs for the frontend dashboard.
These APIs allow users to:
View all household inventory items
Edit existing items directly from the inventory table
This became important after integrating the inventory dashboard using Material React Table.
Why These APIs Were Needed
The frontend inventory system required:
Fetching all items belonging to the logged-in user’s household
Displaying items inside a data table
Supporting inline editing
Updating items without refreshing the entire page manually
This enabled a much smoother inventory management experience.
Get All Items API
Requirements
A logged-in user should be able to:
Fetch all items from their household
View:
name
category
quantity
expiry date
status
Only authenticated household members should access these items.
Define Routes
Update:
src/modules/items/items.routes.js
import { Router } from "express";
import authMiddleware from "../auth/auth.middleware.js";
import housholdMiddleware from "../households/households.middleware.js";
import * as controller from "./items.controller.js";
const router = Router();
router.post(
"/",
authMiddleware,
housholdMiddleware,
controller.createItem
);
router.get(
"/",
authMiddleware,
housholdMiddleware,
controller.getItems
);
router.put(
"/:id",
authMiddleware,
housholdMiddleware,
controller.updateItem
);
export default router;
Controller Layer
Update:
src/modules/items/items.controller.js
import ApiResponse from "../../common/utils/api-response.js";
import * as itemService from "./items.service.js";
const getItems = async (req, res, next) => {
try {
const data = await itemService.getItems(req);
ApiResponse.ok(
res,
"Items fetched successfully",
data
);
} catch (error) {
next(error);
}
};
const updateItem = async (req, res, next) => {
try {
const data = await itemService.updateItem(req);
ApiResponse.ok(
res,
"Item updated successfully",
data
);
} catch (error) {
next(error);
}
};
export {
getItems,
updateItem,
};
Get All Items Service
Create / Update:
src/modules/items/items.service.js
const getItems = async (req) => {
const householdId = req.householdId;
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 10;
const search = req.query.search || "";
const skip = (page - 1) * limit;
const query = {
householdId,
};
if (search) {
query.$or = [
{
name: {
$regex: search,
$options: "i",
},
},
{
category: {
$regex: search,
$options: "i",
},
},
];
}
const items = await itemsModel
.find(query)
.populate("addedBy", "name email")
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit);
const totalItems = await itemsModel.countDocuments({
householdId,
});
return {
items,
totalItems,
};
};
Why Filter Using householdId?
This ensures:
Users only see items belonging to their household
Data remains isolated between households
Unauthorized inventory access is prevented
Without this filtering, users could potentially access items from other households.
Update Item Service
Now users can edit inventory items directly from the dashboard table.
Update:
src/modules/items/items.service.js
const updateItem = async (req) => {
const itemId = req.params.id;
const {
name,
category,
quantity,
expiryDate,
} = req.body;
const item = await itemsModel.findById(itemId);
if (!item) {
throw ApiError.notFound("Item not found");
}
item.name = name;
item.category = category;
item.quantity = quantity;
item.expiryDate = expiryDate;
const today = new Date();
const expiry = new Date(expiryDate);
today.setHours(0, 0, 0, 0);
expiry.setHours(0, 0, 0, 0);
const diffDays = Math.ceil(
(expiry - today) /
(1000 * 60 * 60 * 24)
);
let status = "fresh";
if (diffDays < 0) {
status = "expired";
} else if (diffDays <= 3) {
status = "expiring-soon";
}
item.status = status;
await item.save();
return item;
};
Why Recalculate Status During Update?
Suppose a user edits:
expiry date
quantity
item details
Then the status may also change.
Example:
Fresh → Expiring Soon
Expiring Soon → Expired
So status recalculation ensures item state always stays accurate.
Step 21: Delete Item API
After implementing the Get All Items and Update Item APIs, the next important feature was allowing users to remove inventory items directly from the dashboard.
This became necessary after integrating row actions inside the Material React Table inventory system.
The delete functionality allows users to:
Remove expired items
Delete mistakenly added inventory
Keep household inventory clean and updated
Why Delete API Was Needed
Inside the inventory dashboard, each row already had:
Edit action
Status display
Inline editing support
The next logical feature was:
Delete Item
↓
Remove item from database
↓
Refresh inventory table
↓
Show updated inventory list
This created a much better inventory management experience for users.
Defining Delete Route
Update:
src/modules/items/items.routes.js
import { Router } from "express";
import authMiddleware from "../auth/auth.middleware.js";
import housholdMiddleware from "../households/households.middleware.js";
import * as controller from "./items.controller.js";
const router = Router();
router.post(
"/",
authMiddleware,
housholdMiddleware,
controller.createItem
);
router.get(
"/",
authMiddleware,
housholdMiddleware,
controller.getItems
);
router.put(
"/:id",
authMiddleware,
housholdMiddleware,
controller.updateItem
);
router.delete(
"/:itemId",
authMiddleware,
housholdMiddleware,
controller.deleteItem
);
export default router;
Controller Layer
Update:
src/modules/items/items.controller.js
import ApiResponse from "../../common/utils/api-response.js";
import * as itemService from "./items.service.js";
const deleteItem = async (req, res, next) => {
try {
const data = await itemService.deleteItem(req);
ApiResponse.ok(
res,
"Item Deleted Successfully",
data
);
} catch (err) {
next(err);
}
};
export {
getItems,
updateItem,
deleteItem,
};
Delete Item Service
Update:
src/modules/items/items.service.js
const deleteItem = async (req) => {
const { itemId } = req.params;
const householdId = req.householdId;
const item = await itemsModel.findOne({
_id: itemId,
householdId,
});
if (!item) {
throw ApiError.notFound("Item not found");
}
await itemsModel.deleteOne({
_id: itemId,
householdId,
});
return {
itemId,
};
};



