diff --git a/.env.example b/.env.example deleted file mode 100644 index e0c86ac..0000000 --- a/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -PORT=4000 -DB_HOST=localhost -DB_NAME=taskmanager -DB_USER=postgres -DB_PASS=postgres -JWT_SECRET=supersecretkey diff --git a/package-lock.json b/package-lock.json index 2b4ee5b..8cadc92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "1.0.0", "dependencies": { "bcrypt": "^5.1.1", + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "express-rate-limit": "^8.2.1", "express-validator": "^7.2.1", "jsonwebtoken": "^9.0.2", "pg": "^8.13.1", @@ -2201,6 +2203,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -2821,6 +2836,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-validator": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", @@ -3468,6 +3501,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index 9befbd1..ba173cf 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,10 @@ }, "dependencies": { "bcrypt": "^5.1.1", + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "express-rate-limit": "^8.2.1", "express-validator": "^7.2.1", "jsonwebtoken": "^9.0.2", "pg": "^8.13.1", @@ -29,4 +31,4 @@ "prettier": "^3.3.3", "supertest": "^7.1.1" } -} \ No newline at end of file +} diff --git a/src/app.js b/src/app.js index 2453aff..d4ae0a4 100644 --- a/src/app.js +++ b/src/app.js @@ -3,10 +3,12 @@ const app = express(); const authRoutes = require('./routes/authRoutes'); const taskRoutes = require('./routes/taskRoutes'); const errorHandler = require('./middleware/errorHandler'); +const cors = require('cors'); app.use(express.json()); +app.use(cors()); app.use('/api/auth', authRoutes); app.use('/api/tasks', taskRoutes); app.use(errorHandler); -module.exports = app; \ No newline at end of file +module.exports = app; diff --git a/src/controllers/authController.js b/src/controllers/authController.js index 92291a7..361c1b7 100644 --- a/src/controllers/authController.js +++ b/src/controllers/authController.js @@ -4,11 +4,14 @@ const authService = require('../services/authService'); exports.register = async (req, res, next) => { try { const errors = validationResult(req); - if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); - const { username, password } = req.body; - const user = await authService.register(username, password); + if (!errors.isEmpty()) + return res.status(400).json({ errors: errors.array() }); + const { username, password, role } = req.body; + const user = await authService.register(username, password, role); res.status(201).json({ message: 'User created', user }); - } catch (err) { next(err); } + } catch (err) { + next(err); + } }; exports.login = async (req, res, next) => { @@ -16,5 +19,7 @@ exports.login = async (req, res, next) => { const { username, password } = req.body; const token = await authService.login(username, password); res.json({ token }); - } catch (err) { next(err); } -}; \ No newline at end of file + } catch (err) { + next(err); + } +}; diff --git a/src/controllers/taskController.js b/src/controllers/taskController.js index f508afe..13dad9b 100644 --- a/src/controllers/taskController.js +++ b/src/controllers/taskController.js @@ -3,37 +3,103 @@ const { Task } = require('../models'); exports.getTasks = async (req, res, next) => { try { - const tasks = await Task.findAll({ where: { userId: req.user.id } }); - res.json(tasks); - } catch (err) { next(err); } + let { page = 1, limit = 10, status = 'pending', userId } = req.query; + + page = parseInt(page); + limit = parseInt(limit); + const offset = (page - 1) * limit; + + const where = {}; + + if (req.user.role === 'admin') { + if (userId) where.userId = userId; + } else { + where.userId = req.user.id; + } + + if (status) where.status = status; + + const [tasks, total] = await Promise.all([ + Task.findAll({ + where, + limit, + offset, + order: [['createdAt', 'DESC']], + attributes: [ + 'id', + 'title', + 'description', + 'status', + 'userId', + 'createdAt', + ], + }), + Task.count({ where }), + ]); + + res.status(200).json({ + page, + limit, + totalPages: Math.ceil(total / limit), + totalTasks: total, + tasks, + }); + } 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() }); + 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 }); res.status(201).json(task); - } catch (err) { next(err); } + } catch (err) { + next(err); + } }; exports.updateTask = async (req, res, next) => { try { const task = await Task.findByPk(req.params.id); 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 (task.userId !== req.user.id) + return res.status(403).json({ message: 'Forbidden' }); await task.update(req.body); res.json(task); - } catch (err) { next(err); } + } catch (err) { + next(err); + } }; exports.deleteTask = async (req, res, next) => { try { const task = await Task.findByPk(req.params.id); 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 (task.userId !== req.user.id) + return res.status(403).json({ message: 'Forbidden' }); await task.destroy(); res.json({ message: 'Task deleted' }); - } catch (err) { next(err); } -}; \ No newline at end of file + } catch (err) { + next(err); + } +}; + +exports.SoftDelete = async (req, res) => { + const task = await Task.findOne({ + where: { id: req.params.id, userId: req.user.id }, + }); + + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + task.status = 'deleted'; + task.deletedAt = new Date(); + await task.save(); + + res.json({ message: 'Task Deleted', task }); +}; diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js new file mode 100644 index 0000000..2c0ebfb --- /dev/null +++ b/src/middleware/rateLimit.js @@ -0,0 +1,11 @@ +const rateLimit = require('express-rate-limit'); + +exports.loginLimiter = rateLimit({ + windowMs: 10 * 60 * 1000, + max: 5, + message: { + error: 'Too many login attempts. Please try again in 10 minutes.', + }, + standardHeaders: true, + legacyHeaders: false, +}); diff --git a/src/models/task.js b/src/models/task.js index 374907c..1879ed0 100644 --- a/src/models/task.js +++ b/src/models/task.js @@ -6,12 +6,16 @@ const Task = sequelize.define('Task', { title: { type: DataTypes.STRING, allowNull: false }, description: { type: DataTypes.TEXT }, status: { - type: DataTypes.ENUM('pending', 'in-progress', 'done'), + type: DataTypes.ENUM('pending', 'in-progress', 'done', 'deleted'), defaultValue: 'pending', }, + deletedAt: { + type: DataTypes.DATE, + allowNull: true, + }, }); Task.belongsTo(User, { foreignKey: 'userId', onDelete: 'CASCADE' }); User.hasMany(Task, { foreignKey: 'userId' }); -module.exports = Task; \ No newline at end of file +module.exports = Task; diff --git a/src/models/user.js b/src/models/user.js index afebaad..0687dad 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -5,10 +5,15 @@ 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'), + allowNull: false, + defaultValue: 'user', + }, }); User.beforeCreate(async (user) => { user.password = await bcrypt.hash(user.password, 10); }); -module.exports = User; \ No newline at end of file +module.exports = User; diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js index 34b61e8..2a9c9fc 100644 --- a/src/routes/authRoutes.js +++ b/src/routes/authRoutes.js @@ -2,7 +2,12 @@ const express = require('express'); const { body } = require('express-validator'); const router = express.Router(); const authController = require('../controllers/authController'); +const { loginLimiter } = require('../middleware/rateLimit'); -router.post('/register', [body('username').notEmpty(), body('password').isLength({ min: 5 })], authController.register); -router.post('/login', authController.login); -module.exports = router; \ No newline at end of file +router.post( + '/register', + [body('username').notEmpty(), body('password').isLength({ min: 5 })], + authController.register +); +router.post('/login', loginLimiter, authController.login); +module.exports = router; diff --git a/src/routes/taskRoutes.js b/src/routes/taskRoutes.js index 264132a..11220fa 100644 --- a/src/routes/taskRoutes.js +++ b/src/routes/taskRoutes.js @@ -9,4 +9,5 @@ router.get('/', taskController.getTasks); router.post('/', [body('title').notEmpty()], taskController.createTask); router.put('/:id', taskController.updateTask); router.delete('/:id', taskController.deleteTask); -module.exports = router; \ No newline at end of file +router.delete('/softdelete/:id', taskController.SoftDelete); +module.exports = router; diff --git a/src/services/authService.js b/src/services/authService.js index 4865903..9ec26a4 100644 --- a/src/services/authService.js +++ b/src/services/authService.js @@ -2,21 +2,26 @@ const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const { User } = require('../models'); -exports.register = async (username, password) => { +exports.register = async (username, password, role = 'user') => { const existing = await User.findOne({ where: { username } }); if (existing) throw new Error('Username already exists'); - return await User.create({ username, password }); + const newUser = await User.create({ username, password, role }); + const { password: _, ...safeUser } = newUser.toJSON(); + return safeUser; }; exports.login = async (username, password) => { const user = await User.findOne({ where: { username } }); if (!user) throw new Error('User not found'); + const valid = await bcrypt.compare(password, user.password); if (!valid) throw new Error('Invalid credentials'); + const token = jwt.sign( - { id: user.id, username: user.username }, + { id: user.id, username: user.username, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h' } ); - return token; -}; \ No newline at end of file + + return { token, role: user.role }; +};