diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..77b93f8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["manushamadubhashini"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d466f5 --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +# Build a Simple Task Management API + +A simple RESTful API for managing tasks built with Node.js, Express.js, and MongoDB. + +## Features + +- Create, read, update and delete tasks +- Input validation with express-validator +- MongoDB database integration +- Proper error handling and status codes +- Postman collection included + +## Tech Stack + +- **Backend**: Node.js, Express.js +- **Database**: MongoDB, Mongoose +- **Validation**: Express Validator +- **Environment**: dotenv + +## Prerequisites + +- Node.js (v14+) +- MongoDB +- Postman + +## Installation + +1. **Clone the repository** +```bash +git clone git@github.com:manushamadubhashini/task-api.git +cd task-api +``` + +2. **Install dependencies** +```bash +npm install express mongoose express-validator dotenv +``` + +3. **Create `.env` file** +```env +PORT=3000 +MONGODB_URI=mongodb://localhost:27017/taskmanagement +NODE_ENV=development +``` + + +5. **Run the application** +```bash +npm run dev +``` + +Server will run at `http://localhost:3000` + +## Project Structure + +``` +task-api/ +├── postman/ +│ └── Task Management.postman_collection.json +├── src/ +│ ├── config/ +│ │ └── dbConnection.js +│ ├── controllers/ +│ │ └── taskController.js +│ ├── dto/ +│ │ └── taskDTO.js +│ ├── middleware/ +│ │ ├── errorHandler.js +│ │ └── taskValidator.js +│ ├── models/ +│ │ └── taskModel.js +│ ├── routes/ +│ │ └── taskRoute.js +│ ├── services/ +│ │ └── taskService.js +│ ├── utils/ +│ │ └── customError.js +│ ├── app.js +│ └── index.js +├── .env +├── .gitignore +├── package.json +└── README.md +``` + +## API Endpoints + +Base URL: `http://localhost:3000/api/v1/tasks` + +### 1. Create Task +**POST** `/create` + +Request: +```json +{ + "title" : "Task 5", + "description" : "This is task 5", + "status" : "pending" +} +``` + +Response (201): +```json +{ + "message": "Task created successfully", + "data": { + "id": "T005", + "title": "Task 5", + "description": "This is task 5", + "status": "pending", + "_id": "6909c995b7a10bcb07015c3f", + "createdAt": "2025-11-04T09:38:29.632Z", + "updatedAt": "2025-11-04T09:38:29.632Z", + "__v": 0 + } +} +``` + +### 2. Get All Tasks +**GET** `/getAll` + +Response (200): +```json +{ + "message": "Tasks fetched successfully", + "data": [ + { + "_id": "6909c995b7a10bcb07015c3f", + "id": "T005", + "title": "Task 5", + "description": "This is task 5", + "status": "pending", + "createdAt": "2025-11-04T09:38:29.632Z", + "updatedAt": "2025-11-04T09:38:29.632Z", + "__v": 0 + } + ] +} +``` + +### 3. Get Task by ID +**GET** `/get/:id` + +Response (200): +```json +{ + "message": "Task fetched successfully", + "data": { + "_id": "6909c995b7a10bcb07015c3f", + "id": "T005", + "title": "Task 5", + "description": "This is task 5", + "status": "pending", + "createdAt": "2025-11-04T09:38:29.632Z", + "updatedAt": "2025-11-04T09:38:29.632Z", + "__v": 0 + } +} +``` + +### 4. Delete Task +**DELETE** `/delete/:id` + +Response (200): +```json +{ + "message": "Task deleted successfully", + "data": { + "_id": "69098178f4cdd31945052ef5", + "id": "T002", + "title": "task3", + "description": "this is a task 3", + "status": "pending", + "createdAt": "2025-11-04T04:30:48.107Z", + "updatedAt": "2025-11-04T06:45:04.058Z", + "__v": 0 + } +} +``` + +## Testing with Postman + +1. Open Postman +2. Click **Import** +3. Select `postman/Task Management.postman_collection.json` +4. Run the requests + +## Troubleshooting + +**MongoDB Connection Error** +- Ensure MongoDB is running: `mongod` +- Check `MONGODB_URI` in `.env` + +## Author + +Manusha Madubhashini diff --git a/package.json b/package.json index 1943d9f..276f24c 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,10 @@ "name": "task-api", "version": "1.0.0", "main": "index.js", + "type": "module", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "nodemon src/index.js" }, "keywords": [], "author": "", diff --git a/postman/Task Management.postman_collection.json b/postman/Task Management.postman_collection.json new file mode 100644 index 0000000..82089e9 --- /dev/null +++ b/postman/Task Management.postman_collection.json @@ -0,0 +1,119 @@ +{ + "info": { + "_postman_id": "4560299f-17ae-44f4-9a79-3f0975ee760e", + "name": "Task Management", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "40188628" + }, + "item": [ + { + "name": "New Request", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"title\" : \"task3\",\r\n \"description\" : \"this is task 3\",\r\n \"status\" : \"pending\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:3000/api/v1/tasks/create", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "v1", + "tasks", + "create" + ] + } + }, + "response": [] + }, + { + "name": "New Request", + "request": { + "auth": { + "type": "bearer" + }, + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "http://localhost:3000/api/v1/tasks/delete/T001", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "v1", + "tasks", + "delete", + "T001" + ] + } + }, + "response": [] + }, + { + "name": "New Request", + "request": { + "method": "GET", + "header": [] + }, + "response": [] + }, + { + "name": "New Request", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"title\" : \"task3\",\r\n \"description\" : \"this is a task 3\",\r\n \"status\" : \"pending\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:3000/api/v1/tasks/update/T002", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "v1", + "tasks", + "update", + "T002" + ] + } + }, + "response": [] + }, + { + "name": "New Request", + "request": { + "method": "GET", + "header": [] + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..6309676 --- /dev/null +++ b/src/app.js @@ -0,0 +1,17 @@ +import express from "express"; +import { taskRouter } from "./routes/taskRoute.js"; +import { ErrorHandler } from "./middleware/errorhandler.js"; + +const app = express(); + +// middleware +app.use(express.json()); + +// routes +app.use("/api/v1/tasks" , taskRouter) + +// Error handling middleware +app.use(ErrorHandler); + + +export default app; \ No newline at end of file diff --git a/src/controller.js b/src/controller.js deleted file mode 100644 index b45e9f8..0000000 --- a/src/controller.js +++ /dev/null @@ -1,5 +0,0 @@ -export const myName = async () =>{ - const a = "John Doe"; - return a; - -} \ No newline at end of file diff --git a/src/controllers/taskController.js b/src/controllers/taskController.js new file mode 100644 index 0000000..a00dcb2 --- /dev/null +++ b/src/controllers/taskController.js @@ -0,0 +1,73 @@ +import { request , response } from "express" +import * as taskService from "../services/taskService.js" +import { CustomError } from "../utils/customError.js"; + +console.log("CustomError imported:", CustomError); + + +export const getAllTask = async (request , response , next) =>{ + try { + const tasks = await taskService.getAllTasks(); + response.status(200).json({message : "Tasks fetched successfully", data : tasks}); + + }catch (error) { + next(error) + } +} + +export const createTask = async (request , response , next) => { + try { + const newTask = request.body; + const createdTask = await taskService.createTask(newTask) + response.status(201).json({message : "Task created successfully" , data : createdTask}); + + }catch (error) { + next(error); + } +} + +export const deleteTask = async (request , response , next) => { + try { + const taskId = request.params.id; + const deletedTask = await taskService.deleteTaskById(taskId); + + if(!deletedTask) { + return next(new CustomError(`Task not found with ID: ${taskId}`, 404)); + } + + return response.status(200).json({message : "Task deleted successfully", data : deletedTask}); + + }catch (error) { + next(error); + } +} + +export const updateTask = async (request , response , next) => { + try { + const taskId = request.params.id; + const taskData = request.body; + const updatedTask = await taskService.updateTaskById(taskId,taskData); + if (!updatedTask) { + return next(new CustomError(`Task not found with ID: ${taskId}`, 404)); + } + + return response.status(200).json({message : "Task updated successfully", data : updatedTask}) + + }catch (error) { + next(error); + } +} + +export const getTaskById = async (request , response , next) => { + try { + const taskId = request.params.id; + const task = await taskService.getTaskById(taskId); + + if(!task){ + return next (new CustomError(`Task not found with ID: ${taskId}` , 404)); + } + return response.status(200).json({message : "Task fetched successfully" , data : task}); + }catch (error) { + next(error); + } +} \ No newline at end of file diff --git a/src/dto/taskDto.js b/src/dto/taskDto.js new file mode 100644 index 0000000..70d6ffb --- /dev/null +++ b/src/dto/taskDto.js @@ -0,0 +1,10 @@ +import { status } from "express/lib/response"; +import { title } from "process"; + +export const TaskDto ={ + id : String, + title : String, + description : String, + status : String, + +} \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..01c89f3 --- /dev/null +++ b/src/index.js @@ -0,0 +1,11 @@ +import {DBConnection} from "./config/dbConnection.js"; +import app from "./app.js"; +import dotenv from "dotenv"; + +dotenv.config(); + + +const port = process.env.PORT || 3000; +DBConnection().then(result => console.log(result)); + +app.listen(port,() => console.log(`server is running on port ${port}`)); diff --git a/src/middleware/errorhandler.js b/src/middleware/errorhandler.js new file mode 100644 index 0000000..c0713e4 --- /dev/null +++ b/src/middleware/errorhandler.js @@ -0,0 +1,47 @@ +export const ErrorHandler = (err, req, res, next) => { + // Default to a 500 Internal Server Error + let statusCode = err.statusCode || 500; + let message = err.message || 'Internal Server Error'; + let data = err.data || null; + + // Log error for debugging + console.log("Error caught in handler:"); + console.log("- Type:", err.constructor.name); + console.log("- Message:", err.message); + console.log("- StatusCode:", err.statusCode); + + // 1. Handle CustomError (HIGHEST PRIORITY) + if (err.name === 'CustomError' || err.statusCode) { + statusCode = err.statusCode; + message = err.message; + } + // 2. Handle Mongoose CastError (e.g., invalid ID format) + else if (err.name === 'CastError' && err.kind === 'ObjectId') { + statusCode = 404; + message = `Resource not found with ID of ${err.value}`; + } + // 3. Handle Mongoose Duplicate Key Error + else if (err.code === 11000) { + statusCode = 400; + message = `Duplicate field value entered: ${Object.keys(err.keyValue)}`; + } + // 4. Handle Mongoose Validation Error + else if (err.name === 'ValidationError') { + statusCode = 400; + message = 'Mongoose validation failed'; + data = Object.values(err.errors).map(val => ({ [val.path]: val.message })); + } + + // Send the standardized error response + res.status(statusCode).json({ + success: false, + error: { + message: message, + code: statusCode, + // Only include 'data' if it exists + ...(data && { details: data }), + // Optional: Include stack trace ONLY in development + ...(process.env.NODE_ENV === 'development' && { stack: err.stack }), + }, + }); +}; \ No newline at end of file diff --git a/src/middleware/taskValidator.js b/src/middleware/taskValidator.js new file mode 100644 index 0000000..b391842 --- /dev/null +++ b/src/middleware/taskValidator.js @@ -0,0 +1,106 @@ +import { CustomError } from "../utils/customError.js"; + +// Validate Create Task +export const validateCreateTask = (req, res, next) => { + const { title, description, status } = req.body; + const errors = []; + + // Check required fields + if (!title || title.trim() === "") { + errors.push("Title is required"); + } + + // Validate title length + if (title && title.trim().length < 3) { + errors.push("Title must be at least 3 characters long"); + } + + if (title && title.length > 100) { + errors.push("Title must not exceed 100 characters"); + } + + // Validate description (optional but if provided, check length) + if (description && description.length > 500) { + errors.push("Description must not exceed 500 characters"); + } + + // Validate status (if provided) - matches model enum + const validStatuses = ["pending", "in-progress", "completed"]; + if (status && !validStatuses.includes(status)) { + errors.push(`Status must be one of: ${validStatuses.join(", ")}`); + } + + // If there are validation errors, throw CustomError + if (errors.length > 0) { + return next(new CustomError(errors.join("; "), 400)); + } + + next(); +}; + +// Validate Update Task +export const validateUpdateTask = (req, res, next) => { + const { title, description, status } = req.body; + const errors = []; + + // Check if at least one field is provided for update + if (title === undefined && description === undefined && status === undefined) { + return next(new CustomError("At least one field (title, description, status) is required for update", 400)); + } + + // Validate title if provided + if (title !== undefined) { + if (typeof title !== "string" || title.trim() === "") { + errors.push("Title cannot be empty"); + } else { + if (title.trim().length < 3) { + errors.push("Title must be at least 3 characters long"); + } + if (title.length > 100) { + errors.push("Title must not exceed 100 characters"); + } + } + } + + // Validate description if provided + if (description !== undefined) { + if (typeof description !== "string") { + errors.push("Description must be a string"); + } else if (description.length > 500) { + errors.push("Description must not exceed 500 characters"); + } + } + + // Validate status if provided - matches model enum + const validStatuses = ["pending", "in-progress", "completed"]; + if (status !== undefined && !validStatuses.includes(status)) { + errors.push(`Status must be one of: ${validStatuses.join(", ")}`); + } + + // If there are validation errors, throw CustomError + if (errors.length > 0) { + return next(new CustomError(errors.join("; "), 400)); + } + + next(); +}; + +// Validate Task ID parameter +export const validateTaskId = (req, res, next) => { + const { id } = req.params; + + if (!id) { + return next(new CustomError("Task ID is required", 400)); + } + + // Check if it's a valid custom ID format (T001, T002, etc.) or MongoDB ObjectId + const customIdPattern = /^T\d{3,}$/; + const isValidCustomId = customIdPattern.test(id); + const isValidObjectId = /^[a-f\d]{24}$/i.test(id); + + if (!isValidCustomId && !isValidObjectId) { + return next(new CustomError("Invalid Task ID format. Expected: T001 or valid MongoDB ObjectId", 400)); + } + + next(); +}; \ No newline at end of file diff --git a/src/models/taskModel.js b/src/models/taskModel.js index 453025b..654ec1b 100644 --- a/src/models/taskModel.js +++ b/src/models/taskModel.js @@ -3,16 +3,23 @@ import mongoose from "mongoose"; const taskModel = mongoose.Schema({ id : { type : String, - required : true + required : true, + unique : true }, title : { type : String, - required : true + trim : true, + required : [true , "title is required"], + minlength : [3 , "title must be at least 3 characters long"], + maxlength : [100 , "title must be at most 100 characters long"] }, description : { type : String, + trim : true, + maxlength : [500 , "description must be at most 500 characters long"], + default : "" }, @@ -20,7 +27,13 @@ const taskModel = mongoose.Schema({ type : String, enum : ["pending", "in-progress", "completed"], default : "pending", - required : true - }} + required : [true , "status is required"] + }}, + + { + timestamps : true -}) \ No newline at end of file + } +); + +export default mongoose.model("Tasks", taskModel) \ No newline at end of file diff --git a/src/routes/taskRoute.js b/src/routes/taskRoute.js new file mode 100644 index 0000000..e400e96 --- /dev/null +++ b/src/routes/taskRoute.js @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { getAllTask,createTask,deleteTask,updateTask, getTaskById } from "../controllers/taskController.js"; +import { validateCreateTask, validateTaskId, validateUpdateTask } from "../middleware/taskValidator.js"; + +export const taskRouter = Router(); +taskRouter.get("/getAll" , getAllTask); +taskRouter.post("/create" , validateCreateTask,createTask); +taskRouter.delete("/delete/:id" , validateTaskId , deleteTask); +taskRouter.put("/update/:id" , validateTaskId , validateUpdateTask , updateTask); +taskRouter.get("/get/:id" ,validateTaskId , getTaskById); \ No newline at end of file diff --git a/src/services/taskService.js b/src/services/taskService.js new file mode 100644 index 0000000..933b21e --- /dev/null +++ b/src/services/taskService.js @@ -0,0 +1,30 @@ +import taskModel from "../models/taskModel.js"; + +export const getAllTasks = async () => { + return await taskModel.find(); +}; + +export const createTask = async (task) => { + // Generate a unique ID for the new task + const lastTask = await taskModel.findOne().sort({ id: -1 }); + let newId = "T001"; + if (lastTask && lastTask.id) { + const lastNum = parseInt(lastTask.id.replace("T", "")); + const nextNum = lastNum + 1; + newId = "T" + nextNum.toString().padStart(3, "0"); + } + const newTask = { ...task, id: newId }; + return await taskModel.create(newTask); +}; + +export const deleteTaskById = async (id) => { + return await taskModel.findOneAndDelete({id : id}); +}; + +export const updateTaskById = async (id, task) => { + return await taskModel.findOneAndUpdate({id:id},task,{new : true}) +}; + +export const getTaskById = async (id) => { + return await taskModel.findOne({id : id}); +} \ No newline at end of file diff --git a/src/utils/customError.js b/src/utils/customError.js new file mode 100644 index 0000000..7f14d56 --- /dev/null +++ b/src/utils/customError.js @@ -0,0 +1,8 @@ +export class CustomError extends Error { + constructor(message, statusCode) { + super(message); + this.statusCode = statusCode; + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} \ No newline at end of file