Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
596 changes: 591 additions & 5 deletions package-lock.json

Large diffs are not rendered by default.

17 changes: 14 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,30 @@
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "cross-env NODE_ENV=test jest --runInBand",
"test": "cross-env NODE_ENV=test jest --runInBand --setupFilesAfterEnv=./tests/setup.js",
"lint": "eslint .",
"format": "prettier --write ."
"format": "prettier --write .",
"migrate": "node src/migrations/add-role-and-soft-delete.js"
},
"jest": {
"testEnvironment": "node",
"testMatch": ["**/tests/**/*.test.js"]
},
"dependencies": {
"bcrypt": "^5.1.1",
"bullmq": "^5.20.0",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"express-rate-limit": "^7.4.1",
"express-validator": "^7.2.1",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"pg": "^8.13.1",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.3"
"sequelize": "^6.37.3",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"cross-env": "^7.0.3",
Expand Down
22 changes: 22 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
const express = require('express');
const morgan = require('morgan');
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./config/swagger');
const app = express();
const authRoutes = require('./routes/authRoutes');
const taskRoutes = require('./routes/taskRoutes');
const errorHandler = require('./middleware/errorHandler');

app.use(morgan('combined'));
app.use(express.json());

// Serve Swagger JSON
app.get('/api/docs.json', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});

// Serve Swagger UI
const swaggerOptions = {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'Task Manager API Documentation',
swaggerOptions: {
persistAuthorization: true,
},
};

app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions));

app.use('/api/auth', authRoutes);
app.use('/api/tasks', taskRoutes);
app.use(errorHandler);
Expand Down
43 changes: 43 additions & 0 deletions src/config/swagger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const swaggerJsdoc = require('swagger-jsdoc');
const path = require('path');
require('dotenv').config();

const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'Task Manager API',
version: '1.0.0',
description: 'A RESTful API for managing tasks with authentication, RBAC, and more',
},
servers: [
{
url: `http://localhost:${process.env.PORT || 4000}`,
description: 'Development server',
},
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
},
security: [
{
bearerAuth: [],
},
],
},
apis: [
path.join(__dirname, '../routes/*.js'),
path.join(__dirname, '../controllers/*.js'),
],
};

const swaggerSpec = swaggerJsdoc(options);

module.exports = swaggerSpec;

4 changes: 3 additions & 1 deletion src/controllers/authController.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ exports.register = async (req, res, next) => {
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const { username, password } = req.body;
const user = await authService.register(username, password);
res.status(201).json({ message: 'User created', user });
const safeUser = user.get ? user.get({ plain: true }) : { ...user };
delete safeUser.password;
res.status(201).json({ message: 'User created', user: safeUser });
} catch (err) { next(err); }
};

Expand Down
73 changes: 63 additions & 10 deletions src/controllers/taskController.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,92 @@
const { validationResult } = require('express-validator');
const { Task } = require('../models');
const { Task, User } = require('../models');
const { addEmailJob } = require('../services/queueService');

exports.getTasks = async (req, res, next) => {
try {
const tasks = await Task.findAll({ where: { userId: req.user.id } });
res.json(tasks);
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const status = req.query.status;
const offset = (page - 1) * limit;

const whereClause = { deletedAt: null };
if (req.user.role !== 'admin') {
whereClause.userId = req.user.id;
}
if (status) {
whereClause.status = status;
}

const { count, rows: tasks } = await Task.findAndCountAll({
where: whereClause,
limit,
offset,
order: [['createdAt', 'DESC']],
});

res.json({
tasks,
pagination: {
page,
limit,
total: count,
totalPages: Math.ceil(count / limit),
},
});
} catch (err) { next(err); }
};

exports.createTask = async (req, res, next) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const { title, description } = req.body;
const task = await Task.create({ title, description, userId: req.user.id });
const { title, description, status } = req.body;
const task = await Task.create({ title, description, status, userId: req.user.id });

