diff --git a/README.md b/README.md index 5adf5e3..1b5b081 100644 --- a/README.md +++ b/README.md @@ -99,3 +99,4 @@ base url: `http://localhost:3000` ### Task - Create a new task: `POST /api/organization/:organizationId/team/:teamId/project/:projectId/task/create` +- Update a task: `PUT /api/organization/:organizationId/team/:teamId/project/:projectId/task/:taskId` diff --git a/src/controllers/task.controller.js b/src/controllers/task.controller.js index 2c6e31c..2ee2373 100644 --- a/src/controllers/task.controller.js +++ b/src/controllers/task.controller.js @@ -1,5 +1,8 @@ import prisma from '../config/prismaClient.js'; -import { createTaskValidation } from '../validations/task.validation.js'; +import { + createTaskValidation, + updateTaskValidation, +} from '../validations/task.validation.js'; /** * Helper function to validate required params @@ -357,3 +360,188 @@ export const createTask = async (req, res, next) => { next(error); } }; + +/** + * @desc Update a task + * @route /api/organization/:organizationId/team/:teamId/project/:projectId/task/:taskId + * @method PUT + * @access private + */ +export const updateTask = async (req, res, next) => { + try { + const { organizationId, teamId, projectId, taskId } = req.params; + const { + title, + description, + priority, + sprintId, + assignedTo, + dueDate, + estimatedTime, + parentId, + labels, + } = req.body; + + const user = req.user; + + const { error } = updateTaskValidation(req.body); + if (error) { + return res.status(400).json({ message: error.details[0].message }); + } + + // Check if organization exists + const orgCheck = await checkOrganization(organizationId); + if (!orgCheck.success) { + return res.status(404).json({ + success: false, + message: orgCheck.message, + }); + } + + // Check if team exists + const teamCheck = await checkTeam(teamId, organizationId); + if (!teamCheck.success) { + return res.status(404).json({ + success: false, + message: teamCheck.message, + }); + } + + // Check if project exists + const projectCheck = await checkProject(projectId, teamId, organizationId); + if (!projectCheck.success) { + return res.status(404).json({ + success: false, + message: projectCheck.message, + }); + } + + // Check if task exists and belongs to the project + const task = await prisma.task.findFirst({ + where: { + id: taskId, + projectId, + deletedAt: null, + }, + }); + + if (!task) { + return res.status(404).json({ + success: false, + message: 'Task not found or does not belong to the specified project', + }); + } + + // Check task permissions + const permissionsCheck = checkTaskPermissions( + user, + orgCheck.organization, + teamCheck.team, + projectCheck.project, + 'update', + ); + + if (!permissionsCheck.success) { + return res.status(403).json({ + success: false, + message: permissionsCheck.message, + }); + } + + // If sprintId is provided, verify the sprint exists and belongs to the project + if (sprintId && sprintId !== task.sprintId) { + const sprint = await prisma.sprint.findFirst({ + where: { + id: sprintId, + projectId, + }, + }); + + if (!sprint) { + return res.status(404).json({ + success: false, + message: + 'Sprint not found or does not belong to the specified project', + }); + } + } + + // If parentId is provided, verify the parent task exists, belongs to the project, and is not the task itself + if (parentId && parentId !== task.parentId) { + if (parentId === taskId) { + return res.status(400).json({ + success: false, + message: 'A task cannot be its own parent', + }); + } + + const parentTask = await prisma.task.findFirst({ + where: { + id: parentId, + projectId, + deletedAt: null, + }, + }); + + if (!parentTask) { + return res.status(404).json({ + success: false, + message: + 'Parent task not found or does not belong to the specified project', + }); + } + + // Check for circular dependencies + let currentParent = parentId; + while (currentParent) { + const parent = await prisma.task.findUnique({ + where: { id: currentParent }, + select: { parentId: true }, + }); + + if (!parent) { + return res.status(404).json({ + success: false, + message: 'Parent task not found during circular dependency check', + }); + } + + if (parent.parentId === taskId) { + return res.status(400).json({ + success: false, + message: 'Circular dependency detected in task hierarchy', + }); + } + + currentParent = parent.parentId; + } + } + + // Update the task + const updatedTask = await prisma.task.update({ + where: { id: taskId }, + data: { + title: title !== undefined ? title : task.title, + description: description !== undefined ? description : task.description, + priority: priority !== undefined ? priority : task.priority, + sprintId: sprintId !== undefined ? sprintId : task.sprintId, + assignedTo: assignedTo !== undefined ? assignedTo : task.assignedTo, + dueDate: dueDate !== undefined ? new Date(dueDate) : task.dueDate, + estimatedTime: + estimatedTime !== undefined ? estimatedTime : task.estimatedTime, + parentId: parentId !== undefined ? parentId : task.parentId, + labels: labels !== undefined ? labels : task.labels, + updatedAt: new Date(), + lastModifiedBy: user.id, + }, + }); + + return res.status(200).json({ + success: true, + message: 'Task updated successfully', + task: updatedTask, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/docs/swagger.json b/src/docs/swagger.json index b496d01..322d3b3 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -4669,6 +4669,220 @@ } } } + }, + "/api/organization/{organizationId}/team/{teamId}/project/{projectId}/task/{taskId}": { + "put": { + "tags": ["Task"], + "summary": "Update a task", + "description": "Updates an existing task within a specific project, team, and organization. Requires appropriate permissions.", + "security": [{ "BearerAuth": [] }], + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "description": "ID of the organization", + "schema": { "type": "string" } + }, + { + "name": "teamId", + "in": "path", + "required": true, + "description": "ID of the team", + "schema": { "type": "string" } + }, + { + "name": "projectId", + "in": "path", + "required": true, + "description": "ID of the project", + "schema": { "type": "string" } + }, + { + "name": "taskId", + "in": "path", + "required": true, + "description": "ID of the task to update", + "schema": { "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Updated title of the task", + "example": "Updated: Implement user authentication" + }, + "description": { + "type": "string", + "description": "Updated description of the task", + "example": "Updated: Implement JWT based authentication for the API" + }, + "priority": { + "type": "string", + "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"], + "description": "Updated priority level of the task", + "example": "MEDIUM" + }, + "sprintId": { + "type": "string", + "description": "Updated ID of the sprint this task belongs to", + "example": "cln3k7vxp0000v2k0q2q3k4k5" + }, + "assignedTo": { + "type": "string", + "description": "Updated ID of the user this task is assigned to", + "example": "cln3k7vxp0000v2k0q2q3k4k5" + }, + "dueDate": { + "type": "string", + "format": "date-time", + "description": "Updated due date for the task", + "example": "2024-01-15T23:59:59Z" + }, + "estimatedTime": { + "type": "number", + "description": "Updated estimated time to complete the task in hours", + "example": 12 + }, + "parentId": { + "type": "string", + "description": "Updated ID of the parent task if this is a subtask", + "example": "cln3k7vxp0000v2k0q2q3k4k5" + }, + "labels": { + "type": "array", + "items": { "type": "string" }, + "description": "Updated array of labels for the task", + "example": ["backend", "authentication", "security"] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Task updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "message": { + "type": "string", + "example": "Task updated successfully" + }, + "task": { "$ref": "#/components/schemas/Task" } + } + } + } + } + }, + "400": { + "description": "Validation error or circular dependency detected", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": false }, + "message": { + "oneOf": [ + { + "type": "string", + "example": "\"priority\" must be one of [LOW, MEDIUM, HIGH, CRITICAL]" + }, + { + "type": "string", + "example": "A task cannot be its own parent" + }, + { + "type": "string", + "example": "Circular dependency detected in task hierarchy" + } + ] + } + } + } + } + } + }, + "403": { + "description": "Forbidden - insufficient permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": false }, + "message": { + "type": "string", + "example": "You don't have permission to update tasks in this project" + } + } + } + } + } + }, + "404": { + "description": "Not found - organization, team, project, task, sprint or parent task not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": false }, + "message": { + "oneOf": [ + { + "type": "string", + "example": "Organization not found" + }, + { + "type": "string", + "example": "Task not found or does not belong to the specified project" + }, + { + "type": "string", + "example": "Sprint not found or does not belong to the specified project" + }, + { + "type": "string", + "example": "Parent task not found or does not belong to the specified project" + } + ] + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": false }, + "message": { + "type": "string", + "example": "Internal server error" + } + } + } + } + } + } + } + } } }, diff --git a/src/routes/task.routes.js b/src/routes/task.routes.js index e6d9380..8e283f3 100644 --- a/src/routes/task.routes.js +++ b/src/routes/task.routes.js @@ -1,6 +1,6 @@ import { Router } from 'express'; import { verifyAccessToken } from '../middlewares/auth.middleware.js'; -import { createTask } from '../controllers/task.controller.js'; +import { createTask, updateTask } from '../controllers/task.controller.js'; const router = Router(); @@ -10,4 +10,10 @@ router.post( createTask, ); +router.put( + '/api/organization/:organizationId/team/:teamId/project/:projectId/task/:taskId', + verifyAccessToken, + updateTask, +); + export default router; diff --git a/src/validations/task.validation.js b/src/validations/task.validation.js index 98fdb4a..7ead840 100644 --- a/src/validations/task.validation.js +++ b/src/validations/task.validation.js @@ -57,3 +57,58 @@ export const createTaskValidation = (obj) => { return schema.validate(obj, { abortEarly: false }); }; + +export const updateTaskValidation = (obj) => { + const schema = Joi.object({ + title: Joi.string().trim().min(3).max(200).optional().messages({ + 'string.base': 'Task title must be a string', + 'string.empty': 'Task title cannot be empty', + 'string.min': 'Task title must be at least 3 characters long', + 'string.max': 'Task title cannot exceed 200 characters', + }), + description: Joi.string().trim().allow('', null).max(5000).messages({ + 'string.base': 'Task description must be a string', + 'string.max': 'Task description cannot exceed 5000 characters', + }), + priority: Joi.string().valid('HIGH', 'MEDIUM', 'LOW').optional().messages({ + 'string.base': 'Priority must be a string', + 'any.only': 'Priority must be one of: HIGH, MEDIUM, LOW', + }), + sprintId: Joi.string().uuid().allow(null).messages({ + 'string.base': 'Sprint ID must be a string', + 'string.guid': 'Sprint ID must be a valid UUID', + }), + assignedTo: Joi.string().uuid().allow(null).messages({ + 'string.base': 'Assigned user ID must be a string', + 'string.guid': 'Assigned user ID must be a valid UUID', + }), + dueDate: Joi.date().iso().optional().messages({ + 'date.base': 'Due date must be a valid date', + 'date.format': 'Due date must be in ISO format', + }), + estimatedTime: Joi.number().positive().allow(null).messages({ + 'number.base': 'Estimated time must be a number', + 'number.positive': 'Estimated time must be a positive number', + }), + parentId: Joi.string().uuid().allow(null).messages({ + 'string.base': 'Parent task ID must be a string', + 'string.guid': 'Parent task ID must be a valid UUID', + }), + labels: Joi.array().items(Joi.string().trim()).default([]).messages({ + 'array.base': 'Labels must be an array', + 'string.base': 'Each label must be a string', + }), + // Additional fields if needed + rate: Joi.number().positive().allow(null).messages({ + 'number.base': 'Rate must be a number', + 'number.positive': 'Rate must be a positive number', + }), + order: Joi.number().integer().min(0).default(0).messages({ + 'number.base': 'Order must be a number', + 'number.integer': 'Order must be an integer', + 'number.min': 'Order must be a non-negative number', + }), + }); + + return schema.validate(obj, { abortEarly: false }); +};