Building a Secure API with PostgreSQL (No ORM!): The FreeMentors Guide


ORM or SQL queries, what’s your go-to? For me, it’s always been ORM. I’m pretty familiar with Sequelize, and honestly, I never really understood why anyone would prefer writing SQL. But this project pushed me to get into raw SQL, and it’s definitely been an eye-opener, even if deep down I can’t help but ask, "why would I torture myself like this?”
Understand the Problem You’re Solving
The first thing you need to do is to understand what your API needs to do. In this case, we need an API that enables users to interact with mentors. The requirements are as follows:
Users should be able to sign up and sign in.
Admins should be able to promote users to mentors.
Users can view mentors and request mentorship sessions.
Mentors can accept or decline these requests.
After a session, mentees can leave a review for the mentor.
Optional features:
- Admin can delete inappropriate reviews.
Now, let’s get to it!
Design Your Database Schema
The next step is creating the database schema. I like to begin by identifying the tables I’ll need based on the API requirements. For this project, we need:
Users table (to store user details).
Sessions table (to store session data between mentees and mentors).
Reviews table (to store reviews left by mentees for mentors).
Here’s how I designed my schema:
Users Table
We’ll need a table for users, and we’ll use a role
field to distinguish between regular users and mentors. We’ll also have an isAdmin
field to differentiate admins from normal users.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password TEXT NOT NULL,
address TEXT NOT NULL,
bio TEXT,
is_admin BOOLEAN,
occupation TEXT,
expertise TEXT,
role VARCHAR(10) DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Sessions Table
Next, we’ll need a table to handle mentorship sessions. Each session will link a mentor and a mentee.
CREATE TABLE sessions (
id SERIAL PRIMARY KEY,
mentor_id INTEGER REFERENCES users(id),
mentee_id INTEGER REFERENCES users(id),
questions TEXT,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Reviews Table
Finally, we need a table to store reviews. After a mentorship session, a mentee can leave a review for the mentor.
CREATE TABLE reviews (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES sessions(id),
mentee_id INTEGER REFERENCES users(id),
mentor_id INTEGER REFERENCES users(id),
score INTEGER,
remark TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Once you're confident in your schema, I recommend using dbdiagram.io to visualize the relationships between your tables. This will help you ensure everything is set up correctly before moving forward.
Set Up Your Database
I use TablePlus because it makes managing databases easy. You can import your SQL queries there to create the tables, and once that’s done, you’re ready to move to the backend!
Set Up Your Project
First things first, let's create our project directory and initialize it:
mkdir freementors # Creating our project folder
cd freementors # Moving into it
npm init -y
Next, let's install the dependencies we need:
npm install express pg dotenv bcrypt jsonwebtoken joi cors
npm install -D nodemon
Here's what each package does:
express: Our API framework (because writing a server from scratch is so 2010)
pg: PostgreSQL client (to talk to our database)
dotenv: For environment variables
bcrypt: Password hashing (please, please don't store passwords in plain text)
jsonwebtoken: For JWT authentication
joi: For validation (because trusting user input is like trusting a cat with your sandwich)
cors: Cross-Origin Resource Sharing (for when your frontend gets chatty)
nodemon: Auto-restart our server (because ain't nobody got time to restart manually)
Project Structure
Let's organize our code properly - future you will thank present you:
FREEMENTORS/
├── bin/
│ └── www.js # Server entry point
├── config/
│ └── db.js # Database connection
├── controllers/
│ ├── authController.js # Authentication logic
│ ├── userController.js # User management
│ ├── sessionController.js # Session logic
│ └── reviewController.js # Review management
├── middlewares/
│ ├── auth.js # JWT authentication
│ ├── validation.js # Input validation
│ └── error.js # Error handling
├── models/
│ ├── userModel.js # User SQL queries
│ ├── sessionModel.js # Session SQL queries
│ └── reviewModel.js # Review SQL queries
├── routes/
│ ├── auth.js # Auth routes
│ ├── users.js # User routes
│ ├── sessions.js # Session routes
│ └── reviews.js # Review routes
├── .env # Environment variables
├── .gitignore # Files to ignore
├── app.js # Express application
└── package.json # Dependencies
Server entry point
// bin/www.js
const app = require('../app');
const http = require('http');
const port = process.env.PORT || 3000;
app.set('port', port);
const server = http.createServer(app);
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
console.error(`Failed to start server: ${error}`);
process.exit(1);
}
function onListening() {
console.log(`Server running on port ${port}`);
}
db.js (PostgreSQL Connection Setup)
In the config/db.js
file, I like to set up my PostgreSQL connection using the pg
package. Here’s how you do it:
import pkg from 'pg';
const { Pool } = pkg;
import dotenv from 'dotenv';
dotenv.config();
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
max: 20,
});
export default pool;
User Model (SQL Queries)
In the models/userModel.js
, I’ll write SQL queries to interact with the users table. Here’s an example:
import db from '../config/db.js';
export const createUser = async (user) => {
const { firstName, lastName, email, password, address, bio, occupation, expertise } = user;
const result = await db.query(`
INSERT INTO users (first_name, last_name, email, password, address, bio, occupation, expertise)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
`, [firstName, lastName, email, password, address, bio, occupation, expertise]);
return result.rows[0];
};
export const findUserByEmail = async (email) => {
const result = await db.query(`SELECT * FROM users WHERE email = $1`, [email]);
return result.rows[0];
};
export const findUserById = async (id) => {
const result = await db.query(`SELECT * FROM users WHERE id = $1`, [id]);
return result.rows[0];
};
export const promoteToMentor = async (id) => {
const result = await db.query(`UPDATE users SET role = 'mentor' WHERE id = $1 RETURNING *`, [id]);
return result.rows[0];
};
export const findAllMentors = async () => {
const result = await db.query(`SELECT * FROM users WHERE role = 'mentor'`);
return result.rows;
};
export const findMentorById = async (id) => {
const result = await db.query(`SELECT * FROM users WHERE id = $1 AND role = 'mentor'`, [id]);
return result.rows[0];
};
This is a very simple and clean way to handle database operations using raw SQL queries, directly interacting with the database via the pg
package.
Now, let's create our validation middleware:
We’re setting up validation middleware using Joi, which helps make sure that the data the user sends in their request is valid and follows the correct format. For example, we’ll check if the email is valid, if the password is long enough, and if optional fields like bio or occupation are okay.
import Joi from 'joi';
export const validateSignup = (req, res, next) => {
const schema = Joi.object({
firstName: Joi.string().required(),
lastName: Joi.string().required(),
email: Joi.string().email().required(),
password: Joi.string().min(6).required(),
address: Joi.string().allow(''),
bio: Joi.string().allow(''),
occupation: Joi.string().allow(''),
expertise: Joi.string().allow('')
});
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({ status: 'error', message: error.details[0].message });
}
next();
};
export const validateLogin = (req, res, next) => {
const schema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required()
});
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({ status: 'error', message: error.details[0].message });
}
next();
};
export const validateSession = (req, res, next) => {
const schema = Joi.object({
mentorId: Joi.number().integer().required(),
questions: Joi.string().required()
});
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({ status: 'error', message: error.details[0].message });
}
next();
};
export const validateReview = (req, res, next) => {
const schema = Joi.object({
sessionId: Joi.number().integer().required(),
score: Joi.number().integer().min(1).max(5).required(),
remark: Joi.string().required()
});
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({ status: 'error', message: error.details[0].message });
}
next();
};
Now, let's create our authentication middleware:
We’re setting up authentication middleware to handle token-based authentication. The main function checks if the user has a valid JWT token in the header of the request. If the token is missing or invalid, we respond with an error. We also have two additional middleware functions to check if the user is an admin or a mentor, respectively.
// middlewares/auth.js
const jwt = require('jsonwebtoken');
const authenticateToken = (req, res, next) => {
// Get token from header
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ status: 'error', message: 'Access denied. No token provided.' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(403).json({ status: 'error', message: 'Invalid token.' });
}
};
const isAdmin = (req, res, next) => {
if (!req.user.isAdmin) {
return res.status(403).json({ status: 'error', message: 'Access denied. Admin privileges required.' });
}
next();
};
const isMentor = (req, res, next) => {
if (req.user.role !== 'mentor') {
return res.status(403).json({ status: 'error', message: 'Access denied. Mentor privileges required.' });
}
next();
};
module.exports = {
authenticateToken,
isAdmin,
isMentor
};
Now, let's create our authentication controller:
This controller handles both user registration and login. The registration process includes checking if a user already exists, hashing the password for security, creating a new user in the database, and then generating a JWT token. The login function checks if the user exists and whether the provided password is correct, then generates a token if everything checks out.
// controllers/authController.js
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import userModel from '../models/userModel';
// Register user
export const register = async (req, res, next) => {
try {
const { email, password } = req.body;
// Check if user already exists
const existingUser = await userModel.findUserByEmail(email);
if (existingUser) {
return res.status(400).json({ status: 'error', message: 'Email already in use' });
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user with hashed password
const userData = { ...req.body, password: hashedPassword };
const newUser = await userModel.createUser(userData);
// Generate token
const token = jwt.sign(
{
id: newUser.id,
email: newUser.email,
role: newUser.role,
isAdmin: newUser.is_admin
},
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.status(201).json({
status: 'success',
message: 'User registered successfully',
data: {
token,
user: {
id: newUser.id,
firstName: newUser.first_name,
lastName: newUser.last_name,
email: newUser.email,
role: newUser.role
}
}
});
} catch (error) {
next(error);
}
};
// Login user
export const login = async (req, res, next) => {
try {
const { email, password } = req.body;
// Check if user exists
const user = await userModel.findUserByEmail(email);
if (!user) {
return res.status(401).json({ status: 'error', message: 'Invalid email or password' });
}
// Verify password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({ status: 'error', message: 'Invalid email or password' });
}
// Generate token
const token = jwt.sign(
{
id: user.id,
email: user.email,
role: user.role,
isAdmin: user.is_admin
},
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.status(200).json({
status: 'success',
message: 'Login successful',
data: {
token,
user: {
id: user.id,
firstName: user.first_name,
lastName: user.last_name,
email: user.email,
role: user.role
}
}
});
} catch (error) {
next(error);
}
};
Handle User Routes
Next, we need to create routes for handling user signups, sign-ins, and updates. The most basic example of creating a user would look like this:
// routes/auth.js
const express = require('express');
const { register, login } = require('../controllers/authController');
const { validateSignup, validateLogin } = require('../middlewares/validation');
const router = express.Router();
router.post('/signup', validateSignup, register);
router.post('/login', validateLogin, login);
module.exports = router;
User Management
Next, let's create the user controller:
This controller handles user profile management, promotion to mentor, and retrieving mentor details. It provides the following functionality:
Get User Profile: Fetches and returns the logged-in user's profile details.
Promote User to Mentor: Allows an admin to promote a user to the mentor role.
Get All Mentors: Retrieves a list of all users with the 'mentor' role.
Get Specific Mentor: Fetches detailed information about a specific mentor based on their ID.
// controllers/userController.js
import userModel from '../models/userModel.js';
// Get user profile
export const getUserProfile = async (req, res, next) => {
try {
const userId = req.user.id;
const user = await userModel.findUserById(userId);
if (!user) {
return res.status(404).json({ status: 'error', message: 'User not found' });
}
res.status(200).json({
status: 'success',
data: {
id: user.id,
firstName: user.first_name,
lastName: user.last_name,
email: user.email,
address: user.address,
bio: user.bio,
occupation: user.occupation,
expertise: user.expertise,
role: user.role
}
});
} catch (error) {
next(error);
}
};
// Promote user to mentor (admin only)
export const promoteUser = async (req, res, next) => {
try {
const userId = parseInt(req.params.id);
// Check if user exists
const user = await userModel.findUserById(userId);
if (!user) {
return res.status(404).json({ status: 'error', message: 'User not found' });
}
// Check if user is already a mentor
if (user.role === 'mentor') {
return res.status(400).json({ status: 'error', message: 'User is already a mentor' });
}
// Promote user to mentor
const updatedUser = await userModel.promoteToMentor(userId);
res.status(200).json({
status: 'success',
message: 'User promoted to mentor successfully',
data: {
id: updatedUser.id,
firstName: updatedUser.first_name,
lastName: updatedUser.last_name,
email: updatedUser.email,
role: updatedUser.role
}
});
} catch (error) {
next(error);
}
};
// Get all mentors
export const getAllMentors = async (req, res, next) => {
try {
const mentors = await userModel.getAllMentors();
res.status(200).json({
status: 'success',
results: mentors.length,
data: mentors.map(mentor => ({
id: mentor.id,
firstName: mentor.first_name,
lastName: mentor.last_name,
bio: mentor.bio,
occupation: mentor.occupation,
expertise: mentor.expertise
}))
});
} catch (error) {
next(error);
}
};
// Get specific mentor
export const getMentor = async (req, res, next) => {
try {
const mentorId = parseInt(req.params.id);
const mentor = await userModel.getMentorById(mentorId);
if (!mentor) {
return res.status(404).json({ status: 'error', message: 'Mentor not found' });
}
res.status(200).json({
status: 'success',
data: {
id: mentor.id,
firstName: mentor.first_name,
lastName: mentor.last_name,
bio: mentor.bio,
occupation: mentor.occupation,
expertise: mentor.expertise
}
});
} catch (error) {
next(error);
}
};
And the user routes:
These routes provide access to the user management functionality. The routes are protected with authentication and admin checks where necessary.
// routes/users.js
const express = require('express');
const { getUserProfile, promoteUser, getAllMentors, getMentor } = require('../controllers/userController');
const { authenticateToken, isAdmin } = require('../middlewares/auth');
const router = express.Router();
// Protected routes
router.get('/profile', authenticateToken, getUserProfile);
router.patch('/:id/promote', authenticateToken, isAdmin, promoteUser);
router.get('/mentors', authenticateToken, getAllMentors);
router.get('/mentors/:id', authenticateToken, getMentor);
module.exports = router;
Protected Routes
/profile
: Requires the user to be authenticated before retrieving their profile./:id/promote
: Only accessible by admins, allows them to promote a user to a mentor role./mentors
: Gets a list of all mentors, protected by authentication./mentors/:id
: Retrieves data for a specific mentor based on their ID.
This setup ensures that only authenticated users (with appropriate admin privileges for certain actions) can interact with the user management functionalities.
Mentorship Session Management
Let's create the session model:
// models/sessionModel.js
import db = from '../config/db';
// Create a new session request
export const createSession = async (mentorId, menteeId, questions) => {
const query = `
INSERT INTO sessions (mentor_id, mentee_id, questions)
VALUES ($1, $2, $3)
RETURNING *
`;
const values = [mentorId, menteeId, questions];
const result = await db.query(query, values);
return result.rows[0];
};
// Get all sessions for a user (as mentee)
export const getSessionsByMenteeId = async (menteeId) => {
const query = `
SELECT s.*,
u.first_name as mentor_first_name,
u.last_name as mentor_last_name
FROM sessions s
JOIN users u ON s.mentor_id = u.id
WHERE s.mentee_id = $1
ORDER BY s.created_at DESC
`;
const result = await db.query(query, [menteeId]);
return result.rows;
};
// Get all sessions for a user (as mentor)
export const getSessionsByMentorId = async (mentorId) => {
const query = `
SELECT s.*,
u.first_name as mentee_first_name,
u.last_name as mentee_last_name
FROM sessions s
JOIN users u ON s.mentee_id = u.id
WHERE s.mentor_id = $1
ORDER BY s.created_at DESC
`;
const result = await db.query(query, [mentorId]);
return result.rows;
};
// Get session by ID
export const getSessionById = async (sessionId) => {
const query = `
SELECT s.*,
u_mentee.first_name as mentee_first_name,
u_mentee.last_name as mentee_last_name,
u_mentor.first_name as mentor_first_name,
u_mentor.last_name as mentor_last_name
FROM sessions s
JOIN users u_mentee ON s.mentee_id = u_mentee.id
JOIN users u_mentor ON s.mentor_id = u_mentor.id
WHERE s.id = $1
`;
const result = await db.query(query, [sessionId]);
return result.rows[0];
};
// Update session status
export const updateSessionStatus = async (sessionId, status) => {
const query = `
UPDATE sessions
SET status = $1
WHERE id = $2
RETURNING *
`;
const result = await db.query(query, [status, sessionId]);
return result.rows[0];
};
Now let's create the session controller:
This session controller handles creating, accepting, and declining mentoring sessions. Here's a breakdown of its functionality:The session controller manages mentoring sessions, including creating, accepting, and declining requests.
Create Session Request: Allows a mentee to request a session with a mentor, checks if the mentor exists, and ensures the mentee isn’t requesting a session with themselves.
Get User Sessions: Retrieves all sessions for the logged-in user, grouped by their role (mentee or mentor).
Accept Session: Mentors can accept a session, with checks to verify the session is valid and hasn’t already been handled.
Decline Session: Mentors can decline a session, similar to the "accept" flow but updates the session status to "declined".
Each method includes error handling with appropriate responses.
// controllers/sessionController.js
import sessionModel = from '../models/sessionModel';
import userModel = from '../models/userModel';
// Create session request
export const createSessionRequest = async (req, res, next) => {
try {
const menteeId = req.user.id;
const { mentorId, questions } = req.body;
// Check if mentor exists
const mentor = await userModel.getMentorById(mentorId);
if (!mentor) {
return res.status(404).json({ status: 'error', message: 'Mentor not found' });
}
// Check if user is trying to request session with themselves
if (mentorId === menteeId) {
return res.status(400).json({ status: 'error', message: 'You cannot request a session with yourself' });
}
const session = await sessionModel.createSession(mentorId, menteeId, questions);
res.status(201).json({
status: 'success',
message: 'Session request created successfully',
data: session
});
} catch (error) {
next(error);
}
};
// Get all user sessions
export const getUserSessions = async (req, res, next) => {
try {
const userId = req.user.id;
// Get sessions where user is mentee
const menteeSessions = await sessionModel.getSessionsByMenteeId(userId);
// Get sessions where user is mentor (if applicable)
let mentorSessions = [];
if (req.user.role === 'mentor') {
mentorSessions = await sessionModel.getSessionsByMentorId(userId);
}
res.status(200).json({
status: 'success',
data: {
asMentee: menteeSessions,
asMentor: mentorSessions
}
});
} catch (error) {
next(error);
}
};
// Accept session request (mentor only)
export const acceptSession = async (req, res, next) => {
try {
const mentorId = req.user.id;
const sessionId = parseInt(req.params.id);
// Get session
const session = await sessionModel.getSessionById(sessionId);
if (!session) {
return res.status(404).json({ status: 'error', message: 'Session not found' });
}
// Check if user is the mentor for this session
if (session.mentor_id !== mentorId) {
return res.status(403).json({ status: 'error', message: 'You can only accept sessions where you are the mentor' });
}
// Check if session is already accepted/declined
if (session.status !== 'pending') {
return res.status(400).json({ status: 'error', message: `Session is already ${session.status}` });
}
// Update session status
const updatedSession = await sessionModel.updateSessionStatus(sessionId, 'accepted');
res.status(200).json({
status: 'success',
message: 'Session accepted successfully',
data: updatedSession
});
} catch (error) {
next(error);
}
};
// Decline session request (mentor only)
export const declineSession = async (req, res, next) => {
try {
const mentorId = req.user.id;
const sessionId = parseInt(req.params.id);
// Get session
const session = await sessionModel.getSessionById(sessionId);
if (!session) {
return res.status(404).json({ status: 'error', message: 'Session not found' });
}
// Check if user is the mentor for this session
if (session.mentor_id !== mentorId) {
return res.status(403).json({ status: 'error', message: 'You can only decline sessions where you are the mentor' });
}
// Check if session is already accepted/declined
if (session.status !== 'pending') {
return res.status(400).json({ status: 'error', message: `Session is already ${session.status}` });
}
// Update session status
const updatedSession = await sessionModel.updateSessionStatus(sessionId, 'declined');
res.status(200).json({
status: 'success',
message: 'Session declined successfully',
data: updatedSession
});
} catch (error) {
next(error);
}
};
Testing Your API with Postman
To test the functionality of your API, follow these steps using Postman:
Install Postman: Download and install Postman from here.
Testing User Sign-Up
Method:
POST
Body: Raw JSON data (e.g., for user creation):
{ "firstName": "John", "lastName": "Doe", "email": "john.doe@example.com", "password": "password123", "address": "123 Street", "bio": "A short bio", "occupation": "Developer", "expertise": "Node.js" }
Testing User Login
Method:
POST
Body: Raw JSON data for login:
{ "email": "john.doe@example.com", "password": "password123" }
- This returns a JWT token which you’ll use to authenticate further requests.
Testing Protected Routes
Method:
GET
Headers: Add Authorization as
Bearer YOUR_JWT_TOKEN
.
Final Thoughts
This tutorial provided a step-by-step guide to building an API that handles mentorship sessions using raw SQL queries and PostgreSQL, without relying on ORMs. By the end of it, you should have a working API with the following functionalities:
User registration and login
Admin role management
Mentor-mentee session requests
Review system
By following this guide, you’ve learned how to work with raw SQL in a Node.js app, while structuring a scalable backend that avoids the need for ORMs. Happy coding!
Subscribe to my newsletter
Read articles from Glory Alphonsus directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