// Send email notification in background
const user = await User.findByPk(req.user.id);
await addEmailJob({
to: user.username,
subject: 'Task Created',
body: `Your task "${title}" has been created successfully.`,
});

res.status(201).json(task);
} catch (err) { next(err); }
};

exports.updateTask = async (req, res, next) => {
try {
const task = await Task.findByPk(req.params.id);
const task = await Task.findOne({ where: { id: req.params.id, deletedAt: null } });
if (!task) return res.status(404).json({ message: 'Task not found' });
if (task.userId !== req.user.id) return res.status(403).json({ message: 'Forbidden' });
if (req.user.role !== 'admin' && task.userId !== req.user.id) {
return res.status(403).json({ message: 'Forbidden' });
}

const oldStatus = task.status;
await task.update(req.body);

if (req.body.status === 'done' && oldStatus !== 'done') {
const user = await User.findByPk(task.userId);
await addEmailJob({
to: user.username,
subject: 'Task Completed',
body: `Your task "${task.title}" has been marked as completed.`,
});
}

res.json(task);
} catch (err) { next(err); }
};

exports.deleteTask = async (req, res, next) => {
try {
const task = await Task.findByPk(req.params.id);
const task = await Task.findOne({ where: { id: req.params.id, deletedAt: null } });
if (!task) return res.status(404).json({ message: 'Task not found' });
if (task.userId !== req.user.id) return res.status(403).json({ message: 'Forbidden' });
await task.destroy();
if (req.user.role !== 'admin' && task.userId !== req.user.id) {
return res.status(403).json({ message: 'Forbidden' });
}
await task.update({ deletedAt: new Date() });
res.json({ message: 'Task deleted' });
} catch (err) { next(err); }
};
19 changes: 19 additions & 0 deletions src/middleware/rateLimiter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: 'Too many login attempts, please try again after 15 minutes',
standardHeaders: true,
legacyHeaders: false,
});

// const apiLimiter = rateLimit({
// windowMs: 15 * 60 * 1000,
// max: 100,
// standardHeaders: true,
// legacyHeaders: false,
// });

module.exports = { loginLimiter /*`, apiLimiter*/ };

3 changes: 3 additions & 0 deletions src/models/task.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const Task = sequelize.define('Task', {
type: DataTypes.ENUM('pending', 'in-progress', 'done'),
defaultValue: 'pending',
},
deletedAt: { type: DataTypes.DATE, allowNull: true },
}, {
paranoid: false, // We will handle soft deletes manually
});

Task.belongsTo(User, { foreignKey: 'userId', onDelete: 'CASCADE' });
Expand Down
4 changes: 4 additions & 0 deletions src/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ const bcrypt = require('bcrypt');
const User = sequelize.define('User', {
username: { type: DataTypes.STRING, allowNull: false, unique: true },
password: { type: DataTypes.STRING, allowNull: false },
role: {
type: DataTypes.ENUM('user', 'admin'),
defaultValue: 'user',
},
});

User.beforeCreate(async (user) => {
Expand Down
64 changes: 63 additions & 1 deletion src/routes/authRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,69 @@ const express = require('express');
const { body } = require('express-validator');
const router = express.Router();
const authController = require('../controllers/authController');
const { loginLimiter } = require('../middleware/rateLimiter');

/**
* @swagger
* /api/auth/register:
* post:
* summary: Register a new user
* tags: [Authentication]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - username
* - password
* properties:
* username:
* type: string
* password:
* type: string
* minLength: 5
* responses:
* 201:
* description: User created successfully
* 400:
* description: Validation error
*/
router.post('/register', [body('username').notEmpty(), body('password').isLength({ min: 5 })], authController.register);
router.post('/login', authController.login);

/**
* @swagger
* /api/auth/login:
* post:
* summary: Login user
* tags: [Authentication]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - username
* - password
* properties:
* username:
* type: string
* password:
* type: string
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* type: object
* properties:
* token:
* type: string
* 401:
* description: Invalid credentials
*/
router.post('/login', loginLimiter, authController.login);
module.exports = router;
Loading