From 422b97f78b44ed30920ee7008c7946ffb5ddfd3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=A2m=20Nh=E1=BA=ADt?= Date: Fri, 3 Oct 2025 11:33:25 +0700 Subject: [PATCH] feat: unassign member from task --- backend/app/controllers/TaskController.scala | 113 ++++++++++-------- .../task/AssignMemberToTaskResponse.scala | 2 +- .../dto/websocket/board/BoardMessage.scala | 1 + .../websocket/board/BoardMessageTypes.scala | 1 + .../messageTypes/MemberUnassignedToTask.scala | 19 +++ backend/app/repositories/TaskRepository.scala | 13 +- backend/app/services/TaskService.scala | 35 ++++++ backend/conf/routes | 1 + .../test/controllers/TaskControllerSpec.scala | 42 +++++++ 9 files changed, 170 insertions(+), 57 deletions(-) create mode 100644 backend/app/dto/websocket/board/messageTypes/MemberUnassignedToTask.scala diff --git a/backend/app/controllers/TaskController.scala b/backend/app/controllers/TaskController.scala index dce21bc..b2b7b59 100644 --- a/backend/app/controllers/TaskController.scala +++ b/backend/app/controllers/TaskController.scala @@ -1,43 +1,49 @@ package controllers -import dto.request.task.{AssignMemberRequest, CreateTaskRequest, UpdateTaskRequest} +import dto.request.task.{ + AssignMemberRequest, + CreateTaskRequest, + UpdateTaskRequest +} import dto.response.ApiResponse import play.api.i18n.I18nSupport.RequestWithMessagesApi import play.api.i18n.Messages import play.api.libs.json.{JsValue, Json} -import play.api.mvc.{Action, AnyContent, MessagesAbstractController, MessagesControllerComponents} +import play.api.mvc.{ + Action, + AnyContent, + MessagesAbstractController, + MessagesControllerComponents +} import services.TaskService -import validations.ValidationHandler import utils.WritesExtras.unitWrites +import validations.ValidationHandler import javax.inject.Inject import scala.concurrent.ExecutionContext class TaskController @Inject()( - cc: MessagesControllerComponents, - taskService: TaskService, - authenticatedActionWithUser: AuthenticatedActionWithUser - )(implicit ec: ExecutionContext) - extends MessagesAbstractController(cc) + cc: MessagesControllerComponents, + taskService: TaskService, + authenticatedActionWithUser: AuthenticatedActionWithUser +)(implicit ec: ExecutionContext) + extends MessagesAbstractController(cc) with ValidationHandler { def create(columnId: Int): Action[JsValue] = authenticatedActionWithUser.async(parse.json) { request => implicit val messages: Messages = request.messages val createdBy = request.userToken.userId - handleJsonValidation[CreateTaskRequest](request.body) { - createColumnDto => - taskService - .createNewTask(createColumnDto, columnId, createdBy) - .map { taskId => - Created( - Json.toJson( - ApiResponse[Unit]( - s"Task created successfully with ID: $taskId" - ) - ) + handleJsonValidation[CreateTaskRequest](request.body) { createColumnDto => + taskService + .createNewTask(createColumnDto, columnId, createdBy) + .map { taskId => + Created( + Json.toJson( + ApiResponse[Unit](s"Task created successfully with ID: $taskId") ) - } + ) + } } } @@ -45,19 +51,12 @@ class TaskController @Inject()( authenticatedActionWithUser.async(parse.json) { request => implicit val messages: Messages = request.messages val updatedBy = request.userToken.userId - handleJsonValidation[UpdateTaskRequest](request.body) { - updateTaskDto => - taskService - .updateTask(taskId, updateTaskDto, updatedBy) - .map { _ => - Ok( - Json.toJson( - ApiResponse[Unit]( - s"Task updated successfully" - ) - ) - ) - } + handleJsonValidation[UpdateTaskRequest](request.body) { updateTaskDto => + taskService + .updateTask(taskId, updateTaskDto, updatedBy) + .map { _ => + Ok(Json.toJson(ApiResponse[Unit](s"Task updated successfully"))) + } } } @@ -81,13 +80,7 @@ class TaskController @Inject()( taskService .archiveTask(taskId, archivedBy) .map { _ => - Ok( - Json.toJson( - ApiResponse[Unit]( - s"Task archived successfully" - ) - ) - ) + Ok(Json.toJson(ApiResponse[Unit](s"Task archived successfully"))) } } @@ -97,13 +90,7 @@ class TaskController @Inject()( taskService .restoreTask(taskId, restoredBy) .map { _ => - Ok( - Json.toJson( - ApiResponse[Unit]( - s"Task restored successfully" - ) - ) - ) + Ok(Json.toJson(ApiResponse[Unit](s"Task restored successfully"))) } } @@ -117,7 +104,7 @@ class TaskController @Inject()( } } - def assignMember(projectId:Int, taskId: Int): Action[JsValue] = { + def assignMember(projectId: Int, taskId: Int): Action[JsValue] = { authenticatedActionWithUser.async(parse.json) { request => val assignedBy = request.userToken.userId handleJsonValidation[AssignMemberRequest](request.body) { @@ -136,6 +123,22 @@ class TaskController @Inject()( } } + def unassignMember(projectId: Int, + taskId: Int, + userId: Int): Action[AnyContent] = + authenticatedActionWithUser.async { request => + val unassignedBy = request.userToken.userId + taskService + .unassignMemberFromTask(projectId, taskId, userId, unassignedBy) + .map { _ => + Ok( + Json.toJson( + ApiResponse[Unit](s"Member unassigned from task successfully") + ) + ) + } + } + def getArchivedTasks(projectId: Int): Action[AnyContent] = authenticatedActionWithUser.async { request => implicit val messages: Messages = request.messages @@ -145,7 +148,8 @@ class TaskController @Inject()( .map { tasks => Ok( Json.toJson( - ApiResponse.success("Archived tasks retrieved successfully", tasks) + ApiResponse + .success("Archived tasks retrieved successfully", tasks) ) ) } @@ -166,7 +170,10 @@ class TaskController @Inject()( } } - def search(page: Int, size: Int, keyword: String, projectIds: Option[List[Int]]): Action[AnyContent] = + def search(page: Int, + size: Int, + keyword: String, + projectIds: Option[List[Int]]): Action[AnyContent] = authenticatedActionWithUser.async { request => val userId = request.userToken.userId taskService @@ -178,7 +185,11 @@ class TaskController @Inject()( userId ) .map { tasks => - Ok(Json.toJson(ApiResponse.success("Tasks retrieved successfully", tasks))) + Ok( + Json.toJson( + ApiResponse.success("Tasks retrieved successfully", tasks) + ) + ) } } diff --git a/backend/app/dto/response/task/AssignMemberToTaskResponse.scala b/backend/app/dto/response/task/AssignMemberToTaskResponse.scala index 3884a02..1cbcc66 100644 --- a/backend/app/dto/response/task/AssignMemberToTaskResponse.scala +++ b/backend/app/dto/response/task/AssignMemberToTaskResponse.scala @@ -4,7 +4,7 @@ import play.api.libs.json.{Json, OFormat} case class AssignMemberToTaskResponse(userId: Int, username: String, - columnId: Int) + taskId: Int) object AssignMemberToTaskResponse { implicit val format: OFormat[AssignMemberToTaskResponse] = Json.format[AssignMemberToTaskResponse] diff --git a/backend/app/dto/websocket/board/BoardMessage.scala b/backend/app/dto/websocket/board/BoardMessage.scala index a77aac8..c0aa9a8 100644 --- a/backend/app/dto/websocket/board/BoardMessage.scala +++ b/backend/app/dto/websocket/board/BoardMessage.scala @@ -18,6 +18,7 @@ object BoardMessage { case TaskMoved(p) => Json.toJson(p) case TaskCreated(p) => Json.toJson(p) case MemberAssignedToTask(p) => Json.toJson(p) + case MemberUnassignedFromTask(p) => Json.toJson(p) case TaskUpdated(p) => Json.toJson(p) case TaskStatusUpdated(p) => Json.toJson(p) case ColumnCreated(p) => Json.toJson(p) diff --git a/backend/app/dto/websocket/board/BoardMessageTypes.scala b/backend/app/dto/websocket/board/BoardMessageTypes.scala index 2ad1e45..4757079 100644 --- a/backend/app/dto/websocket/board/BoardMessageTypes.scala +++ b/backend/app/dto/websocket/board/BoardMessageTypes.scala @@ -5,6 +5,7 @@ object BoardMessageTypes { final val TASK_MOVED: String = "TASK_MOVED" final val TASK_CREATED: String = "TASK_CREATED" final val MEMBER_ASSIGNED_TO_TASK: String = "MEMBER_ASSIGNED_TO_TASK" + final val MEMBER_UNASSIGNED_TO_TASK: String = "MEMBER_UNASSIGNED_TO_TASK" final val TASK_UPDATED: String = "TASK_UPDATED" final val TASK_STATUS_UPDATED: String = "TASK_STATUS_UPDATED" final val COLUMN_CREATED: String = "COLUMN_CREATED" diff --git a/backend/app/dto/websocket/board/messageTypes/MemberUnassignedToTask.scala b/backend/app/dto/websocket/board/messageTypes/MemberUnassignedToTask.scala new file mode 100644 index 0000000..3aa136a --- /dev/null +++ b/backend/app/dto/websocket/board/messageTypes/MemberUnassignedToTask.scala @@ -0,0 +1,19 @@ +package dto.websocket.board.messageTypes + +import dto.websocket.board.{BoardMessage, BoardMessageTypes} +import play.api.libs.json.{Json, OFormat} + +case class MemberUnassignedFromTask(payload: MemberUnassignedFromTaskPayload) + extends BoardMessage { + override val messageType: String = BoardMessageTypes.MEMBER_UNASSIGNED_TO_TASK +} + +case class MemberUnassignedFromTaskPayload( + taskId: Int, + userId: Int +) +object MemberUnassignedFromTaskPayload { + implicit val memberAssignedToTaskPayloadFormat + : OFormat[MemberUnassignedFromTaskPayload] = + Json.format[MemberUnassignedFromTaskPayload] +} diff --git a/backend/app/repositories/TaskRepository.scala b/backend/app/repositories/TaskRepository.scala index 127771d..b410349 100644 --- a/backend/app/repositories/TaskRepository.scala +++ b/backend/app/repositories/TaskRepository.scala @@ -2,8 +2,6 @@ package repositories import db.MyPostgresProfile.api.{columnStatusTypeMapper, projectStatusTypeMapper, taskStatusTypeMapper} import dto.response.task.{AssignMemberToTaskResponse, AssignedMemberResponse, TaskSummaryResponse} -import dto.response.task.AssignMemberToTaskResponse -import dto.response.task.TaskSummaryResponse import models.Enums.TaskStatus.TaskStatus import models.Enums.{ColumnStatus, ProjectStatus, TaskStatus} import models.entities.{Task, UserTask} @@ -68,6 +66,11 @@ class TaskRepository@Inject()( ) userTasks += userTask } + + def unassignMemberFromTask(taskId: Int, userId: Int): DBIO[Int] = { + userTasks.filter(ut => ut.taskId === taskId && ut.assignedTo === userId).delete + } + def findArchivedTasksByProjectId(projectId: Int): DBIO[Seq[TaskSummaryResponse]] = { val query = for { ((t, c), p) <- tasks @@ -93,10 +96,10 @@ class TaskRepository@Inject()( up <- userProjects if up.userId === userId && up.projectId === c.projectId u <- users if u.id === userId if !(userTasks.filter(ut => ut.taskId === taskId && ut.assignedTo === userId)).exists - } yield (u.id, u.name, c.id) + } yield (u.id, u.name, t.id) - query.result.headOption.map(_.map { case (uid, uname, colId) => - AssignMemberToTaskResponse(uid, uname, colId) + query.result.headOption.map(_.map { case (uid, uname, taskId) => + AssignMemberToTaskResponse(uid, uname, taskId) }) } diff --git a/backend/app/services/TaskService.scala b/backend/app/services/TaskService.scala index eaffbdc..0abbb5a 100644 --- a/backend/app/services/TaskService.scala +++ b/backend/app/services/TaskService.scala @@ -243,6 +243,41 @@ class TaskService @Inject()(taskRepository: TaskRepository, db.run(action) } + def unassignMemberFromTask(projectId:Int, taskId: Int, userId: Int, unassignedBy: Int): Future[Int] = { + val action = for { + isUserInProject <- projectRepository.isUserInActiveProject( + unassignedBy, + projectId + ) + + _ <- if (isUserInProject) { + DBIO.successful(()) + } else { + DBIO.failed(AppException( + message = s"You do not have permission to unassign members from this task", + statusCode = Status.FORBIDDEN)) + } + + deletionCheck <- taskRepository.unassignMemberFromTask(taskId, userId) + _ <- deletionCheck match { + case 1 => { + val unassignedMemberFromTaskMessage = MemberUnassignedFromTask(MemberUnassignedFromTaskPayload( + taskId, + userId) + ) + broadcastService.broadcastToProject(projectId, unassignedMemberFromTaskMessage) + DBIO.successful(()) + } + case 0 => DBIO.failed(AppException( + message = s"User with ID $userId is not in the project or not assigned to the task", + statusCode = Status.BAD_REQUEST) + ) + } + } yield deletionCheck + + db.run(action) + } + def getActiveTasksInProject(projectId: Int, userId: Int): Future[Seq[TaskSummaryResponse]] = { val action = for { isUserInActiveProject <- projectRepository.isUserInActiveProject( diff --git a/backend/conf/routes b/backend/conf/routes index 20e9804..3482bae 100644 --- a/backend/conf/routes +++ b/backend/conf/routes @@ -56,6 +56,7 @@ PATCH /api/tasks/:taskId/archive controllers.TaskController.archive(taskId PATCH /api/tasks/:taskId/restore controllers.TaskController.restore(taskId: Int) DELETE /api/tasks/:taskId controllers.TaskController.delete(taskId: Int) POST /api/projects/:projectId/tasks/:taskId/members controllers.TaskController.assignMember(projectId: Int, taskId: Int) +DELETE /api/projects/:projectId/tasks/:taskId/members/:userId controllers.TaskController.unassignMember(projectId: Int, taskId: Int, userId: Int) GET /api/projects/:projectId/columns/tasks/archived controllers.TaskController.getArchivedTasks(projectId: Int) GET /api/projects/:projectId/columns/tasks/active controllers.TaskController.getActiveTasksInProject(projectId: Int) GET /api/tasks controllers.TaskController.search(page: Int ?= 2, size: Int ?= 10, keyword: String ?= "", projectIds: Option[List[Int]]) diff --git a/backend/test/controllers/TaskControllerSpec.scala b/backend/test/controllers/TaskControllerSpec.scala index 1553761..609031d 100644 --- a/backend/test/controllers/TaskControllerSpec.scala +++ b/backend/test/controllers/TaskControllerSpec.scala @@ -131,6 +131,48 @@ class TaskControllerSpec ex.message must include("Task with ID 0 does not exist") } + "unassign member from a task successfully" in { + val request = FakeRequest(DELETE, "/api/projects/1/tasks/1/members/1") + .withCookies(Cookie(cookieName, fakeToken)) + + val result = route(app, request).get + + status(result) mustBe OK + (contentAsJson(result) \ "message").as[String] must include( + "Member unassigned from task successfully" + ) + } + + "fail when unassigning member from a task with user not assign to a task" in { + val request = FakeRequest(DELETE, "/api/projects/1/tasks/1/members/0") + .withCookies(Cookie(cookieName, fakeToken)) + + val result = route(app, request).get + + val ex = intercept[AppException] { + await(result) + } + ex.statusCode mustBe BAD_REQUEST + ex.message must include( + "User with ID 0 is not in the project or not assigned to the task" + ) + } + + "fail when unassigning member from a task with user not in project" in { + val request = FakeRequest(DELETE, "/api/projects/2/tasks/1/members/0") + .withCookies(Cookie(cookieName, fakeToken)) + + val result = route(app, request).get + + val ex = intercept[AppException] { + await(result) + } + ex.statusCode mustBe FORBIDDEN + ex.message must include( + "You do not have permission to unassign members from this task" + ) + } + "fail when creating task with duplicate position in same column" in { val body = Json.toJson(CreateTaskRequest("Task 1", 1)) val request = FakeRequest(POST, "/api/columns/1/tasks")