Skip to content

Commit c8b633e

Browse files
feat/BE: remove member from task (#21)
1 parent bb69a3f commit c8b633e

File tree

9 files changed

+170
-57
lines changed

9 files changed

+170
-57
lines changed

backend/app/controllers/TaskController.scala

Lines changed: 62 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,62 @@
11
package controllers
22

3-
import dto.request.task.{AssignMemberRequest, CreateTaskRequest, UpdateTaskRequest}
3+
import dto.request.task.{
4+
AssignMemberRequest,
5+
CreateTaskRequest,
6+
UpdateTaskRequest
7+
}
48
import dto.response.ApiResponse
59
import play.api.i18n.I18nSupport.RequestWithMessagesApi
610
import play.api.i18n.Messages
711
import play.api.libs.json.{JsValue, Json}
8-
import play.api.mvc.{Action, AnyContent, MessagesAbstractController, MessagesControllerComponents}
12+
import play.api.mvc.{
13+
Action,
14+
AnyContent,
15+
MessagesAbstractController,
16+
MessagesControllerComponents
17+
}
918
import services.TaskService
10-
import validations.ValidationHandler
1119
import utils.WritesExtras.unitWrites
20+
import validations.ValidationHandler
1221

1322
import javax.inject.Inject
1423
import scala.concurrent.ExecutionContext
1524

1625
class TaskController @Inject()(
17-
cc: MessagesControllerComponents,
18-
taskService: TaskService,
19-
authenticatedActionWithUser: AuthenticatedActionWithUser
20-
)(implicit ec: ExecutionContext)
21-
extends MessagesAbstractController(cc)
26+
cc: MessagesControllerComponents,
27+
taskService: TaskService,
28+
authenticatedActionWithUser: AuthenticatedActionWithUser
29+
)(implicit ec: ExecutionContext)
30+
extends MessagesAbstractController(cc)
2231
with ValidationHandler {
2332

2433
def create(columnId: Int): Action[JsValue] =
2534
authenticatedActionWithUser.async(parse.json) { request =>
2635
implicit val messages: Messages = request.messages
2736
val createdBy = request.userToken.userId
28-
handleJsonValidation[CreateTaskRequest](request.body) {
29-
createColumnDto =>
30-
taskService
31-
.createNewTask(createColumnDto, columnId, createdBy)
32-
.map { taskId =>
33-
Created(
34-
Json.toJson(
35-
ApiResponse[Unit](
36-
s"Task created successfully with ID: $taskId"
37-
)
38-
)
37+
handleJsonValidation[CreateTaskRequest](request.body) { createColumnDto =>
38+
taskService
39+
.createNewTask(createColumnDto, columnId, createdBy)
40+
.map { taskId =>
41+
Created(
42+
Json.toJson(
43+
ApiResponse[Unit](s"Task created successfully with ID: $taskId")
3944
)
40-
}
45+
)
46+
}
4147
}
4248
}
4349

4450
def update(taskId: Int): Action[JsValue] =
4551
authenticatedActionWithUser.async(parse.json) { request =>
4652
implicit val messages: Messages = request.messages
4753
val updatedBy = request.userToken.userId
48-
handleJsonValidation[UpdateTaskRequest](request.body) {
49-
updateTaskDto =>
50-
taskService
51-
.updateTask(taskId, updateTaskDto, updatedBy)
52-
.map { _ =>
53-
Ok(
54-
Json.toJson(
55-
ApiResponse[Unit](
56-
s"Task updated successfully"
57-
)
58-
)
59-
)
60-
}
54+
handleJsonValidation[UpdateTaskRequest](request.body) { updateTaskDto =>
55+
taskService
56+
.updateTask(taskId, updateTaskDto, updatedBy)
57+
.map { _ =>
58+
Ok(Json.toJson(ApiResponse[Unit](s"Task updated successfully")))
59+
}
6160
}
6261
}
6362

@@ -81,13 +80,7 @@ class TaskController @Inject()(
8180
taskService
8281
.archiveTask(taskId, archivedBy)
8382
.map { _ =>
84-
Ok(
85-
Json.toJson(
86-
ApiResponse[Unit](
87-
s"Task archived successfully"
88-
)
89-
)
90-
)
83+
Ok(Json.toJson(ApiResponse[Unit](s"Task archived successfully")))
9184
}
9285
}
9386

@@ -97,13 +90,7 @@ class TaskController @Inject()(
9790
taskService
9891
.restoreTask(taskId, restoredBy)
9992
.map { _ =>
100-
Ok(
101-
Json.toJson(
102-
ApiResponse[Unit](
103-
s"Task restored successfully"
104-
)
105-
)
106-
)
93+
Ok(Json.toJson(ApiResponse[Unit](s"Task restored successfully")))
10794
}
10895
}
10996

@@ -117,7 +104,7 @@ class TaskController @Inject()(
117104
}
118105
}
119106

120-
def assignMember(projectId:Int, taskId: Int): Action[JsValue] = {
107+
def assignMember(projectId: Int, taskId: Int): Action[JsValue] = {
121108
authenticatedActionWithUser.async(parse.json) { request =>
122109
val assignedBy = request.userToken.userId
123110
handleJsonValidation[AssignMemberRequest](request.body) {
@@ -136,6 +123,22 @@ class TaskController @Inject()(
136123
}
137124
}
138125

126+
def unassignMember(projectId: Int,
127+
taskId: Int,
128+
userId: Int): Action[AnyContent] =
129+
authenticatedActionWithUser.async { request =>
130+
val unassignedBy = request.userToken.userId
131+
taskService
132+
.unassignMemberFromTask(projectId, taskId, userId, unassignedBy)
133+
.map { _ =>
134+
Ok(
135+
Json.toJson(
136+
ApiResponse[Unit](s"Member unassigned from task successfully")
137+
)
138+
)
139+
}
140+
}
141+
139142
def getArchivedTasks(projectId: Int): Action[AnyContent] =
140143
authenticatedActionWithUser.async { request =>
141144
implicit val messages: Messages = request.messages
@@ -145,7 +148,8 @@ class TaskController @Inject()(
145148
.map { tasks =>
146149
Ok(
147150
Json.toJson(
148-
ApiResponse.success("Archived tasks retrieved successfully", tasks)
151+
ApiResponse
152+
.success("Archived tasks retrieved successfully", tasks)
149153
)
150154
)
151155
}
@@ -166,7 +170,10 @@ class TaskController @Inject()(
166170
}
167171
}
168172

169-
def search(page: Int, size: Int, keyword: String, projectIds: Option[List[Int]]): Action[AnyContent] =
173+
def search(page: Int,
174+
size: Int,
175+
keyword: String,
176+
projectIds: Option[List[Int]]): Action[AnyContent] =
170177
authenticatedActionWithUser.async { request =>
171178
val userId = request.userToken.userId
172179
taskService
@@ -178,7 +185,11 @@ class TaskController @Inject()(
178185
userId
179186
)
180187
.map { tasks =>
181-
Ok(Json.toJson(ApiResponse.success("Tasks retrieved successfully", tasks)))
188+
Ok(
189+
Json.toJson(
190+
ApiResponse.success("Tasks retrieved successfully", tasks)
191+
)
192+
)
182193
}
183194
}
184195

backend/app/dto/response/task/AssignMemberToTaskResponse.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import play.api.libs.json.{Json, OFormat}
44

55
case class AssignMemberToTaskResponse(userId: Int,
66
username: String,
7-
columnId: Int)
7+
taskId: Int)
88

99
object AssignMemberToTaskResponse {
1010
implicit val format: OFormat[AssignMemberToTaskResponse] = Json.format[AssignMemberToTaskResponse]

backend/app/dto/websocket/board/BoardMessage.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ object BoardMessage {
1818
case TaskMoved(p) => Json.toJson(p)
1919
case TaskCreated(p) => Json.toJson(p)
2020
case MemberAssignedToTask(p) => Json.toJson(p)
21+
case MemberUnassignedFromTask(p) => Json.toJson(p)
2122
case TaskUpdated(p) => Json.toJson(p)
2223
case TaskStatusUpdated(p) => Json.toJson(p)
2324
case ColumnCreated(p) => Json.toJson(p)

backend/app/dto/websocket/board/BoardMessageTypes.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ object BoardMessageTypes {
55
final val TASK_MOVED: String = "TASK_MOVED"
66
final val TASK_CREATED: String = "TASK_CREATED"
77
final val MEMBER_ASSIGNED_TO_TASK: String = "MEMBER_ASSIGNED_TO_TASK"
8+
final val MEMBER_UNASSIGNED_TO_TASK: String = "MEMBER_UNASSIGNED_TO_TASK"
89
final val TASK_UPDATED: String = "TASK_UPDATED"
910
final val TASK_STATUS_UPDATED: String = "TASK_STATUS_UPDATED"
1011
final val COLUMN_CREATED: String = "COLUMN_CREATED"
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package dto.websocket.board.messageTypes
2+
3+
import dto.websocket.board.{BoardMessage, BoardMessageTypes}
4+
import play.api.libs.json.{Json, OFormat}
5+
6+
case class MemberUnassignedFromTask(payload: MemberUnassignedFromTaskPayload)
7+
extends BoardMessage {
8+
override val messageType: String = BoardMessageTypes.MEMBER_UNASSIGNED_TO_TASK
9+
}
10+
11+
case class MemberUnassignedFromTaskPayload(
12+
taskId: Int,
13+
userId: Int
14+
)
15+
object MemberUnassignedFromTaskPayload {
16+
implicit val memberAssignedToTaskPayloadFormat
17+
: OFormat[MemberUnassignedFromTaskPayload] =
18+
Json.format[MemberUnassignedFromTaskPayload]
19+
}

backend/app/repositories/TaskRepository.scala

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package repositories
22

33
import db.MyPostgresProfile.api.{columnStatusTypeMapper, projectStatusTypeMapper, taskStatusTypeMapper}
44
import dto.response.task.{AssignMemberToTaskResponse, AssignedMemberResponse, TaskSummaryResponse}
5-
import dto.response.task.AssignMemberToTaskResponse
6-
import dto.response.task.TaskSummaryResponse
75
import models.Enums.TaskStatus.TaskStatus
86
import models.Enums.{ColumnStatus, ProjectStatus, TaskStatus}
97
import models.entities.{Task, UserTask}
@@ -68,6 +66,11 @@ class TaskRepository@Inject()(
6866
)
6967
userTasks += userTask
7068
}
69+
70+
def unassignMemberFromTask(taskId: Int, userId: Int): DBIO[Int] = {
71+
userTasks.filter(ut => ut.taskId === taskId && ut.assignedTo === userId).delete
72+
}
73+
7174
def findArchivedTasksByProjectId(projectId: Int): DBIO[Seq[TaskSummaryResponse]] = {
7275
val query = for {
7376
((t, c), p) <- tasks
@@ -93,10 +96,10 @@ class TaskRepository@Inject()(
9396
up <- userProjects if up.userId === userId && up.projectId === c.projectId
9497
u <- users if u.id === userId
9598
if !(userTasks.filter(ut => ut.taskId === taskId && ut.assignedTo === userId)).exists
96-
} yield (u.id, u.name, c.id)
99+
} yield (u.id, u.name, t.id)
97100

98-
query.result.headOption.map(_.map { case (uid, uname, colId) =>
99-
AssignMemberToTaskResponse(uid, uname, colId)
101+
query.result.headOption.map(_.map { case (uid, uname, taskId) =>
102+
AssignMemberToTaskResponse(uid, uname, taskId)
100103
})
101104
}
102105

backend/app/services/TaskService.scala

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,41 @@ class TaskService @Inject()(taskRepository: TaskRepository,
243243
db.run(action)
244244
}
245245

246+
def unassignMemberFromTask(projectId:Int, taskId: Int, userId: Int, unassignedBy: Int): Future[Int] = {
247+
val action = for {
248+
isUserInProject <- projectRepository.isUserInActiveProject(
249+
unassignedBy,
250+
projectId
251+
)
252+
253+
_ <- if (isUserInProject) {
254+
DBIO.successful(())
255+
} else {
256+
DBIO.failed(AppException(
257+
message = s"You do not have permission to unassign members from this task",
258+
statusCode = Status.FORBIDDEN))
259+
}
260+
261+
deletionCheck <- taskRepository.unassignMemberFromTask(taskId, userId)
262+
_ <- deletionCheck match {
263+
case 1 => {
264+
val unassignedMemberFromTaskMessage = MemberUnassignedFromTask(MemberUnassignedFromTaskPayload(
265+
taskId,
266+
userId)
267+
)
268+
broadcastService.broadcastToProject(projectId, unassignedMemberFromTaskMessage)
269+
DBIO.successful(())
270+
}
271+
case 0 => DBIO.failed(AppException(
272+
message = s"User with ID $userId is not in the project or not assigned to the task",
273+
statusCode = Status.BAD_REQUEST)
274+
)
275+
}
276+
} yield deletionCheck
277+
278+
db.run(action)
279+
}
280+
246281
def getActiveTasksInProject(projectId: Int, userId: Int): Future[Seq[TaskSummaryResponse]] = {
247282
val action = for {
248283
isUserInActiveProject <- projectRepository.isUserInActiveProject(

backend/conf/routes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ PATCH /api/tasks/:taskId/archive controllers.TaskController.archive(taskId
5656
PATCH /api/tasks/:taskId/restore controllers.TaskController.restore(taskId: Int)
5757
DELETE /api/tasks/:taskId controllers.TaskController.delete(taskId: Int)
5858
POST /api/projects/:projectId/tasks/:taskId/members controllers.TaskController.assignMember(projectId: Int, taskId: Int)
59+
DELETE /api/projects/:projectId/tasks/:taskId/members/:userId controllers.TaskController.unassignMember(projectId: Int, taskId: Int, userId: Int)
5960
GET /api/projects/:projectId/columns/tasks/archived controllers.TaskController.getArchivedTasks(projectId: Int)
6061
GET /api/projects/:projectId/columns/tasks/active controllers.TaskController.getActiveTasksInProject(projectId: Int)
6162
GET /api/tasks controllers.TaskController.search(page: Int ?= 2, size: Int ?= 10, keyword: String ?= "", projectIds: Option[List[Int]])

backend/test/controllers/TaskControllerSpec.scala

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,48 @@ class TaskControllerSpec
131131
ex.message must include("Task with ID 0 does not exist")
132132
}
133133

134+
"unassign member from a task successfully" in {
135+
val request = FakeRequest(DELETE, "/api/projects/1/tasks/1/members/1")
136+
.withCookies(Cookie(cookieName, fakeToken))
137+
138+
val result = route(app, request).get
139+
140+
status(result) mustBe OK
141+
(contentAsJson(result) \ "message").as[String] must include(
142+
"Member unassigned from task successfully"
143+
)
144+
}
145+
146+
"fail when unassigning member from a task with user not assign to a task" in {
147+
val request = FakeRequest(DELETE, "/api/projects/1/tasks/1/members/0")
148+
.withCookies(Cookie(cookieName, fakeToken))
149+
150+
val result = route(app, request).get
151+
152+
val ex = intercept[AppException] {
153+
await(result)
154+
}
155+
ex.statusCode mustBe BAD_REQUEST
156+
ex.message must include(
157+
"User with ID 0 is not in the project or not assigned to the task"
158+
)
159+
}
160+
161+
"fail when unassigning member from a task with user not in project" in {
162+
val request = FakeRequest(DELETE, "/api/projects/2/tasks/1/members/0")
163+
.withCookies(Cookie(cookieName, fakeToken))
164+
165+
val result = route(app, request).get
166+
167+
val ex = intercept[AppException] {
168+
await(result)
169+
}
170+
ex.statusCode mustBe FORBIDDEN
171+
ex.message must include(
172+
"You do not have permission to unassign members from this task"
173+
)
174+
}
175+
134176
"fail when creating task with duplicate position in same column" in {
135177
val body = Json.toJson(CreateTaskRequest("Task 1", 1))
136178
val request = FakeRequest(POST, "/api/columns/1/tasks")

0 commit comments

Comments
 (0)